Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4993b202d | |||
| 8555702a76 | |||
| fd95e92eb1 | |||
| 6e008773db | |||
| 179d5b0803 | |||
| 342012a321 | |||
| ee6ac913f1 | |||
| 2661169351 | |||
| 80213cf47d | |||
| 18475fdc9f | |||
| 9a32c5c609 | |||
| 32f40e9d1a | |||
| b2a218e2f8 | |||
| d16a227b54 | |||
| a7ba3d3e04 | |||
| 0ae80395cf | |||
| 15ddac178c | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 7219480ca0 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 0750773f94 | |||
| 38c7545413 | |||
| 0d60aeef64 | |||
| c6bc8065a2 | |||
| 4f56d481a2 | |||
| d79371697e | |||
| f7a65b5f7e | |||
| 6b216195c6 | |||
| 266a2a2c14 | |||
| 3c5b476635 | |||
| 59af50bf11 | |||
| 0b9d64f911 | |||
| 6258ebd8de | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
+7
-9
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -61,4 +58,11 @@ public class GrnLine
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||
/// Empty for a non-warranty item.
|
||||
/// </summary>
|
||||
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One warranty number captured against a single received unit of a warranty-tracked
|
||||
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
|
||||
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
|
||||
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
|
||||
/// </summary>
|
||||
public class GrnLineWarrantyNumber
|
||||
{
|
||||
public int GrnLineWarrantyNumberId { get; set; }
|
||||
|
||||
public int GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public string WarrantyNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
|
||||
public int WarrantyPeriodMonths { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -32,6 +38,9 @@ public class Item
|
||||
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
public string? TaxClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -41,6 +50,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 +87,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>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
|
||||
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
|
||||
/// </summary>
|
||||
public enum Warranty
|
||||
{
|
||||
NonWarranty,
|
||||
Warranty
|
||||
}
|
||||
|
||||
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
|
||||
public static class WarrantyPeriods
|
||||
{
|
||||
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace ERPCore.Dtos.Grn;
|
||||
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
|
||||
|
||||
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,
|
||||
HoldStatus HoldStatus, int? BatchId);
|
||||
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
@@ -44,7 +46,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>
|
||||
@@ -59,6 +60,11 @@ public sealed class CreateGrnLineInput
|
||||
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
/// <summary>
|
||||
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
|
||||
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
|
||||
/// </summary>
|
||||
public List<string>? WarrantyNumbers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnRequest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,11 @@ 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);
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
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 +21,22 @@ 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,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
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);
|
||||
|
||||
@@ -61,9 +61,20 @@ public sealed class CreateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[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
|
||||
@@ -80,9 +91,20 @@ public sealed class UpdateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[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 +123,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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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,8 +49,25 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> builder)
|
||||
{
|
||||
builder.ToTable("grn_line_warranty_numbers");
|
||||
builder.HasKey(w => w.GrnLineWarrantyNumberId);
|
||||
|
||||
builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers)
|
||||
.HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A warranty number entered twice on the same line is almost certainly a typo —
|
||||
// catch it at the DB, not just client-side.
|
||||
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,23 @@ 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)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.Warranty)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(Warranty.NonWarranty);
|
||||
builder.Property(i => i.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+404
-149
@@ -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 =>
|
||||
+238
-130
@@ -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");
|
||||
|
||||
+6956
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+407
-147
@@ -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 =>
|
||||
@@ -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 §4–5)
|
||||
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 §5–6)
|
||||
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());
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -95,7 +91,7 @@ public sealed class GrnService : IGrnService
|
||||
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -169,12 +165,12 @@ public sealed class GrnService : IGrnService
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
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,
|
||||
@@ -186,7 +182,8 @@ public sealed class GrnService : IGrnService
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
HoldStatus = input.HoldStatus,
|
||||
WarrantyNumbers = warrantyNumbers
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,10 +231,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,
|
||||
@@ -346,13 +344,28 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// A warranty-tracked item requires exactly one warranty number per received unit —
|
||||
/// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
|
||||
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
|
||||
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
|
||||
/// </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 static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
|
||||
{
|
||||
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
|
||||
if (item.WarrantyPeriodMonths is null)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
|
||||
|
||||
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
|
||||
if (trimmed.Count != qty)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422);
|
||||
if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422);
|
||||
|
||||
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
|
||||
}
|
||||
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
@@ -392,8 +405,9 @@ 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());
|
||||
l.HoldStatus, l.BatchId,
|
||||
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.Warranty, i.WarrantyPeriodMonths, 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,10 @@ 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 warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
Sku = request.Sku.Trim(),
|
||||
@@ -116,8 +127,14 @@ public sealed class ItemService : IItemService
|
||||
DefaultVendorId = request.DefaultVendorId,
|
||||
StockNature = request.StockNature,
|
||||
TrackingMode = request.TrackingMode,
|
||||
Warranty = request.Warranty,
|
||||
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||
TaxClass = request.TaxClass,
|
||||
SalePrice = request.SalePrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
ContentBaseUnit = contentBaseUnit,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -133,7 +150,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 +164,19 @@ 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);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
@@ -158,8 +187,14 @@ public sealed class ItemService : IItemService
|
||||
item.DefaultVendorId = request.DefaultVendorId;
|
||||
item.StockNature = request.StockNature;
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.Warranty = request.Warranty;
|
||||
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||
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 +259,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
|
||||
@@ -335,6 +332,23 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -350,14 +364,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.Warranty, i.WarrantyPeriodMonths, 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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user