Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6d939059 | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 0750773f94 | |||
| 38c7545413 | |||
| 0d60aeef64 | |||
| c6bc8065a2 | |||
| 4f56d481a2 | |||
| d79371697e | |||
| f7a65b5f7e | |||
| 6b216195c6 | |||
| 266a2a2c14 | |||
| 3c5b476635 | |||
| 59af50bf11 | |||
| 0b9d64f911 | |||
| 6258ebd8de | |||
| 37c8da2ced | |||
| b7f9f599eb | |||
| f43a8a8486 | |||
| a271d1832e | |||
| 6ebdbb655a | |||
| 22657f0910 | |||
| 74d3e684d2 | |||
| 2dab7051b3 | |||
| 4f722432cd | |||
| 1f9e12e84b | |||
| 8b8e79e0fe | |||
| ffbd47f6f9 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| 76484c7268 | |||
| b7bd8dca5c |
+14
-5
@@ -30,9 +30,18 @@ yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
# ── 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.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> 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<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleSaleDto>> 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<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<BundleSaleDto>> 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<ActionResult<BundleSaleDto>> 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<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost("{bundleSaleId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
|
||||
public CustomersController(ICustomerService customers) => _customers = customers;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CustomerDto>>> 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<ActionResult<CustomerDto>> 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<ActionResult<CustomerDto>> 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<ActionResult<CustomerDto>> 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<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _customers.SetStatusAsync(customerId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
|
||||
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
|
||||
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||
/// </summary>
|
||||
[Route("api/v1/free-issues")]
|
||||
public sealed class FreeIssuesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> 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<ActionResult<FreeIssueDto>> 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<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<FreeIssueDto>> 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<ActionResult<FreeIssueDto>> 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<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.PostAsync(freeIssueId, ct));
|
||||
|
||||
[HttpPost("{freeIssueId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
|
||||
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic reverse proxy into the external General Ledger service — forwards every
|
||||
/// method/path/query/body under this prefix verbatim via
|
||||
/// <see cref="IGeneralLedgerService"/> 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
|
||||
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
|
||||
|
||||
[HttpPost("{**path}")]
|
||||
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
|
||||
|
||||
[HttpPut("{**path}")]
|
||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||
|
||||
private async Task<IActionResult> 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,18 @@ public sealed class GrnsController : ApiControllerBase
|
||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines.</summary>
|
||||
[HttpPut("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<GrnDto>> Update(int grnId, [FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.UpdateAsync(grnId, request, ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||
[HttpPost("{grnId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -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<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> 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<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost("{salesInvoiceId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||
}
|
||||
@@ -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<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
|
||||
=> Ok(_reports.ListReports());
|
||||
|
||||
[HttpGet("{reportId}")]
|
||||
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<SalesReportDefinitionDto> 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<ActionResult<SalesReportQueryResponse>> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
|
||||
{
|
||||
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> 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<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.PostAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost("{salesSlipId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
|
||||
}
|
||||
@@ -17,4 +17,7 @@ public static class DocumentTypes
|
||||
|
||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||
public const string Production = "PRD";
|
||||
public const string SalesInvoice = "SI";
|
||||
public const string SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
}
|
||||
|
||||
@@ -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<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Customer master for both B2B and B2C sales.
|
||||
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -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<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum BundleSaleStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Posted = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum CustomerType
|
||||
{
|
||||
B2B = 1,
|
||||
B2C = 2,
|
||||
WalkIn = 3
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesDiscountMode
|
||||
{
|
||||
Percentage = 1,
|
||||
Amount = 2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesInvoiceStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Posted = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesInvoiceType
|
||||
{
|
||||
B2B = 1,
|
||||
B2C = 2,
|
||||
Cash = 3,
|
||||
Credit = 4
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesSlipStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Posted = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Customers;
|
||||
|
||||
/// <summary>Customer resource used by sales documents.</summary>
|
||||
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; }
|
||||
}
|
||||
@@ -69,6 +69,8 @@ public sealed class CreateGrnRequest
|
||||
public int? VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||
/// <summary>Optional document-level discount percentage (0–100). When supplied, per-line discounts are ignored.</summary>
|
||||
[Range(0, 100)] public decimal? TotalDiscountPct { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReleaseLineRequest
|
||||
|
||||
@@ -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<BundleSaleLineDto> 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<BundleSaleTemplateLineDto> 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<BundleSalePostingIssueDto> 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<CreateBundleSaleTemplateLineRequest> 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<CreateBundleSaleTemplateLineRequest> 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<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -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<SalesInvoiceLineDto> 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<SalesInvoicePostingIssueDto> 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<CreateSalesInvoiceLineRequest> 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<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -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<SalesFreeIssueRewardOptionDto> RewardOptions);
|
||||
|
||||
public sealed record SalesFreeIssueSuggestionDto(
|
||||
int SalesSlipId,
|
||||
string SlipNo,
|
||||
DateTime SlipDate,
|
||||
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesReportDefinitionDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
IReadOnlyList<string> 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<object> Rows);
|
||||
@@ -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<SalesSlipLineDto> 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<SalesSlipPostingIssueDto> 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<SalesSlipLineDto> 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<CreateSalesSlipLineRequest> 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<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. 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.
|
||||
/// </summary>
|
||||
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<GeneralLedgerResponse> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerResponse
|
||||
{
|
||||
public int StatusCode { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string Body { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -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<BundleSale>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BundleSale> 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<string>().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);
|
||||
}
|
||||
}
|
||||
@@ -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<BundleSaleLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BundleSaleLine> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<BundleSaleTemplate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BundleSaleTemplate> 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<string>().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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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<BundleSaleTemplateLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BundleSaleTemplateLine> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Customer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Customer> 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<string>().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<string>().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);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
|
||||
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
|
||||
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
|
||||
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
|
||||
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
|
||||
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
|
||||
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
|
||||
// renamed Code, so any role already granted this permission keeps it.
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesInvoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesInvoice> 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<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(SalesInvoiceType.B2C);
|
||||
|
||||
builder.Property(x => x.Status)
|
||||
.HasConversion<string>().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<decimal>(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<SalesInvoiceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesInvoiceLine> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SalesSlip>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesSlip> 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<string>().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<decimal>(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<SalesSlipLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesSlipLine> 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();
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,27 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
|
||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
|
||||
// rather than delete+recreate, so any role that already had this permission granted
|
||||
// doesn't silently lose it just because the section it lives under changed.
|
||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -42,7 +43,11 @@ public static class DataSeeder
|
||||
{
|
||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||
dirty |= await SeedItemTypesAsync(db, ct);
|
||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||
dirty |= await SeedSalesStockAsync(db, ct);
|
||||
dirty |= await SeedSalesAsync(db, ct);
|
||||
|
||||
if (dirty) await db.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -99,4 +104,754 @@ public static class DataSeeder
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
||||
/// These values are intentionally editable later through the API.
|
||||
/// </summary>
|
||||
//private static async Task<bool> 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;
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ERPCore.Infra.Persistence;
|
||||
public class ErpDbContext : DbContext
|
||||
{
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private bool _writingAuditLogs;
|
||||
|
||||
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class ErpDbContext : DbContext
|
||||
}
|
||||
|
||||
// --- Master Data (docs/10 Part C.1) ---
|
||||
public DbSet<Customer> Customers => Set<Customer>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||
public DbSet<Brand> Brands => Set<Brand>();
|
||||
@@ -34,6 +36,7 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||
public DbSet<Bin> Bins => Set<Bin>();
|
||||
|
||||
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||
|
||||
@@ -82,6 +85,16 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
||||
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
||||
|
||||
// --- Sales (Phase 1) ---
|
||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
||||
|
||||
// --- Reference data (docs/10 Part C.7) ---
|
||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||
|
||||
@@ -178,24 +191,44 @@ public class ErpDbContext : DbContext
|
||||
// persists the logs without re-auditing them.
|
||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
||||
{
|
||||
var pending = AuditScribe.Capture(ChangeTracker);
|
||||
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
|
||||
? Array.Empty<PendingAudit>()
|
||||
: 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<PendingAudit> pending = _writingAuditLogs
|
||||
? Array.Empty<PendingAudit>()
|
||||
: 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;
|
||||
}
|
||||
|
||||
-2229
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-2454
File diff suppressed because it is too large
Load Diff
-442
@@ -1,442 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// <para>
|
||||
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||
/// <c>categories.ParentId</c> 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 <c>subcategories</c> and repoint items
|
||||
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
name: "BrandId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SubCategoryId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
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<string>(
|
||||
name: "Status",
|
||||
table: "categories",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Active");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAt",
|
||||
table: "categories",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<uint>(
|
||||
name: "xmin",
|
||||
table: "categories",
|
||||
type: "xid",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: 0u);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "brands",
|
||||
columns: table => new
|
||||
{
|
||||
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_types",
|
||||
columns: table => new
|
||||
{
|
||||
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "product_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||
/// <para>
|
||||
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||
/// discarded exactly what <see cref="Up"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<int>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-2454
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ini2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-3001
File diff suppressed because it is too large
Load Diff
-303
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolesNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RoleId",
|
||||
table: "users",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_roles", x => x.RoleId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sub_nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: true),
|
||||
SubNavItemId = table.Column<int>(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<int>(type: "integer", nullable: false),
|
||||
PermissionId = table.Column<int>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1241
-12
File diff suppressed because it is too large
Load Diff
@@ -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<ErpDbContext>(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<IAuthUserService, AuthUserService>();
|
||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||
|
||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
||||
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||||
|
||||
// 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<IUnitOfWork, UnitOfWork>();
|
||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
// Master-data services (docs/11 §2)
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IItemService, ItemService>();
|
||||
builder.Services.AddScoped<IUomService, UomService>();
|
||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
@@ -87,6 +104,18 @@ builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
|
||||
// Sales (Phase 1)
|
||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
||||
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||
@@ -161,7 +190,16 @@ var app = builder.Build();
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||||
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();
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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<BundleSaleTemplate> _templates;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BundleSaleService(
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_templates = templates;
|
||||
_bundles = bundles;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSaleTemplate> 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<BundleSaleTemplateSummaryDto>.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<BundleSaleTemplateDto?> 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<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSale> 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 (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.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<BundleSaleDto?> 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<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
=> _posting.CheckBundleAsync(bundleSaleId, ct);
|
||||
|
||||
public async Task<BundleSaleDto> 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<BundleSaleDto> 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<BundleSaleDto> 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<BundleSaleDto> 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<List<BundleSaleLine>> BuildLinesAsync(
|
||||
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<BundleSaleLine>();
|
||||
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);
|
||||
|
||||
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
|
||||
// Keep any per-line warehouse input from drifting away from the header.
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = item.Name,
|
||||
Qty = qtyBase,
|
||||
UomId = item.BaseUomId,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = unitCostBase,
|
||||
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());
|
||||
|
||||
}
|
||||
@@ -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<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IUnitOfWork uow)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CustomerDto>> 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<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CustomerDto>?> 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<CustomerDto>(Map(customer), customer.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CustomerDto>> 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<CustomerDto>(Map(customer), customer.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CustomerDto>> 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<CustomerDto>(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();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
}
|
||||
@@ -322,6 +322,110 @@ public sealed class GrnService : IGrnService
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||
}
|
||||
|
||||
public async Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status != GrnStatus.Draft)
|
||||
throw new ConflictException($"GRN {grnId} is {grn.Status} and can no longer be edited.");
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
PurchaseOrder? po = null;
|
||||
int vendorId;
|
||||
if (request.PoId is not null)
|
||||
{
|
||||
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||
vendorId = po.VendorId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.VendorId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
decimal unitCost;
|
||||
decimal? poUnitPrice = null;
|
||||
if (input.PoLineId is not null)
|
||||
{
|
||||
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
unitCost = input.UnitCost;
|
||||
}
|
||||
|
||||
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
|
||||
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch,
|
||||
Qty = input.Qty,
|
||||
UnitCost = unitCost,
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
NetUnitCost = netUnitCost,
|
||||
VatPct = input.VatPct,
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
});
|
||||
}
|
||||
|
||||
grn.PoId = request.PoId;
|
||||
grn.VendorId = vendorId;
|
||||
grn.WarehouseId = request.WarehouseId;
|
||||
grn.Lines.Clear();
|
||||
foreach (var line in lines) grn.Lines.Add(line);
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(grn);
|
||||
}
|
||||
|
||||
private async Task<Batch?> ResolveBatchAsync(
|
||||
Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface IBundleSaleService
|
||||
{
|
||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (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.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public interface IGrnService
|
||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines; rejects if the GRN is no longer Draft.</summary>
|
||||
Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
||||
Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesDocumentWorkflowService
|
||||
{
|
||||
Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<SalesPriceResolution> 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<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record SalesLineComputation(
|
||||
decimal Gross,
|
||||
decimal DiscountTotal,
|
||||
decimal NetUnitPrice,
|
||||
decimal LineTotal,
|
||||
decimal TaxAmount);
|
||||
@@ -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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesPostingService
|
||||
{
|
||||
Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> 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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesPricingService
|
||||
{
|
||||
Task<SalesPriceResolution> ResolveAsync(
|
||||
int itemId,
|
||||
int warehouseId,
|
||||
decimal? requestedUnitPrice,
|
||||
bool allowManualOverride,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record SalesPriceResolution(
|
||||
decimal UnitPrice,
|
||||
string PriceSource,
|
||||
decimal BaseCost);
|
||||
@@ -0,0 +1,8 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesPromotionSuggestionService
|
||||
{
|
||||
Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesReportService
|
||||
{
|
||||
IReadOnlyList<SalesReportDefinitionDto> ListReports();
|
||||
SalesReportDefinitionDto? GetReport(string reportId);
|
||||
Task<IReadOnlyList<object>> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesWarehouseSummaryRowDto>> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesDiscountSummaryRowDto>> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesFreeIssueSummaryRowDto>> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
|
||||
public SalesDocumentWorkflowService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoice> 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<SalesSlip> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
|
||||
public SalesDomainService(
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> 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<SalesPriceResolution> 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<bool> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<SalesInvoice> _invoices;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _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<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> 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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesInvoice> 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<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>?> 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<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
=> _posting.CheckInvoiceAsync(salesInvoiceId, ct);
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> 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<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> 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<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesInvoiceDto> 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<SalesInvoiceDto> 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<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesInvoiceLine>();
|
||||
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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesSlip> slips,
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
_bundles = bundles;
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> 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<SalesInvoicePostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesInvoicePostingIssueDto>();
|
||||
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<SalesSlipPostingCheckDto> 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<SalesSlipPostingIssueDto>());
|
||||
|
||||
var issues = new List<SalesSlipPostingIssueDto>();
|
||||
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<BundleSalePostingCheckDto> 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<BundleSalePostingIssueDto>());
|
||||
|
||||
var issues = new List<BundleSalePostingIssueDto>();
|
||||
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
|
||||
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.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.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.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.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.",
|
||||
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||
// stored base quantity directly instead of converting again.
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
getDocId: x => x.BundleSaleId,
|
||||
ct: ct);
|
||||
|
||||
private async Task PostAsync<T>(
|
||||
Func<Task<T?>> load,
|
||||
string notFoundMessage,
|
||||
Func<T, object> statusSelector,
|
||||
Func<T, string> ensureDraftMessage,
|
||||
Func<T, IEnumerable<PostingLine>> getLines,
|
||||
Action<T> setPosted,
|
||||
Action<T> setUpdated,
|
||||
string sourceDocType,
|
||||
Func<T, int> 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, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
}
|
||||
@@ -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<Item> _items;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
|
||||
public SalesPricingService(IRepository<Item> items, IRepository<GrnLine> grnLines, IFifoCostingService fifo)
|
||||
{
|
||||
_items = items;
|
||||
_grnLines = grnLines;
|
||||
_fifo = fifo;
|
||||
}
|
||||
|
||||
public async Task<SalesPriceResolution> 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<decimal?> 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<decimal> GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
public SalesPromotionSuggestionService(IRepository<SalesSlip> slips, IRepository<Item> items)
|
||||
{
|
||||
_slips = slips;
|
||||
_items = items;
|
||||
}
|
||||
|
||||
public async Task<SalesFreeIssueSuggestionDto?> 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 freeIssueLines = slip.Lines
|
||||
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
|
||||
.ToList();
|
||||
|
||||
if (freeIssueLines.Count == 0)
|
||||
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
|
||||
|
||||
var itemIds = freeIssueLines.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<SalesFreeIssueSuggestionLineDto>();
|
||||
|
||||
foreach (var line in freeIssueLines)
|
||||
{
|
||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||
|
||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||
{
|
||||
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,
|
||||
line.FreeQty,
|
||||
line.Qty,
|
||||
rewardOptions));
|
||||
}
|
||||
|
||||
return suggestions.Count == 0
|
||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||
}
|
||||
}
|
||||
@@ -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<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
|
||||
public SalesReportService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SalesReportDefinitionDto> ListReports() => ReportDefinitions;
|
||||
|
||||
public SalesReportDefinitionDto? GetReport(string reportId)
|
||||
{
|
||||
var normalized = reportId.Trim().ToLowerInvariant();
|
||||
return ReportDefinitions.FirstOrDefault(r => r.Id == normalized);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<object>> 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<object>().ToList(),
|
||||
"item" or "item-summary" or "item-wise" => (await ItemSummaryAsync(from, to, itemId, warehouseId, ct)).Cast<object>().ToList(),
|
||||
"customer" or "customer-summary" or "customer-wise" => (await CustomerSummaryAsync(from, to, customerId, ct)).Cast<object>().ToList(),
|
||||
"warehouse" or "warehouse-summary" or "warehouse-wise" => (await WarehouseSummaryAsync(from, to, warehouseId, ct)).Cast<object>().ToList(),
|
||||
"discount" or "discount-summary" => (await DiscountSummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||
"free-issue" or "free-issue-summary" => (await FreeIssueSummaryAsync(from, to, ct)).Cast<object>().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<IReadOnlyList<SalesDailySummaryRowDto>> 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<IReadOnlyList<SalesItemSummaryRowDto>> 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<IReadOnlyList<SalesCustomerSummaryRowDto>> 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<IReadOnlyList<SalesWarehouseSummaryRowDto>> 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<IReadOnlyList<SalesDiscountSummaryRowDto>> 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<IReadOnlyList<SalesFreeIssueSummaryRowDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SalesSlip> _slips;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _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<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> 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<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesSlip> 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<SalesSlipSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesSlipDto>?> 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<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesSlip> 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<FreeIssueSummaryDto>.Create(rows.Select(MapFreeIssueSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<FreeIssueDto>?> 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<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
||||
=> _posting.CheckSlipAsync(salesSlipId, ct);
|
||||
|
||||
public async Task<ETagged<SalesSlipDto>> 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<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesSlipDto>> 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<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipDto> 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<SalesSlipDto> 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<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesSlipLine>();
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=187.127.102.190;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"RootPath": "App_Data/hr-documents",
|
||||
"MaxSizeBytes": 10485760
|
||||
},
|
||||
"GeneralLedgerService": {
|
||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ERPCore",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,48 @@ 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 `<Select>` (Bank Account create's GL-account picker, Budget vs Actual's budget picker) displayed the raw numeric value instead of its label after selection — the underlying value sent to the server was always correct; `@base-ui/react/select`'s `Select.Value` needs an explicit `label` prop per `<SelectItem>` (separate from `children`) to resolve display text, which neither picker was passing. Fixed at the two call sites, not the shared `components/ui/select.tsx` primitive (out of scope — other numeric-valued `<Select>`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 `<entity>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.
|
||||
>
|
||||
> **2026-08-05 — Cheque Management status/type fields were rendering as raw integers, not names (user-reported + confirmed with GL's own `06_Enums_Reference.md`).** That doc's key fact: GL has no global `JsonStringEnumConverter`. A JSON-**body** enum field (e.g. the Issue-cheque form's `payeeType`) is independently declared `string` server-side and parsed via `Enum.TryParse`, and a query-string enum filter binds natively by name — both already correct here, unaffected. But `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are genuine enum-typed properties on GL's own **response** DTOs, backed by real `integer` DB columns — with no converter, GL's JSON serializes each one as its raw number (`1`/`2`/`3`/...), not its name. This wasn't just a cosmetic label bug: every list badge, the dialogs' status-based available-actions logic, and any `===` comparison against this frontend's own string enums (`ChequeBookStatus.Active`, etc.) would have silently mismatched against these numbers. Fixed at the API boundary, not scattered across every consumer: added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (one per affected field, keyed by the exact integers `06_Enums_Reference.md` documents), and applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing GL's actual `number`/`number | null` response shape for these fields) plus `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one — so every page/dialog/badge map keeps working against the same string values as before, unchanged. Cross-checked every other enum in that doc's "Persisted enums" table against this frontend (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) — none are consumed anywhere in this app, confirming Cheque Management was the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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>(CashBankAccountType.Bank)
|
||||
|
||||
const [cashAccountTypes, setCashAccountTypes] = useState<CashAccountType[] | null>(null)
|
||||
const [cashAccountTypesError, setCashAccountTypesError] = useState<string | null>(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<Record<string, string>>({})
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Cash / Bank Account</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Its ledger account is created automatically — no need to pick one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="mb-6 flex max-w-sm gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={accountType === CashBankAccountType.Bank ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAccountType(CashBankAccountType.Bank)}
|
||||
>
|
||||
Bank
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={accountType === CashBankAccountType.Cash ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAccountType(CashBankAccountType.Cash)}
|
||||
>
|
||||
Cash
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.accountName}>
|
||||
<FieldLabel htmlFor="ba-name">Account name</FieldLabel>
|
||||
<Input
|
||||
id="ba-name"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder={accountType === CashBankAccountType.Bank ? "Main Account" : "Head Office Petty Cash"}
|
||||
aria-invalid={!!errors.accountName}
|
||||
/>
|
||||
<FieldError errors={[errors.accountName ? { message: errors.accountName } : undefined]} />
|
||||
</Field>
|
||||
|
||||
{accountType === CashBankAccountType.Bank ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-bank">Bank name (optional)</FieldLabel>
|
||||
<Input id="ba-bank" value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="Commercial Bank" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
|
||||
<Input id="ba-acct-no" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="8001234567" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field data-invalid={!!errors.cashAccountTypeName}>
|
||||
<FieldLabel htmlFor="ba-cash-type">Cash account type</FieldLabel>
|
||||
<Select<string> value={cashAccountTypeChoice} onValueChange={(v) => setCashAccountTypeChoice(v ?? "")}>
|
||||
<SelectTrigger id="ba-cash-type" className="w-full text-base" aria-invalid={!!errors.cashAccountTypeName}>
|
||||
<SelectValue placeholder={cashAccountTypes === null ? "Loading…" : "Select a type"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(cashAccountTypes ?? []).map((t) => (
|
||||
<SelectItem key={t.cashAccountTypeId} value={t.name} label={t.name} className="text-base">
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={OTHER_CASH_TYPE} label="Other, please specify" className="text-base">
|
||||
Other, please specify…
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{cashAccountTypeChoice === OTHER_CASH_TYPE && (
|
||||
<Input
|
||||
value={customCashAccountTypeName}
|
||||
onChange={(e) => setCustomCashAccountTypeName(e.target.value)}
|
||||
placeholder="e.g. Site Cash"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
{cashAccountTypesError && (
|
||||
<p className="text-sm text-destructive">{cashAccountTypesError}</p>
|
||||
)}
|
||||
<FieldError errors={[errors.cashAccountTypeName ? { message: errors.cashAccountTypeName } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
|
||||
<Input
|
||||
id="ba-acct-no"
|
||||
value={accountNumber}
|
||||
onChange={(e) => setAccountNumber(e.target.value)}
|
||||
placeholder="Auto-generated if left blank"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-currency">Currency</FieldLabel>
|
||||
<Input id="ba-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} placeholder="LKR" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<CashAndBankAccountDto[] | null>(null)
|
||||
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [accountType, setAccountType] = useState<AccountTypeFilter>("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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger className="h-14! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && filtered === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Wallet className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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<ChequeBook | null>(null)
|
||||
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Book {chequeBookNo}</h1>
|
||||
<p className="text-base text-muted-foreground">Every leaf in this book — click one to view details or take an action.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && book === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && book !== null && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Bank account</p>
|
||||
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">#{book.branchId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Range</p>
|
||||
<p className="text-base font-medium">
|
||||
{book.startChequeNo} – {book.endChequeNo}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Received</p>
|
||||
<p className="text-base font-medium">{formatReportDate(book.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{book.pages.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No pages found for this book.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Payee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Issue date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{book.pages.map((p) => (
|
||||
<TableRow
|
||||
key={p.chequeNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedPage(p)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{p.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[p.issueStatus])}>
|
||||
{p.issueStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChequePageDialog page={selectedPage} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handlePageUpdated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<CashAndBankAccountDto[] | null>(null)
|
||||
const [bankAccountsError, setBankAccountsError] = useState<string | null>(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<Record<string, string>>({})
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Cheque Book</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Every leaf from the start to end cheque number is generated automatically, all "Unused".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bankAccountsError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{bankAccountsError}</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.bankAccountId}>
|
||||
<FieldLabel htmlFor="cb-bank">Bank account</FieldLabel>
|
||||
<Select<string> value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="cb-bank" className="w-full text-base" aria-invalid={!!errors.bankAccountId}>
|
||||
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(bankAccounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName}
|
||||
{a.bankName ? ` — ${a.bankName}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel>
|
||||
<Input
|
||||
id="cb-branch"
|
||||
type="number"
|
||||
value={branchId}
|
||||
onChange={(e) => setBranchId(e.target.value)}
|
||||
placeholder="1"
|
||||
aria-invalid={!!errors.branchId}
|
||||
/>
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.chequeBookNo}>
|
||||
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
|
||||
<Input
|
||||
id="cb-no"
|
||||
value={chequeBookNo}
|
||||
onChange={(e) => setChequeBookNo(e.target.value)}
|
||||
placeholder="CB-0001"
|
||||
aria-invalid={!!errors.chequeBookNo}
|
||||
/>
|
||||
<FieldError errors={[errors.chequeBookNo ? { message: errors.chequeBookNo } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.startChequeNo}>
|
||||
<FieldLabel htmlFor="cb-start">Start cheque no.</FieldLabel>
|
||||
<Input
|
||||
id="cb-start"
|
||||
value={startChequeNo}
|
||||
onChange={(e) => setStartChequeNo(e.target.value)}
|
||||
placeholder="000001"
|
||||
aria-invalid={!!errors.startChequeNo}
|
||||
/>
|
||||
<FieldError errors={[errors.startChequeNo ? { message: errors.startChequeNo } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.endChequeNo}>
|
||||
<FieldLabel htmlFor="cb-end">End cheque no.</FieldLabel>
|
||||
<Input
|
||||
id="cb-end"
|
||||
value={endChequeNo}
|
||||
onChange={(e) => setEndChequeNo(e.target.value)}
|
||||
placeholder="000025"
|
||||
aria-invalid={!!errors.endChequeNo}
|
||||
/>
|
||||
<FieldError errors={[errors.endChequeNo ? { message: errors.endChequeNo } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.totalLeaves}>
|
||||
<FieldLabel htmlFor="cb-leaves">Total leaves</FieldLabel>
|
||||
<Input
|
||||
id="cb-leaves"
|
||||
type="number"
|
||||
value={totalLeaves}
|
||||
onChange={(e) => setTotalLeaves(e.target.value)}
|
||||
placeholder="25"
|
||||
aria-invalid={!!errors.totalLeaves}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Must equal end − start + 1.</p>
|
||||
<FieldError errors={[errors.totalLeaves ? { message: errors.totalLeaves } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.receivedDate}>
|
||||
<FieldLabel htmlFor="cb-received">Received date</FieldLabel>
|
||||
<Input
|
||||
id="cb-received"
|
||||
type="date"
|
||||
value={receivedDate}
|
||||
onChange={(e) => setReceivedDate(e.target.value)}
|
||||
aria-invalid={!!errors.receivedDate}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cb-desc">Description (optional)</FieldLabel>
|
||||
<Input id="cb-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cb-by">Created by (optional)</FieldLabel>
|
||||
<Input id="cb-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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<ChequeBook[] | null>(null)
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
<p className="text-base text-muted-foreground">Cheque books issued from this company's own supply.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && books === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No cheque books yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{books.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
key={b.chequeBookNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{b.startChequeNo} – {b.endChequeNo}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Cash/Bank accounts and cheque management, from the General Ledger service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>(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<Record<string, string>>({})
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Received Cheque</h1>
|
||||
<p className="text-base text-muted-foreground">Record a cheque received from a customer, supplier, or other party.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.companyId}>
|
||||
<FieldLabel htmlFor="rc-company">Company ID</FieldLabel>
|
||||
<Input
|
||||
id="rc-company"
|
||||
type="number"
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
placeholder="1"
|
||||
aria-invalid={!!errors.companyId}
|
||||
/>
|
||||
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel>
|
||||
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-from-type">Received from type</FieldLabel>
|
||||
<Select<ReceivedFromType> value={receivedFromType} onValueChange={(v) => setReceivedFromType(v ?? ReceivedFromType.Customer)}>
|
||||
<SelectTrigger id="rc-from-type" className="w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(ReceivedFromType).map((t) => (
|
||||
<SelectItem key={t} value={t} label={t} className="text-base">
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.receivedFromName}>
|
||||
<FieldLabel htmlFor="rc-from-name">Received from name</FieldLabel>
|
||||
<Input
|
||||
id="rc-from-name"
|
||||
value={receivedFromName}
|
||||
onChange={(e) => setReceivedFromName(e.target.value)}
|
||||
aria-invalid={!!errors.receivedFromName}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedFromName ? { message: errors.receivedFromName } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-from-id">Received from ID (optional)</FieldLabel>
|
||||
<Input id="rc-from-id" type="number" value={receivedFromId} onChange={(e) => setReceivedFromId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-drawer-bank">Drawer bank (optional)</FieldLabel>
|
||||
<Input id="rc-drawer-bank" value={drawerBankName} onChange={(e) => setDrawerBankName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-drawer-branch">Drawer branch (optional)</FieldLabel>
|
||||
<Input id="rc-drawer-branch" value={drawerBankBranch} onChange={(e) => setDrawerBankBranch(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-holder">Account holder name (optional)</FieldLabel>
|
||||
<Input id="rc-holder" value={accountHolderName} onChange={(e) => setAccountHolderName(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.chequeNo}>
|
||||
<FieldLabel htmlFor="rc-cheque-no">Cheque number</FieldLabel>
|
||||
<Input id="rc-cheque-no" value={chequeNo} onChange={(e) => setChequeNo(e.target.value)} aria-invalid={!!errors.chequeNo} />
|
||||
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.chequeDate}>
|
||||
<FieldLabel htmlFor="rc-cheque-date">Cheque date</FieldLabel>
|
||||
<Input
|
||||
id="rc-cheque-date"
|
||||
type="date"
|
||||
value={chequeDate}
|
||||
onChange={(e) => setChequeDate(e.target.value)}
|
||||
aria-invalid={!!errors.chequeDate}
|
||||
/>
|
||||
<FieldError errors={[errors.chequeDate ? { message: errors.chequeDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="rc-amount">Amount</FieldLabel>
|
||||
<Input id="rc-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.receivedDate}>
|
||||
<FieldLabel htmlFor="rc-received-date">Received date</FieldLabel>
|
||||
<Input
|
||||
id="rc-received-date"
|
||||
type="date"
|
||||
value={receivedDate}
|
||||
onChange={(e) => setReceivedDate(e.target.value)}
|
||||
aria-invalid={!!errors.receivedDate}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-ref-type">Reference type (optional)</FieldLabel>
|
||||
<Input id="rc-ref-type" value={referenceType} onChange={(e) => setReferenceType(e.target.value)} placeholder="Invoice" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-ref-id">Reference ID (optional)</FieldLabel>
|
||||
<Input id="rc-ref-id" type="number" value={referenceId} onChange={(e) => setReferenceId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-created-by">Created by (optional)</FieldLabel>
|
||||
<Input id="rc-created-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user