Compare commits

..

50 Commits

Author SHA1 Message Date
ImanThiyanga 80213cf47d pushing migration changes from now -on 2026-08-12 12:32:25 +05:30
ImanThiyanga 18475fdc9f Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
2026-08-12 06:28:09 +00:00
ImanThiyanga 9a32c5c609 Merge branch 'Dev' into sales-return 2026-08-12 06:26:49 +00:00
ImanThiyanga 32f40e9d1a Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev
Reviewed-on: #39
2026-08-12 05:32:49 +00:00
ImanThiyanga b2a218e2f8 Merge branch 'Dev' into fix/uom-overall 2026-08-12 05:32:39 +00:00
Sasanka d16a227b54 feat: add sales return functionality
- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
2026-08-12 10:18:56 +05:30
ImanThiyanga a7ba3d3e04 sku and item fix 2026-08-11 21:55:04 +05:30
ImanThiyanga 0ae80395cf Merge pull request 'Refactor production and stock tests to remove UOM dependency' (#38) from fix/uom-overall into Dev
Reviewed-on: #38
2026-08-11 06:03:30 +00:00
ImanThiyanga 15ddac178c Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
2026-08-11 11:32:40 +05:30
ImanThiyanga d37824cecc fronted fix 2026-08-07 16:40:31 +05:30
ImanThiyanga 271c940640 created new hosted db update 2026-08-07 14:54:35 +05:30
ImanThiyanga f2825900aa Merge pull request 'Fix bundle sale service interface signature' (#36) from test/sales into Dev
Reviewed-on: #36
2026-08-07 06:18:32 +00:00
ImanThiyanga 6520930aeb migrations removed 2026-08-07 11:42:07 +05:30
DeepnaPooja a8c6b4cb5e Fix bundle sale service interface signature 2026-08-07 11:41:52 +05:30
ImanThiyanga 8e9974b735 Merge pull request 'fix: update toast messages and improve UI elements in purchase orders and stock pages' (#34) from ui-fixers-7/8 into Dev
Reviewed-on: #34
2026-08-07 05:17:04 +00:00
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
DeepnaPooja cbc72ef830 fix sales issues 2026-08-05 17:03:49 +05:30
ImanThiyanga 4324ba1a96 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-08-05 16:16:50 +05:30
ImanThiyanga 1af16d3dec fix: update DefaultConnection string for development environment 2026-08-05 16:16:15 +05:30
ImanThiyanga f140959b43 Merge pull request 'feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments' (#32) from test/rebase into Dev
Reviewed-on: #32
2026-08-05 08:04:42 +00:00
ImanThiyanga c31e23c2b9 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:26:41 +05:30
ImanThiyanga 8e24ed6375 Merge branch 'production' into Dev 2026-08-05 07:22:25 +00:00
ImanThiyanga 9f22026784 Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev
Reviewed-on: #30
2026-08-05 07:22:12 +00:00
ImanThiyanga ef105302bd Merge pull request 'fix: map GL response integers to string enums for Cheque Management fields' (#29) from fix/general-ledger-v01 into production
Reviewed-on: #29
2026-08-05 07:20:28 +00:00
ImanThiyanga d7ee83828c Merge branch 'production' into fix/general-ledger-v01 2026-08-05 07:20:23 +00:00
HarithaRandunu eb7b2691df fix: map GL response integers to string enums for Cheque Management fields 2026-08-05 12:45:34 +05:30
ImanThiyanga 1a0fb4603e Merge pull request 'Dev' (#28) from Dev into production
Reviewed-on: #28
2026-08-05 06:59:22 +00:00
ImanThiyanga 5fc5ef59ac Merge pull request 'feat: enhance purchase orders management with delete and approve actions' (#27) from fixers-frontend-8/5 into Dev
Reviewed-on: #27
2026-08-05 06:58:46 +00:00
Sasanka d1fe164ea2 fix: resolve import conflict in purchase order detail page 2026-08-05 12:21:01 +05:30
Sasanka 02f47bd485 Merge branch 'fixers-frontend-8/5' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into fixers-frontend-8/5 2026-08-05 12:15:28 +05:30
Sasanka 45554ceb9a feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 12:14:08 +05:30
Sasanka 5d0ea3f035 feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 10:56:05 +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
Kalana B. Thilakarathna a414dfc4ea graphify update 2026-07-31 12:01:42 +05:30
Kalana B. Thilakarathna 3cccaf4c63 graphify added 2026-07-31 11:53:20 +05:30
ImanThiyanga b7bd8dca5c Merge pull request 'Dev' (#20) from Dev into production
Reviewed-on: #20
2026-07-31 05:24:39 +00:00
225 changed files with 22569 additions and 2228 deletions
+7 -9
View File
@@ -30,12 +30,10 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
# migrations add` after the initial 4 silently produced a migration git would
# never see, while the (tracked) snapshot's changes committed normally —
# so the snapshot kept claiming tables existed that no migration in git
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
# tables were missing from the actual database for exactly this reason.
# Migrations now stay tracked like any other source file — commit them.
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
@@ -0,0 +1,80 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
[FromQuery] PageQuery query,
CancellationToken ct)
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
[HttpGet("templates/{bundleSaleTemplateId:int}")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
{
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
{
var result = await _bundles.GetAsync(bundleSaleId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{bundleSaleId:int}/posting-check")]
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
[HttpPost]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
{
var result = await _bundles.CreateAsync(request, ct);
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
}
[HttpPut("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
{
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
}
[HttpPost("{bundleSaleId:int}/post")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
[HttpPost("{bundleSaleId:int}/cancel")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
}
@@ -0,0 +1,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));
}
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -34,6 +34,12 @@ public sealed class SalesInvoicesController : ApiControllerBase
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)
@@ -11,39 +11,25 @@ public sealed class SalesReportsController : ApiControllerBase
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet("daily-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesDailySummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesDailySummaryRowDto>>> DailySummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.DailySummaryAsync(from, to, ct));
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("item-wise")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemSummaryRowDto>>> ItemWise(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? itemId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _reports.ItemSummaryAsync(from, to, itemId, warehouseId, ct));
[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);
}
[HttpGet("customer-wise")]
[ProducesResponseType(typeof(IReadOnlyList<SalesCustomerSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesCustomerSummaryRowDto>>> CustomerWise(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? customerId, CancellationToken ct)
=> Ok(await _reports.CustomerSummaryAsync(from, to, customerId, ct));
[HttpGet("warehouse-wise")]
[ProducesResponseType(typeof(IReadOnlyList<SalesWarehouseSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesWarehouseSummaryRowDto>>> WarehouseWise(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _reports.WarehouseSummaryAsync(from, to, warehouseId, ct));
[HttpGet("discount-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesDiscountSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesDiscountSummaryRowDto>>> DiscountSummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.DiscountSummaryAsync(from, to, ct));
[HttpGet("free-issue-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesFreeIssueSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesFreeIssueSummaryRowDto>>> FreeIssueSummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.FreeIssueSummaryAsync(from, to, ct));
[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,49 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
[HttpGet("remaining")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (inbound movement).</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
}
}
@@ -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);
}
}
@@ -34,6 +34,12 @@ public sealed class SalesSlipsController : ApiControllerBase
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)
+2
View File
@@ -19,4 +19,6 @@ public static class DocumentTypes
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string SalesReturn = "SRET";
}
@@ -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,22 @@
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 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,18 @@
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 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; }
}
@@ -25,9 +25,6 @@ public class GrnLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
+34 -1
View File
@@ -24,6 +24,12 @@ public class Item
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
/// <summary>
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
/// and every document line. Nothing converts, so this is the sole meaning of a
/// quantity and cannot be changed once the item has stock history.
/// </summary>
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
@@ -41,6 +47,34 @@ public class Item
/// </summary>
public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
/// for a 1.5 L one. Null (together with the other three) when the item has no
/// measurable content: a screw, a label, a service.
/// <para>
/// Content never affects stock — that is always a pack count. It exists so production
/// can express a formula in millilitres or grams and resolve it to packs
/// (see <c>IItemMeasure</c>).
/// </para>
/// <para>
/// A loose bulk item bought by weight is modelled the same way:
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
/// </para>
/// </summary>
public decimal? ContentQty { get; set; }
public MeasureUnit? ContentUnit { get; set; }
/// <summary>
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
/// </summary>
public decimal? ContentBaseQty { get; set; }
public MeasureUnit? ContentBaseUnit { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -50,5 +84,4 @@ public class Item
public uint RowVersion { get; set; }
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
@@ -21,6 +24,24 @@ public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// A client hint only: the server never reads it when writing an item. Each item's pair is
/// still validated and normalised on its own by <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -15,9 +15,6 @@ public class PoLine
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; }
@@ -31,10 +31,14 @@ public class RunStageInput
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
/// consumption figures below it is <b>not</b> necessarily a pack count.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>
@@ -23,10 +23,17 @@ public class RunStageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
@@ -16,8 +16,6 @@ public class SalesInvoiceLine
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; }
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales return header — a customer returns previously sold goods, generating an
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
/// <see cref="PurchaseReturn"/> with the direction reversed.
/// </summary>
public class SalesReturn
{
public int ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM.
/// </summary>
public class SalesReturnLine
{
public int ReturnLineId { get; set; }
public int ReturnId { get; set; }
public SalesReturn? Return { get; set; }
public int? SalesInvoiceLineId { get; set; }
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
@@ -16,8 +16,6 @@ public class SalesSlipLine
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; }
@@ -32,8 +32,13 @@ public class StageInput
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
/// source output declares.
/// </summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
public decimal QtyPerBatch { get; set; }
}
@@ -20,8 +20,14 @@ public class StageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
/// is null and must be null when it is set — a real item's unit is its own base UOM.
/// WIP never touches stock or the ledger, so this is never converted, only shown.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
public decimal QtyPerBatch { get; set; }
}
+4 -2
View File
@@ -1,8 +1,10 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
/// quantity in the system counts — and as the display label on an intermediate production
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Uom
{
@@ -1,22 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class UomConversion
{
public int ConversionId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int FromUomId { get; set; }
public Uom? FromUom { get; set; }
public int ToUomId { get; set; }
public Uom? ToUom { get; set; }
public decimal Factor { get; set; }
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum BundleSaleStatus
{
Draft = 0,
Posted = 1,
Cancelled = 2
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
/// <para>
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
/// production to turn "2000 ml of syrup" into a pack count.
/// </para>
/// <para>
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
/// </para>
/// </summary>
public enum MeasureUnit
{
Ml,
L,
G,
Kg
}
@@ -3,5 +3,5 @@ namespace ERPCore.Domain.Enums;
public enum SalesDiscountMode
{
Percentage = 1,
FixedAmount = 2
Amount = 2
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
/// <para>
/// Deliberately explicit rather than inferred from whether the item happens to have a
/// content size: templates outlive item edits, so an inferred unit would let adding a
/// content size to an existing item silently reinterpret every saved formula — "300"
/// meaning 300 packs would become 300 ml.
/// </para>
/// </summary>
public enum StageQtyUnit
{
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
Pack,
/// <summary>
/// An amount of the item's content in its base content unit (ml or g), resolved to
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
/// </summary>
Content
}
+1 -2
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
@@ -44,7 +44,6 @@ public sealed class CreateGrnLineInput
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
public int? PoLineId { get; set; }
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
+17 -3
View File
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
/// client-generated SKU rather than stored (docs/10 Part C.9).
/// <para>
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
/// rather than plain labels; the builder captures a number + unit per value and writes it to
/// each generated item's content size.
/// </para>
/// </summary>
public sealed record ItemTypeDto(
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
public sealed class CreateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
public bool IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
/// client that PUT only a name — as the item-types screen used to — would silently clear the
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
/// </summary>
public bool? IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeStatusRequest
+27 -25
View File
@@ -9,7 +9,10 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status);
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -17,26 +20,21 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> are echoed back so a detail screen can show
/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are
/// not accepted on write.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
@@ -64,6 +62,14 @@ public sealed class CreateItemRequest
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemRequest
@@ -83,6 +89,14 @@ public sealed class UpdateItemRequest
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemStatusRequest
@@ -101,15 +115,3 @@ public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
}
public sealed class UomConversionInput
{
[Required] public int FromUom { get; set; }
[Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
public sealed class UpdateUomConversionsRequest
{
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
}
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto(
public sealed class CreatePoLineInput
{
[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; }
+9 -3
View File
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
/// <summary>
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — content
/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are
/// not directly comparable for a Content input.
/// </summary>
public sealed record RunStageInputDto(
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit,
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
/// the terminal output, whose unit is the finished item's base UOM.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
int RunOutputId, int? ItemId, string Name, int? UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
@@ -35,10 +35,11 @@ public sealed record FieldDefDto(
public sealed record StageInputDto(
int InputId, StageInputSource Source, int? ItemId,
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch);
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
public sealed record StageOutputDto(
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch);
public sealed record TemplateStageDto(
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
@@ -134,8 +135,12 @@ public sealed class SaveInputRequest
[StringLength(60)]
public string? FromOutputKey { get; set; }
[Range(1, int.MaxValue)]
public int UomId { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
/// </summary>
[EnumDataType(typeof(StageQtyUnit))]
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
@@ -153,8 +158,12 @@ public sealed class SaveOutputRequest
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
/// is set, because a real item's unit is its own base UOM.
/// </summary>
[Range(1, int.MaxValue)]
public int UomId { get; set; }
public int? UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
@@ -0,0 +1,82 @@
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 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 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 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();
}
@@ -4,7 +4,7 @@ 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 SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
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);
@@ -24,10 +24,17 @@ public sealed record SalesInvoiceSummaryDto(
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; }
@@ -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,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,43 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
// Responses -----------------------------------------------------------------
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /sales-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record SalesReturnSummaryDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
/// <summary>
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
/// whatever has already been returned against it. The invoice line's own <c>Qty</c>
/// is never mutated by a return, so this is computed on read from return history.
/// </summary>
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
// Requests --------------------------------------------------------------------
public sealed class CreateSalesReturnLineInput
{
/// <summary>Original sales invoice line, for traceability against the sale.</summary>
public int? SalesInvoiceLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateSalesReturnRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
}
+20 -2
View File
@@ -4,7 +4,7 @@ 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 SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
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);
@@ -23,10 +23,28 @@ public sealed record SalesSlipSummaryDto(
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,
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; }
@@ -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,23 @@
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.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,20 @@
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.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -49,7 +49,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
@@ -22,6 +22,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived
// server-side by ItemContent.Normalize and is only ever Ml or G.
builder.Property(i => i.ContentQty).HasPrecision(18, 4);
builder.Property(i => i.ContentBaseQty).HasPrecision(18, 4);
builder.Property(i => i.ContentUnit)
.HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.ContentBaseUnit)
.HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(t => t.Name).IsUnique();
// false is the only safe default here: EF uses the CLR default as its "unset" sentinel,
// so if the store default were true, inserting an explicit false would be mistaken for
// "not set" and silently written as true. Sentinel and store default must agree.
builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
@@ -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 }
);
}
}
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromOutput).WithMany()
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
}
@@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
@@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromRunOutput).WithMany()
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
@@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom)
.WithMany()
.HasForeignKey(l => l.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse)
.WithMany()
.HasForeignKey(l => l.WarehouseId)
@@ -79,11 +79,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
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)
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesReturn>
{
public void Configure(EntityTypeBuilder<SalesReturn> builder)
{
builder.ToTable("sales_returns");
builder.HasKey(r => r.ReturnId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<SalesReturnLine>
{
public void Configure(EntityTypeBuilder<SalesReturnLine> builder)
{
builder.ToTable("sales_return_lines");
builder.HasKey(l => l.ReturnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
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)
@@ -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,34 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomConversion>
{
public void Configure(EntityTypeBuilder<UomConversion> builder)
{
builder.ToTable("uom_conversions");
builder.HasKey(c => c.ConversionId);
builder.Property(c => c.Factor).HasPrecision(18, 6);
builder.HasOne(c => c.Item)
.WithMany(i => i.UomConversions)
.HasForeignKey(c => c.ItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.FromUom)
.WithMany()
.HasForeignKey(c => c.FromUomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.ToUom)
.WithMany()
.HasForeignKey(c => c.ToUomId)
.OnDelete(DeleteBehavior.Restrict);
// One conversion per (item, from, to) triple.
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
}
}
@@ -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,747 @@ 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 (500 ml)",
Description = "Secondary seeded sample item; carries a content size so the "
+ "production content-unit path has a fixture",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
ContentBaseUnit = MeasureUnit.Ml,
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,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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)
{
@@ -29,12 +30,12 @@ public class ErpDbContext : DbContext
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
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>();
@@ -88,6 +89,12 @@ public class ErpDbContext : DbContext
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>();
public DbSet<SalesReturn> SalesReturns => Set<SalesReturn>();
public DbSet<SalesReturnLine> SalesReturnLines => Set<SalesReturnLine>();
// --- Reference data (docs/10 Part C.7) ---
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
@@ -185,24 +192,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;
}
@@ -9,11 +9,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260801025920_InitialCreate")]
partial class InitialCreate
[Migration("20260811053751_initial")]
partial class initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -368,6 +368,259 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("brands", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Property<int>("BundleSaleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
b.Property<string>("BundleCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("BundleDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("BundleName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BundleNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal>("BundlePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<int>("CashierUserId")
.HasColumnType("integer");
b.Property<decimal>("ComponentSubtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("CustomerSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("MarginAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("TaxTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleId");
b.HasIndex("BundleNo")
.IsUnique();
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("CashierUserId");
b.HasIndex("CustomerId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sales", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.Property<int>("BundleSaleLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
b.Property<int>("BundleSaleId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IncludeInBundle")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsComponent")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("ParentLineId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleLineId");
b.HasIndex("BundleSaleId");
b.HasIndex("ItemId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Property<int>("BundleSaleTemplateId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TemplateCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TemplateName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("BundleSaleTemplateId");
b.HasIndex("TemplateCode")
.IsUnique();
b.ToTable("bundle_sale_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.Property<int>("BundleSaleTemplateLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<bool>("IncludeInBundle")
.HasColumnType("boolean");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleTemplateLineId");
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("ItemId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_template_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<int>("CategoryId")
@@ -1264,9 +1517,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<decimal>("VatAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -1287,8 +1537,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("PoLineId");
b.HasIndex("UomId");
b.ToTable("grn_lines", (string)null);
});
@@ -1367,6 +1615,22 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<decimal?>("ContentBaseQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("ContentBaseUnit")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<decimal?>("ContentQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("ContentUnit")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1953,6 +2217,15 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Accounts",
SortOrder = 12,
Status = "Active"
},
new
{
NavItemId = 13,
Code = "sales",
Href = "/dashboard/sales",
Label = "Sales",
SortOrder = 13,
Status = "Active"
});
});
@@ -2532,9 +2805,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -2544,8 +2814,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("PoId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("po_lines", (string)null);
@@ -3261,6 +3529,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("QtyUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<decimal>("ReturnedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -3277,9 +3550,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.HasKey("RunInputId");
b.HasIndex("FromRunOutputId");
@@ -3288,8 +3558,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("RunStageId");
b.HasIndex("UomId");
b.ToTable("run_stage_inputs", (string)null);
});
@@ -3331,7 +3599,7 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
b.Property<int?>("UomId")
.HasColumnType("integer");
b.HasKey("RunOutputId");
@@ -3597,9 +3865,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -3609,8 +3874,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("SalesInvoiceId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("sales_invoice_lines", (string)null);
@@ -3786,9 +4049,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -3798,8 +4058,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("SalesSlipId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("sales_slip_lines", (string)null);
@@ -3881,6 +4139,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("QtyUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
@@ -3889,9 +4152,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("UomId")
.HasColumnType("integer");
b.HasKey("InputId");
b.HasIndex("FromOutputId");
@@ -3900,8 +4160,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("StageId");
b.HasIndex("UomId");
b.ToTable("stage_inputs", (string)null);
});
@@ -3928,7 +4186,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("UomId")
b.Property<int?>("UomId")
.HasColumnType("integer");
b.HasKey("OutputId");
@@ -4540,6 +4798,16 @@ namespace ERPCore.Infra.Persistence.Migrations
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -4786,39 +5054,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<int>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int>("FromUomId")
.HasColumnType("integer");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<int>("ToUomId")
.HasColumnType("integer");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.Property<int>("UserId")
@@ -5152,6 +5387,95 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany()
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
.WithMany()
.HasForeignKey("CashierUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("CashierUser");
b.Navigation("Customer");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
.WithMany("Lines")
.HasForeignKey("BundleSaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSale");
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany("Lines")
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
@@ -5376,12 +5700,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasForeignKey("PoLineId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
b.Navigation("Bin");
@@ -5391,8 +5709,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Item");
b.Navigation("PoLine");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
@@ -5599,12 +5915,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -5615,8 +5925,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("PurchaseOrder");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -5937,19 +6245,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromRunOutput");
b.Navigation("Item");
b.Navigation("RunStage");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b =>
@@ -5973,8 +6273,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
@@ -6024,12 +6323,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -6040,8 +6333,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("SalesInvoice");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -6086,12 +6377,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -6102,8 +6387,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("SalesSlip");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -6163,19 +6446,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromOutput");
b.Navigation("Item");
b.Navigation("Stage");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b =>
@@ -6194,8 +6469,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
@@ -6483,33 +6757,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Template");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
@@ -6558,6 +6805,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("SubCategories");
@@ -6581,8 +6838,6 @@ namespace ERPCore.Infra.Persistence.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
@@ -6,10 +6,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
public partial class initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -31,6 +31,25 @@ namespace ERPCore.Infra.Persistence.Migrations
table.PrimaryKey("PK_brands", x => x.BrandId);
});
migrationBuilder.CreateTable(
name: "bundle_sale_templates",
columns: table => new
{
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
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),
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId);
});
migrationBuilder.CreateTable(
name: "categories",
columns: table => new
@@ -592,6 +611,10 @@ namespace ERPCore.Infra.Persistence.Migrations
TrackingMode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TaxClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
SalePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
ContentQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
ContentUnit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
ContentBaseQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
ContentBaseUnit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
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),
@@ -903,6 +926,61 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sales",
columns: table => new
{
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ConcurrencyStamp = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
table.ForeignKey(
name: "FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId",
column: x => x.BundleSaleTemplateId,
principalTable: "bundle_sale_templates",
principalColumn: "BundleSaleTemplateId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_users_CashierUserId",
column: x => x.CashierUserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sales_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_invoices",
columns: table => new
@@ -1020,6 +1098,43 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "bundle_sale_template_lines",
columns: table => new
{
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_bundle_sale_templates_BundleSale~",
column: x => x.BundleSaleTemplateId,
principalTable: "bundle_sale_templates",
principalColumn: "BundleSaleTemplateId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_template_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "item_reorders",
columns: table => new
@@ -1069,40 +1184,6 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "uom_conversions",
columns: table => new
{
ConversionId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<int>(type: "integer", nullable: false),
FromUomId = table.Column<int>(type: "integer", nullable: false),
ToUomId = table.Column<int>(type: "integer", nullable: false),
Factor = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_uom_conversions", x => x.ConversionId);
table.ForeignKey(
name: "FK_uom_conversions_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_uom_conversions_uoms_FromUomId",
column: x => x.FromUomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_uom_conversions_uoms_ToUomId",
column: x => x.ToUomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "role_permissions",
columns: table => new
@@ -1332,6 +1413,46 @@ namespace ERPCore.Infra.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "bundle_sale_lines",
columns: table => new
{
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ParentLineId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
table.ForeignKey(
name: "FK_bundle_sale_lines_bundle_sales_BundleSaleId",
column: x => x.BundleSaleId,
principalTable: "bundle_sales",
principalColumn: "BundleSaleId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_bundle_sale_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_bundle_sale_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_invoice_lines",
columns: table => new
@@ -1343,7 +1464,6 @@ namespace ERPCore.Infra.Persistence.Migrations
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
FreeQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BaseCost = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -1374,12 +1494,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "sales_invoices",
principalColumn: "SalesInvoiceId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_invoice_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_invoice_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
@@ -1399,7 +1513,6 @@ namespace ERPCore.Infra.Persistence.Migrations
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
FreeQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BaseCost = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -1430,12 +1543,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "sales_slips",
principalColumn: "SalesSlipId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_slip_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_slip_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
@@ -1690,7 +1797,7 @@ namespace ERPCore.Infra.Persistence.Migrations
StageId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: true),
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: true),
QtyPerBatch = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
@@ -1769,7 +1876,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PoId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -1791,12 +1897,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "purchase_orders",
principalColumn: "PoId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_po_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_po_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
@@ -1938,7 +2038,7 @@ namespace ERPCore.Infra.Persistence.Migrations
RunStageId = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: true),
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: true),
PlannedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ProducedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ScrappedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -1984,7 +2084,7 @@ namespace ERPCore.Infra.Persistence.Migrations
Source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: true),
FromOutputId = table.Column<int>(type: "integer", nullable: true),
UomId = table.Column<int>(type: "integer", nullable: false),
QtyUnit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
QtyPerBatch = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
@@ -2008,12 +2108,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "template_stages",
principalColumn: "StageId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_stage_inputs_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
@@ -2025,7 +2119,6 @@ namespace ERPCore.Infra.Persistence.Migrations
GrnId = table.Column<int>(type: "integer", nullable: false),
PoLineId = table.Column<int>(type: "integer", nullable: true),
ItemId = table.Column<int>(type: "integer", nullable: false),
UomId = table.Column<int>(type: "integer", nullable: false),
BinId = table.Column<int>(type: "integer", nullable: true),
BatchId = table.Column<int>(type: "integer", nullable: true),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -2072,12 +2165,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "po_lines",
principalColumn: "PoLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
@@ -2118,7 +2205,7 @@ namespace ERPCore.Infra.Persistence.Migrations
Source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ItemId = table.Column<int>(type: "integer", nullable: true),
FromRunOutputId = table.Column<int>(type: "integer", nullable: true),
UomId = table.Column<int>(type: "integer", nullable: false),
QtyUnit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
PlannedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ConsumedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ConsumedValue = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
@@ -2147,12 +2234,6 @@ namespace ERPCore.Infra.Persistence.Migrations
principalTable: "run_stages",
principalColumn: "RunStageId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_run_stage_inputs_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
@@ -2782,7 +2863,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 },
{ 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 },
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 }
{ 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 },
{ 13, "sales", "/dashboard/sales", null, "Sales", 13 }
});
migrationBuilder.InsertData(
@@ -2835,7 +2917,8 @@ namespace ERPCore.Infra.Persistence.Migrations
{ 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 },
{ 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 }
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 },
{ 23, "sales.bundle-sales", "/dashboard/sales/bundles", null, "Bundle Sales", 13, 1 }
});
migrationBuilder.InsertData(
@@ -2905,6 +2988,68 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "brands",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_BundleSaleId",
table: "bundle_sale_lines",
column: "BundleSaleId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_ItemId",
table: "bundle_sale_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_lines_WarehouseId",
table: "bundle_sale_lines",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_BundleSaleTemplateId",
table: "bundle_sale_template_lines",
column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_ItemId",
table: "bundle_sale_template_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_template_lines_WarehouseId",
table: "bundle_sale_template_lines",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sale_templates_TemplateCode",
table: "bundle_sale_templates",
column: "TemplateCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_BundleNo",
table: "bundle_sales",
column: "BundleNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_BundleSaleTemplateId",
table: "bundle_sales",
column: "BundleSaleTemplateId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_CashierUserId",
table: "bundle_sales",
column: "CashierUserId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_CustomerId",
table: "bundle_sales",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_WarehouseId",
table: "bundle_sales",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_categories_Name",
table: "categories",
@@ -2962,11 +3107,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "grn_lines",
column: "PoLineId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_UomId",
table: "grn_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_grns_CreatedBy",
table: "grns",
@@ -3446,11 +3586,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "po_lines",
column: "PoId");
migrationBuilder.CreateIndex(
name: "IX_po_lines_UomId",
table: "po_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_po_lines_WarehouseId",
table: "po_lines",
@@ -3696,11 +3831,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "run_stage_inputs",
column: "RunStageId");
migrationBuilder.CreateIndex(
name: "IX_run_stage_inputs_UomId",
table: "run_stage_inputs",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_run_stage_outputs_ItemId",
table: "run_stage_outputs",
@@ -3741,11 +3871,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "sales_invoice_lines",
column: "SalesInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_lines_UomId",
table: "sales_invoice_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_lines_WarehouseId",
table: "sales_invoice_lines",
@@ -3792,11 +3917,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "sales_slip_lines",
column: "SalesSlipId");
migrationBuilder.CreateIndex(
name: "IX_sales_slip_lines_UomId",
table: "sales_slip_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_sales_slip_lines_WarehouseId",
table: "sales_slip_lines",
@@ -3870,11 +3990,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "stage_inputs",
column: "StageId");
migrationBuilder.CreateIndex(
name: "IX_stage_inputs_UomId",
table: "stage_inputs",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_stage_outputs_ItemId",
table: "stage_outputs",
@@ -4120,22 +4235,6 @@ namespace ERPCore.Infra.Persistence.Migrations
table: "template_stages",
column: "TemplateId");
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_FromUomId",
table: "uom_conversions",
column: "FromUomId");
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_ItemId_FromUomId_ToUomId",
table: "uom_conversions",
columns: new[] { "ItemId", "FromUomId", "ToUomId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_uom_conversions_ToUomId",
table: "uom_conversions",
column: "ToUomId");
migrationBuilder.CreateIndex(
name: "IX_uoms_Name",
table: "uoms",
@@ -4234,6 +4333,12 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "audit_logs");
migrationBuilder.DropTable(
name: "bundle_sale_lines");
migrationBuilder.DropTable(
name: "bundle_sale_template_lines");
migrationBuilder.DropTable(
name: "hr_attendance_records");
@@ -4331,10 +4436,10 @@ namespace ERPCore.Infra.Persistence.Migrations
name: "stock_transfer_lines");
migrationBuilder.DropTable(
name: "uom_conversions");
name: "vendor_quotation_lines");
migrationBuilder.DropTable(
name: "vendor_quotation_lines");
name: "bundle_sales");
migrationBuilder.DropTable(
name: "hr_attendance_upload_batches");
@@ -4393,6 +4498,9 @@ namespace ERPCore.Infra.Persistence.Migrations
migrationBuilder.DropTable(
name: "vendor_quotations");
migrationBuilder.DropTable(
name: "bundle_sale_templates");
migrationBuilder.DropTable(
name: "hr_payroll_runs");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTypeIsMeasurable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMeasurable",
table: "item_types",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsMeasurable",
table: "item_types");
}
}
}
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
partial class ErpDbContextModelSnapshot : ModelSnapshot
@@ -365,6 +365,259 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("brands", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Property<int>("BundleSaleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleId"));
b.Property<string>("BundleCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("BundleDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("BundleName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BundleNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal>("BundlePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<int>("CashierUserId")
.HasColumnType("integer");
b.Property<decimal>("ComponentSubtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("CustomerSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("MarginAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("TaxTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleId");
b.HasIndex("BundleNo")
.IsUnique();
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("CashierUserId");
b.HasIndex("CustomerId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sales", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.Property<int>("BundleSaleLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleLineId"));
b.Property<int>("BundleSaleId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IncludeInBundle")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsComponent")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("ParentLineId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleLineId");
b.HasIndex("BundleSaleId");
b.HasIndex("ItemId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Property<int>("BundleSaleTemplateId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<int>("ConcurrencyStamp")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("TemplateCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TemplateName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("BundleSaleTemplateId");
b.HasIndex("TemplateCode")
.IsUnique();
b.ToTable("bundle_sale_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.Property<int>("BundleSaleTemplateLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BundleSaleTemplateLineId"));
b.Property<int>("BundleSaleTemplateId")
.HasColumnType("integer");
b.Property<bool>("IncludeInBundle")
.HasColumnType("boolean");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("BundleSaleTemplateLineId");
b.HasIndex("BundleSaleTemplateId");
b.HasIndex("ItemId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sale_template_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<int>("CategoryId")
@@ -1261,9 +1514,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<decimal>("VatAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -1284,8 +1534,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("PoLineId");
b.HasIndex("UomId");
b.ToTable("grn_lines", (string)null);
});
@@ -1364,6 +1612,22 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<decimal?>("ContentBaseQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("ContentBaseUnit")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<decimal?>("ContentQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("ContentUnit")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1484,6 +1748,11 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsMeasurable")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -1950,6 +2219,15 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Accounts",
SortOrder = 12,
Status = "Active"
},
new
{
NavItemId = 13,
Code = "sales",
Href = "/dashboard/sales",
Label = "Sales",
SortOrder = 13,
Status = "Active"
});
});
@@ -2529,9 +2807,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -2541,8 +2816,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("PoId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("po_lines", (string)null);
@@ -3258,6 +3531,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("QtyUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<decimal>("ReturnedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -3274,9 +3552,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.HasKey("RunInputId");
b.HasIndex("FromRunOutputId");
@@ -3285,8 +3560,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("RunStageId");
b.HasIndex("UomId");
b.ToTable("run_stage_inputs", (string)null);
});
@@ -3328,7 +3601,7 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
b.Property<int?>("UomId")
.HasColumnType("integer");
b.HasKey("RunOutputId");
@@ -3594,9 +3867,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -3606,8 +3876,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("SalesInvoiceId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("sales_invoice_lines", (string)null);
@@ -3783,9 +4051,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
@@ -3795,8 +4060,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("SalesSlipId");
b.HasIndex("UomId");
b.HasIndex("WarehouseId");
b.ToTable("sales_slip_lines", (string)null);
@@ -3878,6 +4141,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("QtyUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
@@ -3886,9 +4154,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("UomId")
.HasColumnType("integer");
b.HasKey("InputId");
b.HasIndex("FromOutputId");
@@ -3897,8 +4162,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("StageId");
b.HasIndex("UomId");
b.ToTable("stage_inputs", (string)null);
});
@@ -3925,7 +4188,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("UomId")
b.Property<int?>("UomId")
.HasColumnType("integer");
b.HasKey("OutputId");
@@ -4537,6 +4800,16 @@ namespace ERPCore.Infra.Persistence.Migrations
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -4783,39 +5056,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("uoms", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.Property<int>("ConversionId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ConversionId"));
b.Property<decimal>("Factor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int>("FromUomId")
.HasColumnType("integer");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<int>("ToUomId")
.HasColumnType("integer");
b.HasKey("ConversionId");
b.HasIndex("FromUomId");
b.HasIndex("ToUomId");
b.HasIndex("ItemId", "FromUomId", "ToUomId")
.IsUnique();
b.ToTable("uom_conversions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.Property<int>("UserId")
@@ -5149,6 +5389,95 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany()
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
.WithMany()
.HasForeignKey("CashierUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("CashierUser");
b.Navigation("Customer");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale")
.WithMany("Lines")
.HasForeignKey("BundleSaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSale");
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
.WithMany("Lines")
.HasForeignKey("BundleSaleTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("BundleSaleTemplate");
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
@@ -5373,12 +5702,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasForeignKey("PoLineId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
b.Navigation("Bin");
@@ -5388,8 +5711,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Item");
b.Navigation("PoLine");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
@@ -5596,12 +5917,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -5612,8 +5927,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("PurchaseOrder");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -5934,19 +6247,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromRunOutput");
b.Navigation("Item");
b.Navigation("RunStage");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b =>
@@ -5970,8 +6275,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
@@ -6021,12 +6325,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -6037,8 +6335,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("SalesInvoice");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -6083,12 +6379,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -6099,8 +6389,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("SalesSlip");
b.Navigation("Uom");
b.Navigation("Warehouse");
});
@@ -6160,19 +6448,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromOutput");
b.Navigation("Item");
b.Navigation("Stage");
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b =>
@@ -6191,8 +6471,7 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
.WithMany()
.HasForeignKey("UomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
@@ -6480,33 +6759,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Template");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
.WithMany()
.HasForeignKey("FromUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany("UomConversions")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom")
.WithMany()
.HasForeignKey("ToUomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FromUom");
b.Navigation("Item");
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
@@ -6555,6 +6807,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("SubCategories");
@@ -6578,8 +6840,6 @@ namespace ERPCore.Infra.Persistence.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
+26 -4
View File
@@ -11,13 +11,14 @@ 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);
@@ -33,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();
@@ -79,6 +83,7 @@ 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>();
@@ -94,16 +99,23 @@ builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
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>();
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
// Stock transactions + reference data (docs/11 §56)
builder.Services.AddScoped<IStockMutator, StockMutator>();
@@ -179,7 +191,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();
@@ -196,3 +217,4 @@ app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,247 @@
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<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<Warehouse> warehouses,
IRepository<User> users,
ISalesDomainService sales,
ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers,
IUnitOfWork uow)
{
_templates = templates;
_bundles = bundles;
_customers = customers;
_items = items;
_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.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
return bundle is null ? null : Map(bundle);
}
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
=> _posting.CheckBundleAsync(bundleSaleId, ct);
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
var bundle = new BundleSale
{
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
BundleDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
CashierUserId = request.CashierUserId,
BundleSaleTemplateId = request.BundleSaleTemplateId,
BundleName = request.BundleName,
BundleCode = string.Empty,
Status = BundleSaleStatus.Draft,
CreatedAt = DateTime.UtcNow
};
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
Recalculate(bundle, request.BundlePrice);
bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct);
bundle.ConcurrencyStamp = 1;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
bundle.CustomerId = request.CustomerId;
bundle.WarehouseId = request.WarehouseId;
bundle.CashierUserId = request.CashierUserId;
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
bundle.BundleName = request.BundleName;
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice);
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
{
await _posting.PostBundleAsync(bundleSaleId, ct);
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
return Map(bundle);
}
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
{
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
bundle.Status = BundleSaleStatus.Cancelled;
bundle.UpdatedAt = DateTime.UtcNow;
bundle.ConcurrencyStamp++;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
}
private async Task<List<BundleSaleLine>> BuildLinesAsync(
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
{
var lines = new List<BundleSaleLine>();
var sourceLines = requestLines.Count > 0
? requestLines.OrderBy(x => x.SortOrder).ToList()
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
{
ItemId = x.ItemId,
WarehouseId = x.WarehouseId,
Qty = x.Qty,
UnitPrice = x.UnitPrice,
IncludeInBundle = x.IncludeInBundle,
SortOrder = x.SortOrder
}).ToList();
foreach (var r in sourceLines)
{
if (r.Qty <= 0)
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
// Keep any per-line warehouse input from drifting away from the header.
var lineWarehouseId = warehouseId;
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct);
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
WarehouseId = lineWarehouseId,
UnitPrice = r.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.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
}
+9 -22
View File
@@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IUomConverter _uomConverter;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -43,23 +41,21 @@ public sealed class GrnService : IGrnService
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
_pos = pos;
_poLines = poLines;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_bins = bins;
_vendors = vendors;
_batches = batches;
_layers = layers;
_ledger = ledger;
_uomConverter = uomConverter;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
@@ -133,8 +129,6 @@ public sealed class GrnService : IGrnService
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
@@ -150,6 +144,8 @@ public sealed class GrnService : IGrnService
if (poLine.ItemId != input.ItemId)
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
// Both sides are counts of the item's base UOM — the GRN line no longer carries
// a unit of its own — so this comparison and the accrual below are like-for-like.
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
@@ -174,7 +170,6 @@ public sealed class GrnService : IGrnService
{
PoLineId = input.PoLineId,
ItemId = input.ItemId,
UomId = input.UomId,
BinId = input.BinId,
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
@@ -234,10 +229,11 @@ public sealed class GrnService : IGrnService
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
// enters stock value (docs/10 FR-GRN-06, revised). The line quantity is already
// a count of the item's base UOM, so it layers exactly as entered.
var qtyBase = line.Qty;
var unitCostBase = line.NetUnitCost;
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -345,15 +341,6 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
/// <summary>
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
/// identical, so receive costing is unchanged.
/// </summary>
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
@@ -392,7 +379,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId)).ToList());
@@ -0,0 +1,19 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface IBundleSaleService
{
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
}
@@ -0,0 +1,25 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Resolves a production formula quantity to the only unit stock speaks: a count of the
/// item's base UOM. Replaces the old per-item UOM conversion table — everything needed is
/// on the item row, so this does no I/O.
/// </summary>
public interface IItemMeasure
{
/// <summary>
/// Formula quantity → packs. <see cref="StageQtyUnit.Pack"/> passes straight through;
/// <see cref="StageQtyUnit.Content"/> divides by the item's content size, so 2000 ml of
/// a 500 ml bottle is 4 bottles and 300 ml is 0.6 of one.
/// </summary>
decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit);
/// <summary>Packs → formula quantity. The exact inverse of <see cref="ToPacks"/>, for display.</summary>
decimal FromPacks(Item item, decimal packs, StageQtyUnit unit);
/// <summary>Whether the item carries a usable content size.</summary>
bool HasContent(Item item);
}
@@ -24,6 +24,4 @@ public interface IItemService
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, 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,50 @@
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 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);
@@ -9,6 +9,7 @@ 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);
@@ -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,8 @@
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface ISalesPromotionSuggestionService
{
Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default);
}
@@ -4,6 +4,9 @@ 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);
@@ -0,0 +1,18 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales-return business logic — customer returns, mirroring purchase-return logic reversed.</summary>
public interface ISalesReturnService
{
Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default);
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
}
@@ -9,6 +9,9 @@ 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);
@@ -1,35 +0,0 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
/// </summary>
/// <remarks>
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
/// one place that bridges the two.</para>
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
/// mis-cost the run).</para>
/// </remarks>
public interface IUomConverter
{
/// <summary>
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
/// conversion is defined for the item from that UOM to its base.
/// </summary>
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
/// <summary>
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
/// production stage input declares a quantity; its cost comes from the FIFO layers it
/// consumes, not from the document).
/// </summary>
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
}
+61
View File
@@ -0,0 +1,61 @@
using ERPCore.Domain.Enums;
using ERPCore.System.Errors;
namespace ERPCore.Services;
/// <summary>
/// Item content-size rules: validate the (qty, unit) pair and normalise it to a base
/// unit. Pure — no DI, no database — because everything it needs is on the item row.
/// <para>
/// Litres and kilograms exist only at the point of entry. Everything stored and every
/// downstream calculation works in millilitres or grams, so no consumer ever has to ask
/// which unit it is holding.
/// </para>
/// </summary>
public static class ItemContent
{
/// <summary>
/// Rejects a half-filled pair. Both null is valid and means "this item has no
/// measurable content" — a screw, a label, a service.
/// </summary>
public static void ValidatePair(decimal? contentQty, MeasureUnit? contentUnit)
{
if (contentQty is null && contentUnit is null) return;
if (contentQty is null || contentUnit is null)
throw new DomainException(
ErrorCodes.Validation,
"contentQty and contentUnit must be supplied together, or both left null.", 422);
if (contentQty <= 0)
throw new DomainException(
ErrorCodes.Validation, "contentQty must be greater than zero.", 422);
}
/// <summary>
/// Converts an entered content size to its base unit: L → Ml and Kg → G, both ×1000;
/// Ml and G pass through. Both-null in, both-null out.
/// <para>
/// Rounded to 4dp AwayFromZero to match the quantity columns' <c>(18,4)</c> scale and
/// <c>ProductionRunService.Scale</c>, so a content size can never carry precision the
/// database would silently drop.
/// </para>
/// </summary>
public static (decimal? BaseQty, MeasureUnit? BaseUnit) Normalize(
decimal? contentQty, MeasureUnit? contentUnit)
{
if (contentQty is null || contentUnit is null) return (null, null);
var (factor, baseUnit) = contentUnit.Value switch
{
MeasureUnit.Ml => (1m, MeasureUnit.Ml),
MeasureUnit.L => (1000m, MeasureUnit.Ml),
MeasureUnit.G => (1m, MeasureUnit.G),
MeasureUnit.Kg => (1000m, MeasureUnit.G),
_ => throw new DomainException(
ErrorCodes.Validation, $"Unsupported content unit '{contentUnit}'.", 422)
};
return (Math.Round(contentQty.Value * factor, 4, MidpointRounding.AwayFromZero), baseUnit);
}
}
+43 -54
View File
@@ -31,6 +31,8 @@ public sealed class ItemService : IItemService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IProductConfigService _config;
private readonly IUnitOfWork _uow;
@@ -42,6 +44,8 @@ public sealed class ItemService : IItemService
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IProductConfigService config,
IUnitOfWork uow)
{
@@ -52,6 +56,8 @@ public sealed class ItemService : IItemService
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_config = config;
_uow = uow;
}
@@ -79,7 +85,9 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status))
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -89,7 +97,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -104,6 +111,9 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var item = new Item
{
Sku = request.Sku.Trim(),
@@ -118,6 +128,10 @@ public sealed class ItemService : IItemService
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
ContentBaseUnit = contentBaseUnit,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -133,7 +147,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -148,6 +161,18 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
// The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any
// of that history exists, changing it would silently reinterpret every one of those
// numbers (240 bottles becoming 240 cases), so it is frozen instead.
if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct))
throw new DomainException(
ErrorCodes.MasterInUse,
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
@@ -160,6 +185,10 @@ public sealed class ItemService : IItemService
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.ContentQty = request.ContentQty;
item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty;
item.ContentBaseUnit = contentBaseUnit;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
@@ -224,52 +253,14 @@ public sealed class ItemService : IItemService
return new ItemReorderSettingsDto(settings);
}
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
{
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
if (pairs.Distinct().Count() != pairs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
var item = await _items.Query()
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
item.UomConversions.Remove(stale);
foreach (var input in request.Conversions)
{
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
if (existing is null)
{
item.UomConversions.Add(new UomConversion
{
ItemId = itemId,
FromUomId = input.FromUom,
ToUomId = input.ToUom,
Factor = input.Factor
});
}
else
{
existing.Factor = input.Factor;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var conversions = item.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList();
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
/// <summary>
/// Whether anything has ever been recorded against this item's stock. Checks the ledger
/// as well as live layers, because a fully consumed item has no layer left but its
/// history still reads in the old unit.
/// </summary>
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
/// <summary>
/// Validates every FK on an item write, and gates the optional ones on the product
@@ -350,14 +341,12 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
+6 -2
View File
@@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt))
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
@@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService
var itemType = new ItemType
{
Name = name,
IsMeasurable = request.IsMeasurable,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService
// Renaming does not touch existing items: their SKUs already encode the values that
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
itemType.Name = name;
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
// so let a name-only PUT silently clear the flag on every rename.
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
itemType.UpdatedAt = DateTime.UtcNow;
try
@@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService
await _uow.SaveChangesAsync(ct);
}
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt);
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -21,9 +21,10 @@ namespace ERPCore.Services.Production;
/// </remarks>
public static class ProductionGraphValidator
{
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
public sealed record InputDraft(
int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit);
public sealed record OutputDraft(string Key, string Name, int? ItemId);
public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId);
public sealed record StageDraft(
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
@@ -171,6 +172,12 @@ public static class ProductionGraphValidator
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
}
// WIP has no content size — it is counted in whatever unit its source output
// declares — so only a Stock input may be expressed in ml/g.
if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422);
}
}
@@ -189,6 +196,20 @@ public static class ProductionGraphValidator
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
// 7 — output units. An item-bearing output already has a unit (the item's base UOM),
// so carrying a second one could only contradict it. WIP has no item to ask, so it
// must name its own — the label the run board and any downstream input display.
foreach (var s in stages)
foreach (var o in s.Outputs)
{
if (o.ItemId is null && o.UomId is null)
throw new DomainException(ErrorCodes.WipUnitRequired,
$"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422);
if (o.ItemId is not null && o.UomId is not null)
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set.");
}
}
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
@@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo;
private readonly IUomConverter _uomConverter;
private readonly IItemMeasure _measure;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
@@ -35,7 +35,7 @@ public sealed class ProductionRunService : IProductionRunService
IRepository<ProductionRun> runs, IRepository<ProductionTemplate> templates,
IRepository<Warehouse> warehouses, IRepository<Bin> bins,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes,
IFifoCostingService fifo, IUomConverter uomConverter,
IFifoCostingService fifo, IItemMeasure measure,
INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser)
{
_runs = runs;
@@ -45,7 +45,7 @@ public sealed class ProductionRunService : IProductionRunService
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo;
_uomConverter = uomConverter;
_measure = measure;
_numbers = numbers;
_uow = uow;
_currentUser = currentUser;
@@ -245,7 +245,7 @@ public sealed class ProductionRunService : IProductionRunService
Source = i.Source,
ItemId = i.ItemId,
FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value],
UomId = i.UomId,
QtyUnit = i.QtyUnit,
PlannedQty = Scale(i.QtyPerBatch, ratio)
});
}
@@ -367,9 +367,11 @@ public sealed class ProductionRunService : IProductionRunService
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
// PlannedQty is in the input's declared UOM; ConsumedQty is in the item's
// base UOM (the only unit FIFO and the ledger speak). Compare in base.
var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token);
// PlannedQty is in the input's declared unit — content (ml/g) for an item with
// a content size, otherwise packs. ConsumedQty is always packs, the only unit
// FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml
// bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly.
var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit);
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
@@ -1057,7 +1059,9 @@ public sealed class ProductionRunService : IProductionRunService
$"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods "
+ "are not supported in this phase.", 422);
var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct);
// Output quantities are always a count of the finished item's base UOM — produced and
// scrapped are recorded in whole bottles, not millilitres — so nothing to resolve.
var qtyBase = good;
var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -1223,7 +1227,7 @@ public sealed class ProductionRunService : IProductionRunService
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
ParseJson(s.FieldValues),
s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto(
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId,
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit,
i.PlannedQty, i.ConsumedQty, i.ConsumedValue,
i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(),
s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto(
@@ -253,19 +253,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
ProductionGraphValidator.Validate(
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
s.Key, s.Name,
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey, i.QtyUnit)).ToList(),
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId, o.UomId)).ToList())).ToList(),
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
var itemIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
.OfType<int>().Distinct().ToList();
var contentByItem = new Dictionary<int, decimal?>();
if (itemIds.Count > 0)
{
var found = await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => new { i.ItemId, i.Status })
.Select(i => new { i.ItemId, i.Status, i.ContentBaseQty })
.ToListAsync(ct);
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
@@ -277,19 +278,42 @@ public sealed class ProductionTemplateService : IProductionTemplateService
if (inactive.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
contentByItem = found.ToDictionary(f => f.ItemId, f => f.ContentBaseQty);
}
// A Content quantity is only meaningful against an item that declares a content size,
// and it must still land on a storable pack count: quantities persist at (18,4), so
// anything under 0.0001 packs would round to zero and consume nothing at stage start.
foreach (var s in request.Stages)
foreach (var i in s.Inputs.Where(i => i.QtyUnit == StageQtyUnit.Content))
{
var contentBaseQty = contentByItem.GetValueOrDefault(i.ItemId ?? 0);
if (contentBaseQty is not > 0m)
throw new DomainException(ErrorCodes.Validation,
$"Stage '{s.Name}' has an input in content units, but item {i.ItemId} has no content size.", 422);
if (i.QtyPerBatch / contentBaseQty.Value < 0.0001m)
throw new DomainException(ErrorCodes.Validation,
$"Stage '{s.Name}': {i.QtyPerBatch} is less than 0.0001 of item {i.ItemId}'s content size ({contentBaseQty}), which would round to no stock at all.", 422);
}
// Only WIP outputs carry a UOM now; the validator has already rejected one on an
// item-bearing output, so every non-null id here belongs to real work-in-progress.
var uomIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
.Distinct().ToList();
.SelectMany(s => s.Outputs.Select(o => o.UomId))
.OfType<int>().Distinct().ToList();
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
if (uomIds.Count > 0)
{
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
}
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
// client renders off. Unknown kinds would round-trip fine but draw nothing.
@@ -428,7 +452,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
Source = i.Source,
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
UomId = i.UomId,
QtyUnit = i.QtyUnit,
QtyPerBatch = i.QtyPerBatch
});
}
@@ -476,7 +500,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
i.InputId, i.Source, i.ItemId, i.FromOutputId,
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
i.UomId, i.QtyPerBatch)).ToList(),
i.QtyUnit, i.QtyPerBatch)).ToList(),
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
.ToList();
@@ -222,7 +222,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private static PoLine ToLine(CreatePoLineInput l) => new()
{
ItemId = l.ItemId,
UomId = l.UomId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
UnitPrice = l.UnitPrice,
@@ -258,7 +257,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
}
@@ -276,5 +274,5 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
}
@@ -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,105 @@
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 ISalesPricingService _pricing;
public SalesDomainService(
IRepository<Customer> customers,
IRepository<Warehouse> warehouses,
IRepository<User> users,
IRepository<Item> items,
ISalesPricingService pricing)
{
_customers = customers;
_warehouses = warehouses;
_users = users;
_items = items;
_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 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 _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));
}
}
+37 -94
View File
@@ -21,15 +21,18 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly ISalesPricingService _pricing;
private readonly IFifoCostingService _fifo;
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, ISalesPricingService pricing, IFifoCostingService fifo,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
@@ -37,8 +40,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_pricing = pricing;
_fifo = fifo;
_sales = sales;
_posting = posting;
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
@@ -66,12 +71,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
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 ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var invoice = new SalesInvoice
{
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
@@ -85,62 +93,39 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
};
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.Lines, 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>(Map(invoice), invoice.RowVersion);
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 _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.");
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, 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.Lines, ct)) invoice.Lines.Add(line);
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>(Map(invoice), invoice.RowVersion);
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
public async Task<SalesInvoiceDto> PostAsync(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 posted.");
var posted = await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in invoice.Lines)
{
if (line.Qty <= 0) continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, 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 + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token);
}
invoice.Status = SalesInvoiceStatus.Posted;
invoice.UpdatedAt = DateTime.UtcNow;
return invoice;
}, ct);
return Map(posted);
}
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)
{
@@ -151,43 +136,21 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
invoice.Status = SalesInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(invoice);
return _mapping.MapInvoice(invoice);
}
private async Task ValidateReferencesAsync(int customerId, int warehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
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.");
foreach (var line in lines)
{
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
throw new NotFoundException($"UOM {line.UomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
}
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
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);
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, 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 gross = r.Qty * unitPrice;
var discountTotal = r.IsFreeIssue
? 0m
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (r.TaxPct / 100m);
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
{
@@ -195,18 +158,17 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
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 = discountTotal,
DiscountAmount = calc.DiscountTotal,
DiscountMode = r.DiscountMode,
NetUnitPrice = netUnit,
LineTotal = lineTotal,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxAmount = taxAmount,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
});
@@ -226,26 +188,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
invoice.BalanceAmount = invoice.NetPayable;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{
var computed = mode == SalesDiscountMode.FixedAmount
? discountValue
: gross * (discountPct / 100m);
if (computed <= 0m && legacyDiscountAmount > 0m)
computed = legacyDiscountAmount;
return Math.Min(gross, Math.Max(0m, computed));
}
private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto(
x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
private static SalesInvoiceDto Map(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo,
x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount),
x.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());
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.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.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,215 @@
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 item = await _items.Query().AsNoTracking()
.FirstAsync(x => x.ItemId == line.ItemId, ct);
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= line.Qty) continue;
issues.Add(new BundleSalePostingIssueDto(
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
=> PostAsync(
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
getDocId: x => x.SalesInvoiceId,
ct: ct);
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync(
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
getDocId: x => x.SalesSlipId,
ct: ct);
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
=> PostAsync(
load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct),
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
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: DocumentTypes.BundleSale,
getDocId: x => x.BundleSaleId,
ct: ct);
private async Task PostAsync<T>(
Func<Task<T?>> load,
string notFoundMessage,
Func<T, object> statusSelector,
Func<T, string> ensureDraftMessage,
Func<T, IEnumerable<PostingLine>> getLines,
Action<T> setPosted,
Action<T> setUpdated,
string sourceDocType,
Func<T, int> getDocId,
CancellationToken ct)
where T : class
{
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
var status = statusSelector(doc);
var statusValue = status?.ToString() ?? string.Empty;
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in getLines(doc))
{
if (line.Qty <= 0) continue;
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
continue;
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
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,76 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
{
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<Item> _items;
public SalesPromotionSuggestionService(IRepository<SalesSlip> slips, IRepository<Item> items)
{
_slips = slips;
_items = items;
}
public async Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
if (slip is null) return null;
var freeIssueLines = slip.Lines
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
.ToList();
if (freeIssueLines.Count == 0)
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
var candidateItems = await _items.Query().AsNoTracking()
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
.ToListAsync(ct);
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
foreach (var line in freeIssueLines)
{
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
{
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
};
var alternates = await _items.Query().AsNoTracking()
.Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId)
.OrderBy(x => x.Name)
.Take(3)
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice))
.ToListAsync(ct);
rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId)));
suggestions.Add(new SalesFreeIssueSuggestionLineDto(
line.SalesSlipLineId,
item.ItemId,
item.Sku,
item.Name,
line.Qty,
line.FreeQty,
line.Qty,
rewardOptions));
}
return suggestions.Count == 0
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
}
}
+103 -38
View File
@@ -3,12 +3,23 @@ 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;
@@ -18,6 +29,54 @@ public sealed class SalesReportService : ISalesReportService
_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()
@@ -72,42 +131,51 @@ public sealed class SalesReportService : ISalesReportService
public async Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default)
{
var invoiceLines = _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) invoiceLines = invoiceLines.Where(x => x.WarehouseId == warehouseId);
var invoiceQuery = invoiceLines.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,
l.WarehouseId
}));
if (itemId is not null) invoiceQuery = invoiceQuery.Where(x => x.ItemId == itemId);
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);
var slipLines = _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) slipLines = slipLines.Where(x => x.WarehouseId == warehouseId);
var slipQuery = slipLines.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,
l.WarehouseId
}));
if (itemId is not null) slipQuery = slipQuery.Where(x => x.ItemId == itemId);
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 rows = await invoiceQuery.Concat(slipQuery)
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,
@@ -116,11 +184,8 @@ public sealed class SalesReportService : ISalesReportService
g.Sum(x => x.Gross),
g.Sum(x => x.DiscountAmount),
g.Sum(x => x.TaxAmount),
g.Sum(x => x.LineTotal + x.TaxAmount)))
.OrderByDescending(x => x.NetAmount)
.ToListAsync(ct);
return rows;
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)
@@ -0,0 +1,200 @@
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.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Sales-return service. Auto-posts with a mandatory Return reason code and
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
/// reversed.
/// </summary>
public sealed class SalesReturnService : ISalesReturnService
{
private readonly IRepository<SalesReturn> _returns;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
private readonly IRepository<SalesReturnLine> _returnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_customers = customers;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_salesInvoiceLines = salesInvoiceLines;
_returnLines = returnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (customerId is not null) q = q.Where(r => r.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new SalesReturnSummaryDto(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty)))
.ToListAsync(ct);
return PagedResponse<SalesReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.SalesReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a sales return.", 400);
if (!await _customers.Query().AnyAsync(c => c.CustomerId == request.CustomerId, ct))
throw new DomainException(ErrorCodes.Validation, $"Customer {request.CustomerId} does not exist.", 422);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
// Original sold qty is never mutated — "remaining returnable" is computed from
// return history instead, so the invoice keeps recording what was actually sold.
var pendingByInvoiceLine = new Dictionary<int, decimal>();
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
if (line.SalesInvoiceLineId is not null)
{
var invoiceLineId = line.SalesInvoiceLineId.Value;
var invoiceLine = await _salesInvoiceLines.Query().AsNoTracking().FirstOrDefaultAsync(l => l.SalesInvoiceLineId == invoiceLineId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} does not exist.", 422);
if (invoiceLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} is for a different item.", 422);
var alreadyReturned = await _returnLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceLineId == invoiceLineId)
.SumAsync(l => (decimal?)l.Qty, ct) ?? 0m;
pendingByInvoiceLine.TryGetValue(invoiceLineId, out var pending);
var remaining = invoiceLine.Qty - alreadyReturned - pending;
if (line.Qty > remaining)
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
}
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.SalesReturn, token);
var ret = new SalesReturn
{
DocNo = docNo,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new SalesReturnLine
{
SalesInvoiceLineId = l.SalesInvoiceLineId,
ItemId = l.ItemId,
Qty = l.Qty
}).ToList()
};
await _returns.AddAsync(ret, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
return (ret, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
public async Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
{
var lines = await _salesInvoiceLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceId == salesInvoiceId)
.Select(l => new { l.SalesInvoiceLineId, l.Qty })
.ToListAsync(ct);
var lineIds = lines.Select(l => l.SalesInvoiceLineId).ToList();
var returnedByLine = await _returnLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceLineId != null && lineIds.Contains(l.SalesInvoiceLineId.Value))
.GroupBy(l => l.SalesInvoiceLineId!.Value)
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
return lines
.Select(l => new SalesInvoiceLineRemainingDto(
l.SalesInvoiceLineId,
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m)))
.ToList();
}
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
}
+113 -91
View File
@@ -22,15 +22,18 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly ISalesPricingService _pricing;
private readonly IFifoCostingService _fifo;
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, ISalesPricingService pricing, IFifoCostingService fifo,
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;
@@ -39,8 +42,10 @@ public sealed class SalesSlipService : ISalesSlipService
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_pricing = pricing;
_fifo = fifo;
_sales = sales;
_posting = posting;
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
@@ -67,12 +72,40 @@ public sealed class SalesSlipService : ISalesSlipService
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
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 ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var slip = new SalesSlip
{
@@ -85,61 +118,38 @@ public sealed class SalesSlipService : ISalesSlipService
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.Lines, 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>(Map(slip), slip.RowVersion);
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 _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.");
var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, ct);
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, 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.Lines, ct)) slip.Lines.Add(line);
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>(Map(slip), slip.RowVersion);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
public async Task<SalesSlipDto> PostAsync(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 posted.");
var posted = await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in slip.Lines)
{
if (line.Qty <= 0 && line.FreeQty <= 0) continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, 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 + line.FreeQty, cost, 0m, nameof(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token);
}
slip.Status = SalesSlipStatus.Posted;
slip.UpdatedAt = DateTime.UtcNow;
return slip;
}, ct);
return Map(posted);
}
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)
{
@@ -150,45 +160,21 @@ public sealed class SalesSlipService : ISalesSlipService
slip.Status = SalesSlipStatus.Cancelled;
slip.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(slip);
return _mapping.MapSlip(slip);
}
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List<CreateSalesSlipLineRequest> lines, CancellationToken ct)
{
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 (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
throw new NotFoundException($"User {cashierUserId} was not found.");
foreach (var line in lines)
{
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
throw new NotFoundException($"UOM {line.UomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
}
}
private async Task<List<SalesSlipLine>> BuildLinesAsync(List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
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);
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, 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 gross = r.Qty * unitPrice;
var discountTotal = r.IsFreeIssue
? 0m
: CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount);
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (r.TaxPct / 100m);
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
{
@@ -196,18 +182,17 @@ public sealed class SalesSlipService : ISalesSlipService
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 = discountTotal,
NetUnitPrice = netUnit,
LineTotal = lineTotal,
DiscountAmount = calc.DiscountTotal,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxAmount = taxAmount,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
});
@@ -225,24 +210,61 @@ public sealed class SalesSlipService : ISalesSlipService
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
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 computed = mode == SalesDiscountMode.FixedAmount
? discountValue
: gross * (discountPct / 100m);
if (computed <= 0m && legacyDiscountAmount > 0m)
computed = legacyDiscountAmount;
return Math.Min(gross, Math.Max(0m, computed));
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();
// The line carries no unit of its own — its quantity is a count of the item's base
// UOM — so the display name comes from there.
var uom = item is null ? null : _uoms.Query().AsNoTracking()
.Where(u => u.UomId == item.BaseUomId)
.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 ?? "—",
uom?.Name ?? $"UOM {item?.BaseUomId ?? 0}",
line?.Qty ?? 0m,
line?.FreeQty ?? 0m,
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
}
private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
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.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 static SalesSlipDto Map(SalesSlip x) => new(
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt,
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount),
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);
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services.Stock;
/// <summary>
/// Content ↔ pack arithmetic (see <see cref="IItemMeasure"/>). Stateless and I/O-free:
/// the content size is already on the <see cref="Item"/> every caller has loaded.
/// </summary>
public sealed class ItemMeasure : IItemMeasure
{
public bool HasContent(Item item) => item.ContentBaseQty is > 0m;
public decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit)
{
if (unit == StageQtyUnit.Pack) return formulaQty;
// Rounded to the quantity columns' (18,4) scale with the same mode as
// ProductionRunService.Scale, so what FIFO consumes and what the cost pool divides
// by are the same number to the last stored digit.
return Math.Round(formulaQty / RequireContent(item), 4, MidpointRounding.AwayFromZero);
}
public decimal FromPacks(Item item, decimal packs, StageQtyUnit unit)
{
if (unit == StageQtyUnit.Pack) return packs;
return Math.Round(packs * RequireContent(item), 4, MidpointRounding.AwayFromZero);
}
private static decimal RequireContent(Item item)
=> item.ContentBaseQty is > 0m
? item.ContentBaseQty.Value
: throw new DomainException(
ErrorCodes.Validation,
$"Item {item.ItemId} has no content size, so its quantity cannot be expressed in content units.", 422);
}
@@ -1,38 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
/// receive path keeps costing exactly as before.
/// </summary>
public sealed class UomConverter : IUomConverter
{
private readonly IRepository<UomConversion> _conversions;
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
// Quantity scales up by the factor, so the per-unit cost scales down by it —
// total value is preserved.
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
}

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