Compare commits

...

25 Commits

Author SHA1 Message Date
ImanThiyanga 722b1e78ed feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:23:07 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
DeepnaPooja 0750773f94 centralize sales flow 2026-08-04 17:19:30 +05:30
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
DeepnaPooja 0d60aeef64 Merge pull request 'Enhance/sales' (#25) from enhance/sales into Dev
Reviewed-on: #25
2026-08-04 06:06:31 +00:00
DeepnaPooja c6bc8065a2 DB issue fix 2026-08-04 11:27:39 +05:30
DeepnaPooja 4f56d481a2 Add bundle sales front end integration 2026-08-04 11:18:32 +05:30
DeepnaPooja d79371697e Add bundle sales backend implementation 2026-08-04 11:17:21 +05:30
ImanThiyanga f7a65b5f7e fixed dbContext 2026-08-03 12:18:44 +05:30
ashan_rusiru 6b216195c6 Merge pull request 'Feat/sales management' (#24) from feat/sales-management into Dev
Reviewed-on: #24
2026-08-02 04:56:51 +00:00
DeepnaPooja 266a2a2c14 implement fe with backend 2026-08-02 01:25:17 +05:30
DeepnaPooja 3c5b476635 sales ,invoice ,slip and free issues BE fixes 2026-08-02 01:12:09 +05:30
DeepnaPooja 59af50bf11 Add sales slip document 2026-08-02 01:07:01 +05:30
DeepnaPooja 0b9d64f911 Add sales invoice document 2026-08-02 01:05:09 +05:30
DeepnaPooja 6258ebd8de implemnet customer master used for both B2B and B2C. 2026-08-02 00:59:16 +05:30
ImanThiyanga 37c8da2ced removed all migration updated 2026-08-01 08:45:10 +05:30
ImanThiyanga b7f9f599eb Merge pull request 'Feat/sales management' (#23) from feat/sales-management into Dev
Reviewed-on: #23
2026-08-01 01:47:27 +00:00
ImanThiyanga f43a8a8486 Merge pull request 'Feat/general ledger service' (#22) from feat/general-ledger-service into Dev
Reviewed-on: #22
2026-08-01 01:46:45 +00:00
ImanThiyanga 6ebdbb655a changes for db 2026-07-31 18:11:40 +05:30
DeepnaPooja 74d3e684d2 Add sales report functions 2026-07-31 17:59:06 +05:30
DeepnaPooja 2dab7051b3 Add discoont rule 2026-07-31 17:59:05 +05:30
DeepnaPooja 4f722432cd add shared pricing resolution with fixed yem price,GRN weighted and FIFO valuation cost fallback 2026-07-31 17:59:05 +05:30
DeepnaPooja 1f9e12e84b Add sales slip document 2026-07-31 17:59:05 +05:30
DeepnaPooja 8b8e79e0fe Add sales invoice document 2026-07-31 17:59:05 +05:30
DeepnaPooja ffbd47f6f9 implemnet customer master used for both B2B and B2C. 2026-07-31 17:58:14 +05:30
160 changed files with 19836 additions and 37680 deletions
+6
View File
@@ -30,6 +30,12 @@ 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 ─────────────────────────────────────────────────────────
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
@@ -0,0 +1,79 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<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] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<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,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));
}
+3
View File
@@ -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; }
}
@@ -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,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);
}
}
@@ -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);
}
}
@@ -38,7 +38,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
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 = 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 = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
);
}
}
@@ -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,6 +33,7 @@ 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.
// 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.
@@ -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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
}
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)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,138 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddLedgersNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 19, "NAV:ledgers", 11, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 9, "ledgers.trial-balance", "/dashboard/ledgers/trial-balance", null, "Trial Balance", 11, 1 },
{ 10, "ledgers.balance-sheet", "/dashboard/ledgers/balance-sheet", null, "Balance Sheet", 11, 2 },
{ 11, "ledgers.general-ledger", "/dashboard/ledgers/general-ledger", null, "General Ledger", 11, 3 },
{ 12, "ledgers.profit-and-loss", "/dashboard/ledgers/profit-and-loss", null, "Profit & Loss", 11, 4 },
{ 13, "ledgers.cash-flow", "/dashboard/ledgers/cash-flow", null, "Cash Flow", 11, 5 },
{ 14, "ledgers.budget-vs-actual", "/dashboard/ledgers/budget-vs-actual", null, "Budget vs Actual", 11, 6 },
{ 15, "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", null, "Cash / Bank Accounts", 11, 7 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 20, "NAV:ledgers.trial-balance", null, 9 },
{ 21, "NAV:ledgers.balance-sheet", null, 10 },
{ 22, "NAV:ledgers.general-ledger", null, 11 },
{ 23, "NAV:ledgers.profit-and-loss", null, 12 },
{ 24, "NAV:ledgers.cash-flow", null, 13 },
{ 25, "NAV:ledgers.budget-vs-actual", null, 14 },
{ 26, "NAV:ledgers.bank-accounts", null, 15 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 19);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 20);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 21);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 22);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 23);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 9);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 10);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 11);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 12);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 13);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 14);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 11);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTaxReportNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
column: "SortOrder",
value: 8);
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 16, "ledgers.tax-report", "/dashboard/ledgers/tax-report", null, "Tax Report", 11, 7 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 27, "NAV:ledgers.tax-report", null, 16 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 27);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 16);
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
column: "SortOrder",
value: 7);
}
}
}
@@ -1,82 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class FixProcurementNavIdCollision : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 17, "procurement.requisitions", "/dashboard/procurement/requisitions", null, "Requisitions", 4, 1 },
{ 18, "procurement.rfqs", "/dashboard/procurement/rfqs", null, "RFQs", 4, 2 },
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 28, "NAV:procurement.requisitions", null, 17 },
{ 29, "NAV:procurement.rfqs", null, 18 },
{ 30, "NAV:procurement.purchase-orders", null, 19 },
{ 31, "NAV:procurement.purchase-returns", null, 20 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 28);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 29);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 30);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 31);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 17);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 18);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 19);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 20);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,106 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAccountsNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26,
column: "Code",
value: "NAV:accounts.bank-accounts");
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
values: new object[] { "accounts.bank-accounts", "/dashboard/accounts/bank-accounts", 12, 1 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 32, "NAV:accounts", 12, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 33, "NAV:accounts.cheque-books", null, 21 },
{ 34, "NAV:accounts.received-cheques", null, 22 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 32);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 33);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 34);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 21);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 22);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 12);
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26,
column: "Code",
value: "NAV:ledgers.bank-accounts");
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
values: new object[] { "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", 11, 8 });
}
}
}
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class production : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -8,15 +8,17 @@ 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);
@@ -32,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();
@@ -72,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>();
@@ -97,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 §56)
builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
@@ -171,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();
@@ -188,3 +216,4 @@ app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,250 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class BundleSaleService : IBundleSaleService
{
private readonly IRepository<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 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,
ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers,
IUnitOfWork uow)
{
_templates = templates;
_bundles = bundles;
_customers = customers;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_sales = sales;
_posting = posting;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
}
public async Task<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, 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 (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);
if (r.WarehouseId != warehouseId)
throw new DomainException(ErrorCodes.Validation,
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
UomId = r.UomId,
WarehouseId = r.WarehouseId,
UnitPrice = resolved.UnitPrice,
LineTotal = calc.LineTotal,
IncludeInBundle = r.IncludeInBundle,
IsComponent = true,
ParentLineId = null
});
}
return lines;
}
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
{
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
bundle.BundlePrice = bundlePrice;
bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal;
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
bundle.TaxTotal = 0m;
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
}
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
private static BundleSaleDto Map(BundleSale x) => new(
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
}
+168
View File
@@ -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,18 @@
using ERPCore.Common.Http;
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, 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,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,214 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesPostingService : ISalesPostingService
{
private readonly IRepository<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 ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesPostingService(
IRepository<SalesInvoice> invoices,
IRepository<SalesSlip> slips,
IRepository<BundleSale> bundles,
IRepository<Item> items,
IFifoCostingService fifo,
ISalesDomainService sales,
ICurrentUser currentUser,
IUnitOfWork uow)
{
_invoices = invoices;
_slips = slips;
_bundles = bundles;
_items = items;
_fifo = fifo;
_sales = sales;
_currentUser = currentUser;
_uow = uow;
}
public async Task<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 available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= line.Qty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
=> PostAsync(
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: nameof(SalesInvoice),
getDocId: x => x.SalesInvoiceId,
ct: ct);
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync(
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: nameof(SalesSlip),
getDocId: x => x.SalesSlipId,
ct: ct);
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
=> PostAsync(
load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct),
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: nameof(BundleSale),
getDocId: x => x.BundleSaleId,
ct: ct);
private async Task PostAsync<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, 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,74 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
{
private const decimal FreeIssueThreshold = 10m;
private readonly IRepository<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 itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
var candidateItems = await _items.Query().AsNoTracking()
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
.ToListAsync(ct);
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
{
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
if (freeQty <= 0m) continue;
var rewardOptions = new List<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,
freeQty,
FreeIssueThreshold,
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);
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=dbuser"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ERPCore",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+5
View File
@@ -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
+7
View File
@@ -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.
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
emergencyContactName: employee.emergencyContactName,
emergencyContactRelationship: employee.emergencyContactRelationship,
emergencyContactPhone: employee.emergencyContactPhone,
hireDate: employee.hireDate,
confirmationDate: employee.confirmationDate,
lastWorkingDate: employee.lastWorkingDate,
departmentId: employee.departmentId,
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
return new Set(named.map((n) => n.id))
}
export default function TemplateBuilderPage() {
function TemplateBuilderContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
@@ -808,3 +808,11 @@ export default function TemplateBuilderPage() {
</div>
)
}
export default function TemplateBuilderPage() {
return (
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
<TemplateBuilderContent />
</Suspense>
)
}
@@ -0,0 +1,355 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
type EditableLine = BundleSaleTemplateLine & { key: string }
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSaleDetailPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(null)
const [bundleName, setBundleName] = useState("")
const [bundlePrice, setBundlePrice] = useState(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
Promise.all([
bundleApi.getBundle(bundleSaleId),
bundleApi.listTemplates({ pageSize: 200 }),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => {
const data = bundleRes
setBundle(data)
setTemplates(templateRes.items)
setCustomers(custRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(data.customerId)
setWarehouseId(data.warehouseId)
setCashierUserId(data.cashierUserId)
setTemplateId(data.bundleSaleTemplateId)
setBundleName(data.bundleName)
setBundlePrice(data.bundlePrice)
setLines(
data.lines.map((line) => ({
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
includeInBundle: line.includeInBundle,
sortOrder: line.bundleSaleLineId,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id])
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
const isDraft = bundle?.status === "Draft"
const canEdit = isDraft
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = lines[lines.length - 1]
if (!source) return
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
setBusy(true)
setError(null)
try {
const request: UpdateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
setBundle(res)
setEditing(false)
router.refresh()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function postBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
if (!check.canPost) {
setError("Resolve stock shortages before posting this bundle.")
return
}
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function cancelBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-4" />
Print
</Link>
{canEdit ? (
editing ? (
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
<Save className="size-4" />
Save
</Button>
) : (
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
<Edit className="size-4" />
Edit
</Button>
)
) : null}
{isDraft ? (
<>
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
<CheckCircle2 className="size-4" />
Post
</Button>
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
<XCircle className="size-4" />
Cancel
</Button>
</>
) : null}
</div>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Component breakdown</h2>
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
</div>
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
{editing && isDraft ? <TableHead /> : null}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,297 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
type EditableLine = BundleSaleTemplateLine & { key: string }
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(res.lines.map(blankLine))
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [templateId])
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = lines[lines.length - 1] ?? template?.lines[0]
if (!source) return
setLines((prev) => [...prev, blankLine(source)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
return
}
setSaving(true)
setSubmitError(null)
try {
const request: CreateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.createBundle(request)
toast.success("Bundle saved", res.bundleNo)
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Override allowed</Label>
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
{allowPriceOverride ? "Yes" : "No"}
</button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Editable component rows</h2>
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving..." : "Save draft"}
</Button>
</div>
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -0,0 +1,292 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PaginationMeta } from "@/types/common"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSalesPage() {
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cust, whRes]) => {
setCustomers(cust.items)
setWarehouses(whRes.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setError(null)
bundleApi
.listBundles({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
q: query || undefined,
customerId: customerId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(() => rows ?? [], [rows])
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Sales</h1>
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Bundle
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Bundle</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
{row.bundleNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="inline-flex gap-2">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View bundle ${row.bundleNo}`}>
<Eye className="size-4" />
</Link>
<Link href={`/print/sales/bundles/${row.bundleSaleId}`} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Print bundle ${row.bundleNo}`}>
<Printer className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Rows loaded</div>
<div className="font-mono text-base font-semibold tabular-nums">{visibleRows.length}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="font-mono text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function BundleRegisterPage() {
redirect("/dashboard/sales/bundles")
}
@@ -0,0 +1,27 @@
"use client"
import Link from "next/link"
import { ArrowLeft, FileText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export default function BundleReportsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Reports</h1>
<p className="text-base text-muted-foreground">Bundle-level reporting will be added after the backend module is wired.</p>
</div>
</div>
<div className="rounded-2xl border border-dashed p-8 text-muted-foreground">
<FileText className="mb-3 size-6" />
This screen is a placeholder for bundle sales reporting.
</div>
</div>
)
}
@@ -0,0 +1,494 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
type Line = CreateSalesSlipLineRequest & { key: string }
type FreeIssueRow = {
salesSlipId: number
slipNo: string
status: string
etag: string
warehouseName: string
itemName: string
itemSku: string
uomName: string
qty: number
freeQty: number
}
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
export default function NewFreeIssuePage() {
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [rows, setRows] = useState<FreeIssueRow[]>([])
const [editingRowId, setEditingRowId] = useState<number | null>(null)
const [editingLines, setEditingLines] = useState<Line[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function refreshRows() {
const list = await salesApi.listFreeIssues({ pageSize: 50 })
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = items.find((x) => x.itemId === firstLine?.itemId)
const uom = uoms.find((x) => x.uomId === firstLine?.uomId)
const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
slipNo: detail.data.slipNo,
status: detail.data.status,
etag: detail.etag ?? "",
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
}),
)
setRows(details.filter((row) => row.status !== "Cancelled"))
}
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(async ([cust, itemRes, uomRes, whRes, userRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
},
])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function updateEditingLine(key: string, patch: Partial<Line>) {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
}
function selectEditingItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
}
async function submit() {
const activeLines = editingRowId ? editingLines : lines
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.")
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
setSaving(true)
setError(null)
try {
const payload: CreateSalesSlipRequest = {
customerId,
warehouseId,
cashierUserId,
lines: activeLines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId ?? null,
})),
}
if (editingRowId) {
const latest = await salesApi.getFreeIssue(editingRowId)
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
toast.success("Free issue updated")
setEditingRowId(null)
setEditingLines([])
} else {
const created = await salesApi.createFreeIssue(payload)
toast.success("Free issue created", created.data.slipNo)
}
setLines([blankLine("line-1")])
await refreshRows()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
async function startEdit(row: FreeIssueRow) {
try {
const detail = await salesApi.getFreeIssue(row.salesSlipId)
const detailLine = detail.data.lines[0]
setEditingRowId(row.salesSlipId)
setCustomerId(detail.data.customerId)
setWarehouseId(detail.data.warehouseId)
setCashierUserId(detail.data.cashierUserId)
setEditingLines([
{
key: "edit-line-1",
itemId: detailLine?.itemId ?? 0,
uomId: detailLine?.uomId ?? 0,
warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId,
qty: detailLine?.qty ?? 1,
freeQty: detailLine?.freeQty ?? 0,
unitPrice: detailLine?.unitPrice ?? null,
allowManualPriceOverride: true,
discountMode: detailLine?.discountMode ?? "Percentage",
discountPct: detailLine?.discountPct ?? 0,
discountAmount: detailLine?.discountAmount ?? 0,
discountValue: 0,
taxPct: detailLine?.taxPct ?? 0,
isFreeIssue: detailLine?.isFreeIssue ?? false,
parentLineId: detailLine?.parentLineId ?? null,
},
])
} catch (err) {
setError(errorMessage(err))
}
}
function cancelEdit() {
setEditingRowId(null)
setEditingLines([])
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) cancelEdit()
await refreshRows()
} catch (err) {
setError(errorMessage(err))
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
const activeLines = editingRowId ? editingLines : lines
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Free Issues</h1>
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div>
</div>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{editingRowId ? (
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3">
<h2 className="text-sm font-semibold text-sky-900">Inline edit free issue</h2>
<Button type="button" variant="outline" size="sm" onClick={cancelEdit}>
<X className="size-4" /> Cancel edit
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{activeLines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectEditingItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateEditingLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : (
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Free issue lines</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
)}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div>
<Button variant="outline" size="sm" onClick={refreshRows}>
Refresh
</Button>
</div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead>
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-4 text-sm">Product</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length > 0 ? (
rows.map((row) => (
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5">
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell>
<TableCell className="px-4 py-3.5 min-w-80">
<div className="font-medium">{row.itemName}</div>
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.qty}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.freeQty}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => startEdit(row)}
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800"
>
<Pencil className="mr-1 size-3.5" />
Edit
</button>
{row.status === "Draft" ? (
<button
type="button"
onClick={() => deleteRow(row)}
className="inline-flex h-7 items-center rounded-full border border-rose-200 bg-rose-50 px-2 text-xs text-rose-700 hover:bg-rose-100 hover:text-rose-800"
>
<Trash2 className="mr-1 size-3.5" />
Delete
</button>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">
No free issues created yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
}
@@ -0,0 +1,3 @@
"use client"
export { default } from "./new/page"

Some files were not shown because too many files have changed in this diff Show More