diff --git a/.gitignore b/.gitignore index 6b1868c..2b7364c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,11 @@ Thumbs.db .idea/ # ── Migrations ───────────────────────────────────────────────────────── -# New EF Core migrations are not committed. Note the 4 migrations already in -# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does -# not apply to tracked files — so edits to those still get committed as normal. -# Untracking them too takes `git rm --cached`. -**/Migrations/ +# Reverted 2026-07-31: excluding new EF Core migrations while +# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef +# migrations add` after the initial 4 silently produced a migration git would +# never see, while the (tracked) snapshot's changes committed normally — +# so the snapshot kept claiming tables existed that no migration in git +# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing +# tables were missing from the actual database for exactly this reason. +# Migrations now stay tracked like any other source file — commit them. diff --git a/Backend/ERPCore/Controllers/BundleSalesController.cs b/Backend/ERPCore/Controllers/BundleSalesController.cs new file mode 100644 index 0000000..6b45382 --- /dev/null +++ b/Backend/ERPCore/Controllers/BundleSalesController.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/bundle-sales")] +public sealed class BundleSalesController : ApiControllerBase +{ + private readonly IBundleSaleService _bundles; + + public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles; + + [HttpGet("templates")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> ListTemplates( + [FromQuery] PageQuery query, + CancellationToken ct) + => Ok(await _bundles.ListTemplatesAsync(query, ct)); + + [HttpGet("templates/{bundleSaleTemplateId:int}")] + [ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTemplate(int bundleSaleTemplateId, CancellationToken ct) + { + var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct)); + + [HttpGet("{bundleSaleId:int}")] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int bundleSaleId, CancellationToken ct) + { + var result = await _bundles.GetAsync(bundleSaleId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{bundleSaleId:int}/posting-check")] + [ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)] + public async Task> PostingCheck(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct) + { + var result = await _bundles.CreateAsync(request, ct); + return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result); + } + + [HttpPut("{bundleSaleId:int}")] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] + public async Task> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct) + { + return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct)); + } + + [HttpPost("{bundleSaleId:int}/post")] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] + public async Task> Post(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.PostAsync(bundleSaleId, ct)); + + [HttpPost("{bundleSaleId:int}/cancel")] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] + public async Task> Cancel(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.CancelAsync(bundleSaleId, ct)); +} diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs new file mode 100644 index 0000000..ff7cda4 --- /dev/null +++ b/Backend/ERPCore/Controllers/CustomersController.cs @@ -0,0 +1,69 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Customers; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Customer master endpoints for Phase 1 sales. +[Route("api/v1/customers")] +public sealed class CustomersController : ApiControllerBase +{ + private readonly ICustomerService _customers; + + public CustomersController(ICustomerService customers) => _customers = customers; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] EntityStatus? status, + [FromQuery] CustomerType? customerType, + CancellationToken ct) + => Ok(await _customers.ListAsync(query, status, customerType, ct)); + + [HttpGet("{customerId:int}")] + [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int customerId, CancellationToken ct) + { + var result = await _customers.GetAsync(customerId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateCustomerRequest request, CancellationToken ct) + { + var result = await _customers.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value); + } + + [HttpPut("{customerId:int}")] + [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _customers.UpdateAsync(customerId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{customerId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct) + { + await _customers.SetStatusAsync(customerId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/FreeIssuesController.cs b/Backend/ERPCore/Controllers/FreeIssuesController.cs new file mode 100644 index 0000000..93057e8 --- /dev/null +++ b/Backend/ERPCore/Controllers/FreeIssuesController.cs @@ -0,0 +1,78 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Thin API alias for free-issue handling. Free issues are modeled as sales slips +/// with line-level IsFreeIssue/FreeQty flags, so this controller reuses +/// the existing sales-slip CRUD surface under a more business-friendly route. +/// +[Route("api/v1/free-issues")] +public sealed class FreeIssuesController : ApiControllerBase +{ + private readonly ISalesSlipService _slips; + + public FreeIssuesController(ISalesSlipService slips) => _slips = slips; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] SalesSlipStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct)); + + [HttpGet("{freeIssueId:int}")] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int freeIssueId, CancellationToken ct) + { + var result = await _slips.GetFreeIssueAsync(freeIssueId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpGet("{freeIssueId:int}/posting-check")] + [ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PostingCheck(int freeIssueId, CancellationToken ct) + => Ok(await _slips.CheckPostingAsync(freeIssueId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct) + { + var result = await _slips.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value); + } + + [HttpPut("{freeIssueId:int}")] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost("{freeIssueId:int}/post")] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)] + public async Task> Post(int freeIssueId, CancellationToken ct) + => Ok(await _slips.PostAsync(freeIssueId, ct)); + + [HttpPost("{freeIssueId:int}/cancel")] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)] + public async Task> Cancel(int freeIssueId, CancellationToken ct) + => Ok(await _slips.CancelAsync(freeIssueId, ct)); +} diff --git a/Backend/ERPCore/Controllers/GeneralLedgerController.cs b/Backend/ERPCore/Controllers/GeneralLedgerController.cs new file mode 100644 index 0000000..2f0bf12 --- /dev/null +++ b/Backend/ERPCore/Controllers/GeneralLedgerController.cs @@ -0,0 +1,43 @@ +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Generic reverse proxy into the external General Ledger service — forwards every +/// method/path/query/body under this prefix verbatim via +/// and returns GL's response (status, content-type, +/// body) unchanged. No endpoint-specific shape lives here; see +/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy +/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint +/// () — the shared GL API key is attached server-side +/// only and is never exposed to the frontend. +/// +[Route("api/v1/gl")] +public sealed class GeneralLedgerController : ApiControllerBase +{ + private readonly IGeneralLedgerService _gl; + + public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl; + + [HttpGet("{**path}")] + public Task Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct); + + [HttpPost("{**path}")] + public Task Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct); + + [HttpPut("{**path}")] + public Task Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct); + + private async Task ForwardAsync(HttpMethod method, string path, CancellationToken ct) + { + var body = method == HttpMethod.Get ? null : Request.Body; + var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct); + return new ContentResult + { + StatusCode = result.StatusCode, + Content = result.Body, + ContentType = result.ContentType ?? "application/json" + }; + } +} diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs new file mode 100644 index 0000000..29eab9e --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/sales-invoices")] +public sealed class SalesInvoicesController : ApiControllerBase +{ + private readonly ISalesInvoiceService _invoices; + + public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] SalesInvoiceStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct)); + + [HttpGet("{salesInvoiceId:int}")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int salesInvoiceId, CancellationToken ct) + { + var result = await _invoices.GetAsync(salesInvoiceId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpGet("{salesInvoiceId:int}/posting-check")] + [ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PostingCheck(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct) + { + var result = await _invoices.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value); + } + + [HttpPut("{salesInvoiceId:int}")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost("{salesInvoiceId:int}/post")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + public async Task> Post(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.PostAsync(salesInvoiceId, ct)); + + [HttpPost("{salesInvoiceId:int}/cancel")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + public async Task> Cancel(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.CancelAsync(salesInvoiceId, ct)); +} diff --git a/Backend/ERPCore/Controllers/SalesReportsController.cs b/Backend/ERPCore/Controllers/SalesReportsController.cs new file mode 100644 index 0000000..b6f18c7 --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesReportsController.cs @@ -0,0 +1,35 @@ +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/reports/sales")] +public sealed class SalesReportsController : ApiControllerBase +{ + private readonly ISalesReportService _reports; + + public SalesReportsController(ISalesReportService reports) => _reports = reports; + + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public ActionResult> ListReports() + => Ok(_reports.ListReports()); + + [HttpGet("{reportId}")] + [ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetReport(string reportId) + { + var report = _reports.GetReport(reportId); + return report is null ? NotFound() : Ok(report); + } + + [HttpPost("query")] + [ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)] + public async Task> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct) + { + var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct); + return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows)); + } +} diff --git a/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs new file mode 100644 index 0000000..f5874dc --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs @@ -0,0 +1,22 @@ +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")] +public sealed class SalesSlipPromotionsController : ApiControllerBase +{ + private readonly ISalesPromotionSuggestionService _suggestions; + + public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions; + + [HttpGet] + [ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int salesSlipId, CancellationToken ct) + { + var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct); + return result is null ? NotFound() : Ok(result); + } +} \ No newline at end of file diff --git a/Backend/ERPCore/Controllers/SalesSlipsController.cs b/Backend/ERPCore/Controllers/SalesSlipsController.cs new file mode 100644 index 0000000..a59f93e --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesSlipsController.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/sales-slips")] +public sealed class SalesSlipsController : ApiControllerBase +{ + private readonly ISalesSlipService _slips; + + public SalesSlipsController(ISalesSlipService slips) => _slips = slips; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] SalesSlipStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct)); + + [HttpGet("{salesSlipId:int}")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int salesSlipId, CancellationToken ct) + { + var result = await _slips.GetAsync(salesSlipId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpGet("{salesSlipId:int}/posting-check")] + [ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PostingCheck(int salesSlipId, CancellationToken ct) + => Ok(await _slips.CheckPostingAsync(salesSlipId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct) + { + var result = await _slips.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value); + } + + [HttpPut("{salesSlipId:int}")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost("{salesSlipId:int}/post")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + public async Task> Post(int salesSlipId, CancellationToken ct) + => Ok(await _slips.PostAsync(salesSlipId, ct)); + + [HttpPost("{salesSlipId:int}/cancel")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + public async Task> Cancel(int salesSlipId, CancellationToken ct) + => Ok(await _slips.CancelAsync(salesSlipId, ct)); +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs index 564e516..cbd91da 100644 --- a/Backend/ERPCore/Domain/DocumentTypes.cs +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -17,4 +17,7 @@ public static class DocumentTypes /// Production run (docs/30 FR-MFG-08) — PRD-2026-00001. public const string Production = "PRD"; + public const string SalesInvoice = "SI"; + public const string SalesSlip = "SSL"; + public const string BundleSale = "BND"; } diff --git a/Backend/ERPCore/Domain/Entities/BundleSale.cs b/Backend/ERPCore/Domain/Entities/BundleSale.cs new file mode 100644 index 0000000..3129935 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSale.cs @@ -0,0 +1,33 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class BundleSale +{ + public int BundleSaleId { get; set; } + public string BundleNo { get; set; } = string.Empty; + public DateTime BundleDate { get; set; } + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + public string CustomerSnapshotName { get; set; } = string.Empty; + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + public int CashierUserId { get; set; } + public User? CashierUser { get; set; } + public int BundleSaleTemplateId { get; set; } + public BundleSaleTemplate? BundleSaleTemplate { get; set; } + public string BundleName { get; set; } = string.Empty; + public string BundleCode { get; set; } = string.Empty; + public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft; + public decimal ComponentSubtotal { get; set; } + public decimal BundlePrice { get; set; } + public decimal MarginAmount { get; set; } + public decimal DiscountTotal { get; set; } + public decimal TaxTotal { get; set; } + public decimal GrandTotal { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public int ConcurrencyStamp { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs new file mode 100644 index 0000000..fecf7ef --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs @@ -0,0 +1,24 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class BundleSaleLine +{ + public int BundleSaleLineId { get; set; } + public int BundleSaleId { get; set; } + public BundleSale? BundleSale { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Qty { get; set; } + public int UomId { get; set; } + public Uom? Uom { get; set; } + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + public decimal UnitPrice { get; set; } + public decimal LineTotal { get; set; } + public bool IncludeInBundle { get; set; } = true; + public bool IsComponent { get; set; } = true; + public int? ParentLineId { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs new file mode 100644 index 0000000..0343a23 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs @@ -0,0 +1,17 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class BundleSaleTemplate +{ + public int BundleSaleTemplateId { get; set; } + public string TemplateCode { get; set; } = string.Empty; + public string TemplateName { get; set; } = string.Empty; + public string? Description { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public int ConcurrencyStamp { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs new file mode 100644 index 0000000..e41414b --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs @@ -0,0 +1,20 @@ +namespace ERPCore.Domain.Entities; + +public class BundleSaleTemplateLine +{ + public int BundleSaleTemplateLineId { get; set; } + public int BundleSaleTemplateId { get; set; } + public BundleSaleTemplate? BundleSaleTemplate { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + public int UomId { get; set; } + public Uom? Uom { get; set; } + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal Qty { get; set; } + public decimal UnitPrice { get; set; } + public bool IncludeInBundle { get; set; } = true; + public int SortOrder { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Customer.cs b/Backend/ERPCore/Domain/Entities/Customer.cs new file mode 100644 index 0000000..916a1b9 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Customer.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Customer master for both B2B and B2C sales. +/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse. +/// +public class Customer +{ + public int CustomerId { get; set; } + public string CustomerCode { get; set; } = string.Empty; + public CustomerType CustomerType { get; set; } = CustomerType.B2C; + + public string Name { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + + public string? TaxRegistrationNo { get; set; } + public decimal CreditLimit { get; set; } + public int CreditDays { get; set; } + + public int? DefaultWarehouseId { get; set; } + public Warehouse? DefaultWarehouse { get; set; } + + public EntityStatus Status { get; set; } = EntityStatus.Active; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoice.cs b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs new file mode 100644 index 0000000..15f40de --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesInvoice +{ + public int SalesInvoiceId { get; set; } + public string InvoiceNo { get; set; } = string.Empty; + public DateTime InvoiceDate { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string CustomerSnapshotName { get; set; } = string.Empty; + public string? CustomerSnapshotTaxNo { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft; + + public decimal Subtotal { get; set; } + public decimal DiscountTotal { get; set; } + public decimal TaxTotal { get; set; } + public decimal GrandTotal { get; set; } + public decimal RoundOff { get; set; } + public decimal NetPayable { get; set; } + public decimal PaidAmount { get; set; } + public decimal BalanceAmount { get; set; } + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs new file mode 100644 index 0000000..e7e4beb --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesInvoiceLine +{ + public int SalesInvoiceLineId { get; set; } + + public int SalesInvoiceId { get; set; } + public SalesInvoice? SalesInvoice { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public string Description { get; set; } = string.Empty; + + public decimal Qty { get; set; } + public decimal FreeQty { get; set; } + public int UomId { get; set; } + public Uom? Uom { get; set; } + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal UnitPrice { get; set; } + public decimal BaseCost { get; set; } + public string PriceSource { get; set; } = string.Empty; + public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage; + public decimal DiscountPct { get; set; } + public decimal DiscountAmount { get; set; } + public decimal NetUnitPrice { get; set; } + public decimal LineTotal { get; set; } + public decimal TaxPct { get; set; } + public decimal TaxAmount { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesSlip.cs b/Backend/ERPCore/Domain/Entities/SalesSlip.cs new file mode 100644 index 0000000..46f697a --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesSlip.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesSlip +{ + public int SalesSlipId { get; set; } + public string SlipNo { get; set; } = string.Empty; + public DateTime SlipDate { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string CustomerSnapshotName { get; set; } = string.Empty; + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int CashierUserId { get; set; } + public User? CashierUser { get; set; } + + public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft; + + public decimal Subtotal { get; set; } + public decimal DiscountTotal { get; set; } + public decimal TaxTotal { get; set; } + public decimal GrandTotal { get; set; } + public decimal PaidAmount { get; set; } + public decimal BalanceAmount { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs new file mode 100644 index 0000000..0eb3de2 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesSlipLine +{ + public int SalesSlipLineId { get; set; } + + public int SalesSlipId { get; set; } + public SalesSlip? SalesSlip { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public string Description { get; set; } = string.Empty; + + public decimal Qty { get; set; } + public decimal FreeQty { get; set; } + public int UomId { get; set; } + public Uom? Uom { get; set; } + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal UnitPrice { get; set; } + public decimal BaseCost { get; set; } + public string PriceSource { get; set; } = string.Empty; + public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage; + public decimal DiscountPct { get; set; } + public decimal DiscountAmount { get; set; } + public decimal NetUnitPrice { get; set; } + public decimal LineTotal { get; set; } + public decimal TaxPct { get; set; } + public decimal TaxAmount { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs b/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs new file mode 100644 index 0000000..a17d0eb --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum BundleSaleStatus +{ + Draft = 0, + Posted = 1, + Cancelled = 2 +} diff --git a/Backend/ERPCore/Domain/Enums/CustomerType.cs b/Backend/ERPCore/Domain/Enums/CustomerType.cs new file mode 100644 index 0000000..5928ba5 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CustomerType.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum CustomerType +{ + B2B = 1, + B2C = 2, + WalkIn = 3 +} diff --git a/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs new file mode 100644 index 0000000..6f26fb2 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs @@ -0,0 +1,7 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesDiscountMode +{ + Percentage = 1, + Amount = 2 +} diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs new file mode 100644 index 0000000..b3c4aab --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesInvoiceStatus +{ + Draft = 1, + Posted = 2, + Cancelled = 3 +} diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs new file mode 100644 index 0000000..375c497 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesInvoiceType +{ + B2B = 1, + B2C = 2, + Cash = 3, + Credit = 4 +} diff --git a/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs new file mode 100644 index 0000000..400d11f --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesSlipStatus +{ + Draft = 1, + Posted = 2, + Cancelled = 3 +} diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs new file mode 100644 index 0000000..3b7175d --- /dev/null +++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Customers; + +/// Customer resource used by sales documents. +public sealed record CustomerDto( + int CustomerId, + string CustomerCode, + CustomerType CustomerType, + string Name, + string? DisplayName, + string? Phone, + string? Email, + string? AddressLine1, + string? AddressLine2, + string? City, + string? Country, + string? TaxRegistrationNo, + decimal CreditLimit, + int CreditDays, + int? DefaultWarehouseId, + EntityStatus Status, + DateTime CreatedAt, + DateTime? UpdatedAt); + +public sealed class CreateCustomerRequest +{ + [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty; + [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [StringLength(200)] public string? DisplayName { get; set; } + [StringLength(30)] public string? Phone { get; set; } + [StringLength(100)] public string? Email { get; set; } + [StringLength(250)] public string? AddressLine1 { get; set; } + [StringLength(250)] public string? AddressLine2 { get; set; } + [StringLength(100)] public string? City { get; set; } + [StringLength(100)] public string? Country { get; set; } + [StringLength(50)] public string? TaxRegistrationNo { get; set; } + [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; } + [Range(0, int.MaxValue)] public int CreditDays { get; set; } + public int? DefaultWarehouseId { get; set; } +} + +public sealed class UpdateCustomerRequest +{ + [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty; + [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [StringLength(200)] public string? DisplayName { get; set; } + [StringLength(30)] public string? Phone { get; set; } + [StringLength(100)] public string? Email { get; set; } + [StringLength(250)] public string? AddressLine1 { get; set; } + [StringLength(250)] public string? AddressLine2 { get; set; } + [StringLength(100)] public string? City { get; set; } + [StringLength(100)] public string? Country { get; set; } + [StringLength(50)] public string? TaxRegistrationNo { get; set; } + [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; } + [Range(0, int.MaxValue)] public int CreditDays { get; set; } + public int? DefaultWarehouseId { get; set; } +} + +public sealed class UpdateCustomerStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs new file mode 100644 index 0000000..7d14af7 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Sales; + +public sealed record BundleSaleLineDto( + int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId, + decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId); + +public sealed record BundleSaleDto( + int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName, + int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode, + BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount, + decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt, + IReadOnlyList Lines); + +public sealed record BundleSaleSummaryDto( + int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName, + int WarehouseId, string BundleName, string BundleCode, BundleSaleStatus Status, + decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt); + +public sealed record BundleSaleTemplateLineDto( + int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty, + decimal UnitPrice, bool IncludeInBundle, int SortOrder); + +public sealed record BundleSaleTemplateDto( + int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, + EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList Lines); + +public sealed record BundleSaleTemplateSummaryDto( + int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, + EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed record BundleSalePostingIssueDto( + int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId, + decimal RequestedQty, decimal AvailableQty, decimal ShortQty); + +public sealed record BundleSalePostingCheckDto( + int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost, + IReadOnlyList Issues); + +public sealed class CreateBundleSaleTemplateLineRequest +{ + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + [Required] public int WarehouseId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } + public bool IncludeInBundle { get; set; } = true; + public int SortOrder { get; set; } +} + +public sealed class CreateBundleSaleTemplateRequest +{ + [Required] public string TemplateCode { get; set; } = string.Empty; + [Required] public string TemplateName { get; set; } = string.Empty; + public string? Description { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class CreateBundleSaleRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required] public int BundleSaleTemplateId { get; set; } + [Required] public string BundleName { get; set; } = string.Empty; + [Range(0, double.MaxValue)] public decimal BundlePrice { get; set; } + public bool AllowPriceOverride { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdateBundleSaleRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required] public int BundleSaleTemplateId { get; set; } + [Required] public string BundleName { get; set; } = string.Empty; + [Range(0, double.MaxValue)] public decimal BundlePrice { get; set; } + public bool AllowPriceOverride { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs new file mode 100644 index 0000000..c541132 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Sales; + +public sealed record SalesInvoiceLineDto( + int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId, + int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, + decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, + decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); + +public sealed record SalesInvoiceTotalsDto( + decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, + decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount); + +public sealed record SalesInvoiceDto( + int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, + string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, + SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt, + DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines); + +public sealed record SalesInvoiceSummaryDto( + int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType, + SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt); + +public sealed record SalesInvoicePostingIssueDto( + int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId, + decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue); + +public sealed record SalesInvoicePostingCheckDto( + int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost, + IReadOnlyList Issues); + +public sealed class CreateSalesInvoiceLineRequest +{ + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + [Required] public int WarehouseId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + [Range(0, double.MaxValue)] public decimal FreeQty { get; set; } + [Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; } + public bool AllowManualPriceOverride { get; set; } + [Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage; + [Range(0, 100)] public decimal DiscountPct { get; set; } + [Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; } + [Range(0, double.MaxValue)] public decimal DiscountValue { get; set; } + [Range(0, 100)] public decimal TaxPct { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } +} + +public sealed class CreateSalesInvoiceRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdateSalesInvoiceRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs new file mode 100644 index 0000000..dffd1c8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs @@ -0,0 +1,23 @@ +namespace ERPCore.Dtos.Sales; + +public sealed record SalesFreeIssueRewardOptionDto( + int ItemId, + string Sku, + string Name, + decimal? SalePrice); + +public sealed record SalesFreeIssueSuggestionLineDto( + int SalesSlipLineId, + int ItemId, + string ItemSku, + string ItemName, + decimal Qty, + decimal SuggestedFreeQty, + decimal TriggerQty, + IReadOnlyList RewardOptions); + +public sealed record SalesFreeIssueSuggestionDto( + int SalesSlipId, + string SlipNo, + DateTime SlipDate, + IReadOnlyList Lines); \ No newline at end of file diff --git a/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs new file mode 100644 index 0000000..bea474c --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs @@ -0,0 +1,68 @@ +namespace ERPCore.Dtos.Sales; + +public sealed record SalesDailySummaryRowDto( + DateOnly Date, + int InvoiceCount, + int SlipCount, + decimal InvoiceSubtotal, + decimal SlipSubtotal, + decimal DiscountTotal, + decimal FreeQtyTotal, + decimal TaxTotal, + decimal GrandTotal); + +public sealed record SalesItemSummaryRowDto( + int ItemId, + string ItemName, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesCustomerSummaryRowDto( + int CustomerId, + string CustomerName, + int InvoiceCount, + int SlipCount, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesWarehouseSummaryRowDto( + int WarehouseId, + string WarehouseName, + int InvoiceCount, + int SlipCount, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesDiscountSummaryRowDto( + string DocumentType, + string DocumentNo, + DateTime DocumentDate, + string CustomerName, + decimal Subtotal, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesFreeIssueSummaryRowDto( + string DocumentType, + string DocumentNo, + DateTime DocumentDate, + string CustomerName, + int ItemId, + string ItemName, + decimal FreeQty, + decimal FreeValue, + int WarehouseId, + string WarehouseName); diff --git a/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs new file mode 100644 index 0000000..c900df0 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs @@ -0,0 +1,21 @@ +namespace ERPCore.Dtos.Sales; + +public sealed record SalesReportDefinitionDto( + string Id, + string Name, + string Description, + IReadOnlyList SupportedFilters); + +public sealed record SalesReportQueryRequest( + string ReportType, + DateOnly From, + DateOnly To, + int? ItemId = null, + int? CustomerId = null, + int? WarehouseId = null); + +public sealed record SalesReportQueryResponse( + string ReportType, + DateOnly From, + DateOnly To, + IReadOnlyList Rows); diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs new file mode 100644 index 0000000..85ec177 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -0,0 +1,77 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Sales; + +public sealed record SalesSlipLineDto( + int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId, + int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, + decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, + decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); + +public sealed record SalesSlipTotalsDto( + decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, + decimal PaidAmount, decimal BalanceAmount); + +public sealed record SalesSlipDto( + int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList Lines); + +public sealed record SalesSlipSummaryDto( + int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status, + SalesSlipTotalsDto Totals, DateTime CreatedAt); + +public sealed record SalesSlipPostingIssueDto( + int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId, + decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue); + +public sealed record SalesSlipPostingCheckDto( + int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost, + IReadOnlyList Issues); + +public sealed record FreeIssueSummaryDto( + int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt, + int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName, + int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel); + +public sealed record FreeIssueDto( + int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status, + int CustomerId, string CustomerSnapshotName, int WarehouseId, string WarehouseName, + int CashierUserId, DateTime CreatedAt, DateTime? UpdatedAt, FreeIssueSummaryDto Summary, + IReadOnlyList Lines); + +public sealed class CreateSalesSlipLineRequest +{ + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + [Required] public int WarehouseId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + [Range(0, double.MaxValue)] public decimal FreeQty { get; set; } + [Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; } + public bool AllowManualPriceOverride { get; set; } + [Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage; + [Range(0, 100)] public decimal DiscountPct { get; set; } + [Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; } + [Range(0, double.MaxValue)] public decimal DiscountValue { get; set; } + [Range(0, 100)] public decimal TaxPct { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } +} + +public sealed class CreateSalesSlipRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdateSalesSlipRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs b/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs new file mode 100644 index 0000000..170879f --- /dev/null +++ b/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs @@ -0,0 +1,55 @@ +using ERPCore.System.Errors; + +namespace ERPCore.Infra.Gl; + +/// +/// HTTP implementation of . Registered as a typed +/// client (`AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>`) with its +/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the +/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response +/// body straight through, unparsed — GL's own response (status, content-type, body) is +/// returned exactly as received; nothing here reshapes it. +/// +public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient +{ + private readonly HttpClient _http = http; + private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty; + + public async Task SendAsync( + HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) + { + var relativeUri = path.TrimStart('/') + queryString; + using var request = new HttpRequestMessage(method, relativeUri); + request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey); + + if (body is not null && method != HttpMethod.Get) + { + var content = new StreamContent(body); + if (!string.IsNullOrEmpty(contentType)) + content.Headers.TryAddWithoutValidation("Content-Type", contentType); + request.Content = content; + } + + HttpResponseMessage response; + try + { + response = await _http.SendAsync(request, ct); + } + catch (HttpRequestException) + { + throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503); + } + + var responseBody = await response.Content.ReadAsStringAsync(ct); + return new GeneralLedgerResponse + { + StatusCode = (int)response.StatusCode, + ContentType = response.Content.Headers.ContentType?.ToString(), + Body = responseBody + }; + } +} diff --git a/Backend/ERPCore/Infra/Gl/GeneralLedgerResponse.cs b/Backend/ERPCore/Infra/Gl/GeneralLedgerResponse.cs new file mode 100644 index 0000000..bfaec0c --- /dev/null +++ b/Backend/ERPCore/Infra/Gl/GeneralLedgerResponse.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Infra.Gl; + +/// +/// Raw HTTP result from the external General Ledger service — status code, content +/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own +/// envelope (see the GL service's own API reference) is passed through byte-for-byte +/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision +/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md). +/// +public sealed class GeneralLedgerResponse +{ + public int StatusCode { get; init; } + public string? ContentType { get; init; } + public string Body { get; init; } = string.Empty; +} diff --git a/Backend/ERPCore/Infra/Gl/IGeneralLedgerClient.cs b/Backend/ERPCore/Infra/Gl/IGeneralLedgerClient.cs new file mode 100644 index 0000000..962c0eb --- /dev/null +++ b/Backend/ERPCore/Infra/Gl/IGeneralLedgerClient.cs @@ -0,0 +1,13 @@ +namespace ERPCore.Infra.Gl; + +/// +/// Typed HTTP transport to the external General Ledger service. Injects the shared +/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim — +/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only +/// consumes this. +/// +public interface IGeneralLedgerClient +{ + Task SendAsync( + HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct); +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs new file mode 100644 index 0000000..d53b314 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BundleSaleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("bundle_sales"); + builder.HasKey(x => x.BundleSaleId); + builder.Property(x => x.BundleNo).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.BundleNo).IsUnique(); + builder.Property(x => x.BundleDate).IsRequired(); + builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200); + builder.Property(x => x.BundleName).IsRequired().HasMaxLength(200); + builder.Property(x => x.BundleCode).IsRequired().HasMaxLength(50); + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(BundleSaleStatus.Draft); + builder.Property(x => x.ComponentSubtotal).HasPrecision(18, 4); + builder.Property(x => x.BundlePrice).HasPrecision(18, 4); + builder.Property(x => x.MarginAmount).HasPrecision(18, 4); + builder.Property(x => x.DiscountTotal).HasPrecision(18, 4); + builder.Property(x => x.TaxTotal).HasPrecision(18, 4); + builder.Property(x => x.GrandTotal).HasPrecision(18, 4); + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.ConcurrencyStamp) + .IsRequired() + .HasColumnType("integer") + .HasDefaultValue(0) + .IsConcurrencyToken(); + + builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.BundleSaleTemplate).WithMany().HasForeignKey(x => x.BundleSaleTemplateId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.BundleSale) + .HasForeignKey(x => x.BundleSaleId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs new file mode 100644 index 0000000..fac3e88 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs @@ -0,0 +1,24 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("bundle_sale_lines"); + builder.HasKey(x => x.BundleSaleLineId); + builder.Property(x => x.Description).IsRequired().HasMaxLength(200); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.UnitPrice).HasPrecision(18, 4); + builder.Property(x => x.LineTotal).HasPrecision(18, 4); + builder.Property(x => x.IncludeInBundle).HasDefaultValue(true); + builder.Property(x => x.IsComponent).HasDefaultValue(true); + + builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs new file mode 100644 index 0000000..e580e08 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs @@ -0,0 +1,34 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("bundle_sale_templates"); + builder.HasKey(x => x.BundleSaleTemplateId); + + builder.Property(x => x.TemplateCode).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.TemplateCode).IsUnique(); + builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200); + builder.Property(x => x.Description).HasMaxLength(1000); + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.ConcurrencyStamp) + .IsRequired() + .HasColumnType("integer") + .HasDefaultValue(0) + .IsConcurrencyToken(); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.BundleSaleTemplate) + .HasForeignKey(x => x.BundleSaleTemplateId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs new file mode 100644 index 0000000..113647e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.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 BundleSaleTemplateLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("bundle_sale_template_lines"); + builder.HasKey(x => x.BundleSaleTemplateLineId); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.UnitPrice).HasPrecision(18, 4); + builder.Property(x => x.SortOrder).HasDefaultValue(0); + + builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs new file mode 100644 index 0000000..a52b327 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs @@ -0,0 +1,50 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("customers"); + builder.HasKey(c => c.CustomerId); + + builder.Property(c => c.CustomerCode).IsRequired().HasMaxLength(50); + builder.HasIndex(c => c.CustomerCode).IsUnique(); + + builder.Property(c => c.CustomerType) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(CustomerType.B2C); + + builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + builder.Property(c => c.DisplayName).HasMaxLength(200); + builder.Property(c => c.Phone).HasMaxLength(30); + builder.Property(c => c.Email).HasMaxLength(100); + builder.Property(c => c.AddressLine1).HasMaxLength(250); + builder.Property(c => c.AddressLine2).HasMaxLength(250); + builder.Property(c => c.City).HasMaxLength(100); + builder.Property(c => c.Country).HasMaxLength(100); + builder.Property(c => c.TaxRegistrationNo).HasMaxLength(50); + + builder.Property(c => c.CreditLimit).HasPrecision(18, 4); + builder.Property(c => c.CreditDays).IsRequired(); + + builder.HasOne(c => c.DefaultWarehouse) + .WithMany() + .HasForeignKey(c => c.DefaultWarehouseId) + .OnDelete(DeleteBehavior.SetNull); + + builder.Property(c => c.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasIndex(c => c.Status); + builder.HasIndex(c => c.CustomerType); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs index 0a89d0a..afc2771 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs @@ -36,7 +36,10 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 }, new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 }, new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 }, - new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 } + new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }, + new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 }, + new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }, + new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 } ); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs index 065f5b8..47bec28 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs @@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoices"); + builder.HasKey(x => x.SalesInvoiceId); + + builder.Property(x => x.InvoiceNo).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.InvoiceNo).IsUnique(); + + builder.Property(x => x.InvoiceDate).IsRequired(); + + builder.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200); + builder.Property(x => x.CustomerSnapshotTaxNo).HasMaxLength(50); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.InvoiceType) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesInvoiceType.B2C); + + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesInvoiceStatus.Draft); + + foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) }) + builder.Property(p).HasPrecision(18, 4); + + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.RowVersion).IsRowVersion(); + + builder.HasIndex(x => x.Status); + builder.HasIndex(x => x.InvoiceDate); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.SalesInvoice) + .HasForeignKey(x => x.SalesInvoiceId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoice_lines"); + builder.HasKey(x => x.SalesInvoiceLineId); + + builder.HasOne(x => x.Item) + .WithMany() + .HasForeignKey(x => x.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.Description).IsRequired().HasMaxLength(200); + + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.FreeQty).HasPrecision(18, 4); + builder.Property(x => x.UnitPrice).HasPrecision(18, 4); + builder.Property(x => x.BaseCost).HasPrecision(18, 4); + builder.Property(x => x.DiscountPct).HasPrecision(9, 4); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 4); + builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4); + builder.Property(x => x.LineTotal).HasPrecision(18, 4); + builder.Property(x => x.TaxPct).HasPrecision(9, 4); + builder.Property(x => x.TaxAmount).HasPrecision(18, 4); + + builder.HasOne(x => x.Uom) + .WithMany() + .HasForeignKey(x => x.UomId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.PriceSource).HasMaxLength(50); + builder.Property(x => x.RowVersion).IsRowVersion(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs new file mode 100644 index 0000000..deeeed0 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs @@ -0,0 +1,95 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class SalesSlipConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slips"); + builder.HasKey(x => x.SalesSlipId); + + builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.SlipNo).IsUnique(); + + builder.Property(x => x.SlipDate).IsRequired(); + + builder.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(x => x.CashierUser) + .WithMany() + .HasForeignKey(x => x.CashierUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesSlipStatus.Draft); + + foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) }) + builder.Property(p).HasPrecision(18, 4); + + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.RowVersion).IsRowVersion(); + + builder.HasIndex(x => x.Status); + builder.HasIndex(x => x.SlipDate); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.SalesSlip) + .HasForeignKey(x => x.SalesSlipId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slip_lines"); + builder.HasKey(x => x.SalesSlipLineId); + + builder.HasOne(x => x.Item) + .WithMany() + .HasForeignKey(x => x.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.Description).IsRequired().HasMaxLength(200); + + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.FreeQty).HasPrecision(18, 4); + builder.Property(x => x.UnitPrice).HasPrecision(18, 4); + builder.Property(x => x.BaseCost).HasPrecision(18, 4); + builder.Property(x => x.DiscountPct).HasPrecision(9, 4); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 4); + builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4); + builder.Property(x => x.LineTotal).HasPrecision(18, 4); + builder.Property(x => x.TaxPct).HasPrecision(9, 4); + builder.Property(x => x.TaxAmount).HasPrecision(18, 4); + + builder.HasOne(x => x.Uom) + .WithMany() + .HasForeignKey(x => x.UomId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.PriceSource).HasMaxLength(50); + builder.Property(x => x.RowVersion).IsRowVersion(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs index eb4f156..4282211 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs @@ -33,11 +33,27 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration + /// Seeds a printable company profile with reasonable defaults for invoice headers. + /// These values are intentionally editable later through the API. + /// + //private static async Task SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct) + //{ + // if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false; + + // db.CompanyProfiles.Add(new CompanyProfile + // { + // CompanyProfileId = CompanyProfile.SingletonId, + // LegalName = "ERP Core Trading (Pvt) Ltd", + // TradeName = "ERP Core Trading", + // TaxRegistrationNo = "TAX-DEFAULT-001", + // VatRegistrationNo = "VAT-DEFAULT-001", + // AddressLine1 = "1 Demo Street", + // City = "Colombo", + // Country = "Sri Lanka", + // Phone = "+94 11 000 0000", + // Email = "accounts@example.com", + // BankName = "Demo Bank", + // BankBranch = "Colombo Main", + // AccountName = "ERP Core Trading (Pvt) Ltd", + // AccountNumber = "000123456789", + // SwiftCode = "DEMO1234", + // FooterNote = "Thank you for your business." + // }); + // return true; + //} + + /// + /// Seeds the minimum catalog data required for the sales demo rows to exist. + /// These are safe additive rows and do not alter any existing data. + /// + private static async Task SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct) + { + var dirty = false; + + dirty |= await SeedWarehousesAsync(db, ct); + dirty |= await SeedUomsAsync(db, ct); + dirty |= await SeedCategoriesAsync(db, ct); + dirty |= await SeedItemsAsync(db, ct); + + return dirty; + } + + /// + /// Seeds a simple on-hand FIFO layer for the demo sales item so the sample + /// invoices can be posted without immediately failing stock validation. + /// This keeps the stock-check and posting flows testable on a fresh database. + /// + private static async Task SeedSalesStockAsync(ErpDbContext db, CancellationToken ct) + { + var warehouse = await db.Warehouses.AsNoTracking() + .OrderBy(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var item = await db.Items.AsNoTracking() + .OrderBy(i => i.ItemId) + .FirstOrDefaultAsync(ct); + var secondItem = await db.Items.AsNoTracking() + .OrderByDescending(i => i.ItemId) + .FirstOrDefaultAsync(ct); + + if (warehouse is null || item is null || secondItem is null) + return false; + + var existing = await db.StockLayers.AnyAsync( + l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m, + ct); + if (existing) return false; + + db.StockLayers.Add(new StockLayer + { + ItemId = item.ItemId, + WarehouseId = warehouse.WarehouseId, + QtyReceived = 100m, + QtyRemaining = 100m, + UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m, + ReceiptDate = DateTime.UtcNow.AddDays(-7) + }); + db.StockLayers.Add(new StockLayer + { + ItemId = secondItem.ItemId, + WarehouseId = warehouse.WarehouseId, + QtyReceived = 5m, + QtyRemaining = 5m, + UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m, + ReceiptDate = DateTime.UtcNow.AddDays(-6) + }); + return true; + } + + private static async Task SeedWarehousesAsync(ErpDbContext db, CancellationToken ct) + { + var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct); + var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Warehouse { Code = "MAIN", Name = "Main Warehouse" }, + new Warehouse { Code = "SHOP", Name = "Sales Counter" } + }; + + var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList(); + if (toAdd.Count == 0) return false; + + db.Warehouses.AddRange(toAdd); + return true; + } + + private static async Task SeedUomsAsync(ErpDbContext db, CancellationToken ct) + { + var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct); + var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Uom { Name = "PCS" }, + new Uom { Name = "BOX" } + }; + + var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList(); + if (toAdd.Count == 0) return false; + + db.Uoms.AddRange(toAdd); + return true; + } + + private static async Task SeedCategoriesAsync(ErpDbContext db, CancellationToken ct) + { + var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct); + var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }, + new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow } + }; + + var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList(); + if (toAdd.Count == 0) return false; + + db.Categories.AddRange(toAdd); + return true; + } + + private static async Task SeedItemsAsync(ErpDbContext db, CancellationToken ct) + { + var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct); + var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var category = await db.Categories.AsNoTracking() + .OrderBy(c => c.CategoryId) + .FirstOrDefaultAsync(ct); + var uom = await db.Uoms.AsNoTracking() + .OrderBy(u => u.UomId) + .FirstOrDefaultAsync(ct); + + if (category is null || uom is null) + return false; + + var now = DateTime.UtcNow; + var seeds = new[] + { + new Item + { + Sku = "SKU-DEMO-001", + Name = "Demo Item 1", + Description = "Seeded sample item for sales documents", + CategoryId = category.CategoryId, + BaseUomId = uom.UomId, + StockNature = StockNature.Stocked, + TrackingMode = TrackingMode.None, + SalePrice = 100m, + Status = EntityStatus.Active, + CreatedAt = now + }, + new Item + { + Sku = "SKU-DEMO-002", + Name = "Demo Item 2", + Description = "Secondary seeded sample item for sales documents", + CategoryId = category.CategoryId, + BaseUomId = uom.UomId, + StockNature = StockNature.Stocked, + TrackingMode = TrackingMode.None, + SalePrice = 50m, + Status = EntityStatus.Active, + CreatedAt = now + } + }; + + var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList(); + if (toAdd.Count == 0) return false; + + db.Items.AddRange(toAdd); + return true; + } + + /// + /// Seeds the minimum sales bootstrap data needed for UI/backend development: + /// a couple of customer rows, current-year document counters, and a few draft + /// invoice/slip samples when the required master data already exists. + /// This intentionally never clears or rewrites any existing rows. + /// + private static async Task SeedSalesAsync(ErpDbContext db, CancellationToken ct) + { + var dirty = false; + + dirty |= await SeedSalesCustomersAsync(db, ct); + dirty |= await SeedSalesSequencesAsync(db, ct); + dirty |= await SeedSampleSalesDocsAsync(db, ct); + + try + { + dirty |= await SeedBundleSalesAsync(db, ct); + } + catch + { + // Bundle demo data is best-effort only; never block startup because of seed drift. + } + + return dirty; + } + + private static async Task SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct) + { + var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct); + var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Customer + { + CustomerCode = "CUST-WALKIN", + CustomerType = CustomerType.B2C, + Name = "Walk-in Customer", + DisplayName = "Walk-in Customer", + CreditLimit = 0m, + CreditDays = 0, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }, + new Customer + { + CustomerCode = "CUST-DEMO", + CustomerType = CustomerType.B2B, + Name = "Demo Retail Ltd", + DisplayName = "Demo Retail Ltd", + Phone = "+94 11 000 0000", + Email = "sales@example.com", + AddressLine1 = "1 Demo Street", + City = "Colombo", + Country = "Sri Lanka", + TaxRegistrationNo = "VAT-DEMO-001", + CreditLimit = 250000m, + CreditDays = 30, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + } + }; + + var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList(); + if (toAdd.Count == 0) return false; + + db.Customers.AddRange(toAdd); + return true; + } + + private static async Task SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct) + { + var year = DateTime.UtcNow.Year; + var existing = await db.NumberSequences + .Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale)) + .Select(s => s.DocType) + .ToListAsync(ct); + var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 }, + new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 }, + new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 } + }; + + var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList(); + if (toAdd.Count == 0) return false; + + db.NumberSequences.AddRange(toAdd); + return true; + } + + private static async Task SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct)) + return false; + + var customer = await db.Customers.AsNoTracking() + .OrderBy(c => c.CustomerId) + .FirstOrDefaultAsync(ct); + var warehouse = await db.Warehouses.AsNoTracking() + .OrderBy(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var secondaryWarehouse = await db.Warehouses.AsNoTracking() + .OrderByDescending(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var items = await db.Items.AsNoTracking() + .OrderBy(i => i.ItemId) + .Take(2) + .ToListAsync(ct); + var uom = await db.Uoms.AsNoTracking() + .OrderBy(u => u.UomId) + .FirstOrDefaultAsync(ct); + var user = await db.Users.AsNoTracking() + .OrderBy(u => u.UserId) + .FirstOrDefaultAsync(ct); + + if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null) + return false; + + var now = DateTime.UtcNow; + var template = new BundleSaleTemplate + { + TemplateCode = "BND-DEMO-001", + TemplateName = "Demo Bundle Pack", + Description = "Seeded fixed bundle template for integration testing", + Status = EntityStatus.Active, + CreatedAt = now, + Lines = + [ + new BundleSaleTemplateLine + { + ItemId = items[0].ItemId, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + Qty = 1m, + UnitPrice = items[0].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + SortOrder = 1 + }, + new BundleSaleTemplateLine + { + ItemId = items[1].ItemId, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + Qty = 1m, + UnitPrice = items[1].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + SortOrder = 2 + } + ] + }; + + db.BundleSaleTemplates.Add(template); + await db.SaveChangesAsync(ct); + + var bundleSales = new[] + { + new BundleSale + { + BundleNo = $"BND-{now:yyyy}-00001", + BundleDate = now.Date.AddDays(-2), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.DisplayName ?? customer.Name, + WarehouseId = warehouse.WarehouseId, + CashierUserId = user.UserId, + BundleSaleTemplateId = template.BundleSaleTemplateId, + BundleName = "Demo Bundle Draft", + BundleCode = "BND-DEMO-001", + Status = BundleSaleStatus.Draft, + ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), + BundlePrice = 0m, + MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()), + DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), + TaxTotal = 0m, + GrandTotal = 0m, + CreatedAt = now.AddDays(-2), + Lines = + [ + new BundleSaleLine + { + ItemId = items[0].ItemId, + Description = items[0].Name, + Qty = 1m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = items[0].SalePrice.GetValueOrDefault(), + LineTotal = items[0].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + IsComponent = true + }, + new BundleSaleLine + { + ItemId = items[1].ItemId, + Description = items[1].Name, + Qty = 1m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = items[1].SalePrice.GetValueOrDefault(), + LineTotal = items[1].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + IsComponent = true + } + ] + }, + new BundleSale + { + BundleNo = $"BND-{now:yyyy}-00002", + BundleDate = now.Date.AddDays(-1), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.DisplayName ?? customer.Name, + WarehouseId = warehouse.WarehouseId, + CashierUserId = user.UserId, + BundleSaleTemplateId = template.BundleSaleTemplateId, + BundleName = "Demo Bundle Posted", + BundleCode = "BND-DEMO-001", + Status = BundleSaleStatus.Posted, + ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(), + BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m, + MarginAmount = -10m, + DiscountTotal = 10m, + TaxTotal = 0m, + GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m, + CreatedAt = now.AddDays(-1), + UpdatedAt = now.AddHours(-2), + Lines = + [ + new BundleSaleLine + { + ItemId = items[0].ItemId, + Description = items[0].Name, + Qty = 1m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = items[0].SalePrice.GetValueOrDefault(), + LineTotal = items[0].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + IsComponent = true + }, + new BundleSaleLine + { + ItemId = items[1].ItemId, + Description = items[1].Name, + Qty = 1m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = items[1].SalePrice.GetValueOrDefault(), + LineTotal = items[1].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + IsComponent = true + } + ] + }, + new BundleSale + { + BundleNo = $"BND-{now:yyyy}-00003", + BundleDate = now.Date, + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.DisplayName ?? customer.Name, + WarehouseId = secondaryWarehouse.WarehouseId, + CashierUserId = user.UserId, + BundleSaleTemplateId = template.BundleSaleTemplateId, + BundleName = "Demo Bundle Cancelled", + BundleCode = "BND-DEMO-001", + Status = BundleSaleStatus.Cancelled, + ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(), + BundlePrice = items[0].SalePrice.GetValueOrDefault(), + MarginAmount = 0m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = items[0].SalePrice.GetValueOrDefault(), + CreatedAt = now, + UpdatedAt = now, + Lines = + [ + new BundleSaleLine + { + ItemId = items[0].ItemId, + Description = items[0].Name, + Qty = 1m, + UomId = uom.UomId, + WarehouseId = secondaryWarehouse.WarehouseId, + UnitPrice = items[0].SalePrice.GetValueOrDefault(), + LineTotal = items[0].SalePrice.GetValueOrDefault(), + IncludeInBundle = true, + IsComponent = true + } + ] + } + }; + + db.BundleSales.AddRange(bundleSales); + return true; + } + + private static async Task SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct)) + return false; + + var customer = await db.Customers.AsNoTracking() + .OrderBy(c => c.CustomerId) + .FirstOrDefaultAsync(ct); + var warehouse = await db.Warehouses.AsNoTracking() + .OrderBy(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var secondaryWarehouse = await db.Warehouses.AsNoTracking() + .OrderByDescending(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var items = await db.Items.AsNoTracking() + .OrderBy(i => i.ItemId) + .Take(2) + .ToListAsync(ct); + var uom = await db.Uoms.AsNoTracking() + .OrderBy(u => u.UomId) + .FirstOrDefaultAsync(ct); + var user = await db.Users.AsNoTracking() + .OrderBy(u => u.UserId) + .FirstOrDefaultAsync(ct); + + if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null) + return false; + + var postableItem = items[0]; + var shortageItem = items[1]; + var today = DateTime.UtcNow.Date; + var createdAt = DateTime.UtcNow.AddDays(-1); + + db.SalesInvoices.AddRange( + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00001", + InvoiceDate = today.AddDays(-2), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = warehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2C, + Status = SalesInvoiceStatus.Draft, + Subtotal = 200m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 200m, + RoundOff = 0m, + NetPayable = 200m, + PaidAmount = 0m, + BalanceAmount = 200m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + Lines = + [ + new SalesInvoiceLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 2m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 100m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 100m, + LineTotal = 200m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00002", + InvoiceDate = today.AddDays(-1), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = secondaryWarehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2B, + Status = SalesInvoiceStatus.Draft, + Subtotal = 300m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 300m, + RoundOff = 0m, + NetPayable = 300m, + PaidAmount = 0m, + BalanceAmount = 300m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + Lines = + [ + new SalesInvoiceLine + { + ItemId = shortageItem.ItemId, + Description = shortageItem.Name, + Qty = 6m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = secondaryWarehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 50m, + LineTotal = 300m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00003", + InvoiceDate = today, + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = warehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2C, + Status = SalesInvoiceStatus.Posted, + Subtotal = 100m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 100m, + RoundOff = 0m, + NetPayable = 100m, + PaidAmount = 100m, + BalanceAmount = 0m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + UpdatedAt = DateTime.UtcNow, + Lines = + [ + new SalesInvoiceLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 1m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 100m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 100m, + LineTotal = 100m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + } + ); + + db.SalesSlips.AddRange( + new SalesSlip + { + SlipNo = $"SSL-{today:yyyy}-00001", + SlipDate = today.AddDays(-2), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + WarehouseId = warehouse.WarehouseId, + CashierUserId = user.UserId, + Status = SalesSlipStatus.Draft, + Subtotal = 50m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 50m, + PaidAmount = 0m, + BalanceAmount = 50m, + CreatedAt = createdAt, + Lines = + [ + new SalesSlipLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 1m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 50m, + LineTotal = 50m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesSlip + { + SlipNo = $"SSL-{today:yyyy}-00002", + SlipDate = today.AddDays(-1), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + WarehouseId = secondaryWarehouse.WarehouseId, + CashierUserId = user.UserId, + Status = SalesSlipStatus.Draft, + Subtotal = 150m, + DiscountTotal = 15m, + TaxTotal = 0m, + GrandTotal = 135m, + PaidAmount = 0m, + BalanceAmount = 135m, + CreatedAt = createdAt, + Lines = + [ + new SalesSlipLine + { + ItemId = shortageItem.ItemId, + Description = shortageItem.Name, + Qty = 3m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = secondaryWarehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 10m, + DiscountAmount = 15m, + NetUnitPrice = 45m, + LineTotal = 135m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + } + ); + + return true; + } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index ca895cd..556890c 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -15,6 +15,7 @@ namespace ERPCore.Infra.Persistence; public class ErpDbContext : DbContext { private readonly ICurrentUser _currentUser; + private bool _writingAuditLogs; public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options) { @@ -22,6 +23,7 @@ public class ErpDbContext : DbContext } // --- Master Data (docs/10 Part C.1) --- + public DbSet Customers => Set(); public DbSet Categories => Set(); public DbSet SubCategories => Set(); public DbSet Brands => Set(); @@ -34,6 +36,7 @@ public class ErpDbContext : DbContext public DbSet Vendors => Set(); public DbSet Warehouses => Set(); public DbSet Bins => Set(); + /// Singleton row (FR-MD-11). public DbSet ProductConfig => Set(); @@ -82,6 +85,16 @@ public class ErpDbContext : DbContext public DbSet PurchaseReturns => Set(); public DbSet PurchaseReturnLines => Set(); + // --- Sales (Phase 1) --- + public DbSet SalesInvoices => Set(); + public DbSet SalesInvoiceLines => Set(); + public DbSet SalesSlips => Set(); + public DbSet SalesSlipLines => Set(); + public DbSet BundleSaleTemplates => Set(); + public DbSet BundleSaleTemplateLines => Set(); + public DbSet BundleSales => Set(); + public DbSet BundleSaleLines => Set(); + // --- Reference data (docs/10 Part C.7) --- public DbSet ReasonCodes => Set(); @@ -178,24 +191,44 @@ public class ErpDbContext : DbContext // persists the logs without re-auditing them. public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) { - var pending = AuditScribe.Capture(ChangeTracker); + IReadOnlyList pending = _writingAuditLogs + ? Array.Empty() + : AuditScribe.Capture(ChangeTracker); var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); if (pending.Count > 0) { - WriteAuditLogs(pending); - await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + try + { + _writingAuditLogs = true; + WriteAuditLogs(pending); + await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + } + finally + { + _writingAuditLogs = false; + } } return result; } public override int SaveChanges(bool acceptAllChangesOnSuccess) { - var pending = AuditScribe.Capture(ChangeTracker); + IReadOnlyList pending = _writingAuditLogs + ? Array.Empty() + : AuditScribe.Capture(ChangeTracker); var result = base.SaveChanges(acceptAllChangesOnSuccess); if (pending.Count > 0) { - WriteAuditLogs(pending); - base.SaveChanges(acceptAllChangesOnSuccess); + try + { + _writingAuditLogs = true; + WriteAuditLogs(pending); + base.SaveChanges(acceptAllChangesOnSuccess); + } + finally + { + _writingAuditLogs = false; + } } return result; } diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs deleted file mode 100644 index 0406326..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs +++ /dev/null @@ -1,2229 +0,0 @@ -// -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("20260715093552_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => - { - b.Property("AuditId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("ChangeSet") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EntityId") - .HasColumnType("integer"); - - b.Property("EntityType") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("AuditId"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.HasIndex("EntityType", "EntityId"); - - b.ToTable("audit_logs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.Property("BatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); - - b.Property("BatchNo") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ExpiryDate") - .HasColumnType("date"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.HasKey("BatchId"); - - b.HasIndex("ItemId", "BatchNo") - .IsUnique(); - - b.ToTable("batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ParentId") - .HasColumnType("integer"); - - b.HasKey("CategoryId"); - - b.HasIndex("ParentId"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Property("GrnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PoId") - .HasColumnType("integer"); - - b.Property("PostedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("GrnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("PoId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("grns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.Property("GrnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("GrnId") - .HasColumnType("integer"); - - b.Property("HoldStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoLineId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceivedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("GrnLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("GrnId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoLineId"); - - b.HasIndex("UomId"); - - b.ToTable("grn_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => - { - b.Property("JournalId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); - - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("CreditAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("DebitAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.HasKey("JournalId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.ToTable("journal_entry_stubs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("integer") - .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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoId") - .HasColumnType("integer"); - - 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("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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.PurchaseReturn", b => - { - b.Property("ReturnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReturnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("purchase_returns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.Property("ReturnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnId") - .HasColumnType("integer"); - - b.HasKey("ReturnLineId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("ReturnId"); - - b.ToTable("purchase_return_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => - { - b.Property("ReasonCodeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Context") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("ReasonCodeId"); - - b.HasIndex("Context", "Code") - .IsUnique(); - - b.ToTable("reason_codes", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.Property("SerialId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SerialNo") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("SerialId"); - - b.HasIndex("ItemId", "SerialNo") - .IsUnique(); - - b.ToTable("serials", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Property("AdjustmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("AdjustmentId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_adjustments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.Property("AdjLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); - - b.Property("AdjustmentId") - .HasColumnType("integer"); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyDelta") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.HasKey("AdjLineId"); - - b.HasIndex("AdjustmentId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.ToTable("stock_adjustment_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Property("CountId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); - - b.Property("CountType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("CountId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_counts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.Property("CountLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CountId") - .HasColumnType("integer"); - - b.Property("CountedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SystemQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Variance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("CountLineId"); - - b.HasIndex("BinId"); - - b.HasIndex("CountId"); - - b.HasIndex("ItemId"); - - b.ToTable("stock_count_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.Property("LayerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyRemaining") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceiptDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LayerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("SerialId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); - - b.ToTable("stock_layers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.Property("LedgerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Direction") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("character varying(5)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyBase") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunningBalance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LedgerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("SerialId"); - - b.HasIndex("UserId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); - - b.HasIndex("ItemId", "WarehouseId", "LedgerId"); - - b.ToTable("stock_ledger", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Property("TransferId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DestWarehouseId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SrcWarehouseId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TransferId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DestWarehouseId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("SrcWarehouseId"); - - b.HasIndex("Status"); - - b.ToTable("stock_transfers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.Property("TransferLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("DestBinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SrcBinId") - .HasColumnType("integer"); - - b.Property("TransferId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.HasKey("TransferLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("DestBinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.HasIndex("SrcBinId"); - - b.HasIndex("TransferId"); - - b.ToTable("stock_transfer_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ToUomId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("AuthUserId") - .HasColumnType("uuid") - .HasColumnName("auth_user_id"); - - 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("AuthUserId") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.Property("VendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("integer"); - - 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("integer"); - - 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.AuditLog", b => - { - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - 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.Grn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany() - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") - .WithMany("Lines") - .HasForeignKey("GrnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") - .WithMany() - .HasForeignKey("PoLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("Bin"); - - b.Navigation("Grn"); - - b.Navigation("Item"); - - b.Navigation("PoLine"); - - b.Navigation("Uom"); - }); - - 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.PurchaseReturn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") - .WithMany("Lines") - .HasForeignKey("ReturnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Return"); - }); - - 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.Serial", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") - .WithMany("Lines") - .HasForeignKey("AdjustmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Adjustment"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") - .WithMany("Lines") - .HasForeignKey("CountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Count"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Serial"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", null) - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", null) - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") - .WithMany() - .HasForeignKey("DestWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") - .WithMany() - .HasForeignKey("SrcWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("DestWarehouse"); - - b.Navigation("SrcWarehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("DestBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("SrcBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") - .WithMany("Lines") - .HasForeignKey("TransferId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Transfer"); - }); - - 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.Grn", b => - { - b.Navigation("Lines"); - }); - - 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.PurchaseReturn", 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.StockAdjustment", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Navigation("Lines"); - }); - - 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/20260715093552_InitialCreate.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.cs deleted file mode 100644 index 1d0bd52..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.cs +++ /dev/null @@ -1,1791 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "categories", - columns: table => new - { - CategoryId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - ParentId = table.Column(type: "integer", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_categories", x => x.CategoryId); - table.ForeignKey( - name: "FK_categories_categories_ParentId", - column: x => x.ParentId, - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "journal_entry_stubs", - columns: table => new - { - JournalId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - SourceDocType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), - SourceDocId = table.Column(type: "integer", nullable: false), - DebitAccount = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreditAccount = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Amount = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_journal_entry_stubs", x => x.JournalId); - }); - - migrationBuilder.CreateTable( - name: "number_sequences", - columns: table => new - { - SequenceId = table.Column(type: "integer", 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: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_number_sequences", x => x.SequenceId); - }); - - migrationBuilder.CreateTable( - name: "reason_codes", - columns: table => new - { - ReasonCodeId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Description = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Context = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_reason_codes", x => x.ReasonCodeId); - }); - - migrationBuilder.CreateTable( - name: "uoms", - columns: table => new - { - UomId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_uoms", x => x.UomId); - }); - - migrationBuilder.CreateTable( - name: "users", - columns: table => new - { - UserId = table.Column(type: "integer", 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), - auth_user_id = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_users", x => x.UserId); - }); - - migrationBuilder.CreateTable( - name: "vendors", - columns: table => new - { - VendorId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Terms = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - TaxReg = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "LKR"), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_vendors", x => x.VendorId); - }); - - migrationBuilder.CreateTable( - name: "warehouses", - columns: table => new - { - WarehouseId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_warehouses", x => x.WarehouseId); - }); - - migrationBuilder.CreateTable( - name: "audit_logs", - columns: table => new - { - AuditId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "integer", nullable: false), - EntityType = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), - EntityId = table.Column(type: "integer", nullable: false), - Action = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), - ChangeSet = table.Column(type: "jsonb", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_audit_logs", x => x.AuditId); - table.ForeignKey( - name: "FK_audit_logs_users_UserId", - column: x => x.UserId, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "requisitions", - columns: table => new - { - RequisitionId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - RequestedBy = table.Column(type: "integer", 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: "items", - columns: table => new - { - ItemId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Sku = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - CategoryId = table.Column(type: "integer", nullable: false), - BaseUomId = table.Column(type: "integer", nullable: false), - DefaultVendorId = table.Column(type: "integer", nullable: true), - ItemType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - TrackingMode = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - TaxClass = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_items", x => x.ItemId); - table.ForeignKey( - name: "FK_items_categories_CategoryId", - column: x => x.CategoryId, - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_items_uoms_BaseUomId", - column: x => x.BaseUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_items_vendors_DefaultVendorId", - column: x => x.DefaultVendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "bins", - columns: table => new - { - BinId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - WarehouseId = table.Column(type: "integer", nullable: false), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - BinType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_bins", x => x.BinId); - table.ForeignKey( - name: "FK_bins_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "purchase_returns", - columns: table => new - { - ReturnId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - VendorId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - ReasonCodeId = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedBy = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_purchase_returns", x => x.ReturnId); - table.ForeignKey( - name: "FK_purchase_returns_reason_codes_ReasonCodeId", - column: x => x.ReasonCodeId, - principalTable: "reason_codes", - principalColumn: "ReasonCodeId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_returns_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_returns_vendors_VendorId", - column: x => x.VendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_returns_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stock_adjustments", - columns: table => new - { - AdjustmentId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - ReasonCodeId = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedBy = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_adjustments", x => x.AdjustmentId); - table.ForeignKey( - name: "FK_stock_adjustments_reason_codes_ReasonCodeId", - column: x => x.ReasonCodeId, - principalTable: "reason_codes", - principalColumn: "ReasonCodeId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustments_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustments_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stock_counts", - columns: table => new - { - CountId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - CountType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedBy = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_counts", x => x.CountId); - table.ForeignKey( - name: "FK_stock_counts_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_counts_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stock_transfers", - columns: table => new - { - TransferId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - SrcWarehouseId = table.Column(type: "integer", nullable: false), - DestWarehouseId = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedBy = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_transfers", x => x.TransferId); - table.ForeignKey( - name: "FK_stock_transfers_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfers_warehouses_DestWarehouseId", - column: x => x.DestWarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfers_warehouses_SrcWarehouseId", - column: x => x.SrcWarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "purchase_orders", - columns: table => new - { - PoId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - VendorId = table.Column(type: "integer", nullable: false), - RequisitionId = table.Column(type: "integer", nullable: true), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - ApprovalRequired = table.Column(type: "boolean", nullable: false), - CreatedBy = table.Column(type: "integer", 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: "rfqs", - columns: table => new - { - RfqId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - RequisitionId = table.Column(type: "integer", 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: "batches", - columns: table => new - { - BatchId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - BatchNo = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - ExpiryDate = table.Column(type: "date", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_batches", x => x.BatchId); - table.ForeignKey( - name: "FK_batches_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "item_reorders", - columns: table => new - { - ReorderId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - ReorderPoint = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - ReorderQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_item_reorders", x => x.ReorderId); - table.ForeignKey( - name: "FK_item_reorders_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_item_reorders_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "requisition_lines", - columns: table => new - { - ReqLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RequisitionId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", 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: "serials", - columns: table => new - { - SerialId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - SerialNo = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_serials", x => x.SerialId); - table.ForeignKey( - name: "FK_serials_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "uom_conversions", - columns: table => new - { - ConversionId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - FromUomId = table.Column(type: "integer", nullable: false), - ToUomId = table.Column(type: "integer", nullable: false), - Factor = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_uom_conversions", x => x.ConversionId); - table.ForeignKey( - name: "FK_uom_conversions_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_uom_conversions_uoms_FromUomId", - column: x => x.FromUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_uom_conversions_uoms_ToUomId", - column: x => x.ToUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stock_count_lines", - columns: table => new - { - CountLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - CountId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", nullable: false), - BinId = table.Column(type: "integer", nullable: true), - SystemQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - CountedQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true), - Variance = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_count_lines", x => x.CountLineId); - table.ForeignKey( - name: "FK_stock_count_lines_bins_BinId", - column: x => x.BinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_count_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_count_lines_stock_counts_CountId", - column: x => x.CountId, - principalTable: "stock_counts", - principalColumn: "CountId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "grns", - columns: table => new - { - GrnId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - PoId = table.Column(type: "integer", nullable: true), - VendorId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedBy = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - PostedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_grns", x => x.GrnId); - table.ForeignKey( - name: "FK_grns_purchase_orders_PoId", - column: x => x.PoId, - principalTable: "purchase_orders", - principalColumn: "PoId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grns_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grns_vendors_VendorId", - column: x => x.VendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grns_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "po_lines", - columns: table => new - { - PoLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - PoId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", nullable: false), - UomId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", 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: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RfqId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", 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: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RfqId = table.Column(type: "integer", nullable: false), - VendorId = table.Column(type: "integer", 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: "stock_adjustment_lines", - columns: table => new - { - AdjLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - AdjustmentId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", nullable: false), - BinId = table.Column(type: "integer", nullable: true), - BatchId = table.Column(type: "integer", nullable: true), - SerialId = table.Column(type: "integer", nullable: true), - QtyDelta = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_adjustment_lines", x => x.AdjLineId); - table.ForeignKey( - name: "FK_stock_adjustment_lines_batches_BatchId", - column: x => x.BatchId, - principalTable: "batches", - principalColumn: "BatchId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustment_lines_bins_BinId", - column: x => x.BinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustment_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustment_lines_serials_SerialId", - column: x => x.SerialId, - principalTable: "serials", - principalColumn: "SerialId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_adjustment_lines_stock_adjustments_AdjustmentId", - column: x => x.AdjustmentId, - principalTable: "stock_adjustments", - principalColumn: "AdjustmentId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "stock_ledger", - columns: table => new - { - LedgerId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - BinId = table.Column(type: "integer", nullable: true), - BatchId = table.Column(type: "integer", nullable: true), - SerialId = table.Column(type: "integer", nullable: true), - UserId = table.Column(type: "integer", nullable: false), - Direction = table.Column(type: "character varying(5)", maxLength: 5, nullable: false), - QtyBase = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), - Value = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - RunningBalance = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - SourceDocType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), - SourceDocId = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_ledger", x => x.LedgerId); - table.ForeignKey( - name: "FK_stock_ledger_batches_BatchId", - column: x => x.BatchId, - principalTable: "batches", - principalColumn: "BatchId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_ledger_bins_BinId", - column: x => x.BinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_ledger_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_ledger_serials_SerialId", - column: x => x.SerialId, - principalTable: "serials", - principalColumn: "SerialId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_ledger_users_UserId", - column: x => x.UserId, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_ledger_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stock_transfer_lines", - columns: table => new - { - TransferLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - TransferId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", nullable: false), - SrcBinId = table.Column(type: "integer", nullable: true), - DestBinId = table.Column(type: "integer", nullable: true), - BatchId = table.Column(type: "integer", nullable: true), - SerialId = table.Column(type: "integer", nullable: true), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: true), - QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_transfer_lines", x => x.TransferLineId); - table.ForeignKey( - name: "FK_stock_transfer_lines_batches_BatchId", - column: x => x.BatchId, - principalTable: "batches", - principalColumn: "BatchId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfer_lines_bins_DestBinId", - column: x => x.DestBinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfer_lines_bins_SrcBinId", - column: x => x.SrcBinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfer_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfer_lines_serials_SerialId", - column: x => x.SerialId, - principalTable: "serials", - principalColumn: "SerialId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_transfer_lines_stock_transfers_TransferId", - column: x => x.TransferId, - principalTable: "stock_transfers", - principalColumn: "TransferId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "grn_lines", - columns: table => new - { - GrnLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - GrnId = table.Column(type: "integer", nullable: false), - PoLineId = table.Column(type: "integer", nullable: true), - ItemId = table.Column(type: "integer", nullable: false), - UomId = table.Column(type: "integer", nullable: false), - BinId = table.Column(type: "integer", nullable: true), - BatchId = table.Column(type: "integer", nullable: true), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), - ReceivedValue = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - HoldStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_grn_lines", x => x.GrnLineId); - table.ForeignKey( - name: "FK_grn_lines_batches_BatchId", - column: x => x.BatchId, - principalTable: "batches", - principalColumn: "BatchId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grn_lines_bins_BinId", - column: x => x.BinId, - principalTable: "bins", - principalColumn: "BinId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grn_lines_grns_GrnId", - column: x => x.GrnId, - principalTable: "grns", - principalColumn: "GrnId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_grn_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grn_lines_po_lines_PoLineId", - column: x => x.PoLineId, - principalTable: "po_lines", - principalColumn: "PoLineId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_grn_lines_uoms_UomId", - column: x => x.UomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "vendor_quotation_lines", - columns: table => new - { - QuotationLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - QuotationId = table.Column(type: "integer", nullable: false), - ItemId = table.Column(type: "integer", 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.CreateTable( - name: "purchase_return_lines", - columns: table => new - { - ReturnLineId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ReturnId = table.Column(type: "integer", nullable: false), - GrnLineId = table.Column(type: "integer", nullable: true), - ItemId = table.Column(type: "integer", nullable: false), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_purchase_return_lines", x => x.ReturnLineId); - table.ForeignKey( - name: "FK_purchase_return_lines_grn_lines_GrnLineId", - column: x => x.GrnLineId, - principalTable: "grn_lines", - principalColumn: "GrnLineId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_return_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_return_lines_purchase_returns_ReturnId", - column: x => x.ReturnId, - principalTable: "purchase_returns", - principalColumn: "ReturnId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "stock_layers", - columns: table => new - { - LayerId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "integer", nullable: false), - WarehouseId = table.Column(type: "integer", nullable: false), - BatchId = table.Column(type: "integer", nullable: true), - SerialId = table.Column(type: "integer", nullable: true), - GrnLineId = table.Column(type: "integer", nullable: true), - QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - QtyRemaining = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), - ReceiptDate = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stock_layers", x => x.LayerId); - table.ForeignKey( - name: "FK_stock_layers_batches_BatchId", - column: x => x.BatchId, - principalTable: "batches", - principalColumn: "BatchId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_layers_grn_lines_GrnLineId", - column: x => x.GrnLineId, - principalTable: "grn_lines", - principalColumn: "GrnLineId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_layers_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_layers_serials_SerialId", - column: x => x.SerialId, - principalTable: "serials", - principalColumn: "SerialId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_stock_layers_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.InsertData( - table: "users", - columns: new[] { "UserId", "auth_user_id", "DisplayName", "Status", "Username" }, - values: new object[] { 1, null, "System", "Active", "system" }); - - migrationBuilder.CreateIndex( - name: "IX_audit_logs_CreatedAt", - table: "audit_logs", - column: "CreatedAt"); - - migrationBuilder.CreateIndex( - name: "IX_audit_logs_EntityType_EntityId", - table: "audit_logs", - columns: new[] { "EntityType", "EntityId" }); - - migrationBuilder.CreateIndex( - name: "IX_audit_logs_UserId", - table: "audit_logs", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_batches_ItemId_BatchNo", - table: "batches", - columns: new[] { "ItemId", "BatchNo" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_bins_WarehouseId_Code", - table: "bins", - columns: new[] { "WarehouseId", "Code" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_categories_ParentId", - table: "categories", - column: "ParentId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_BatchId", - table: "grn_lines", - column: "BatchId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_BinId", - table: "grn_lines", - column: "BinId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_GrnId", - table: "grn_lines", - column: "GrnId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_ItemId", - table: "grn_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_PoLineId", - table: "grn_lines", - column: "PoLineId"); - - migrationBuilder.CreateIndex( - name: "IX_grn_lines_UomId", - table: "grn_lines", - column: "UomId"); - - migrationBuilder.CreateIndex( - name: "IX_grns_CreatedBy", - table: "grns", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_grns_DocNo", - table: "grns", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_grns_PoId", - table: "grns", - column: "PoId"); - - migrationBuilder.CreateIndex( - name: "IX_grns_Status", - table: "grns", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_grns_VendorId", - table: "grns", - column: "VendorId"); - - migrationBuilder.CreateIndex( - name: "IX_grns_WarehouseId", - table: "grns", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_item_reorders_ItemId_WarehouseId", - table: "item_reorders", - columns: new[] { "ItemId", "WarehouseId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_item_reorders_WarehouseId", - table: "item_reorders", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_items_BaseUomId", - table: "items", - column: "BaseUomId"); - - migrationBuilder.CreateIndex( - name: "IX_items_CategoryId", - table: "items", - column: "CategoryId"); - - migrationBuilder.CreateIndex( - name: "IX_items_DefaultVendorId", - table: "items", - column: "DefaultVendorId"); - - migrationBuilder.CreateIndex( - name: "IX_items_Sku", - table: "items", - column: "Sku", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_items_Status", - table: "items", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_journal_entry_stubs_SourceDocType_SourceDocId", - table: "journal_entry_stubs", - columns: new[] { "SourceDocType", "SourceDocId" }); - - 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_purchase_return_lines_GrnLineId", - table: "purchase_return_lines", - column: "GrnLineId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_return_lines_ItemId", - table: "purchase_return_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_return_lines_ReturnId", - table: "purchase_return_lines", - column: "ReturnId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_returns_CreatedBy", - table: "purchase_returns", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_returns_DocNo", - table: "purchase_returns", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_purchase_returns_ReasonCodeId", - table: "purchase_returns", - column: "ReasonCodeId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_returns_VendorId", - table: "purchase_returns", - column: "VendorId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_returns_WarehouseId", - table: "purchase_returns", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_reason_codes_Context_Code", - table: "reason_codes", - columns: new[] { "Context", "Code" }, - unique: true); - - 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_serials_ItemId_SerialNo", - table: "serials", - columns: new[] { "ItemId", "SerialNo" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustment_lines_AdjustmentId", - table: "stock_adjustment_lines", - column: "AdjustmentId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustment_lines_BatchId", - table: "stock_adjustment_lines", - column: "BatchId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustment_lines_BinId", - table: "stock_adjustment_lines", - column: "BinId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustment_lines_ItemId", - table: "stock_adjustment_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustment_lines_SerialId", - table: "stock_adjustment_lines", - column: "SerialId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustments_CreatedBy", - table: "stock_adjustments", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustments_DocNo", - table: "stock_adjustments", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustments_ReasonCodeId", - table: "stock_adjustments", - column: "ReasonCodeId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_adjustments_WarehouseId", - table: "stock_adjustments", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_count_lines_BinId", - table: "stock_count_lines", - column: "BinId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_count_lines_CountId", - table: "stock_count_lines", - column: "CountId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_count_lines_ItemId", - table: "stock_count_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_counts_CreatedBy", - table: "stock_counts", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_stock_counts_DocNo", - table: "stock_counts", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_stock_counts_Status", - table: "stock_counts", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_stock_counts_WarehouseId", - table: "stock_counts", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_layers_BatchId", - table: "stock_layers", - column: "BatchId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_layers_GrnLineId", - table: "stock_layers", - column: "GrnLineId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_layers_ItemId_WarehouseId_ReceiptDate_LayerId", - table: "stock_layers", - columns: new[] { "ItemId", "WarehouseId", "ReceiptDate", "LayerId" }); - - migrationBuilder.CreateIndex( - name: "IX_stock_layers_SerialId", - table: "stock_layers", - column: "SerialId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_layers_WarehouseId", - table: "stock_layers", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_BatchId", - table: "stock_ledger", - column: "BatchId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_BinId", - table: "stock_ledger", - column: "BinId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_ItemId_WarehouseId_CreatedAt", - table: "stock_ledger", - columns: new[] { "ItemId", "WarehouseId", "CreatedAt" }); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_ItemId_WarehouseId_LedgerId", - table: "stock_ledger", - columns: new[] { "ItemId", "WarehouseId", "LedgerId" }); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_SerialId", - table: "stock_ledger", - column: "SerialId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_SourceDocType_SourceDocId", - table: "stock_ledger", - columns: new[] { "SourceDocType", "SourceDocId" }); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_UserId", - table: "stock_ledger", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_ledger_WarehouseId", - table: "stock_ledger", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_BatchId", - table: "stock_transfer_lines", - column: "BatchId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_DestBinId", - table: "stock_transfer_lines", - column: "DestBinId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_ItemId", - table: "stock_transfer_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_SerialId", - table: "stock_transfer_lines", - column: "SerialId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_SrcBinId", - table: "stock_transfer_lines", - column: "SrcBinId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfer_lines_TransferId", - table: "stock_transfer_lines", - column: "TransferId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfers_CreatedBy", - table: "stock_transfers", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfers_DestWarehouseId", - table: "stock_transfers", - column: "DestWarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfers_DocNo", - table: "stock_transfers", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfers_SrcWarehouseId", - table: "stock_transfers", - column: "SrcWarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_stock_transfers_Status", - table: "stock_transfers", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_FromUomId", - table: "uom_conversions", - column: "FromUomId"); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_ItemId_FromUomId_ToUomId", - table: "uom_conversions", - columns: new[] { "ItemId", "FromUomId", "ToUomId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_ToUomId", - table: "uom_conversions", - column: "ToUomId"); - - migrationBuilder.CreateIndex( - name: "IX_uoms_Name", - table: "uoms", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_users_auth_user_id", - table: "users", - column: "auth_user_id", - unique: true); - - 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"); - - migrationBuilder.CreateIndex( - name: "IX_vendors_Code", - table: "vendors", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_vendors_Status", - table: "vendors", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_warehouses_Code", - table: "warehouses", - column: "Code", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "audit_logs"); - - migrationBuilder.DropTable( - name: "item_reorders"); - - migrationBuilder.DropTable( - name: "journal_entry_stubs"); - - migrationBuilder.DropTable( - name: "number_sequences"); - - migrationBuilder.DropTable( - name: "purchase_return_lines"); - - migrationBuilder.DropTable( - name: "requisition_lines"); - - migrationBuilder.DropTable( - name: "rfq_lines"); - - migrationBuilder.DropTable( - name: "stock_adjustment_lines"); - - migrationBuilder.DropTable( - name: "stock_count_lines"); - - migrationBuilder.DropTable( - name: "stock_layers"); - - migrationBuilder.DropTable( - name: "stock_ledger"); - - migrationBuilder.DropTable( - name: "stock_transfer_lines"); - - migrationBuilder.DropTable( - name: "uom_conversions"); - - migrationBuilder.DropTable( - name: "vendor_quotation_lines"); - - migrationBuilder.DropTable( - name: "purchase_returns"); - - migrationBuilder.DropTable( - name: "stock_adjustments"); - - migrationBuilder.DropTable( - name: "stock_counts"); - - migrationBuilder.DropTable( - name: "grn_lines"); - - migrationBuilder.DropTable( - name: "serials"); - - migrationBuilder.DropTable( - name: "stock_transfers"); - - migrationBuilder.DropTable( - name: "vendor_quotations"); - - migrationBuilder.DropTable( - name: "reason_codes"); - - migrationBuilder.DropTable( - name: "batches"); - - migrationBuilder.DropTable( - name: "bins"); - - migrationBuilder.DropTable( - name: "grns"); - - migrationBuilder.DropTable( - name: "po_lines"); - - migrationBuilder.DropTable( - name: "rfqs"); - - migrationBuilder.DropTable( - name: "items"); - - migrationBuilder.DropTable( - name: "purchase_orders"); - - migrationBuilder.DropTable( - name: "warehouses"); - - migrationBuilder.DropTable( - name: "categories"); - - migrationBuilder.DropTable( - name: "uoms"); - - migrationBuilder.DropTable( - name: "requisitions"); - - migrationBuilder.DropTable( - name: "vendors"); - - migrationBuilder.DropTable( - name: "users"); - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs deleted file mode 100644 index 829393d..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs +++ /dev/null @@ -1,2454 +0,0 @@ -// -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("20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig")] - partial class AddBrandsSubcategoriesItemTypesAndProductConfig - { - /// - 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.AuditLog", b => - { - b.Property("AuditId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("ChangeSet") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EntityId") - .HasColumnType("integer"); - - b.Property("EntityType") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("AuditId"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.HasIndex("EntityType", "EntityId"); - - b.ToTable("audit_logs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.Property("BatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); - - b.Property("BatchNo") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ExpiryDate") - .HasColumnType("date"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.HasKey("BatchId"); - - b.HasIndex("ItemId", "BatchNo") - .IsUnique(); - - b.ToTable("batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => - { - b.Property("BrandId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("BrandId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("brands", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("CategoryId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Property("GrnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PoId") - .HasColumnType("integer"); - - b.Property("PostedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("GrnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("PoId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("grns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.Property("GrnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("GrnId") - .HasColumnType("integer"); - - b.Property("HoldStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoLineId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceivedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("GrnLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("GrnId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoLineId"); - - b.HasIndex("UomId"); - - b.ToTable("grn_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("integer"); - - b.Property("BrandId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("integer"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("StockNature") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubCategoryId") - .HasColumnType("integer"); - - 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("BrandId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("SubCategoryId"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => - { - b.Property("ItemTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemTypeId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("item_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => - { - b.Property("JournalId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); - - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("CreditAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("DebitAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.HasKey("JournalId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.ToTable("journal_entry_stubs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("integer") - .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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoId") - .HasColumnType("integer"); - - 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("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - 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.ProductConfig", b => - { - b.Property("ConfigId") - .HasColumnType("integer"); - - b.Property("BrandsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("ItemTypesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SubcategoriesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("integer"); - - b.HasKey("ConfigId"); - - b.HasIndex("UpdatedBy"); - - b.ToTable("product_config", null, t => - { - t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Property("PoId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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.PurchaseReturn", b => - { - b.Property("ReturnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReturnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("purchase_returns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.Property("ReturnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnId") - .HasColumnType("integer"); - - b.HasKey("ReturnLineId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("ReturnId"); - - b.ToTable("purchase_return_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => - { - b.Property("ReasonCodeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Context") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("ReasonCodeId"); - - b.HasIndex("Context", "Code") - .IsUnique(); - - b.ToTable("reason_codes", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.Property("SerialId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SerialNo") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("SerialId"); - - b.HasIndex("ItemId", "SerialNo") - .IsUnique(); - - b.ToTable("serials", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Property("AdjustmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("AdjustmentId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_adjustments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.Property("AdjLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); - - b.Property("AdjustmentId") - .HasColumnType("integer"); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyDelta") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.HasKey("AdjLineId"); - - b.HasIndex("AdjustmentId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.ToTable("stock_adjustment_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Property("CountId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); - - b.Property("CountType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("CountId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_counts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.Property("CountLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CountId") - .HasColumnType("integer"); - - b.Property("CountedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SystemQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Variance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("CountLineId"); - - b.HasIndex("BinId"); - - b.HasIndex("CountId"); - - b.HasIndex("ItemId"); - - b.ToTable("stock_count_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.Property("LayerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyRemaining") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceiptDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LayerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("SerialId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); - - b.ToTable("stock_layers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.Property("LedgerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Direction") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("character varying(5)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyBase") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunningBalance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LedgerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("SerialId"); - - b.HasIndex("UserId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); - - b.HasIndex("ItemId", "WarehouseId", "LedgerId"); - - b.ToTable("stock_ledger", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Property("TransferId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DestWarehouseId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SrcWarehouseId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TransferId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DestWarehouseId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("SrcWarehouseId"); - - b.HasIndex("Status"); - - b.ToTable("stock_transfers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.Property("TransferLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("DestBinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SrcBinId") - .HasColumnType("integer"); - - b.Property("TransferId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.HasKey("TransferLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("DestBinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.HasIndex("SrcBinId"); - - b.HasIndex("TransferId"); - - b.ToTable("stock_transfer_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.Property("SubCategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("SubCategoryId"); - - b.HasIndex("Status"); - - b.HasIndex("CategoryId", "Name") - .IsUnique(); - - b.ToTable("subcategories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ToUomId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("AuthUserId") - .HasColumnType("uuid") - .HasColumnName("auth_user_id"); - - 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("AuthUserId") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.Property("VendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("integer"); - - 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("integer"); - - 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.AuditLog", b => - { - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - 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.Grn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany() - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") - .WithMany("Lines") - .HasForeignKey("GrnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") - .WithMany() - .HasForeignKey("PoLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("Bin"); - - b.Navigation("Grn"); - - b.Navigation("Item"); - - b.Navigation("PoLine"); - - b.Navigation("Uom"); - }); - - 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.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId") - .OnDelete(DeleteBehavior.Restrict); - - 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.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") - .WithMany() - .HasForeignKey("SubCategoryId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Brand"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - - b.Navigation("SubCategory"); - }); - - 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.ProductConfig", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("UpdatedByUser"); - }); - - 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.PurchaseReturn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") - .WithMany("Lines") - .HasForeignKey("ReturnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Return"); - }); - - 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.Serial", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") - .WithMany("Lines") - .HasForeignKey("AdjustmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Adjustment"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") - .WithMany("Lines") - .HasForeignKey("CountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Count"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Serial"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", null) - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", null) - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") - .WithMany() - .HasForeignKey("DestWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") - .WithMany() - .HasForeignKey("SrcWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("DestWarehouse"); - - b.Navigation("SrcWarehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("DestBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("SrcBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") - .WithMany("Lines") - .HasForeignKey("TransferId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Transfer"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany("SubCategories") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Category"); - }); - - 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("SubCategories"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Navigation("Lines"); - }); - - 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.PurchaseReturn", 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.StockAdjustment", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Navigation("Lines"); - }); - - 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/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs deleted file mode 100644 index a7e0ac5..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs +++ /dev/null @@ -1,442 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - /// Adds the Brand / SubCategory / ItemType masters and the singleton product config, - /// and converts CATEGORY from a self-nesting tree into a fixed two-level - /// Category → SubCategory hierarchy (docs/10 Part C.1). - /// - /// This migration carries data, not just DDL. The scaffolded version dropped - /// categories.ParentId outright, which would have silently flattened every - /// child category into a root and left items pointing at what is now a top-level - /// category — losing the parent entirely. The hand-written steps below (marked - /// "data migration") move child categories into subcategories and repoint items - /// onto the correct (category, subcategory) pair before the column goes away. - /// - /// - public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // NOTE: the ParentId drop is deliberately deferred to the bottom of this method — - // the data migration reads it. Order here is load-bearing. - migrationBuilder.RenameColumn( - name: "ItemType", - table: "items", - newName: "StockNature"); - - migrationBuilder.AddColumn( - name: "BrandId", - table: "items", - type: "integer", - nullable: true); - - migrationBuilder.AddColumn( - name: "SubCategoryId", - table: "items", - type: "integer", - nullable: true); - - migrationBuilder.AddColumn( - name: "CreatedAt", - table: "categories", - type: "timestamp with time zone", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - - migrationBuilder.AddColumn( - name: "Status", - table: "categories", - type: "character varying(20)", - maxLength: 20, - nullable: false, - defaultValue: "Active"); - - migrationBuilder.AddColumn( - name: "UpdatedAt", - table: "categories", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "xmin", - table: "categories", - type: "xid", - rowVersion: true, - nullable: false, - defaultValue: 0u); - - migrationBuilder.CreateTable( - name: "brands", - columns: table => new - { - BrandId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_brands", x => x.BrandId); - }); - - migrationBuilder.CreateTable( - name: "item_types", - columns: table => new - { - ItemTypeId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_item_types", x => x.ItemTypeId); - }); - - migrationBuilder.CreateTable( - name: "product_config", - columns: table => new - { - ConfigId = table.Column(type: "integer", nullable: false), - SubcategoriesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), - BrandsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), - ItemTypesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - UpdatedBy = table.Column(type: "integer", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_product_config", x => x.ConfigId); - table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); - table.ForeignKey( - name: "FK_product_config_users_UpdatedBy", - column: x => x.UpdatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "subcategories", - columns: table => new - { - SubCategoryId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - CategoryId = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_subcategories", x => x.SubCategoryId); - table.ForeignKey( - name: "FK_subcategories_categories_CategoryId", - column: x => x.CategoryId, - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - }); - - // --------------------------------------------------------------------------- - // DATA MIGRATION — must run before ParentId is dropped. - // --------------------------------------------------------------------------- - - // Existing categories predate CreatedAt; the added column defaulted them to - // 0001-01-01. Stamp them with the migration time instead of a sentinel date. - migrationBuilder.Sql(@" - UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc'; - "); - - // Carry the old category id alongside each new subcategory so items can be - // repointed by join below. Dropped again once the repoint is done. - migrationBuilder.Sql(@" - ALTER TABLE subcategories ADD COLUMN legacy_category_id integer; - "); - - // Walk the old tree to its roots. The previous model allowed unlimited nesting, - // but the new one is exactly two levels — so a category at any depth below the - // root collapses into a subcategory of its ROOT ancestor (a grandchild cannot - // become a subcategory of its immediate parent, since that parent is itself - // ceasing to be a category). - migrationBuilder.Sql(@" - WITH RECURSIVE tree AS ( - SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id - FROM categories - WHERE ""ParentId"" IS NULL - UNION ALL - SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id - FROM categories c - JOIN tree t ON c.""ParentId"" = t.""CategoryId"" - ) - INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id) - SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId"" - FROM tree t - WHERE t.""ParentId"" IS NOT NULL; - "); - - // Repoint items: an item that pointed at a child category now carries the root - // category plus the subcategory it actually meant. - migrationBuilder.Sql(@" - UPDATE items i - SET ""SubCategoryId"" = s.""SubCategoryId"", - ""CategoryId"" = s.""CategoryId"" - FROM subcategories s - WHERE s.legacy_category_id = i.""CategoryId""; - "); - - // The self-FK must go before the delete, or RESTRICT rejects removing a parent - // whose own child row is still present. - migrationBuilder.DropForeignKey( - name: "FK_categories_categories_ParentId", - table: "categories"); - - // Every non-root category now lives in `subcategories`, and no item references - // one any more (repointed above), so the rows can go. - migrationBuilder.Sql(@" - DELETE FROM categories WHERE ""ParentId"" IS NOT NULL; - ALTER TABLE subcategories DROP COLUMN legacy_category_id; - "); - - migrationBuilder.DropIndex( - name: "IX_categories_ParentId", - table: "categories"); - - migrationBuilder.DropColumn( - name: "ParentId", - table: "categories"); - - // Seed the singleton config (FR-MD-11) — all features on. Item writes read this - // row, so it must exist before the app serves a single request. - migrationBuilder.Sql(@" - INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"") - VALUES (1, TRUE, TRUE, TRUE) - ON CONFLICT (""ConfigId"") DO NOTHING; - "); - - // --------------------------------------------------------------------------- - - migrationBuilder.CreateIndex( - name: "IX_items_BrandId", - table: "items", - column: "BrandId"); - - migrationBuilder.CreateIndex( - name: "IX_items_SubCategoryId", - table: "items", - column: "SubCategoryId"); - - migrationBuilder.CreateIndex( - name: "IX_categories_Name", - table: "categories", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_categories_Status", - table: "categories", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_brands_Name", - table: "brands", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_brands_Status", - table: "brands", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_item_types_Name", - table: "item_types", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_item_types_Status", - table: "item_types", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_product_config_UpdatedBy", - table: "product_config", - column: "UpdatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_subcategories_CategoryId_Name", - table: "subcategories", - columns: new[] { "CategoryId", "Name" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_subcategories_Status", - table: "subcategories", - column: "Status"); - - migrationBuilder.AddForeignKey( - name: "FK_items_brands_BrandId", - table: "items", - column: "BrandId", - principalTable: "brands", - principalColumn: "BrandId", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_items_subcategories_SubCategoryId", - table: "items", - column: "SubCategoryId", - principalTable: "subcategories", - principalColumn: "SubCategoryId", - onDelete: ReferentialAction.Restrict); - } - - /// - /// Reverses the schema change and puts the subcategory data back where it came from. - /// - /// The scaffolded version simply dropped subcategories, which would have - /// discarded exactly what preserved. Instead each subcategory is - /// restored as a child category and its items are repointed back onto it. This is - /// not perfectly lossless: the old tree's depth is gone (a former grandchild comes - /// back as a direct child of its root), and Brand data cannot survive a schema that - /// has nowhere to put it. - /// - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_items_brands_BrandId", - table: "items"); - - migrationBuilder.DropForeignKey( - name: "FK_items_subcategories_SubCategoryId", - table: "items"); - - // Restore the parent column + self-FK first so subcategories have somewhere to - // land, then move them back before the table is dropped. - migrationBuilder.AddColumn( - name: "ParentId", - table: "categories", - type: "integer", - nullable: true); - - // --------------------------------------------------------------------------- - // DATA MIGRATION (reverse) — must run before `subcategories` is dropped. - // --------------------------------------------------------------------------- - - migrationBuilder.Sql(@" - ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer; - "); - - // Each subcategory becomes a child category again under the same parent. - migrationBuilder.Sql(@" - INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id) - SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId"" - FROM subcategories s; - "); - - // Items that carried a subcategory point back at the restored child category. - migrationBuilder.Sql(@" - UPDATE items i - SET ""CategoryId"" = c.""CategoryId"" - FROM categories c - WHERE c.legacy_subcategory_id = i.""SubCategoryId""; - "); - - migrationBuilder.Sql(@" - ALTER TABLE categories DROP COLUMN legacy_subcategory_id; - "); - - // --------------------------------------------------------------------------- - - migrationBuilder.DropTable( - name: "brands"); - - migrationBuilder.DropTable( - name: "item_types"); - - migrationBuilder.DropTable( - name: "product_config"); - - migrationBuilder.DropTable( - name: "subcategories"); - - migrationBuilder.DropIndex( - name: "IX_items_BrandId", - table: "items"); - - migrationBuilder.DropIndex( - name: "IX_items_SubCategoryId", - table: "items"); - - migrationBuilder.DropIndex( - name: "IX_categories_Name", - table: "categories"); - - migrationBuilder.DropIndex( - name: "IX_categories_Status", - table: "categories"); - - migrationBuilder.DropColumn( - name: "BrandId", - table: "items"); - - migrationBuilder.DropColumn( - name: "SubCategoryId", - table: "items"); - - migrationBuilder.DropColumn( - name: "CreatedAt", - table: "categories"); - - migrationBuilder.DropColumn( - name: "Status", - table: "categories"); - - migrationBuilder.DropColumn( - name: "UpdatedAt", - table: "categories"); - - migrationBuilder.DropColumn( - name: "xmin", - table: "categories"); - - migrationBuilder.RenameColumn( - name: "StockNature", - table: "items", - newName: "ItemType"); - - // ParentId itself was re-added at the top of this method, ahead of the reverse - // data migration that populates it. - migrationBuilder.CreateIndex( - name: "IX_categories_ParentId", - table: "categories", - column: "ParentId"); - - migrationBuilder.AddForeignKey( - name: "FK_categories_categories_ParentId", - table: "categories", - column: "ParentId", - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs deleted file mode 100644 index 184ab18..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs +++ /dev/null @@ -1,2454 +0,0 @@ -// -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("20260718081219_ini2")] - partial class ini2 - { - /// - 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.AuditLog", b => - { - b.Property("AuditId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("ChangeSet") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EntityId") - .HasColumnType("integer"); - - b.Property("EntityType") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("AuditId"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.HasIndex("EntityType", "EntityId"); - - b.ToTable("audit_logs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.Property("BatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); - - b.Property("BatchNo") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ExpiryDate") - .HasColumnType("date"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.HasKey("BatchId"); - - b.HasIndex("ItemId", "BatchNo") - .IsUnique(); - - b.ToTable("batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => - { - b.Property("BrandId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("BrandId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("brands", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("CategoryId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Property("GrnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PoId") - .HasColumnType("integer"); - - b.Property("PostedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("GrnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("PoId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("grns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.Property("GrnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("GrnId") - .HasColumnType("integer"); - - b.Property("HoldStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoLineId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceivedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("GrnLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("GrnId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoLineId"); - - b.HasIndex("UomId"); - - b.ToTable("grn_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("integer"); - - b.Property("BrandId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("integer"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("StockNature") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubCategoryId") - .HasColumnType("integer"); - - 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("BrandId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("SubCategoryId"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => - { - b.Property("ItemTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemTypeId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("item_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => - { - b.Property("JournalId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); - - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("CreditAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("DebitAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.HasKey("JournalId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.ToTable("journal_entry_stubs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("integer") - .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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoId") - .HasColumnType("integer"); - - 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("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - 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.ProductConfig", b => - { - b.Property("ConfigId") - .HasColumnType("integer"); - - b.Property("BrandsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("ItemTypesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SubcategoriesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("integer"); - - b.HasKey("ConfigId"); - - b.HasIndex("UpdatedBy"); - - b.ToTable("product_config", null, t => - { - t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Property("PoId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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.PurchaseReturn", b => - { - b.Property("ReturnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReturnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("purchase_returns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.Property("ReturnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnId") - .HasColumnType("integer"); - - b.HasKey("ReturnLineId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("ReturnId"); - - b.ToTable("purchase_return_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => - { - b.Property("ReasonCodeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Context") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("ReasonCodeId"); - - b.HasIndex("Context", "Code") - .IsUnique(); - - b.ToTable("reason_codes", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.Property("SerialId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SerialNo") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("SerialId"); - - b.HasIndex("ItemId", "SerialNo") - .IsUnique(); - - b.ToTable("serials", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Property("AdjustmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("AdjustmentId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_adjustments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.Property("AdjLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); - - b.Property("AdjustmentId") - .HasColumnType("integer"); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyDelta") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.HasKey("AdjLineId"); - - b.HasIndex("AdjustmentId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.ToTable("stock_adjustment_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Property("CountId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); - - b.Property("CountType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("CountId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_counts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.Property("CountLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CountId") - .HasColumnType("integer"); - - b.Property("CountedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SystemQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Variance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("CountLineId"); - - b.HasIndex("BinId"); - - b.HasIndex("CountId"); - - b.HasIndex("ItemId"); - - b.ToTable("stock_count_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.Property("LayerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyRemaining") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceiptDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LayerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("SerialId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); - - b.ToTable("stock_layers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.Property("LedgerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Direction") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("character varying(5)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyBase") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunningBalance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LedgerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("SerialId"); - - b.HasIndex("UserId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); - - b.HasIndex("ItemId", "WarehouseId", "LedgerId"); - - b.ToTable("stock_ledger", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Property("TransferId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DestWarehouseId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SrcWarehouseId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TransferId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DestWarehouseId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("SrcWarehouseId"); - - b.HasIndex("Status"); - - b.ToTable("stock_transfers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.Property("TransferLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("DestBinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SrcBinId") - .HasColumnType("integer"); - - b.Property("TransferId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.HasKey("TransferLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("DestBinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.HasIndex("SrcBinId"); - - b.HasIndex("TransferId"); - - b.ToTable("stock_transfer_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.Property("SubCategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("SubCategoryId"); - - b.HasIndex("Status"); - - b.HasIndex("CategoryId", "Name") - .IsUnique(); - - b.ToTable("subcategories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ToUomId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("AuthUserId") - .HasColumnType("uuid") - .HasColumnName("auth_user_id"); - - 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("AuthUserId") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.Property("VendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("integer"); - - 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("integer"); - - 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.AuditLog", b => - { - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - 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.Grn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany() - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") - .WithMany("Lines") - .HasForeignKey("GrnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") - .WithMany() - .HasForeignKey("PoLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("Bin"); - - b.Navigation("Grn"); - - b.Navigation("Item"); - - b.Navigation("PoLine"); - - b.Navigation("Uom"); - }); - - 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.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId") - .OnDelete(DeleteBehavior.Restrict); - - 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.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") - .WithMany() - .HasForeignKey("SubCategoryId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Brand"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - - b.Navigation("SubCategory"); - }); - - 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.ProductConfig", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("UpdatedByUser"); - }); - - 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.PurchaseReturn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") - .WithMany("Lines") - .HasForeignKey("ReturnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Return"); - }); - - 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.Serial", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") - .WithMany("Lines") - .HasForeignKey("AdjustmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Adjustment"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") - .WithMany("Lines") - .HasForeignKey("CountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Count"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Serial"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", null) - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", null) - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") - .WithMany() - .HasForeignKey("DestWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") - .WithMany() - .HasForeignKey("SrcWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("DestWarehouse"); - - b.Navigation("SrcWarehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("DestBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("SrcBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") - .WithMany("Lines") - .HasForeignKey("TransferId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Transfer"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany("SubCategories") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Category"); - }); - - 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("SubCategories"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Navigation("Lines"); - }); - - 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.PurchaseReturn", 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.StockAdjustment", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Navigation("Lines"); - }); - - 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/20260718081219_ini2.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs deleted file mode 100644 index 4c89398..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class ini2 : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs deleted file mode 100644 index ca80bfc..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs +++ /dev/null @@ -1,3001 +0,0 @@ -// -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("20260718092655_AddRolesNavPermissions")] - partial class AddRolesNavPermissions - { - /// - 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.AuditLog", b => - { - b.Property("AuditId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("ChangeSet") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EntityId") - .HasColumnType("integer"); - - b.Property("EntityType") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("AuditId"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.HasIndex("EntityType", "EntityId"); - - b.ToTable("audit_logs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.Property("BatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); - - b.Property("BatchNo") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ExpiryDate") - .HasColumnType("date"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.HasKey("BatchId"); - - b.HasIndex("ItemId", "BatchNo") - .IsUnique(); - - b.ToTable("batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => - { - b.Property("BrandId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("BrandId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("brands", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("CategoryId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Property("GrnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PoId") - .HasColumnType("integer"); - - b.Property("PostedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("GrnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("PoId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("grns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.Property("GrnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("GrnId") - .HasColumnType("integer"); - - b.Property("HoldStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoLineId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceivedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("GrnLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("GrnId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoLineId"); - - b.HasIndex("UomId"); - - b.ToTable("grn_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("integer"); - - b.Property("BrandId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("integer"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("StockNature") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubCategoryId") - .HasColumnType("integer"); - - 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("BrandId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("SubCategoryId"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => - { - b.Property("ItemTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemTypeId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("item_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => - { - b.Property("JournalId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); - - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("CreditAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("DebitAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.HasKey("JournalId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.ToTable("journal_entry_stubs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => - { - b.Property("NavItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Href") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Icon") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.HasKey("NavItemId"); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("nav_items", (string)null); - - b.HasData( - new - { - NavItemId = 1, - Code = "dashboard", - Href = "/dashboard", - Label = "Dashboard", - SortOrder = 1, - Status = "Active" - }, - new - { - NavItemId = 2, - Code = "products", - Href = "/dashboard/products", - Label = "Products", - SortOrder = 2, - Status = "Active" - }, - new - { - NavItemId = 3, - Code = "vendors", - Href = "/dashboard/vendors", - Label = "Vendors", - SortOrder = 3, - Status = "Active" - }, - new - { - NavItemId = 4, - Code = "procurement", - Href = "/dashboard/procurement", - Label = "Procurement", - SortOrder = 4, - Status = "Active" - }, - new - { - NavItemId = 5, - Code = "receiving", - Href = "/dashboard/receiving/grn", - Label = "Receiving", - SortOrder = 5, - Status = "Active" - }, - new - { - NavItemId = 6, - Code = "stock", - Href = "/dashboard/stock", - Label = "Stock", - SortOrder = 6, - Status = "Active" - }, - new - { - NavItemId = 7, - Code = "warehouses", - Href = "/dashboard/warehouse", - Label = "Warehouses", - SortOrder = 7, - Status = "Active" - }, - new - { - NavItemId = 8, - Code = "orders", - Href = "/dashboard/orders", - Label = "Orders", - SortOrder = 8, - Status = "Active" - }, - new - { - NavItemId = 9, - Code = "settings", - Href = "/dashboard/settings", - Label = "Settings", - SortOrder = 9, - Status = "Active" - }, - new - { - NavItemId = 10, - Code = "help", - Href = "/dashboard/help", - Label = "Help", - SortOrder = 10, - Status = "Active" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("integer") - .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.Permission", b => - { - b.Property("PermissionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("NavItemId") - .HasColumnType("integer"); - - b.Property("SubNavItemId") - .HasColumnType("integer"); - - b.HasKey("PermissionId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("NavItemId"); - - b.HasIndex("SubNavItemId"); - - b.ToTable("permissions", (string)null); - - b.HasData( - new - { - PermissionId = 1, - Code = "NAV:dashboard", - NavItemId = 1 - }, - new - { - PermissionId = 2, - Code = "NAV:products", - NavItemId = 2 - }, - new - { - PermissionId = 3, - Code = "NAV:vendors", - NavItemId = 3 - }, - new - { - PermissionId = 4, - Code = "NAV:procurement", - NavItemId = 4 - }, - new - { - PermissionId = 5, - Code = "NAV:receiving", - NavItemId = 5 - }, - new - { - PermissionId = 6, - Code = "NAV:stock", - NavItemId = 6 - }, - new - { - PermissionId = 7, - Code = "NAV:warehouses", - NavItemId = 7 - }, - new - { - PermissionId = 8, - Code = "NAV:orders", - NavItemId = 8 - }, - new - { - PermissionId = 9, - Code = "NAV:settings", - NavItemId = 9 - }, - new - { - PermissionId = 10, - Code = "NAV:help", - NavItemId = 10 - }, - new - { - PermissionId = 11, - Code = "NAV:products.item", - SubNavItemId = 1 - }, - new - { - PermissionId = 12, - Code = "NAV:products.category", - SubNavItemId = 2 - }, - new - { - PermissionId = 13, - Code = "NAV:products.brand", - SubNavItemId = 3 - }, - new - { - PermissionId = 14, - Code = "NAV:products.item-type", - SubNavItemId = 4 - }, - new - { - PermissionId = 15, - Code = "NAV:products.uom", - SubNavItemId = 5 - }, - new - { - PermissionId = 16, - Code = "NAV:products.configuration", - SubNavItemId = 6 - }, - new - { - PermissionId = 17, - Code = "NAV:settings.roles", - SubNavItemId = 7 - }, - new - { - PermissionId = 18, - Code = "NAV:settings.users", - SubNavItemId = 8 - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => - { - b.Property("PoLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoId") - .HasColumnType("integer"); - - 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("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - 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.ProductConfig", b => - { - b.Property("ConfigId") - .HasColumnType("integer"); - - b.Property("BrandsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("ItemTypesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SubcategoriesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("integer"); - - b.HasKey("ConfigId"); - - b.HasIndex("UpdatedBy"); - - b.ToTable("product_config", null, t => - { - t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Property("PoId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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.PurchaseReturn", b => - { - b.Property("ReturnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReturnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("purchase_returns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.Property("ReturnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnId") - .HasColumnType("integer"); - - b.HasKey("ReturnLineId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("ReturnId"); - - b.ToTable("purchase_return_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => - { - b.Property("ReasonCodeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Context") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("ReasonCodeId"); - - b.HasIndex("Context", "Code") - .IsUnique(); - - b.ToTable("reason_codes", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => - { - b.Property("RoleId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); - - b.Property("AuthRoleId") - .HasColumnType("uuid") - .HasColumnName("auth_role_id"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsSystemRole") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("RoleId"); - - b.HasIndex("AuthRoleId") - .IsUnique(); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("roles", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => - { - b.Property("RoleId") - .HasColumnType("integer"); - - b.Property("PermissionId") - .HasColumnType("integer"); - - b.HasKey("RoleId", "PermissionId"); - - b.HasIndex("PermissionId"); - - b.ToTable("role_permissions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.Property("SerialId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SerialNo") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("SerialId"); - - b.HasIndex("ItemId", "SerialNo") - .IsUnique(); - - b.ToTable("serials", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Property("AdjustmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("AdjustmentId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_adjustments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.Property("AdjLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); - - b.Property("AdjustmentId") - .HasColumnType("integer"); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyDelta") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.HasKey("AdjLineId"); - - b.HasIndex("AdjustmentId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.ToTable("stock_adjustment_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Property("CountId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); - - b.Property("CountType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("CountId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_counts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.Property("CountLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CountId") - .HasColumnType("integer"); - - b.Property("CountedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SystemQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Variance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("CountLineId"); - - b.HasIndex("BinId"); - - b.HasIndex("CountId"); - - b.HasIndex("ItemId"); - - b.ToTable("stock_count_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.Property("LayerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyRemaining") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceiptDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LayerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("SerialId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); - - b.ToTable("stock_layers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.Property("LedgerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Direction") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("character varying(5)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyBase") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunningBalance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LedgerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("SerialId"); - - b.HasIndex("UserId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); - - b.HasIndex("ItemId", "WarehouseId", "LedgerId"); - - b.ToTable("stock_ledger", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Property("TransferId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DestWarehouseId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SrcWarehouseId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TransferId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DestWarehouseId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("SrcWarehouseId"); - - b.HasIndex("Status"); - - b.ToTable("stock_transfers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.Property("TransferLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("DestBinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SrcBinId") - .HasColumnType("integer"); - - b.Property("TransferId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.HasKey("TransferLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("DestBinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.HasIndex("SrcBinId"); - - b.HasIndex("TransferId"); - - b.ToTable("stock_transfer_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.Property("SubCategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("SubCategoryId"); - - b.HasIndex("Status"); - - b.HasIndex("CategoryId", "Name") - .IsUnique(); - - b.ToTable("subcategories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => - { - b.Property("SubNavItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Href") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Icon") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("NavItemId") - .HasColumnType("integer"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.HasKey("SubNavItemId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("NavItemId"); - - b.ToTable("sub_nav_items", (string)null); - - b.HasData( - new - { - SubNavItemId = 1, - Code = "products.item", - Href = "/dashboard/products", - Label = "Item", - NavItemId = 2, - SortOrder = 1, - Status = "Active" - }, - new - { - SubNavItemId = 2, - Code = "products.category", - Href = "/dashboard/products/categories", - Label = "Category", - NavItemId = 2, - SortOrder = 2, - Status = "Active" - }, - new - { - SubNavItemId = 3, - Code = "products.brand", - Href = "/dashboard/products/brands", - Label = "Brand", - NavItemId = 2, - SortOrder = 3, - Status = "Active" - }, - new - { - SubNavItemId = 4, - Code = "products.item-type", - Href = "/dashboard/products/item-types", - Label = "Item Type", - NavItemId = 2, - SortOrder = 4, - Status = "Active" - }, - new - { - SubNavItemId = 5, - Code = "products.uom", - Href = "/dashboard/products/uoms", - Label = "UOM", - NavItemId = 2, - SortOrder = 5, - Status = "Active" - }, - new - { - SubNavItemId = 6, - Code = "products.configuration", - Href = "/dashboard/products/settings", - Label = "Configuration", - NavItemId = 2, - SortOrder = 6, - Status = "Active" - }, - new - { - SubNavItemId = 7, - Code = "settings.roles", - Href = "/dashboard/settings/roles", - Label = "Roles", - NavItemId = 9, - SortOrder = 1, - Status = "Active" - }, - new - { - SubNavItemId = 8, - Code = "settings.users", - Href = "/dashboard/settings/users", - Label = "Users", - NavItemId = 9, - SortOrder = 2, - Status = "Active" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ToUomId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("AuthUserId") - .HasColumnType("uuid") - .HasColumnName("auth_user_id"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RoleId") - .HasColumnType("integer"); - - 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("AuthUserId") - .IsUnique(); - - b.HasIndex("RoleId"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.Property("VendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("integer"); - - 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("integer"); - - 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.AuditLog", b => - { - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - 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.Grn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany() - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") - .WithMany("Lines") - .HasForeignKey("GrnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") - .WithMany() - .HasForeignKey("PoLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("Bin"); - - b.Navigation("Grn"); - - b.Navigation("Item"); - - b.Navigation("PoLine"); - - b.Navigation("Uom"); - }); - - 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.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId") - .OnDelete(DeleteBehavior.Restrict); - - 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.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") - .WithMany() - .HasForeignKey("SubCategoryId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Brand"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - - b.Navigation("SubCategory"); - }); - - 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.Permission", b => - { - b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") - .WithMany() - .HasForeignKey("NavItemId") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") - .WithMany() - .HasForeignKey("SubNavItemId") - .OnDelete(DeleteBehavior.Cascade); - - b.Navigation("NavItem"); - - b.Navigation("SubNavItem"); - }); - - 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.ProductConfig", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("UpdatedByUser"); - }); - - 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.PurchaseReturn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") - .WithMany("Lines") - .HasForeignKey("ReturnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Return"); - }); - - 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.RolePermission", b => - { - b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") - .WithMany() - .HasForeignKey("PermissionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Role", "Role") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Permission"); - - b.Navigation("Role"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") - .WithMany("Lines") - .HasForeignKey("AdjustmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Adjustment"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") - .WithMany("Lines") - .HasForeignKey("CountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Count"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Serial"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", null) - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", null) - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") - .WithMany() - .HasForeignKey("DestWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") - .WithMany() - .HasForeignKey("SrcWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("DestWarehouse"); - - b.Navigation("SrcWarehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("DestBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("SrcBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") - .WithMany("Lines") - .HasForeignKey("TransferId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Transfer"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany("SubCategories") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => - { - b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") - .WithMany("Children") - .HasForeignKey("NavItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("NavItem"); - }); - - 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.User", b => - { - b.HasOne("ERPCore.Domain.Entities.Role", "Role") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Role"); - }); - - 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("SubCategories"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Navigation("ReorderSettings"); - - b.Navigation("UomConversions"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => - { - b.Navigation("Children"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", 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.StockAdjustment", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Navigation("Lines"); - }); - - 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/20260718092655_AddRolesNavPermissions.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs deleted file mode 100644 index 07ace7f..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class AddRolesNavPermissions : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "RoleId", - table: "users", - type: "integer", - nullable: true); - - migrationBuilder.CreateTable( - name: "nav_items", - columns: table => new - { - NavItemId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - SortOrder = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active") - }, - constraints: table => - { - table.PrimaryKey("PK_nav_items", x => x.NavItemId); - }); - - migrationBuilder.CreateTable( - name: "roles", - columns: table => new - { - RoleId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - auth_role_id = table.Column(type: "uuid", nullable: false), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - IsSystemRole = table.Column(type: "boolean", nullable: false, defaultValue: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_roles", x => x.RoleId); - }); - - migrationBuilder.CreateTable( - name: "sub_nav_items", - columns: table => new - { - SubNavItemId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - NavItemId = table.Column(type: "integer", nullable: false), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - SortOrder = table.Column(type: "integer", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active") - }, - constraints: table => - { - table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId); - table.ForeignKey( - name: "FK_sub_nav_items_nav_items_NavItemId", - column: x => x.NavItemId, - principalTable: "nav_items", - principalColumn: "NavItemId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "permissions", - columns: table => new - { - PermissionId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), - NavItemId = table.Column(type: "integer", nullable: true), - SubNavItemId = table.Column(type: "integer", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_permissions", x => x.PermissionId); - table.ForeignKey( - name: "FK_permissions_nav_items_NavItemId", - column: x => x.NavItemId, - principalTable: "nav_items", - principalColumn: "NavItemId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_permissions_sub_nav_items_SubNavItemId", - column: x => x.SubNavItemId, - principalTable: "sub_nav_items", - principalColumn: "SubNavItemId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "role_permissions", - columns: table => new - { - RoleId = table.Column(type: "integer", nullable: false), - PermissionId = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId }); - table.ForeignKey( - name: "FK_role_permissions_permissions_PermissionId", - column: x => x.PermissionId, - principalTable: "permissions", - principalColumn: "PermissionId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_role_permissions_roles_RoleId", - column: x => x.RoleId, - principalTable: "roles", - principalColumn: "RoleId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.InsertData( - table: "nav_items", - columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" }, - values: new object[,] - { - { 1, "dashboard", "/dashboard", null, "Dashboard", 1 }, - { 2, "products", "/dashboard/products", null, "Products", 2 }, - { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 }, - { 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 }, - { 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 }, - { 6, "stock", "/dashboard/stock", null, "Stock", 6 }, - { 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 }, - { 8, "orders", "/dashboard/orders", null, "Orders", 8 }, - { 9, "settings", "/dashboard/settings", null, "Settings", 9 }, - { 10, "help", "/dashboard/help", null, "Help", 10 } - }); - - migrationBuilder.UpdateData( - table: "users", - keyColumn: "UserId", - keyValue: 1, - column: "RoleId", - value: null); - - migrationBuilder.InsertData( - table: "permissions", - columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" }, - values: new object[,] - { - { 1, "NAV:dashboard", 1, null }, - { 2, "NAV:products", 2, null }, - { 3, "NAV:vendors", 3, null }, - { 4, "NAV:procurement", 4, null }, - { 5, "NAV:receiving", 5, null }, - { 6, "NAV:stock", 6, null }, - { 7, "NAV:warehouses", 7, null }, - { 8, "NAV:orders", 8, null }, - { 9, "NAV:settings", 9, null }, - { 10, "NAV:help", 10, null } - }); - - migrationBuilder.InsertData( - table: "sub_nav_items", - columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" }, - values: new object[,] - { - { 1, "products.item", "/dashboard/products", null, "Item", 2, 1 }, - { 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 }, - { 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 }, - { 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 }, - { 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 }, - { 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 }, - { 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 }, - { 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 } - }); - - migrationBuilder.InsertData( - table: "permissions", - columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" }, - values: new object[,] - { - { 11, "NAV:products.item", null, 1 }, - { 12, "NAV:products.category", null, 2 }, - { 13, "NAV:products.brand", null, 3 }, - { 14, "NAV:products.item-type", null, 4 }, - { 15, "NAV:products.uom", null, 5 }, - { 16, "NAV:products.configuration", null, 6 }, - { 17, "NAV:settings.roles", null, 7 }, - { 18, "NAV:settings.users", null, 8 } - }); - - migrationBuilder.CreateIndex( - name: "IX_users_RoleId", - table: "users", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "IX_nav_items_Code", - table: "nav_items", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_permissions_Code", - table: "permissions", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_permissions_NavItemId", - table: "permissions", - column: "NavItemId"); - - migrationBuilder.CreateIndex( - name: "IX_permissions_SubNavItemId", - table: "permissions", - column: "SubNavItemId"); - - migrationBuilder.CreateIndex( - name: "IX_role_permissions_PermissionId", - table: "role_permissions", - column: "PermissionId"); - - migrationBuilder.CreateIndex( - name: "IX_roles_auth_role_id", - table: "roles", - column: "auth_role_id", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_roles_Code", - table: "roles", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_sub_nav_items_Code", - table: "sub_nav_items", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_sub_nav_items_NavItemId", - table: "sub_nav_items", - column: "NavItemId"); - - migrationBuilder.AddForeignKey( - name: "FK_users_roles_RoleId", - table: "users", - column: "RoleId", - principalTable: "roles", - principalColumn: "RoleId", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_users_roles_RoleId", - table: "users"); - - migrationBuilder.DropTable( - name: "role_permissions"); - - migrationBuilder.DropTable( - name: "permissions"); - - migrationBuilder.DropTable( - name: "roles"); - - migrationBuilder.DropTable( - name: "sub_nav_items"); - - migrationBuilder.DropTable( - name: "nav_items"); - - migrationBuilder.DropIndex( - name: "IX_users_RoleId", - table: "users"); - - migrationBuilder.DropColumn( - name: "RoleId", - table: "users"); - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs deleted file mode 100644 index c484971..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ /dev/null @@ -1,5861 +0,0 @@ -// -using System; -using ERPCore.Infra.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - [DbContext(typeof(ErpDbContext))] - partial class ErpDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => - { - b.Property("AttendanceRecordId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId")); - - b.Property("AttendanceDate") - .HasColumnType("timestamp with time zone"); - - b.Property("AttendanceStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("AttendanceUploadBatchId") - .HasColumnType("integer"); - - b.Property("CheckIn") - .HasColumnType("interval"); - - b.Property("CheckOut") - .HasColumnType("interval"); - - b.Property("DuplicateOfAttendanceRecordId") - .HasColumnType("integer"); - - b.Property("EarlyLeaveMinutes") - .HasColumnType("integer"); - - b.Property("EditedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EditedBy") - .HasColumnType("integer"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("IsManualOverride") - .HasColumnType("boolean"); - - b.Property("LateMinutes") - .HasColumnType("integer"); - - b.Property("Notes") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("OvertimeMinutes") - .HasColumnType("integer"); - - b.Property("RowValidationStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("WorkShiftId") - .HasColumnType("integer"); - - b.Property("WorkingMinutes") - .HasColumnType("integer"); - - b.HasKey("AttendanceRecordId"); - - b.HasIndex("AttendanceUploadBatchId"); - - b.HasIndex("WorkShiftId"); - - b.HasIndex("EmployeeId", "AttendanceDate"); - - b.ToTable("hr_attendance_records", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b => - { - b.Property("AttendanceUploadBatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId")); - - b.Property("ConfirmedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ConfirmedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("OriginalFileName") - .HasMaxLength(260) - .HasColumnType("character varying(260)"); - - b.Property("PeriodEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("PeriodStart") - .HasColumnType("timestamp with time zone"); - - b.Property("RowCountDuplicate") - .HasColumnType("integer"); - - b.Property("RowCountError") - .HasColumnType("integer"); - - b.Property("RowCountTotal") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SourceType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UploadedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UploadedBy") - .HasColumnType("integer"); - - b.HasKey("AttendanceUploadBatchId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("PeriodStart", "PeriodEnd"); - - b.ToTable("hr_attendance_upload_batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => - { - b.Property("AuditId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("ChangeSet") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EntityId") - .HasColumnType("integer"); - - b.Property("EntityType") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("AuditId"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.HasIndex("EntityType", "EntityId"); - - b.ToTable("audit_logs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.Property("BatchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); - - b.Property("BatchNo") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ExpiryDate") - .HasColumnType("date"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.HasKey("BatchId"); - - b.HasIndex("ItemId", "BatchNo") - .IsUnique(); - - b.ToTable("batches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b => - { - b.Property("BranchId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId")); - - b.Property("Address") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("BranchId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_branches", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => - { - b.Property("BrandId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("BrandId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("brands", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("CategoryId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => - { - b.Property("DepartmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId")); - - b.Property("BranchId") - .HasColumnType("integer"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("HeadEmployeeId") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ParentDepartmentId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("DepartmentId"); - - b.HasIndex("BranchId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("HeadEmployeeId"); - - b.HasIndex("ParentDepartmentId"); - - b.HasIndex("Status"); - - b.ToTable("hr_departments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b => - { - b.Property("DesignationId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("DesignationId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_designations", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => - { - b.Property("EmployeeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId")); - - b.Property("AddressLine1") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("AddressLine2") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("BranchId") - .HasColumnType("integer"); - - b.Property("City") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ConfirmationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Country") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DateOfBirth") - .HasColumnType("timestamp with time zone"); - - b.Property("DepartmentId") - .HasColumnType("integer"); - - b.Property("DesignationId") - .HasColumnType("integer"); - - b.Property("Email") - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("EmergencyContactName") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("EmergencyContactPhone") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("EmergencyContactRelationship") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("EmployeeCode") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("EmploymentTypeId") - .HasColumnType("integer"); - - b.Property("EpfNumber") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("EtfNumber") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("FullName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Gender") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("HireDate") - .HasColumnType("timestamp with time zone"); - - b.Property("LastWorkingDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Nationality") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Nic") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PersonalMobile") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PostalCode") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ProfilePhotoPath") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("ReportingManagerId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxIdentificationNumber") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("WorkShiftId") - .HasColumnType("integer"); - - b.HasKey("EmployeeId"); - - b.HasIndex("BranchId"); - - b.HasIndex("DepartmentId"); - - b.HasIndex("DesignationId"); - - b.HasIndex("Email"); - - b.HasIndex("EmployeeCode") - .IsUnique(); - - b.HasIndex("EmploymentTypeId"); - - b.HasIndex("ReportingManagerId"); - - b.HasIndex("Status"); - - b.HasIndex("UserId") - .IsUnique(); - - b.HasIndex("WorkShiftId"); - - b.ToTable("hr_employees", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => - { - b.Property("EmployeeBankDetailId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeBankDetailId")); - - b.Property("AccountHolderName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("AccountNumber") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("BankName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("BranchName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("IsPrimary") - .HasColumnType("boolean"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("SwiftCode") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("EmployeeBankDetailId"); - - b.HasIndex("EmployeeId"); - - b.ToTable("hr_employee_bank_details", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => - { - b.Property("EmployeeDocumentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeDocumentId")); - - b.Property("ContentType") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("ExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("HrDocumentTypeId") - .HasColumnType("integer"); - - b.Property("IssueDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Notes") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("OriginalFileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("character varying(260)"); - - b.Property("RelativePath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SizeBytes") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("StoredFileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("character varying(260)"); - - b.Property("UploadedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UploadedBy") - .HasColumnType("integer"); - - b.Property("VerifiedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("VerifiedBy") - .HasColumnType("integer"); - - b.HasKey("EmployeeDocumentId"); - - b.HasIndex("EmployeeId"); - - b.HasIndex("ExpiryDate"); - - b.HasIndex("HrDocumentTypeId"); - - b.ToTable("hr_employee_documents", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => - { - b.Property("EmployeeLoanId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeLoanId")); - - b.Property("ApprovedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ApprovedBy") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("InstallmentAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("InterestRate") - .HasPrecision(6, 4) - .HasColumnType("numeric(6,4)"); - - b.Property("LoanKind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("NumberOfInstallments") - .HasColumnType("integer"); - - b.Property("OutstandingBalance") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("PrincipalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("StartMonth") - .HasColumnType("integer"); - - b.Property("StartYear") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("EmployeeLoanId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("EmployeeId"); - - b.HasIndex("Status"); - - b.ToTable("hr_employee_loans", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => - { - b.Property("EmployeeSalaryStructureId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureId")); - - b.Property("ApprovedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ApprovedBy") - .HasColumnType("integer"); - - b.Property("BasicSalary") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("Currency") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("character varying(3)"); - - b.Property("EffectiveFrom") - .HasColumnType("timestamp with time zone"); - - b.Property("EffectiveTo") - .HasColumnType("timestamp with time zone"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("EmployeeSalaryStructureId"); - - b.HasIndex("EmployeeId", "EffectiveTo"); - - b.ToTable("hr_employee_salary_structures", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => - { - b.Property("EmployeeSalaryStructureLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureLineId")); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("EmployeeSalaryStructureId") - .HasColumnType("integer"); - - b.Property("SalaryComponentId") - .HasColumnType("integer"); - - b.HasKey("EmployeeSalaryStructureLineId"); - - b.HasIndex("EmployeeSalaryStructureId"); - - b.HasIndex("SalaryComponentId"); - - b.ToTable("hr_employee_salary_structure_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => - { - b.Property("EmploymentTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmploymentTypeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("EmploymentTypeId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_employment_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Property("GrnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PoId") - .HasColumnType("integer"); - - b.Property("PostedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("GrnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("PoId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("grns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.Property("GrnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("DiscountPct") - .HasPrecision(9, 4) - .HasColumnType("numeric(9,4)"); - - b.Property("GrnId") - .HasColumnType("integer"); - - b.Property("HoldStatus") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LineTotal") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("NetUnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("PoLineId") - .HasColumnType("integer"); - - b.Property("PoUnitPrice") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceivedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.Property("VatAmount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("VatPct") - .HasPrecision(9, 4) - .HasColumnType("numeric(9,4)"); - - b.HasKey("GrnLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("GrnId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoLineId"); - - b.HasIndex("UomId"); - - b.ToTable("grn_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => - { - b.Property("HrDocumentTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HrDocumentTypeId")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiryTracked") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RequiredAtOnboarding") - .HasColumnType("boolean"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("HrDocumentTypeId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_document_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("integer"); - - b.Property("BrandId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("integer"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SalePrice") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - 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("StockNature") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SubCategoryId") - .HasColumnType("integer"); - - 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("BrandId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("SubCategoryId"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => - { - b.Property("ItemTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemTypeId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("item_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => - { - b.Property("JournalId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); - - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("CreditAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("DebitAccount") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.HasKey("JournalId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.ToTable("journal_entry_stubs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => - { - b.Property("LeaveBalanceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveBalanceId")); - - b.Property("AdjustmentDays") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("CarriedForwardDays") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("EntitledDays") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("LeaveTypeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("TakenDays") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Year") - .HasColumnType("integer"); - - b.HasKey("LeaveBalanceId"); - - b.HasIndex("LeaveTypeId"); - - b.HasIndex("EmployeeId", "LeaveTypeId", "Year") - .IsUnique(); - - b.ToTable("hr_leave_balances", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => - { - b.Property("LeaveRequestId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveRequestId")); - - b.Property("ApprovedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ApprovedBy") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DaysCount") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("LeaveTypeId") - .HasColumnType("integer"); - - b.Property("Reason") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("RejectionReason") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("StartDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("LeaveRequestId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("EmployeeId"); - - b.HasIndex("LeaveTypeId"); - - b.HasIndex("Status"); - - b.HasIndex("StartDate", "EndDate"); - - b.ToTable("hr_leave_requests", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => - { - b.Property("LeaveTypeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveTypeId")); - - b.Property("AccrualPerYear") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("CarryForwardAllowed") - .HasColumnType("boolean"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CountsAsNoPay") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsPaid") - .HasColumnType("boolean"); - - b.Property("MaxCarryForwardDays") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RequiresApproval") - .HasColumnType("boolean"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("LeaveTypeId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_leave_types", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => - { - b.Property("LoanInstallmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LoanInstallmentId")); - - b.Property("DueMonth") - .HasColumnType("integer"); - - b.Property("DueYear") - .HasColumnType("integer"); - - b.Property("EmployeeLoanId") - .HasColumnType("integer"); - - b.Property("InstallmentNumber") - .HasColumnType("integer"); - - b.Property("PaidAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("PayrollRunId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("ScheduledAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("LoanInstallmentId"); - - b.HasIndex("EmployeeLoanId"); - - b.HasIndex("PayrollRunId"); - - b.HasIndex("DueYear", "DueMonth"); - - b.ToTable("hr_loan_installments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => - { - b.Property("NavItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Href") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Icon") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.HasKey("NavItemId"); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("nav_items", (string)null); - - b.HasData( - new - { - NavItemId = 1, - Code = "dashboard", - Href = "/dashboard", - Label = "Dashboard", - SortOrder = 1, - Status = "Active" - }, - new - { - NavItemId = 2, - Code = "products", - Href = "/dashboard/products", - Label = "Products", - SortOrder = 2, - Status = "Active" - }, - new - { - NavItemId = 3, - Code = "vendors", - Href = "/dashboard/vendors", - Label = "Vendors", - SortOrder = 3, - Status = "Active" - }, - new - { - NavItemId = 4, - Code = "procurement", - Href = "/dashboard/procurement", - Label = "Procurement", - SortOrder = 4, - Status = "Active" - }, - new - { - NavItemId = 5, - Code = "receiving", - Href = "/dashboard/receiving/grn", - Label = "Receiving", - SortOrder = 5, - Status = "Active" - }, - new - { - NavItemId = 6, - Code = "stock", - Href = "/dashboard/stock", - Label = "Stock", - SortOrder = 6, - Status = "Active" - }, - new - { - NavItemId = 7, - Code = "warehouses", - Href = "/dashboard/warehouse", - Label = "Warehouses", - SortOrder = 7, - Status = "Active" - }, - new - { - NavItemId = 8, - Code = "orders", - Href = "/dashboard/orders", - Label = "Orders", - SortOrder = 8, - Status = "Active" - }, - new - { - NavItemId = 9, - Code = "settings", - Href = "/dashboard/settings", - Label = "Settings", - SortOrder = 9, - Status = "Active" - }, - new - { - NavItemId = 10, - Code = "help", - Href = "/dashboard/help", - Label = "Help", - SortOrder = 10, - Status = "Active" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("integer") - .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.PayrollLine", b => - { - b.Property("PayrollLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineId")); - - b.Property("AbsentDays") - .HasColumnType("integer"); - - b.Property("BasicSalary") - .HasColumnType("numeric(18,2)"); - - b.Property("EmployeeId") - .HasColumnType("integer"); - - b.Property("EpfEmployeeAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("EpfEmployerAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("EtfEmployerAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("GrossSalary") - .HasColumnType("numeric(18,2)"); - - b.Property("LateDeductionAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("LateMinutesTotal") - .HasColumnType("integer"); - - b.Property("LeaveDays") - .HasColumnType("integer"); - - b.Property("LoanDeductionAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("NetSalary") - .HasColumnType("numeric(18,2)"); - - b.Property("NoPayAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("OtMinutesTotal") - .HasColumnType("integer"); - - b.Property("OtherDeductionsAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("OvertimeAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("PayrollRunId") - .HasColumnType("integer"); - - b.Property("PresentDays") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("TaxAmount") - .HasColumnType("numeric(18,2)"); - - b.Property("TotalAllowances") - .HasColumnType("numeric(18,2)"); - - b.Property("WorkingDays") - .HasColumnType("integer"); - - b.HasKey("PayrollLineId"); - - b.HasIndex("EmployeeId"); - - b.HasIndex("PayrollRunId", "EmployeeId") - .IsUnique(); - - b.ToTable("hr_payroll_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => - { - b.Property("PayrollLineComponentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineComponentId")); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("ComponentCategory") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PayrollLineId") - .HasColumnType("integer"); - - b.Property("SalaryComponentId") - .HasColumnType("integer"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.HasKey("PayrollLineComponentId"); - - b.HasIndex("PayrollLineId"); - - b.HasIndex("SalaryComponentId"); - - b.ToTable("hr_payroll_line_components", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => - { - b.Property("PayrollRunId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollRunId")); - - b.Property("ApprovedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ApprovedBy") - .HasColumnType("integer"); - - b.Property("BranchId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("GeneratedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("GeneratedBy") - .HasColumnType("integer"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LockedBy") - .HasColumnType("integer"); - - b.Property("PeriodMonth") - .HasColumnType("integer"); - - b.Property("PeriodYear") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UnlockReason") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("UnlockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UnlockedBy") - .HasColumnType("integer"); - - b.HasKey("PayrollRunId"); - - b.HasIndex("BranchId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); - - b.ToTable("hr_payroll_runs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => - { - b.Property("PayrollStatutorySettingId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollStatutorySettingId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("EffectiveFrom") - .HasColumnType("timestamp with time zone"); - - b.Property("EffectiveTo") - .HasColumnType("timestamp with time zone"); - - b.Property("EpfEmployeeRate") - .HasPrecision(6, 4) - .HasColumnType("numeric(6,4)"); - - b.Property("EpfEmployerRate") - .HasPrecision(6, 4) - .HasColumnType("numeric(6,4)"); - - b.Property("EtfEmployerRate") - .HasPrecision(6, 4) - .HasColumnType("numeric(6,4)"); - - b.Property("OtMultiplierDefault") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("PayrollStatutorySettingId"); - - b.HasIndex("EffectiveFrom"); - - b.ToTable("hr_payroll_statutory_settings", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => - { - b.Property("PayslipId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayslipId")); - - b.Property("GeneratedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("PayrollLineId") - .HasColumnType("integer"); - - b.Property("ReleasedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReleasedBy") - .HasColumnType("integer"); - - b.HasKey("PayslipId"); - - b.HasIndex("PayrollLineId") - .IsUnique(); - - b.ToTable("hr_payslips", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => - { - b.Property("PermissionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("character varying(80)"); - - b.Property("NavItemId") - .HasColumnType("integer"); - - b.Property("SubNavItemId") - .HasColumnType("integer"); - - b.HasKey("PermissionId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("NavItemId"); - - b.HasIndex("SubNavItemId"); - - b.ToTable("permissions", (string)null); - - b.HasData( - new - { - PermissionId = 1, - Code = "NAV:dashboard", - NavItemId = 1 - }, - new - { - PermissionId = 2, - Code = "NAV:products", - NavItemId = 2 - }, - new - { - PermissionId = 3, - Code = "NAV:vendors", - NavItemId = 3 - }, - new - { - PermissionId = 4, - Code = "NAV:procurement", - NavItemId = 4 - }, - new - { - PermissionId = 5, - Code = "NAV:receiving", - NavItemId = 5 - }, - new - { - PermissionId = 6, - Code = "NAV:stock", - NavItemId = 6 - }, - new - { - PermissionId = 7, - Code = "NAV:warehouses", - NavItemId = 7 - }, - new - { - PermissionId = 8, - Code = "NAV:orders", - NavItemId = 8 - }, - new - { - PermissionId = 9, - Code = "NAV:settings", - NavItemId = 9 - }, - new - { - PermissionId = 10, - Code = "NAV:help", - NavItemId = 10 - }, - new - { - PermissionId = 11, - Code = "NAV:products.item", - SubNavItemId = 1 - }, - new - { - PermissionId = 12, - Code = "NAV:products.category", - SubNavItemId = 2 - }, - new - { - PermissionId = 13, - Code = "NAV:products.brand", - SubNavItemId = 3 - }, - new - { - PermissionId = 14, - Code = "NAV:products.item-type", - SubNavItemId = 4 - }, - new - { - PermissionId = 15, - Code = "NAV:products.uom", - SubNavItemId = 5 - }, - new - { - PermissionId = 16, - Code = "NAV:products.configuration", - SubNavItemId = 6 - }, - new - { - PermissionId = 17, - Code = "NAV:settings.roles", - SubNavItemId = 7 - }, - new - { - PermissionId = 18, - Code = "NAV:settings.users", - SubNavItemId = 8 - }, - new - { - PermissionId = 19, - Code = "NAV:procurement.requisitions", - SubNavItemId = 9 - }, - new - { - PermissionId = 20, - Code = "NAV:procurement.rfqs", - SubNavItemId = 10 - }, - new - { - PermissionId = 21, - Code = "NAV:procurement.purchase-orders", - SubNavItemId = 11 - }, - new - { - PermissionId = 22, - Code = "NAV:procurement.purchase-returns", - SubNavItemId = 12 - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => - { - b.Property("PoLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PoId") - .HasColumnType("integer"); - - 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("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - 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.ProductConfig", b => - { - b.Property("ConfigId") - .HasColumnType("integer"); - - b.Property("BrandsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("ItemTypesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SubcategoriesEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("integer"); - - b.HasKey("ConfigId"); - - b.HasIndex("UpdatedBy"); - - b.ToTable("product_config", null, t => - { - t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => - { - b.Property("RunId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunId")); - - b.Property("CancelReasonCodeId") - .HasColumnType("integer"); - - b.Property("CompletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("OutputBinId") - .HasColumnType("integer"); - - b.Property("ReworkCount") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("ScaleFactor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TargetQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("TemplateId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("RunId"); - - b.HasIndex("CancelReasonCodeId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("OutputBinId"); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("TemplateId", "Status"); - - b.ToTable("production_runs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => - { - b.Property("TemplateId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TemplateId")); - - b.Property("Annotations") - .HasColumnType("jsonb"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TemplateId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("CreatedBy"); - - b.HasIndex("Status"); - - b.ToTable("production_templates", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Property("PoId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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.PurchaseReturn", b => - { - b.Property("ReturnId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("VendorId") - .HasColumnType("integer"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("ReturnId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("VendorId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("purchase_returns", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.Property("ReturnLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnId") - .HasColumnType("integer"); - - b.HasKey("ReturnLineId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("ReturnId"); - - b.ToTable("purchase_return_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => - { - b.Property("ReasonCodeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Context") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("ReasonCodeId"); - - b.HasIndex("Context", "Code") - .IsUnique(); - - b.ToTable("reason_codes", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("integer"); - - 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("integer"); - - 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("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => - { - b.Property("RoleId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); - - b.Property("AuthRoleId") - .HasColumnType("uuid") - .HasColumnName("auth_role_id"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsSystemRole") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("RoleId"); - - b.HasIndex("AuthRoleId") - .IsUnique(); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("roles", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => - { - b.Property("RoleId") - .HasColumnType("integer"); - - b.Property("PermissionId") - .HasColumnType("integer"); - - b.HasKey("RoleId", "PermissionId"); - - b.HasIndex("PermissionId"); - - b.ToTable("role_permissions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => - { - b.Property("RunEdgeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunEdgeId")); - - b.Property("ChildRunStageId") - .HasColumnType("integer"); - - b.Property("ParentRunStageId") - .HasColumnType("integer"); - - b.Property("RunId") - .HasColumnType("integer"); - - b.HasKey("RunEdgeId"); - - b.HasIndex("ChildRunStageId"); - - b.HasIndex("RunId"); - - b.HasIndex("ParentRunStageId", "ChildRunStageId") - .IsUnique(); - - b.ToTable("run_edges", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => - { - b.Property("RunStageId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunStageId")); - - b.Property("ActualEndAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ActualStartAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EstimatedMinutes") - .HasColumnType("integer"); - - b.Property("FieldDefs") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("FieldValues") - .HasColumnType("jsonb"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); - - b.Property("PosX") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("PosY") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RoleLabel") - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("RunId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TemplateStageId") - .HasColumnType("integer"); - - b.HasKey("RunStageId"); - - b.HasIndex("TemplateStageId"); - - b.HasIndex("RunId", "Status"); - - b.ToTable("run_stages", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => - { - b.Property("EventId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Note") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Payload") - .HasColumnType("jsonb"); - - b.Property("RunId") - .HasColumnType("integer"); - - b.Property("RunStageId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("EventId"); - - b.HasIndex("RunStageId"); - - b.HasIndex("UserId"); - - b.HasIndex("RunId", "EventId"); - - b.ToTable("run_stage_events", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => - { - b.Property("RunInputId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunInputId")); - - b.Property("ConsumedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ConsumedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("DeliveredQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("FromRunOutputId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("PlannedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReturnedValue") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunStageId") - .HasColumnType("integer"); - - b.Property("Source") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("RunInputId"); - - b.HasIndex("FromRunOutputId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RunStageId"); - - b.HasIndex("UomId"); - - b.ToTable("run_stage_inputs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => - { - b.Property("RunOutputId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunOutputId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); - - b.Property("PlannedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ProducedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunStageId") - .HasColumnType("integer"); - - b.Property("ScrapReasonCodeId") - .HasColumnType("integer"); - - b.Property("ScrappedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("TransferredQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("RunOutputId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RunStageId"); - - b.HasIndex("ScrapReasonCodeId"); - - b.HasIndex("UomId"); - - b.ToTable("run_stage_outputs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => - { - b.Property("SalaryComponentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalaryComponentId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ComponentType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEpfEtfApplicable") - .HasColumnType("boolean"); - - b.Property("IsTaxable") - .HasColumnType("boolean"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("SalaryComponentId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_salary_components", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.Property("SerialId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SerialNo") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("SerialId"); - - b.HasIndex("ItemId", "SerialNo") - .IsUnique(); - - b.ToTable("serials", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => - { - b.Property("EdgeId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EdgeId")); - - b.Property("ChildStageId") - .HasColumnType("integer"); - - b.Property("ParentStageId") - .HasColumnType("integer"); - - b.Property("TemplateId") - .HasColumnType("integer"); - - b.HasKey("EdgeId"); - - b.HasIndex("ChildStageId"); - - b.HasIndex("TemplateId"); - - b.HasIndex("ParentStageId", "ChildStageId") - .IsUnique(); - - b.ToTable("stage_edges", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => - { - b.Property("InputId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InputId")); - - b.Property("FromOutputId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyPerBatch") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Source") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("StageId") - .HasColumnType("integer"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("InputId"); - - b.HasIndex("FromOutputId"); - - b.HasIndex("ItemId"); - - b.HasIndex("StageId"); - - b.HasIndex("UomId"); - - b.ToTable("stage_inputs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => - { - b.Property("OutputId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("OutputId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); - - b.Property("QtyPerBatch") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("StageId") - .HasColumnType("integer"); - - b.Property("UomId") - .HasColumnType("integer"); - - b.HasKey("OutputId"); - - b.HasIndex("ItemId"); - - b.HasIndex("StageId"); - - b.HasIndex("UomId"); - - b.ToTable("stage_outputs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Property("AdjustmentId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("ReasonCodeId") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("AdjustmentId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("ReasonCodeId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_adjustments", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.Property("AdjLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); - - b.Property("AdjustmentId") - .HasColumnType("integer"); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyDelta") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.HasKey("AdjLineId"); - - b.HasIndex("AdjustmentId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.ToTable("stock_adjustment_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Property("CountId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); - - b.Property("CountType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("CountId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("WarehouseId"); - - b.ToTable("stock_counts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.Property("CountLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CountId") - .HasColumnType("integer"); - - b.Property("CountedQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("SystemQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Variance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("CountLineId"); - - b.HasIndex("BinId"); - - b.HasIndex("CountId"); - - b.HasIndex("ItemId"); - - b.ToTable("stock_count_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.Property("LayerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("GrnLineId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyRemaining") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReceiptDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LayerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("GrnLineId"); - - b.HasIndex("SerialId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); - - b.ToTable("stock_layers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.Property("LedgerId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("BinId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Direction") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("character varying(5)"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("QtyBase") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RunningBalance") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SourceDocId") - .HasColumnType("integer"); - - b.Property("SourceDocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("integer"); - - b.HasKey("LedgerId"); - - b.HasIndex("BatchId"); - - b.HasIndex("BinId"); - - b.HasIndex("SerialId"); - - b.HasIndex("UserId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("SourceDocType", "SourceDocId"); - - b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); - - b.HasIndex("ItemId", "WarehouseId", "LedgerId"); - - b.ToTable("stock_ledger", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Property("TransferId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("integer"); - - b.Property("DestWarehouseId") - .HasColumnType("integer"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("SrcWarehouseId") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("TransferId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DestWarehouseId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("SrcWarehouseId"); - - b.HasIndex("Status"); - - b.ToTable("stock_transfers", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.Property("TransferLineId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); - - b.Property("BatchId") - .HasColumnType("integer"); - - b.Property("DestBinId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("SerialId") - .HasColumnType("integer"); - - b.Property("SrcBinId") - .HasColumnType("integer"); - - b.Property("TransferId") - .HasColumnType("integer"); - - b.Property("UnitCost") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.HasKey("TransferLineId"); - - b.HasIndex("BatchId"); - - b.HasIndex("DestBinId"); - - b.HasIndex("ItemId"); - - b.HasIndex("SerialId"); - - b.HasIndex("SrcBinId"); - - b.HasIndex("TransferId"); - - b.ToTable("stock_transfer_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.Property("SubCategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - 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("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("SubCategoryId"); - - b.HasIndex("Status"); - - b.HasIndex("CategoryId", "Name") - .IsUnique(); - - b.ToTable("subcategories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => - { - b.Property("SubNavItemId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Href") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Icon") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("NavItemId") - .HasColumnType("integer"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.HasKey("SubNavItemId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("NavItemId"); - - b.ToTable("sub_nav_items", (string)null); - - b.HasData( - new - { - SubNavItemId = 1, - Code = "products.item", - Href = "/dashboard/products", - Label = "Item", - NavItemId = 2, - SortOrder = 1, - Status = "Active" - }, - new - { - SubNavItemId = 2, - Code = "products.category", - Href = "/dashboard/products/categories", - Label = "Category", - NavItemId = 2, - SortOrder = 2, - Status = "Active" - }, - new - { - SubNavItemId = 3, - Code = "products.brand", - Href = "/dashboard/products/brands", - Label = "Brand", - NavItemId = 2, - SortOrder = 3, - Status = "Active" - }, - new - { - SubNavItemId = 4, - Code = "products.item-type", - Href = "/dashboard/products/item-types", - Label = "Item Type", - NavItemId = 2, - SortOrder = 4, - Status = "Active" - }, - new - { - SubNavItemId = 5, - Code = "products.uom", - Href = "/dashboard/products/uoms", - Label = "UOM", - NavItemId = 2, - SortOrder = 5, - Status = "Active" - }, - new - { - SubNavItemId = 6, - Code = "products.configuration", - Href = "/dashboard/products/settings", - Label = "Configuration", - NavItemId = 2, - SortOrder = 6, - Status = "Active" - }, - new - { - SubNavItemId = 7, - Code = "settings.roles", - Href = "/dashboard/settings/roles", - Label = "Roles", - NavItemId = 9, - SortOrder = 1, - Status = "Active" - }, - new - { - SubNavItemId = 8, - Code = "settings.users", - Href = "/dashboard/settings/users", - Label = "Users", - NavItemId = 9, - SortOrder = 2, - Status = "Active" - }, - new - { - SubNavItemId = 9, - Code = "procurement.requisitions", - Href = "/dashboard/procurement/requisitions", - Label = "Requisitions", - NavItemId = 4, - SortOrder = 1, - Status = "Active" - }, - new - { - SubNavItemId = 10, - Code = "procurement.rfqs", - Href = "/dashboard/procurement/rfqs", - Label = "RFQs", - NavItemId = 4, - SortOrder = 2, - Status = "Active" - }, - new - { - SubNavItemId = 11, - Code = "procurement.purchase-orders", - Href = "/dashboard/procurement/purchase-orders", - Label = "Purchase Orders", - NavItemId = 4, - SortOrder = 3, - Status = "Active" - }, - new - { - SubNavItemId = 12, - Code = "procurement.purchase-returns", - Href = "/dashboard/procurement/purchase-returns", - Label = "Purchase Returns", - NavItemId = 4, - SortOrder = 4, - Status = "Active" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => - { - b.Property("TaxSlabId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TaxSlabId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EffectiveFrom") - .HasColumnType("timestamp with time zone"); - - b.Property("EffectiveTo") - .HasColumnType("timestamp with time zone"); - - b.Property("LowerBound") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("Rate") - .HasPrecision(6, 4) - .HasColumnType("numeric(6,4)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("UpperBound") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("TaxSlabId"); - - b.HasIndex("EffectiveFrom"); - - b.ToTable("hr_tax_slabs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => - { - b.Property("StageId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("StageId")); - - b.Property("EstimatedMinutes") - .HasColumnType("integer"); - - b.Property("FieldDefs") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); - - b.Property("PosX") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("PosY") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RoleLabel") - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("TemplateId") - .HasColumnType("integer"); - - b.HasKey("StageId"); - - b.HasIndex("TemplateId"); - - b.ToTable("template_stages", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("integer"); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("ToUomId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("AuthUserId") - .HasColumnType("uuid") - .HasColumnName("auth_user_id"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Email") - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("RoleId") - .HasColumnType("integer"); - - 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("AuthUserId") - .IsUnique(); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("RoleId"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("integer"); - - b.Property("VendorId") - .HasColumnType("integer"); - - 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("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("integer"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("integer"); - - 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("integer"); - - 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.WorkShift", b => - { - b.Property("WorkShiftId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkShiftId")); - - b.Property("BreakMinutes") - .HasColumnType("integer"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EndTime") - .HasColumnType("interval"); - - b.Property("GraceMinutes") - .HasColumnType("integer"); - - b.Property("IsOvernight") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("OtMultiplier") - .HasPrecision(6, 2) - .HasColumnType("numeric(6,2)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("StandardWorkingMinutes") - .HasColumnType("integer"); - - b.Property("StartTime") - .HasColumnType("interval"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WorkingDaysMask") - .HasColumnType("integer"); - - b.HasKey("WorkShiftId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("hr_work_shifts", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => - { - b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") - .WithMany() - .HasForeignKey("AttendanceUploadBatchId") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") - .WithMany() - .HasForeignKey("WorkShiftId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("AttendanceUploadBatch"); - - b.Navigation("Employee"); - - b.Navigation("WorkShift"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => - { - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - 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.Department", b => - { - b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") - .WithMany() - .HasForeignKey("BranchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") - .WithMany() - .HasForeignKey("HeadEmployeeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") - .WithMany() - .HasForeignKey("ParentDepartmentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Branch"); - - b.Navigation("HeadEmployee"); - - b.Navigation("ParentDepartment"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => - { - b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") - .WithMany() - .HasForeignKey("BranchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Department", "Department") - .WithMany() - .HasForeignKey("DepartmentId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") - .WithMany() - .HasForeignKey("DesignationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") - .WithMany() - .HasForeignKey("EmploymentTypeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") - .WithMany() - .HasForeignKey("ReportingManagerId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", "User") - .WithOne() - .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") - .WithMany() - .HasForeignKey("WorkShiftId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Branch"); - - b.Navigation("Department"); - - b.Navigation("Designation"); - - b.Navigation("EmploymentType"); - - b.Navigation("ReportingManager"); - - b.Navigation("User"); - - b.Navigation("WorkShift"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Employee"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") - .WithMany() - .HasForeignKey("HrDocumentTypeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Employee"); - - b.Navigation("HrDocumentType"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Employee"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Employee"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => - { - b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") - .WithMany("Lines") - .HasForeignKey("EmployeeSalaryStructureId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") - .WithMany() - .HasForeignKey("SalaryComponentId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("EmployeeSalaryStructure"); - - b.Navigation("SalaryComponent"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany() - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") - .WithMany("Lines") - .HasForeignKey("GrnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") - .WithMany() - .HasForeignKey("PoLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("Bin"); - - b.Navigation("Grn"); - - b.Navigation("Item"); - - b.Navigation("PoLine"); - - b.Navigation("Uom"); - }); - - 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.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId") - .OnDelete(DeleteBehavior.Restrict); - - 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.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") - .WithMany() - .HasForeignKey("SubCategoryId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Brand"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - - b.Navigation("SubCategory"); - }); - - 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.LeaveBalance", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") - .WithMany() - .HasForeignKey("LeaveTypeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Employee"); - - b.Navigation("LeaveType"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") - .WithMany() - .HasForeignKey("LeaveTypeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Employee"); - - b.Navigation("LeaveType"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => - { - b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") - .WithMany("Installments") - .HasForeignKey("EmployeeLoanId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") - .WithMany() - .HasForeignKey("PayrollRunId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("EmployeeLoan"); - - b.Navigation("PayrollRun"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") - .WithMany() - .HasForeignKey("EmployeeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") - .WithMany("Lines") - .HasForeignKey("PayrollRunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Employee"); - - b.Navigation("PayrollRun"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => - { - b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") - .WithMany("Components") - .HasForeignKey("PayrollLineId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") - .WithMany() - .HasForeignKey("SalaryComponentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("PayrollLine"); - - b.Navigation("SalaryComponent"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => - { - b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") - .WithMany() - .HasForeignKey("BranchId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Branch"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => - { - b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") - .WithOne() - .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("PayrollLine"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => - { - b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") - .WithMany() - .HasForeignKey("NavItemId") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") - .WithMany() - .HasForeignKey("SubNavItemId") - .OnDelete(DeleteBehavior.Cascade); - - b.Navigation("NavItem"); - - b.Navigation("SubNavItem"); - }); - - 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.ProductConfig", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("UpdatedByUser"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => - { - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason") - .WithMany() - .HasForeignKey("CancelReasonCodeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin") - .WithMany() - .HasForeignKey("OutputBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") - .WithMany("Runs") - .HasForeignKey("TemplateId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("CancelReason"); - - b.Navigation("Creator"); - - b.Navigation("OutputBin"); - - b.Navigation("Template"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - }); - - 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.PurchaseReturn", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Vendor"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => - { - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") - .WithMany("Lines") - .HasForeignKey("ReturnId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Return"); - }); - - 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.RolePermission", b => - { - b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") - .WithMany() - .HasForeignKey("PermissionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Role", "Role") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Permission"); - - b.Navigation("Role"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => - { - b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage") - .WithMany() - .HasForeignKey("ChildRunStageId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage") - .WithMany() - .HasForeignKey("ParentRunStageId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") - .WithMany("Edges") - .HasForeignKey("RunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ChildRunStage"); - - b.Navigation("ParentRunStage"); - - b.Navigation("Run"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => - { - b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") - .WithMany("Stages") - .HasForeignKey("RunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage") - .WithMany() - .HasForeignKey("TemplateStageId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Run"); - - b.Navigation("TemplateStage"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => - { - b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") - .WithMany("Events") - .HasForeignKey("RunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") - .WithMany("Events") - .HasForeignKey("RunStageId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("ERPCore.Domain.Entities.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Run"); - - b.Navigation("RunStage"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => - { - b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput") - .WithMany() - .HasForeignKey("FromRunOutputId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") - .WithMany("Inputs") - .HasForeignKey("RunStageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("FromRunOutput"); - - b.Navigation("Item"); - - b.Navigation("RunStage"); - - b.Navigation("Uom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") - .WithMany("Outputs") - .HasForeignKey("RunStageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason") - .WithMany() - .HasForeignKey("ScrapReasonCodeId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("RunStage"); - - b.Navigation("ScrapReason"); - - b.Navigation("Uom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => - { - b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage") - .WithMany() - .HasForeignKey("ChildStageId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage") - .WithMany() - .HasForeignKey("ParentStageId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") - .WithMany("Edges") - .HasForeignKey("TemplateId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ChildStage"); - - b.Navigation("ParentStage"); - - b.Navigation("Template"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => - { - b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput") - .WithMany() - .HasForeignKey("FromOutputId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") - .WithMany("Inputs") - .HasForeignKey("StageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("FromOutput"); - - b.Navigation("Item"); - - b.Navigation("Stage"); - - b.Navigation("Uom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") - .WithMany("Outputs") - .HasForeignKey("StageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Stage"); - - b.Navigation("Uom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") - .WithMany() - .HasForeignKey("ReasonCodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("ReasonCode"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => - { - b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") - .WithMany("Lines") - .HasForeignKey("AdjustmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Adjustment"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") - .WithMany("Lines") - .HasForeignKey("CountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Count"); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") - .WithMany() - .HasForeignKey("GrnLineId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Batch"); - - b.Navigation("GrnLine"); - - b.Navigation("Item"); - - b.Navigation("Serial"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("BinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", null) - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", null) - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") - .WithMany() - .HasForeignKey("DestWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") - .WithMany() - .HasForeignKey("SrcWarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("DestWarehouse"); - - b.Navigation("SrcWarehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Batch", null) - .WithMany() - .HasForeignKey("BatchId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("DestBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Serial", null) - .WithMany() - .HasForeignKey("SerialId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Bin", null) - .WithMany() - .HasForeignKey("SrcBinId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") - .WithMany("Lines") - .HasForeignKey("TransferId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Transfer"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany("SubCategories") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => - { - b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") - .WithMany("Children") - .HasForeignKey("NavItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("NavItem"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => - { - b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") - .WithMany("Stages") - .HasForeignKey("TemplateId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Template"); - }); - - 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.User", b => - { - b.HasOne("ERPCore.Domain.Entities.Role", "Role") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Role"); - }); - - 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("SubCategories"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => - { - b.Navigation("Installments"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Navigation("ReorderSettings"); - - b.Navigation("UomConversions"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => - { - b.Navigation("Children"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => - { - b.Navigation("Components"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => - { - b.Navigation("Edges"); - - b.Navigation("Events"); - - b.Navigation("Stages"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => - { - b.Navigation("Edges"); - - b.Navigation("Runs"); - - b.Navigation("Stages"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", 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.RunStage", b => - { - b.Navigation("Events"); - - b.Navigation("Inputs"); - - b.Navigation("Outputs"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => - { - b.Navigation("Inputs"); - - b.Navigation("Outputs"); - }); - - 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/Program.cs b/Backend/ERPCore/Program.cs index 1a40914..cc51f3e 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -1,21 +1,24 @@ using System.Text.Json.Serialization; using ERPCore.Infra.Auth; using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Infra.Gl; using ERPCore.Infra.Persistence; using ERPCore.Infra.Storage; using ERPCore.Infra.UoW; using ERPCore.Repositories; using ERPCore.Repositories.Interfaces; using ERPCore.Services; +using ERPCore.Services.Interfaces; using ERPCore.Services.Auth; using ERPCore.Services.Hrm; -using ERPCore.Services.Interfaces; using ERPCore.Services.Production; using ERPCore.Services.Stock; using ERPCore.System.Errors; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.OpenApi; +using Npgsql; using Serilog; var builder = WebApplication.CreateBuilder(args); @@ -31,7 +34,10 @@ builder.Services.AddControllers() // EF Core + PostgreSQL builder.Services.AddDbContext(o => - o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +{ + o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); + o.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); +}); // ProblemDetails (RFC 7807) + domain-exception mapping builder.Services.AddProblemDetails(); @@ -51,6 +57,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md) +builder.Services.AddHttpClient(c => +{ + var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"] + ?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured."); + c.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddScoped(); + // Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation // JIT-provisions a local shadow user and injects the local `int` id as `nameid`. builder.Services.AddHttpContextAccessor(); @@ -62,11 +77,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); // Master-data services (docs/11 §2) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +//builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -87,6 +104,18 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Sales (Phase 1) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -161,7 +190,16 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - await DataSeeder.SeedAsync(db); + // await EnsureMigrationBaselineAsync(db); + await db.Database.MigrateAsync(); + try + { + await DataSeeder.SeedAsync(db); + } + catch (Exception ex) + { + throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex); + } } app.UseSerilogRequestLogging(); @@ -178,3 +216,4 @@ app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health"); app.Run(); + diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs new file mode 100644 index 0000000..072958e --- /dev/null +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -0,0 +1,250 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class BundleSaleService : IBundleSaleService +{ + private readonly IRepository _templates; + private readonly IRepository _bundles; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public BundleSaleService( + IRepository bundles, + IRepository templates, + IRepository customers, + IRepository items, + IRepository uoms, + IRepository warehouses, + IRepository users, + ISalesDomainService sales, + ISalesPostingService posting, + ICurrentUser currentUser, + INumberSequenceService numbers, + IUnitOfWork uow) + { + _templates = templates; + _bundles = bundles; + _customers = customers; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _users = users; + _sales = sales; + _posting = posting; + _currentUser = currentUser; + _numbers = numbers; + _uow = uow; + } + + public async Task> ListTemplatesAsync(PageQuery query, CancellationToken ct = default) + { + IQueryable q = _templates.Query().AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%")); + } + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + return PagedResponse.Create(rows.Select(x => new BundleSaleTemplateSummaryDto( + x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default) + { + var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct); + return template is null ? null : new BundleSaleTemplateDto( + template.BundleSaleTemplateId, + template.TemplateCode, + template.TemplateName, + template.Description, + template.Status, + template.CreatedAt, + template.UpdatedAt, + template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( + x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); + } + + public async Task> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _bundles.Query().AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%")); + } + if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); + if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetAsync(int bundleSaleId, CancellationToken ct = default) + { + var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct); + return bundle is null ? null : Map(bundle); + } + + public Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default) + => _posting.CheckBundleAsync(bundleSaleId, ct); + + public async Task CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default) + { + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); + var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); + var bundle = new BundleSale + { + BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct), + BundleDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + CashierUserId = request.CashierUserId, + BundleSaleTemplateId = request.BundleSaleTemplateId, + BundleName = request.BundleName, + BundleCode = string.Empty, + Status = BundleSaleStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct); + Recalculate(bundle, request.BundlePrice); + bundle.BundleCode = $"{bundle.BundleNo}-B"; + await _bundles.AddAsync(bundle, ct); + bundle.ConcurrencyStamp = 1; + await _uow.SaveChangesAsync(ct); + return Map(bundle); + } + + public async Task UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default) + { + var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); + if (bundle.Status != BundleSaleStatus.Draft) + throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited."); + + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); + var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); + bundle.CustomerId = request.CustomerId; + bundle.WarehouseId = request.WarehouseId; + bundle.CashierUserId = request.CashierUserId; + bundle.BundleSaleTemplateId = request.BundleSaleTemplateId; + bundle.BundleName = request.BundleName; + bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + bundle.Lines.Clear(); + foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line); + Recalculate(bundle, request.BundlePrice); + bundle.UpdatedAt = DateTime.UtcNow; + bundle.ConcurrencyStamp++; + await _uow.SaveChangesAsync(ct); + return Map(bundle); + } + + public async Task PostAsync(int bundleSaleId, CancellationToken ct = default) + { + await _posting.PostBundleAsync(bundleSaleId, ct); + var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.BundleSaleId == bundleSaleId, ct); + return Map(bundle); + } + + public async Task CancelAsync(int bundleSaleId, CancellationToken ct = default) + { + var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); + if (bundle.Status != BundleSaleStatus.Draft) + throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled."); + bundle.Status = BundleSaleStatus.Cancelled; + bundle.UpdatedAt = DateTime.UtcNow; + bundle.ConcurrencyStamp++; + await _uow.SaveChangesAsync(ct); + return Map(bundle); + } + + private async Task> BuildLinesAsync( + BundleSaleTemplate template, int warehouseId, IReadOnlyList requestLines, CancellationToken ct) + { + var lines = new List(); + var sourceLines = requestLines.Count > 0 + ? requestLines.OrderBy(x => x.SortOrder).ToList() + : template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest + { + ItemId = x.ItemId, + UomId = x.UomId, + WarehouseId = x.WarehouseId, + Qty = x.Qty, + UnitPrice = x.UnitPrice, + IncludeInBundle = x.IncludeInBundle, + SortOrder = x.SortOrder + }).ToList(); + + foreach (var r in sourceLines) + { + if (r.Qty <= 0) + throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422); + if (r.WarehouseId != warehouseId) + throw new DomainException(ErrorCodes.Validation, + $"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422); + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct); + var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); + lines.Add(new BundleSaleLine + { + ItemId = r.ItemId, + Description = item.Name, + Qty = r.Qty, + UomId = r.UomId, + WarehouseId = r.WarehouseId, + UnitPrice = resolved.UnitPrice, + LineTotal = calc.LineTotal, + IncludeInBundle = r.IncludeInBundle, + IsComponent = true, + ParentLineId = null + }); + } + return lines; + } + + private static void Recalculate(BundleSale bundle, decimal bundlePrice) + { + bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal); + bundle.BundlePrice = bundlePrice; + bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal; + bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice); + bundle.TaxTotal = 0m; + bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal; + } + + private static BundleSaleSummaryDto MapSummary(BundleSale x) => new( + x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt); + + private static BundleSaleDto Map(BundleSale x) => new( + x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId, + x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal, + x.CreatedAt, x.UpdatedAt, + x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList()); + +} diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs new file mode 100644 index 0000000..0f7c64e --- /dev/null +++ b/Backend/ERPCore/Services/CustomerService.cs @@ -0,0 +1,168 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Customers; +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 CustomerService : ICustomerService +{ + private readonly IRepository _customers; + private readonly IRepository _warehouses; + private readonly IUnitOfWork _uow; + + public CustomerService(IRepository customers, IRepository warehouses, IUnitOfWork uow) + { + _customers = customers; + _warehouses = warehouses; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default) + { + var q = _customers.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%") + || EF.Functions.ILike(c.CustomerCode, $"%{term}%") + || (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%"))); + } + if (status is not null) q = q.Where(c => c.Status == status); + if (customerType is not null) q = q.Where(c => c.CustomerType == customerType); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(c => c.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(c => new CustomerDto( + c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email, + c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo, + c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int customerId, CancellationToken ct = default) + { + var customer = await _customers.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.CustomerId == customerId, ct); + return customer is null ? null : new ETagged(Map(customer), customer.RowVersion); + } + + public async Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default) + { + var code = request.CustomerCode.Trim(); + var name = request.Name.Trim(); + + if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A customer code '{code}' already exists."); + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var email = request.Email.Trim(); + if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct)) + throw new ConflictException($"A customer with email '{email}' already exists."); + } + + if (request.DefaultWarehouseId is not null) + { + var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct); + if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found."); + } + + var customer = new Customer + { + CustomerCode = code, + CustomerType = request.CustomerType, + Name = name, + DisplayName = Normalize(request.DisplayName), + Phone = Normalize(request.Phone), + Email = Normalize(request.Email), + AddressLine1 = Normalize(request.AddressLine1), + AddressLine2 = Normalize(request.AddressLine2), + City = Normalize(request.City), + Country = Normalize(request.Country), + TaxRegistrationNo = Normalize(request.TaxRegistrationNo), + CreditLimit = request.CreditLimit, + CreditDays = request.CreditDays, + DefaultWarehouseId = request.DefaultWarehouseId, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _customers.AddAsync(customer, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(customer), customer.RowVersion); + } + + public async Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var customer = await _customers.GetByIdAsync(customerId, ct) + ?? throw new NotFoundException($"Customer {customerId} was not found."); + + if (customer.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412); + + var code = request.CustomerCode.Trim(); + var name = request.Name.Trim(); + + if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal) + && await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct)) + throw new ConflictException($"A customer code '{code}' already exists."); + + if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(request.Email) + && await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct)) + throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists."); + + if (request.DefaultWarehouseId is not null) + { + var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct); + if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found."); + } + + customer.CustomerCode = code; + customer.CustomerType = request.CustomerType; + customer.Name = name; + customer.DisplayName = Normalize(request.DisplayName); + customer.Phone = Normalize(request.Phone); + customer.Email = Normalize(request.Email); + customer.AddressLine1 = Normalize(request.AddressLine1); + customer.AddressLine2 = Normalize(request.AddressLine2); + customer.City = Normalize(request.City); + customer.Country = Normalize(request.Country); + customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo); + customer.CreditLimit = request.CreditLimit; + customer.CreditDays = request.CreditDays; + customer.DefaultWarehouseId = request.DefaultWarehouseId; + customer.UpdatedAt = DateTime.UtcNow; + + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(customer), customer.RowVersion); + } + + public async Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default) + { + var customer = await _customers.GetByIdAsync(customerId, ct) + ?? throw new NotFoundException($"Customer {customerId} was not found."); + + customer.Status = status; + customer.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static CustomerDto Map(Customer c) => new( + c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email, + c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo, + c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt); + + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/Backend/ERPCore/Services/GeneralLedgerService.cs b/Backend/ERPCore/Services/GeneralLedgerService.cs new file mode 100644 index 0000000..4b70e23 --- /dev/null +++ b/Backend/ERPCore/Services/GeneralLedgerService.cs @@ -0,0 +1,16 @@ +using ERPCore.Infra.Gl; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services; + +/// +public sealed class GeneralLedgerService : IGeneralLedgerService +{ + private readonly IGeneralLedgerClient _client; + + public GeneralLedgerService(IGeneralLedgerClient client) => _client = client; + + public Task ForwardAsync( + HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) + => _client.SendAsync(method, path, queryString, contentType, body, ct); +} diff --git a/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs new file mode 100644 index 0000000..b67ee2f --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs @@ -0,0 +1,18 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface IBundleSaleService +{ + Task> ListTemplatesAsync(PageQuery query, CancellationToken ct = default); + Task GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default); + Task> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int bundleSaleId, CancellationToken ct = default); + Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default); + Task CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default); + Task UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default); + Task PostAsync(int bundleSaleId, CancellationToken ct = default); + Task CancelAsync(int bundleSaleId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs new file mode 100644 index 0000000..47e837c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Customers; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +public interface ICustomerService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default); + Task?> GetAsync(int customerId, CancellationToken ct = default); + Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default); + Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs new file mode 100644 index 0000000..eeff07b --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs @@ -0,0 +1,16 @@ +using ERPCore.Infra.Gl; + +namespace ERPCore.Services.Interfaces; + +/// +/// Single entry point into the external General Ledger service — the one function +/// used both by (frontend +/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to +/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md). +/// No business logic lives here yet; this pass only connects the transport. +/// +public interface IGeneralLedgerService +{ + Task ForwardAsync( + HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs new file mode 100644 index 0000000..fc3a7b3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs @@ -0,0 +1,9 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesDocumentWorkflowService +{ + Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default); + Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs new file mode 100644 index 0000000..99372be --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesDomainService +{ + Task ValidateSalesHeaderAsync( + int customerId, + int warehouseId, + int? cashierUserId, + bool requireCashierUser, + CancellationToken ct = default); + + Task ValidateSalesLineAsync( + int headerWarehouseId, + int lineItemId, + int lineUomId, + int lineWarehouseId, + decimal qty, + decimal freeQty, + int? parentLineId, + CancellationToken ct = default); + + Task ResolveLinePriceAsync( + int itemId, + int warehouseId, + decimal? requestedUnitPrice, + bool allowManualOverride, + CancellationToken ct = default); + + SalesLineComputation ComputeLine( + decimal qty, + decimal freeQty, + decimal unitPrice, + SalesDiscountMode discountMode, + decimal discountPct, + decimal discountValue, + decimal discountAmount, + decimal taxPct, + bool isFreeIssue); + + Task IsStockedItemAsync(int itemId, CancellationToken ct = default); +} + +public sealed record SalesLineComputation( + decimal Gross, + decimal DiscountTotal, + decimal NetUnitPrice, + decimal LineTotal, + decimal TaxAmount); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs new file mode 100644 index 0000000..c54c918 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -0,0 +1,17 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesInvoiceService +{ + Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); + Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); + Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task PostAsync(int salesInvoiceId, CancellationToken ct = default); + Task CancelAsync(int salesInvoiceId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs new file mode 100644 index 0000000..6c09cf2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs @@ -0,0 +1,12 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesMappingService +{ + SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice); + SalesSlipTotalsDto MapSlipTotals(SalesSlip slip); + SalesInvoiceDto MapInvoice(SalesInvoice invoice); + SalesSlipDto MapSlip(SalesSlip slip); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs new file mode 100644 index 0000000..4c9cfa3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs @@ -0,0 +1,14 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesPostingService +{ + Task CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default); + Task CheckSlipAsync(int salesSlipId, CancellationToken ct = default); + Task CheckBundleAsync(int bundleSaleId, CancellationToken ct = default); + + Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default); + Task PostSlipAsync(int salesSlipId, CancellationToken ct = default); + Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs new file mode 100644 index 0000000..87617e4 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Services.Interfaces; + +public interface ISalesPricingService +{ + Task ResolveAsync( + int itemId, + int warehouseId, + decimal? requestedUnitPrice, + bool allowManualOverride, + CancellationToken ct = default); +} + +public sealed record SalesPriceResolution( + decimal UnitPrice, + string PriceSource, + decimal BaseCost); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs new file mode 100644 index 0000000..97158ec --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs @@ -0,0 +1,8 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesPromotionSuggestionService +{ + Task GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs new file mode 100644 index 0000000..32fa43d --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs @@ -0,0 +1,16 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesReportService +{ + IReadOnlyList ListReports(); + SalesReportDefinitionDto? GetReport(string reportId); + Task> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default); + Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + Task> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default); + Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default); + Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default); + Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs new file mode 100644 index 0000000..6411b0f --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs @@ -0,0 +1,19 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesSlipService +{ + Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetAsync(int salesSlipId, CancellationToken ct = default); + Task> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default); + Task CheckPostingAsync(int salesSlipId, CancellationToken ct = default); + Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task PostAsync(int salesSlipId, CancellationToken ct = default); + Task CancelAsync(int salesSlipId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs new file mode 100644 index 0000000..e3a8780 --- /dev/null +++ b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs @@ -0,0 +1,44 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + + public SalesDocumentWorkflowService(IRepository invoices, IRepository slips) + { + _invoices = invoices; + _slips = slips; + } + + public async Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited."); + return invoice; + } + + public async Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited."); + return slip; + } +} diff --git a/Backend/ERPCore/Services/SalesDomainService.cs b/Backend/ERPCore/Services/SalesDomainService.cs new file mode 100644 index 0000000..8fb1a93 --- /dev/null +++ b/Backend/ERPCore/Services/SalesDomainService.cs @@ -0,0 +1,110 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesDomainService : ISalesDomainService +{ + private readonly IRepository _customers; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly ISalesPricingService _pricing; + + public SalesDomainService( + IRepository customers, + IRepository warehouses, + IRepository users, + IRepository items, + IRepository uoms, + ISalesPricingService pricing) + { + _customers = customers; + _warehouses = warehouses; + _users = users; + _items = items; + _uoms = uoms; + _pricing = pricing; + } + + public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default) + { + if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) + throw new NotFoundException($"Customer {customerId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + if (requireCashierUser) + { + if (cashierUserId is null) + throw new DomainException(ErrorCodes.Validation, "Cashier user is required.", 422); + if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) + throw new NotFoundException($"User {cashierUserId} was not found."); + } + } + + public async Task ValidateSalesLineAsync( + int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default) + { + if (qty <= 0) + throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422); + if (freeQty < 0) + throw new DomainException(ErrorCodes.Validation, "Sales free quantity cannot be negative.", 422); + if (parentLineId is not null && parentLineId <= 0) + throw new DomainException(ErrorCodes.Validation, "Parent line id must be positive when supplied.", 422); + if (lineWarehouseId != headerWarehouseId) + throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422); + if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct)) + throw new NotFoundException($"Item {lineItemId} was not found."); + if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct)) + throw new NotFoundException($"UOM {lineUomId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct)) + throw new NotFoundException($"Warehouse {lineWarehouseId} was not found."); + } + + public Task ResolveLinePriceAsync(int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default) + => _pricing.ResolveAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct); + + public SalesLineComputation ComputeLine( + decimal qty, + decimal freeQty, + decimal unitPrice, + SalesDiscountMode discountMode, + decimal discountPct, + decimal discountValue, + decimal discountAmount, + decimal taxPct, + bool isFreeIssue) + { + var gross = qty * unitPrice; + var discountTotal = isFreeIssue + ? 0m + : CalculateDiscount(gross, discountMode, discountPct, discountValue, discountAmount); + var netUnit = qty > 0 ? (gross - discountTotal) / qty : 0m; + var lineTotal = gross - discountTotal; + var taxAmount = lineTotal * (taxPct / 100m); + return new SalesLineComputation(gross, discountTotal, netUnit, lineTotal, taxAmount); + } + + public async Task IsStockedItemAsync(int itemId, CancellationToken ct = default) + => await _items.Query().AsNoTracking() + .Where(x => x.ItemId == itemId) + .Select(x => x.StockNature == StockNature.Stocked) + .FirstAsync(ct); + + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) + { + var computed = mode == SalesDiscountMode.Amount + ? discountValue + : gross * (discountPct / 100m); + + if (computed <= 0m && legacyDiscountAmount > 0m) + computed = legacyDiscountAmount; + + return Math.Min(gross, Math.Max(0m, computed)); + } +} diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs new file mode 100644 index 0000000..61d9e0e --- /dev/null +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -0,0 +1,195 @@ +using ERPCore.Common.Http; +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesInvoiceService : ISalesInvoiceService +{ + private readonly IRepository _invoices; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; + private readonly ISalesMappingService _mapping; + private readonly ISalesDocumentWorkflowService _workflow; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public SalesInvoiceService( + IRepository invoices, IRepository customers, IRepository items, + IRepository uoms, IRepository warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping, + ISalesDocumentWorkflowService workflow, + ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) + { + _invoices = invoices; + _customers = customers; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _sales = sales; + _posting = posting; + _mapping = mapping; + _workflow = workflow; + _currentUser = currentUser; + _numbers = numbers; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.InvoiceNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%")); + } + if (status is not null) q = q.Where(x => x.Status == status); + if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); + if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + + return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); + return invoice is null ? null : new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); + } + + public Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default) + => _posting.CheckInvoiceAsync(salesInvoiceId, ct); + + public async Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default) + { + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); + var invoice = new SalesInvoice + { + InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct), + InvoiceDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + InvoiceType = request.InvoiceType, + Status = SalesInvoiceStatus.Draft, + CreatedBy = _currentUser.AuditUserId, + CreatedAt = DateTime.UtcNow + }; + invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct); + invoice.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct); + Recalculate(invoice); + + await _invoices.AddAsync(invoice, ct); + await _uow.SaveChangesAsync(ct); + return new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); + } + + public async Task> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct); + + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); + invoice.CustomerId = request.CustomerId; + invoice.WarehouseId = request.WarehouseId; + invoice.InvoiceType = request.InvoiceType; + invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct); + invoice.Lines.Clear(); + foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) invoice.Lines.Add(line); + Recalculate(invoice); + invoice.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); + } + + public async Task PostAsync(int salesInvoiceId, CancellationToken ct = default) + { + await _posting.PostInvoiceAsync(salesInvoiceId, ct); + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); + return _mapping.MapInvoice(invoice); + } + + public async Task CancelAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled."); + invoice.Status = SalesInvoiceStatus.Cancelled; + invoice.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return _mapping.MapInvoice(invoice); + } + + private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) + { + var lines = new List(); + foreach (var r in requests) + { + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; + + var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue); + + lines.Add(new SalesInvoiceLine + { + ItemId = r.ItemId, + Description = item.Name, + Qty = r.Qty, + FreeQty = r.FreeQty, + UomId = r.UomId, + WarehouseId = r.WarehouseId, + UnitPrice = unitPrice, + BaseCost = unitPrice, + PriceSource = priceSource, + DiscountPct = r.DiscountPct, + DiscountAmount = calc.DiscountTotal, + DiscountMode = r.DiscountMode, + NetUnitPrice = calc.NetUnitPrice, + LineTotal = calc.LineTotal, + TaxPct = r.TaxPct, + TaxAmount = calc.TaxAmount, + IsFreeIssue = r.IsFreeIssue, + ParentLineId = r.ParentLineId + }); + } + return lines; + } + + private static void Recalculate(SalesInvoice invoice) + { + invoice.Subtotal = invoice.Lines.Sum(x => x.Qty * x.UnitPrice); + invoice.DiscountTotal = invoice.Lines.Sum(x => x.DiscountAmount); + invoice.TaxTotal = invoice.Lines.Sum(x => x.TaxAmount); + invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal) + invoice.TaxTotal; + invoice.RoundOff = 0m; + invoice.NetPayable = invoice.GrandTotal + invoice.RoundOff; + invoice.PaidAmount = 0m; + invoice.BalanceAmount = invoice.NetPayable; + } + + private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( + x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, + x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt); +} diff --git a/Backend/ERPCore/Services/SalesMappingService.cs b/Backend/ERPCore/Services/SalesMappingService.cs new file mode 100644 index 0000000..4d60780 --- /dev/null +++ b/Backend/ERPCore/Services/SalesMappingService.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services; + +public sealed class SalesMappingService : ISalesMappingService +{ + public SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice) + => new( + invoice.Subtotal, + invoice.DiscountTotal, + invoice.Lines.Sum(l => l.FreeQty), + invoice.TaxTotal, + invoice.GrandTotal, + invoice.RoundOff, + invoice.NetPayable, + invoice.PaidAmount, + invoice.BalanceAmount); + + public SalesSlipTotalsDto MapSlipTotals(SalesSlip slip) + => new( + slip.Subtotal, + slip.DiscountTotal, + slip.Lines.Sum(l => l.FreeQty), + slip.TaxTotal, + slip.GrandTotal, + slip.PaidAmount, + slip.BalanceAmount); + + public SalesInvoiceDto MapInvoice(SalesInvoice invoice) + => new( + invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId, + invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, + invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt, + MapInvoiceTotals(invoice), + invoice.Lines.Select(l => new SalesInvoiceLineDto( + l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + + public SalesSlipDto MapSlip(SalesSlip slip) + => new( + slip.SalesSlipId, slip.SlipNo, slip.SlipDate, slip.CustomerId, slip.CustomerSnapshotName, + slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt, + MapSlipTotals(slip), + slip.Lines.Select(l => new SalesSlipLineDto( + l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); +} diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs new file mode 100644 index 0000000..556ab58 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -0,0 +1,214 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Sales; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPostingService : ISalesPostingService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + private readonly IRepository _bundles; + private readonly IRepository _items; + private readonly IFifoCostingService _fifo; + private readonly ISalesDomainService _sales; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public SalesPostingService( + IRepository invoices, + IRepository slips, + IRepository bundles, + IRepository items, + IFifoCostingService fifo, + ISalesDomainService sales, + ICurrentUser currentUser, + IUnitOfWork uow) + { + _invoices = invoices; + _slips = slips; + _bundles = bundles; + _items = items; + _fifo = fifo; + _sales = sales; + _currentUser = currentUser; + _uow = uow; + } + + public async Task CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + + if (invoice.Status != SalesInvoiceStatus.Draft) + return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in invoice.Lines) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var requestedQty = line.Qty + line.FreeQty; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= requestedQty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new SalesInvoicePostingIssueDto( + line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, + requestedQty, available, requestedQty - available, line.IsFreeIssue)); + } + + return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues); + } + + public async Task CheckSlipAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + + if (slip.Status != SalesSlipStatus.Draft) + return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in slip.Lines) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var requestedQty = line.Qty + line.FreeQty; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= requestedQty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new SalesSlipPostingIssueDto( + line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, + requestedQty, available, requestedQty - available, line.IsFreeIssue)); + } + + return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues); + } + + public async Task CheckBundleAsync(int bundleSaleId, CancellationToken ct = default) + { + var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); + + if (bundle.Status != BundleSaleStatus.Draft) + return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle)) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= line.Qty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available)); + } + + return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues); + } + + public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) + => PostAsync( + load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), + notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + setPosted: x => x.Status = SalesInvoiceStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(SalesInvoice), + getDocId: x => x.SalesInvoiceId, + ct: ct); + + public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default) + => PostAsync( + load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), + notFoundMessage: $"Sales slip {salesSlipId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + setPosted: x => x.Status = SalesSlipStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(SalesSlip), + getDocId: x => x.SalesSlipId, + ct: ct); + + public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default) + => PostAsync( + load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct), + notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)), + setPosted: x => x.Status = BundleSaleStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(BundleSale), + getDocId: x => x.BundleSaleId, + ct: ct); + + private async Task PostAsync( + Func> load, + string notFoundMessage, + Func statusSelector, + Func ensureDraftMessage, + Func> getLines, + Action setPosted, + Action setUpdated, + string sourceDocType, + Func getDocId, + CancellationToken ct) + where T : class + { + var doc = await load() ?? throw new NotFoundException(notFoundMessage); + var status = statusSelector(doc); + var statusValue = status?.ToString() ?? string.Empty; + if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal)) + throw new ConflictException(ensureDraftMessage(doc)); + + await _uow.ExecuteInTransactionAsync(async token => + { + foreach (var line in getLines(doc)) + { + if (line.Qty <= 0) continue; + if (!await _sales.IsStockedItemAsync(line.ItemId, token)) + continue; + + var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token); + var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); + await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, + Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token); + } + + setPosted(doc); + setUpdated(doc); + }, ct); + } + + private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty); +} diff --git a/Backend/ERPCore/Services/SalesPricingService.cs b/Backend/ERPCore/Services/SalesPricingService.cs new file mode 100644 index 0000000..b8d09b4 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPricingService.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPricingService : ISalesPricingService +{ + private readonly IRepository _items; + private readonly IRepository _grnLines; + private readonly IFifoCostingService _fifo; + + public SalesPricingService(IRepository items, IRepository grnLines, IFifoCostingService fifo) + { + _items = items; + _grnLines = grnLines; + _fifo = fifo; + } + + public async Task ResolveAsync( + int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default) + { + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct) + ?? throw new InvalidOperationException($"Item {itemId} was not found."); + + if (requestedUnitPrice is not null) + { + if (!allowManualOverride) + { + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (grnPrice is not null) + return new SalesPriceResolution(grnPrice.Value, "GRN_WEIGHTED_AVG", grnPrice.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value); + } + + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (weighted is not null) + return new SalesPriceResolution(weighted.Value, "GRN_WEIGHTED_AVG", weighted.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + private async Task GetWeightedGrnPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var rows = await _grnLines.Query().AsNoTracking() + .Where(l => l.ItemId == itemId + && l.Grn != null + && l.Grn.WarehouseId == warehouseId + && l.Grn.Status == GrnStatus.Confirmed) + .Select(l => new { l.Qty, l.ReceivedValue }) + .ToListAsync(ct); + + var totalQty = rows.Sum(x => x.Qty); + if (totalQty <= 0) return null; + + var totalValue = rows.Sum(x => x.ReceivedValue); + return totalValue / totalQty; + } + + private async Task GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct); + return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m; + } +} diff --git a/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs new file mode 100644 index 0000000..bdec4a0 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs @@ -0,0 +1,74 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Sales; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService +{ + private const decimal FreeIssueThreshold = 10m; + + private readonly IRepository _slips; + private readonly IRepository _items; + + public SalesPromotionSuggestionService(IRepository slips, IRepository items) + { + _slips = slips; + _items = items; + } + + public async Task GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); + + if (slip is null) return null; + + var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList(); + var candidateItems = await _items.Query().AsNoTracking() + .Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active) + .ToListAsync(ct); + + var byItemId = candidateItems.ToDictionary(x => x.ItemId); + var suggestions = new List(); + + foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold)) + { + if (!byItemId.TryGetValue(line.ItemId, out var item)) continue; + + var freeQty = Math.Floor(line.Qty / FreeIssueThreshold); + if (freeQty <= 0m) continue; + + var rewardOptions = new List + { + new(item.ItemId, item.Sku, item.Name, item.SalePrice) + }; + + var alternates = await _items.Query().AsNoTracking() + .Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId) + .OrderBy(x => x.Name) + .Take(3) + .Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice)) + .ToListAsync(ct); + + rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId))); + + suggestions.Add(new SalesFreeIssueSuggestionLineDto( + line.SalesSlipLineId, + item.ItemId, + item.Sku, + item.Name, + line.Qty, + freeQty, + FreeIssueThreshold, + rewardOptions)); + } + + return suggestions.Count == 0 + ? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty()) + : new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions); + } +} \ No newline at end of file diff --git a/Backend/ERPCore/Services/SalesReportService.cs b/Backend/ERPCore/Services/SalesReportService.cs new file mode 100644 index 0000000..e931fba --- /dev/null +++ b/Backend/ERPCore/Services/SalesReportService.cs @@ -0,0 +1,362 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Sales; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesReportService : ISalesReportService +{ + private static readonly SalesReportDefinitionDto[] ReportDefinitions = + [ + new("daily-summary", "Daily Summary", "Aggregated sales by day across posted invoices and slips.", ["from", "to"]), + new("item-summary", "Item Summary", "Aggregated sales by item across posted invoices and slips.", ["from", "to", "itemId", "warehouseId"]), + new("customer-summary", "Customer Summary", "Aggregated sales by customer across posted invoices and slips.", ["from", "to", "customerId"]), + new("warehouse-summary", "Warehouse Summary", "Aggregated sales by warehouse across posted invoices and slips.", ["from", "to", "warehouseId"]), + new("discount-summary", "Discount Summary", "Documents with discounts applied.", ["from", "to"]), + new("free-issue-summary", "Free Issue Summary", "Lines with free quantities issued.", ["from", "to"]) + ]; + + private readonly IRepository _invoices; + private readonly IRepository _slips; + + public SalesReportService(IRepository invoices, IRepository slips) + { + _invoices = invoices; + _slips = slips; + } + + public IReadOnlyList ListReports() => ReportDefinitions; + + public SalesReportDefinitionDto? GetReport(string reportId) + { + var normalized = reportId.Trim().ToLowerInvariant(); + return ReportDefinitions.FirstOrDefault(r => r.Id == normalized); + } + + public async Task> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default) + { + var normalized = reportType.Trim().ToLowerInvariant(); + ValidateFilters(normalized, itemId, customerId, warehouseId); + + return normalized switch + { + "daily" or "daily-summary" => (await DailySummaryAsync(from, to, ct)).Cast().ToList(), + "item" or "item-summary" or "item-wise" => (await ItemSummaryAsync(from, to, itemId, warehouseId, ct)).Cast().ToList(), + "customer" or "customer-summary" or "customer-wise" => (await CustomerSummaryAsync(from, to, customerId, ct)).Cast().ToList(), + "warehouse" or "warehouse-summary" or "warehouse-wise" => (await WarehouseSummaryAsync(from, to, warehouseId, ct)).Cast().ToList(), + "discount" or "discount-summary" => (await DiscountSummaryAsync(from, to, ct)).Cast().ToList(), + "free-issue" or "free-issue-summary" => (await FreeIssueSummaryAsync(from, to, ct)).Cast().ToList(), + _ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400) + }; + } + + private static void ValidateFilters(string reportType, int? itemId, int? customerId, int? warehouseId) + { + var allowed = reportType switch + { + "daily" or "daily-summary" => new FilterSet(false, false, false), + "item" or "item-summary" or "item-wise" => new FilterSet(true, false, true), + "customer" or "customer-summary" or "customer-wise" => new FilterSet(false, true, false), + "warehouse" or "warehouse-summary" or "warehouse-wise" => new FilterSet(false, false, true), + "discount" or "discount-summary" => new FilterSet(false, false, false), + "free-issue" or "free-issue-summary" => new FilterSet(false, false, false), + _ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400) + }; + + if (!allowed.Item && itemId is not null) + throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'itemId' is not valid for report type '{reportType}'.", 400); + if (!allowed.Customer && customerId is not null) + throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'customerId' is not valid for report type '{reportType}'.", 400); + if (!allowed.Warehouse && warehouseId is not null) + throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'warehouseId' is not valid for report type '{reportType}'.", 400); + } + + private readonly record struct FilterSet(bool Item, bool Customer, bool Warehouse); + + public async Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + var invoiceRows = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .GroupBy(x => DateOnly.FromDateTime(x.InvoiceDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = g.Count(), + SlipCount = 0, + InvoiceSubtotal = g.Sum(x => x.Subtotal), + SlipSubtotal = 0m, + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .GroupBy(x => DateOnly.FromDateTime(x.SlipDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = 0, + SlipCount = g.Count(), + InvoiceSubtotal = 0m, + SlipSubtotal = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => x.Date) + .OrderBy(x => x.Key) + .Select(g => new SalesDailySummaryRowDto( + g.Key, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.InvoiceSubtotal), + g.Sum(x => x.SlipSubtotal), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.FreeQtyTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.GrandTotal))) + .ToList(); + } + + public async Task> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default) + { + var invoiceRows = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .SelectMany(x => x.Lines.Select(l => new + { + l.ItemId, + l.Description, + l.Qty, + l.FreeQty, + Gross = l.Qty * l.UnitPrice, + l.DiscountAmount, + l.TaxAmount, + l.LineTotal, + x.WarehouseId + })) + .ToListAsync(ct); + + if (warehouseId is not null) + invoiceRows = invoiceRows.Where(x => x.WarehouseId == warehouseId).ToList(); + if (itemId is not null) + invoiceRows = invoiceRows.Where(x => x.ItemId == itemId).ToList(); + + var slipRows = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .SelectMany(x => x.Lines.Select(l => new + { + l.ItemId, + l.Description, + l.Qty, + l.FreeQty, + Gross = l.Qty * l.UnitPrice, + l.DiscountAmount, + l.TaxAmount, + l.LineTotal, + x.WarehouseId + })) + .ToListAsync(ct); + + if (warehouseId is not null) + slipRows = slipRows.Where(x => x.WarehouseId == warehouseId).ToList(); + if (itemId is not null) + slipRows = slipRows.Where(x => x.ItemId == itemId).ToList(); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.ItemId, x.Description }) + .OrderByDescending(g => g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount)) + .Select(g => new SalesItemSummaryRowDto( + g.Key.ItemId, + g.Key.Description, + g.Sum(x => x.Qty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.Gross), + g.Sum(x => x.DiscountAmount), + g.Sum(x => x.TaxAmount), + g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount))) + .ToList(); + } + + public async Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default) + { + var invoiceQuery = _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) invoiceQuery = invoiceQuery.Where(x => x.CustomerId == customerId); + + var slipQuery = _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) slipQuery = slipQuery.Where(x => x.CustomerId == customerId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.CustomerId, x.CustomerName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesCustomerSummaryRowDto( + g.Key.CustomerId, + g.Key.CustomerName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default) + { + var invoiceQuery = _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (warehouseId is not null) invoiceQuery = invoiceQuery.Where(x => x.WarehouseId == warehouseId); + + var slipQuery = _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (warehouseId is not null) slipQuery = slipQuery.Where(x => x.WarehouseId == warehouseId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.WarehouseId, x.WarehouseName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesWarehouseSummaryRowDto( + g.Key.WarehouseId, + g.Key.WarehouseName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + var invoices = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Invoice", x.InvoiceNo, x.InvoiceDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + var slips = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Slip", x.SlipNo, x.SlipDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + return invoices.Concat(slips).OrderByDescending(x => x.DiscountTotal).ToList(); + } + + public async Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + var invoiceRows = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .SelectMany(x => x.Lines.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Invoice", + x.InvoiceNo, + x.InvoiceDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + var slipRows = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)) + .SelectMany(x => x.Lines.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Slip", + x.SlipNo, + x.SlipDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows).OrderByDescending(x => x.FreeQty).ToList(); + } +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs new file mode 100644 index 0000000..9e8074d --- /dev/null +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -0,0 +1,270 @@ +using ERPCore.Common.Http; +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesSlipService : ISalesSlipService +{ + private readonly IRepository _slips; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; + private readonly ISalesMappingService _mapping; + private readonly ISalesDocumentWorkflowService _workflow; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public SalesSlipService( + IRepository slips, IRepository customers, IRepository items, + IRepository uoms, IRepository warehouses, IRepository users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping, + ISalesDocumentWorkflowService workflow, + ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) + { + _slips = slips; + _customers = customers; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _users = users; + _sales = sales; + _posting = posting; + _mapping = mapping; + _workflow = workflow; + _currentUser = currentUser; + _numbers = numbers; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _slips.Query().AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%")); + } + if (status is not null) q = q.Where(x => x.Status == status); + if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); + if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.SalesSlipId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); + return slip is null ? null : new ETagged(_mapping.MapSlip(slip), slip.RowVersion); + } + + public async Task> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _slips.Query().AsNoTracking().Include(x => x.Lines); + q = q.Where(x => x.Lines.Any(l => l.IsFreeIssue || l.FreeQty > 0m)); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%")); + } + if (status is not null) q = q.Where(x => x.Status == status); + if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); + if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.SalesSlipId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + return PagedResponse.Create(rows.Select(MapFreeIssueSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); + return slip is null ? null : new ETagged(MapFreeIssue(slip), slip.RowVersion); + } + + public Task CheckPostingAsync(int salesSlipId, CancellationToken ct = default) + => _posting.CheckSlipAsync(salesSlipId, ct); + + public async Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default) + { + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); + + var slip = new SalesSlip + { + SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct), + SlipDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + CashierUserId = request.CashierUserId, + Status = SalesSlipStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct); + Recalculate(slip); + + await _slips.AddAsync(slip, ct); + await _uow.SaveChangesAsync(ct); + return new ETagged(_mapping.MapSlip(slip), slip.RowVersion); + } + + public async Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, ct); + + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); + slip.CustomerId = request.CustomerId; + slip.WarehouseId = request.WarehouseId; + slip.CashierUserId = request.CashierUserId; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines.Clear(); + foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line); + Recalculate(slip); + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return new ETagged(_mapping.MapSlip(slip), slip.RowVersion); + } + + public async Task PostAsync(int salesSlipId, CancellationToken ct = default) + { + await _posting.PostSlipAsync(salesSlipId, ct); + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.SalesSlipId == salesSlipId, ct); + return _mapping.MapSlip(slip); + } + + public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); + slip.Status = SalesSlipStatus.Cancelled; + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return _mapping.MapSlip(slip); + } + + private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) + { + var lines = new List(); + foreach (var r in requests) + { + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; + + var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue); + + lines.Add(new SalesSlipLine + { + ItemId = r.ItemId, + Description = item.Name, + Qty = r.Qty, + FreeQty = r.FreeQty, + UomId = r.UomId, + WarehouseId = r.WarehouseId, + UnitPrice = unitPrice, + BaseCost = unitPrice, + PriceSource = priceSource, + DiscountMode = r.DiscountMode, + DiscountPct = r.DiscountPct, + DiscountAmount = calc.DiscountTotal, + NetUnitPrice = calc.NetUnitPrice, + LineTotal = calc.LineTotal, + TaxPct = r.TaxPct, + TaxAmount = calc.TaxAmount, + IsFreeIssue = r.IsFreeIssue, + ParentLineId = r.ParentLineId + }); + } + return lines; + } + + private static void Recalculate(SalesSlip slip) + { + slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice); + slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount); + slip.TaxTotal = slip.Lines.Sum(x => x.TaxAmount); + slip.GrandTotal = slip.Lines.Sum(x => x.LineTotal) + slip.TaxTotal; + slip.PaidAmount = 0m; + slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount; + } + + private SalesSlipSummaryDto MapSummary(SalesSlip x) => new( + x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status, + _mapping.MapSlipTotals(x), x.CreatedAt); + + private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x) + { + var line = x.Lines.FirstOrDefault(); + var item = line is null ? null : _items.Query().AsNoTracking() + .Where(i => i.ItemId == line.ItemId) + .Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId }) + .FirstOrDefault(); + var uom = line is null ? null : _uoms.Query().AsNoTracking() + .Where(u => u.UomId == line.UomId) + .Select(u => new { u.UomId, u.Name }) + .FirstOrDefault(); + var warehouse = _warehouses.Query().AsNoTracking() + .Where(w => w.WarehouseId == x.WarehouseId) + .Select(w => new { w.WarehouseId, w.Name }) + .FirstOrDefault(); + return new FreeIssueSummaryDto( + x.SalesSlipId, + x.SlipNo, + x.Status, + x.CreatedAt, + x.WarehouseId, + warehouse?.Name ?? $"Warehouse {x.WarehouseId}", + line?.ItemId ?? 0, + item?.Sku ?? $"SKU-{line?.ItemId ?? 0}", + item?.Name ?? line?.Description ?? "—", + line?.UomId ?? 0, + uom?.Name ?? $"UOM {line?.UomId ?? 0}", + line?.Qty ?? 0m, + line?.FreeQty ?? 0m, + line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}"); + } + + private FreeIssueDto MapFreeIssue(SalesSlip x) + { + var summary = MapFreeIssueSummary(x); + return new FreeIssueDto( + x.SalesSlipId, + x.SlipNo, + x.SlipDate, + x.Status, + x.CustomerId, + x.CustomerSnapshotName, + x.WarehouseId, + summary.WarehouseName, + x.CashierUserId, + x.CreatedAt, + x.UpdatedAt, + summary, + x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + } + + private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 903af82..bc439f3 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -66,4 +66,7 @@ public static class ErrorCodes public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED"; public const string RunCostClosed = "RUN_COST_CLOSED"; public const string RunNotCancellable = "RUN_NOT_CANCELLABLE"; + + // General Ledger service proxy (GeneralLedgerController → external GL service, docs/12) + public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE"; } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index 28ac658..f09d44e 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root" + "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root" }, "AuthHex": { "BaseUrl": "http://localhost:5011" diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index ee6e372..9d07a93 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -22,5 +22,9 @@ "RootPath": "App_Data/hr-documents", "MaxSizeBytes": 10485760 }, + "GeneralLedgerService": { + "BaseUrl": "https://localhost:7024/api/v1/", + "ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" + }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index f9431cf..f65e8f9 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,6 +4,11 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done Spec: `docs/10-BACKEND-PHASE1.md` (model + 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. +## 8. Sales +- [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved. +- [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation. +- [x] Free-issue CRUD exposed as `api/v1/free-issues` as a thin alias over sales slips. Free issue remains a line-level `IsFreeIssue` / `FreeQty` behavior, not a separate table. + ## 0. Bootstrap - [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4) - [x] Folder structure per 00-CORE §5.3 @@ -105,6 +110,44 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built. - [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified. +## 7. External Integrations +> **General Ledger service** (separate microservice, own repo/DB) — connected 2026-07-20 as a generic reverse-proxy only; no ERPCore business logic posts to it yet. Full contract + progress detail: `docs/12-GENERAL-LEDGER-INTEGRATION.md`. +- [~] Generic proxy `GET|POST|PUT /api/v1/gl/{**path}` (`GeneralLedgerController` → `IGeneralLedgerService` → `IGeneralLedgerClient`) — forwards method/path/query/body/content-type verbatim to the GL service with a server-attached `X-Api-Key`; GL's response (status + body) returned unchanged. ErpAccess-door-policy-gated like every other v1 endpoint. Config: `GeneralLedgerService:BaseUrl`/`ApiKey` in `appsettings.json`. Build verified clean; **not yet live-smoke-tested** (no running GL instance this pass). +- [ ] Internal wiring — ERPCore services (GRN confirm, adjustments, etc.) calling `IGeneralLedgerService` directly to post real journal entries. Deliberately deferred. + +> ### 2026-07-20 — RBAC nav seed for the frontend's new "Ledgers" section +> The `Frontend/PROGRESS.md` §8 "Ledgers" sidebar section (docs/21-GENERAL-LEDGER-FRONTEND.md) needs a matching `NavItem`/`SubNavItem`/`Permission` row for every entry, or the sidebar filters it out for every role regardless of the frontend change (docs/10 C.8, `GET /auth/me`'s `navCodes`). Added via `NavItemConfiguration.cs`/`SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` `HasData`: `NavItem` `ledgers` (id 11), 7 `SubNavItem` rows (ids 9–15, `ledgers.trial-balance` … `ledgers.bank-accounts`), 8 `Permission` rows (ids 19–26) — same one-`Permission`-per-nav-entry convention as every existing nav row. Migration `AddLedgersNavSeed`. +> **Build note:** a locally running `ERPCore.exe` (PID 29692) held the default `bin/Debug` output locked for the whole session, so `dotnet ef migrations add` twice produced an empty no-op migration off a stale assembly (`--no-build` silently reused pre-edit code) before the real cause was found. Fixed by building to a scratch output directory (unaffected by the lock), copying the fresh `ERPCore.dll` over the locked `bin/Debug` copy (the running process only locks the `.exe`, not the `.dll`), then re-scaffolding — the resulting migration's `Up`/`Down` were verified by inspection against the identical, already-applied `AddRolesNavPermissions` migration's `InsertData`/`DeleteData` shape. The stray process was left running rather than killed, since it wasn't started by this work and may be in active use elsewhere. +> **Not yet applied to a live database** — no Postgres instance was available in this pass to run `dotnet ef database update` against. `dotnet build` is clean (0 warnings/0 errors). +> **Operational step still needed post-deploy (not code):** a new `NavItem`/`SubNavItem` carries no `RolePermission` grants by default — an administrator must check the new Ledgers permissions for the relevant role(s) via **Settings → Roles** before anyone sees the sidebar entry, same as every previous nav addition. + +> ### 2026-07-30 — RBAC nav seed: 8th sub-item for the new "Tax Report" screen +> The frontend's GL-revision pass (`docs/21-GENERAL-LEDGER-FRONTEND.md`, Frontend/PROGRESS.md §8) added a Tax Report screen to the Ledgers sidebar section — needs the same nav-seed treatment as every other entry (docs/10 C.8). Added `SubNavItem` id 16 (`ledgers.tax-report`, `/dashboard/ledgers/tax-report`, sort order 7) and `Permission` id 27 (`NAV:ledgers.tax-report`); re-sequenced the existing `ledgers.bank-accounts` row's `SortOrder` from 7→8 so Tax Report sits before it, matching the sidebar array's actual order. Migration `AddTaxReportNavSeed` — no locked-process issue this time (confirmed no stray `ERPCore.exe` running before scaffolding), generated cleanly on the first attempt with real `InsertData`/`UpdateData`/`DeleteData` (`Down()` correctly restores `bank-accounts`' `SortOrder` to 7). `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same open item as the original `AddLedgersNavSeed` migration; both are still pending `dotnet ef database update` against a real Postgres instance. + +> ### 2026-07-30 (2) — Fixed a real `SubNavItemId`/`PermissionId` collision between Procurement and Ledgers seed data +> **Root cause:** when the 2026-07-20 `AddLedgersNavSeed` migration was authored, its `SubNavItem`/`Permission` IDs were picked by looking at the *actual DB row count*, not the config source — but `SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` already had `HasData` entries for Procurement's 4 sub-items (`procurement.requisitions`/`.rfqs`/`.purchase-orders`/`.purchase-returns`, ids 9–12/19–22) that **had never actually been migrated into any database** (no migration `Up()` anywhere ever inserts them — confirmed by grep across every migration file). Ledgers then claimed the same ids (9–12 sub-nav, 19–22 permission) for its own rows, so the config ended up with two `HasData` entries sharing the same primary key per table. `ErpDbContextModelSnapshot.cs` had silently absorbed both (`dotnet ef migrations add` doesn't hard-fail on this at scaffold time), but **EF's runtime model validator does** — `dotnet ef migrations add` for anything touching these tables, and by extension normal app startup/first `DbContext` use, throws `InvalidOperationException: A seed entity ... has the same key value as another seed entity mapped to the same table`. This is very likely the crash the user was hitting. +> **Fix:** moved Procurement's 4 sub-nav rows off the colliding ids onto **17–20** (`SubNavItemId`) and **28–31** (`PermissionId`), past every id already claimed by Ledgers/Tax-Report (max 16/27). Removed the phantom duplicate Procurement entries from `ErpDbContextModelSnapshot.cs` (they never reflected real DB state) so the differ could compute a clean diff, then generated migration **`FixProcurementNavIdCollision`** — pure `InsertData` for the 4 sub-nav rows + 4 permission rows at their new ids (this is also the *first* migration that actually creates Procurement's sub-nav-item/permission rows in the database at all). `Down()` is a clean `DeleteData` reversal. +> **Verified:** running `dotnet ef migrations add` against the pre-fix config reproduced the exact `InvalidOperationException` above (scaffold failed outright, no migration file produced), confirming this was a real, reproducible crash and not a false alarm; after the fix, the same command succeeded and `dotnet ef migrations list` builds the full model with no error, listing all 7 migrations (the last 2 — `AddTaxReportNavSeed`, `FixProcurementNavIdCollision` — still `(Pending)`, no Postgres instance available this session); `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same standing blocker as the two prior nav-seed migrations. +> Also fixed, same pass: `Frontend/erp-system/components/Layouts/AppSidebar.tsx`'s auto-expand-active-parent logic tripped `react-hooks/set-state-in-effect` (`setExpanded` called synchronously inside a `useEffect`) — converted to the same "adjust state during render" pattern used for the Ledgers report pages, keyed on a `pathname + item-codes` composite key (tracked via a `lastAutoExpandKey` state var) so it still re-fires once `items` populates after the RBAC `navCodes` fetch resolves. `npx eslint components/Layouts/AppSidebar.tsx` clean. + +> ### 2026-07-31 — RBAC nav seed: new "Accounts" nav item (Cheque Management screens + Cash/Bank Accounts moved off Ledgers) +> The frontend added a new "Accounts" sidebar section (`Frontend/PROGRESS.md` §8) for the new Cheque Management screens and to hold Cash/Bank Accounts, which moved out of Ledgers into it (user-requested — Cheque Books/Received Cheques/Cash-Bank Accounts are all the same kind of operational account bookkeeping, not a statutory report). Migration **`AddAccountsNavSeed`**: `InsertData` for `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books` id 21/33, `accounts.received-cheques` id 22/34); **`UpdateData`, not delete-and-recreate**, for the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) — same ids, just new `Code`/`Href`/`NavItemId` — so a role that had already been granted this permission under its old `ledgers.bank-accounts` code doesn't silently lose it just because the section changed. `Down()` correctly reverses both the inserts and the renamed-row update back to its Ledgers-era values. +> **No locked-process issue avoided this time** — `ERPCore.exe` was found running twice during this pass (the user had restarted it between turns to test the Tax Report fix); confirmed with the user before killing it each time, per this session's standing caution around stopping their dev server. `dotnet build` clean (0 warnings/0 errors); `dotnet ef migrations list` shows all 8 migrations with none pending. **Applied to the live database this session** (`dotnet ef database update`) — unlike every prior nav-seed migration this session, this one did not have to wait for a live Postgres instance to become available. +> **Operational step still needed post-deploy (not code):** same as every previous nav addition — an administrator must grant the new `NAV:accounts`/`NAV:accounts.cheque-books`/`NAV:accounts.received-cheques` permissions to the relevant role(s) via **Settings → Roles** before anyone sees the new sidebar entries (the re-homed `NAV:accounts.bank-accounts` keeps whatever grants it already had). + +> ### 2026-07-31 (2) — Root-caused and fixed a repo-wide bug: 36 tables (all of HRM + all of Manufacturing) existed in the EF model but not in the actual database, and no `dotnet ef migrations add` could ever surface it +> **User-reported:** after rebasing `feat/general-ledger-service` onto `origin/Dev`, some tables from the other branch weren't being created by `migrations add` + `database update`. Ground-truthed against the live Postgres instance (queried `pg_tables`/`__EFMigrationsHistory` directly, since EF's own diff tooling only ever compares the compiled model against `ErpDbContextModelSnapshot.cs` — never the real database — so it's structurally blind to this class of bug): the database had 45 tables; the current model/snapshot expects 80. All 25 `hr_*` tables and all 11 `production_runs`/`production_templates`/`run_*`/`stage_*`/`template_stages` tables were completely absent, despite `ErpDbContext`/`Infra/Persistence/Configurations` fully describing them and `ErpDbContextModelSnapshot.cs` already listing them. +> **Root cause: a `.gitignore` rule (`**/Migrations/`, added early on to stop *new* EF migrations from being committed) combined disastrously with `ErpDbContextModelSnapshot.cs` staying tracked** (`.gitignore` doesn't retroactively untrack already-tracked files, and the snapshot was one of the original 4 tracked migrations). Every `dotnet ef migrations add` after that point updated the snapshot (which **did** get committed normally, since it was already tracked) but wrote its actual migration `.cs`/`.Designer.cs` pair as new, gitignored, never-committed files. Confirmed via `git show --stat` on every historical commit touching the snapshot: several — including the commit that added the entire HRM module and the one that added Manufacturing (`7d6e597`) — show large snapshot insertions with **zero** migration files in the same commit. Net effect: the snapshot has been silently lying about the applied-migration history for a long time; `dotnet ef migrations add` never detects a "missing" table because, as far as the (already-tracked, already-correct-looking) snapshot is concerned, nothing has changed — the actual `CreateTable` migration simply never existed anywhere in git, on any machine that didn't happen to still have it sitting locally, ungitignored-but-untracked. +> **Fix, in order:** +> 1. Confirmed the exact 36-table gap by comparing `pg_tables` against every `b.ToTable(...)` call in the snapshot (script, not archaeology — this is the only way to get ground truth once the snapshot itself is suspect). +> 2. Temporarily removed just those 36 entities' blocks from `ErpDbContextModelSnapshot.cs` (verified 2–3 balanced-brace occurrences per entity removed cleanly, nothing else touched), so `dotnet ef migrations add` would have something real to diff against. +> 3. Generated **`AddMissingHrmAndManufacturingTables`** — verified its `Up()` contains exactly 36 `CreateTable` calls (matching the missing-table list precisely, no more/fewer) and its `Down()` exactly 36 matching `DropTable` calls; no `AlterColumn`/`DropColumn`/`RenameColumn` against any pre-existing table, confirming this was a pure addition with zero collateral schema drift. +> 4. Applied it (`dotnet ef database update`); re-queried `pg_tables` live — all 81 tables (80 + `__EFMigrationsHistory`) now present. Confirmed fully settled by scaffolding one more throwaway migration afterward and checking it came back empty (no remaining model/snapshot drift), then removing it. +> 5. **Fixed the actual root cause, not just this one symptom:** reverted the `.gitignore` rule — EF Core migrations are now tracked like any other source file, so this can't recur the same way. Every migration created since the rule was added (`AddLedgersNavSeed`, `AddTaxReportNavSeed`, `FixProcurementNavIdCollision`, `AddAccountsNavSeed`, the empty `production` migration, and this pass's `AddMissingHrmAndManufacturingTables`) was sitting on disk ungitignored-but-uncommitted the whole time — now staged to actually join the repo. +> **Verified:** `dotnet build` clean (0 errors, pre-existing `CS8981` naming warning on the already-present `production` migration class only); `dotnet ef migrations list` shows all 10 migrations, none pending. **A locally running `ERPCore.exe` had to be stopped mid-session (user's explicit approval obtained first) to free the build lock**, same recurring issue as every previous migration pass this week. +> **Left as-is, deliberately:** the empty `production` migration (`20260731123720_production.cs`) — it's a harmless no-op (it was the user's own prior attempt to fix this exact bug, which came back empty for the reason explained above) and renaming/removing it now would just be churn; the real fix landed in the next migration. +> **Action needed from the user:** the `.gitignore` fix means these migration files are no longer excluded, but nothing has been `git add`ed or committed yet — per standing instruction, commits only happen when explicitly asked. + ## Deferred (Phase 2+ — do NOT build now, hooks only) - [ ] Vendor invoice + three-way match - [ ] Reservation/allocation fulfilment diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 90d3a13..0d154fb 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -42,6 +42,13 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry. - [~] 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 +## 3.5 Sales screens +- [~] Sales hub (`app/dashboard/sales`) — new module entry point linking to invoices, slips, free issues, and reports +- [~] Sales invoices (`app/dashboard/sales/invoices`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales invoice API, including save/post/cancel on the detail page +- [~] Sales slips (`app/dashboard/sales/slips`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales slip API, including save/post/cancel on the detail page +- [~] Free issues (`app/dashboard/sales/free-issues`, `/new`, `/[id]`) — alias-only surface over sales slips for free-issue handling; edit/save/post/cancel stays on the slip screen +- [~] Sales reports (`app/dashboard/sales/reports`, `/[reportId]`) — report catalog + report metadata view wired to `/reports/sales` + ## 4. Receiving screens - [~] 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, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry. @@ -83,6 +90,46 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [ ] `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 +## 8. General Ledger (Ledgers + Accounts sections) +> Two sidebar sections (`app/dashboard/ledgers/*`, `app/dashboard/accounts/*`), sourced entirely from the external General Ledger service via ERPCore's generic proxy (`docs/12-GENERAL-LEDGER-INTEGRATION.md`). Full detail, decisions, and known gaps: `docs/21-GENERAL-LEDGER-FRONTEND.md`. +- [x] Ledgers: reports hub + 7 report screens (Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, Tax Report) — statutory-format header/table, PDF **and CSV** download via the same endpoint with `outputFormat=Pdf`/`Csv` +- [x] Accounts: hub + Cash/Bank Accounts — unified list (GL's own server-side `accountType` union + client-side text search) + create (Cash/Bank toggle; GL account is now auto-created server-side, **no picker** — see 2026-07-31 (6) below). **Moved here from Ledgers (2026-07-31 (5))** +- [ ] Cash/Bank Accounts — edit: **not built**, GL has no `GET`/`PUT` by id for either table to build it against (list shows a disabled Edit affordance with an explanatory tooltip instead of a broken form) +- [x] Accounts: Cheque Books — list/filter, create (auto-generates every leaf), drill-down to a book's own pages list, per-page details/issue/status-update in a modal +- [x] Accounts: Received Cheques — list/filter, create, per-row details/status-update in a modal +- [x] Sidebar "Ledgers" (7 sub-items) + new "Accounts" (3 sub-items) nav items (`components/Layouts/AppSidebar.tsx`) + header title mappings (`components/Layouts/Header.tsx`) +- [x] Dedicated GL fetch client (`lib/api/general-ledger.ts`) — GL's envelope differs from ERPCore's own `ProblemDetails`, so this does not reuse `lib/api-client.ts`; now also covers Cheque Management (`chequeBooksApi`/`chequePagesApi`/`receivedChequesApi`) + +> **2026-07-30 — GL's 2026-07-22 backend revision built out (large pass).** Five reports restructured (Trial Balance flattened, Profit & Loss → nested named sections with a Gross Profit subtotal, Cash Flow → a real structured statement replacing the four `StatCard`s), a new CSV export on all seven reports (`components/reports/DownloadCsvButton.tsx`), a brand-new **Tax Report** screen (Income Tax Computation, collapsible optional-adjustments panel, payable/refundable sign-dependent final row), and Cash/Bank accounts split into two real GL endpoints (`POST /bank-accounts` vs `POST /cash-accounts`, unified `GET /bank-accounts?accountType=`) with a two-choice create-form toggle and a Cash Account Type picker that can create a new type on the fly. Extracted `components/reports/{ReportSection,ReportSubtotal}.tsx` — shared by Profit & Loss and Cash Flow rather than duplicating the "bordered section + bold subtotal" markup twice. Three response shapes (`ProfitAndLossResponse`/`CashFlowResponse`/`TaxSummaryResponse`) are **inferred** where GL's own reference doesn't spell out every field verbatim — flagged in `types/general-ledger.ts`'s own comments and `docs/21-GENERAL-LEDGER-FRONTEND.md`, same posture as the original inferred `BankAccount` shape. Backend: migration `AddTaxReportNavSeed` adds the 8th sidebar sub-item + its permission row. Verified: `tsc --noEmit` clean, `eslint` clean across every touched file, `npm run build` succeeds with all 9 `/dashboard/ledgers/*` routes (incl. `/tax-report`), `dotnet build` clean. **Not done:** live smoke test against a running GL instance (still no instance available this session) — the three inferred response shapes are the highest-value thing to verify first. + +> **2026-07-20, same-day fixes (user-reported):** (1) General Ledger report was wrongly calling `GET /accounts` to populate an account picker — GL documents `accountId` on this report as a raw id, not a code-lookup value, so the picker is gone; the screen now only ever calls `/reports`, entering `accountId` directly and reading the account's code/name for display off the report's own returned rows instead. (2) `ReportType`/`ReportOutputFormat`/`GlAccountTypeId` converted from string/numeric literal unions to real TS enums. (3) Fixed a UI-only bug where a selected ``s elsewhere in the app likely share this latent bug; flagged in `docs/21-GENERAL-LEDGER-FRONTEND.md` §4 for whoever next touches one). Verified: `tsc --noEmit` and `eslint` clean. +> +> **2026-07-20 (2) — `react-hooks/set-state-in-effect` errors resolved, Ledgers pages only (scope confirmed with the user — this is not an app-wide lint pass; the same error is pre-existing on ~35 other files elsewhere in the app, left untouched).** All 7 report/list screens called `setState` synchronously as the first statement of a data-fetching effect (clearing stale results before the async call) — flagged as an error, not just a warning, by this project's current eslint config. Fixed with React's own "adjust state during render" pattern instead of an effect: each page now tracks the key it last loaded for (`asOfDate`/period/`accountId`/`budgetId`) in a small extra piece of state, and resets the result/error state **during render** when that key changes (before the effect below ever runs) rather than synchronously inside the effect. Behavior is unchanged — stale results still clear the instant a filter changes. `bank-accounts/page.tsx`'s mount-only `load()` had a redundant `setError(null)` (state already starts `null`; nothing else ever recalls `load()`), removed outright rather than worked around. Verified: `npx eslint app/dashboard/ledgers` produces zero output, `tsc --noEmit` clean, `npm run build` succeeds. +> +> **2026-07-31 — Fixed a live runtime crash on Cash Flow: GL omits empty list/section fields entirely instead of sending `[]`/`{lines:[],total:0}`.** User-reported error clicking into the page: `TypeError: Cannot read properties of undefined (reading 'map')` at `bucketOperatingLines` → `report.nonCashAdjustments.map(...)`, confirming the exact risk `CashFlowResponse` had been flagged with since it was built (inferred shape, never verified live). Root cause: GL's serializer drops a list/section property from the JSON body altogether when there's nothing to report for the period, rather than emitting an empty array/zero-totalled object. Fixed defensively in `cash-flow/page.tsx` (`?? []` on `nonCashAdjustments`/`workingCapitalChanges`, a new `activitySectionLines()` helper + optional chaining for `investingActivities`/`financingActivities`/their `.total`) and, proactively, in `profit-and-loss/page.tsx` (`isEmpty` check and every section's `.total` access) since `ProfitAndLossResponse` shares the identical nested-section shape and was equally exposed — not yet crashed on, but certain to under the same conditions (a section with nothing posted for the period). `types/general-ledger.ts`'s `CashFlowResponse`/`ProfitAndLossResponse` fields updated from required to optional to match, with comments pointing back at this confirmed-live behavior. Verified: `tsc --noEmit`/`eslint` clean on all touched files; `npm run build`'s TypeScript step fails, but only on a pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error present before this pass — out of scope per standing instruction to keep fixes scoped to Ledgers. **Tax Report's `TaxSummaryResponse` is the one remaining inferred shape not yet defensively hardened or live-verified** — same class of risk, flagged for the next time that screen is touched. +> +> **2026-07-31 (2) — Corrected against GL's own authoritative API reference (`04_API_Reference_And_Scenarios.md`, user-supplied): Cash Flow's shape was fundamentally wrong, not just missing defensive guards; Tax Report was missing five real fields.** With the actual GL API reference in hand (not inference), checked every report's response shape against it: Trial Balance, Balance Sheet, General Ledger, Profit & Loss, and Budget vs Actual all match exactly, confirming those five were built correctly. Two did not: **(1) `CashFlowResponse` doesn't have `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as flat top-level fields at all — everything genuinely nests under `operatingActivities` (`{ profitForPeriod, nonCashAdjustments[], workingCapitalChanges[], netCashFromOperatingActivities }`), and `investingActivities`/`financingActivities` each carry their own differently-named total (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`.** This — not just "the field might be missing" — was the real cause of the crash fixed in the previous entry; the previous fix's defensive `?? []` guards were correct in spirit but pointed at the wrong (nonexistent) top-level fields, so the page would have kept rendering an empty operating-activities section forever even without crashing. Rewrote `cash-flow/page.tsx` and `CashFlowResponse`/added `CashFlowOperatingActivities`/`CashFlowInvestingActivities`/`CashFlowFinancingActivities` to `types/general-ledger.ts` to match the confirmed contract exactly; also caught that `workingCapitalChanges[]` entries use `changeAmount`, not `amount`. **(2) `TaxSummaryResponse`/the Tax Report's `ROWS` table were missing `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and `quarterlyTaxPayments` entirely** — real GL-computed figures that were silently never rendered, not just a wrong guess at a field name. Added all five in their correct position in the confirmed row order (`profitBeforeTax` → `balanceTaxPayable`). Verified: `tsc --noEmit`/`eslint` clean on every touched file. +> +> **2026-07-31 (3) — Balance Sheet regrouped into a proper LKAS Statement of Financial Position layout (user-reported).** `BalanceSheetRow`'s shape was already correct (confirmed against GL's reference above), but the flat one-table rendering made a rollup total visually indistinguishable from the leaf amounts it already sums — e.g. "Cash and Bank"'s balance already includes "Petty Cash"/"Main Operating Bank Account"/"Savings Bank Account" beneath it, but every row read the same weight (only `depth===0` did any, subtle, bolding), inviting a user to double-count by adding up everything they see. Rewrote `balance-sheet/page.tsx`: rows now group by `accountType` into ASSETS/LIABILITIES/EQUITY sections, each ending in a bold "Total {Section Name}" row (summed from that section's depth-0 rows only — a depth-0 row's balance already rolls up its own descendants, so summing depth-0 rows avoids double-counting), any row with a deeper row immediately following it is bolded as a rollup regardless of its own depth (not just the very top level), and a final "Total Liabilities and Equity" row for the standard balance-check. One quirk handled explicitly: GL's synthetic "Current Year Earnings" balancing row is documented to always carry `depth: 1` even though it's a peer Equity entry, not a child of whatever real account happens to precede it — a new `effectiveDepth()` helper special-cases it to 0 so it isn't mis-rendered as nested under (and excluded from the total alongside) an unrelated account. Manually verified the new grouping/summing logic against the actual numbers from the reported screenshot: Total Assets (5,880,466.50) = Total Liabilities (2,025,000.00) + Total Equity (3,855,466.50), exact match. Verified: `tsc --noEmit`/`eslint` clean. +> +> **2026-07-31 (4) — Superseded by GL's own retrofit: `BalanceSheet` is a genuinely different, classified response shape now, not just a re-grouping of the same flat array.** GL's own API reference (user-supplied) documents a 2026-07-31 backend retrofit: the flat recursive-rollup array (`{depth, lineItem, accountType, balance}`, what entry (3) above regrouped client-side) is replaced entirely by a **pre-classified nested object** — `{ asOfDate, nonCurrentAssets: {lines[], total}, currentAssets: {lines[], total}, unclassifiedAssets: {lines[], total}, totalAssets, equity: {lines[], total}, nonCurrentLiabilities: {lines[], total}, currentLiabilities: {lines[], total}, unclassifiedLiabilities: {lines[], total}, totalEquityAndLiabilities }`, driven by a new `accounts.balance_sheet_classification` tag GL now maintains server-side. This means entry (3)'s client-side grouping/rollup logic (`effectiveDepth`, `sectionTotal`, the `depth`-based rollup-bolding) is entirely obsolete — GL now does the Non-Current/Current classification itself, the frontend just renders the sections it's given. Replaced `BalanceSheetRow` with `BalanceSheetLine`/`BalanceSheetSection`/`BalanceSheetResponse` in `types/general-ledger.ts` (every section marked optional, same defensive posture adopted for `CashFlowResponse`/`ProfitAndLossResponse` after the Cash Flow crash, since this exact shape isn't live-verified against this frontend yet) and rewrote `balance-sheet/page.tsx` from scratch to consume it. **Also changed the layout to match a user-supplied reference Statement of Financial Position image** (a real classified SOFP: Non-Current Assets/Current Assets each their own subtotaled block, then Equity and Liabilities the same way, ending in a Total Assets vs Total Equity-and-Liabilities check) — rather than inventing new one-off markup for this, reused the same `ReportSection`/`ReportSubtotal` shared components Profit & Loss and Cash Flow already use (one `ReportSection` per GL-provided section, a `ReportSubtotal` for each side's grand total), keeping Balance Sheet visually and structurally consistent with the rest of the Ledgers screens rather than a bespoke table. Account codes are deliberately not shown per line (the reference template shows plain line-item names only). Verified: `tsc --noEmit`/`eslint` clean; grepped the codebase to confirm no lingering references to the removed `BalanceSheetRow`/flat shape. +> +> **2026-07-31 (5) — New "Accounts" nav section: Cheque Management built out, Cash/Bank Accounts moved under it.** New Cheque Management module (`04_API_Reference_And_Scenarios.md`, Module: Cheque Management — added to GL 2026-07-30, beyond its original plan): two independent sub-areas, **Cheque Books/Pages** (cheques issued from this company's own supply) and **Received Cheques** (cheques received from others, deliberately unlinked to any cheque book). Added `PayeeType`/`ReceivedFromType`/`ChequeBookStatus`/`ChequePageIssueStatus`/`ChequePageStatusAction`/`ReceivedChequeStatus`/`ReceivedChequeStatusAction` enums and `ChequeBook`/`ChequePage`/`ReceivedCheque` (+ their create/status-update request types) to `types/general-ledger.ts`, and `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` to `lib/api/general-ledger.ts`. `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are GL's own documented "loose references" (no Branch/Company/Customer/Supplier table exists in that service) — taken as plain numeric inputs, not picker dropdowns, matching GL's stated design rather than fabricating master data that doesn't exist. +> +> **Cheque Books** (`app/dashboard/accounts/cheque-books/{page,new,[chequeBookNo]/page}.tsx`): list with a status filter, create form (bank account picker restricted to `Bank`-type accounts only — GL's own module note says cheque books are bank-account-only, never cash-account), and a book-detail page showing every leaf (`GET /cheque-books/{chequeBookNo}?expand=pages`) — clicking a leaf opens `components/accounts/ChequePageDialog.tsx`, a modal with read-only details plus status-appropriate actions (`Unused` → Issue/Cancel/Void; `Issued` → Clear/Bounce/Cancel; terminal statuses → read-only), each action revealing only the fields that specific transition actually needs (e.g. Clear asks for `clearedDate`, Cancel asks for `cancelReason`, Bounce/Void need nothing beyond an optional `performedBy`). A modal was chosen over a second-level page for the leaf-details view (left open in the request) so working through several leaves in one book doesn't lose the list's scroll position/context each time. +> +> **Received Cheques** (`app/dashboard/accounts/received-cheques/{page,new/page}.tsx` + `components/accounts/ReceivedChequeDialog.tsx`): same list-then-modal shape — status filter, create form, and a details/status-update modal (`Received` → Deposit/Cancel; `Deposited` → Clear/Return), Deposit asking for a bank-account picker + date, the rest needing nothing beyond an optional note. +> +> **2026-07-31 (6) — Two user-reported fixes: `glAccountCode` removed from Cash/Bank Account creation (further GL retrofit), and the three GL create-form pages widened to fill the page.** (1) GL's reference now documents that `POST /bank-accounts`/`POST /cash-accounts` no longer accept `glAccountCode` — the backing GL account (a `Bank`/`Cash` root, plus a type-header node for Cash) is always found-or-created server-side, never caller-selected. Removed the field from `CreateBankAccountRequest`/`CreateCashAccountRequest`, deleted the "GL account" `Select` and its `glAccountsApi.list()` fetch from `bank-accounts/new/page.tsx` outright, and dropped the check from `validateBankAccountForm`. Typed the create response as a new `CreateCashOrBankAccountResponse` (`glAccount` nested, confirmed from GL's doc) so the success toast can surface the auto-generated GL account code. The Cash/Bank **list** page is untouched — GL's list endpoint still returns a flat `glAccountId` per row, still resolved via `glAccountsApi.list()` there. (2) `bank-accounts/new`, `cheque-books/new`, and `received-cheques/new` each wrapped their form in a `max-w-lg` card, leaving roughly half of any normal desktop screen blank. Dropped the `max-w-lg` cap (now full-width, matching the un-capped card convention every report page already uses) and replaced the vertical one-field-per-row stacking (plus scattered ad-hoc `grid grid-cols-2` pairs) with one consistent `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` wrapper per form. Left the two modals (`ChequePageDialog`/`ReceivedChequeDialog`) at their existing fixed width on purpose — the complaint was about full-page create forms, not dialogs. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build`'s Turbopack compile succeeds, its TypeScript step fails only on the same pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error noted in earlier entries. +> +> **Cash/Bank Accounts moved from Ledgers to the new Accounts section** (user-requested), since it's the same kind of "operational account bookkeeping" as cheques, not a statutory report — `app/dashboard/ledgers/bank-accounts/*` relocated verbatim to `app/dashboard/accounts/bank-accounts/*` (internal links updated, no behavior change), removed from the Ledgers hub's card grid. +> +> **Backend:** migration `AddAccountsNavSeed` adds `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books`, `accounts.received-cheques`), and **re-homes** the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) from Ledgers to Accounts via `UpdateData` (new `Code`/`Href`/`NavItemId`) rather than delete-and-recreate — keeps the same ids so any role already granted that permission doesn't silently lose it just because the section it lives under changed. Applied to the live database this session (`dotnet ef database update`). +> +> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error. +> +> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds. + ## 7. UX states - [~] 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 diff --git a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/new/page.tsx b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/new/page.tsx new file mode 100644 index 0000000..c80eab0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/new/page.tsx @@ -0,0 +1,204 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" + +import { bankAccountsApi, cashAccountTypesApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { validateBankAccountForm } from "@/lib/validations/general-ledger" +import { cn } from "@/lib/utils" +import { CashAccountType, CashBankAccountType } from "@/types/general-ledger" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Field, FieldError, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" + +const OTHER_CASH_TYPE = "__other__" + +export default function NewBankAccountPage() { + const router = useRouter() + + const [accountType, setAccountType] = useState(CashBankAccountType.Bank) + + const [cashAccountTypes, setCashAccountTypes] = useState(null) + const [cashAccountTypesError, setCashAccountTypesError] = useState(null) + + const [accountName, setAccountName] = useState("") + const [bankName, setBankName] = useState("") + const [cashAccountTypeChoice, setCashAccountTypeChoice] = useState("") + const [customCashAccountTypeName, setCustomCashAccountTypeName] = useState("") + const [accountNumber, setAccountNumber] = useState("") + const [currencyCode, setCurrencyCode] = useState("LKR") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (accountType !== CashBankAccountType.Cash || cashAccountTypes !== null) return + cashAccountTypesApi + .list() + .then(setCashAccountTypes) + .catch((err) => setCashAccountTypesError(errorMessage(err))) + // Only fetched once, lazily, the first time "Cash" is selected. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountType]) + + const cashAccountTypeName = + cashAccountTypeChoice === OTHER_CASH_TYPE ? customCashAccountTypeName.trim() : cashAccountTypeChoice + + async function handleSubmit() { + const nextErrors = validateBankAccountForm({ accountName }) + if (accountType === CashBankAccountType.Cash && !cashAccountTypeName) { + nextErrors.cashAccountTypeName = "Select or enter a cash account type" + } + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const created = + accountType === CashBankAccountType.Bank + ? await bankAccountsApi.createBank({ + accountName, + bankName: bankName || null, + accountNumber: accountNumber || null, + currencyCode: currencyCode || undefined, + }) + : await bankAccountsApi.createCash({ + accountName, + cashAccountTypeName, + accountNumber: accountNumber || null, + currencyCode: currencyCode || undefined, + }) + toast.success(`${accountType} account created`, `${created.accountName} — GL account ${created.glAccount.accountCode}`) + router.push("/dashboard/accounts/bank-accounts") + } catch (err) { + toast.error(`Could not create ${accountType.toLowerCase()} account`, errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + + +
+

New Cash / Bank Account

+

+ Its ledger account is created automatically — no need to pick one. +

+
+
+ +
+
+ + +
+ +
+ + Account name + setAccountName(e.target.value)} + placeholder={accountType === CashBankAccountType.Bank ? "Main Account" : "Head Office Petty Cash"} + aria-invalid={!!errors.accountName} + /> + + + + {accountType === CashBankAccountType.Bank ? ( + <> + + Bank name (optional) + setBankName(e.target.value)} placeholder="Commercial Bank" /> + + + Account number (optional) + setAccountNumber(e.target.value)} placeholder="8001234567" /> + + + ) : ( + <> + + Cash account type + value={cashAccountTypeChoice} onValueChange={(v) => setCashAccountTypeChoice(v ?? "")}> + + + + + {(cashAccountTypes ?? []).map((t) => ( + + {t.name} + + ))} + + Other, please specify… + + + + {cashAccountTypeChoice === OTHER_CASH_TYPE && ( + setCustomCashAccountTypeName(e.target.value)} + placeholder="e.g. Site Cash" + className="mt-2" + /> + )} + {cashAccountTypesError && ( +

{cashAccountTypesError}

+ )} + +
+ + Account number (optional) + setAccountNumber(e.target.value)} + placeholder="Auto-generated if left blank" + /> + + + )} + + + Currency + setCurrencyCode(e.target.value)} maxLength={3} placeholder="LKR" /> + +
+ +
+ + Cancel + + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx new file mode 100644 index 0000000..214ff90 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx @@ -0,0 +1,192 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react" + +import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatReportDate } from "@/lib/format" +import { cn } from "@/lib/utils" +import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger" + +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" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +type AccountTypeFilter = CashBankAccountType | "Both" + +export default function BankAccountsPage() { + const [accounts, setAccounts] = useState(null) + const [glAccounts, setGlAccounts] = useState(null) + const [error, setError] = useState(null) + const [search, setSearch] = useState("") + const [accountType, setAccountType] = useState("Both") + + // GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table + // before) — re-fetches whenever the filter changes, unlike the plain client-side search below. + useEffect(() => { + let cancelled = false + Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) + .then(([banks, gl]) => { + if (cancelled) return + setAccounts(banks) + setGlAccounts(gl.items) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [accountType]) + + const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts]) + + const filtered = useMemo(() => { + if (!accounts) return null + const q = search.trim().toLowerCase() + if (!q) return accounts + return accounts.filter((a) => { + const gl = glAccountsById.get(a.glAccountId) + return ( + a.accountName.toLowerCase().includes(q) || + (a.bankName ?? "").toLowerCase().includes(q) || + (a.cashAccountTypeName ?? "").toLowerCase().includes(q) || + (a.accountNumber ?? "").toLowerCase().includes(q) || + a.currencyCode.toLowerCase().includes(q) || + (gl?.accountCode ?? "").toLowerCase().includes(q) + ) + }) + }, [accounts, search, glAccountsById]) + + return ( +
+
+
+ + + +
+

Cash / Bank Accounts

+

Cash and Bank accounts linked to a GL account, for reconciliation.

+
+
+ + + New Account + +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search name, bank/type, account no. or currency…" + className="h-14 w-full pl-11 text-base" + aria-label="Search cash/bank accounts" + /> +
+ value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}> + + + + + All types + Bank + Cash + + +
+ + {error && ( +
{error}
+ )} + + {!error && filtered === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && filtered !== null && filtered.length === 0 && ( +
+ +

+ {search ? "No accounts match your search." : "No cash/bank accounts yet."} +

+
+ )} + + {!error && filtered !== null && filtered.length > 0 && ( + + + + Type + Account name + Bank / Cash type + Account no. + GL account + Currency + Created + Actions + + + + {filtered.map((a) => { + const gl = glAccountsById.get(a.glAccountId) + return ( + + + + {a.accountType} + + + {a.accountName} + {a.bankName ?? a.cashAccountTypeName ?? "—"} + {a.accountNumber ?? "—"} + + {gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`} + + {a.currencyCode} + {formatReportDate(a.createdAt)} + + + + } + > + + + + Editing isn't available yet — the General Ledger service has no update endpoint for + {a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts. + + + + + ) + })} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/cheque-books/[chequeBookNo]/page.tsx b/Frontend/erp-system/app/dashboard/accounts/cheque-books/[chequeBookNo]/page.tsx new file mode 100644 index 0000000..9aa9de6 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/cheque-books/[chequeBookNo]/page.tsx @@ -0,0 +1,159 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" +import { ArrowLeft, BookText } from "lucide-react" + +import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate } from "@/lib/format" +import { cn } from "@/lib/utils" +import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger" + +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" +import { ChequePageDialog } from "@/components/accounts/ChequePageDialog" + +const STATUS_BADGE: Record = { + [ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground", + [ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary", + [ChequePageIssueStatus.Cleared]: "bg-success/10 text-success", + [ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive", + [ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive", + [ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground", +} + +export default function ChequeBookDetailPage() { + const params = useParams<{ chequeBookNo: string }>() + const chequeBookNo = decodeURIComponent(params.chequeBookNo) + + const [book, setBook] = useState(null) + const [bankAccount, setBankAccount] = useState(null) + const [error, setError] = useState(null) + const [selectedPage, setSelectedPage] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + useEffect(() => { + let cancelled = false + chequeBooksApi + .get(chequeBookNo, true) + .then((res) => { + if (cancelled) return + setBook(res) + return bankAccountsApi.list(CashBankAccountType.Bank).then((banks) => { + if (cancelled) return + setBankAccount(banks.find((b) => b.accountId === res.bankAccountId) ?? null) + }) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [chequeBookNo]) + + function handlePageUpdated(updated: ChequePage) { + setBook((prev) => (prev ? { ...prev, pages: prev.pages.map((p) => (p.chequeNo === updated.chequeNo ? updated : p)) } : prev)) + setSelectedPage(updated) + } + + return ( +
+
+ + + +
+

Cheque Book {chequeBookNo}

+

Every leaf in this book — click one to view details or take an action.

+
+
+ + {error && ( +
{error}
+ )} + + {!error && book === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && book !== null && ( + <> +
+
+

Bank account

+

{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}

+
+
+

Branch

+

#{book.branchId}

+
+
+

Range

+

+ {book.startChequeNo} – {book.endChequeNo} +

+
+
+

Received

+

{formatReportDate(book.receivedDate)}

+
+
+ + {book.pages.length === 0 ? ( +
+ +

No pages found for this book.

+
+ ) : ( + + + + Cheque no. + Status + Payee + Issue date + Amount + + + + {book.pages.map((p) => ( + { + setSelectedPage(p) + setDialogOpen(true) + }} + > + {p.chequeNo} + + + {p.issueStatus} + + + {p.payeeName ?? "—"} + {formatReportDate(p.issueDate)} + + {p.amount !== null ? formatAmount(p.amount) : "—"} + + + ))} + +
+ )} + + )} + + +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/cheque-books/new/page.tsx b/Frontend/erp-system/app/dashboard/accounts/cheque-books/new/page.tsx new file mode 100644 index 0000000..f910f1a --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/cheque-books/new/page.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" + +import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { validateChequeBookForm } from "@/lib/validations/general-ledger" +import { cn } from "@/lib/utils" +import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Field, FieldError, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" + +export default function NewChequeBookPage() { + const router = useRouter() + + const [bankAccounts, setBankAccounts] = useState(null) + const [bankAccountsError, setBankAccountsError] = useState(null) + + const [branchId, setBranchId] = useState("") + const [bankAccountId, setBankAccountId] = useState("") + const [chequeBookNo, setChequeBookNo] = useState("") + const [startChequeNo, setStartChequeNo] = useState("") + const [endChequeNo, setEndChequeNo] = useState("") + const [totalLeaves, setTotalLeaves] = useState("") + const [receivedDate, setReceivedDate] = useState("") + const [description, setDescription] = useState("") + const [createdBy, setCreatedBy] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + // Cheque books can only be tied to a real bank account — GL's own module note (§5.8) says + // statement import/reconcile, and by extension cheque books, are bank_account-only, never cash_account. + useEffect(() => { + bankAccountsApi + .list(CashBankAccountType.Bank) + .then(setBankAccounts) + .catch((err) => setBankAccountsError(errorMessage(err))) + }, []) + + async function handleSubmit() { + const nextErrors = validateChequeBookForm({ + branchId, + bankAccountId, + chequeBookNo, + startChequeNo, + endChequeNo, + totalLeaves, + receivedDate, + }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const created = await chequeBooksApi.create({ + branchId: Number(branchId), + bankAccountId: Number(bankAccountId), + chequeBookNo, + startChequeNo, + endChequeNo, + totalLeaves: Number(totalLeaves), + receivedDate, + description: description || undefined, + createdBy: createdBy || undefined, + }) + toast.success("Cheque book created", `${created.chequeBookNo} — ${created.totalLeaves} leaves`) + router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(created.chequeBookNo)}`) + } catch (err) { + toast.error("Could not create cheque book", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + + +
+

New Cheque Book

+

+ Every leaf from the start to end cheque number is generated automatically, all "Unused". +

+
+
+ + {bankAccountsError && ( +
{bankAccountsError}
+ )} + +
+
+ + Bank account + value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? "")}> + + + + + {(bankAccounts ?? []).map((a) => ( + + {a.accountName} + {a.bankName ? ` — ${a.bankName}` : ""} + + ))} + + + + + + + Branch ID + setBranchId(e.target.value)} + placeholder="1" + aria-invalid={!!errors.branchId} + /> + + + + + Cheque book number + setChequeBookNo(e.target.value)} + placeholder="CB-0001" + aria-invalid={!!errors.chequeBookNo} + /> + + + + + Start cheque no. + setStartChequeNo(e.target.value)} + placeholder="000001" + aria-invalid={!!errors.startChequeNo} + /> + + + + End cheque no. + setEndChequeNo(e.target.value)} + placeholder="000025" + aria-invalid={!!errors.endChequeNo} + /> + + + + + Total leaves + setTotalLeaves(e.target.value)} + placeholder="25" + aria-invalid={!!errors.totalLeaves} + /> +

Must equal end − start + 1.

+ +
+ + + Received date + setReceivedDate(e.target.value)} + aria-invalid={!!errors.receivedDate} + /> + + + + + Description (optional) + setDescription(e.target.value)} /> + + + + Created by (optional) + setCreatedBy(e.target.value)} /> + +
+ +
+ + Cancel + + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/cheque-books/page.tsx b/Frontend/erp-system/app/dashboard/accounts/cheque-books/page.tsx new file mode 100644 index 0000000..c925ddb --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/cheque-books/page.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft, BookText, Plus } from "lucide-react" + +import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatReportDate } from "@/lib/format" +import { cn } from "@/lib/utils" +import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger" + +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +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 = ChequeBookStatus | "All" + +const STATUS_BADGE: Record = { + [ChequeBookStatus.Active]: "bg-success/10 text-success", + [ChequeBookStatus.Completed]: "bg-primary/10 text-primary", + [ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive", +} + +export default function ChequeBooksPage() { + const router = useRouter() + const [books, setBooks] = useState(null) + const [bankAccounts, setBankAccounts] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + + useEffect(() => { + let cancelled = false + Promise.all([ + chequeBooksApi.list(status === "All" ? undefined : { status }), + bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank), + ]) + .then(([result, banks]) => { + if (cancelled) return + setBooks(result.items) + setBankAccounts(banks) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + // bankAccounts intentionally excluded — fetched once, reused across status re-fetches. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]) + + const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts]) + + return ( +
+
+
+ + + +
+

Cheque Books

+

Cheque books issued from this company's own supply.

+
+
+ + + New Cheque Book + +
+ +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Completed + Cancelled + + +
+ + {error && ( +
{error}
+ )} + + {!error && books === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && books !== null && books.length === 0 && ( +
+ +

No cheque books yet.

+
+ )} + + {!error && books !== null && books.length > 0 && ( + + + + Cheque book no. + Bank account + Branch + Range + Leaves + Received + Status + + + + {books.map((b) => { + const bank = bankAccountsById.get(b.bankAccountId) + return ( + router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)} + > + {b.chequeBookNo} + + {bank ? bank.accountName : `#${b.bankAccountId}`} + + #{b.branchId} + + {b.startChequeNo} – {b.endChequeNo} + + {b.totalLeaves} + {formatReportDate(b.receivedDate)} + + + {b.status} + + + + ) + })} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/page.tsx b/Frontend/erp-system/app/dashboard/accounts/page.tsx new file mode 100644 index 0000000..34e7de5 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link" +import { BookText, Inbox, Wallet, 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: "Cash / Bank Accounts", + description: "Cash and Bank accounts linked to a GL account — list, create, and reconcile against them.", + href: "/dashboard/accounts/bank-accounts", + icon: Wallet, + }, + { + title: "Cheque Books", + description: "Cheque books issued from this company’s own supply — issue, clear, bounce, cancel or void a leaf.", + href: "/dashboard/accounts/cheque-books", + icon: BookText, + }, + { + title: "Received Cheques", + description: "Cheques received from customers/suppliers — deposit, clear, return, or cancel.", + href: "/dashboard/accounts/received-cheques", + icon: Inbox, + }, +] + +export default function AccountsHubPage() { + return ( +
+
+

Accounts

+

+ Cash/Bank accounts and cheque management, from the General Ledger service. +

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

{area.description}

+
+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/received-cheques/new/page.tsx b/Frontend/erp-system/app/dashboard/accounts/received-cheques/new/page.tsx new file mode 100644 index 0000000..49684d4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/received-cheques/new/page.tsx @@ -0,0 +1,215 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" + +import { receivedChequesApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { validateReceivedChequeForm } from "@/lib/validations/general-ledger" +import { cn } from "@/lib/utils" +import { ReceivedFromType } from "@/types/general-ledger" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Field, FieldError, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" + +export default function NewReceivedChequePage() { + const router = useRouter() + + const [companyId, setCompanyId] = useState("") + const [branchId, setBranchId] = useState("") + const [receivedFromType, setReceivedFromType] = useState(ReceivedFromType.Customer) + const [receivedFromId, setReceivedFromId] = useState("") + const [receivedFromName, setReceivedFromName] = useState("") + const [drawerBankName, setDrawerBankName] = useState("") + const [drawerBankBranch, setDrawerBankBranch] = useState("") + const [accountHolderName, setAccountHolderName] = useState("") + const [chequeNo, setChequeNo] = useState("") + const [chequeDate, setChequeDate] = useState("") + const [amount, setAmount] = useState("") + const [receivedDate, setReceivedDate] = useState("") + const [referenceType, setReferenceType] = useState("") + const [referenceId, setReferenceId] = useState("") + const [notes, setNotes] = useState("") + const [createdBy, setCreatedBy] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit() { + const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const created = await receivedChequesApi.create({ + companyId: Number(companyId), + branchId: branchId ? Number(branchId) : undefined, + receivedFromType, + receivedFromId: receivedFromId ? Number(receivedFromId) : undefined, + receivedFromName, + drawerBankName: drawerBankName || undefined, + drawerBankBranch: drawerBankBranch || undefined, + accountHolderName: accountHolderName || undefined, + chequeNo, + chequeDate, + amount: Number(amount), + receivedDate, + referenceType: referenceType || undefined, + referenceId: referenceId ? Number(referenceId) : undefined, + notes: notes || undefined, + createdBy: createdBy || undefined, + }) + toast.success("Received cheque recorded", `${created.chequeNo} — ${created.receivedFromName}`) + router.push("/dashboard/accounts/received-cheques") + } catch (err) { + toast.error("Could not record received cheque", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + + +
+

New Received Cheque

+

Record a cheque received from a customer, supplier, or other party.

+
+
+ +
+
+ + Company ID + setCompanyId(e.target.value)} + placeholder="1" + aria-invalid={!!errors.companyId} + /> + + + + Branch ID (optional) + setBranchId(e.target.value)} /> + + + + Received from type + value={receivedFromType} onValueChange={(v) => setReceivedFromType(v ?? ReceivedFromType.Customer)}> + + + + + {Object.values(ReceivedFromType).map((t) => ( + + {t} + + ))} + + + + + + Received from name + setReceivedFromName(e.target.value)} + aria-invalid={!!errors.receivedFromName} + /> + + + + Received from ID (optional) + setReceivedFromId(e.target.value)} /> + + + + Drawer bank (optional) + setDrawerBankName(e.target.value)} /> + + + Drawer branch (optional) + setDrawerBankBranch(e.target.value)} /> + + + Account holder name (optional) + setAccountHolderName(e.target.value)} /> + + + + Cheque number + setChequeNo(e.target.value)} aria-invalid={!!errors.chequeNo} /> + + + + Cheque date + setChequeDate(e.target.value)} + aria-invalid={!!errors.chequeDate} + /> + + + + + Amount + setAmount(e.target.value)} aria-invalid={!!errors.amount} /> + + + + Received date + setReceivedDate(e.target.value)} + aria-invalid={!!errors.receivedDate} + /> + + + + + Reference type (optional) + setReferenceType(e.target.value)} placeholder="Invoice" /> + + + Reference ID (optional) + setReferenceId(e.target.value)} /> + + + + Notes (optional) + setNotes(e.target.value)} /> + + + Created by (optional) + setCreatedBy(e.target.value)} /> + +
+ +
+ + Cancel + + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/accounts/received-cheques/page.tsx b/Frontend/erp-system/app/dashboard/accounts/received-cheques/page.tsx new file mode 100644 index 0000000..d66699e --- /dev/null +++ b/Frontend/erp-system/app/dashboard/accounts/received-cheques/page.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Inbox, Plus } from "lucide-react" + +import { receivedChequesApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate } from "@/lib/format" +import { cn } from "@/lib/utils" +import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger" + +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +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" +import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog" + +type StatusFilter = ReceivedChequeStatus | "All" + +const STATUS_BADGE: Record = { + [ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground", + [ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary", + [ReceivedChequeStatus.Cleared]: "bg-success/10 text-success", + [ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive", + [ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive", +} + +export default function ReceivedChequesPage() { + const [cheques, setCheques] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [selected, setSelected] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + useEffect(() => { + let cancelled = false + receivedChequesApi + .list(status === "All" ? undefined : { status }) + .then((res) => { + if (!cancelled) setCheques(res.items) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [status]) + + function handleUpdated(updated: ReceivedCheque) { + setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev)) + setSelected(updated) + } + + return ( +
+
+
+ + + +
+

Received Cheques

+

Cheques received from customers, suppliers, or others.

+
+
+ + + New Received Cheque + +
+ +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Received + Deposited + Cleared + Returned + Cancelled + + +
+ + {error && ( +
{error}
+ )} + + {!error && cheques === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && cheques !== null && cheques.length === 0 && ( +
+ +

No received cheques yet.

+
+ )} + + {!error && cheques !== null && cheques.length > 0 && ( + + + + Cheque no. + Received from + Type + Amount + Received + Status + + + + {cheques.map((c) => ( + { + setSelected(c) + setDialogOpen(true) + }} + > + {c.chequeNo} + {c.receivedFromName} + {c.receivedFromType} + {formatAmount(c.amount)} + {formatReportDate(c.receivedDate)} + + + {c.status} + + + + ))} + +
+ )} + + +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx new file mode 100644 index 0000000..d835278 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Landmark } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatReportDate, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { BalanceSheetResponse, BalanceSheetSection, ReportType } from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" +import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection" +import { ReportSubtotal } from "@/components/reports/ReportSubtotal" + +// GL's leaf rows carry `accountCode` too, but a classified Statement of Financial Position +// (matching the reference template this screen follows) shows plain line-item names only, no +// codes — same convention as the Balance Sheet's account-code-free presentation elsewhere. +function sectionLines(section?: BalanceSheetSection): ReportSectionLine[] { + return (section?.lines ?? []).map((l) => ({ label: l.accountName, amount: l.balance })) +} + +export default function BalanceSheetPage() { + const [asOfDate, setAsOfDate] = useState(todayIso()) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + + // Clears stale results the instant asOfDate changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== asOfDate) { + setLoadedFor(asOfDate) + setReport(null) + setError(null) + } + + useEffect(() => { + if (!asOfDate) return + let cancelled = false + reportsApi + .balanceSheet(asOfDate) + .then((res) => { + if (!cancelled) setReport(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [asOfDate]) + + const isEmpty = + report !== null && + (report.nonCurrentAssets?.lines?.length ?? 0) === 0 && + (report.currentAssets?.lines?.length ?? 0) === 0 && + (report.unclassifiedAssets?.lines?.length ?? 0) === 0 && + (report.equity?.lines?.length ?? 0) === 0 && + (report.nonCurrentLiabilities?.lines?.length ?? 0) === 0 && + (report.currentLiabilities?.lines?.length ?? 0) === 0 && + (report.unclassifiedLiabilities?.lines?.length ?? 0) === 0 + + return ( +
+
+ + + +
+

Balance Sheet

+

Statement of Financial Position — Assets, Liabilities, Equity.

+
+
+ +
+
+ + setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" /> +
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && report === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && isEmpty && ( +
+ +

No Asset/Liability/Equity accounts as at this date.

+
+ )} + + {!error && report !== null && !isEmpty && ( +
+ + +
+

Assets

+ + + + + +

Equity and Liabilities

+ + + + + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/budget-vs-actual/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/budget-vs-actual/page.tsx new file mode 100644 index 0000000..fc51fd2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/budget-vs-actual/page.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, BadgeDollarSign } from "lucide-react" + +import { glBudgetsApi, reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount } from "@/lib/format" +import { cn } from "@/lib/utils" +import { BudgetVsActualRow, GlBudget, ReportType } from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" + +export default function BudgetVsActualPage() { + const [budgets, setBudgets] = useState(null) + const [budgetsError, setBudgetsError] = useState(null) + const [budgetId, setBudgetId] = useState("") + + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + glBudgetsApi + .list() + .then((res) => setBudgets(res)) + .catch((err) => setBudgetsError(errorMessage(err))) + }, []) + + // Clears stale results the instant budgetId changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const [loadedFor, setLoadedFor] = useState("") + if (loadedFor !== budgetId) { + setLoadedFor(budgetId) + setRows(null) + setError(null) + } + + useEffect(() => { + if (!budgetId) return + let cancelled = false + reportsApi + .budgetVsActual(budgetId) + .then((res) => { + if (!cancelled) setRows(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [budgetId]) + + const selectedBudget = useMemo(() => (budgets ?? []).find((b) => b.budgetId === budgetId) ?? null, [budgets, budgetId]) + + const totalBudgeted = (rows ?? []).reduce((sum, r) => sum + r.budgetedAmount, 0) + const totalActual = (rows ?? []).reduce((sum, r) => sum + r.actualAmount, 0) + const totalVariance = (rows ?? []).reduce((sum, r) => sum + r.variance, 0) + + return ( +
+
+ + + +
+

Budget vs Actual

+

Budgeted amounts against real postings per account/period.

+
+
+ + {budgetsError && ( +
{budgetsError}
+ )} + +
+
+ + value={budgetId} onValueChange={(v) => setBudgetId(v ?? "")}> + + + + + {(budgets ?? []).map((b) => ( + + {b.name} + + ))} + + +
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && !budgetId && ( +
+ +

Select a budget to compare against actuals.

+
+ )} + + {!error && budgetId && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && budgetId && rows !== null && rows.length === 0 && ( +
+ +

This budget has no lines yet.

+
+ )} + + {!error && budgetId && rows !== null && rows.length > 0 && ( +
+ + + + + Account + Budgeted + Actual + Variance + + + + {rows.map((row) => ( + + + {row.accountCode}{" "} + {row.accountName} + + {formatAmount(row.budgetedAmount)} + {formatAmount(row.actualAmount)} + + {formatAmount(row.variance)} + + + ))} + + + + Total + {formatAmount(totalBudgeted)} + {formatAmount(totalActual)} + {formatAmount(totalVariance)} + + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/cash-flow/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/cash-flow/page.tsx new file mode 100644 index 0000000..9468a69 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/cash-flow/page.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Wallet } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { + CashFlowFinancingActivities, + CashFlowInvestingActivities, + CashFlowResponse, + ReportType, +} from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" +import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection" +import { ReportSubtotal } from "@/components/reports/ReportSubtotal" + +/** + * Combines `operatingActivities.nonCashAdjustments` + `.workingCapitalChanges` into one labeled + * set, then buckets by sign — per docs/21-GENERAL-LEDGER-FRONTEND.md §3: "Additions to Cash" + * (amount >= 0) and "Subtractions From Cash" (amount < 0). A working-capital line's label uses its + * `direction` field ("Decrease in Trade Receivables"); a non-cash-adjustment line just prints its + * plain `description` ("Depreciation"), no Increase/Decrease prefix. + */ +function bucketOperatingLines(report: CashFlowResponse): { additions: ReportSectionLine[]; subtractions: ReportSectionLine[] } { + // GL omits these list fields entirely (rather than sending `[]`) when there's nothing to + // report for the period, instead of an empty array — confirmed live, not just a type-safety guard. + const operating = report.operatingActivities + const combined: ReportSectionLine[] = [ + ...(operating?.nonCashAdjustments ?? []).map((a) => ({ label: a.description, amount: a.amount })), + ...(operating?.workingCapitalChanges ?? []).map((w) => ({ + label: `${w.direction} in ${w.accountName}`, + amount: w.changeAmount, + })), + ] + return { + additions: combined.filter((l) => l.amount >= 0), + subtractions: combined.filter((l) => l.amount < 0), + } +} + +/** Same "GL omits empty list fields" defense as bucketOperatingLines — the section itself, + * or just its `lines[]`, may be missing entirely rather than `{ lines: [], ...: 0 }`. */ +function activitySectionLines( + section?: CashFlowInvestingActivities | CashFlowFinancingActivities | null +): ReportSectionLine[] { + return (section?.lines ?? []).map((l) => ({ label: l.description, amount: l.amount })) +} + +export default function CashFlowPage() { + const [periodStart, setPeriodStart] = useState(startOfMonthIso()) + const [periodEnd, setPeriodEnd] = useState(todayIso()) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + + // Clears stale results the instant the period changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const periodKey = `${periodStart}|${periodEnd}` + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== periodKey) { + setLoadedFor(periodKey) + setReport(null) + setError(null) + } + + useEffect(() => { + if (!periodStart || !periodEnd) return + let cancelled = false + reportsApi + .cashFlow(periodStart, periodEnd) + .then((res) => { + if (!cancelled) setReport(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [periodStart, periodEnd]) + + const buckets = report ? bucketOperatingLines(report) : null + + return ( +
+
+ + + +
+

Cash Flow

+

Statement of Cash Flows for a period.

+
+
+ +
+
+
+ + setPeriodStart(e.target.value)} className="h-11 text-base" /> +
+
+ + setPeriodEnd(e.target.value)} className="h-11 text-base" /> +
+
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && report === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && report !== null && buckets !== null && ( +
+ + +
+ + + + + + 1 + ? report.investingActivities?.netCashFromInvestingActivities + : undefined + } + /> + 1 + ? report.financingActivities?.netCashFromFinancingActivities + : undefined + } + /> + + +
+ +

+ + Opening/closing cash balances are computed by the General Ledger service but not shown on this + screen, matching GL's own PDF/CSV output. +

+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx new file mode 100644 index 0000000..5b6f70b --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx @@ -0,0 +1,148 @@ +"use client" + +import { Fragment, useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, BookOpen } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { GeneralLedgerRow, ReportType } from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" + +export default function GeneralLedgerReportPage() { + const [periodStart, setPeriodStart] = useState(startOfMonthIso()) + const [periodEnd, setPeriodEnd] = useState(todayIso()) + + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + // Clears stale results the instant the period changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const periodKey = `${periodStart}|${periodEnd}` + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== periodKey) { + setLoadedFor(periodKey) + setRows(null) + setError(null) + } + + useEffect(() => { + if (!periodStart || !periodEnd) return + let cancelled = false + reportsApi + .generalLedger(periodStart, periodEnd) + .then((res) => { + if (!cancelled) setRows(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [periodStart, periodEnd]) + + return ( +
+
+ + + +
+

General Ledger

+

Every posted movement on every account, with a running balance per account.

+
+
+ +
+
+
+ + setPeriodStart(e.target.value)} className="h-11 text-base" /> +
+
+ + setPeriodEnd(e.target.value)} className="h-11 text-base" /> +
+
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && rows === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && rows.length === 0 && ( +
+ +

No postings on any account in the selected period.

+
+ )} + + {!error && rows !== null && rows.length > 0 && ( +
+ + + + + Date + Journal No. + Narration + Debit + Credit + Running balance + + + + {rows.map((row, i) => { + const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode + return ( + + {isNewAccount && ( + + + {row.accountCode} — {row.accountName} + + + )} + + {formatReportDate(row.entryDate)} + {row.journalNo} + {row.narration ?? "—"} + {formatAmount(row.debitAmount, true)} + {formatAmount(row.creditAmount, true)} + {formatAmount(row.runningBalance)} + + + ) + })} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/page.tsx new file mode 100644 index 0000000..e9d3482 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/page.tsx @@ -0,0 +1,91 @@ +import Link from "next/link" +import { + BadgeDollarSign, + BookOpen, + Landmark, + LineChart, + PieChart, + Receipt, + Scale, + 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: "Trial Balance", + description: "Every postable account's debit/credit balance as at a given date.", + href: "/dashboard/ledgers/trial-balance", + icon: Scale, + }, + { + title: "Balance Sheet", + description: "Statement of Financial Position — Assets, Liabilities and Equity as at a date.", + href: "/dashboard/ledgers/balance-sheet", + icon: Landmark, + }, + { + title: "General Ledger", + description: "Every posted movement on one account across a date range, with running balance.", + href: "/dashboard/ledgers/general-ledger", + icon: BookOpen, + }, + { + title: "Profit & Loss", + description: "Statement of Profit or Loss — Income and Expense for a period.", + href: "/dashboard/ledgers/profit-and-loss", + icon: LineChart, + }, + { + title: "Cash Flow", + description: "Statement of Cash Flows — operating, investing and financing movement for a period.", + href: "/dashboard/ledgers/cash-flow", + icon: PieChart, + }, + { + title: "Budget vs Actual", + description: "Budgeted amounts against real postings per account/period, with variance.", + href: "/dashboard/ledgers/budget-vs-actual", + icon: BadgeDollarSign, + }, + { + title: "Tax Report", + description: "Income Tax Computation for a period, with optional adjustments.", + href: "/dashboard/ledgers/tax-report", + icon: Receipt, + }, +] + +export default function LedgersHubPage() { + return ( +
+
+

Ledgers

+

+ Statutory-format financial reports from the General Ledger service. +

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

{area.description}

+
+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx new file mode 100644 index 0000000..f6be336 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, LineChart } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { ProfitAndLossResponse, ProfitAndLossSection, ReportType } from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" +import { ReportSection } from "@/components/reports/ReportSection" +import { ReportSubtotal } from "@/components/reports/ReportSubtotal" + +function pnlSectionLines(section: ProfitAndLossSection | undefined) { + return (section?.lines ?? []).map((line) => ({ + label: ( + <> + {line.accountCode} {line.accountName} + + ), + amount: line.amount, + })) +} + +export default function ProfitAndLossPage() { + const [periodStart, setPeriodStart] = useState(startOfMonthIso()) + const [periodEnd, setPeriodEnd] = useState(todayIso()) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + + // Clears stale results the instant the period changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const periodKey = `${periodStart}|${periodEnd}` + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== periodKey) { + setLoadedFor(periodKey) + setReport(null) + setError(null) + } + + useEffect(() => { + if (!periodStart || !periodEnd) return + let cancelled = false + reportsApi + .profitAndLoss(periodStart, periodEnd) + .then((res) => { + if (!cancelled) setReport(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [periodStart, periodEnd]) + + // GL omits an empty/zero section entirely rather than sending `{ lines: [], total: 0 }` — + // confirmed on Cash Flow's equivalent fields, same inferred nested-section shape here. + const isEmpty = + report !== null && + (report.sales?.lines?.length ?? 0) === 0 && + (report.costOfSales?.lines?.length ?? 0) === 0 && + (report.otherIncome?.lines?.length ?? 0) === 0 && + (report.distributionExpenses?.lines?.length ?? 0) === 0 && + (report.administrationExpenses?.lines?.length ?? 0) === 0 && + (report.otherExpenses?.lines?.length ?? 0) === 0 && + (report.financialExpenses?.lines?.length ?? 0) === 0 && + (report.unclassified?.lines?.length ?? 0) === 0 + + return ( +
+
+ + + +
+

Profit & Loss

+

Statement of Profit or Loss — Income and Expense for a period.

+
+
+ +
+
+
+ + setPeriodStart(e.target.value)} className="h-11 text-base" /> +
+
+ + setPeriodEnd(e.target.value)} className="h-11 text-base" /> +
+
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && report === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && isEmpty && ( +
+ +

No Income/Expense accounts posted in this period.

+
+ )} + + {!error && report !== null && !isEmpty && ( +
+ + +
+ + + + + + + + + {report.unclassified && ( + + )} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/tax-report/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/tax-report/page.tsx new file mode 100644 index 0000000..84fc52d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/tax-report/page.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ChevronDown, ChevronRight, Receipt } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { ReportType, TaxSummaryResponse } from "@/types/general-ledger" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" + +// Fixed row order + Add:/Less: labels per the confirmed GL contract +// (04_API_Reference_And_Scenarios.md, Module: Reporting — `profitBeforeTax` through +// `balanceTaxPayable`) — GL sends plain numbers, not pre-formatted rows; the prefix/bold treatment +// lives here, not derived from sign. `taxRatePercent` is shown separately below the table (it's a +// percentage, not a currency amount) rather than run through `formatAmount` here. +const ROWS: { key: keyof TaxSummaryResponse; label: string; bold?: boolean }[] = [ + { key: "profitBeforeTax", label: "Profit Before Tax" }, + { key: "nonDeductibleExpenses", label: "Add: Non-Deductible Expenses" }, + { key: "allowableDeductions", label: "Less: Allowable Deductions" }, + { key: "adjustedBusinessProfit", label: "Adjusted Business Profit", bold: true }, + { key: "otherTaxableIncome", label: "Add: Other Taxable Income" }, + { key: "assessableIncome", label: "Assessable Income", bold: true }, + { key: "qualifyingPaymentsReliefs", label: "Less: Qualifying Payments / Reliefs" }, + { key: "taxableIncome", label: "Taxable Income", bold: true }, + { key: "corporateIncomeTax", label: "Corporate Income Tax" }, + { key: "surchargeAmount", label: "Add: Surcharge / Education Levy" }, + { key: "grossTaxLiability", label: "Gross Tax Liability", bold: true }, + { key: "apitCredit", label: "Less: APIT Credit" }, + { key: "whtCredit", label: "Less: WHT Credit" }, + { key: "quarterlyTaxPayments", label: "Less: Quarterly Tax Payments" }, +] + +export default function TaxReportPage() { + const [periodStart, setPeriodStart] = useState(startOfMonthIso()) + const [periodEnd, setPeriodEnd] = useState(todayIso()) + + const [adjustmentsOpen, setAdjustmentsOpen] = useState(false) + const [allowableDeductions, setAllowableDeductions] = useState("") + const [otherTaxableIncome, setOtherTaxableIncome] = useState("") + const [qualifyingPaymentsReliefs, setQualifyingPaymentsReliefs] = useState("") + const [surchargeAmount, setSurchargeAmount] = useState("") + const [taxRateOverride, setTaxRateOverride] = useState("") + + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + + // Optional inputs get no client-side default — an untouched field sends nothing (undefined, + // dropped by lib/api/general-ledger.ts's query builder), letting GL's own server-side + // defaulting be the single source of truth for what "not supplied" means. + const params = { + periodStart, + periodEnd, + allowableDeductions: allowableDeductions === "" ? undefined : Number(allowableDeductions), + otherTaxableIncome: otherTaxableIncome === "" ? undefined : Number(otherTaxableIncome), + qualifyingPaymentsReliefs: qualifyingPaymentsReliefs === "" ? undefined : Number(qualifyingPaymentsReliefs), + surchargeAmount: surchargeAmount === "" ? undefined : Number(surchargeAmount), + taxRateOverride: taxRateOverride === "" ? undefined : Number(taxRateOverride), + } + const paramsKey = JSON.stringify(params) + + // Clears stale results the instant a param changes, during the render that reacts to it — not + // inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== paramsKey) { + setLoadedFor(paramsKey) + setReport(null) + setError(null) + } + + useEffect(() => { + if (!periodStart || !periodEnd) return + let cancelled = false + reportsApi + .taxSummary(params) + .then((res) => { + if (!cancelled) setReport(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + // paramsKey covers every field inside params; re-running on params itself would compare by + // reference and fire every render, since it's a fresh object literal each time. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paramsKey, periodStart, periodEnd]) + + const isRefund = report !== null && report.balanceTaxPayable < 0 + const finalLabel = isRefund ? "BALANCE TAX REFUNDABLE" : "BALANCE TAX PAYABLE" + const finalAmount = report ? Math.abs(report.balanceTaxPayable) : 0 + + return ( +
+
+ + + +
+

Tax Report

+

Income Tax Computation for a period.

+
+
+ +
+
+
+ + setPeriodStart(e.target.value)} className="h-11 text-base" /> +
+
+ + setPeriodEnd(e.target.value)} className="h-11 text-base" /> +
+
+
+ + +
+
+ +
+ + {adjustmentsOpen && ( +
+
+ + setAllowableDeductions(e.target.value)} + placeholder="0" + className="h-10 text-base" + /> +
+
+ + setOtherTaxableIncome(e.target.value)} + placeholder="0" + className="h-10 text-base" + /> +
+
+ + setQualifyingPaymentsReliefs(e.target.value)} + placeholder="System default" + className="h-10 text-base" + /> +
+
+ + setSurchargeAmount(e.target.value)} + placeholder="0" + className="h-10 text-base" + /> +
+
+ + setTaxRateOverride(e.target.value)} + placeholder="System default" + className="h-10 text-base" + /> +
+ {(allowableDeductions || + otherTaxableIncome || + qualifyingPaymentsReliefs || + surchargeAmount || + taxRateOverride) && ( +
+ +
+ )} +
+ )} +
+ + {error && ( +
{error}
+ )} + + {!error && report === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && report !== null && ( +
+ {/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller + identity block (company name/address/TIN/BRN) that today lives only in GL's own + appsettings.json, with no endpoint exposing it. Deliberately not fabricated here — + see docs/21-GENERAL-LEDGER-FRONTEND.md §3's open question. */} +
+

+ General Ledger +

+

Income Tax Computation

+

+ For the period {formatReportDate(periodStart)} to {formatReportDate(periodEnd)} +

+

+ Company identity (name/address/TIN/BRN) isn't shown here — GL exposes no endpoint for it yet. +

+
+ +
+ +

+ All amounts in Sri Lankan Rupees (LKR) unless stated otherwise. +

+
+ + + + {ROWS.map((row) => ( + + {row.label} + + {formatAmount(report[row.key] as number)} + + + ))} + + {finalLabel} + + {formatAmount(finalAmount)} + + + +
+ +

+ Tax rate applied: {report.taxRatePercent}% +

+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx new file mode 100644 index 0000000..b2640a4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Scale } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate, todayIso } from "@/lib/format" +import { cn } from "@/lib/utils" +import { ReportType, TrialBalanceRow } from "@/types/general-ledger" + +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton" +import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton" +import { ReportHeader } from "@/components/reports/ReportHeader" + +export default function TrialBalancePage() { + const [asOfDate, setAsOfDate] = useState(todayIso()) + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + // Clears stale results the instant asOfDate changes, during the render that reacts to it — + // not inside the effect below, which would be a synchronous setState-in-effect + // (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during + // render" pattern instead. + const [loadedFor, setLoadedFor] = useState(null) + if (loadedFor !== asOfDate) { + setLoadedFor(asOfDate) + setRows(null) + setError(null) + } + + useEffect(() => { + if (!asOfDate) return + let cancelled = false + reportsApi + .trialBalance(asOfDate) + .then((res) => { + if (!cancelled) setRows(res) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + return () => { + cancelled = true + } + }, [asOfDate]) + + const totalDebit = (rows ?? []).reduce((sum, r) => sum + r.debit, 0) + const totalCredit = (rows ?? []).reduce((sum, r) => sum + r.credit, 0) + + return ( +
+
+ + + +
+

Trial Balance

+

Every postable account's balance as at a date.

+
+
+ +
+
+ + setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" /> +
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!error && rows === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && rows.length === 0 && ( +
+ +

No postable accounts as at this date.

+
+ )} + + {!error && rows !== null && rows.length > 0 && ( +
+ + + + + Account + Debit + Credit + + + + {rows.map((row, i) => ( + + + {row.accountCode}{" "} + {row.accountName} + + {formatAmount(row.debit, true)} + {formatAmount(row.credit, true)} + + ))} + + + + Total + {formatAmount(totalDebit)} + {formatAmount(totalCredit)} + + +
+
+ )} +
+ ) +} 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 index 65c6a6b..a51cde1 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -3,8 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" - +import { AlertTriangle, ArrowLeft, Ban, Check, 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" @@ -195,10 +194,12 @@ export default function PurchaseOrderDetailPage() { const updated = await purchaseOrdersApi.submit(po.poId) setPo(updated) setLines(toDraftLines(updated)) - toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`) + toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`) + toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`) } catch (err) { setSaveError(errorMessage(err)) - toast.error("Could not submit purchase order", errorMessage(err)) + toast.error("Could not approve purchase order", errorMessage(err)) + toast.error("Could not approve purchase order", errorMessage(err)) } finally { setSubmitting(false) } @@ -269,9 +270,6 @@ export default function PurchaseOrderDetailPage() {
- - -

{po.docNo}

@@ -286,9 +284,12 @@ export default function PurchaseOrderDetailPage() {
{po.status === "Draft" && ( <> - + +
+ + +
{requisitionId && (
From Requisition #{requisitionId}
@@ -290,10 +386,22 @@ function NewPurchaseOrderContent() {

Lines

- +
+ + + New item + + +
{lines.length > 0 && ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx index bf4b352..7676f2e 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react" +import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react" -import { purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement" @@ -16,6 +16,7 @@ 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 { toast } from "@/components/ui/toast" import { PoStatusBadge } from "@/components/procurement/status-badges" type StatusFilter = PurchaseOrderStatus | "All" @@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + const [deletingId, setDeletingId] = useState(null) + const [approvingId, setApprovingId] = useState(null) useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) @@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() { return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` } + async function handleDelete(po: PurchaseOrderSummary) { + if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return + setDeletingId(po.poId) + try { + await purchaseOrdersApi.remove(po.poId) + toast.success("Draft deleted", po.docNo) + load() + } catch (err) { + toast.error("Could not delete purchase order", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + async function handleApprove(po: PurchaseOrderSummary) { + if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return + setApprovingId(po.poId) + try { + const updated = await purchaseOrdersApi.submit(po.poId) + toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`) + load() + } catch (err) { + toast.error("Could not approve purchase order", errorMessage(err)) + } finally { + setApprovingId(null) + } + } + const hasFilters = query.length > 0 || status !== "All" return ( @@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() { Status Grand total Created + Actions - {pos.map((po) => ( - - - - {po.docNo} - - - {vendorCode(po.vendorId)} - - - - {po.totals.currency} {po.totals.grandTotal.toFixed(2)} - {new Date(po.createdAt).toLocaleString()} - - ))} + {pos.map((po) => { + const editable = isPoEditable(po.status) + return ( + + + + {po.docNo} + + + {vendorCode(po.vendorId)} + + + + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {new Date(po.createdAt).toLocaleString()} + +
+ + + + {editable && ( + <> + + + + + + + )} +
+
+
+ ) + })}
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 index 324cfe1..4c4370b 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useEffect, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import {} from "lucide-react" import { purchaseReturnsApi } from "@/lib/api/purchase-returns" import { grnsApi } from "@/lib/api/grns" @@ -156,9 +156,6 @@ function NewPurchaseReturnContent() { return (
- - -

New Purchase Return

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

diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx index e74c1b8..51fec29 100644 --- a/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react" +import { FileText, Send, ShoppingCart } from "lucide-react" import { requisitionsApi } from "@/lib/api/requisitions" import { itemsApi } from "@/lib/api/items" @@ -74,9 +74,6 @@ export default function RequisitionDetailPage() {
- - -

{requisition.docNo}

diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx index a6143d3..b15f1dc 100644 --- a/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Plus, Trash2 } from "lucide-react" +import { Plus, Trash2 } from "lucide-react" import { requisitionsApi } from "@/lib/api/requisitions" import { itemsApi } from "@/lib/api/items" @@ -103,9 +103,6 @@ export default function NewRequisitionPage() { return (
- - -

New Requisition

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

diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx index ea3de47..1c4ddaf 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { ArrowLeft, ShoppingCart } from "lucide-react" +import { ShoppingCart } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" import { vendorsApi } from "@/lib/api/vendors" @@ -158,9 +158,6 @@ export default function RfqDetailPage() { return (
- - -

{rfq.docNo}

diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx index cfb8bcb..c7bc58f 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -3,7 +3,7 @@ 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 { Plus, Trash2 } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" import { requisitionsApi } from "@/lib/api/requisitions" @@ -149,9 +149,6 @@ function NewRfqContent() { return (
- - -

New RFQ

diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx index 825740d..613f95b 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -1,6 +1,6 @@ "use client" -import { Plus, Trash2, X } from "lucide-react" +import { Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" import { CustomFieldType, StageInputSource } from "@/types/production" @@ -15,6 +15,7 @@ import { } from "./types" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Field, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -175,265 +176,264 @@ export function StageEditorPanel({ } return ( -

-
-

Stage editor

- -
+ !next && onClose()}> + + + Stage editor + -
- - Name - onChange({ name: e.target.value })} placeholder="e.g. Welding" /> - +
+ + Name + onChange({ name: e.target.value })} placeholder="e.g. Welding" /> + - - Role label - onChange({ roleLabel: e.target.value })} - placeholder="e.g. QA" - list="role-suggestions" - /> - - {ROLE_SUGGESTIONS.map((r) => ( - - + + Role label + onChange({ roleLabel: e.target.value })} + placeholder="e.g. QA" + list="role-suggestions" + /> + + {ROLE_SUGGESTIONS.map((r) => ( + + - - Estimated minutes - onChange({ estimatedMinutes: Number(e.target.value) || 0 })} - /> - + + Estimated minutes + onChange({ estimatedMinutes: Number(e.target.value) || 0 })} + /> + - {/* Inputs */} -
-
-

Inputs

- {!readOnly && ( - - )} -
-
- {data.inputs.length === 0 &&

No inputs yet.

} - {data.inputs.map((input) => ( -
-
- - value={input.source} - onValueChange={(v) => v && changeInputSource(input.localId, v)} - > - - - - - Stock - Upstream - - - {!readOnly && ( - - )} -
- -
- {input.source === "Stock" ? ( - - value={input.itemId} - onValueChange={(v) => v && pickInputItem(input, v)} + {/* Inputs */} +
+
+

Inputs

+ {!readOnly && ( + + )} +
+
+ {data.inputs.length === 0 &&

No inputs yet.

} + {data.inputs.map((input) => ( +
+
+ + value={input.source} + onValueChange={(v) => v && changeInputSource(input.localId, v)} > - - - - - {items.map((i) => ( - - {i.name} · {i.sku} - - ))} - - - ) : ( - - value={input.fromOutputKey} - onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })} - > - - - - - {upstreamOptions.map((o) => ( - - {o.stageName} — {o.outputName} - - ))} - - - )} - - updateInput(input.localId, { qtyPerBatch })} - onUomChange={(uomId) => updateInput(input.localId, { uomId })} - /> -
-
- ))} -
-
- - {/* Outputs */} -
-
-

- Outputs{isTerminal && (terminal — finished good)} -

- {!readOnly && ( - - )} -
-
- {data.outputs.length === 0 &&

No outputs yet.

} - {data.outputs.map((output) => ( -
-
- {isTerminal ? ( - value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}> - + - {items.map((i) => ( - - {i.name} · {i.sku} - - ))} + Stock + Upstream - ) : ( - updateOutput(output.key, { name: e.target.value })} - placeholder="Output name (work in progress)" - className="h-8 flex-1 text-sm" - /> - )} - {!readOnly && ( - - )} -
- updateOutput(output.key, { qtyPerBatch })} - onUomChange={(uomId) => updateOutput(output.key, { uomId })} - /> -
- ))} -
-
+ {!readOnly && ( + + )} +
- {/* Custom fields */} -
-
-

Custom fields

- {!readOnly && ( - - )} -
-
- {data.fieldDefs.length === 0 &&

No custom fields.

} - {data.fieldDefs.map((field) => ( -
-
- updateField(field.localId, { label: e.target.value })} - placeholder="Label" - className="h-8 flex-1 text-sm" - /> - {!readOnly && ( - - )} -
- {field.key &&

key: {field.key}

} -
- - value={field.type} - onValueChange={(v) => v && updateField(field.localId, { type: v })} - > - - - - - {FIELD_TYPES.map((t) => ( - {t} - ))} - - -
- updateField(field.localId, { required: checked })} +
+ {input.source === "Stock" ? ( + + value={input.itemId} + onValueChange={(v) => v && pickInputItem(input, v)} + > + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + + value={input.fromOutputKey} + onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })} + > + + + + + {upstreamOptions.map((o) => ( + + {o.stageName} — {o.outputName} + + ))} + + + )} + + updateInput(input.localId, { qtyPerBatch })} + onUomChange={(uomId) => updateInput(input.localId, { uomId })} /> - Required
- {field.type === "Select" && ( - updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} - placeholder="Options, comma separated" - className="mt-2 h-8 text-sm" - /> - )} -
- ))} + ))} +
-
- {!readOnly && ( - - )} -
-
+ {/* Outputs */} +
+
+

+ Outputs{isTerminal && (terminal — finished good)} +

+ {!readOnly && ( + + )} +
+
+ {data.outputs.length === 0 &&

No outputs yet.

} + {data.outputs.map((output) => ( +
+
+ {isTerminal ? ( + value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}> + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + updateOutput(output.key, { name: e.target.value })} + placeholder="Output name (work in progress)" + className="h-8 flex-1 text-sm" + /> + )} + {!readOnly && ( + + )} +
+ updateOutput(output.key, { qtyPerBatch })} + onUomChange={(uomId) => updateOutput(output.key, { uomId })} + /> +
+ ))} +
+
+ + {/* Custom fields */} +
+
+

Custom fields

+ {!readOnly && ( + + )} +
+
+ {data.fieldDefs.length === 0 &&

No custom fields.

} + {data.fieldDefs.map((field) => ( +
+
+ updateField(field.localId, { label: e.target.value })} + placeholder="Label" + className="h-8 flex-1 text-sm" + /> + {!readOnly && ( + + )} +
+ {field.key &&

key: {field.key}

} +
+ + value={field.type} + onValueChange={(v) => v && updateField(field.localId, { type: v })} + > + + + + + {FIELD_TYPES.map((t) => ( + {t} + ))} + + +
+ updateField(field.localId, { required: checked })} + /> + Required +
+
+ {field.type === "Select" && ( + updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} + placeholder="Options, comma separated" + className="mt-2 h-8 text-sm" + /> + )} +
+ ))} +
+
+ + {!readOnly && ( + + )} +
+ +
) } diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx index 8a19847..54f030d 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -20,7 +20,7 @@ import { } from "@xyflow/react" import "@xyflow/react/dist/style.css" import { useTheme } from "next-themes" -import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react" +import { AlertTriangle, Lock, Minus, Plus, Save, Square } from "lucide-react" import { cn } from "@/lib/utils" import { productionTemplatesApi } from "@/lib/api/production-templates" @@ -643,9 +643,6 @@ export default function TemplateBuilderPage() {
- - -

{name || "Untitled template"}

@@ -768,46 +765,44 @@ export default function TemplateBuilderPage() {
)} -
-
- {mounted && ( - - - - - - )} -
- - {selectedNode && ( - updateNodeData(selectedNode.id, patch)} - onDelete={() => deleteNode(selectedNode.id)} - onClose={() => setSelectedNodeId(null)} - /> +
+ {mounted && ( + + + + + )}
+ + {selectedNode && ( + updateNodeData(selectedNode.id, patch)} + onDelete={() => deleteNode(selectedNode.id)} + onClose={() => setSelectedNodeId(null)} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 9edce27..c426249 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Save } from "lucide-react" +import { AlertTriangle, Save } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" @@ -183,9 +183,6 @@ export default function ItemDetailPage() {
- - -

{item.sku}

diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx index 0541ec0..b753a75 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -1,19 +1,17 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" import { useParams } from "next/navigation" -import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" +import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" import { categoriesApi, subCategoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" import { validateCategoryName } from "@/lib/validations/master-data" -import { cn } from "@/lib/utils" import { Category, SubCategory } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" @@ -113,9 +111,6 @@ export default function CategorySubCategoriesPage() {
- - -

{category ? `${category.name} — Subcategories` : "Subcategories"}

@@ -171,13 +166,13 @@ export default function CategorySubCategoriesPage() { {!error && subCategories !== null && subCategories.length > 0 && ( - - - ID - Name - Status - Created At - Actions + + + ID + Name + Status + Created At + Actions diff --git a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx index 1381bcc..eac3119 100644 --- a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -1,18 +1,16 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" -import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react" +import { Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react" import { itemTypesApi } from "@/lib/api/item-types" import { errorMessage } from "@/lib/error-map" import { validateItemTypeName } from "@/lib/validations/master-data" -import { cn } from "@/lib/utils" import { ItemType } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } 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" @@ -110,9 +108,6 @@ export default function ItemTypesPage() {
- - -

Item Types

@@ -174,13 +169,13 @@ export default function ItemTypesPage() { {!error && itemTypes !== null && itemTypes.length > 0 && (

- - - ID - Name - Status - Created At - Actions + + + ID + Name + Status + Created At + Actions diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index 37ff5f3..fa24983 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Plus, X } from "lucide-react" +import { Plus, X } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" @@ -254,9 +254,6 @@ export default function NewItemPage() { return (
- - -

New Item

Category, subcategory, brand, and item types (FR-MD-01).

@@ -532,14 +529,14 @@ export default function NewItemPage() { {variants.length > 0 && (
- - + + {activeCategories.map((cat) => ( - {cat.name} + {cat.name} ))} - SKU + SKU {priceMode === "fixed" && ( - Sale price + Sale price )} {/* Quantity column removed 2026-07-17: there is no `initialQty` on the Item contract and no initial-receipt flow — stock arrives via a GRN. diff --git a/Frontend/erp-system/app/dashboard/products/settings/page.tsx b/Frontend/erp-system/app/dashboard/products/settings/page.tsx index ba369e9..01c5d80 100644 --- a/Frontend/erp-system/app/dashboard/products/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/settings/page.tsx @@ -1,15 +1,12 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" -import { ArrowLeft, Package } from "lucide-react" +import { Package } from "lucide-react" import { productConfigApi } from "@/lib/api/product-config" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { ProductConfig } from "@/types/master-data" -import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { toast } from "@/components/ui/toast" @@ -70,9 +67,6 @@ export default function ProductSettingsPage() { return (
- - -

Product Configuration

diff --git a/Frontend/erp-system/app/dashboard/products/uoms/page.tsx b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx index 960cb72..8ce3c1e 100644 --- a/Frontend/erp-system/app/dashboard/products/uoms/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx @@ -1,16 +1,14 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" -import { ArrowLeft, Plus, Ruler } from "lucide-react" +import { 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 { Button } 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" @@ -59,9 +57,6 @@ export default function UomsPage() {

- - -

Units of Measure

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

diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 26d1b7d..4d66b46 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" +import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" import { grnsApi } from "@/lib/api/grns" import { warehousesApi } from "@/lib/api/warehouses" @@ -118,9 +118,6 @@ export default function GrnDetailPage() {
- - -

{grn.docNo}

diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 292283b..f0a3d71 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react" +import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react" import { grnsApi } from "@/lib/api/grns" import { purchaseOrdersApi } from "@/lib/api/purchase-orders" @@ -303,9 +303,6 @@ export default function NewGrnPage() { return (
- - -

New GRN

Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).

@@ -446,7 +443,7 @@ export default function NewGrnPage() {
- Item + Item UOM Bin Qty @@ -454,7 +451,7 @@ export default function NewGrnPage() { Disc % VAT % Line total - Hold status + Hold status Batch / Serial diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx new file mode 100644 index 0000000..3efe88f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx @@ -0,0 +1,355 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { useParams, useRouter } from "next/navigation" +import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react" + +import { bundleApi } from "@/lib/api/bundles" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles" +import { customersApi } from "@/lib/api/customers" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" + +type EditableLine = BundleSaleTemplateLine & { key: string } + +function statusClass(status: BundleSale["status"]) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function BundleSaleDetailPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const bundleSaleId = Number(params.id) + const [bundle, setBundle] = useState(null) + const [editing, setEditing] = useState(false) + const [templates, setTemplates] = useState([]) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [templateId, setTemplateId] = useState(null) + const [bundleName, setBundleName] = useState("") + const [bundlePrice, setBundlePrice] = useState(0) + const [allowPriceOverride, setAllowPriceOverride] = useState(false) + const [lines, setLines] = useState([]) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!Number.isFinite(bundleSaleId)) { + setError(`Invalid bundle id '${params.id}'.`) + return + } + Promise.all([ + bundleApi.getBundle(bundleSaleId), + bundleApi.listTemplates({ pageSize: 200 }), + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => { + const data = bundleRes + setBundle(data) + setTemplates(templateRes.items) + setCustomers(custRes.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setCustomerId(data.customerId) + setWarehouseId(data.warehouseId) + setCashierUserId(data.cashierUserId) + setTemplateId(data.bundleSaleTemplateId) + setBundleName(data.bundleName) + setBundlePrice(data.bundlePrice) + setLines( + data.lines.map((line) => ({ + key: `${line.bundleSaleLineId}`, + bundleSaleTemplateLineId: line.bundleSaleLineId, + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + unitPrice: line.unitPrice, + includeInBundle: line.includeInBundle, + sortOrder: line.bundleSaleLineId, + })) + ) + }) + .catch((err) => setError(errorMessage(err))) + }, [bundleSaleId, params.id]) + + const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId]) + const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines]) + const isDraft = bundle?.status === "Draft" + const canEdit = isDraft + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function addLine() { + const source = lines[lines.length - 1] + if (!source) return + setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function saveBundle() { + if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return + setBusy(true) + setError(null) + try { + const request: UpdateBundleSaleRequest = { + customerId, + warehouseId, + cashierUserId, + bundleSaleTemplateId: templateId, + bundleName, + bundlePrice, + allowPriceOverride, + lines: lines.map(({ key, ...line }) => line), + } + const res = await bundleApi.updateBundle(bundle.bundleSaleId, request) + setBundle(res) + setEditing(false) + router.refresh() + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function postBundle() { + if (!bundle) return + setBusy(true) + setError(null) + try { + const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId) + if (!check.canPost) { + setError("Resolve stock shortages before posting this bundle.") + return + } + const updated = await bundleApi.postBundle(bundle.bundleSaleId) + setBundle({ ...bundle, ...updated }) + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function cancelBundle() { + if (!bundle) return + setBusy(true) + setError(null) + try { + const updated = await bundleApi.cancelBundle(bundle.bundleSaleId) + setBundle({ ...bundle, ...updated }) + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(false) + } + } + + if (error && !bundle) return
{error}
+ if (!bundle) return
Loading bundle sale...
+ + const printHref = `/print/sales/bundles/${bundle.bundleSaleId}` + + return ( +
+
+
+ + + +
+

{bundle.bundleNo}

+

{bundle.bundleName}

+
+
+
+ + + Print + + {canEdit ? ( + editing ? ( + + ) : ( + + ) + ) : null} + {isDraft ? ( + <> + + + + ) : null} +
+
+ + {error ?
{error}
: null} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setBundleName(e.target.value)} disabled={!editing || !isDraft} /> +
+
+ + setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} /> +
+
+
+ +
+
+
+

Component breakdown

+

{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}

+
+ {editing && isDraft ? : {bundle.status}} +
+
+
+ + + Item + UOM + Qty + Unit price + Include + {editing && isDraft ? : null} + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + {line.includeInBundle ? "Yes" : "No"} + {editing && isDraft ? : null} + + ))} + +
+

+ + +
+
+
Component subtotal
{componentSubtotal.toFixed(2)}
+
Bundle price
{bundlePrice.toFixed(2)}
+
Margin
{(bundlePrice - componentSubtotal).toFixed(2)}
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx new file mode 100644 index 0000000..42337bf --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx @@ -0,0 +1,288 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save } from "lucide-react" + +import { bundleApi } from "@/lib/api/bundles" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { toast } from "@/components/ui/toast" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles" + +type EditableLine = BundleSaleTemplateLine & { key: string } + +const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() }) + +export default function NewBundleSalePage() { + const router = useRouter() + const searchParams = useSearchParams() + const templateFromQuery = searchParams.get("templateId") + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [templates, setTemplates] = useState([]) + const [template, setTemplate] = useState(null) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [templateId, setTemplateId] = useState(templateFromQuery ? Number(templateFromQuery) : null) + const [bundleName, setBundleName] = useState("Demo Bundle") + const [bundlePrice, setBundlePrice] = useState(0) + const [allowPriceOverride, setAllowPriceOverride] = useState(false) + const [lines, setLines] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [submitError, setSubmitError] = useState(null) + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + bundleApi.listTemplates({ pageSize: 200 }), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setTemplates(templateRes.items) + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(whRes.items[0]?.warehouseId ?? null) + setCashierUserId(userRes.items[0]?.userId ?? null) + setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null) + }) + .catch((err) => setSubmitError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + if (!templateId) return + bundleApi.getTemplate(templateId).then((res) => { + setTemplate(res) + setLines(res.lines.map(blankLine)) + setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0)) + }).catch((err) => setSubmitError(errorMessage(err))) + }, [templateId]) + + const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template]) + const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines]) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function addLine() { + const source = lines[lines.length - 1] ?? template?.lines[0] + if (!source) return + setLines((prev) => [...prev, blankLine(source)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function submit() { + if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) { + setSubmitError("Select customer, warehouse, cashier, and bundle template.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one bundle component line.") + return + } + setSaving(true) + setSubmitError(null) + try { + const request: CreateBundleSaleRequest = { + customerId, + warehouseId, + cashierUserId, + bundleSaleTemplateId: templateId, + bundleName, + bundlePrice, + allowPriceOverride, + lines: lines.map(({ key, ...line }) => line), + } + const res = await bundleApi.createBundle(request) + toast.success("Bundle saved", res.bundleNo) + router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loading) return
Loading masters...
+ + return ( +
+
+ + + +
+

Create bundle sale

+

Create a fixed bundle from a stored template.

+
+
+ + {submitError ?
{submitError}
: null} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setBundleName(e.target.value)} /> +
+
+ + setBundlePrice(Number(e.target.value))} /> +
+
+ + +
+
+
+ +
+
+
+

Editable component rows

+

These rows are sent to the backend and stored with the bundle.

+
+ +
+
+ + + + Item + UOM + Qty + Unit price + Include + + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> + updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /> + {line.includeInBundle ? "Yes" : "No"} + + + + + ))} + +
+
+
+ +
+
+
Component subtotal
{componentSubtotal.toFixed(2)}
+
Bundle price
{bundlePrice.toFixed(2)}
+
Margin
{(bundlePrice - componentSubtotal).toFixed(2)}
+
+
+ +
+ Cancel + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx new file mode 100644 index 0000000..927fdc0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx @@ -0,0 +1,292 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react" + +import { bundleApi } from "@/lib/api/bundles" +import { customersApi } from "@/lib/api/customers" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +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" +import { PaginationMeta } from "@/types/common" +import { Customer } from "@/types/customers" +import { Warehouse } from "@/types/master-data" +import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles" +import { cn } from "@/lib/utils" + +type StatusFilter = BundleSaleStatus | "All" + +const PAGE_SIZE = 10 +const tabs = ["All", "Draft", "Posted", "Cancelled"] as const + +function statusClass(status: BundleSaleStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function BundleSalesPage() { + const [rows, setRows] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [showFilters, setShowFilters] = useState(false) + const [customers, setCustomers] = useState([]) + const [warehouses, setWarehouses] = useState([]) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status, customerId, warehouseId]) + + useEffect(() => { + Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })]) + .then(([cust, whRes]) => { + setCustomers(cust.items) + setWarehouses(whRes.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + useEffect(() => { + setError(null) + bundleApi + .listBundles({ + page, + pageSize: PAGE_SIZE, + status: status === "All" ? undefined : status, + q: query || undefined, + customerId: customerId ?? undefined, + warehouseId: warehouseId ?? undefined, + }) + .then((res) => { + setRows(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + }, [page, status, query, customerId, warehouseId]) + + const visibleRows = useMemo(() => rows ?? [], [rows]) + const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null + const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0) + const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}` + + return ( +
+
+
+

Bundle Sales

+

Fixed bundle register with draft, posted, and cancelled states.

+
+
+ + + Print batch + + + + New Bundle + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+
+ setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" /> + +
+
+ + {showFilters && ( +
+
+
Customer
+ +
+
+
Warehouse
+ +
+
+ +
+
+ )} + + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Bundle + Customer + Date + Price + Grand + Status + View + + + + {visibleRows.map((row) => ( + + + + {row.bundleNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {row.bundlePrice.toFixed(2)} + {row.grandTotal.toFixed(2)} + + + {row.status} + + + +
+ + + + + + +
+
+
+ ))} +
+
+
+ +
+
+
+
Rows loaded
+
{visibleRows.length}
+
+
+
Grand total
+
{bundleTotal.toFixed(2)}
+
+
+
+ + {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/sales/bundles/register/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/register/page.tsx new file mode 100644 index 0000000..ade96a5 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/register/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function BundleRegisterPage() { + redirect("/dashboard/sales/bundles") +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx new file mode 100644 index 0000000..b6022c6 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx @@ -0,0 +1,27 @@ +"use client" + +import Link from "next/link" +import { ArrowLeft, FileText } from "lucide-react" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +export default function BundleReportsPage() { + return ( +
+
+ + + +
+

Bundle Reports

+

Bundle-level reporting will be added after the backend module is wired.

+
+
+
+ + This screen is a placeholder for bundle sales reporting. +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx new file mode 100644 index 0000000..2dced6d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx @@ -0,0 +1,494 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { toast } from "@/components/ui/toast" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" + +type Line = CreateSalesSlipLineRequest & { key: string } + +type FreeIssueRow = { + salesSlipId: number + slipNo: string + status: string + etag: string + warehouseName: string + itemName: string + itemSku: string + uomName: string + qty: number + freeQty: number +} + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +export default function NewFreeIssuePage() { + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [rows, setRows] = useState([]) + const [editingRowId, setEditingRowId] = useState(null) + const [editingLines, setEditingLines] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + async function refreshRows() { + const list = await salesApi.listFreeIssues({ pageSize: 50 }) + const details = await Promise.all( + list.items.map(async (summary) => { + const detail = await salesApi.getFreeIssue(summary.salesSlipId) + const firstLine = detail.data.lines[0] + const item = items.find((x) => x.itemId === firstLine?.itemId) + const uom = uoms.find((x) => x.uomId === firstLine?.uomId) + const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId) + return { + salesSlipId: detail.data.salesSlipId, + slipNo: detail.data.slipNo, + status: detail.data.status, + etag: detail.etag ?? "", + warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`, + itemName: item?.name ?? firstLine?.description ?? "—", + itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`, + uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`, + qty: firstLine?.qty ?? 0, + freeQty: firstLine?.freeQty ?? 0, + } satisfies FreeIssueRow + }), + ) + setRows(details.filter((row) => row.status !== "Cancelled")) + } + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(async ([cust, itemRes, uomRes, whRes, userRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(whRes.items[0]?.warehouseId ?? null) + setCashierUserId(userRes.items[0]?.userId ?? null) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: whRes.items[0]?.warehouseId ?? 0, + }, + ]) + await refreshRows() + }) + .catch((err) => setError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateEditingLine(key: string, patch: Partial) { + setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + } + + function selectEditingItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + } + + async function submit() { + const activeLines = editingRowId ? editingLines : lines + if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.") + if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.") + if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.") + if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.") + + setSaving(true) + setError(null) + try { + const payload: CreateSalesSlipRequest = { + customerId, + warehouseId, + cashierUserId, + lines: activeLines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId ?? null, + })), + } + + if (editingRowId) { + const latest = await salesApi.getFreeIssue(editingRowId) + if (!latest.etag) throw new Error("Missing ETag for free issue update.") + await salesApi.updateFreeIssue(editingRowId, payload, latest.etag) + toast.success("Free issue updated") + setEditingRowId(null) + setEditingLines([]) + } else { + const created = await salesApi.createFreeIssue(payload) + toast.success("Free issue created", created.data.slipNo) + } + + setLines([blankLine("line-1")]) + await refreshRows() + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function startEdit(row: FreeIssueRow) { + try { + const detail = await salesApi.getFreeIssue(row.salesSlipId) + const detailLine = detail.data.lines[0] + setEditingRowId(row.salesSlipId) + setCustomerId(detail.data.customerId) + setWarehouseId(detail.data.warehouseId) + setCashierUserId(detail.data.cashierUserId) + setEditingLines([ + { + key: "edit-line-1", + itemId: detailLine?.itemId ?? 0, + uomId: detailLine?.uomId ?? 0, + warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId, + qty: detailLine?.qty ?? 1, + freeQty: detailLine?.freeQty ?? 0, + unitPrice: detailLine?.unitPrice ?? null, + allowManualPriceOverride: true, + discountMode: detailLine?.discountMode ?? "Percentage", + discountPct: detailLine?.discountPct ?? 0, + discountAmount: detailLine?.discountAmount ?? 0, + discountValue: 0, + taxPct: detailLine?.taxPct ?? 0, + isFreeIssue: detailLine?.isFreeIssue ?? false, + parentLineId: detailLine?.parentLineId ?? null, + }, + ]) + } catch (err) { + setError(errorMessage(err)) + } + } + + function cancelEdit() { + setEditingRowId(null) + setEditingLines([]) + } + + async function deleteRow(row: FreeIssueRow) { + if (row.status !== "Draft") return + try { + await salesApi.cancelFreeIssue(row.salesSlipId) + toast.success("Free issue cancelled", row.slipNo) + if (editingRowId === row.salesSlipId) cancelEdit() + await refreshRows() + } catch (err) { + setError(errorMessage(err)) + } + } + + if (loading) { + return
Loading masters...
+ } + + const activeLines = editingRowId ? editingLines : lines + + return ( +
+
+
+ + + +
+

Free Issues

+

Create and manage free-issue sales slips from the backend.

+
+
+ +
+ + {error ?
{error}
: null} + + {editingRowId ? ( +
+
+

Inline edit free issue

+ +
+
+ + + + ID + Item + UOM + Qty + Free + + + + + {activeLines.map((line, idx) => ( + + {idx + 1} + + + + + + + + updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + editing + + ))} + +
+
+
+ ) : ( +
+
+

Free issue lines

+ +
+
+ + + + ID + Item + UOM + Qty + Free + + + + + {lines.map((line, idx) => ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + + + + ))} + +
+
+
+ )} + +
+
+
+

Created free issues

+

Persisted backend records with draft-only cancellation.

+
+ +
+
+ + + + Promotion + Warehouse + Product + Qty + Free + Actions + + + + {rows.length > 0 ? ( + rows.map((row) => ( + + +
Buy {row.qty} Get {row.freeQty || 0}
+
{row.slipNo}
+
+ {row.warehouseName} + +
{row.itemName}
+
{row.itemSku} · {row.uomName}
+
+ {row.qty} + {row.freeQty} + +
+ + {row.status === "Draft" ? ( + + ) : null} +
+
+
+ )) + ) : ( + + + No free issues created yet. + + + )} +
+
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx new file mode 100644 index 0000000..474a1ae --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx @@ -0,0 +1,3 @@ +"use client" + +export { default } from "./new/page" diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx new file mode 100644 index 0000000..269be9b --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -0,0 +1,601 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales" +import { toast } from "@/components/ui/toast" +import { Label } from "@/components/ui/label" + +type Line = CreateSalesInvoiceLineRequest & { key: string } + +const money = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +function statusClass(status: SalesInvoiceStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { + const router = useRouter() + const resolvedParams = use(params) + const invoiceId = Number(resolvedParams.id) + + const [invoice, setInvoice] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [lines, setLines] = useState([blankLine("line-1")]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [invoiceType, setInvoiceType] = useState("B2C") + const [etag, setEtag] = useState(null) + const [postingCheck, setPostingCheck] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [busy, setBusy] = useState<"post" | "cancel" | null>(null) + + useEffect(() => { + if (!Number.isFinite(invoiceId)) { + setError(`Invalid invoice id '${resolvedParams.id}'.`) + return + } + + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.getInvoice(invoiceId), + ]) + .then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => { + setCustomers(customerRes.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(warehouseRes.items) + setInvoice(doc.data) + setEtag(doc.etag) + setCustomerId(doc.data.customerId) + setWarehouseId(doc.data.warehouseId) + setInvoiceType(doc.data.invoiceType) + setLines( + doc.data.lines.map((line) => ({ + key: String(line.salesInvoiceLineId), + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + freeQty: line.freeQty, + unitPrice: line.unitPrice, + allowManualPriceOverride: true, + discountMode: line.discountMode, + discountPct: line.discountPct, + discountAmount: line.discountAmount, + discountValue: 0, + taxPct: line.taxPct, + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId, + })) + ) + }) + .catch((err) => setError(errorMessage(err))) + }, [invoiceId, resolvedParams.id]) + + useEffect(() => { + if (!invoice || invoice.status !== "Draft") { + setPostingCheck(null) + return + } + salesApi + .checkInvoicePosting(invoiceId) + .then((result) => setPostingCheck(result)) + .catch(() => setPostingCheck(null)) + }, [invoice, invoiceId]) + + const isDraft = invoice?.status === "Draft" + const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId]) + const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId]) + const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0 + const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateHeaderWarehouse(nextWarehouseId: number | null) { + setWarehouseId(nextWarehouseId) + setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId }))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function save() { + if (!customerId || !warehouseId || !etag) return + if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) { + setError("Select item, UOM and warehouse for every line.") + return + } + + setSaving(true) + setError(null) + try { + const updated = await salesApi.updateInvoice( + invoiceId, + { + customerId, + warehouseId, + invoiceType, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + }, + etag, + ) + setInvoice(updated.data) + setEtag(updated.etag) + toast.success("Invoice saved", updated.data.invoiceNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function post() { + if (!postingCheck?.canPost) { + setError("Resolve stock shortages before posting this invoice.") + return + } + setBusy("post") + setError(null) + try { + const posted = await salesApi.postInvoice(invoiceId) + setInvoice(posted) + toast.success("Invoice posted", posted.invoiceNo) + router.refresh() + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(null) + } + } + + async function cancel() { + setBusy("cancel") + setError(null) + try { + const cancelled = await salesApi.cancelInvoice(invoiceId) + setInvoice(cancelled) + toast.success("Invoice cancelled", cancelled.invoiceNo) + router.refresh() + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(null) + } + } + + if (error && !invoice) { + return
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice data is loading or unavailable."}
+ } + + return ( +
+
+
+ + + +
+

Invoice Details

+

{invoice.invoiceNo}

+
+
+ + + Print + +
+ + {error ?
{error}
: null} + +
+
+
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
+ Status: + {invoice.status} + Type: {invoice.invoiceType} + Date: {new Date(invoice.invoiceDate).toLocaleDateString()} +
+
+
+
ERP Core Trading
+
Company details are not configured for this invoice view.
+
+
+
+ +
+
+
Bill To
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {customer?.displayName ?
Registered name: {customer.displayName}
: null} + {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
Location: {warehouse?.location ?? "—"}
+
+
+
Totals
+
+
Subtotal
+
{money.format(invoice.totals.subtotal)}
+
Discount
+
{money.format(invoice.totals.discountTotal)}
+
Free qty
+
{freeQtyTotal.toFixed(0)}
+
Tax
+
{money.format(invoice.totals.taxTotal)}
+
Net payable
+
{money.format(invoice.totals.netPayable)}
+
+
+
+ +
+ + + + + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + + + + + ))} + +
ItemUOMQtyFreeUnit priceDiscountTaxLine total
+
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}{line.qty.toFixed(0)}{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}{money.format(line.unitPrice)}{money.format(line.discountAmount)}{money.format(line.taxAmount)}{money.format(line.lineTotal)}
+
+ + {invoice.lines.some((line) => line.freeQty > 0) ? ( +
+
Free issue summary
+
+ {invoice.lines.filter((line) => line.freeQty > 0).map((line) => ( +
+
{line.description}
+
Free qty: {line.freeQty.toFixed(0)}
+
+ ))} +
+
+ ) : null} + + {invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? ( +
+
+
Stock shortage detected before posting
+
The invoice cannot be posted until every line has enough available stock in the selected warehouse.
+
+ + + + + + + + + + + + {postingCheck.issues.map((issue) => ( + + + + + + + + ))} + +
ItemWarehouseRequestedAvailableShort
+
{issue.itemSku}
+
{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}
+
{issue.warehouseId}{issue.requestedQty.toFixed(0)}{issue.availableQty.toFixed(0)}{issue.shortQty.toFixed(0)}
+
+
+
+ ) : null} + +
+
Notes
+

Standard invoice template view.

+
+ +
+
+

Actions

+ {isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."} +
+ + {isDraft ? ( + <> +
+ + + + +
+ +
+ + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + + + ))} + +
ItemUOMQtyFreeUnit priceRemove
+ + + + + updateLine(line.key, { qty: Number(e.target.value) || 0 })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + updateLine(line.key, { freeQty: Number(e.target.value) || 0 })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + ) : ( +
+ This invoice is {invoice.status.toLowerCase()} and cannot be edited. +
+ )} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx new file mode 100644 index 0000000..c86302d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +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 { cn } from "@/lib/utils" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { SalesInvoice } from "@/types/sales" + +export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) { + const invoiceId = Number(params.id) + const [invoice, setInvoice] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!Number.isFinite(invoiceId)) { + setError(`Invalid invoice id '${params.id}'.`) + return + } + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.getInvoice(invoiceId), + ]) + .then(([cust, itemRes, uomRes, whRes, doc]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setInvoice(doc.data) + }) + .catch((err) => setError(errorMessage(err))) + }, [params.id, invoiceId]) + + const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice]) + const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0 + + if (error && !invoice) { + return
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === invoice.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId) + + return ( +
+
+
+ + + +
+

Invoice Print

+

{invoice.invoiceNo}

+
+
+ +
+ +
+
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
Status: {invoice.status} · Type: {invoice.invoiceType}
+
+
+
ERP Core Trading
+
Company details are not configured for this print view.
+
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
+
Printed: {new Date().toLocaleString()}
+
Free qty total: {freeQtyTotal.toFixed(2)}
+
+
+ +
+
+
Customer
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} + {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
+
+
Totals
+
+
Subtotal
{invoice.totals.subtotal.toFixed(2)}
+
Discount
{invoice.totals.discountTotal.toFixed(2)}
+
Free qty
{freeQtyTotal.toFixed(2)}
+
Tax
{invoice.totals.taxTotal.toFixed(2)}
+
Net payable
{invoice.totals.netPayable.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Discount + Tax + Line total + + + + {invoice.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(2)} + {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} + {line.unitPrice.toFixed(2)} + {line.discountAmount.toFixed(2)} + {line.taxAmount.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+ + {invoice.lines.some((line) => line.freeQty > 0) ? ( +
+
Free issue summary
+
+ {invoice.lines.filter((line) => line.freeQty > 0).map((line) => ( +
+
{line.description}
+
Invoice qty: {line.qty.toFixed(2)}
+
Free qty: {line.freeQty.toFixed(2)}
+
+ ))} +
+
+ ) : null} + +
+
+
Notes
+

Standard invoice print view.

+
+
+
Totals
+
+
Subtotal{invoice.totals.subtotal.toFixed(2)}
+
Discount total{invoice.totals.discountTotal.toFixed(2)}
+
Free qty total{invoice.totals.freeQtyTotal.toFixed(2)}
+
Tax total{invoice.totals.taxTotal.toFixed(2)}
+
Net payable{invoice.totals.netPayable.toFixed(2)}
+
Paid{invoice.totals.paidAmount.toFixed(2)}
+
Balance{invoice.totals.balanceAmount.toFixed(2)}
+
+
+
+ +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx new file mode 100644 index 0000000..4b84e07 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -0,0 +1,489 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesInvoiceLineRequest & { key: string } +type ActiveFocScheme = { + id: number + slipNo: string + schemeLabel: string + warehouseName: string + productLabel: string +} + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +const lkr = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +export default function NewSalesInvoicePage() { + const router = useRouter() + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [invoiceType, setInvoiceType] = useState("B2C") + const [lines, setLines] = useState([blankLine("line-1")]) + const [activeFocSchemes, setActiveFocSchemes] = useState([]) + const [loading, setLoading] = useState(true) + const [submitError, setSubmitError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.listFreeIssues({ pageSize: 20 }), + ]) + .then(([cust, itemRes, uomRes, whRes, freeIssueRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(defaultWarehouseId) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: defaultWarehouseId ?? 0, + unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), + }, + ]) + setActiveFocSchemes( + freeIssueRes.items.flatMap((issue) => { + if (!issue.itemId) return [] + const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId) + const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId) + return [ + { + id: issue.salesSlipId, + slipNo: issue.slipNo, + schemeLabel: issue.schemeLabel, + warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`, + productLabel: `${item?.sku ?? issue.itemSku} - ${item?.name ?? issue.itemName}`, + }, + ] + }), + ) + }) + .catch((err) => setSubmitError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateHeaderWarehouse(nextWarehouseId: number | null) { + setWarehouseId(nextWarehouseId) + setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId }))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [ + ...prev, + { + ...blankLine(`line-${Date.now()}`), + warehouseId: warehouseId ?? 0, + }, + ]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + const grossTotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0), + [lines], + ) + const discountTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)) + }, 0), + [lines], + ) + const taxTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100) + const taxable = Math.max(0, gross - discount) + return sum + taxable * (Number(line.taxPct || 0) / 100) + }, 0), + [lines], + ) + const netTotal = Math.max(0, grossTotal - discountTotal) + const payableTotal = netTotal + taxTotal + + async function submit() { + if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") + if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.") + if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") + if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.") + + const payload: CreateSalesInvoiceRequest = { + customerId, + warehouseId, + invoiceType, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + + setSaving(true) + setSubmitError(null) + try { + const created = await salesApi.createInvoice(payload) + toast.success("Invoice created", created.data.invoiceNo) + router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`) + } catch (err) { + if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) { + router.push(`/login?next=/dashboard/sales/invoices/new`) + return + } + setSubmitError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loading) { + return
Loading masters...
+ } + + return ( +
+
+
+ + + +
+

New invoice

+

Select items, set quantity, unit price and line discount.

+
+
+
+ + +
+
+ + {submitError ?
{submitError}
: null} + +
+

Invoice header

+
+
+ + +
+
+ + +
+
+ + value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}> + + + + + B2B + B2C + Cash + Credit + + +
+
+ +
+ + Draft + + {lkr.format(payableTotal)} +
+
+
+
+ +
+
+

Invoice lines

+ +
+
+ + + + # + Item + UOM + Qty + Free + Unit price + Discount % + Line total + + + + + {lines.map((line, idx) => { + const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const lineDiscount = + line.discountMode === "Amount" + ? Number(line.discountAmount || 0) + : lineGross * (Number(line.discountPct || 0) / 100) + const lineNet = Math.max(0, lineGross - lineDiscount) + + return ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-9 w-28 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { discountPct: Number(e.target.value) || 0 })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + {lkr.format(lineNet)} + + + + + ) + })} + +
+
+
+ +
+ {/*
+

Active FOC schemes

+
    + {activeFocSchemes.length > 0 ? ( + activeFocSchemes.map((scheme) => ( +
  • +
    + {scheme.schemeLabel} + {scheme.slipNo} +
    +
    + {scheme.productLabel} + {scheme.warehouseName} +
    +
  • + )) + ) : ( +
  • + No active free-issue schemes found. +
  • + )} +
+
*/} + +
+ {[ + ["Gross", lkr.format(grossTotal)], + ["Discount", `-${lkr.format(discountTotal)}`], + ["Net", lkr.format(netTotal)], + ["Tax", lkr.format(taxTotal)], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
Payable
+
{lkr.format(payableTotal)}
+
+
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx new file mode 100644 index 0000000..d439ef7 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -0,0 +1,239 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { PaginationMeta } from "@/types/common" +import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" +import { cn } from "@/lib/utils" + +type StatusFilter = SalesInvoiceStatus | "All" + +const PAGE_SIZE = 10 +const tabs = ["All", "Draft", "Posted", "Cancelled"] as const + +function statusClass(status: SalesInvoiceStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesInvoicesPage() { + const [rows, setRows] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + useEffect(() => { + setError(null) + salesApi + .listInvoices({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRows(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + }, [page, status]) + + const visibleRows = useMemo( + () => + rows?.filter((row) => + `${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + ) ?? [], + [rows, query] + ) + const hasFilters = status !== "All" || query.length > 0 + const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) + const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) + const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` + + return ( +
+
+
+

Sales Invoices

+

Invoice register with filters, posting flow, and settlement tracking.

+
+
+ + + Print batch + + + + New Invoice + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+ +
+ setSearchInput(e.target.value)} + placeholder="Filter by customer or invoice number" + className="h-12 w-full lg:max-w-sm" + /> + +
+
+ + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No invoices match your filters." : "No invoices yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Invoice + Customer + Date + Due + Lines + Gross + Discount + Net + Status + View + + + + {visibleRows.map((row) => ( + + + + {row.invoiceNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.invoiceDate).toLocaleDateString()} + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + + {row.status} + + + + + + + + + ))} + +
+
+ +
+
+
+
Gross total
+
{grossTotal.toFixed(2)}
+
+
+
Discount total
+
-{discountTotal.toFixed(2)}
+
+
+
Net total
+
{netTotal.toFixed(2)}
+
+
+
+ + {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/sales/page.tsx b/Frontend/erp-system/app/dashboard/sales/page.tsx new file mode 100644 index 0000000..ad9c562 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link" +import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +const sections = [ + { + title: "Invoices", + description: "Create and manage sales invoices.", + href: "/dashboard/sales/invoices", + icon: FileText, + }, + { + title: "Slips", + description: "Counter-style sales documents.", + href: "/dashboard/sales/slips", + icon: ShoppingCart, + }, + { + title: "Free Issues", + description: "Promotional free-issue slips.", + href: "/dashboard/sales/free-issues", + icon: PackageX, + }, + // { + // title: "Reports", + // description: "Sales report catalog and query entry point.", + // href: "/dashboard/sales/reports", + // icon: FileBarChart, + // }, +] + +export default function SalesHubPage() { + return ( +
+
+
+
+ + Sales +
+

Sales

+

+ Invoices, slips, and free issues in one place. +

+
+
+ +
+ {sections.map((section) => { + const Icon = section.icon + return ( + +
+ +
+

{section.title}

+

{section.description}

+
+ Open +
+ + ) + })} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx b/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx new file mode 100644 index 0000000..3b0677c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx @@ -0,0 +1,242 @@ +"use client" + +import { use, useEffect, useRef, useState } from "react" +import Link from "next/link" +import { ArrowLeft, FileBarChart } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { SalesReportDefinition } from "@/types/sales" + +function formatHeader(key: string) { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/_/g, " ") + .trim() +} + +function formatCell(value: unknown) { + if (value === null || value === undefined) return "" + if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 }) + if (typeof value === "string") { + const isoDate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value) + if (isoDate) { + const parsed = new Date(value) + if (!Number.isNaN(parsed.getTime())) { + return new Intl.DateTimeFormat("en-LK", { + year: "numeric", + month: "short", + day: "2-digit", + }).format(parsed) + } + } + return value + } + if (typeof value === "boolean") return value ? "Yes" : "No" + if (Array.isArray(value)) return value.map((item) => formatCell(item)).join(", ") + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +const reportColumns: Record = { + "daily-summary": ["date", "invoiceCount", "slipCount", "invoiceSubtotal", "slipSubtotal", "discountTotal", "freeQtyTotal", "taxTotal", "grandTotal"], + "item-summary": ["itemId", "itemName", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "customer-summary": ["customerId", "customerName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "warehouse-summary": ["warehouseId", "warehouseName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "discount-summary": ["documentType", "documentNo", "documentDate", "customerName", "subtotal", "discountTotal", "taxTotal", "netAmount"], + "free-issue-summary": ["documentType", "documentNo", "documentDate", "customerName", "itemId", "itemName", "freeQty", "freeValue", "warehouseId", "warehouseName"], +} + +export default function SalesReportDetailPage({ params }: { params: Promise<{ reportId: string }> }) { + const resolvedParams = use(params) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [from, setFrom] = useState("") + const [to, setTo] = useState("") + const [queryLoading, setQueryLoading] = useState(false) + const [rows, setRows] = useState(null) + const [queryError, setQueryError] = useState(null) + const lastAutoRunKey = useRef("") + + useEffect(() => { + setError(null) + setLoading(true) + salesApi.getReport(resolvedParams.reportId) + .then(setReport) + .catch((err) => setError(errorMessage(err))) + .finally(() => setLoading(false)) + }, [resolvedParams.reportId]) + + useEffect(() => { + const now = new Date() + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1) + setFrom(firstDay.toISOString().slice(0, 10)) + setTo(now.toISOString().slice(0, 10)) + }, [resolvedParams.reportId]) + + useEffect(() => { + if (!report || !from || !to) return + + const runKey = `${report.id}:${from}:${to}` + if (lastAutoRunKey.current === runKey) return + + lastAutoRunKey.current = runKey + setQueryError(null) + setQueryLoading(true) + salesApi + .queryReport({ + reportType: report.id, + from, + to, + }) + .then((response) => setRows(response.rows)) + .catch((err) => { + setRows(null) + setQueryError(errorMessage(err)) + }) + .finally(() => setQueryLoading(false)) + }, [report, from, to]) + + const runReport = () => { + if (!report) return + if (!from || !to) { + setQueryError("Select both from and to dates.") + setRows(null) + return + } + + lastAutoRunKey.current = `${report.id}:${from}:${to}` + setQueryError(null) + setQueryLoading(true) + salesApi + .queryReport({ + reportType: report.id, + from, + to, + }) + .then((response) => setRows(response.rows)) + .catch((err) => { + setRows(null) + setQueryError(errorMessage(err)) + }) + .finally(() => setQueryLoading(false)) + } + + const columns = report ? (reportColumns[report.id] ?? (rows && rows.length > 0 ? Object.keys(rows[0] as Record) : [])) : [] + + return ( +
+
+ + + +
+

Report Details

+

Metadata for the selected sales report.

+
+
+ + {error &&
{error}
} + + {!error && loading && } + + {!error && !loading && report === null && ( +
+ Report metadata could not be loaded. The report id may be invalid, or your session may have expired. +
+ )} + + {!error && report && ( +
+ + +
+
+ +
+
+ {report.name} +

{report.id}

+
+
+
+ +

{report.description}

+
+
+ + {resolvedParams.reportId !== "daily-summary" && ( + + +
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+
+ +
+
+
+ )} + + {queryError &&
{queryError}
} + + {queryLoading && } + + {!queryLoading && rows && rows.length === 0 && ( +
+ No rows returned for the selected date range. +
+ )} + + {!queryLoading && rows && rows.length > 0 && ( + + + Report Results + + + + + + {columns.map((key) => ( + + ))} + + + + {rows.map((row, index) => { + const record = row as Record + return ( + + {columns.map((key) => ( + + ))} + + ) + })} + +
+ {formatHeader(key)} +
+ {formatCell(record[key])} +
+
+
+ )} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/reports/page.tsx b/Frontend/erp-system/app/dashboard/sales/reports/page.tsx new file mode 100644 index 0000000..3a6797f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/reports/page.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { BarChart3, CalendarRange, FileBarChart, ShoppingCart, type LucideIcon } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { SalesReportDefinition } from "@/types/sales" + +const reportIcons: Record = { + sales: BarChart3, + invoice: FileBarChart, + product: ShoppingCart, + period: CalendarRange, +} + +export default function SalesReportsPage() { + const [reports, setReports] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + setError(null) + salesApi.listReports().then(setReports).catch((err) => setError(errorMessage(err))) + }, []) + + return ( +
+
+

Sales Reports

+

+ Card-based report hub with the same layout and handling style used across stock management. +

+
+ + {error &&
{error}
} + + {!error && reports === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && reports && reports.length === 0 && ( +
+ +

No sales reports are available.

+
+ )} + + {!error && reports && reports.length > 0 && ( +
+ {reports.map((report) => ( + + + +
+
+ {(() => { + const Icon = reportIcons[report.id.toLowerCase()] ?? FileBarChart + return + })()} +
+ {report.name} +
+
+ +

{report.description}

+
+
+ + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx new file mode 100644 index 0000000..9a50c88 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx @@ -0,0 +1,465 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +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" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { FreeIssuePromotionSuggestions } from "@/components/sales/FreeIssuePromotionSuggestions" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, SalesFreeIssueSuggestion, SalesSlip, SalesSlipPostingCheck } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesSlipLineRequest & { key: string } + +const money = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = use(params) + const slipId = Number(resolvedParams.id) + const [slip, setSlip] = useState(null) + const [etag, setEtag] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [promotionSuggestion, setPromotionSuggestion] = useState(null) + const [postingCheck, setPostingCheck] = useState(null) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null) + + useEffect(() => { + if (!Number.isFinite(slipId)) { + setError(`Invalid slip id '${resolvedParams.id}'.`) + return + } + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + salesApi.getSlip(slipId), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes, doc]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setSlip(doc.data) + setEtag(doc.etag) + setCustomerId(doc.data.customerId) + setWarehouseId(doc.data.warehouseId) + setCashierUserId(doc.data.cashierUserId) + setPromotionSuggestion(null) + setLines( + doc.data.lines.map((line) => ({ + key: String(line.salesSlipLineId), + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + freeQty: line.freeQty, + unitPrice: line.unitPrice, + allowManualPriceOverride: true, + discountMode: line.discountMode, + discountPct: line.discountPct, + discountAmount: line.discountAmount, + discountValue: 0, + taxPct: line.taxPct, + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId, + })) + ) + }) + .then(async () => { + try { + const suggestions = await salesApi.getFreeIssueSuggestions(slipId) + setPromotionSuggestion(suggestions) + } catch { + setPromotionSuggestion(null) + } + }) + .catch((err) => setError(errorMessage(err))) + }, [resolvedParams.id, slipId]) + + useEffect(() => { + if (!slip || slip.status !== "Draft") { + setPostingCheck(null) + return + } + salesApi + .checkSlipPosting(slipId) + .then((result) => setPostingCheck(result)) + .catch(() => setPostingCheck(null)) + }, [slip, slipId]) + + const subtotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice ?? 0), 0), + [lines] + ) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function selectItem(key: string, itemId: number) { + updateLine(key, { + itemId, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function save() { + if (!customerId || !warehouseId || !cashierUserId || !etag) return + setSaving(true) + setError(null) + try { + const payload = { + customerId, + warehouseId, + cashierUserId, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + const updated = await salesApi.updateSlip(slipId, payload, etag) + setSlip(updated.data) + setEtag(updated.etag) + toast.success("Slip saved", updated.data.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function post() { + if (!postingCheck?.canPost) { + setError("Resolve stock shortages before posting this slip.") + return + } + setActionBusy("post") + setError(null) + try { + const posted = await salesApi.postSlip(slipId) + setSlip(posted) + toast.success("Slip posted", posted.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setActionBusy(null) + } + } + + async function cancel() { + setActionBusy("cancel") + setError(null) + try { + const cancelled = await salesApi.cancelSlip(slipId) + setSlip(cancelled) + toast.success("Slip cancelled", cancelled.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setActionBusy(null) + } + } + + if (error && !slip) return
{error}
+ if (!slip) return
{error ?? "Slip data is loading or unavailable."}
+ + const locked = slip.status !== "Draft" + const canPost = slip.status === "Draft" && (postingCheck?.canPost ?? true) && actionBusy === null + + return ( +
+
+
+ + + +
+

Sales Slip

+

{slip.slipNo} · {slip.status}

+
+
+
+ + + Print + + + + +
+
+ + {error &&
{error}
} + +
+
+
+
+
Sales Slip
+

{slip.slipNo}

+
+ Status: + + {slip.status} + + Date: {new Date(slip.slipDate).toLocaleDateString()} + Cashier: {users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`} +
+
+
+
{customers.find((c) => c.customerId === slip.customerId)?.displayName ?? customers.find((c) => c.customerId === slip.customerId)?.name ?? slip.customerSnapshotName}
+
Warehouse: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}
+
+
+
+ +
+
+
Customer
+
{slip.customerSnapshotName}
+
Customer ID: {slip.customerId}
+
+
+
Warehouse
+
{warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}
+
+
+
Totals
+
+
Subtotal
+
{money.format(slip.totals.subtotal)}
+
Discount
+
{money.format(slip.totals.discountTotal)}
+
Free qty
+
{slip.totals.freeQtyTotal.toFixed(0)}
+
Grand total
+
{money.format(slip.totals.grandTotal)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Line total + + + + {slip.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(0)} + {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} + {money.format(line.unitPrice)} + {money.format(line.lineTotal)} +
+ ))} +
+
+
+
+ + {slip.status === "Draft" && postingCheck && !postingCheck.canPost ? ( +
+
Stock shortage detected before posting
+
This slip cannot be posted until every line has enough available stock in the selected warehouse.
+
+ + + + + + + + + + + + {postingCheck.issues.map((issue) => ( + + + + + + + + ))} + +
ItemWarehouseRequestedAvailableShort
+
{issue.itemSku}
+
{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}
+
{issue.warehouseId}{issue.requestedQty.toFixed(0)}{issue.availableQty.toFixed(0)}{issue.shortQty.toFixed(0)}
+
+
+ ) : null} + + {slip.status === "Draft" ? ( + <> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Lines

+ +
+ + + + Item + UOM + Qty + Free + Unit price + + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /> + updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /> + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /> + + + ))} + +
+
Subtotal: {subtotal.toFixed(2)}
+
+ + + +
+ Back + + +
+ + ) : null} +
+ ) +} + diff --git a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx new file mode 100644 index 0000000..b3b3088 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx @@ -0,0 +1,440 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesSlipLineRequest & { key: string } + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +const lkr = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +export default function NewSalesSlipPage() { + const router = useRouter() + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [loading, setLoading] = useState(true) + const [submitError, setSubmitError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(whRes.items[0]?.warehouseId ?? null) + setCashierUserId(userRes.items[0]?.userId ?? null) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: whRes.items[0]?.warehouseId ?? 0, + unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), + }, + ]) + }) + .catch((err) => setSubmitError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + const grossTotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0), + [lines], + ) + const discountTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)) + }, 0), + [lines], + ) + const taxTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100) + const taxable = Math.max(0, gross - discount) + return sum + taxable * (Number(line.taxPct || 0) / 100) + }, 0), + [lines], + ) + const netTotal = Math.max(0, grossTotal - discountTotal) + const payableTotal = netTotal + taxTotal + + async function submit() { + if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.") + if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.") + if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.") + if (lines.some((line) => Number(line.uomId) === 0)) return setSubmitError("Select a valid UOM for every line.") + + const payload: CreateSalesSlipRequest = { + customerId, + warehouseId, + cashierUserId, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + + setSaving(true) + setSubmitError(null) + try { + const created = await salesApi.createSlip(payload) + toast.success("Slip created", created.data.slipNo) + router.push(`/dashboard/sales/slips/${created.data.salesSlipId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loading) { + return
Loading masters...
+ } + + return ( +
+
+
+ + + +
+

New sales slip

+

Create counter sales slips from the live backend.

+
+
+
+ + +
+
+ + {submitError ?
{submitError}
: null} + +
+

Slip header

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Draft + + {lkr.format(payableTotal)} +
+
+
+
+ +
+
+

Slip lines

+ +
+
+ + + + # + Item + UOM + Qty + Free + Unit price + Discount % + Line total + + + + + {lines.map((line, idx) => { + const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const lineDiscount = + String(line.discountMode) === "Amount" + ? Number(line.discountAmount || 0) + : lineGross * (Number(line.discountPct || 0) / 100) + const lineNet = Math.max(0, lineGross - lineDiscount) + + return ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-9 w-28 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { discountPct: Number(e.target.value) || 0 })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + {lkr.format(lineNet)} + + + + + ) + })} + +
+
+
+ +
+
+

Slip notes

+
    +
  • Cashier posting follows the standard sales-slip workflow.
  • +
  • Free issue lines are captured from the slip itself, not from a separate register here.
  • +
  • Use the slip detail page after save to post or cancel.
  • +
+
+ +
+ {[ + ["Gross", lkr.format(grossTotal)], + ["Discount", `-${lkr.format(discountTotal)}`], + ["Net", lkr.format(netTotal)], + ["Tax", lkr.format(taxTotal)], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
Payable
+
{lkr.format(payableTotal)}
+
+
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/slips/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx new file mode 100644 index 0000000..ad31b4a --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales" + +type StatusFilter = SalesSlipStatus | "All" + +const PAGE_SIZE = 10 +const tabs = ["All", "Draft", "Posted", "Cancelled"] as const + +function statusClass(status: SalesSlipStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesSlipsPage() { + const [rows, setRows] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + useEffect(() => { + setError(null) + salesApi + .listSlips({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRows(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + }, [page, status]) + + const visibleRows = useMemo( + () => + rows?.filter((row) => + `${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + ) ?? [], + [rows, query] + ) + const hasFilters = status !== "All" || query.length > 0 + const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) + const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) + const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` + + return ( +
+
+
+

Sales Slips

+

Counter sales register with posting and cancellation flow.

+
+
+ + + Print batch + + + + New Slip + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+ +
+ setSearchInput(e.target.value)} + placeholder="Filter by customer or slip number" + className="h-12 w-full lg:max-w-sm" + /> + +
+
+ + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No slips match your filters." : "No slips yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Slip + Customer + Date + Status + Lines + Gross + Discount + Net + Actions + + + + {visibleRows.map((row) => ( + + + + {row.slipNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + + + {row.status} + + + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + +
+ + + +
+
+
+ ))} +
+
+
+ +
+
+
+
Gross total
+
{grossTotal.toFixed(2)}
+
+
+
Discount total
+
-{discountTotal.toFixed(2)}
+
+
+
Net total
+
{netTotal.toFixed(2)}
+
+
+
+ + {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/settings/company-profile/page.tsx b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx new file mode 100644 index 0000000..a149a86 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Building2, Save } from "lucide-react" + +import { companyApi } from "@/lib/api/company" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { CompanyProfile } from "@/types/company" + +import { buttonVariants, Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function CompanyProfilePage() { + const [profile, setProfile] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + companyApi + .getProfile() + .then((res) => { + setProfile(res.data) + setEtag(res.etag) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function patch(key: K, value: CompanyProfile[K]) { + setProfile((prev) => (prev ? { ...prev, [key]: value } : prev)) + } + + async function save() { + if (!profile || !etag) return + setSaving(true) + setError(null) + try { + const updated = await companyApi.updateProfile(profile, etag) + setProfile(updated.data) + setEtag(updated.etag) + toast.success("Company profile saved", updated.data.legalName) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( +
+
+ + + +
+

Company Profile

+

Invoice header, tax details, logo, and bank information.

+
+
+ + {error &&
{error}
} + {!error && !profile && } + + {!error && profile && ( +
+
+ +

Invoice Header

+
+ +
+ patch("legalName", v)} /> + patch("tradeName", v)} /> + patch("logoUrl", v)} /> + patch("taxRegistrationNo", v)} /> + patch("vatRegistrationNo", v)} /> + patch("phone", v)} /> + patch("email", v)} /> + patch("city", v)} /> + patch("country", v)} /> + patch("addressLine1", v)} /> + patch("addressLine2", v)} /> +
+ +
+ +
+ +

Bank Details

+
+ +
+ patch("bankName", v)} /> + patch("bankBranch", v)} /> + patch("accountName", v)} /> + patch("accountNumber", v)} /> + patch("swiftCode", v)} /> + patch("footerNote", v)} /> +
+ +
+ + Cancel + + +
+
+ )} +
+ ) +} + +function Field({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) { + return ( +
+ + onChange(e.target.value)} /> +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/page.tsx b/Frontend/erp-system/app/dashboard/settings/page.tsx index dbe71d9..70fe245 100644 --- a/Frontend/erp-system/app/dashboard/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { ShieldCheck, SlidersHorizontal, Users } from "lucide-react" +import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react" const cards = [ { @@ -20,6 +20,12 @@ const cards = [ icon: SlidersHorizontal, description: "Configure product and master-data options", }, + { + title: "Company Profile", + href: "/dashboard/settings/company-profile", + icon: Building2, + description: "Invoice header, tax, and bank details", + }, ] export default function SettingsPage() { diff --git a/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx index 4a439ce..5d2ac07 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Save } from "lucide-react" +import { AlertTriangle, Save } from "lucide-react" import { navApi } from "@/lib/api/nav" import { rolesApi } from "@/lib/api/roles" @@ -132,9 +132,6 @@ export default function RoleDetailPage() {
- - -

{role.code}

diff --git a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx index 6268997..aac81d7 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Save } from "lucide-react" +import { Save } from "lucide-react" import { rolesApi } from "@/lib/api/roles" import { usersApi } from "@/lib/api/users" @@ -83,9 +83,6 @@ export default function UserDetailPage() { return (
- - -

{user.username}

diff --git a/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx index bda4069..95be015 100644 --- a/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react" +import { CheckCircle2, Plus, Trash2 } from "lucide-react" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { warehousesApi } from "@/lib/api/warehouses" @@ -123,9 +123,6 @@ export default function NewAdjustmentPage() { return (
- - -

New Adjustment

Posts immediately on creation (FR-STK-07) — a reason code is mandatory.

diff --git a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx index 84d7439..8bebc55 100644 --- a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react" +import { Plus, SlidersHorizontal } from "lucide-react" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { warehousesApi } from "@/lib/api/warehouses" @@ -39,9 +39,6 @@ export default function AdjustmentsListPage() {
- - -

Stock Adjustments

Increase, decrease, or write off stock with a reason code (FR-STK-07).

diff --git a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx index 0a61115..d203c1a 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx @@ -2,15 +2,14 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" -import Link from "next/link" -import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react" +import { CheckCircle2, ClipboardCheck, Save } from "lucide-react" import { stockCountsApi } from "@/lib/api/stock-counts" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { PostCountResponse, StockCount } from "@/types/stock" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -91,9 +90,6 @@ export default function CountDetailPage() {
- - -

{count.docNo}

diff --git a/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx index 05ecd1e..166502d 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import {} from "lucide-react" import { stockCountsApi } from "@/lib/api/stock-counts" import { warehousesApi } from "@/lib/api/warehouses" @@ -82,9 +82,6 @@ export default function NewCountPage() { return (
- - -

New Count

System quantities are snapshotted immediately; enter counted quantities next.

diff --git a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx index 6269438..d945a6a 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ClipboardList, Plus } from "lucide-react" +import { ClipboardList, Plus } from "lucide-react" import { stockCountsApi } from "@/lib/api/stock-counts" import { warehousesApi } from "@/lib/api/warehouses" @@ -36,9 +36,6 @@ export default function CountsListPage() {
- - -

Stock Counts

Cycle or full physical counts (FR-STK-08).

diff --git a/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx b/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx index caf125c..d83de7b 100644 --- a/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx @@ -2,17 +2,15 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { ArrowLeft, PackageSearch, Search } from "lucide-react" +import { PackageSearch, Search } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { OnHand } from "@/types/stock" import { ItemListItem, Warehouse } from "@/types/master-data" -import { 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" @@ -59,9 +57,6 @@ export default function StockEnquiryPage() { return (
- - -

Stock Enquiry

On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).

diff --git a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx index 1bad740..4572ab9 100644 --- a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx @@ -1,8 +1,7 @@ "use client" import { useEffect, useMemo, useState } from "react" -import Link from "next/link" -import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react" +import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" @@ -13,7 +12,7 @@ import { LedgerEntry } from "@/types/stock" import { PaginationMeta } from "@/types/common" import { ItemListItem, Warehouse } from "@/types/master-data" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" @@ -81,9 +80,6 @@ export default function StockLedgerPage() { return (
- - -

Stock Ledger

Immutable, append-only movement journal (FR-STK-01).

diff --git a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx index e6db99c..0fd1441 100644 --- a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx @@ -1,18 +1,16 @@ "use client" import { useEffect, useMemo, useState } from "react" -import Link from "next/link" -import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react" +import { AlertTriangle, CheckCircle2 } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { ReorderAlert } from "@/types/stock" import { ItemListItem, Warehouse } from "@/types/master-data" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } 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" @@ -59,9 +57,6 @@ export default function ReorderAlertsPage() { return (
- - -

Reorder Alerts

Items at or below their reorder point (FR-STK-10).

diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx index 32574bd..d376106 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx @@ -2,15 +2,13 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" -import Link from "next/link" -import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react" +import { CheckCircle2, PackageCheck, Truck } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } 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" @@ -85,9 +83,6 @@ export default function TransferDetailPage() {
- - -

{transfer.docNo}

diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx index c5b628a..2a76ce5 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Plus, Trash2 } from "lucide-react" +import { Plus, Trash2 } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" import { warehousesApi } from "@/lib/api/warehouses" @@ -138,9 +138,6 @@ export default function NewTransferPage() { return (
- - -

New Transfer

Create a transfer, then dispatch and receive it (FR-STK-05).

diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx index 9282fab..226086a 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react" +import { ArrowLeftRight, Plus } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" import { warehousesApi } from "@/lib/api/warehouses" @@ -36,9 +36,6 @@ export default function TransfersListPage() {
- - -

Stock Transfers

Move stock between warehouses (FR-STK-05/06).

diff --git a/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx b/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx index 34d664b..64ac1a7 100644 --- a/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx @@ -2,18 +2,15 @@ import { Suspense, useEffect, useMemo, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" -import Link from "next/link" -import { ArrowLeft, BadgeDollarSign } from "lucide-react" +import { BadgeDollarSign } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { Valuation } from "@/types/stock" import { ItemListItem, Warehouse } from "@/types/master-data" -import { buttonVariants } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -65,9 +62,6 @@ function ValuationContent() { return (
- - -

Valuation

FIFO cost-layer breakdown and total stock value (FR-STK-04).

diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx index ca2037f..bb3a380 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react" +import { AlertOctagon, CheckCircle2 } from "lucide-react" import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage" import { reasonCodesApi } from "@/lib/api/reason-codes" @@ -99,9 +99,6 @@ export default function NewWastagePage() { return (
- - -

Record Wastage

Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.

diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx index b63d6cf..54a0bbd 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { AlertOctagon, ArrowLeft, Plus } from "lucide-react" +import { AlertOctagon, Plus } from "lucide-react" import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage" import { reasonCodesApi } from "@/lib/api/reason-codes" @@ -68,9 +68,6 @@ export default function WastagePage() {
- - -

Wastage

diff --git a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx index 85730ed..77be003 100644 --- a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Save } from "lucide-react" +import { AlertTriangle, Save } from "lucide-react" import { vendorsApi } from "@/lib/api/vendors" import { errorMessage, fieldErrors } from "@/lib/error-map" @@ -132,9 +132,6 @@ export default function VendorDetailPage() {

- - -

{vendor.code}

diff --git a/Frontend/erp-system/app/dashboard/vendors/page.tsx b/Frontend/erp-system/app/dashboard/vendors/page.tsx index c3afcbf..fc34a81 100644 --- a/Frontend/erp-system/app/dashboard/vendors/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/page.tsx @@ -5,8 +5,9 @@ import Link from "next/link" import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { vendorsApi } from "@/lib/api/vendors" -import { errorMessage, fieldErrors } from "@/lib/error-map" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" +import { generateVendorCode } from "@/lib/vendor-code" import { EntityStatus, PaginationMeta } from "@/types/common" import { Vendor } from "@/types/master-data" @@ -44,7 +45,6 @@ export default function VendorsPage() { const [page, setPage] = useState(1) const [open, setOpen] = useState(false) - const [code, setCode] = useState("") const [name, setName] = useState("") const [terms, setTerms] = useState("") const [taxReg, setTaxReg] = useState("") @@ -52,8 +52,14 @@ export default function VendorsPage() { const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // Separate from the paginated table list above — this needs every existing code (up to the + // server's page-size cap) to de-dupe against, not just the current page's 5 rows. + const [allVendorCodes, setAllVendorCodes] = useState([]) + const [actionPendingId, setActionPendingId] = useState(null) + const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : "" + useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) return () => clearTimeout(timeout) @@ -75,8 +81,11 @@ export default function VendorsPage() { useEffect(load, [query, status, page]) + useEffect(() => { + vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {}) + }, []) + function resetForm() { - setCode("") setName("") setTerms("") setTaxReg("") @@ -86,7 +95,6 @@ export default function VendorsPage() { async function handleCreate() { const nextErrors: Record = {} - if (!code.trim()) nextErrors.code = "Vendor code is required" if (!name.trim()) nextErrors.name = "Vendor name is required" if (!currency.trim()) nextErrors.currency = "Currency is required" setErrors(nextErrors) @@ -94,14 +102,15 @@ export default function VendorsPage() { setSubmitting(true) try { - const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency }) + const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency }) toast.success("Vendor created", `${result.data.code} — ${result.data.name}`) setOpen(false) resetForm() load() + setAllVendorCodes((codes) => [...codes, result.data.code]) } catch (err) { - const fe = fieldErrors(err) - if (fe?.code) setErrors({ code: fe.code }) + // A 409 here means another creation raced ours for the same generated code — the + // proactive de-dupe above only knows about codes loaded when the dialog opened. toast.error("Could not create vendor", errorMessage(err)) } finally { setSubmitting(false) @@ -145,19 +154,18 @@ export default function VendorsPage() { New vendor - Create a supplier record. + Create a supplier record. Its code is generated from the name. - - Code - setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} /> - - Name setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} /> + + Code (auto-generated) + + Payment terms (optional) setTerms(e.target.value)} placeholder="NET30" /> diff --git a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx index d8b6826..d973456 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx @@ -2,15 +2,13 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" -import Link from "next/link" -import { ArrowLeft, MapPinned, Plus } from "lucide-react" +import { MapPinned, Plus } from "lucide-react" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { Bin, Warehouse } from "@/types/master-data" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { @@ -90,9 +88,6 @@ export default function WarehouseDetailPage() {
- - -

{warehouse.code}

{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)

diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index af4d534..6ce171e 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -2,14 +2,13 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react" +import { Plus, Warehouse as WarehouseIcon } from "lucide-react" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { cn } from "@/lib/utils" import { Bin, Warehouse } from "@/types/master-data" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { @@ -100,9 +99,6 @@ export default function WarehousesPage() {
- - -

Warehouses

Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).

diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index a0ee55b..b251b5b 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -219,3 +219,34 @@ } } +@media print { + body { + background: white !important; + color: black !important; + } + + .print\:hidden { + display: none !important; + } + + .invoice-sheet { + border: 0 !important; + box-shadow: none !important; + padding: 0 !important; + } + + .invoice-header { + break-inside: avoid; + } + + .invoice-sheet table { + width: 100% !important; + } + + .invoice-sheet tr, + .invoice-sheet td, + .invoice-sheet th { + break-inside: avoid; + } +} + diff --git a/Frontend/erp-system/app/layout.tsx b/Frontend/erp-system/app/layout.tsx index b1c1f57..28ad97b 100644 --- a/Frontend/erp-system/app/layout.tsx +++ b/Frontend/erp-system/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { ThemeProvider } from "next-themes"; -import { TooltipProvider } from "@/components/ui/tooltip"; +import { Providers } from "@/components/providers"; import "./globals.css"; const geistSans = Geist({ @@ -28,17 +27,11 @@ export default function RootLayout({ - - {children} - + {children} ); diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 612976d..345bfa1 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -31,6 +31,8 @@ function LoginForm() { const [showPassword, setShowPassword] = useState(false) const [remember, setRemember] = useState(false) const [submitError, setSubmitError] = useState(null) + const returnTo = searchParams.get("next") + const sessionNotice = returnTo ? "Your session is missing or expired. Sign in again to continue." : null const form = useForm({ resolver: zodResolver(loginSchema), @@ -85,6 +87,11 @@ function LoginForm() {

Sign in to your account

Access your ERP dashboard and manage your business

+ {sessionNotice ? ( +
+ {sessionNotice} +
+ ) : null}
diff --git a/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx new file mode 100644 index 0000000..1c834ad --- /dev/null +++ b/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import { Printer } from "lucide-react" + +import { bundleApi } from "@/lib/api/bundles" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { BundleSale } from "@/types/bundles" + +export default function BundlePrintPage() { + const params = useParams<{ id: string }>() + const bundleSaleId = Number(params.id) + const [bundle, setBundle] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!Number.isFinite(bundleSaleId)) { + setError(`Invalid bundle id '${params.id}'.`) + return + } + bundleApi.getBundle(bundleSaleId).then((res) => setBundle(res)).catch((err) => setError(errorMessage(err))) + }, [bundleSaleId, params.id]) + + if (error && !bundle) return
{error}
+ if (!bundle) return
Loading bundle print...
+ + return ( +
+
+ +
+
+
Bundle Sales
+

{bundle.bundleNo}

+

{bundle.bundleName}

+
+
+
Customer
{bundle.customerSnapshotName}
+
Warehouse
{bundle.warehouseId}
+
Status
{bundle.status}
+
+
+ + + + Item + Description + Qty + Price + Total + + + + {bundle.lines.map((line) => ( + + {line.itemId} + {line.description} + {line.qty.toFixed(2)} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/bundles/page.tsx b/Frontend/erp-system/app/print/sales/bundles/page.tsx new file mode 100644 index 0000000..ac0db97 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/bundles/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { Printer } from "lucide-react" + +import { bundleApi } from "@/lib/api/bundles" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { BundleSaleSummary } from "@/types/bundles" + +function statusClass(status: BundleSaleSummary["status"]) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function BundleBatchPrintPage() { + const searchParams = useSearchParams() + const status = searchParams.get("status") ?? "All" + const query = searchParams.get("q") ?? "" + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + bundleApi.listBundles({ page: 1, pageSize: 200 }).then((res) => setRows(res.items)).catch((err) => setError(errorMessage(err))) + }, []) + + const visibleRows = rows?.filter((row) => { + const matchesStatus = status === "All" || row.status === status + const matchesQuery = `${row.bundleNo} ${row.customerSnapshotName} ${row.bundleName}`.toLowerCase().includes(query.toLowerCase()) + return matchesStatus && matchesQuery + }) + + if (error && !rows) return
{error}
+ if (!rows) return
Loading bundle print data...
+ + return ( +
+
+ +
+
+
Bundle Sales
+

Bundle Batch Print

+

Printed register snapshot of current bundle sales.

+
+
+ + + + Bundle + Customer + Date + Price + Grand + Status + + + + {visibleRows?.map((row) => ( + + {row.bundleNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {row.bundlePrice.toFixed(2)} + {row.grandTotal.toFixed(2)} + + + {row.status} + + + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx new file mode 100644 index 0000000..629418d --- /dev/null +++ b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx @@ -0,0 +1,148 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import { Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { SalesInvoice } from "@/types/sales" + +export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = use(params) + const invoiceId = Number(resolvedParams.id) + const [invoice, setInvoice] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!Number.isFinite(invoiceId)) { + setError(`Invalid invoice id '${resolvedParams.id}'.`) + return + } + + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.getInvoice(invoiceId), + ]) + .then(([cust, itemRes, uomRes, whRes, doc]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setInvoice(doc.data) + }) + .catch((err) => setError(errorMessage(err))) + }, [resolvedParams.id, invoiceId]) + + const freeQtyTotal = useMemo(() => invoice?.totals.freeQtyTotal ?? 0, [invoice]) + + if (error && !invoice) { + return
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === invoice.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId) + + return ( +
+
+ +
+ +
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
Status: {invoice.status} · Type: {invoice.invoiceType}
+
+
+
ERP Core Trading
+
Invoice print view
+
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
+
Printed: {new Date().toLocaleString()}
+
Free qty total: {freeQtyTotal.toFixed(2)}
+
+
+ +
+
+
Customer
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} + {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
+
+
Totals
+
+
Subtotal
{invoice.totals.subtotal.toFixed(2)}
+
Discount
{invoice.totals.discountTotal.toFixed(2)}
+
Free qty
{freeQtyTotal.toFixed(2)}
+
Tax
{invoice.totals.taxTotal.toFixed(2)}
+
Net payable
{invoice.totals.netPayable.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Discount + Tax + Line total + + + + {invoice.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(2)} + {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} + {line.unitPrice.toFixed(2)} + {line.discountAmount.toFixed(2)} + {line.taxAmount.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/invoices/page.tsx b/Frontend/erp-system/app/print/sales/invoices/page.tsx new file mode 100644 index 0000000..ab16f55 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/invoices/page.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { SalesInvoiceSummary } from "@/types/sales" + +function statusClass(status: SalesInvoiceSummary["status"]) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesInvoiceBatchPrintPage() { + const searchParams = useSearchParams() + const status = searchParams.get("status") ?? "All" + const query = searchParams.get("q") ?? "" + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + salesApi + .listInvoices({ page: 1, pageSize: 200 }) + .then((res) => setRows(res.items)) + .catch((err) => setError(errorMessage(err))) + }, []) + + const visibleRows = rows?.filter((row) => { + const matchesStatus = status === "All" || row.status === status + const matchesQuery = + `${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + return matchesStatus && matchesQuery + }) + + if (error && !rows) { + return
{error}
+ } + + if (!rows) { + return
{error ?? "Invoice batch print data is loading or unavailable."}
+ } + + return ( +
+
+ +
+ +
+
Sales Invoices
+

Invoice Batch Print

+

Printed register snapshot of current invoices.

+
+ +
+ + + + Invoice + Customer + Date + Due + Lines + Gross + Discount + Net + Status + + + + {visibleRows?.map((row) => ( + + {row.invoiceNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.invoiceDate).toLocaleDateString()} + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + + {row.status} + + + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx new file mode 100644 index 0000000..24ca5db --- /dev/null +++ b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import { Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { SalesSlip } from "@/types/sales" + +export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = use(params) + const slipId = Number(resolvedParams.id) + const [slip, setSlip] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!Number.isFinite(slipId)) { + setError(`Invalid slip id '${resolvedParams.id}'.`) + return + } + + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + salesApi.getSlip(slipId), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes, doc]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setSlip(doc.data) + }) + .catch((err) => setError(errorMessage(err))) + }, [resolvedParams.id, slipId]) + + const subtotal = useMemo(() => slip?.totals.subtotal ?? 0, [slip]) + + if (error && !slip) { + return
{error}
+ } + + if (!slip) { + return
{error ?? "Slip print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === slip.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId) + + return ( +
+
+ +
+ +
+
Sales Slip
+

{slip.slipNo}

+
Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}
+
+ +
+
+
Customer
+
{slip.customerSnapshotName}
+
Customer ID: {slip.customerId}
+ {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouse?.code ?? slip.warehouseId}
+
+
+
Totals
+
+
Subtotal
{slip.totals.subtotal.toFixed(2)}
+
Discount
{slip.totals.discountTotal.toFixed(2)}
+
Free qty
{slip.totals.freeQtyTotal.toFixed(2)}
+
Net total
{slip.totals.grandTotal.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Line total + + + + {slip.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(0)} + {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+ +
+
Cashier
+
{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}
+
Subtotal: {subtotal.toFixed(2)}
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/slips/page.tsx b/Frontend/erp-system/app/print/sales/slips/page.tsx new file mode 100644 index 0000000..0c941b4 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/slips/page.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { SalesSlipSummary } from "@/types/sales" + +function statusClass(status: SalesSlipSummary["status"]) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesSlipBatchPrintPage() { + const searchParams = useSearchParams() + const status = searchParams.get("status") ?? "All" + const query = searchParams.get("q") ?? "" + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + salesApi + .listSlips({ page: 1, pageSize: 200 }) + .then((res) => setRows(res.items)) + .catch((err) => setError(errorMessage(err))) + }, []) + + const visibleRows = rows?.filter((row) => { + const matchesStatus = status === "All" || row.status === status + const matchesQuery = + `${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + return matchesStatus && matchesQuery + }) + + if (error && !rows) { + return
{error}
+ } + + if (!rows) { + return
{error ?? "Slip batch print data is loading or unavailable."}
+ } + + return ( +
+
+ +
+ +
+
Sales Slips
+

Slip Batch Print

+

Printed register snapshot of current slips.

+
+ +
+ + + + Slip + Customer + Date + Status + Lines + Gross + Discount + Net + + + + {visibleRows?.map((row) => ( + + {row.slipNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + + + {row.status} + + + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index c80abe1..4d86d3d 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -5,26 +5,37 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { Banknote, + BadgeDollarSign, + BookOpen, + BookText, Boxes, Building2, CalendarCheck, CalendarClock, ChevronRight, ClipboardList, + CreditCard, Factory, FileBarChart, FileText, HelpCircle, IdCard, + Inbox, + Landmark, LayoutGrid, LayoutTemplate, + LineChart, ListTree, Menu, Package, PackageCheck, PackageX, PlayCircle, + PieChart, + Receipt, + ReceiptText, Ruler, + Scale, Settings, ShieldCheck, ShoppingCart, @@ -32,6 +43,7 @@ import { Tag, Truck, Users, + Wallet, Warehouse, X, type LucideIcon, @@ -88,6 +100,21 @@ const navItems: { { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, ], }, + { + title: "Sales", + code: "sales", + href: "/dashboard/sales", + landingHref: "/dashboard/sales/invoices", + icon: ReceiptText, + chevron: true, + children: [ + { title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, + { title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart }, + { title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes }, + { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, + // { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, + ], + }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, @@ -116,10 +143,38 @@ const navItems: { { title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck }, { title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock }, { title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote }, - { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, + // { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, { title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal }, ], }, + { + title: "Ledgers", + code: "ledgers", + href: "/dashboard/ledgers", + icon: Landmark, + chevron: true, + children: [ + { title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale }, + { title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark }, + { title: "General Ledger", code: "ledgers.general-ledger", href: "/dashboard/ledgers/general-ledger", icon: BookOpen }, + { title: "Profit & Loss", code: "ledgers.profit-and-loss", href: "/dashboard/ledgers/profit-and-loss", icon: LineChart }, + { title: "Cash Flow", code: "ledgers.cash-flow", href: "/dashboard/ledgers/cash-flow", icon: PieChart }, + { title: "Budget vs Actual", code: "ledgers.budget-vs-actual", href: "/dashboard/ledgers/budget-vs-actual", icon: BadgeDollarSign }, + { title: "Tax Report", code: "ledgers.tax-report", href: "/dashboard/ledgers/tax-report", icon: Receipt }, + ], + }, + { + title: "Accounts", + code: "accounts", + href: "/dashboard/accounts", + icon: CreditCard, + chevron: true, + children: [ + { title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet }, + { title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText }, + { title: "Received Cheques", code: "accounts.received-cheques", href: "/dashboard/accounts/received-cheques", icon: Inbox }, + ], + }, { title: "Settings", code: "settings", @@ -157,7 +212,13 @@ function SidebarContent({ // route auto-expanded; user toggles are preserved across navigation. const [expanded, setExpanded] = useState>({}) - useEffect(() => { + // Adjusted during render (not in an effect) each time pathname or the + // available item set changes — `items` starts empty while auth/nav codes + // are loading, so this also needs to re-run once the real list arrives. + const autoExpandKey = `${pathname}::${items.map((i) => i.code).join(",")}` + const [lastAutoExpandKey, setLastAutoExpandKey] = useState(null) + if (autoExpandKey !== lastAutoExpandKey) { + setLastAutoExpandKey(autoExpandKey) const parent = items.find((i) => { if (!i.children?.length) return false if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true @@ -168,7 +229,7 @@ function SidebarContent({ if (parent) { setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true })) } - }, [pathname, items]) + } const toggleExpand = (code: string) => setExpanded((prev) => ({ ...prev, [code]: !prev[code] })) @@ -341,14 +402,14 @@ export function AppSidebar() { // flashing the full menu to a restricted role. Once resolved, a nav item // is visible if its own code is granted, or (for parents) if any child is. // - // "procurement", "hrm" and "production" are exempted from that check (frontend-only): no + // "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no // role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children // server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted // the permission properly (Settings → Roles → Sidebar permissions) or a backend seed // grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // anything server-side. - const bypassCodes = new Set(["procurement", "hrm", "production"]) + const bypassCodes = new Set(["procurement", "sales", "hrm", "production"]) const visibleItems = loading ? [] : navItems diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index 44d22af..c8b4046 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useState } from "react" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react" @@ -41,6 +41,27 @@ const PROCUREMENT_TITLES: Record = { "/dashboard/procurement/purchase-returns/new": "New Purchase Return", } +const LEDGER_TITLES: Record = { + "/dashboard/ledgers": "Ledgers", + "/dashboard/ledgers/trial-balance": "Trial Balance", + "/dashboard/ledgers/balance-sheet": "Balance Sheet", + "/dashboard/ledgers/general-ledger": "General Ledger", + "/dashboard/ledgers/profit-and-loss": "Profit & Loss", + "/dashboard/ledgers/cash-flow": "Cash Flow", + "/dashboard/ledgers/budget-vs-actual": "Budget vs Actual", + "/dashboard/ledgers/tax-report": "Tax Report", +} + +const ACCOUNTS_TITLES: Record = { + "/dashboard/accounts": "Accounts", + "/dashboard/accounts/bank-accounts": "Cash / Bank Accounts", + "/dashboard/accounts/bank-accounts/new": "New Bank Account", + "/dashboard/accounts/cheque-books": "Cheque Books", + "/dashboard/accounts/cheque-books/new": "New Cheque Book", + "/dashboard/accounts/received-cheques": "Received Cheques", + "/dashboard/accounts/received-cheques/new": "New Received Cheque", +} + const STOCK_TITLES: Record = { "/dashboard/stock": "Stock Management", "/dashboard/stock/enquiry": "Stock Enquiry", @@ -62,6 +83,11 @@ function titleFromPath(pathname: string) { if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note" if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note" + if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname] + + if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname] + if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book" + if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname] if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer" if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count" @@ -85,6 +111,7 @@ function titleFromPath(pathname: string) { if (pathname === "/dashboard/products/brands") return "Brands" if (pathname === "/dashboard/products/item-types") return "Item Types" if (pathname === "/dashboard/products/settings") return "Product Configuration" + if (pathname === "/dashboard/settings/company-profile") return "Company Profile" if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item" const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard" @@ -140,8 +167,7 @@ export function Header() { // Read after mount, not during render: localStorage doesn't exist on the server, and // reading it while rendering would desync the hydration pass. - const [user, setUser] = useState(null) - useEffect(() => setUser(getStoredUser()), []) + const [user] = useState(() => getStoredUser()) const markAllAsRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, unread: false }))) diff --git a/Frontend/erp-system/components/accounts/ChequePageDialog.tsx b/Frontend/erp-system/components/accounts/ChequePageDialog.tsx new file mode 100644 index 0000000..62c75a3 --- /dev/null +++ b/Frontend/erp-system/components/accounts/ChequePageDialog.tsx @@ -0,0 +1,381 @@ +"use client" + +import { useState } from "react" + +import { chequePagesApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate } from "@/lib/format" +import { validateIssueChequeForm } from "@/lib/validations/general-ledger" +import { cn } from "@/lib/utils" +import { ChequePage, ChequePageIssueStatus, ChequePageStatusAction, PayeeType } from "@/types/general-ledger" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" + +const STATUS_BADGE: Record = { + [ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground", + [ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary", + [ChequePageIssueStatus.Cleared]: "bg-success/10 text-success", + [ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive", + [ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive", + [ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground", +} + +type PendingAction = "Issue" | ChequePageStatusAction | null + +interface ChequePageDialogProps { + page: ChequePage | null + open: boolean + onOpenChange: (open: boolean) => void + /** Called with the server's response after a successful issue/status-update, so the caller's list stays in sync. */ + onUpdated: (updated: ChequePage) => void +} + +/** View a single cheque page's details, and (from `Unused`/`Issued`) issue it or move it through + * Clear/Bounce/Cancel/Void — a modal rather than a separate page, so acting on several leaves from + * a book's page list doesn't lose scroll position/context each time (docs/21 §7). */ +export function ChequePageDialog({ page, open, onOpenChange, onUpdated }: ChequePageDialogProps) { + const [pendingAction, setPendingAction] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [errors, setErrors] = useState>({}) + + const [payeeType, setPayeeType] = useState(PayeeType.Supplier) + const [payeeName, setPayeeName] = useState("") + const [payeeId, setPayeeId] = useState("") + const [issueDate, setIssueDate] = useState("") + const [amount, setAmount] = useState("") + const [currencyCode, setCurrencyCode] = useState("LKR") + const [voucherId, setVoucherId] = useState("") + const [referenceNo, setReferenceNo] = useState("") + const [purpose, setPurpose] = useState("") + const [isCrossCheque, setIsCrossCheque] = useState(false) + const [isAccountPayee, setIsAccountPayee] = useState(false) + const [isPostDated, setIsPostDated] = useState(false) + const [notes, setNotes] = useState("") + const [printedBy, setPrintedBy] = useState("") + + const [clearedDate, setClearedDate] = useState("") + const [cancelReason, setCancelReason] = useState("") + const [performedBy, setPerformedBy] = useState("") + + function resetActionState() { + setPendingAction(null) + setErrors({}) + setPayeeType(PayeeType.Supplier) + setPayeeName("") + setPayeeId("") + setIssueDate("") + setAmount("") + setCurrencyCode("LKR") + setVoucherId("") + setReferenceNo("") + setPurpose("") + setIsCrossCheque(false) + setIsAccountPayee(false) + setIsPostDated(false) + setNotes("") + setPrintedBy("") + setClearedDate("") + setCancelReason("") + setPerformedBy("") + } + + function handleOpenChange(next: boolean) { + if (!next) resetActionState() + onOpenChange(next) + } + + async function submitIssue() { + if (!page) return + const nextErrors = validateIssueChequeForm({ payeeName, issueDate, amount }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + setSubmitting(true) + try { + const updated = await chequePagesApi.issue(page.chequeNo, { + payeeType, + payeeId: payeeId ? Number(payeeId) : undefined, + payeeName, + issueDate, + amount: Number(amount), + currencyCode: currencyCode || undefined, + voucherId: voucherId ? Number(voucherId) : undefined, + referenceNo: referenceNo || undefined, + purpose: purpose || undefined, + isCrossCheque, + isAccountPayee, + isPostDated, + notes: notes || undefined, + printedBy: printedBy || undefined, + }) + toast.success("Cheque issued", `${updated.chequeNo} → ${updated.payeeName}`) + onUpdated(updated) + resetActionState() + } catch (err) { + toast.error("Could not issue cheque", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function submitStatusAction(action: ChequePageStatusAction) { + if (!page) return + if (action === ChequePageStatusAction.Clear && !clearedDate) { + setErrors({ clearedDate: "Cleared date is required" }) + return + } + if (action === ChequePageStatusAction.Cancel && !cancelReason.trim()) { + setErrors({ cancelReason: "Cancel reason is required" }) + return + } + setSubmitting(true) + try { + const updated = await chequePagesApi.updateStatus(page.chequeNo, { + action, + clearedDate: action === ChequePageStatusAction.Clear ? clearedDate : undefined, + cancelReason: action === ChequePageStatusAction.Cancel ? cancelReason : undefined, + performedBy: performedBy || undefined, + }) + toast.success(`Cheque ${action.toLowerCase()}d`, updated.chequeNo) + onUpdated(updated) + resetActionState() + } catch (err) { + toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (!page) return null + + const availableActions: ChequePageStatusAction[] = + page.issueStatus === ChequePageIssueStatus.Unused + ? [ChequePageStatusAction.Cancel, ChequePageStatusAction.Void] + : page.issueStatus === ChequePageIssueStatus.Issued + ? [ChequePageStatusAction.Clear, ChequePageStatusAction.Bounce, ChequePageStatusAction.Cancel] + : [] + + return ( + + + + + Cheque {page.chequeNo} + + {page.issueStatus} + + + + + {!pendingAction && ( +
+
+
Payee
+
{page.payeeName ?? "—"}
+
Payee type
+
{page.payeeType ?? "—"}
+
Issue date
+
{formatReportDate(page.issueDate)}
+
Amount
+
{page.amount !== null ? formatAmount(page.amount) : "—"}
+
Reference no.
+
{page.referenceNo ?? "—"}
+
Purpose
+
{page.purpose ?? "—"}
+
Notes
+
{page.notes ?? "—"}
+ {page.issueStatus === ChequePageIssueStatus.Cleared && ( + <> +
Cleared date
+
{formatReportDate(page.clearedDate)}
+ + )} + {page.issueStatus === ChequePageIssueStatus.Cancelled && ( + <> +
Cancel reason
+
{page.cancelReason ?? "—"}
+ + )} +
+ + {availableActions.length > 0 && ( +
+ {page.issueStatus === ChequePageIssueStatus.Unused && ( + + )} + {availableActions.map((action) => ( + + ))} +
+ )} +
+ )} + + {pendingAction === "Issue" && ( + + + Payee name + setPayeeName(e.target.value)} aria-invalid={!!errors.payeeName} /> + + + + + Payee type + value={payeeType} onValueChange={(v) => setPayeeType(v ?? PayeeType.Supplier)}> + + + + + {Object.values(PayeeType).map((t) => ( + + {t} + + ))} + + + + + + Payee ID (optional) + setPayeeId(e.target.value)} /> + + +
+ + Issue date + setIssueDate(e.target.value)} + aria-invalid={!!errors.issueDate} + /> + + + + Amount + setAmount(e.target.value)} aria-invalid={!!errors.amount} /> + + +
+ +
+ + Currency + setCurrencyCode(e.target.value)} maxLength={3} /> + + + Voucher ID (optional) + setVoucherId(e.target.value)} /> + +
+ + + Reference no. (optional) + setReferenceNo(e.target.value)} /> + + + + Purpose (optional) + setPurpose(e.target.value)} /> + + +
+ + + +
+ + + Notes (optional) + setNotes(e.target.value)} /> + + + + Printed by (optional) + setPrintedBy(e.target.value)} /> + +
+ )} + + {pendingAction === ChequePageStatusAction.Clear && ( + + + Cleared date + setClearedDate(e.target.value)} + aria-invalid={!!errors.clearedDate} + /> + + + + Performed by (optional) + setPerformedBy(e.target.value)} /> + + + )} + + {pendingAction === ChequePageStatusAction.Cancel && ( + + + Cancel reason + setCancelReason(e.target.value)} + aria-invalid={!!errors.cancelReason} + /> + + + + Performed by (optional) + setPerformedBy(e.target.value)} /> + + + )} + + {(pendingAction === ChequePageStatusAction.Bounce || pendingAction === ChequePageStatusAction.Void) && ( + + + Performed by (optional) + setPerformedBy(e.target.value)} /> + + + )} + + {pendingAction && ( + + + + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/components/accounts/ReceivedChequeDialog.tsx b/Frontend/erp-system/components/accounts/ReceivedChequeDialog.tsx new file mode 100644 index 0000000..1ef4ec2 --- /dev/null +++ b/Frontend/erp-system/components/accounts/ReceivedChequeDialog.tsx @@ -0,0 +1,236 @@ +"use client" + +import { useEffect, useState } from "react" + +import { bankAccountsApi, receivedChequesApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { formatAmount, formatReportDate } from "@/lib/format" +import { cn } from "@/lib/utils" +import { + CashAndBankAccountDto, + CashBankAccountType, + ReceivedCheque, + ReceivedChequeStatus, + ReceivedChequeStatusAction, +} from "@/types/general-ledger" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" + +const STATUS_BADGE: Record = { + [ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground", + [ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary", + [ReceivedChequeStatus.Cleared]: "bg-success/10 text-success", + [ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive", + [ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive", +} + +interface ReceivedChequeDialogProps { + cheque: ReceivedCheque | null + open: boolean + onOpenChange: (open: boolean) => void + onUpdated: (updated: ReceivedCheque) => void +} + +/** View a received cheque's details and (from `Received`/`Deposited`) move it through + * Deposit/Clear/Return/Cancel — a modal, same posture as `ChequePageDialog`. */ +export function ReceivedChequeDialog({ cheque, open, onOpenChange, onUpdated }: ReceivedChequeDialogProps) { + const [pendingAction, setPendingAction] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [errors, setErrors] = useState>({}) + + const [bankAccounts, setBankAccounts] = useState(null) + const [depositBankAccountId, setDepositBankAccountId] = useState("") + const [depositDate, setDepositDate] = useState("") + const [performedBy, setPerformedBy] = useState("") + const [notes, setNotes] = useState("") + + useEffect(() => { + if (pendingAction !== ReceivedChequeStatusAction.Deposit || bankAccounts !== null) return + bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([])) + // Only fetched once, lazily, the first time Deposit is chosen. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingAction]) + + function resetActionState() { + setPendingAction(null) + setErrors({}) + setDepositBankAccountId("") + setDepositDate("") + setPerformedBy("") + setNotes("") + } + + function handleOpenChange(next: boolean) { + if (!next) resetActionState() + onOpenChange(next) + } + + async function submitStatusAction(action: ReceivedChequeStatusAction) { + if (!cheque) return + if (action === ReceivedChequeStatusAction.Deposit) { + const nextErrors: Record = {} + if (!depositBankAccountId) nextErrors.depositBankAccountId = "Select a deposit bank account" + if (!depositDate) nextErrors.depositDate = "Deposit date is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + } + setSubmitting(true) + try { + const updated = await receivedChequesApi.updateStatus(cheque.receivedChequeId, { + action, + depositBankAccountId: action === ReceivedChequeStatusAction.Deposit ? Number(depositBankAccountId) : undefined, + depositDate: action === ReceivedChequeStatusAction.Deposit ? depositDate : undefined, + notes: notes || undefined, + performedBy: performedBy || undefined, + }) + toast.success(`Cheque ${action.toLowerCase()}ed`, updated.chequeNo) + onUpdated(updated) + resetActionState() + } catch (err) { + toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (!cheque) return null + + const availableActions: ReceivedChequeStatusAction[] = + cheque.status === ReceivedChequeStatus.Received + ? [ReceivedChequeStatusAction.Deposit, ReceivedChequeStatusAction.Cancel] + : cheque.status === ReceivedChequeStatus.Deposited + ? [ReceivedChequeStatusAction.Clear, ReceivedChequeStatusAction.Return] + : [] + + return ( + + + + + Cheque {cheque.chequeNo} + + {cheque.status} + + + + + {!pendingAction && ( +
+
+
Received from
+
{cheque.receivedFromName}
+
Type
+
{cheque.receivedFromType}
+
Cheque date
+
{formatReportDate(cheque.chequeDate)}
+
Amount
+
{formatAmount(cheque.amount)}
+
Received date
+
{formatReportDate(cheque.receivedDate)}
+
Drawer bank
+
{cheque.drawerBankName ?? "—"}
+
Drawer branch
+
{cheque.drawerBankBranch ?? "—"}
+
Account holder
+
{cheque.accountHolderName ?? "—"}
+
Reference
+
{cheque.referenceType ?? "—"}
+
Notes
+
{cheque.notes ?? "—"}
+ {cheque.status === ReceivedChequeStatus.Deposited && ( + <> +
Deposited to
+
#{cheque.depositBankAccountId}
+
Deposit date
+
{formatReportDate(cheque.depositDate)}
+ + )} +
+ + {availableActions.length > 0 && ( +
+ {availableActions.map((action) => ( + + ))} +
+ )} +
+ )} + + {pendingAction === ReceivedChequeStatusAction.Deposit && ( + + + Deposit bank account + value={depositBankAccountId} onValueChange={(v) => setDepositBankAccountId(v ?? "")}> + + + + + {(bankAccounts ?? []).map((a) => ( + + {a.accountName} + + ))} + + + + + + Deposit date + setDepositDate(e.target.value)} + aria-invalid={!!errors.depositDate} + /> + + + + Performed by (optional) + setPerformedBy(e.target.value)} /> + + + Notes (optional) + setNotes(e.target.value)} /> + + + )} + + {(pendingAction === ReceivedChequeStatusAction.Clear || + pendingAction === ReceivedChequeStatusAction.Return || + pendingAction === ReceivedChequeStatusAction.Cancel) && ( + + + Performed by (optional) + setPerformedBy(e.target.value)} /> + + + Notes (optional) + setNotes(e.target.value)} /> + + + )} + + {pendingAction && ( + + + + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/components/dashboard/recent-orders-table.tsx b/Frontend/erp-system/components/dashboard/recent-orders-table.tsx index 3bf1829..45c5fe6 100644 --- a/Frontend/erp-system/components/dashboard/recent-orders-table.tsx +++ b/Frontend/erp-system/components/dashboard/recent-orders-table.tsx @@ -29,9 +29,9 @@ const statusStyles: Record = { } // Use a fixed locale to avoid hydration mismatches between server and client -const currency = new Intl.NumberFormat("en-US", { +const currency = new Intl.NumberFormat("en-LK", { style: "currency", - currency: "USD", + currency: "LKR", }) const columns: DataTableColumn[] = [ diff --git a/Frontend/erp-system/components/providers.tsx b/Frontend/erp-system/components/providers.tsx new file mode 100644 index 0000000..e3dd194 --- /dev/null +++ b/Frontend/erp-system/components/providers.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ThemeProvider } from "next-themes" + +import { TooltipProvider } from "@/components/ui/tooltip" + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/Frontend/erp-system/components/reports/DownloadCsvButton.tsx b/Frontend/erp-system/components/reports/DownloadCsvButton.tsx new file mode 100644 index 0000000..76158fb --- /dev/null +++ b/Frontend/erp-system/components/reports/DownloadCsvButton.tsx @@ -0,0 +1,42 @@ +"use client" + +import { useState } from "react" +import { FileSpreadsheet } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { ReportType } from "@/types/general-ledger" + +import { Button } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" + +interface DownloadCsvButtonProps { + reportType: ReportType + /** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */ + params: Record + /** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */ + disabled?: boolean +} + +/** Same mechanism as DownloadPdfButton, `outputFormat=Csv` — GL's bytes are downloaded unmodified. */ +export function DownloadCsvButton({ reportType, params, disabled }: DownloadCsvButtonProps) { + const [downloading, setDownloading] = useState(false) + + async function handleDownload() { + setDownloading(true) + try { + await reportsApi.downloadCsv(reportType, params) + } catch (err) { + toast.error("Could not download report", errorMessage(err)) + } finally { + setDownloading(false) + } + } + + return ( + + ) +} diff --git a/Frontend/erp-system/components/reports/DownloadPdfButton.tsx b/Frontend/erp-system/components/reports/DownloadPdfButton.tsx new file mode 100644 index 0000000..fc4fae2 --- /dev/null +++ b/Frontend/erp-system/components/reports/DownloadPdfButton.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useState } from "react" +import { Download } from "lucide-react" + +import { reportsApi } from "@/lib/api/general-ledger" +import { errorMessage } from "@/lib/error-map" +import { ReportType } from "@/types/general-ledger" + +import { Button } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" + +interface DownloadPdfButtonProps { + reportType: ReportType + /** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */ + params: Record + /** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */ + disabled?: boolean +} + +export function DownloadPdfButton({ reportType, params, disabled }: DownloadPdfButtonProps) { + const [downloading, setDownloading] = useState(false) + + async function handleDownload() { + setDownloading(true) + try { + await reportsApi.downloadPdf(reportType, params) + } catch (err) { + toast.error("Could not download report", errorMessage(err)) + } finally { + setDownloading(false) + } + } + + return ( + + ) +} diff --git a/Frontend/erp-system/components/reports/ReportHeader.tsx b/Frontend/erp-system/components/reports/ReportHeader.tsx new file mode 100644 index 0000000..c568ec8 --- /dev/null +++ b/Frontend/erp-system/components/reports/ReportHeader.tsx @@ -0,0 +1,27 @@ +// Statutory-style report header (docs/21-GENERAL-LEDGER-FRONTEND.md "Sri Lankan Standard report +// UI"): centered title block, LKAS-aligned statement names, period/as-at line, currency note — +// the same shape whether the report renders on screen or the downloaded PDF (the PDF itself is +// rendered server-side by the GL service; this header is the on-screen equivalent). + +interface ReportHeaderProps { + title: string + subtitle: string + currencyNote?: string +} + +export function ReportHeader({ + title, + subtitle, + currencyNote = "All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.", +}: ReportHeaderProps) { + return ( +
+

+ General Ledger +

+

{title}

+

{subtitle}

+

{currencyNote}

+
+ ) +} diff --git a/Frontend/erp-system/components/reports/ReportSection.tsx b/Frontend/erp-system/components/reports/ReportSection.tsx new file mode 100644 index 0000000..f5d1aa4 --- /dev/null +++ b/Frontend/erp-system/components/reports/ReportSection.tsx @@ -0,0 +1,42 @@ +import { formatAmount } from "@/lib/format" +import { Table, TableBody, TableCell, TableFooter, TableRow } from "@/components/ui/table" + +export interface ReportSectionLine { + label: React.ReactNode + amount: number +} + +interface ReportSectionProps { + title: string + lines: ReportSectionLine[] + /** Omit to hide the total row entirely (e.g. a single-line section that would just repeat itself). */ + total?: number +} + +/** One bordered statement section: its lines, then an optional bold total row. Renders nothing when there are no lines. */ +export function ReportSection({ title, lines, total }: ReportSectionProps) { + if (lines.length === 0) return null + return ( +
+

{title}

+ + + {lines.map((line, i) => ( + + {line.label} + {formatAmount(line.amount)} + + ))} + + {total !== undefined && ( + + + Total {title} + {formatAmount(total)} + + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/components/reports/ReportSubtotal.tsx b/Frontend/erp-system/components/reports/ReportSubtotal.tsx new file mode 100644 index 0000000..8da7d39 --- /dev/null +++ b/Frontend/erp-system/components/reports/ReportSubtotal.tsx @@ -0,0 +1,23 @@ +import { formatAmount } from "@/lib/format" +import { cn } from "@/lib/utils" + +interface ReportSubtotalProps { + label: string + amount: number + large?: boolean +} + +/** A bold, unbordered subtotal/total line (Gross Profit, Net Cash From Operations, the final total, etc.). */ +export function ReportSubtotal({ label, amount, large }: ReportSubtotalProps) { + return ( +
+ {label} + {formatAmount(amount)} +
+ ) +} diff --git a/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx b/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx new file mode 100644 index 0000000..03d0387 --- /dev/null +++ b/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx @@ -0,0 +1,65 @@ +"use client" + +import Link from "next/link" +import { Lightbulb, PackageCheck } from "lucide-react" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { SalesFreeIssueSuggestion } from "@/types/sales" + +export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: SalesFreeIssueSuggestion | null }) { + if (!suggestion || suggestion.lines.length === 0) { + return ( +
+ No free-issue promotion suggestions were generated for this slip yet. +
+ ) + } + + return ( +
+
+
+
+ + Backend suggestion +
+

Free-issue promotions

+

The server suggests reward quantities and alternate products for this slip.

+
+ + Create free issue + +
+ +
+ {suggestion.lines.map((line) => ( +
+
+
+
{line.itemName}
+
{line.itemSku} • Qty {line.qty}
+
+
+ Buy {line.triggerQty} get {line.suggestedFreeQty} free +
+
+ +
+ {line.rewardOptions.map((option, index) => ( + + {index === 0 ? : null} + {option.name} + + ))} +
+ +
+ Suggested free qty: {line.suggestedFreeQty.toFixed(2)} +
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx b/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx new file mode 100644 index 0000000..2813912 --- /dev/null +++ b/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx @@ -0,0 +1,42 @@ +"use client" + +import Link from "next/link" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { SalesSlipSummary } from "@/types/sales" + +export function FreeIssueSuggestions({ rows }: { rows: SalesSlipSummary[] }) { + if (rows.length === 0) return null + + return ( +
+
+
+

Free issue suggestions

+

Recent free-issue slips created from the free-issues page.

+
+ + Open free issues + +
+ +
+ {rows.map((row) => ( + +
+
{row.slipNo}
+
{row.status}
+
+
{row.customerSnapshotName}
+
Free qty total: {row.totals.freeQtyTotal.toFixed(2)}
+ + ))} +
+
+ ) +} \ No newline at end of file diff --git a/Frontend/erp-system/components/ui/alert-dialog.tsx b/Frontend/erp-system/components/ui/alert-dialog.tsx index 315d66d..57d09dc 100644 --- a/Frontend/erp-system/components/ui/alert-dialog.tsx +++ b/Frontend/erp-system/components/ui/alert-dialog.tsx @@ -43,23 +43,23 @@ const variantConfig: Record< > = { info: { icon: InfoIcon, - iconClass: "text-sky-600 bg-sky-50", - ringClass: "ring-sky-100", + iconClass: "text-info bg-info/10", + ringClass: "ring-info/20", }, warning: { icon: AlertTriangleIcon, - iconClass: "text-amber-600 bg-amber-50", - ringClass: "ring-amber-100", + iconClass: "text-warning bg-warning/10", + ringClass: "ring-warning/20", }, destructive: { icon: XCircleIcon, - iconClass: "text-red-600 bg-red-50", - ringClass: "ring-red-100", + iconClass: "text-destructive bg-destructive/10", + ringClass: "ring-destructive/20", }, success: { icon: CheckCircle2Icon, - iconClass: "text-emerald-600 bg-emerald-50", - ringClass: "ring-emerald-100", + iconClass: "text-success bg-success/10", + ringClass: "ring-success/20", }, } @@ -92,7 +92,7 @@ function AlertDialogContent({
- + {title} {description && ( - + {description} )} diff --git a/Frontend/erp-system/lib/api/bundles.ts b/Frontend/erp-system/lib/api/bundles.ts new file mode 100644 index 0000000..9400d3f --- /dev/null +++ b/Frontend/erp-system/lib/api/bundles.ts @@ -0,0 +1,49 @@ +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { + BundleSale, + BundleSalePostingCheck, + BundleSaleSummary, + BundleSaleTemplate, + BundleSaleTemplateSummary, + CreateBundleSaleRequest, + UpdateBundleSaleRequest, +} from "@/types/bundles" + +export const bundleApi = { + listBundles(params: { page?: number; pageSize?: number; status?: string; customerId?: number; warehouseId?: number; q?: string } = {}): Promise> { + return apiRequest>(`/bundle-sales${buildQuery(params)}`) + }, + + getBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}`) + }, + + createBundle(request: CreateBundleSaleRequest): Promise { + return apiRequest("/bundle-sales", { method: "POST", body: request }) + }, + + updateBundle(bundleSaleId: number, request: UpdateBundleSaleRequest): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}`, { method: "PUT", body: request }) + }, + + postBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/post`, { method: "POST" }) + }, + + cancelBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/cancel`, { method: "POST" }) + }, + + checkBundlePosting(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/posting-check`) + }, + + listTemplates(params: { page?: number; pageSize?: number; q?: string } = {}): Promise> { + return apiRequest>(`/bundle-sales/templates${buildQuery(params)}`) + }, + + getTemplate(bundleSaleTemplateId: number): Promise { + return apiRequest(`/bundle-sales/templates/${bundleSaleTemplateId}`) + }, +} diff --git a/Frontend/erp-system/lib/api/customers.ts b/Frontend/erp-system/lib/api/customers.ts new file mode 100644 index 0000000..eaeabb9 --- /dev/null +++ b/Frontend/erp-system/lib/api/customers.ts @@ -0,0 +1,15 @@ +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { Customer } from "@/types/customers" + +export interface ListCustomersParams { + page?: number + pageSize?: number + q?: string +} + +export const customersApi = { + list(params: ListCustomersParams = {}): Promise> { + return apiRequest>(`/customers${buildQuery(params)}`) + }, +} diff --git a/Frontend/erp-system/lib/api/general-ledger.ts b/Frontend/erp-system/lib/api/general-ledger.ts new file mode 100644 index 0000000..c7948a9 --- /dev/null +++ b/Frontend/erp-system/lib/api/general-ledger.ts @@ -0,0 +1,322 @@ +// Client for the external General Ledger service, reached through ERPCore's generic +// reverse-proxy at /api/v1/gl/* (docs/12-GENERAL-LEDGER-INTEGRATION.md). Deliberately NOT +// built on lib/api-client.ts's apiRequest/apiRequestWithETag: those assume ERPCore's own +// RFC 7807 ProblemDetails error shape and a bare-DTO success body. GL wraps every response +// (success AND error) in its own `{ statusCode, success, message, data }` envelope instead, +// and — a documented GL quirk — success bodies are camelCase while error bodies are +// PascalCase, so this module unwraps both forms itself rather than trusting one casing. +import { + CashAccountType, + CashAndBankAccountDto, + CashBankAccountType, + CreateBankAccountRequest, + CreateCashAccountRequest, + CreateCashOrBankAccountResponse, + GlAccountListResult, + GlBudget, + GlFilePayload, + ReportOutputFormat, + ReportType, + TrialBalanceRow, + BalanceSheetResponse, + GeneralLedgerRow, + ProfitAndLossResponse, + CashFlowResponse, + BudgetVsActualRow, + TaxSummaryResponse, + TaxSummaryParams, + GlPagedResult, + ChequeBook, + ChequeBookStatus, + ChequePage, + CreateChequeBookRequest, + IssueChequePageRequest, + UpdateChequePageStatusRequest, + ReceivedCheque, + ReceivedChequeStatus, + ReceivedFromType, + CreateReceivedChequeRequest, + UpdateReceivedChequeStatusRequest, +} from "@/types/general-ledger" + +const GL_BASE = "/api/v1/gl" + +/** Duck-type compatible with lib/error-map.ts's ApiErrorLike — `detail` carries GL's own message. */ +export class GlApiError extends Error { + status: number + detail: string + + constructor(status: number, message: string) { + super(message) + this.status = status + this.detail = message + } +} + +interface GlEnvelope { + statusCode?: number + StatusCode?: number + success?: boolean + Success?: boolean + message?: string + Message?: string + data?: T + Data?: T + // Surfaces only when the proxy itself fails before reaching GL (e.g. ERPCore's own + // 503 GL_SERVICE_UNAVAILABLE ProblemDetails) rather than GL's own envelope. + title?: string + detail?: string +} + +type GlQueryValue = string | number | undefined + +async function glRequest( + path: string, + options: { method?: string; query?: Record; body?: unknown } = {} +): Promise { + const { method = "GET", query, body } = options + const search = new URLSearchParams() + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === "") continue + search.set(key, String(value)) + } + } + const qs = search.toString() + + const response = await fetch(`${GL_BASE}${path}${qs ? `?${qs}` : ""}`, { + method, + credentials: "include", // the proxy is ErpAccess-gated, same as every other v1 endpoint + headers: { + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + let envelope: GlEnvelope | null = null + try { + envelope = (await response.json()) as GlEnvelope + } catch { + // Non-JSON body — e.g. an unreachable proxy hop. Falls through to the generic message below. + } + + const success = envelope?.success ?? envelope?.Success ?? false + if (!response.ok || !success) { + const message = + envelope?.message ?? envelope?.Message ?? envelope?.detail ?? envelope?.title ?? + response.statusText ?? "General Ledger service request failed" + throw new GlApiError(response.status, message) + } + + return (envelope?.data ?? envelope?.Data) as T +} + +/** Decodes a base64 payload and triggers a browser download — no server round-trip needed. */ +function downloadBase64File(base64: string, fileName: string, contentType: string) { + const byteChars = atob(base64) + const byteNumbers = new Array(byteChars.length) + for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i) + const blob = new Blob([new Uint8Array(byteNumbers)], { type: contentType }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +/** Shared by downloadPdf/downloadCsv — same report call, only `outputFormat` differs. */ +async function downloadReportFile( + reportType: ReportType, + outputFormat: ReportOutputFormat.Pdf | ReportOutputFormat.Csv, + params: Record +): Promise { + const payload = await glRequest("/reports", { + query: { reportType, outputFormat, ...params }, + }) + downloadBase64File(payload.contentBase64, payload.fileName, payload.contentType) +} + +export const reportsApi = { + trialBalance(asOfDate: string) { + return glRequest("/reports", { + query: { reportType: ReportType.TrialBalance, outputFormat: ReportOutputFormat.Json, asOfDate }, + }) + }, + + /** Confirmed classified-statement shape (2026-07-31 rework) — see types/general-ledger.ts's BalanceSheetResponse note. */ + balanceSheet(asOfDate: string) { + return glRequest("/reports", { + query: { reportType: ReportType.BalanceSheet, outputFormat: ReportOutputFormat.Json, asOfDate }, + }) + }, + + // GL's `accountCode` param is optional (renamed from `accountId` in GL's 2026-07-22 revision, + // CLAUDE.md Rule 8.2 on GL's side — behavior unchanged): omitted, this returns the true General + // Ledger — every postable account's own transactions together, each with its own running + // balance (resets per account), sorted by accountCode then entryDate. Supplying accountCode + // switches to "Account Ledger" mode (one account + its descendants, one running balance) — not + // used by this page; add it back with an accountCode param if a single-account view is needed later. + generalLedger(periodStart: string, periodEnd: string) { + return glRequest("/reports", { + query: { reportType: ReportType.GeneralLedger, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd }, + }) + }, + + /** Nested-sections shape as of the 2026-07-22 rework — see types/general-ledger.ts's ProfitAndLossResponse note. */ + profitAndLoss(periodStart: string, periodEnd: string) { + return glRequest("/reports", { + query: { reportType: ReportType.ProfitAndLoss, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd }, + }) + }, + + /** Confirmed structured-statement shape (2026-07-22 rework) — see types/general-ledger.ts's CashFlowResponse note; everything nests under `operatingActivities`. */ + cashFlow(periodStart: string, periodEnd: string) { + return glRequest("/reports", { + query: { reportType: ReportType.CashFlow, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd }, + }) + }, + + budgetVsActual(budgetId: number) { + return glRequest("/reports", { + query: { reportType: ReportType.BudgetVsActual, outputFormat: ReportOutputFormat.Json, budgetId }, + }) + }, + + /** Income Tax Computation — new report (2026-07-22). Optional params get no client-side default; an untouched field sends nothing, letting GL's own server-side defaulting be the single source of truth. */ + taxSummary(params: TaxSummaryParams) { + return glRequest("/reports", { + query: { reportType: ReportType.TaxSummary, outputFormat: ReportOutputFormat.Json, ...params }, + }) + }, + + /** Same report call as the Json variants above, only `outputFormat` differs — the PDF bytes come from the same endpoint. */ + downloadPdf(reportType: ReportType, params: Record): Promise { + return downloadReportFile(reportType, ReportOutputFormat.Pdf, params) + }, + + /** Same mechanism as downloadPdf, `outputFormat=Csv` — GL's bytes are downloaded unmodified, not reshaped/reformatted client-side. */ + downloadCsv(reportType: ReportType, params: Record): Promise { + return downloadReportFile(reportType, ReportOutputFormat.Csv, params) + }, +} + +/** + * Chart of Accounts — used by the Cash/Bank Accounts list page to resolve each row's `glAccountId` + * into a readable account code/name (retrofit 2026-07-31: the create form no longer needs this at + * all, since `glAccountCode` was removed from the create request — the GL account is auto-created). + * The General Ledger **report** page deliberately does NOT use this: it always calls the report in + * full-ledger mode (no `accountCode`), so every account's code/name shown come from the report's own + * rows (`GeneralLedgerRow.accountCode`/`accountName`), not a separate `/accounts` call — see + * docs/21-GENERAL-LEDGER-FRONTEND.md. + */ +export const glAccountsApi = { + list(): Promise { + return glRequest("/accounts") + }, +} + +/** Used only to populate the Budget vs Actual report's budget picker. */ +export const glBudgetsApi = { + list(): Promise { + return glRequest("/budgets") + }, +} + +export const bankAccountsApi = { + /** GL's own server-side union of both tables (2026-07-22 rework) — `accountType` narrows which table(s) contribute rows; client-side filters still layer on top. */ + list(accountType?: CashBankAccountType | "Both"): Promise { + return glRequest("/bank-accounts", { query: { accountType } }) + }, + + createBank(request: CreateBankAccountRequest): Promise { + return glRequest("/bank-accounts", { method: "POST", body: request }) + }, + + createCash(request: CreateCashAccountRequest): Promise { + return glRequest("/cash-accounts", { method: "POST", body: request }) + }, + + // No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today + // (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit"). +} + +/** Feeds the Cash/Bank create form's Cash Account Type picker; a name with no match creates a new type on the fly server-side (nothing to pre-create from this list). */ +export const cashAccountTypesApi = { + list(): Promise { + return glRequest("/cash-account-types") + }, +} + +/** + * Cheque Books/Pages — cheques issued from this company's own cheque books (Cheque Management + * module, added to GL 2026-07-30). `chequeBookNo` is the identifying value GL uses in its own + * routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always + * read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them. + */ +export const chequeBooksApi = { + list(params?: { + bankAccountId?: number + branchId?: number + status?: ChequeBookStatus + page?: number + pageSize?: number + }): Promise> { + return glRequest>("/cheque-books", { query: { ...params } }) + }, + + /** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */ + get(chequeBookNo: string, expandPages = false): Promise { + return glRequest(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, { + query: expandPages ? { expand: "pages" } : undefined, + }) + }, + + /** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */ + create(request: CreateChequeBookRequest): Promise { + return glRequest("/cheque-books", { method: "POST", body: request }) + }, +} + +export const chequePagesApi = { + issue(chequeNo: string, request: IssueChequePageRequest): Promise { + return glRequest(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, { + method: "PUT", + body: request, + }) + }, + + /** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */ + updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise { + return glRequest(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, { + method: "PUT", + body: request, + }) + }, +} + +/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */ +export const receivedChequesApi = { + list(params?: { + companyId?: number + branchId?: number + status?: ReceivedChequeStatus + receivedFromType?: ReceivedFromType + page?: number + pageSize?: number + }): Promise> { + return glRequest>("/received-cheques", { query: { ...params } }) + }, + + create(request: CreateReceivedChequeRequest): Promise { + return glRequest("/received-cheques", { method: "POST", body: request }) + }, + + /** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */ + updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise { + return glRequest(`/received-cheques/${id}/status`, { method: "PUT", body: request }) + }, +} diff --git a/Frontend/erp-system/lib/api/sales.ts b/Frontend/erp-system/lib/api/sales.ts new file mode 100644 index 0000000..fa8a9c3 --- /dev/null +++ b/Frontend/erp-system/lib/api/sales.ts @@ -0,0 +1,139 @@ +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, PagedResponse } from "@/types/common" +import { + CreateSalesInvoiceRequest, + CreateSalesSlipRequest, + SalesInvoice, + SalesInvoiceStatus, + SalesInvoicePostingCheck, + SalesInvoiceSummary, + SalesListFilterParams, + SalesReportDefinition, + SalesReportQueryRequest, + SalesReportQueryResponse, + SalesFreeIssueSuggestion, + FreeIssueDetail, + FreeIssueSummary, + SalesSlip, + SalesSlipStatus, + SalesSlipPostingCheck, + SalesSlipSummary, + UpdateSalesInvoiceRequest, + UpdateSalesSlipRequest, +} from "@/types/sales" + +type SalesReportId = string + +export interface ListSalesInvoicesParams extends SalesListFilterParams { + status?: SalesInvoiceStatus +} + +export interface ListSalesSlipsParams extends SalesListFilterParams { + status?: SalesSlipStatus +} + +export interface ListFreeIssuesParams extends SalesListFilterParams { + status?: SalesSlipStatus +} + +export const salesApi = { + listInvoices(params: ListSalesInvoicesParams = {}): Promise> { + return apiRequest>(`/sales-invoices${buildQuery(params)}`) + }, + + getInvoice(salesInvoiceId: number): Promise> { + return apiRequestWithETag(`/sales-invoices/${salesInvoiceId}`) + }, + + createInvoice(request: CreateSalesInvoiceRequest): Promise> { + return apiRequestWithETag("/sales-invoices", { method: "POST", body: request }) + }, + + updateInvoice(salesInvoiceId: number, request: UpdateSalesInvoiceRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/sales-invoices/${salesInvoiceId}`, { method: "PUT", body: request, ifMatch }) + }, + + postInvoice(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/post`, { method: "POST" }) + }, + + checkInvoicePosting(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/posting-check`) + }, + + cancelInvoice(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" }) + }, + + listSlips(params: ListSalesSlipsParams = {}): Promise> { + return apiRequest>(`/sales-slips${buildQuery(params)}`) + }, + + getSlip(salesSlipId: number): Promise> { + return apiRequestWithETag(`/sales-slips/${salesSlipId}`) + }, + + createSlip(request: CreateSalesSlipRequest): Promise> { + return apiRequestWithETag("/sales-slips", { method: "POST", body: request }) + }, + + updateSlip(salesSlipId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/sales-slips/${salesSlipId}`, { method: "PUT", body: request, ifMatch }) + }, + + postSlip(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/post`, { method: "POST" }) + }, + + checkSlipPosting(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/posting-check`) + }, + + cancelSlip(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/cancel`, { method: "POST" }) + }, + + listFreeIssues(params: ListFreeIssuesParams = {}): Promise> { + return apiRequest>(`/free-issues${buildQuery(params)}`) + }, + + getFreeIssue(freeIssueId: number): Promise> { + return apiRequestWithETag(`/free-issues/${freeIssueId}`) + }, + + createFreeIssue(request: CreateSalesSlipRequest): Promise> { + return apiRequestWithETag("/free-issues", { method: "POST", body: request }) + }, + + updateFreeIssue(freeIssueId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/free-issues/${freeIssueId}`, { method: "PUT", body: request, ifMatch }) + }, + + postFreeIssue(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/post`, { method: "POST" }) + }, + + checkFreeIssuePosting(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/posting-check`) + }, + + cancelFreeIssue(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/cancel`, { method: "POST" }) + }, + + getFreeIssueSuggestions(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/free-issue-suggestions`) + }, + + listReports(): Promise { + return apiRequest("/reports/sales") + }, + + getReport(reportId: SalesReportId): Promise { + return apiRequest(`/reports/sales/${reportId}`) + }, + + queryReport(request: SalesReportQueryRequest): Promise { + return apiRequest("/reports/sales/query", { method: "POST", body: request }) + }, +} diff --git a/Frontend/erp-system/lib/format.ts b/Frontend/erp-system/lib/format.ts new file mode 100644 index 0000000..28e9275 --- /dev/null +++ b/Frontend/erp-system/lib/format.ts @@ -0,0 +1,35 @@ +// Formatting helpers for statutory-style financial reports (docs/21-GENERAL-LEDGER-FRONTEND.md) — +// comma-grouped thousands, fixed 2 decimals, negatives in parentheses (standard financial-statement +// convention), rather than the plain `.toFixed(2)` used by the inventory-side stock screens. + +const AMOUNT_FORMATTER = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +/** `1234.5` -> "1,234.50"; `-1234.5` -> "(1,234.50)"; `0`/`null`/`undefined` -> the given fallback. */ +export function formatAmount(value: number | null | undefined, zeroDash = false): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—" + if (zeroDash && value === 0) return "—" + const formatted = AMOUNT_FORMATTER.format(Math.abs(value)) + return value < 0 ? `(${formatted})` : formatted +} + +/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */ +export function formatReportDate(value: string | null | undefined): string { + if (!value) return "—" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) +} + +/** Today's date as `YYYY-MM-DD`, for default report filter values. */ +export function todayIso(): string { + return new Date().toISOString().slice(0, 10) +} + +/** The first day of the current month as `YYYY-MM-DD`, for default period-start filter values. */ +export function startOfMonthIso(): string { + const now = new Date() + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10) +} diff --git a/Frontend/erp-system/lib/sales-line-utils.ts b/Frontend/erp-system/lib/sales-line-utils.ts new file mode 100644 index 0000000..5037f43 --- /dev/null +++ b/Frontend/erp-system/lib/sales-line-utils.ts @@ -0,0 +1,6 @@ +import { ItemListItem } from "@/types/master-data" + +export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null { + if (!itemId) return null + return items.find((item) => item.itemId === itemId)?.salePrice ?? null +} \ No newline at end of file diff --git a/Frontend/erp-system/lib/validations/general-ledger.ts b/Frontend/erp-system/lib/validations/general-ledger.ts new file mode 100644 index 0000000..7420f32 --- /dev/null +++ b/Frontend/erp-system/lib/validations/general-ledger.ts @@ -0,0 +1,55 @@ +// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields the browser +// already knows about. Everything else is server-authoritative and surfaced via the GL +// service's own error message (lib/error-map.ts). + +export function validateBankAccountForm(input: { accountName: string }): Record { + const errors: Record = {} + if (!input.accountName.trim()) errors.accountName = "Account name is required" + return errors +} + +export function validateChequeBookForm(input: { + branchId: string + bankAccountId: string + chequeBookNo: string + startChequeNo: string + endChequeNo: string + totalLeaves: string + receivedDate: string +}): Record { + const errors: Record = {} + if (!input.branchId.trim()) errors.branchId = "Branch ID is required" + if (!input.bankAccountId) errors.bankAccountId = "Select a bank account" + if (!input.chequeBookNo.trim()) errors.chequeBookNo = "Cheque book number is required" + if (!input.startChequeNo.trim()) errors.startChequeNo = "Start cheque number is required" + if (!input.endChequeNo.trim()) errors.endChequeNo = "End cheque number is required" + if (!input.totalLeaves.trim()) errors.totalLeaves = "Total leaves is required" + if (!input.receivedDate) errors.receivedDate = "Received date is required" + return errors +} + +export function validateIssueChequeForm(input: { payeeName: string; issueDate: string; amount: string }): Record { + const errors: Record = {} + if (!input.payeeName.trim()) errors.payeeName = "Payee name is required" + if (!input.issueDate) errors.issueDate = "Issue date is required" + if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0" + return errors +} + +export function validateReceivedChequeForm(input: { + companyId: string + receivedFromName: string + chequeNo: string + chequeDate: string + amount: string + receivedDate: string +}): Record { + const errors: Record = {} + if (!input.companyId.trim()) errors.companyId = "Company ID is required" + if (!input.receivedFromName.trim()) errors.receivedFromName = "Received-from name is required" + if (!input.chequeNo.trim()) errors.chequeNo = "Cheque number is required" + if (!input.chequeDate) errors.chequeDate = "Cheque date is required" + if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0" + if (!input.receivedDate) errors.receivedDate = "Received date is required" + return errors +} diff --git a/Frontend/erp-system/lib/vendor-code.ts b/Frontend/erp-system/lib/vendor-code.ts new file mode 100644 index 0000000..9908f8d --- /dev/null +++ b/Frontend/erp-system/lib/vendor-code.ts @@ -0,0 +1,18 @@ +/** First word of the name, uppercased and stripped to alphanumerics — falls back to "VN" + * so an empty/punctuation-only name still yields a usable base. Mirrors the warehouse + * code generator (app/dashboard/warehouse/page.tsx). */ +function vendorCodeBase(name: string): string { + const firstWord = name.trim().split(/\s+/)[0] ?? "" + const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, 10) || "VN" +} + +/** Appends a numeric suffix until the code doesn't collide with an existing one — the + * backend enforces global uniqueness (409 on conflict) but has no generation of its own. */ +export function generateVendorCode(name: string, existingCodes: string[]): string { + const base = `VN-${vendorCodeBase(name)}` + if (!existingCodes.includes(base)) return base + let suffix = 2 + while (existingCodes.includes(`${base}${suffix}`)) suffix += 1 + return `${base}${suffix}` +} diff --git a/Frontend/erp-system/types/bundles.ts b/Frontend/erp-system/types/bundles.ts new file mode 100644 index 0000000..d5d8ae7 --- /dev/null +++ b/Frontend/erp-system/types/bundles.ts @@ -0,0 +1,114 @@ +import { EntityStatus } from "@/types/common" + +export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled" + +export interface BundleSaleTemplateLine { + bundleSaleTemplateLineId: number + itemId: number + uomId: number + warehouseId: number + qty: number + unitPrice: number + includeInBundle: boolean + sortOrder: number +} + +export interface BundleSaleTemplateSummary { + bundleSaleTemplateId: number + templateCode: string + templateName: string + description: string | null + status: EntityStatus + lineCount: number + createdAt: string + updatedAt: string | null +} + +export interface BundleSaleTemplate { + bundleSaleTemplateId: number + templateCode: string + templateName: string + description: string | null + status: EntityStatus + createdAt: string + updatedAt: string | null + lines: BundleSaleTemplateLine[] +} + +export interface BundleSaleLine { + bundleSaleLineId: number + itemId: number + description: string + qty: number + uomId: number + warehouseId: number + unitPrice: number + lineTotal: number + includeInBundle: boolean + isComponent: boolean + parentLineId: number | null +} + +export interface BundleSaleTotals { + componentSubtotal: number + bundlePrice: number + marginAmount: number + discountTotal: number + taxTotal: number + grandTotal: number +} + +export interface BundleSaleSummary { + bundleSaleId: number + bundleNo: string + bundleDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + bundleName: string + bundleCode: string + status: BundleSaleStatus + componentSubtotal: number + bundlePrice: number + grandTotal: number + createdAt: string +} + +export interface BundleSale extends BundleSaleSummary, BundleSaleTotals { + cashierUserId: number + bundleSaleTemplateId: number + updatedAt: string | null + lines: BundleSaleLine[] +} + +export interface BundleSalePostingIssue { + bundleSaleLineId: number + itemId: number + itemSku: string + itemName: string + warehouseId: number + requestedQty: number + availableQty: number + shortQty: number +} + +export interface BundleSalePostingCheck { + bundleSaleId: number + bundleNo: string + status: BundleSaleStatus + canPost: boolean + issues: BundleSalePostingIssue[] +} + +export interface CreateBundleSaleRequest { + customerId: number + warehouseId: number + cashierUserId: number + bundleSaleTemplateId: number + bundleName: string + bundlePrice: number + allowPriceOverride: boolean + lines: BundleSaleTemplateLine[] +} + +export type UpdateBundleSaleRequest = CreateBundleSaleRequest diff --git a/Frontend/erp-system/types/customers.ts b/Frontend/erp-system/types/customers.ts new file mode 100644 index 0000000..a11afdc --- /dev/null +++ b/Frontend/erp-system/types/customers.ts @@ -0,0 +1,24 @@ +import { EntityStatus } from "@/types/common" + +export type CustomerType = "B2B" | "B2C" + +export interface Customer { + customerId: number + customerCode: string + customerType: CustomerType + name: string + displayName: string | null + phone: string | null + email: string | null + addressLine1: string | null + addressLine2: string | null + city: string | null + country: string | null + taxRegistrationNo: string | null + creditLimit: number + creditDays: number + defaultWarehouseId: number | null + status: EntityStatus + createdAt: string + updatedAt: string | null +} diff --git a/Frontend/erp-system/types/general-ledger.ts b/Frontend/erp-system/types/general-ledger.ts new file mode 100644 index 0000000..66817fa --- /dev/null +++ b/Frontend/erp-system/types/general-ledger.ts @@ -0,0 +1,558 @@ +// Mirrors the external General Ledger service's own contract (04_API_Reference_And_Scenarios.md +// in that service's repo — ERPCore does not re-document it, see docs/12-GENERAL-LEDGER-INTEGRATION.md). +// Every field here is read through ERPCore's generic proxy (`lib/api/general-ledger.ts`), which +// forwards byte-for-byte, so these types describe GL's response `data` shape directly, not an +// ERPCore DTO. As of GL's 2026-07-22 revision (docs/21-GENERAL-LEDGER-FRONTEND.md §0/§3/§4). + +/** `account_type` seed rows — the block each drives in a generated account code (1000/2000/3000/4000/5000). */ +export enum GlAccountTypeId { + Asset = 1, + Liability = 2, + Equity = 3, + Income = 4, + Expense = 5, +} + +export interface GlAccount { + accountId: number + accountCode: string + accountName: string + accountTypeId: GlAccountTypeId + parentAccountId: number | null + isControlAccount: boolean + isPostable: boolean + isActive: boolean + currencyCode: string + cashFlowCategory?: string | null +} + +export interface GlAccountListResult { + items: GlAccount[] + totalCount: number + page: number | null + pageSize: number | null +} + +export interface GlBudget { + budgetId: number + fiscalYearId: number + name: string + lines: unknown[] +} + +export enum ReportOutputFormat { + Json = "Json", + Pdf = "Pdf", + Csv = "Csv", +} + +export enum ReportType { + TrialBalance = "TrialBalance", + BalanceSheet = "BalanceSheet", + GeneralLedger = "GeneralLedger", + ProfitAndLoss = "ProfitAndLoss", + CashFlow = "CashFlow", + BudgetVsActual = "BudgetVsActual", + TaxSummary = "TaxSummary", +} + +/** Flat as of the 2026-07-22 revision — GL dropped the hierarchy (`depth`/`indentedCode`) this report used to carry. */ +export interface TrialBalanceRow { + accountCode: string + accountName: string + debit: number + credit: number +} + +/** One leaf account within a Balance Sheet section — no `depth`/hierarchy anymore (2026-07-31 rework, see BalanceSheetResponse). */ +export interface BalanceSheetLine { + accountCode: string + accountName: string + balance: number +} + +export interface BalanceSheetSection { + lines: BalanceSheetLine[] + total: number +} + +/** + * Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting, retrofit 2026-07-31) — + * replaces the old flat recursive-rollup array (`{depth, lineItem, accountType, balance}`) entirely + * with a classified LKAS 1 Statement of Financial Position: Non-Current/Current split for both + * Assets and Liabilities, driven by GL's new `accounts.balance_sheet_classification` tag. + * `equity.lines[]` always includes a synthetic `{ accountCode: "", accountName: "Current Year + * Earnings", balance }` line (even at `0.00`). Untagged leaf accounts land in + * `unclassifiedAssets`/`unclassifiedLiabilities` rather than being silently dropped. + * + * Every section is optional — same defensive posture as `CashFlowResponse`/`ProfitAndLossResponse` + * (confirmed live: GL omits an empty section from the JSON entirely rather than sending + * `{ lines: [], total: 0 }`), applied here pre-emptively since this exact shape hasn't been + * live-verified against this frontend yet. + */ +export interface BalanceSheetResponse { + asOfDate: string + nonCurrentAssets?: BalanceSheetSection + currentAssets?: BalanceSheetSection + unclassifiedAssets?: BalanceSheetSection + totalAssets: number + equity?: BalanceSheetSection + nonCurrentLiabilities?: BalanceSheetSection + currentLiabilities?: BalanceSheetSection + unclassifiedLiabilities?: BalanceSheetSection + totalEquityAndLiabilities: number +} + +export interface GeneralLedgerRow { + entryDate: string + journalNo: string + accountCode: string + accountName: string + narration: string | null + debitAmount: number + creditAmount: number + runningBalance: number +} + +/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */ +export interface ProfitAndLossLine { + accountCode: string + accountName: string + amount: number +} + +export interface ProfitAndLossSection { + lines: ProfitAndLossLine[] + total: number +} + +/** + * Nested-sections shape (2026-07-22 rework, replaces the old flat `ProfitAndLossRow[]`). GL's own + * reference names the sections and the two top-level totals but does not spell out each line's + * exact field names — `ProfitAndLossLine` above is an **inferred** shape (matching every other + * report's `accountCode`/`accountName` convention), not a confirmed contract. `unclassified` is + * only present with lines if the Chart of Accounts has untagged Income/Expense accounts. + * + * Every section is optional — **confirmed live** (2026-07-31, via the identical bug on + * `CashFlowResponse`'s list fields below): GL's serializer omits a section from the JSON + * entirely when it has nothing to report for the period, rather than sending `{ lines: [], total: 0 }`. + * Every consumer must optional-chain (`report.sales?.lines`), never assume presence. + */ +export interface ProfitAndLossResponse { + sales?: ProfitAndLossSection + costOfSales?: ProfitAndLossSection + grossProfit: number + otherIncome?: ProfitAndLossSection + distributionExpenses?: ProfitAndLossSection + administrationExpenses?: ProfitAndLossSection + otherExpenses?: ProfitAndLossSection + financialExpenses?: ProfitAndLossSection + unclassified?: ProfitAndLossSection + netProfitForPeriod: number +} + +export interface CashFlowNonCashAdjustment { + description: string + amount: number +} + +/** `changeAmount`, not `amount` — confirmed field name (04_API_Reference_And_Scenarios.md, Module: Reporting). */ +export interface CashFlowWorkingCapitalChange { + accountCode: string + accountName: string + direction: "Increase" | "Decrease" + changeAmount: number +} + +/** One line inside an Investing/Financing section — GL's reference confirms `lines[]` exists but not + * this line's own field names; `{description, amount}` here matches every other report's line-item + * convention but is not verified verbatim. */ +export interface CashFlowActivityLine { + description: string + amount: number +} + +export interface CashFlowOperatingActivities { + profitForPeriod: number + nonCashAdjustments: CashFlowNonCashAdjustment[] + workingCapitalChanges: CashFlowWorkingCapitalChange[] + netCashFromOperatingActivities: number +} + +export interface CashFlowInvestingActivities { + lines: CashFlowActivityLine[] + netCashFromInvestingActivities: number +} + +export interface CashFlowFinancingActivities { + lines: CashFlowActivityLine[] + netCashFromFinancingActivities: number +} + +/** + * Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting — GL's own API reference, + * not inferred). **Everything nests under `operatingActivities`** — the previous version of this + * type had `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as + * flat top-level fields, which was wrong and caused a live runtime crash (`Cannot read properties + * of undefined (reading 'map')` on `nonCashAdjustments` — it was never at the top level to begin + * with). `investingActivities`/`financingActivities` each have their own differently-named total + * field (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`. + * `openingCashBalance`/`closingCashBalance` are returned for validation but deliberately not + * rendered on screen — GL's own PDF/CSV doesn't print them either. + */ +export interface CashFlowResponse { + periodStart: string + periodEnd: string + operatingActivities: CashFlowOperatingActivities + investingActivities: CashFlowInvestingActivities + financingActivities: CashFlowFinancingActivities + netIncreaseDecreaseInCash: number + openingCashBalance: number + closingCashBalance: number +} + +export interface BudgetVsActualRow { + budgetLineId: number + accountCode: string + accountName: string + periodId: number + budgetedAmount: number + actualAmount: number + variance: number +} + +/** + * Income Tax Computation (2026-07-22 redesign). Confirmed shape (04_API_Reference_And_Scenarios.md, + * Module: Reporting) — not inferred. The previous version of this type was missing + * `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and + * `quarterlyTaxPayments` entirely, which meant the Tax Report screen was silently dropping real + * GL-computed figures rather than a made-up guess just being wrong. Full row order, + * `profitBeforeTax` → `balanceTaxPayable`, with bold reconciliation checkpoints at + * `adjustedBusinessProfit`/`assessableIncome`/`taxableIncome`/`grossTaxLiability`/the final total. + */ +export interface TaxSummaryResponse { + periodStart: string + periodEnd: string + profitBeforeTax: number + nonDeductibleExpenses: number + allowableDeductions: number + adjustedBusinessProfit: number + otherTaxableIncome: number + assessableIncome: number + qualifyingPaymentsReliefs: number + taxableIncome: number + taxRatePercent: number + corporateIncomeTax: number + surchargeAmount: number + grossTaxLiability: number + apitCredit: number + whtCredit: number + quarterlyTaxPayments: number + balanceTaxPayable: number +} + +export interface TaxSummaryParams { + periodStart: string + periodEnd: string + allowableDeductions?: number + otherTaxableIncome?: number + qualifyingPaymentsReliefs?: number + surchargeAmount?: number + taxRateOverride?: number +} + +/** `outputFormat=Pdf`/`Csv` response shape — base64-encoded bytes inside the normal envelope either way. */ +export interface GlFilePayload { + fileName: string + contentType: string + contentBase64: string +} + +/** Cash and Bank are two separate GL tables/endpoints (2026-07-22 rework) — this discriminates the unified list row and the create-form toggle, not a database column on either side. */ +export enum CashBankAccountType { + Cash = "Cash", + Bank = "Bank", +} + +/** + * `GET /bank-accounts?accountType=Cash|Bank|Both` row shape (2026-07-22 rework) — GL's own reference + * documents this explicitly (`docs/21` §4), unlike the old single-table `BankAccount` shape it + * replaces, which was inferred. `bankName` is `null` on `Cash` rows, `cashAccountTypeName` is `null` + * on `Bank` rows. + */ +export interface CashAndBankAccountDto { + accountType: CashBankAccountType + accountId: number + accountName: string + bankName: string | null + cashAccountTypeName: string | null + accountNumber: string | null + glAccountId: number + currencyCode: string + createdAt: string +} + +/** + * Confirmed (04_API_Reference_And_Scenarios.md, Module: Bank, retrofit 2026-07-31) — + * `glAccountCode` was removed from this request entirely. The backing GL account is now always + * auto-created server-side (a root "Bank" header account is found-or-created, then a postable leaf + * named after `accountName` is created under it) — the caller never selects or supplies a GL account. + */ +export interface CreateBankAccountRequest { + accountName: string + bankName?: string | null + accountNumber?: string | null + currencyCode?: string +} + +/** + * Confirmed (retrofit 2026-07-31) — same `glAccountCode` removal as `CreateBankAccountRequest`, plus + * one more auto-created level: a root "Cash" header, then a per-`cashAccountTypeName` header (created + * once, reused thereafter), then a postable leaf named after `accountName`. + */ +export interface CreateCashAccountRequest { + accountName: string + cashAccountTypeName: string + accountNumber?: string | null + currencyCode?: string +} + +/** + * `POST /bank-accounts` / `POST /cash-accounts` response (retrofit 2026-07-31) — since the GL account + * is now auto-created rather than caller-supplied, the created leaf account (nested under its + * auto-created/reused header via `parentAccount`) is returned under `glAccount` so the caller can see + * exactly what was generated. `glAccount`/`glAccountId` are confirmed from the reference doc; the + * other fields are inferred (they mirror the create request's own fields plus an id, following this + * project's usual `` response convention). + */ +export interface CreateCashOrBankAccountResponse { + accountName: string + bankName?: string | null + cashAccountTypeName?: string | null + accountNumber?: string | null + currencyCode: string + glAccountId: number + glAccount: GlAccount & { parentAccount?: GlAccount | null } +} + +/** `GET /cash-account-types` row — flat reference list (seeded Petty Cash / Till Cash / Safe Cash / Cash in Transit, grows over time via on-the-fly creation from the create form). */ +export interface CashAccountType { + cashAccountTypeId: number + name: string +} + +/** Shared `{ items, totalCount, page, pageSize }` list envelope used by every Cheque Management list endpoint. */ +export interface GlPagedResult { + items: T[] + totalCount: number + page: number | null + pageSize: number | null +} + +// --------------------------------------------------------------------------- +// Cheque Management (new GL module, added 2026-07-30, beyond the original plan). +// Purely operational tracking — no endpoint here ever creates/touches a journal entry itself. +// `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are documented as deliberately +// "loose references" (plain unvalidated numbers) — no Branch/Company/Customer/Supplier table +// exists in this GL service for them to point at, so the frontend takes them as free-entry +// numbers rather than picker dropdowns, matching GL's own stated design. +// --------------------------------------------------------------------------- + +export enum PayeeType { + Supplier = "Supplier", + Customer = "Customer", + Employee = "Employee", + Other = "Other", +} + +export enum ReceivedFromType { + Customer = "Customer", + Supplier = "Supplier", + Other = "Other", +} + +export enum ChequeBookStatus { + Active = "Active", + Completed = "Completed", + Cancelled = "Cancelled", +} + +export enum ChequePageIssueStatus { + Unused = "Unused", + Issued = "Issued", + Cleared = "Cleared", + Bounced = "Bounced", + Cancelled = "Cancelled", + Void = "Void", +} + +/** `PUT /cheque-pages/{chequeNo}/status`'s `action` values. */ +export enum ChequePageStatusAction { + Clear = "Clear", + Bounce = "Bounce", + Cancel = "Cancel", + Void = "Void", +} + +export enum ReceivedChequeStatus { + Received = "Received", + Deposited = "Deposited", + Cleared = "Cleared", + Returned = "Returned", + Cancelled = "Cancelled", +} + +/** `PUT /received-cheques/{id}/status`'s `action` values. */ +export enum ReceivedChequeStatusAction { + Deposit = "Deposit", + Clear = "Clear", + Return = "Return", + Cancel = "Cancel", +} + +/** + * A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue` + * request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in + * prose — this type is built from those, not a guessed shape. `chequeNo` (not a numeric id) is + * the documented identifying value for `GET/PUT /cheque-pages/{chequeNo}`, so it's used as the + * key/URL param throughout rather than an unconfirmed `chequePageId`. + */ +export interface ChequePage { + chequeNo: string + chequeBookNo?: string + issueStatus: ChequePageIssueStatus + payeeType: PayeeType | null + payeeId: number | null + payeeName: string | null + issueDate: string | null + amount: number | null + currencyCode: string | null + voucherId: number | null + referenceNo: string | null + purpose: string | null + isCrossCheque: boolean | null + isAccountPayee: boolean | null + isPostDated: boolean | null + notes: string | null + printedBy: string | null + printedAt: string | null + clearedDate: string | null + clearedByBank: boolean | null + cancelReason: string | null +} + +/** + * A cheque book issued from this company's own supply. `chequeBookNo` (caller-supplied, unique) + * is the documented identifying value for `GET /cheque-books/{chequeBookNo}`, used as the + * key/URL param throughout. `pages[]` is only populated when fetched with `?expand=pages`. + */ +export interface ChequeBook { + chequeBookNo: string + branchId: number + bankAccountId: number + startChequeNo: string + endChequeNo: string + totalLeaves: number + receivedDate: string + description: string | null + createdBy: string | null + status: ChequeBookStatus + pages: ChequePage[] +} + +export interface CreateChequeBookRequest { + branchId: number + bankAccountId: number + chequeBookNo: string + startChequeNo: string + endChequeNo: string + totalLeaves: number + receivedDate: string + description?: string + createdBy?: string +} + +export interface IssueChequePageRequest { + payeeType: PayeeType + payeeId?: number + payeeName: string + issueDate: string + amount: number + currencyCode?: string + voucherId?: number + referenceNo?: string + purpose?: string + isCrossCheque?: boolean + isAccountPayee?: boolean + isPostDated?: boolean + notes?: string + printedBy?: string +} + +export interface UpdateChequePageStatusRequest { + action: ChequePageStatusAction + /** Required only for `action: "Clear"`. */ + clearedDate?: string + /** Required only for `action: "Cancel"`. */ + cancelReason?: string + performedBy?: string +} + +/** + * A cheque received from a customer/supplier/other party — deliberately has no link to a + * `ChequeBook` (it isn't one of this company's own). GL's reference uses a numeric `{id}` in the + * URL for `GET/PUT /received-cheques/{id}` without spelling out the JSON field's exact name — + * `receivedChequeId` follows this project's consistent `Id` convention (e.g. `assetId`, + * `disposalId`), not a confirmed literal. + */ +export interface ReceivedCheque { + receivedChequeId: number + companyId: number + branchId: number | null + receivedFromType: ReceivedFromType + receivedFromId: number | null + receivedFromName: string + drawerBankName: string | null + drawerBankBranch: string | null + accountHolderName: string | null + chequeNo: string + chequeDate: string + amount: number + receivedDate: string + referenceType: string | null + referenceId: number | null + notes: string | null + createdBy: string | null + status: ReceivedChequeStatus + depositBankAccountId: number | null + depositDate: string | null +} + +export interface CreateReceivedChequeRequest { + companyId: number + branchId?: number + receivedFromType: ReceivedFromType + receivedFromId?: number + receivedFromName: string + drawerBankName?: string + drawerBankBranch?: string + accountHolderName?: string + chequeNo: string + chequeDate: string + amount: number + receivedDate: string + referenceType?: string + referenceId?: number + notes?: string + createdBy?: string +} + +export interface UpdateReceivedChequeStatusRequest { + action: ReceivedChequeStatusAction + /** Required only for `action: "Deposit"`. */ + depositBankAccountId?: number + /** Required only for `action: "Deposit"`. */ + depositDate?: string + notes?: string + performedBy?: string +} diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts new file mode 100644 index 0000000..b973e39 --- /dev/null +++ b/Frontend/erp-system/types/sales.ts @@ -0,0 +1,314 @@ +import { EntityStatus } from "@/types/common" + +export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit" +export type SalesInvoiceStatus = "Draft" | "Posted" | "Cancelled" +export type SalesSlipStatus = "Draft" | "Posted" | "Cancelled" +export type SalesDiscountMode = "Percentage" | "Amount" + +export interface SalesInvoiceLine { + salesInvoiceLineId: number + itemId: number + description: string + qty: number + freeQty: number + uomId: number + warehouseId: number + unitPrice: number + baseCost: number + priceSource: string + discountPct: number + discountAmount: number + discountMode: SalesDiscountMode + netUnitPrice: number + lineTotal: number + taxPct: number + taxAmount: number + isFreeIssue: boolean + parentLineId: number | null +} + +export interface SalesInvoiceTotals { + subtotal: number + discountTotal: number + freeQtyTotal: number + taxTotal: number + grandTotal: number + roundOff: number + netPayable: number + paidAmount: number + balanceAmount: number +} + +export interface SalesInvoiceSummary { + salesInvoiceId: number + invoiceNo: string + invoiceDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + invoiceType: SalesInvoiceType + status: SalesInvoiceStatus + totals: SalesInvoiceTotals + createdAt: string +} + +export interface SalesInvoicePostingIssue { + salesInvoiceLineId: number + itemId: number + itemSku: string + itemName: string + warehouseId: number + requestedQty: number + availableQty: number + shortQty: number + isFreeIssue: boolean +} + +export interface SalesInvoicePostingCheck { + salesInvoiceId: number + invoiceNo: string + status: SalesInvoiceStatus + canPost: boolean + issues: SalesInvoicePostingIssue[] +} + +export interface SalesSlipPostingIssue { + salesSlipLineId: number + itemId: number + itemSku: string + itemName: string + warehouseId: number + requestedQty: number + availableQty: number + shortQty: number + isFreeIssue: boolean +} + +export interface SalesSlipPostingCheck { + salesSlipId: number + slipNo: string + status: SalesSlipStatus + canPost: boolean + issues: SalesSlipPostingIssue[] +} + +export interface SalesInvoice extends SalesInvoiceSummary { + customerSnapshotTaxNo: string | null + createdBy: number + updatedAt: string | null + lines: SalesInvoiceLine[] +} + +export interface CreateSalesInvoiceLineRequest { + itemId: number + uomId: number + warehouseId: number + qty: number + freeQty: number + unitPrice?: number | null + allowManualPriceOverride: boolean + discountMode: SalesDiscountMode + discountPct: number + discountAmount: number + discountValue: number + taxPct: number + isFreeIssue: boolean + parentLineId?: number | null +} + +export interface CreateSalesInvoiceRequest { + customerId: number + warehouseId: number + invoiceType: SalesInvoiceType + lines: CreateSalesInvoiceLineRequest[] +} + +export type UpdateSalesInvoiceRequest = CreateSalesInvoiceRequest + +export interface SalesSlipLine { + salesSlipLineId: number + itemId: number + description: string + qty: number + freeQty: number + uomId: number + warehouseId: number + unitPrice: number + baseCost: number + priceSource: string + discountPct: number + discountAmount: number + discountMode: SalesDiscountMode + netUnitPrice: number + lineTotal: number + taxPct: number + taxAmount: number + isFreeIssue: boolean + parentLineId: number | null +} + +export interface SalesSlipTotals { + subtotal: number + discountTotal: number + freeQtyTotal: number + taxTotal: number + grandTotal: number + paidAmount: number + balanceAmount: number +} + +export interface SalesSlipSummary { + salesSlipId: number + slipNo: string + slipDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + status: SalesSlipStatus + totals: SalesSlipTotals + createdAt: string +} + +export interface SalesSlip { + salesSlipId: number + slipNo: string + slipDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + cashierUserId: number + status: SalesSlipStatus + createdAt: string + updatedAt: string | null + totals: SalesSlipTotals + lines: SalesSlipLine[] +} + +export interface CreateSalesSlipLineRequest { + itemId: number + uomId: number + warehouseId: number + qty: number + freeQty: number + unitPrice?: number | null + allowManualPriceOverride: boolean + discountMode: SalesDiscountMode + discountPct: number + discountAmount: number + discountValue: number + taxPct: number + isFreeIssue: boolean + parentLineId?: number | null +} + +export interface CreateSalesSlipRequest { + customerId: number + warehouseId: number + cashierUserId: number + lines: CreateSalesSlipLineRequest[] +} + +export type UpdateSalesSlipRequest = CreateSalesSlipRequest + +export interface SalesReportDefinition { + id: string + name: string + description: string + supportedFilters: string[] +} + +export interface SalesReportQueryRequest { + reportType: string + from: string + to: string + itemId?: number | null + customerId?: number | null + warehouseId?: number | null +} + +export interface SalesReportQueryResponse { + reportType: string + from: string + to: string + rows: unknown[] +} + +export interface SalesListFilterParams { + page?: number + pageSize?: number + status?: SalesInvoiceStatus | SalesSlipStatus + customerId?: number + warehouseId?: number +} + +export interface FreeIssueSummaryItem extends SalesSlipSummary { + // Alias for the free issue route. Kept as a slip-shaped payload because the backend + // stores free issues as sales slips with free-issue line flags. + isFreeIssue?: boolean +} + +export interface FreeIssueSummary { + salesSlipId: number + slipNo: string + status: SalesSlipStatus + createdAt: string + warehouseId: number + warehouseName: string + itemId: number + itemSku: string + itemName: string + uomId: number + uomName: string + qty: number + freeQty: number + schemeLabel: string +} + +export interface FreeIssueDetail { + salesSlipId: number + slipNo: string + slipDate: string + status: SalesSlipStatus + customerId: number + customerSnapshotName: string + warehouseId: number + warehouseName: string + cashierUserId: number + createdAt: string + updatedAt: string | null + summary: FreeIssueSummary + lines: SalesSlipLine[] +} + +export interface SalesFreeIssueRewardOption { + itemId: number + sku: string + name: string + salePrice: number | null +} + +export interface SalesFreeIssueSuggestionLine { + salesSlipLineId: number + itemId: number + itemSku: string + itemName: string + qty: number + suggestedFreeQty: number + triggerQty: number + rewardOptions: SalesFreeIssueRewardOption[] +} + +export interface SalesFreeIssueSuggestion { + salesSlipId: number + slipNo: string + slipDate: string + lines: SalesFreeIssueSuggestionLine[] +} + +export interface SalesCustomer { + customerId: number + code: string + name: string + taxNo: string | null + status: EntityStatus +} diff --git a/docs/00-CORE.md b/docs/00-CORE.md index 1d2d258..0218b00 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -21,6 +21,7 @@ A modular ERP built in phases. **Phase 1** delivers the **Inventory & Supply Cha Full requirements live in the backend spec (see §7). This file does **not** duplicate them. **Phase 2 (HRM)** is now underway alongside Phase 1 — see §7 routing to `12-BACKEND-HRM.md` / `13-BACKEND-HRM-API.md` / `21-FRONTEND-HRM.md`. +Sales has its own API reference at `14-BACKEND-SALES-API.md`. --- @@ -45,7 +46,10 @@ erp-monorepo/ ├── 02-SECURITY.md # accepted-risks register + per-feature security checklist ├── 10-BACKEND-PHASE1.md # backend spec: SRS + ER/entities + tech + architecture ├── 11-BACKEND-PHASE1.md # backend API reference (complete req/res) - └── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture + ├── 14-BACKEND-SALES-API.md # sales API reference (invoices, slips, free issues, reports) + ├── 12-GENERAL-LEDGER-INTEGRATION.md # ERPCore ↔ external General Ledger service (transport only) + ├── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture + └── 21-GENERAL-LEDGER-FRONTEND.md # Ledgers section: reports UI + cash/bank accounts ``` The `Backend/ERPCore/` internal layout is created in §5.3. @@ -340,6 +344,9 @@ All frontend work is governed by `20-FRONTEND.md`. | API endpoints, request/response shapes, error catalog, enums (Phase 1) | **`11-BACKEND-PHASE1.md`** | | HRM requirements, business rules, entities, ER model (Phase 2) | **`12-BACKEND-HRM.md`** | | HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** | +| Connecting to the external General Ledger service (proxy, config, API key) | **`12-GENERAL-LEDGER-INTEGRATION.md`** | +| The Ledgers frontend section (reports, cash/bank accounts) | **`21-GENERAL-LEDGER-FRONTEND.md`** | +| Sales API endpoints, request/response shapes, error catalog, enums | **`14-BACKEND-SALES-API.md`** | | Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** | | HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** | | Manufacturing requirements, business rules, entities, stock/costing integration, API (Phase 2) | **`30-BACKEND-PHASE2.md`** | @@ -352,6 +359,7 @@ All frontend work is governed by `20-FRONTEND.md`. Quick resolver: - *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (Phase 1) / `12-BACKEND-HRM.md` (HRM) — schema is authoritative there. - *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md` (Phase 1) / `13-BACKEND-HRM-API.md` (HRM). +- *"What does the sales API accept/return?"* → `14-BACKEND-SALES-API.md`. - *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md` (Phase 1) / `21-FRONTEND-HRM.md` (HRM). - *"What security risks / checks apply to this feature?"* → `02-SECURITY.md`. diff --git a/docs/01-DOC-GUIDE.md b/docs/01-DOC-GUIDE.md index f353525..3545ee6 100644 --- a/docs/01-DOC-GUIDE.md +++ b/docs/01-DOC-GUIDE.md @@ -38,10 +38,13 @@ | `11-BACKEND-PHASE1.md` | High | Backend **API reference**: every endpoint with complete request/response, error catalog, enums. | Any API contract / controller / client work (Phase 1). | Claude + humans | | `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans | | `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans | +| `12-GENERAL-LEDGER-INTEGRATION.md` | High | ERPCore ↔ external **General Ledger service**: connection/proxy contract, config, progress. GL's own endpoint contract lives in the GL service's own repo, not here. | Any work touching the GL proxy or a future internal GL caller. | Claude + humans | +| `14-BACKEND-SALES-API.md` | High | Sales **API reference**: invoices, slips, free issues, reports, enums. | Any sales API contract / controller work. | Claude + humans | | `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans | | `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans | | `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans | | `21-FRONTEND-PHASE2.md` | High | Manufacturing frontend **user-flows**: template canvas builder, run board, run execution screens. | Any manufacturing frontend work. | Claude + humans | +| `21-GENERAL-LEDGER-FRONTEND.md` | High | **Ledgers** sidebar section: statutory-format report screens + PDF download, Cash/Bank Accounts (list/create; edit gap explained). Extends `20-FRONTEND.md` rather than duplicating it. | Any work on `app/dashboard/ledgers/*`. | Claude + humans | | `Backend/PROGRESS.md` | Low | Backend **change checklist**, git-shared. | After making backend changes. | **Claude** | | `Frontend/PROGRESS.md` | Low | Frontend **change checklist**, git-shared. | After making frontend changes. | **Claude** | @@ -195,7 +198,7 @@ Spec: docs/20-FRONTEND.md (flows + rules) · docs/11-BACKEND-PHASE1.md (API cont ## 7. Adding future docs When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting), follow the same scheme HRM (Phase 2) established: -- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`, mirroring the `10`/`11` SRS+ER / API-reference split), linked from the hub. +- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`; sales now uses `14-BACKEND-SALES-API.md` alongside the existing phase docs), linked from the hub. - Frontend additions → extend `20-FRONTEND.md` or add `2x-` files (e.g. HRM used `21-FRONTEND-HRM.md`). - Always register the new doc in `00-CORE.md` routing (§7) and in this index (§2). diff --git a/docs/12-GENERAL-LEDGER-INTEGRATION.md b/docs/12-GENERAL-LEDGER-INTEGRATION.md new file mode 100644 index 0000000..a827537 --- /dev/null +++ b/docs/12-GENERAL-LEDGER-INTEGRATION.md @@ -0,0 +1,156 @@ +# 12 · GENERAL LEDGER INTEGRATION — ERPCore ↔ External GL Service + +> **Navigation:** you arrived from `00-CORE.md`. This doc is the **connection layer only** — the transport between ERPCore and the external General Ledger service. It is not the GL service's own API contract: every endpoint, request/response shape, and error code the GL service exposes is documented in that service's own repo (`04_API_Reference_And_Scenarios.md`, its `01_Full_Development_Plan.md`/`02_Phase_By_Phase_Development_Plan.md`/`03_Progress_Tracker.md` companions, and its Postman collection) — ERPCore does not duplicate that content here (`01-DOC-GUIDE.md` §5, single source of truth per topic). +> **Record work:** `Backend/PROGRESS.md`. + +--- + +## 1. What this is + +The General Ledger service is a **separate ASP.NET Core microservice** — its own repo, own database, no UI, no end-user login. It is called server-to-server, authenticated with a single shared secret (`X-Api-Key`), by "the core backend" — which, from this repo's side, is ERPCore. + +This pass connects the two systems at the **transport level only**: + +- A generic reverse-proxy endpoint on ERPCore (`/api/v1/gl/*`) that the frontend can call, which forwards the request to the GL service and returns its response unchanged. +- A service-layer function (`IGeneralLedgerService.ForwardAsync`) that does the actual forwarding — usable today by the controller, and **intended to be called directly by other ERPCore services later** (GRN confirm, adjustments, etc. posting real journal entries into GL instead of — or alongside — the local `JournalEntryStub`). That internal wiring is **explicitly deferred**; nothing in ERPCore calls GL yet except the proxy itself. + +There is **no GL-specific business logic, no typed DTOs, and no per-endpoint validation** in ERPCore for this integration. Every request under `/api/v1/gl/*` is forwarded byte-for-byte — method, path, query string, request body, and `Content-Type` — and GL's response (status code, content type, body) comes back exactly as GL sent it, un-reshaped. + +--- + +## 2. Architecture + +``` +Frontend ──► ERPCore /api/v1/gl/{**path} (ErpAccess door policy, same as every other v1 endpoint) + │ + ▼ + GeneralLedgerController (Controllers/GeneralLedgerController.cs) + │ Request.Method, path, QueryString, ContentType, Body — untouched + ▼ + IGeneralLedgerService.ForwardAsync (Services/GeneralLedgerService.cs) + │ the one function — controller and future internal callers both use this + ▼ + IGeneralLedgerClient.SendAsync (Infra/Gl/GeneralLedgerClient.cs) + │ attaches X-Api-Key, streams body through, no parsing + ▼ + External GL service (GeneralLedgerService:BaseUrl) +``` + +**Why a generic proxy and not typed endpoints:** the GL service's own reference (`04_API_Reference_And_Scenarios.md`) documents ~15 modules and 40+ endpoint variations (Accounts, Fiscal Calendar, Currency, Cost Centers, Journal Entries, Recurring Entries, Tax, Fixed Assets, Bank, Budget, Reporting, System Config, Audit Log). Modelling all of that as ERPCore DTOs before any of it is actually consumed would be premature — this pass connects the pipe; the next pass (deferred, see §6) picks specific GL calls to wire into specific ERPCore workflows and can build typed request/response DTOs for exactly those calls at that point. + +**Layering matches the existing AuthHex integration** (`Infra/Auth/AuthHex/IAuthHexClient` → `Services/Auth/*` → `AuthController`), except AuthHex's client unwraps a known envelope into typed results, while GL's client deliberately does not — see §4. + +--- + +## 3. Configuration + +`Backend/ERPCore/appsettings.json`: + +```json +"GeneralLedgerService": { + "BaseUrl": "https://localhost:7024/api/v1/", + "ApiKey": "" +} +``` + +| Key | Purpose | +|---|---| +| `GeneralLedgerService:BaseUrl` | The GL service's base URL, **including** `/api/v1/` and a trailing slash (so relative paths combine correctly against `HttpClient.BaseAddress`). GL's own docs note it calls `UseHttpsRedirection()` before its API-key check runs — use the `https://` URL directly, per GL's own integration guide §3, to avoid every call taking a `307` round-trip. | +| `GeneralLedgerService:ApiKey` | The shared secret sent as `X-Api-Key` on every forwarded call. **Ships blank** — same pattern as this project's own `ApiKey`-style secrets (02-SECURITY AR-06): get a value from the GL service's own `POST /api/v1/dev-tools/generate-api-key` (Development only) and paste it in here *and* into the GL service's own `ApiKey:Value` config — it is one shared string on both sides, not a per-caller credential. | + +Not touched by this pass: `appsettings.Development.json` / `appsettings.Production.json` — fill in a real key locally the same way `AuthHex:BaseUrl` and other per-environment values are handled today (edit the file directly for local dev; environment-variable override for prod, per `00-CORE.md §5.4`'s secrets note). + +--- + +## 4. The proxy contract + +### `{GET|POST|PUT} /api/v1/gl/{**path}` + +- **Auth:** requires a valid ERPCore session (AuthHex-issued token, `erp_at` cookie or Bearer header) satisfying the `ErpAccess` door policy — identical to every other `/api/v1/*` endpoint (`ApiControllerBase`). The GL `X-Api-Key` itself is attached **server-side only**; the frontend never sees it and cannot set it. +- **Method:** only `GET`/`POST`/`PUT` are exposed — the GL service's own API reference has no `PATCH` or `DELETE` endpoint anywhere in its surface, so those verbs aren't wired. +- **`{**path}`:** everything after `/api/v1/gl/` is forwarded as-is to the GL service's own `/api/v1/{path}` — e.g. a frontend call to `POST /api/v1/gl/journal-entries` reaches GL's `POST /api/v1/journal-entries`. Query string is forwarded unchanged. +- **Body:** for `POST`/`PUT`, the raw request body (and its `Content-Type` header) is streamed straight through — this is what makes the one `multipart/form-data` endpoint (bank statement import) work through the proxy without ERPCore needing to understand multipart at all. +- **Response:** GL's status code, `Content-Type`, and body are returned to the frontend **unchanged**. This deliberately preserves two documented GL quirks rather than papering over them: + - GL's success bodies are camelCase (`{"statusCode":200,...}`), its error bodies (from its own exception/API-key middleware) are PascalCase (`{"StatusCode":400,...}`) — GL's own docs flag this as a known inconsistency, not fixed on their side yet. ERPCore does not normalize it; the frontend must handle both shapes (case-insensitive deserialization), same as GL's own integration guide recommends to *its* callers. + - `outputFormat=Json` report amounts keep full decimal precision (no rounding) — since ERPCore never deserializes the body into its own model, nothing here can accidentally truncate it. +- **This proxy needs zero code changes whenever GL's own domain contract evolves — confirmed again as of GL's 2026-07-22 revision (§7).** Five reports were restructured, `outputFormat` gained a `Csv` value, Cash/Bank accounts split into two endpoints, and numerous fields were renamed from numeric IDs to business codes (`CLAUDE.md` Rule 8.2 on GL's side) — none of it touched this file. That's the entire point of a byte-for-byte proxy over typed per-endpoint DTOs (§2's rationale): a generic passthrough only ever needs a code change when the *transport* contract changes (new HTTP verb, new content type, new auth mechanism), never when GL's own request/response payloads gain, lose, or rename fields. §7 is a curated reference of what's actually being called today, kept for the frontend team's convenience — it is documentation, not something this proxy depends on or needs updated in lockstep with. + +### Failure modes added by ERPCore itself + +| Case | Status | `code` | +|---|---|---| +| GL service unreachable (connection refused/DNS/etc.) | 503 | `GL_SERVICE_UNAVAILABLE` | +| GL service timed out | 503 | `GL_SERVICE_UNAVAILABLE` | +| No/invalid ERPCore session | 401/403 | (standard ERPCore door-policy response — request never reaches the proxy) | + +Everything else — `401` for a bad `X-Api-Key` (can't happen here since ERPCore always attaches the configured key, but a misconfigured/blank key on either side would surface as this), every domain `400`/`404`/`409`/`422` GL itself returns — passes straight through with GL's own body and message, untouched. + +--- + +## 5. What is *not* done in this pass + +- **No internal service calls into GL.** `IGeneralLedgerService.ForwardAsync` exists and is registered in DI, but no ERPCore service (GRN, Adjustment, Transfer, PO, etc.) calls it yet. The local `JournalEntryStub` (docs/10 §C.7, FR-STK-13) is untouched and remains the only GL-adjacent artifact those flows produce today. +- **No typed request/response DTOs** for any GL endpoint — the proxy is byte-for-byte, so nothing in ERPCore currently understands GL's schema. +- **No path allowlist.** The proxy forwards *any* path under `/api/v1/gl/` to GL — it trusts GL's own routing/auth to reject anything invalid. Acceptable for now because the proxy is still gated by ERPCore's own door policy (only ERP-admitted users reach it at all) and GL requires its own valid `X-Api-Key` regardless, but worth revisiting alongside per-endpoint RBAC (02-SECURITY Part D) if GL exposes anything sufficiently destructive to warrant narrowing. +- **No `appsettings.Development.json`/`appsettings.Production.json` changes** — only the base `appsettings.json` shape was added, per this pass's scope (connect the pipe, not configure every environment). +- **No CORS/rate-limiting review specific to this proxy** — it inherits whatever ERPCore already has (currently none, same accepted gap as `AuthController`, 02-SECURITY AR-08). + +--- + +## 6. Progress + +- [x] **Transport wired (2026-07-20).** `Infra/Gl/{IGeneralLedgerClient,GeneralLedgerClient,GeneralLedgerResponse}` — typed `HttpClient` (`GeneralLedgerService:BaseUrl`), attaches `X-Api-Key` (`GeneralLedgerService:ApiKey`), streams request/response bodies through unparsed. `Services/{Interfaces/IGeneralLedgerService,GeneralLedgerService}` — the shared forwarding function. `Controllers/GeneralLedgerController` — `GET|POST|PUT /api/v1/gl/{**path}`, `ErpAccess`-gated like every other v1 controller. Config keys added to `appsettings.json`. Build verified clean (`dotnet build`, 0 warnings/0 errors). +- [ ] **Live smoke test against a running GL instance** — not yet run in this pass (no GL instance available in this session). Before relying on this in anger: generate a dev API key from GL's `POST /api/v1/dev-tools/generate-api-key`, put it in both sides' config, and round-trip at least one call per HTTP verb (e.g. `GET /api/v1/gl/accounts`, `POST /api/v1/gl/accounts`, `PUT /api/v1/gl/accounts/{code}`) plus the multipart import call, to confirm the byte-for-byte passthrough holds in practice (headers, streaming, status codes). +- [ ] **Internal wiring** — pick the first real ERPCore→GL use case (most likely candidate: posting `FifoCostingService`/`StockMutator`-driven movements as real GL journal entries instead of `JournalEntryStub` rows) and build the typed request/response DTOs + service calls for that specific flow through `IGeneralLedgerService`. Deliberately deferred per this pass's scope. +- [ ] **Security pass once real traffic flows** — revisit the "no path allowlist" note in §5, decide whether `GeneralLedgerService:ApiKey` needs to move to a secret store before any shared/staging use (same trigger as `02-SECURITY.md` AR-06), and whether this proxy needs its own accepted-risk entry there. + +--- + +## 7. Endpoint reference — GL endpoints currently used (or planned) by the frontend + +**Purpose of this section:** a quick-reference summary of exactly which GL endpoints the frontend calls, for connecting new screens without re-reading GL's full `04_API_Reference_And_Scenarios.md` from scratch each time. **This is a curated summary, not the canonical contract** — for exact request/response shapes, every field, and every error case, GL's own reference stays authoritative (`01-DOC-GUIDE.md §5`). Every call below goes through this proxy at `/api/v1/gl/{path}` (drop the leading `/api/v1` from GL's own documented paths, prefix `/api/v1/gl` instead — e.g. GL's `GET /api/v1/reports` is called here as `GET /api/v1/gl/reports`). + +As of GL's 2026-07-22 revision. Full detail for anything marked "see GL §X" is in GL's `04_API_Reference_And_Scenarios.md` at that section. + +### Reporting (`docs/21` §3 — all seven Ledgers report screens) + +| Method | Path | Used for | Key params | +|---|---|---|---| +| `GET` | `/reports` | All seven report screens, one shared endpoint | `reportType` (`GeneralLedger`\|`TrialBalance`\|`BalanceSheet`\|`ProfitAndLoss`\|`CashFlow`\|`BudgetVsActual`\|`TaxSummary`), `outputFormat` (`Json`\|`Pdf`\|`Csv`), plus report-specific params — see GL §"GET /reports" for the full per-`reportType` table | + +One endpoint, one row — every report screen and both download buttons (`DownloadPdfButton`/`DownloadCsvButton`) call this same path with different query parameters. Nothing else in this module hits a second reporting endpoint. + +### Accounts (`docs/21` §3/§4 — GL-account pickers only, never a screen of its own here) + +| Method | Path | Used for | Key params | +|---|---|---|---| +| `GET` | `/accounts` | Populates the `glAccountCode` picker on the Bank/Cash create forms | none required — full list | + +This module never creates, edits, or has a dedicated screen for GL's own Chart of Accounts — `GET /accounts` is called purely to feed a `code — name` dropdown elsewhere. + +### Bank & Cash Accounts (`docs/21` §4) + +| Method | Path | Used for | Key params | +|---|---|---|---| +| `GET` | `/bank-accounts` | Unified Cash+Bank list page | `accountType` (optional — `Cash`\|`Bank`\|`Both`, default `Both`) | +| `POST` | `/bank-accounts` | Create form, "Bank" selected | `accountName`, `bankName`/`accountNumber` (optional), `glAccountCode`, `currencyCode` (optional) | +| `POST` | `/cash-accounts` | Create form, "Cash" selected | `accountName`, `cashAccountTypeName`, `accountNumber` (optional, auto-generated if omitted), `glAccountCode`, `currencyCode` (optional) | +| `GET` | `/cash-account-types` | Populates the Cash Account Type picker on the create form | none — full list | +| `POST` | `/bank-accounts/{id}/statement-lines/import` | Not yet built on the frontend — bank statement `.xlsx` import, `multipart/form-data` | field name `file` | +| `PUT` | `/bank-statement-lines/{id}/reconcile` | Not yet built on the frontend — reconcile a statement line | `matchedJournalNo` | + +No `GET`/`PUT` by id exists on GL's side for either `bank_account` or `cash_account` — this is the entire reason edit can't be built yet (`docs/21` §4's known gap). + +### Budgets (`docs/21` §3 — picker only) + +| Method | Path | Used for | Key params | +|---|---|---|---| +| `GET` | `/budgets` | Populates the `budgetId` picker on the Budget vs Actual report screen | none — full list | + +### Not currently called by this frontend, listed for awareness + +GL exposes a substantially larger surface than this module touches — Journal Entries, Fiscal Calendar, Cost Centers, Currency, Tax Codes, Fixed Assets, Recurring Templates, System Config, Audit Log. None of these have a screen in `docs/21` today. If a future pass adds one, add its row to the relevant table above rather than leaving this reference to drift out of sync with what's actually called — that's the whole value of keeping this section current. + +--- + +*End of 12-GENERAL-LEDGER-INTEGRATION.md. Hub: `00-CORE.md`. Record work: `Backend/PROGRESS.md`.* diff --git a/docs/14-BACKEND-SALES-API.md b/docs/14-BACKEND-SALES-API.md new file mode 100644 index 0000000..a6a1b3b --- /dev/null +++ b/docs/14-BACKEND-SALES-API.md @@ -0,0 +1,278 @@ +# 14 · BACKEND — Sales API Reference + +> **Authoritative for:** sales API contracts for invoices, slips, bundle sales, free issues, and sales reports. +> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`. +> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table. + +--- + +## 1. Conventions + +- Base URL: `https://{host}/api/v1` +- Authentication: same as the rest of the v1 API +- Concurrency: `ETag` / `If-Match` on mutable resources +- Validation: `ProblemDetails` / `ValidationProblemDetails` + +--- + +## 2. Sales Invoices + +### `GET /api/v1/sales-invoices` +Query: +- `page` +- `pageSize` +- `q` +- `status` +- `customerId` +- `warehouseId` + +Returns a paged list of `SalesInvoiceSummaryDto`. +The current frontend register uses this endpoint directly for the invoice hub view. + +### `GET /api/v1/sales-invoices/{salesInvoiceId}` +Returns `SalesInvoiceDto`. +The detail page shows the document template and only exposes edit actions when the invoice is `Draft`. + +### `POST /api/v1/sales-invoices` +Creates a draft sales invoice. + +Request: +```json +{ + "customerId": 2, + "warehouseId": 1, + "invoiceType": "B2C", + "lines": [ + { + "itemId": 1, + "uomId": 1, + "warehouseId": 1, + "qty": 1, + "freeQty": 0, + "unitPrice": 100, + "allowManualPriceOverride": false, + "discountMode": "Percentage", + "discountPct": 0, + "discountAmount": 0, + "discountValue": 0, + "taxPct": 0, + "isFreeIssue": false, + "parentLineId": null + } + ] +} +``` + +### `PUT /api/v1/sales-invoices/{salesInvoiceId}` +Updates a draft invoice. Requires `If-Match`. + +### `POST /api/v1/sales-invoices/{salesInvoiceId}/post` +Posts stock out and marks the invoice as `Posted`. +Before posting, the UI calls `GET /api/v1/sales-invoices/{salesInvoiceId}/posting-check` to show shortages. + +### `POST /api/v1/sales-invoices/{salesInvoiceId}/cancel` +Cancels a draft invoice. + +--- + +## 3. Sales Slips + +### `GET /api/v1/sales-slips` +Query: +- `page` +- `pageSize` +- `q` +- `status` +- `customerId` +- `warehouseId` + +Returns a paged list of `SalesSlipSummaryDto`. + +### `GET /api/v1/sales-slips/{salesSlipId}` +Returns `SalesSlipDto`. +The detail page follows the same document-style layout and draft-only edit behavior as invoices. + +### `POST /api/v1/sales-slips` +Creates a draft sales slip. + +Request: +```json +{ + "customerId": 2, + "warehouseId": 1, + "cashierUserId": 1, + "lines": [ + { + "itemId": 1, + "uomId": 1, + "warehouseId": 1, + "qty": 1, + "freeQty": 0, + "unitPrice": 50, + "allowManualPriceOverride": false, + "discountMode": "Percentage", + "discountPct": 0, + "discountAmount": 0, + "discountValue": 0, + "taxPct": 0, + "isFreeIssue": false, + "parentLineId": null + } + ] +} +``` + +### `PUT /api/v1/sales-slips/{salesSlipId}` +Updates a draft slip. Requires `If-Match`. + +### `POST /api/v1/sales-slips/{salesSlipId}/post` +Posts stock out and marks the slip as `Posted`. +Before posting, the UI calls `GET /api/v1/sales-slips/{salesSlipId}/posting-check`. + +### `POST /api/v1/sales-slips/{salesSlipId}/cancel` +Cancels a draft slip. + +--- + +## 4. Bundle Sales + +Bundle sales are a separate sales document family for fixed bundle compositions. + +### `GET /api/v1/bundle-sales` +Query: +- `page` +- `pageSize` +- `q` +- `customerId` +- `warehouseId` + +Returns a paged list of `BundleSaleSummaryDto`. + +### `GET /api/v1/bundle-sales/{bundleSaleId}` +Returns `BundleSaleDto`. + +### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check` +Validates component stock before posting. + +### `POST /api/v1/bundle-sales` +Creates a draft bundle sale from a fixed template. + +Request: +```json +{ + "customerId": 2, + "warehouseId": 1, + "cashierUserId": 1, + "bundleSaleTemplateId": 1, + "bundleName": "Summer Promo Pack", + "bundleCode": "BND-001", + "bundlePrice": 2500, + "allowPriceOverride": true +} +``` + +### `PUT /api/v1/bundle-sales/{bundleSaleId}` +Updates a draft bundle sale. Requires `If-Match`. + +### `POST /api/v1/bundle-sales/{bundleSaleId}/post` +Posts the bundle and consumes stock from the included component lines. + +### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel` +Cancels a draft bundle sale. + +Business rule: +- bundle composition is fixed by template lines +- posting consumes component stock, not a synthetic bundle stock item +- print pages should show both the bundle summary and the component breakdown + +--- + +## 5. Free Issues + +Free issue is a business alias over sales slips. +There is no separate free-issue table in the current schema. + +### `GET /api/v1/free-issues` +Same contract as `GET /api/v1/sales-slips`. + +### `GET /api/v1/free-issues/{freeIssueId}` +Same contract as `GET /api/v1/sales-slips/{salesSlipId}`. + +### `POST /api/v1/free-issues` +Same contract as `POST /api/v1/sales-slips`. + +### `PUT /api/v1/free-issues/{freeIssueId}` +Same contract as `PUT /api/v1/sales-slips/{salesSlipId}`. + +### `POST /api/v1/free-issues/{freeIssueId}/post` +Same contract as `POST /api/v1/sales-slips/{salesSlipId}/post`. + +### `POST /api/v1/free-issues/{freeIssueId}/cancel` +Same contract as `POST /api/v1/sales-slips/{salesSlipId}/cancel`. + +Business rule: +- free issue is represented by `IsFreeIssue=true` and/or `FreeQty>0` +- it still posts stock out through the normal slip posting flow +- the frontend free-issue screen is the main working page for create/list/edit/cancel +- cancelled free-issue records are hidden from the working list view + +--- + +## 6. Sales Reports + +### `GET /api/v1/reports/sales` +Returns the report catalog. + +Response: +```json +[ + { + "id": "daily-summary", + "name": "Daily Summary", + "description": "Aggregated sales by day across posted invoices and slips.", + "supportedFilters": ["from", "to"] + } +] +``` + +### `GET /api/v1/reports/sales/{reportId}` +Returns a single report definition by id. + +### `POST /api/v1/reports/sales/query` +Returns the actual report data. + +Request: +```json +{ + "reportType": "item-summary", + "from": "2026-07-01", + "to": "2026-07-28", + "itemId": 1, + "customerId": null, + "warehouseId": 1 +} +``` + +Supported report types: +- `daily-summary` +- `item-summary` +- `customer-summary` +- `warehouse-summary` +- `discount-summary` +- `free-issue-summary` + +Validation rule: +- invalid filters for a given report type are rejected +- unsupported report types are rejected +- the frontend report page uses the same endpoint for all report types +- `daily-summary` auto-loads with a default date range in the UI +- the report results table is rendered from the backend response rows, not hardcoded data + +--- + +## 7. Notes + +- The sales report service reads both invoices and slips where relevant. +- Free issue reporting is derived from the same sales document lines. +- Bundle sales are treated as a separate fixed-composition document family. +- There is no separate free-issue table in the current schema. diff --git a/docs/15-BACKEND-SALES-BUNDLES.md b/docs/15-BACKEND-SALES-BUNDLES.md new file mode 100644 index 0000000..40da017 --- /dev/null +++ b/docs/15-BACKEND-SALES-BUNDLES.md @@ -0,0 +1,136 @@ +# 15 · BACKEND — Bundle Sales API + +> **Authoritative for:** fixed-composition bundle sales, templates, posting, and print data. +> **Navigation:** start from `00-CORE.md`. This module follows the same repository/UoW/ETag/audit patterns as invoices and slips. + +--- + +## 1. Concept + +Bundle sales are a separate sales document family for fixed bundle compositions. + +Rules: +- a bundle sale is created from a bundle template +- the bundle template defines fixed component stock lines +- posting consumes stock from the component items, not from a synthetic bundle SKU +- the bundle header carries the commercial sale value +- print views show both bundle summary and component breakdown + +--- + +## 2. API + +### `GET /api/v1/bundle-sales` +Query: +- `page` +- `pageSize` +- `q` +- `customerId` +- `warehouseId` + +### `GET /api/v1/bundle-sales/{bundleSaleId}` +Returns the bundle sale header and all component lines. + +### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check` +Validates component stock before posting. + +### `POST /api/v1/bundle-sales` +Creates a draft bundle sale from a fixed bundle template. + +### `PUT /api/v1/bundle-sales/{bundleSaleId}` +Updates a draft bundle sale. Requires `If-Match`. + +### `POST /api/v1/bundle-sales/{bundleSaleId}/post` +Consumes stock from included component lines and marks the bundle as posted. + +### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel` +Cancels a draft bundle sale. + +--- + +## 3. Template Rules + +- bundle templates are fixed in composition +- each template line maps to one component stock item +- component quantities are expanded into the sale draft at creation time +- price override is allowed only when the caller is permitted by business rules + +--- + +## 4. Data Model + +### `BundleSaleTemplate` +- `BundleSaleTemplateId` +- `TemplateCode` +- `TemplateName` +- `Description` +- `Status` +- `CreatedAt` +- `UpdatedAt` +- `RowVersion` + +### `BundleSaleTemplateLine` +- `BundleSaleTemplateLineId` +- `BundleSaleTemplateId` +- `ItemId` +- `UomId` +- `WarehouseId` +- `Qty` +- `UnitPrice` +- `IncludeInBundle` +- `SortOrder` + +### `BundleSale` +- `BundleSaleId` +- `BundleNo` +- `BundleDate` +- `CustomerId` +- `CustomerSnapshotName` +- `WarehouseId` +- `CashierUserId` +- `BundleSaleTemplateId` +- `BundleName` +- `BundleCode` +- `Status` +- `ComponentSubtotal` +- `BundlePrice` +- `MarginAmount` +- `DiscountTotal` +- `TaxTotal` +- `GrandTotal` +- `CreatedAt` +- `UpdatedAt` +- `RowVersion` + +### `BundleSaleLine` +- `BundleSaleLineId` +- `BundleSaleId` +- `ItemId` +- `Description` +- `Qty` +- `UomId` +- `WarehouseId` +- `UnitPrice` +- `LineTotal` +- `IncludeInBundle` +- `IsComponent` +- `ParentLineId` +- `RowVersion` + +--- + +## 5. Posting + +Posting behavior: +- validate each included component line has sufficient stock +- consume FIFO layers from the component items +- write stock ledger rows for each component +- mark the bundle as posted in the same transaction + +--- + +## 6. Notes + +- This module is intentionally separate from invoices and slips. +- Bundle sales are for fixed compositions only in this phase. +- Print views should mirror the existing sales document print behavior without the dashboard shell. diff --git a/docs/21-GENERAL-LEDGER-FRONTEND.md b/docs/21-GENERAL-LEDGER-FRONTEND.md new file mode 100644 index 0000000..c89cdbb --- /dev/null +++ b/docs/21-GENERAL-LEDGER-FRONTEND.md @@ -0,0 +1,532 @@ +# 21 · GENERAL LEDGER — Frontend (Ledgers section) + +> **Navigation:** you arrived from `00-CORE.md`. The transport this section calls through is +> `docs/12-GENERAL-LEDGER-INTEGRATION.md` (ERPCore's `/api/v1/gl/*` proxy into the external General +> Ledger service — that doc's new §7 has a curated endpoint reference for everything this page uses). +> General frontend architecture/validation rules are `20-FRONTEND.md` — this doc only adds what's +> specific to the Ledgers screens. Record work in `Frontend/PROGRESS.md`. +> **GL's own endpoint contract** (exact request/response shapes, error cases) lives in the General +> Ledger service's own repo (`04_API_Reference_And_Scenarios.md`) — not duplicated here in full, per +> `01-DOC-GUIDE.md §5`'s single-source-of-truth rule; `docs/12` §7 is a summary for quick reference, +> that doc is the canonical detail. + +--- + +## 0. Major update, 2026-07-22 — the GL backend changed substantially since this section was last built + +**Nothing in this pass has been implemented against the frontend codebase yet.** The 2026-07-20 build (§6's history) reflects an earlier, now-superseded GL contract. This revision describes the *target* design against the confirmed, live-verified-on-GL's-side contract as of 2026-07-22 — every section below should be read as "what to build," not "what exists," until §6 says otherwise. + +Three things changed on GL's side, in order of how much they affect this doc: + +1. **Five reports got real structural rework** (not just field renames) — Trial Balance flattened, Profit & Loss restructured into named sections, Cash Flow's display simplified, Tax Report **completely redesigned** into an Income Tax Computation (previously a VAT/WHT/NBT summary), plus a new `outputFormat=Csv` on all seven reports. +2. **Cash and Bank accounts are two separate GL tables/endpoints now**, not one table with a type field — `POST /bank-accounts` (unchanged) and a new `POST /cash-accounts`, unified for reading via `GET /bank-accounts?accountType=Cash|Bank|Both`. +3. **`CLAUDE.md` Rule 8.2 renamed numeric ID fields to business codes across most of GL's API** — the one that reaches this page: `GeneralLedger`'s `accountId` param is now `accountCode` (still optional, same two-mode behavior as before), and the Bank/Cash create forms' `glAccountId` is now `glAccountCode`. + +--- + +## 1. What this is + +A new **Ledgers** sidebar section (`app/dashboard/ledgers/*`) giving the frontend statutory-format +financial reports and cash/bank-account management, sourced entirely from the external General Ledger +service via ERPCore's generic proxy (`docs/12-GENERAL-LEDGER-INTEGRATION.md`). Every screen calls +`GET /api/v1/gl/reports` or `/gl/bank-accounts`/`/gl/cash-accounts` — there is no ERPCore business +logic behind any of it yet (that internal wiring, e.g. posting real journal entries from +GRN/adjustments, is tracked separately and deliberately deferred, see `docs/12` §6). + +**Sidebar structure** (`components/Layouts/AppSidebar.tsx`, mirrored server-side in +`Backend/ERPCore/Infra/Persistence/Configurations/{NavItem,SubNavItem,Permission}Configuration.cs` +per the existing RBAC-nav convention, docs/10 C.8/C.9) — **gains one entry, Tax Report:** + +``` +Ledgers +├── Trial Balance /dashboard/ledgers/trial-balance +├── Balance Sheet /dashboard/ledgers/balance-sheet +├── General Ledger /dashboard/ledgers/general-ledger +├── Profit & Loss /dashboard/ledgers/profit-and-loss +├── Cash Flow /dashboard/ledgers/cash-flow +├── Budget vs Actual /dashboard/ledgers/budget-vs-actual +├── Tax Report /dashboard/ledgers/tax-report (new) +└── Cash / Bank Accounts /dashboard/ledgers/bank-accounts (+ /new) +``` + +`/dashboard/ledgers` itself is a card-grid hub, same pattern as `/dashboard/stock` — gains an 8th card. + +--- + +## 2. Transport: a dedicated GL client, not `lib/api-client.ts` + +`lib/api/general-ledger.ts` is a **separate** fetch client from the one every other screen in this +app uses (`lib/api-client.ts`'s `apiRequest`/`apiRequestWithETag`). Reason: those assume ERPCore's +own RFC 7807 `ProblemDetails` error shape and a bare-DTO success body. The GL service wraps **every** +response — success and error alike — in its own `{ statusCode, success, message, data }` envelope, +and (a documented GL quirk, carried through the proxy unchanged per `docs/12` §4) success bodies are +camelCase while error bodies are PascalCase. `glRequest()` unwraps both casings itself and throws a +`GlApiError` shaped `{ status, detail }` — duck-type compatible with `lib/error-map.ts`'s +`ApiErrorLike`, so `errorMessage()`/toasts work unchanged across both API surfaces. + +All calls are relative to `/api/v1/gl/*` — same-origin through the existing Next `rewrites()` proxy +(`next.config.ts`) → ERPCore → the GL service. No new proxy config was needed, and **still isn't** for +any of this revision's changes — ERPCore's `GeneralLedgerController` (docs/12) is a byte-for-byte +passthrough that never inspects GL's own field names or report contents, so every rename/restructure +described in this doc requires zero ERPCore-side change, only frontend-side (`docs/12` §4's new note +on this). + +**No ETag/If-Match anywhere in this module** — the GL service's own reference documents no +concurrency tokens on any of the endpoints this UI calls. + +--- + +## 3. Reports: "Sri Lankan Standard" report UI + +Every report screen (`app/dashboard/ledgers/{trial-balance,balance-sheet,general-ledger,profit-and-loss,cash-flow,budget-vs-actual,tax-report}/page.tsx`) +shares three building blocks: + +- **`components/reports/ReportHeader.tsx`** — a centered statutory header block: small-caps + "General Ledger" eyebrow, the report's **LKAS-aligned statement name** (not always GL's own + `reportType` value — see the mapping below), the as-at/for-period line, and a currency note. This + is the "Sri Lankan Standard report UI" requested: the on-screen equivalent of the formal + company-financial-statement layout (title block, period line, right-aligned money columns, + bold/indented subtotal rows), not a bespoke ad-hoc table per screen. **The Tax Report screen does + not use this component** — see its own entry below, it needs a fuller identity block GL's own PDF + gives that report alone. +- **`components/reports/DownloadPdfButton.tsx`** — re-issues the *exact same* report call with + `outputFormat=Pdf` instead of `Json`, base64-decodes `data.contentBase64` client-side into a + `Blob`, and triggers a browser download. +- **`components/reports/DownloadCsvButton.tsx`** *(new)* — identical mechanism, `outputFormat=Csv` + instead, `Blob` typed `text/csv`, triggers a `.csv` download. Sits next to the PDF button on every + screen — two download buttons now, not one. No proxy/transport concern either (`docs/12` §7) — same + passthrough as `Pdf` always was. + +| Screen | `reportType` | On-screen title | Required params | Response shape | +|---|---|---|---|---| +| Trial Balance | `TrialBalance` | Trial Balance | `asOfDate` | **Flat** array `{accountCode, accountName, debit, credit}` — no `depth` anymore | +| Balance Sheet | `BalanceSheet` | Statement of Financial Position | `asOfDate` | Unchanged — hierarchical array `{depth, lineItem, accountType, balance}`, always ends with a synthetic `"Current Year Earnings"` row under Equity | +| General Ledger | `GeneralLedger` | General Ledger | `periodStart`, `periodEnd` (`accountCode` optional — renamed from `accountId`, see below) | Unchanged shape | +| Profit & Loss | `ProfitAndLoss` | Statement of Profit or Loss | `periodStart`, `periodEnd` | **Nested object**, not a flat array — named sections, see below | +| Cash Flow | `CashFlow` | Statement of Cash Flows | `periodStart`, `periodEnd` | Nested object; `workingCapitalChanges[]` gained `direction`; `openingCashBalance`/`closingCashBalance` still returned, not displayed | +| Budget vs Actual | `BudgetVsActual` | Budget vs Actual | `budgetId` | Unchanged | +| Tax Report *(new)* | `TaxSummary` | Income Tax Computation | `periodStart`, `periodEnd`; optional `allowableDeductions`, `otherTaxableIncome`, `qualifyingPaymentsReliefs`, `surchargeAmount`, `taxRateOverride` | Single flat object, see below | + +**Defaults, so a screen is never blank on first load:** `asOfDate`/`periodEnd` default to today, +`periodStart` to the 1st of the current month (`lib/format.ts`'s `todayIso`/`startOfMonthIso`) — +these params are *required* by GL (400 if missing), so the UI always sends something sensible rather +than erroring on mount. The Tax Report's five optional parameters get **no forced client-side +default** — an untouched field sends nothing, letting GL's own server-side defaulting (`0` for four +of them, `system_config`-driven for `qualifyingPaymentsReliefs`/the tax rate) be the single source of +truth for what "not supplied" means. + +**`reportType`/`outputFormat` are TS enums** (`types/general-ledger.ts`'s `ReportType`, +`ReportOutputFormat`) — `ReportOutputFormat` gains a `Csv` member. `GlAccount.accountTypeId` stays a +`GlAccountTypeId` enum (`Asset=1`…`Expense=5`). + +**General Ledger: `accountId` renamed to `accountCode` (GL's `CLAUDE.md` Rule 8.2), behavior +unchanged from the 2026-07-20 correction.** GL's own reference still documents two modes: +`accountCode` supplied → "Account Ledger" (one account + its descendants, one running balance); +`accountCode` omitted → the **true General Ledger**, every `is_postable` account's own transactions +together, each with its own running balance that resets whenever the account changes, sorted by +`accountCode` then `entryDate`. This screen still always calls the second mode — +`reportsApi.generalLedger(periodStart, periodEnd)` never sends `accountCode` — and has no account +picker/input, same as before; only the underlying param name the client would use *if* a +single-account mode were ever added changed, not this screen's own behavior. + +**Amount formatting** (`lib/format.ts`): comma-grouped thousands + fixed 2 decimals + parentheses for +negatives (`formatAmount`) — standard financial-statement convention. **CSV export does not reuse +this formatter** — GL's own CSV cells are plain decimals with a leading minus sign, no thousands +separator, no parentheses (spreadsheet-numeric-parsing convention, not human-display convention). +`DownloadCsvButton` downloads GL's bytes unmodified, same "don't reshape what GL sent" posture as the +PDF button. + +**Hierarchical rows:** Balance Sheet still carries `depth`, rendered with `depth`-proportional left +padding. **Trial Balance dropped this entirely** — now a plain flat list, same row-component style as +Budget vs Actual. + +**Profit & Loss is a nested object, not a flat array.** GL's response has named sections — `sales`, +`costOfSales`, `otherIncome`, `distributionExpenses`, `administrationExpenses`, `otherExpenses`, +`financialExpenses`, `unclassified` (only present with lines if the COA has untagged Income/Expense +accounts — GL flags this as worth watching for during setup) — each `{ lines[], total }`, plus +top-level `grossProfit`/`netProfitForPeriod`. The screen renders one bordered section per non-empty +group, in this fixed order: Sales, Cost of Sales, **Gross Profit** (its own bold subtotal row, not a +section), Other Income, the four expense groups, `unclassified` last if present, then **Net Profit +for the Period**. + +**Cash Flow is a structured statement layout, not four `StatCard`s.** `Net Earnings` as its own line, +an "Additions to Cash" bordered section and a "Subtractions From Cash" bordered section — client-side +bucketed by sign from the combined `nonCashAdjustments[]` + `workingCapitalChanges[]` (a working-capital +line's label uses its `direction` field, e.g. `direction: "Decrease"` + `accountName: "Trade +Receivables"` → `"Decrease in Trade Receivables"`; a non-cash-adjustment line just prints its plain +`description`, e.g. `"Depreciation"`, no Increase/Decrease prefix) — then `Net Cash From Operations` +as a subtotal, Investing/Financing sections (their `lines[]`, no subtotal row when a section has only +one line), ending in the final combined total. `openingCashBalance`/`closingCashBalance` are fetched +(still in the response) but **not rendered anywhere on screen** — same "computed but not displayed" +posture GL's own PDF/CSV takes. + +**Tax Report (`app/dashboard/ledgers/tax-report/page.tsx`) is an entirely new screen.** Layout: +- A `periodStart`/`periodEnd` date-range picker, same component as every other period-based report. +- **A collapsible "Adjustments" panel**, collapsed by default, with five optional numeric inputs — + `Allowable Deductions`, `Other Taxable Income`, `Qualifying Payments / Reliefs`, + `Surcharge / Education Levy`, and `Tax Rate Override` (%). Each maps directly to the report call's + optional query params. +- Result: a two-column `Description`/`Amount` table in GL's own fixed order (`profitBeforeTax` through + `balanceTaxPayable`) — `Add:`/`Less:` prefixes are literal row labels the component renders per a + fixed lookup, not derived from the amount's sign. Bold rows for the reconciliation checkpoints + (`Adjusted Business Profit`, `Assessable Income`, `Taxable Income`, `Gross Tax Liability`, the final + total). +- **The final row's label depends on the sign of `balanceTaxPayable`** — GL's own PDF renders a + negative value (credits/payments exceeded the gross liability) as **"BALANCE TAX REFUNDABLE"**, not + a negative "payable" figure (confirmed live on GL's own seeded loss-making test data, + `03_Progress_Tracker.md`). The on-screen table should match this exactly: `balanceTaxPayable >= 0` → + label `"BALANCE TAX PAYABLE"`, amount as-is; `balanceTaxPayable < 0` → label + `"BALANCE TAX REFUNDABLE"`, amount shown as its absolute value (not in parentheses — GL's own PDF + changes the *label*, not the sign display, for this one row specifically). +- Uses its own header block, not the shared `ReportHeader` — GL's own PDF gives this one report a + fuller identity block (company name, address, TIN, BRN) via `Company:Address`/`Tin`/`Brn` config + values (`05_LKAS_Report_PDF_Templates.md` §0/§8, confirmed implemented server-side). **Open question, + genuinely unresolved:** GL exposes no endpoint returning these four values as data — they're + `appsettings.json` entries, read only by the PDF renderer. The on-screen React version needs the + *same* values from *somewhere*, and there's no way to fetch them from GL today. Two options, neither + picked here: (a) GL adds a small `GET /company-info`-style endpoint; (b) the frontend duplicates + these four values in its own config/env, accepting the risk of drifting out of sync with GL's + `appsettings.json` whenever one side changes without the other. Flag this for a decision before + building this screen's header — don't silently pick one. + +--- + +## 4. Cash / Bank Accounts + +`app/dashboard/ledgers/bank-accounts/page.tsx` (list) + `/new/page.tsx` (create). No delete — GL's +own reference has no delete endpoint for either concept, consistent with every other master in this +system (FR-MD-08's deactivate-not-delete posture, though GL doesn't even expose deactivate here). + +**Rework — Cash and Bank are separate GL tables/endpoints, not one table with a type field.** An +earlier design (drafted, never shipped on GL's side) proposed a single `bank_account.accountType` +discriminator; GL's team judged a cash account a *peer* concept to a bank account, not a *kind of* +bank account, and built two separate tables instead (`Accounting_System_Design_LKAS.md` §5.8). This +still satisfies the original requirement — **the create page only ever offers exactly two choices, +Cash or Bank, nothing else** — the two-choice constraint now lives in "which endpoint does the form +submit to," not "which value of a discriminator field." + +### The three endpoints this page now uses + +- **`POST /bank-accounts`** — unchanged in shape from the original 2026-07-18 design, except + `glAccountId` → `glAccountCode` (Rule 8.2). Fields: `accountName` (required), `bankName`/ + `accountNumber` (both optional/nullable — a Bank account can genuinely be created with these blank + and filled in later, unlike Cash's stricter rule below), `glAccountCode` (required), `currencyCode` + (optional, defaults `"LKR"`). +- **`POST /cash-accounts`** *(new)* — `accountName` (required), `cashAccountTypeName` (required — + matched case-insensitively against GL's `cash_account_type` reference table; a name with no + existing match **creates a new type on the fly**, so a frontend "Other, please specify" free-text + option becomes a permanent selectable choice for every future cash account created after it), + `accountNumber` (**optional** — if omitted, GL auto-generates a sequential `CASH-000001`-style + reference; if supplied, used as entered), `glAccountCode` (required), `currencyCode` (optional, + defaults `"LKR"`). +- **`GET /cash-account-types`** *(new)* — flat, unfiltered list, seeded with Petty Cash / Till Cash / + Safe Cash / Cash in Transit, growing over time. Feeds the create form's Cash Account Type dropdown. + +### Create form +A **two-choice toggle** at the top — **Cash** or **Bank**, backed by a `CashBankAccountType` TS enum +(`Cash`/`Bank`) — decides which of the two fieldsets below shows and which endpoint the form submits +to: + +- **Bank selected:** Bank Name + Account Number fields (both optional on this side — GL doesn't + enforce them as required for a Bank row), submits to `POST /bank-accounts`. +- **Cash selected:** a Cash Account Type `` picker (bank account form, and Budget vs Actual's budget picker) displayed the +selected item's **numeric value** after picking it, even though the correct id/code was genuinely +being sent to the server. Cause: this app's `` (with `label={...}` set correctly from the start, avoiding the + raw-number-display bug from §4's earlier fix) fed by `GET /cash-account-types`, an "Other, please + specify" free-text option, `glAccountCode` picker now keyed by the account's code string instead + of its numeric id. + - Cash/Bank list page — server-side `accountType` filter (`Both`/`Bank`/`Cash`, a `