Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea6158cd9 |
+6
-7
@@ -30,10 +30,9 @@ yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# New EF Core migrations are not committed. Note the 4 migrations already in
|
||||
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
|
||||
# not apply to tracked files — so edits to those still get committed as normal.
|
||||
# Untracking them too takes `git rm --cached`.
|
||||
**/Migrations/
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Customers;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
|
||||
public CustomersController(ICustomerService customers) => _customers = customers;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.GetAsync(customerId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{customerId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _customers.SetStatusAsync(customerId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic reverse proxy into the external General Ledger service — forwards every
|
||||
/// method/path/query/body under this prefix verbatim via
|
||||
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
|
||||
/// body) unchanged. No endpoint-specific shape lives here; see
|
||||
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
|
||||
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
|
||||
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
|
||||
|
||||
[HttpPost("{**path}")]
|
||||
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
|
||||
|
||||
[HttpPut("{**path}")]
|
||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||
|
||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||
{
|
||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = result.StatusCode,
|
||||
Content = result.Body,
|
||||
ContentType = result.ContentType ?? "application/json"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,8 @@ namespace ERPCore.Controllers;
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
private readonly IGrnPaymentService _payments;
|
||||
|
||||
public GrnsController(IGrnService grns, IGrnPaymentService payments)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
}
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
@@ -64,23 +59,4 @@ public sealed class GrnsController : ApiControllerBase
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(grnId, request, ct);
|
||||
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this GRN, newest first.</summary>
|
||||
[HttpGet("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(grnId, ct));
|
||||
}
|
||||
|
||||
@@ -81,4 +81,11 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||
[Route("api/v1/production-runs")]
|
||||
public sealed class ProductionRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionRunService _runs;
|
||||
|
||||
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.GetAsync(runId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
|
||||
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
|
||||
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
|
||||
|
||||
// --- stage actions (docs/30 §D.3) ---------------------------------------
|
||||
//
|
||||
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
|
||||
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
|
||||
// the status guards instead: a double-fire finds the stage already moved on and gets a
|
||||
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
|
||||
// deviation from §D.3's "idempotency-key honored".
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
|
||||
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<StartStageResultDto>> Start(
|
||||
int runId, int runStageId,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunStageDto>> Complete(
|
||||
int runId, int runStageId, [FromBody] CompleteStageRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
|
||||
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ApproveStageResultDto>> Approve(
|
||||
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
|
||||
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TransferResultDto>> Transfer(
|
||||
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
|
||||
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
|
||||
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
|
||||
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
|
||||
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CancelRunResultDto>> Cancel(
|
||||
int runId, [FromBody] CancelRunRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CancelAsync(runId, request, ct));
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||
[Route("api/v1/production-templates")]
|
||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionTemplateService _templates;
|
||||
|
||||
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _templates.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.GetAsync(templateId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Create(
|
||||
[FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Update(
|
||||
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{templateId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _templates.SetStatusAsync(templateId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
|
||||
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
|
||||
[Route("api/v1/sales-day-end")]
|
||||
public sealed class SalesDayEndController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesDayEndService _dayEnds;
|
||||
|
||||
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
|
||||
|
||||
/// <summary>List day-end closes, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
|
||||
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
|
||||
|
||||
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
|
||||
[HttpGet("preview")]
|
||||
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
|
||||
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
|
||||
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
|
||||
|
||||
[HttpGet("{salesDayEndId:int}")]
|
||||
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
|
||||
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _dayEnds.CloseAsync(request, ct);
|
||||
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-invoices")]
|
||||
public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] SalesInvoiceStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.GetAsync(salesInvoiceId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{salesInvoiceId:int}/post")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
|
||||
|
||||
[HttpPost("{salesInvoiceId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/reports/sales")]
|
||||
public sealed class SalesReportsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReportService _reports;
|
||||
|
||||
public SalesReportsController(ISalesReportService reports) => _reports = reports;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
|
||||
=> Ok(_reports.ListReports());
|
||||
|
||||
[HttpGet("{reportId}")]
|
||||
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
|
||||
{
|
||||
var report = _reports.GetReport(reportId);
|
||||
return report is null ? NotFound() : Ok(report);
|
||||
}
|
||||
|
||||
[HttpPost("query")]
|
||||
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
|
||||
{
|
||||
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
|
||||
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips")]
|
||||
public sealed class SalesSlipsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] SalesSlipStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{salesSlipId:int}")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.GetAsync(salesSlipId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{salesSlipId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _slips.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{salesSlipId:int}")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{salesSlipId:int}/post")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.PostAsync(salesSlipId, ct));
|
||||
|
||||
[HttpPost("{salesSlipId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
|
||||
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
|
||||
}
|
||||
@@ -14,14 +14,4 @@ public static class DocumentTypes
|
||||
public const string Adjustment = "ADJ";
|
||||
public const string Count = "CNT";
|
||||
public const string PurchaseReturn = "PRET";
|
||||
|
||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||
public const string Production = "PRD";
|
||||
public const string SalesInvoice = "SI";
|
||||
public const string SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
public const string SalesReturn = "SRET";
|
||||
|
||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||
public const string SalesDayEnd = "DEND";
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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; }
|
||||
|
||||
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
|
||||
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
|
||||
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Customer master for both B2B and B2C sales.
|
||||
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
|
||||
/// </summary>
|
||||
public class Customer
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
public CustomerType CustomerType { get; set; } = CustomerType.B2C;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? AddressLine1 { get; set; }
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? TaxRegistrationNo { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public int CreditDays { get; set; }
|
||||
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -30,18 +30,8 @@ public class Grn
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ 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; }
|
||||
|
||||
@@ -58,11 +61,4 @@ 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>();
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
|
||||
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
|
||||
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
|
||||
/// </summary>
|
||||
public class GrnPayment
|
||||
{
|
||||
public int GrnPaymentId { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
|
||||
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
|
||||
public long GlBankAccountId { get; set; }
|
||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -24,12 +24,6 @@ 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; }
|
||||
|
||||
@@ -38,9 +32,6 @@ 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>
|
||||
@@ -50,34 +41,6 @@ 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; }
|
||||
@@ -87,4 +50,5 @@ 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,15 +7,12 @@ 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"/>. The chosen
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -24,24 +21,6 @@ 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,6 +15,9 @@ 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; }
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
|
||||
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
|
||||
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
|
||||
/// readable even if the template is later edited (FR-MFG-06).
|
||||
/// <para>The run's <b>cost pool</b> is derived, never stored:
|
||||
/// <c>Σ RunStageInput.ConsumedValue − Σ RunStageInput.ReturnedValue</c>. The terminal
|
||||
/// approve divides it by the good quantity to cost the finished layer, then closes it
|
||||
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class ProductionRun
|
||||
{
|
||||
public int RunId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
|
||||
public int? OutputBinId { get; set; }
|
||||
public Bin? OutputBin { get; set; }
|
||||
|
||||
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
|
||||
public decimal TargetQty { get; set; }
|
||||
|
||||
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
|
||||
public decimal ScaleFactor { get; set; }
|
||||
|
||||
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
|
||||
|
||||
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
|
||||
public int ReworkCount { get; set; }
|
||||
|
||||
public int? CancelReasonCodeId { get; set; }
|
||||
public ReasonCode? CancelReason { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
|
||||
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
|
||||
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A reusable production-line definition — the stage graph designed on the canvas
|
||||
/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
|
||||
/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
|
||||
/// (FR-MFG-06, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
|
||||
/// why runs copy display fields at creation. Mutable aggregate with a
|
||||
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class ProductionTemplate
|
||||
{
|
||||
public int TemplateId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
|
||||
/// verbatim and never interpreted server-side.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not in docs/30 Part C — added because the builder canvas already draws these and
|
||||
/// without somewhere to keep them a save would silently discard the user's layout notes.
|
||||
/// They carry no graph semantics: no ports, no edges, and the validator never sees them.
|
||||
/// Nullable so a template that has none stores nothing rather than an empty array.
|
||||
/// </remarks>
|
||||
public string? Annotations { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
|
||||
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
|
||||
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
|
||||
}
|
||||
@@ -28,12 +28,5 @@ public class PurchaseReturn
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses the Inventory/Clearing lines a Grn posts — <see cref="PurchaseReturnLine"/> carries no unit
|
||||
/// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
|
||||
/// creation.
|
||||
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
|
||||
/// edge table, but the run graph needs its own copy: deriving edges at read time through
|
||||
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> would let a later template edit silently
|
||||
/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
|
||||
/// breaks outright once that link is nulled by a stage deletion.</para>
|
||||
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
|
||||
/// "delivering parents". Note that <b>WIP delivery is routed by
|
||||
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
|
||||
/// validation only.</para>
|
||||
/// </summary>
|
||||
public class RunEdge
|
||||
{
|
||||
public int RunEdgeId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
public int ParentRunStageId { get; set; }
|
||||
public RunStage? ParentRunStage { get; set; }
|
||||
|
||||
public int ChildRunStageId { get; set; }
|
||||
public RunStage? ChildRunStage { get; set; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
|
||||
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
|
||||
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
|
||||
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
|
||||
/// drift from the edge set.</para>
|
||||
/// </summary>
|
||||
public class RunStage
|
||||
{
|
||||
public int RunStageId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance link back to the template stage. <b>Nullable</b>: a template PUT may
|
||||
/// delete a stage while completed/cancelled runs still reference it (the edit-lock
|
||||
/// only blocks edits during InProgress runs), so the FK is <c>SET NULL</c> rather
|
||||
/// than blocking the edit forever. Everything needed to display a historical run is
|
||||
/// copied below, which is exactly what FR-MFG-06 anticipates.
|
||||
/// </summary>
|
||||
public int? TemplateStageId { get; set; }
|
||||
public TemplateStage? TemplateStage { get; set; }
|
||||
|
||||
// --- copied from the template at run creation (FR-MFG-06) ---
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? RoleLabel { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public decimal PosX { get; set; }
|
||||
public decimal PosY { get; set; }
|
||||
|
||||
public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
|
||||
|
||||
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
|
||||
public DateTime? ActualStartAt { get; set; }
|
||||
public DateTime? ActualEndAt { get; set; }
|
||||
|
||||
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
|
||||
public string FieldDefs { get; set; } = "[]";
|
||||
|
||||
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
|
||||
public string? FieldValues { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency token. Stage actions re-read the stage inside their transaction and
|
||||
/// let this xmin check serialize concurrent requests — it is what stops two
|
||||
/// simultaneous terminal approves from both reading <c>Done</c> and posting two
|
||||
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
|
||||
/// </summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
|
||||
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
|
||||
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
|
||||
/// every mutating action and never updated or deleted, so a run's story — including the
|
||||
/// figures discarded by each rework — survives in full.
|
||||
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. The doc hangs
|
||||
/// events off the stage only, which leaves run-level events (cancel, terminal reject)
|
||||
/// with no home and forces the detail timeline to join through stages. Keeping both
|
||||
/// links makes <see cref="RunStageId"/> optional and the timeline a single query.</para>
|
||||
/// </summary>
|
||||
public class RunStageEvent
|
||||
{
|
||||
public int EventId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
/// <summary>Null for run-level events (Cancel).</summary>
|
||||
public int? RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
public RunStageEventType EventType { get; set; }
|
||||
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
|
||||
/// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
|
||||
/// Pre-serialized string, written only through <c>ProductionJson</c>.
|
||||
/// </summary>
|
||||
public string? Payload { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
|
||||
/// quantity scaled at creation, plus the live consumption/delivery figures.
|
||||
/// Model: docs/30 Part C.
|
||||
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
|
||||
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
|
||||
/// return or run cancel. Those four columns are the whole cost pool
|
||||
/// (<c>Σ consumed − Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
|
||||
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
|
||||
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
|
||||
/// transfer WIP in. The stage becomes Ready only when every upstream input has
|
||||
/// <c>DeliveredQty >= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
|
||||
/// </summary>
|
||||
public class RunStageInput
|
||||
{
|
||||
public int RunInputId { get; set; }
|
||||
|
||||
public int RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
public StageInputSource Source { get; set; }
|
||||
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
|
||||
public int? FromRunOutputId { get; set; }
|
||||
public RunStageOutput? FromRunOutput { 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>). 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>
|
||||
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
|
||||
/// <c>max(0, PlannedQty − ConsumedQty)</c> and adds to these, so a rework restart
|
||||
/// with an unchanged planned quantity consumes nothing and one with a raised planned
|
||||
/// quantity consumes only the delta (FR-MFG-16).
|
||||
/// </summary>
|
||||
public decimal ConsumedQty { get; set; }
|
||||
public decimal ConsumedValue { get; set; }
|
||||
|
||||
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
|
||||
public decimal DeliveredQty { get; set; }
|
||||
|
||||
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
|
||||
public decimal ReturnedQty { get; set; }
|
||||
public decimal ReturnedValue { get; set; }
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
|
||||
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
|
||||
/// Model: docs/30 Part C.
|
||||
/// <para><b>Available to transfer is derived, never stored:</b>
|
||||
/// <c>ProducedQty − ScrappedQty − TransferredQty</c>. Every transfer path checks it and
|
||||
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
|
||||
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
|
||||
/// write-off ledger entry is posted (FR-MFG-11).</para>
|
||||
/// </summary>
|
||||
public class RunStageOutput
|
||||
{
|
||||
public int RunOutputId { get; set; }
|
||||
|
||||
public int RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <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. 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>
|
||||
public decimal ProducedQty { get; set; }
|
||||
|
||||
public decimal ScrappedQty { get; set; }
|
||||
|
||||
/// <summary>Mandatory when <see cref="ScrappedQty"/> > 0, context <c>Production</c> (FR-MFG-11).</summary>
|
||||
public int? ScrapReasonCodeId { get; set; }
|
||||
public ReasonCode? ScrapReason { get; set; }
|
||||
|
||||
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
|
||||
public decimal TransferredQty { get; set; }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A cashier's end-of-day close-out. Aggregates every <see cref="SalesSlip"/> the
|
||||
/// cashier posted on <see cref="BusinessDate"/>, freezes them against this record
|
||||
/// (<see cref="SalesSlip.DayEndId"/>) so a slip can only ever belong to one day-end,
|
||||
/// and posts one consolidated GL journal entry for the day's revenue/tax/COGS —
|
||||
/// the sales-side equivalent of how <see cref="Grn"/> posts per receipt. Unique per
|
||||
/// (CashierUserId, BusinessDate): closing twice replays the existing record instead
|
||||
/// of creating a second one (same idempotent-replay pattern as Grn.ConfirmAsync).
|
||||
/// </summary>
|
||||
public class SalesDayEnd
|
||||
{
|
||||
public int SalesDayEndId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int CashierUserId { get; set; }
|
||||
public User? CashierUser { get; set; }
|
||||
|
||||
/// <summary>The calendar date being closed (matched against each slip's <see cref="SalesSlip.SlipDate"/>, UTC).</summary>
|
||||
public DateOnly BusinessDate { get; set; }
|
||||
|
||||
public int SlipCount { get; set; }
|
||||
public int BundleCount { get; set; }
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
|
||||
/// <summary>Sum of the FIFO cost consumed for these slips (from <see cref="StockLedger"/>), for the COGS GL lines.</summary>
|
||||
public decimal CostOfGoodsSold { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this close (null when there was nothing to post).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int ClosedBy { get; set; }
|
||||
public User? ClosedByUser { get; set; }
|
||||
public DateTime ClosedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesSlip> Slips { get; set; } = new List<SalesSlip>();
|
||||
public ICollection<BundleSale> BundleSales { get; set; } = new List<BundleSale>();
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class SalesInvoice
|
||||
{
|
||||
public int SalesInvoiceId { get; set; }
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
public string CustomerSnapshotName { get; set; } = string.Empty;
|
||||
public string? CustomerSnapshotTaxNo { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
||||
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public decimal RoundOff { get; set; }
|
||||
public decimal NetPayable { get; set; }
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Journal number of the revenue-recognition GL journal entry posted when this
|
||||
/// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
|
||||
/// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
|
||||
/// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
|
||||
/// cashier document and don't go through that close-out.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class SalesInvoiceLine
|
||||
{
|
||||
public int SalesInvoiceLineId { get; set; }
|
||||
|
||||
public int SalesInvoiceId { get; set; }
|
||||
public SalesInvoice? SalesInvoice { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal BaseCost { get; set; }
|
||||
public string PriceSource { get; set; } = string.Empty;
|
||||
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
||||
public decimal DiscountPct { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal NetUnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public decimal TaxPct { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public bool IsFreeIssue { get; set; }
|
||||
public int? ParentLineId { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A customer payment made against a Posted sales invoice's balance (installments
|
||||
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
|
||||
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
|
||||
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
|
||||
/// </summary>
|
||||
public class SalesInvoicePayment
|
||||
{
|
||||
public int SalesInvoicePaymentId { get; set; }
|
||||
|
||||
public int SalesInvoiceId { get; set; }
|
||||
public SalesInvoice? SalesInvoice { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
|
||||
/// <summary>GL's numeric id for the bank/cash account the payment was received into.</summary>
|
||||
public long GlBankAccountId { get; set; }
|
||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses Inventory/COGS only — <see cref="SalesReturnLine"/> carries no unit price, so revenue/tax aren't
|
||||
/// reversed here (they'd need a per-line price this document doesn't capture).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class SalesSlip
|
||||
{
|
||||
public int SalesSlipId { get; set; }
|
||||
public string SlipNo { get; set; } = string.Empty;
|
||||
public DateTime SlipDate { get; set; }
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
public string CustomerSnapshotName { get; set; } = string.Empty;
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int CashierUserId { get; set; }
|
||||
public User? CashierUser { get; set; }
|
||||
|
||||
public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft;
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class SalesSlipLine
|
||||
{
|
||||
public int SalesSlipLineId { get; set; }
|
||||
|
||||
public int SalesSlipId { get; set; }
|
||||
public SalesSlip? SalesSlip { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal BaseCost { get; set; }
|
||||
public string PriceSource { get; set; } = string.Empty;
|
||||
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
||||
public decimal DiscountPct { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal NetUnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public decimal TaxPct { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public bool IsFreeIssue { get; set; }
|
||||
public int? ParentLineId { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
|
||||
/// at least one entry stage and exactly one terminal stage; that is enforced in
|
||||
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
|
||||
/// Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageEdge
|
||||
{
|
||||
public int EdgeId { get; set; }
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
public int ParentStageId { get; set; }
|
||||
public TemplateStage? ParentStage { get; set; }
|
||||
|
||||
public int ChildStageId { get; set; }
|
||||
public TemplateStage? ChildStage { get; set; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
|
||||
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
|
||||
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
|
||||
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
|
||||
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
|
||||
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
|
||||
/// internal WIP and never touches stock or the ledger.</item>
|
||||
/// </list>
|
||||
/// Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageInput
|
||||
{
|
||||
public int InputId { get; set; }
|
||||
|
||||
public int StageId { get; set; }
|
||||
public TemplateStage? Stage { get; set; }
|
||||
|
||||
public StageInputSource Source { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
|
||||
public int? FromOutputId { get; set; }
|
||||
public StageOutput? FromOutput { 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; }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
|
||||
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
|
||||
/// is ever written for them. The terminal stage is the exception: it has exactly one
|
||||
/// output and that output <b>must</b> reference a real Item (the finished good), which
|
||||
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageOutput
|
||||
{
|
||||
public int OutputId { get; set; }
|
||||
|
||||
public int StageId { get; set; }
|
||||
public TemplateStage? Stage { get; set; }
|
||||
|
||||
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -27,10 +27,5 @@ public class StockAdjustment
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this adjustment (set on create — it
|
||||
/// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
|
||||
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
|
||||
/// custom field definitions. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class TemplateStage
|
||||
{
|
||||
public int StageId { get; set; }
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
|
||||
public string? RoleLabel { get; set; }
|
||||
|
||||
public int EstimatedMinutes { get; set; }
|
||||
|
||||
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
|
||||
public decimal PosX { get; set; }
|
||||
public decimal PosY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
|
||||
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
|
||||
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
|
||||
/// hold canonical JSON.
|
||||
/// </summary>
|
||||
public string FieldDefs { get; set; } = "[]";
|
||||
|
||||
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
|
||||
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class Uom
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum BundleSaleStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Posted = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
|
||||
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
|
||||
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
|
||||
/// <see cref="Select"/> is the only type that reads <c>options</c>.
|
||||
/// </summary>
|
||||
public enum CustomFieldType
|
||||
{
|
||||
Text,
|
||||
Number,
|
||||
Checkbox,
|
||||
Date,
|
||||
Select
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum CustomerType
|
||||
{
|
||||
B2B = 1,
|
||||
B2C = 2,
|
||||
WalkIn = 3
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
|
||||
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
|
||||
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
|
||||
/// stock return (FR-MFG-17). Stored as a string.
|
||||
/// </summary>
|
||||
public enum ProductionRunStatus
|
||||
{
|
||||
InProgress,
|
||||
Completed,
|
||||
Cancelled
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
|
||||
/// <code>
|
||||
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
|
||||
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
|
||||
/// InProgress ──complete (produced/scrap/fields)──▶ Done
|
||||
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
|
||||
/// </code>
|
||||
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
|
||||
/// rework transition back into this set and is recorded as a
|
||||
/// <see cref="RunStageEventType"/> instead. Stored as a string.
|
||||
/// </summary>
|
||||
public enum ProductionStageStatus
|
||||
{
|
||||
Waiting,
|
||||
Ready,
|
||||
InProgress,
|
||||
Done,
|
||||
Approved
|
||||
}
|
||||
@@ -5,8 +5,5 @@ public enum ReasonContext
|
||||
{
|
||||
Adjustment,
|
||||
Return,
|
||||
Count,
|
||||
|
||||
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
|
||||
Production
|
||||
Count
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
|
||||
/// Every mutating action on a run writes exactly one event carrying who/when plus a
|
||||
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
|
||||
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
|
||||
/// </summary>
|
||||
public enum RunStageEventType
|
||||
{
|
||||
Start,
|
||||
Complete,
|
||||
Approve,
|
||||
Transfer,
|
||||
RejectIntake,
|
||||
TerminalReject,
|
||||
LeftoverReturn,
|
||||
Cancel,
|
||||
QuantityEdit
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesDiscountMode
|
||||
{
|
||||
Percentage = 1,
|
||||
Amount = 2
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesInvoiceStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Posted = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesInvoiceType
|
||||
{
|
||||
B2B = 1,
|
||||
B2C = 2,
|
||||
Cash = 3,
|
||||
Credit = 4
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum SalesSlipStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Posted = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
|
||||
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
|
||||
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
|
||||
/// parent stage's output and flow as internal WIP — they never touch the ledger.
|
||||
/// Stored as a string.
|
||||
/// </summary>
|
||||
public enum StageInputSource
|
||||
{
|
||||
Stock,
|
||||
Upstream
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
|
||||
/// <para>
|
||||
/// Deliberately explicit rather than inferred from whether the item happens to have a
|
||||
/// content size: templates outlive item edits, so an inferred unit would let adding a
|
||||
/// content size to an existing item silently reinterpret every saved formula — "300"
|
||||
/// meaning 300 packs would become 300 ml.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum StageQtyUnit
|
||||
{
|
||||
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
|
||||
Pack,
|
||||
|
||||
/// <summary>
|
||||
/// An amount of the item's content in its base content unit (ml or g), resolved to
|
||||
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
|
||||
/// </summary>
|
||||
Content
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
namespace ERPCore.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
|
||||
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
|
||||
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
|
||||
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
|
||||
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
|
||||
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
|
||||
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). Widening
|
||||
/// those columns would be a second Phase-1 schema change beyond the single deviation
|
||||
/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
|
||||
/// instead. docs/30 §A.2 and §D.5 are amended to match.</para>
|
||||
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
|
||||
/// document per movement. The run's own document number uses
|
||||
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
|
||||
/// </remarks>
|
||||
public static class LedgerSourceTypes
|
||||
{
|
||||
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
|
||||
public const string ProductionIssue = "PRDI";
|
||||
|
||||
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
|
||||
public const string ProductionReceipt = "PRDR";
|
||||
|
||||
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
|
||||
public const string ProductionReturn = "PRDL";
|
||||
|
||||
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
|
||||
public const string ProductionCancelReturn = "PRDC";
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Customers;
|
||||
|
||||
/// <summary>Customer resource used by sales documents.</summary>
|
||||
public sealed record CustomerDto(
|
||||
int CustomerId,
|
||||
string CustomerCode,
|
||||
CustomerType CustomerType,
|
||||
string Name,
|
||||
string? DisplayName,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? TaxRegistrationNo,
|
||||
decimal CreditLimit,
|
||||
int CreditDays,
|
||||
int? DefaultWarehouseId,
|
||||
EntityStatus Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
|
||||
public sealed class CreateCustomerRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
|
||||
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(200)] public string? DisplayName { get; set; }
|
||||
[StringLength(30)] public string? Phone { get; set; }
|
||||
[StringLength(100)] public string? Email { get; set; }
|
||||
[StringLength(250)] public string? AddressLine1 { get; set; }
|
||||
[StringLength(250)] public string? AddressLine2 { get; set; }
|
||||
[StringLength(100)] public string? City { get; set; }
|
||||
[StringLength(100)] public string? Country { get; set; }
|
||||
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
|
||||
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCustomerRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
|
||||
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(200)] public string? DisplayName { get; set; }
|
||||
[StringLength(30)] public string? Phone { get; set; }
|
||||
[StringLength(100)] public string? Email { get; set; }
|
||||
[StringLength(250)] public string? AddressLine1 { get; set; }
|
||||
[StringLength(250)] public string? AddressLine2 { get; set; }
|
||||
[StringLength(100)] public string? City { get; set; }
|
||||
[StringLength(100)] public string? Country { get; set; }
|
||||
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
|
||||
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCustomerStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -5,47 +5,32 @@ 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? BinId,
|
||||
int GrnLineId, int? PoLineId, int ItemId, int UomId, 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, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||
HoldStatus HoldStatus, int? BatchId);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
|
||||
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
decimal PaidAmount, decimal BalanceAmount);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
public sealed record GrnPaymentDto(
|
||||
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateGrnPaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
@@ -59,6 +44,7 @@ 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>
|
||||
@@ -73,11 +59,6 @@ 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,16 +5,12 @@ 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: the chosen values are encoded into the
|
||||
/// 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
|
||||
/// 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, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
int ItemTypeId, string Name, 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). ----
|
||||
@@ -22,21 +18,11 @@ 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,11 +9,7 @@ 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,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status);
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
@@ -21,22 +17,26 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
|
||||
/// <summary>
|
||||
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
|
||||
/// <para>
|
||||
/// <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.
|
||||
/// <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.
|
||||
/// </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,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> Conversions,
|
||||
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,20 +61,9 @@ 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
|
||||
@@ -91,20 +80,9 @@ 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
|
||||
@@ -123,3 +101,15 @@ 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 WarehouseId,
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
@@ -25,6 +25,7 @@ public sealed record PurchaseOrderSummaryDto(
|
||||
public sealed class CreatePoLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -9,13 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record PurchaseReturnSummaryDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Production;
|
||||
|
||||
// Production run contract (docs/30-BACKEND-PHASE2.md §D.2–D.3).
|
||||
//
|
||||
// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
|
||||
// status move only through the stage-action endpoints. Requests here carry quantities and
|
||||
// references, nothing else.
|
||||
|
||||
// --- responses ---------------------------------------------------------------
|
||||
|
||||
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
|
||||
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
|
||||
|
||||
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
|
||||
public sealed record RunSummaryDto(
|
||||
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||
decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
|
||||
StageSummaryDto StageSummary, int WarehouseId,
|
||||
int? FinishedItemId, string? FinishedItemName,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
|
||||
/// surfaced so the UI can preview the finished unit cost before approving the terminal.
|
||||
/// </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, 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. <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,
|
||||
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
|
||||
decimal TransferredQty, decimal AvailableToTransfer);
|
||||
|
||||
/// <summary>
|
||||
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
|
||||
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
|
||||
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
|
||||
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
|
||||
/// passed through verbatim.
|
||||
/// </summary>
|
||||
public sealed record RunStageDto(
|
||||
int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
|
||||
int EstimatedMinutes, decimal PosX, decimal PosY,
|
||||
ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
|
||||
DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
|
||||
IReadOnlyList<FieldDefDto> FieldDefs, JsonElement? FieldValues,
|
||||
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> Outputs);
|
||||
|
||||
public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
|
||||
|
||||
public sealed record RunEventDto(
|
||||
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
|
||||
public sealed record RunGraphDto(
|
||||
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||
int WarehouseId, int? OutputBinId,
|
||||
decimal TargetQty, decimal ScaleFactor,
|
||||
ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
|
||||
CostPoolDto CostPool,
|
||||
IReadOnlyList<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
|
||||
|
||||
// --- requests ----------------------------------------------------------------
|
||||
|
||||
public sealed class CreateRunRequest
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int TemplateId { get; set; }
|
||||
|
||||
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal TargetQty { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int WarehouseId { get; set; }
|
||||
|
||||
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
|
||||
public int? OutputBinId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
||||
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
||||
/// </summary>
|
||||
public sealed class UpdateStageQuantitiesRequest
|
||||
{
|
||||
public List<StageQuantityLine> Inputs { get; set; } = new();
|
||||
public List<StageQuantityLine> Outputs { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StageQuantityLine
|
||||
{
|
||||
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal PlannedQty { get; set; }
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Production;
|
||||
|
||||
// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
|
||||
// guards on the stage's current status, and returns the refreshed stage plus whatever
|
||||
// stock effects it caused.
|
||||
|
||||
// --- start -------------------------------------------------------------------
|
||||
|
||||
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
|
||||
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
|
||||
|
||||
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
|
||||
public sealed record ConsumedInputDto(
|
||||
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
|
||||
|
||||
public sealed record StartStageResultDto(
|
||||
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
|
||||
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
|
||||
RunStageDto Stage);
|
||||
|
||||
// --- complete ----------------------------------------------------------------
|
||||
|
||||
public sealed class CompleteStageRequest
|
||||
{
|
||||
[Required, MinLength(1)]
|
||||
public List<CompleteOutputLine> Outputs { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
|
||||
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
|
||||
/// </summary>
|
||||
public JsonElement? FieldValues { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CompleteOutputLine
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int RunOutputId { get; set; }
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal ProducedQty { get; set; }
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal ScrappedQty { get; set; }
|
||||
|
||||
/// <summary>Mandatory once <see cref="ScrappedQty"/> > 0; must be a Production reason.</summary>
|
||||
public int? ScrapReasonCodeId { get; set; }
|
||||
}
|
||||
|
||||
// --- approve / transfer ------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// One WIP hand-off from a parent output to a child input. Deliveries route by
|
||||
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
|
||||
/// </summary>
|
||||
public sealed record TransferDto(
|
||||
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
|
||||
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
|
||||
|
||||
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
|
||||
public sealed record ReceiptDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BinId,
|
||||
decimal QtyReceived, decimal UnitCost, decimal Value);
|
||||
|
||||
public sealed record ApproveStageResultDto(
|
||||
int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
|
||||
IReadOnlyList<TransferDto> Transfers,
|
||||
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
|
||||
RunStageDto Stage);
|
||||
|
||||
public sealed class ApproveStageRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional partial transfers. Omitted or empty means transfer the full available
|
||||
/// quantity of every output (FR-MFG-12).
|
||||
/// </summary>
|
||||
public List<TransferLine> Transfers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TransferLine
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int RunOutputId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit target. When an output feeds several child inputs the server
|
||||
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
|
||||
/// </summary>
|
||||
public int? RunInputId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TransferRemainderRequest
|
||||
{
|
||||
[Range(1, int.MaxValue)]
|
||||
public int RunOutputId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
public int? RunInputId { get; set; }
|
||||
}
|
||||
|
||||
public sealed record TransferResultDto(
|
||||
int RunStageId, IReadOnlyList<TransferDto> Transfers, RunStageDto Stage);
|
||||
|
||||
// --- leftover return ---------------------------------------------------------
|
||||
|
||||
public sealed class ReturnLeftoverRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
|
||||
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
|
||||
/// to stock. Cannot exceed consumed − already returned.
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
}
|
||||
|
||||
public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
|
||||
|
||||
public sealed record ReturnLeftoverResultDto(
|
||||
int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
|
||||
ProductionCreatedLayerDto CreatedLayer, IReadOnlyList<int> LedgerRefs, CostPoolDto CostPool);
|
||||
|
||||
// --- rejection / rework ------------------------------------------------------
|
||||
|
||||
public sealed class RejectRequest
|
||||
{
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
|
||||
public sealed record PulledBackDto(
|
||||
int RunInputId, int ParentRunStageId, int ParentRunOutputId,
|
||||
decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
|
||||
|
||||
public sealed record RejectIntakeResultDto(
|
||||
int RunStageId, ProductionStageStatus Status,
|
||||
IReadOnlyList<PulledBackDto> PulledBack, RunGraphDto Run);
|
||||
|
||||
public sealed record TerminalRejectResultDto(
|
||||
int RunStageId, int ReworkCount, RunGraphDto Run);
|
||||
|
||||
// --- cancel ------------------------------------------------------------------
|
||||
|
||||
public sealed class CancelRunRequest
|
||||
{
|
||||
public int? ReasonCodeId { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
|
||||
|
||||
/// <summary>
|
||||
/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
|
||||
/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
|
||||
/// </summary>
|
||||
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
|
||||
|
||||
public sealed record CancelRunResultDto(
|
||||
int RunId, ProductionRunStatus Status,
|
||||
IReadOnlyList<CancelReturnDto> Returns,
|
||||
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
|
||||
IReadOnlyList<int> LedgerRefs);
|
||||
@@ -1,186 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Production;
|
||||
|
||||
// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
|
||||
//
|
||||
// The `Key` vocabulary: every stage and every output carries a client-facing string key
|
||||
// alongside its database id. On a GET the key IS the stringified id; on a save the client
|
||||
// echoes those keys back for rows it kept and mints "tmp-<uuid>" keys for rows it just
|
||||
// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
|
||||
// what lets one payload shape — and one validator — serve both POST (nothing has an id
|
||||
// yet) and PUT (most things do).
|
||||
|
||||
// --- responses ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>StageNames</c> is an addition to the documented shape. The template overview is a
|
||||
/// canvas showing every template as a production line with its stages left-to-right
|
||||
/// (docs/21 §1), so it needs the names for every listed row — without them the client would
|
||||
/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
|
||||
/// matching the graph endpoint.
|
||||
/// </remarks>
|
||||
public sealed record TemplateSummaryDto(
|
||||
int TemplateId, string Code, string Name, EntityStatus Status,
|
||||
int StageCount, IReadOnlyList<string> StageNames,
|
||||
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
|
||||
|
||||
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
|
||||
public sealed record FieldDefDto(
|
||||
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? Options, bool Required);
|
||||
|
||||
public sealed record StageInputDto(
|
||||
int InputId, StageInputSource Source, int? ItemId,
|
||||
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);
|
||||
|
||||
public sealed record TemplateStageDto(
|
||||
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
|
||||
decimal PosX, decimal PosY, IReadOnlyList<FieldDefDto> FieldDefs,
|
||||
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
|
||||
|
||||
public sealed record TemplateEdgeDto(
|
||||
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
|
||||
|
||||
/// <summary>
|
||||
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
|
||||
/// meaning whatsoever, and invisible to the graph validator.
|
||||
/// </summary>
|
||||
public sealed record CanvasAnnotationDto(
|
||||
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
|
||||
string? Label, decimal? Rotation);
|
||||
|
||||
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
|
||||
/// <remarks>
|
||||
/// <c>ActiveRunCount</c> and <c>Annotations</c> are additions to the documented shape.
|
||||
/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
|
||||
/// the condition <c>UpdateAsync</c> enforces — without it the builder would need a second
|
||||
/// request to the list endpoint just to know whether to disable itself.
|
||||
/// </remarks>
|
||||
public sealed record TemplateGraphDto(
|
||||
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
|
||||
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
|
||||
IReadOnlyList<CanvasAnnotationDto> Annotations, int ActiveRunCount,
|
||||
int CreatedBy, DateTime CreatedAt);
|
||||
|
||||
// --- requests ----------------------------------------------------------------
|
||||
// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
|
||||
// POST and PUT share this shape; PUT replaces the whole graph.
|
||||
|
||||
public sealed class SaveTemplateRequest
|
||||
{
|
||||
[Required, StringLength(30, MinimumLength = 1)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(150, MinimumLength = 1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required, MinLength(1)]
|
||||
public List<SaveStageRequest> Stages { get; set; } = new();
|
||||
|
||||
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
|
||||
public List<SaveEdgeRequest> Edges { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
|
||||
/// column — a hand-rolled request should not be able to store an unbounded blob.
|
||||
/// </summary>
|
||||
[MaxLength(200)]
|
||||
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SaveStageRequest
|
||||
{
|
||||
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
|
||||
[Required, StringLength(60, MinimumLength = 1)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(150, MinimumLength = 1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(60)]
|
||||
public string? RoleLabel { get; set; }
|
||||
|
||||
[Range(0, 1_000_000)]
|
||||
public int EstimatedMinutes { get; set; }
|
||||
|
||||
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
|
||||
public decimal PosX { get; set; }
|
||||
public decimal PosY { get; set; }
|
||||
|
||||
public List<FieldDefDto> FieldDefs { get; set; } = new();
|
||||
public List<SaveInputRequest> Inputs { get; set; } = new();
|
||||
public List<SaveOutputRequest> Outputs { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SaveInputRequest
|
||||
{
|
||||
[Required]
|
||||
public StageInputSource Source { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
|
||||
[StringLength(60)]
|
||||
public string? FromOutputKey { 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; }
|
||||
}
|
||||
|
||||
public sealed class SaveOutputRequest
|
||||
{
|
||||
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
|
||||
[Required, StringLength(60, MinimumLength = 1)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
|
||||
[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; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SaveEdgeRequest
|
||||
{
|
||||
[Required, StringLength(60, MinimumLength = 1)]
|
||||
public string ParentKey { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(60, MinimumLength = 1)]
|
||||
public string ChildKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
|
||||
public sealed class UpdateTemplateStatusRequest
|
||||
{
|
||||
[Required]
|
||||
public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
// Responses -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Qty/revenue sold for one item across a closed (or about-to-close) day, for the day-end report.
|
||||
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
|
||||
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
|
||||
|
||||
public sealed record SalesDayEndDto(
|
||||
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
|
||||
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
|
||||
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown);
|
||||
|
||||
public sealed record SalesDayEndSummaryDto(
|
||||
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
|
||||
int SlipCount, int BundleCount, decimal GrandTotal, string? GlJournalNo, DateTime ClosedAt);
|
||||
|
||||
/// <summary>What closing right now would include — call before <c>POST /sales-day-end</c> so the
|
||||
/// cashier can see the day's totals, and any still-Draft slips/bundles blocking the close.</summary>
|
||||
public sealed record SalesDayEndPreviewDto(
|
||||
int CashierUserId, DateOnly BusinessDate, bool AlreadyClosed,
|
||||
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
|
||||
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
|
||||
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose);
|
||||
|
||||
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
|
||||
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
|
||||
|
||||
// Requests ------------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateSalesDayEndRequest
|
||||
{
|
||||
[Required] public int CashierUserId { get; set; }
|
||||
/// <summary>Defaults to today (UTC) when omitted.</summary>
|
||||
public DateOnly? BusinessDate { get; set; }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesInvoiceLineDto(
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
|
||||
public sealed record SalesInvoiceTotalsDto(
|
||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record SalesInvoiceDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
||||
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
|
||||
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||
|
||||
public sealed record SalesInvoiceSummaryDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateSalesInvoicePaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
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 WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
|
||||
public bool AllowManualPriceOverride { get; set; }
|
||||
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
|
||||
[Range(0, 100)] public decimal TaxPct { get; set; }
|
||||
public bool IsFreeIssue { get; set; }
|
||||
public int? ParentLineId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateSalesInvoiceRequest
|
||||
{
|
||||
[Required] public int CustomerId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
||||
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UpdateSalesInvoiceRequest
|
||||
{
|
||||
[Required] public int CustomerId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
||||
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
@@ -1,68 +0,0 @@
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesDailySummaryRowDto(
|
||||
DateOnly Date,
|
||||
int InvoiceCount,
|
||||
int SlipCount,
|
||||
decimal InvoiceSubtotal,
|
||||
decimal SlipSubtotal,
|
||||
decimal DiscountTotal,
|
||||
decimal FreeQtyTotal,
|
||||
decimal TaxTotal,
|
||||
decimal GrandTotal);
|
||||
|
||||
public sealed record SalesItemSummaryRowDto(
|
||||
int ItemId,
|
||||
string ItemName,
|
||||
decimal SoldQty,
|
||||
decimal FreeQty,
|
||||
decimal GrossAmount,
|
||||
decimal DiscountTotal,
|
||||
decimal TaxTotal,
|
||||
decimal NetAmount);
|
||||
|
||||
public sealed record SalesCustomerSummaryRowDto(
|
||||
int CustomerId,
|
||||
string CustomerName,
|
||||
int InvoiceCount,
|
||||
int SlipCount,
|
||||
decimal SoldQty,
|
||||
decimal FreeQty,
|
||||
decimal GrossAmount,
|
||||
decimal DiscountTotal,
|
||||
decimal TaxTotal,
|
||||
decimal NetAmount);
|
||||
|
||||
public sealed record SalesWarehouseSummaryRowDto(
|
||||
int WarehouseId,
|
||||
string WarehouseName,
|
||||
int InvoiceCount,
|
||||
int SlipCount,
|
||||
decimal SoldQty,
|
||||
decimal FreeQty,
|
||||
decimal GrossAmount,
|
||||
decimal DiscountTotal,
|
||||
decimal TaxTotal,
|
||||
decimal NetAmount);
|
||||
|
||||
public sealed record SalesDiscountSummaryRowDto(
|
||||
string DocumentType,
|
||||
string DocumentNo,
|
||||
DateTime DocumentDate,
|
||||
string CustomerName,
|
||||
decimal Subtotal,
|
||||
decimal DiscountTotal,
|
||||
decimal TaxTotal,
|
||||
decimal NetAmount);
|
||||
|
||||
public sealed record SalesFreeIssueSummaryRowDto(
|
||||
string DocumentType,
|
||||
string DocumentNo,
|
||||
DateTime DocumentDate,
|
||||
string CustomerName,
|
||||
int ItemId,
|
||||
string ItemName,
|
||||
decimal FreeQty,
|
||||
decimal FreeValue,
|
||||
int WarehouseId,
|
||||
string WarehouseName);
|
||||
@@ -1,21 +0,0 @@
|
||||
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);
|
||||
@@ -1,44 +0,0 @@
|
||||
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, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
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, string? GlJournalNo);
|
||||
|
||||
/// <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();
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesSlipLineDto(
|
||||
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
|
||||
public sealed record SalesSlipTotalsDto(
|
||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record SalesSlipDto(
|
||||
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList<SalesSlipLineDto> Lines);
|
||||
|
||||
public sealed record SalesSlipSummaryDto(
|
||||
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||
|
||||
public sealed record SalesSlipPostingIssueDto(
|
||||
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||
|
||||
public sealed record SalesSlipPostingCheckDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
||||
IReadOnlyList<SalesSlipPostingIssueDto> Issues);
|
||||
|
||||
public sealed record FreeIssueSummaryDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
|
||||
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
|
||||
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 WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
|
||||
public bool AllowManualPriceOverride { get; set; }
|
||||
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
|
||||
[Range(0, 100)] public decimal TaxPct { get; set; }
|
||||
public bool IsFreeIssue { get; set; }
|
||||
public int? ParentLineId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateSalesSlipRequest
|
||||
{
|
||||
[Required] public int CustomerId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required] public int CashierUserId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UpdateSalesSlipRequest
|
||||
{
|
||||
[Required] public int CustomerId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required] public int CashierUserId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -9,13 +9,12 @@ public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, in
|
||||
|
||||
public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record AdjustmentSummaryDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record CountSummaryDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>`) with its
|
||||
/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the
|
||||
/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response
|
||||
/// body straight through, unparsed — GL's own response (status, content-type, body) is
|
||||
/// returned exactly as received; nothing here reshapes it.
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty;
|
||||
|
||||
public async Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
{
|
||||
var relativeUri = path.TrimStart('/') + queryString;
|
||||
using var request = new HttpRequestMessage(method, relativeUri);
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
|
||||
|
||||
if (body is not null && method != HttpMethod.Get)
|
||||
{
|
||||
var content = new StreamContent(body);
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
request.Content = content;
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503);
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
return new GeneralLedgerResponse
|
||||
{
|
||||
StatusCode = (int)response.StatusCode,
|
||||
ContentType = response.Content.Headers.ContentType?.ToString(),
|
||||
Body = responseBody
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Raw HTTP result from the external General Ledger service — status code, content
|
||||
/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own
|
||||
/// envelope (see the GL service's own API reference) is passed through byte-for-byte
|
||||
/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision
|
||||
/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerResponse
|
||||
{
|
||||
public int StatusCode { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string Body { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HTTP transport to the external General Ledger service. Injects the shared
|
||||
/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim —
|
||||
/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only
|
||||
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Customer> builder)
|
||||
{
|
||||
builder.ToTable("customers");
|
||||
builder.HasKey(c => c.CustomerId);
|
||||
|
||||
builder.Property(c => c.CustomerCode).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(c => c.CustomerCode).IsUnique();
|
||||
|
||||
builder.Property(c => c.CustomerType)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(CustomerType.B2C);
|
||||
|
||||
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
builder.Property(c => c.DisplayName).HasMaxLength(200);
|
||||
builder.Property(c => c.Phone).HasMaxLength(30);
|
||||
builder.Property(c => c.Email).HasMaxLength(100);
|
||||
builder.Property(c => c.AddressLine1).HasMaxLength(250);
|
||||
builder.Property(c => c.AddressLine2).HasMaxLength(250);
|
||||
builder.Property(c => c.City).HasMaxLength(100);
|
||||
builder.Property(c => c.Country).HasMaxLength(100);
|
||||
builder.Property(c => c.TaxRegistrationNo).HasMaxLength(50);
|
||||
|
||||
builder.Property(c => c.CreditLimit).HasPrecision(18, 4);
|
||||
builder.Property(c => c.CreditDays).IsRequired();
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.Property(c => c.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(c => c.CreatedAt).IsRequired();
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(c => c.Status);
|
||||
builder.HasIndex(c => c.CustomerType);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
|
||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.CreatedAt).IsRequired();
|
||||
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
@@ -31,27 +28,6 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnPayment> builder)
|
||||
{
|
||||
builder.ToTable("grn_payments");
|
||||
builder.HasKey(p => p.GrnPaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.GrnId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||
@@ -73,25 +49,8 @@ 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,23 +22,10 @@ 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,11 +19,6 @@ 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);
|
||||
|
||||
@@ -36,10 +36,7 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
|
||||
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
|
||||
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,26 +42,10 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
||||
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
|
||||
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
|
||||
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
|
||||
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
|
||||
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
|
||||
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
|
||||
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
|
||||
// renamed Code, so any role already granted this permission keeps it.
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one
|
||||
// file, following the StockConfiguration.cs precedent of grouping an aggregate's
|
||||
// configurations together.
|
||||
//
|
||||
// Cascade shape (deliberate): the two aggregate roots cascade to their own children —
|
||||
// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs.
|
||||
// Every *cross* reference is Restrict, because making them cascade would give EF two
|
||||
// delete paths to the same table and the model validator rejects that. The consequence
|
||||
// is an ordering rule for the service layer: when replacing a graph, delete edges before
|
||||
// stages and inputs before outputs.
|
||||
|
||||
public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<ProductionTemplate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductionTemplate> builder)
|
||||
{
|
||||
builder.ToTable("production_templates");
|
||||
builder.HasKey(t => t.TemplateId);
|
||||
|
||||
builder.Property(t => t.Code).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(t => t.Code).IsUnique();
|
||||
|
||||
builder.Property(t => t.Name).IsRequired().HasMaxLength(150);
|
||||
builder.Property(t => t.Description).HasMaxLength(500);
|
||||
|
||||
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
|
||||
// Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
|
||||
// typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
|
||||
builder.Property(t => t.Annotations).HasColumnType("jsonb");
|
||||
|
||||
builder.Property(t => t.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(t => t.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(t => t.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<TemplateStage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TemplateStage> builder)
|
||||
{
|
||||
builder.ToTable("template_stages");
|
||||
builder.HasKey(s => s.StageId);
|
||||
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
|
||||
builder.Property(s => s.RoleLabel).HasMaxLength(60);
|
||||
builder.Property(s => s.PosX).HasPrecision(18, 4);
|
||||
builder.Property(s => s.PosY).HasPrecision(18, 4);
|
||||
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
|
||||
|
||||
builder.HasOne(s => s.Template).WithMany(t => t.Stages)
|
||||
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StageEdgeConfiguration : IEntityTypeConfiguration<StageEdge>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StageEdge> builder)
|
||||
{
|
||||
builder.ToTable("stage_edges");
|
||||
builder.HasKey(e => e.EdgeId);
|
||||
|
||||
builder.HasOne(e => e.Template).WithMany(t => t.Edges)
|
||||
.HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(e => e.ParentStage).WithMany()
|
||||
.HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(e => e.ChildStage).WithMany()
|
||||
.HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// One arrow per ordered pair; self-loops and cycles are rejected by the validator.
|
||||
builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInput>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StageInput> builder)
|
||||
{
|
||||
builder.ToTable("stage_inputs");
|
||||
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.FromOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StageOutputConfiguration : IEntityTypeConfiguration<StageOutput>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StageOutput> builder)
|
||||
{
|
||||
builder.ToTable("stage_outputs");
|
||||
builder.HasKey(o => o.OutputId);
|
||||
|
||||
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
|
||||
builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(o => o.Stage).WithMany(s => s.Outputs)
|
||||
.HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<ProductionRun>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductionRun> builder)
|
||||
{
|
||||
builder.ToTable("production_runs");
|
||||
builder.HasKey(r => r.RunId);
|
||||
|
||||
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(r => r.DocNo).IsUnique();
|
||||
|
||||
builder.Property(r => r.TargetQty).HasPrecision(18, 4);
|
||||
builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
|
||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(r => r.CreatedAt).IsRequired();
|
||||
builder.Property(r => r.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(r => r.Template).WithMany(t => t.Runs)
|
||||
.HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06).
|
||||
builder.HasIndex(r => r.Status);
|
||||
builder.HasIndex(r => new { r.TemplateId, r.Status });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RunStage> builder)
|
||||
{
|
||||
builder.ToTable("run_stages");
|
||||
builder.HasKey(s => s.RunStageId);
|
||||
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
|
||||
builder.Property(s => s.RoleLabel).HasMaxLength(60);
|
||||
builder.Property(s => s.PosX).HasPrecision(18, 4);
|
||||
builder.Property(s => s.PosY).HasPrecision(18, 4);
|
||||
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
|
||||
builder.Property(s => s.FieldValues).HasColumnType("jsonb");
|
||||
builder.Property(s => s.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(s => s.Run).WithMany(r => r.Stages)
|
||||
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// SetNull, not Restrict: a template edit may delete a stage that completed or
|
||||
// cancelled runs still point at. Everything needed to render such a run is copied
|
||||
// onto this row, so losing the provenance link is the intended trade (FR-MFG-06).
|
||||
builder.HasOne(s => s.TemplateStage).WithMany()
|
||||
.HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasIndex(s => new { s.RunId, s.Status });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RunEdgeConfiguration : IEntityTypeConfiguration<RunEdge>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RunEdge> builder)
|
||||
{
|
||||
builder.ToTable("run_edges");
|
||||
builder.HasKey(e => e.RunEdgeId);
|
||||
|
||||
builder.HasOne(e => e.Run).WithMany(r => r.Edges)
|
||||
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(e => e.ParentRunStage).WithMany()
|
||||
.HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(e => e.ChildRunStage).WithMany()
|
||||
.HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunStageInput>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RunStageInput> builder)
|
||||
{
|
||||
builder.ToTable("run_stage_inputs");
|
||||
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);
|
||||
builder.Property(i => i.DeliveredQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ReturnedQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ReturnedValue).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs)
|
||||
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.FromRunOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Transfers route by the source output, so this is the hot lookup.
|
||||
builder.HasIndex(i => i.FromRunOutputId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration<RunStageOutput>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RunStageOutput> builder)
|
||||
{
|
||||
builder.ToTable("run_stage_outputs");
|
||||
builder.HasKey(o => o.RunOutputId);
|
||||
|
||||
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
|
||||
builder.Property(o => o.PlannedQty).HasPrecision(18, 4);
|
||||
builder.Property(o => o.ProducedQty).HasPrecision(18, 4);
|
||||
builder.Property(o => o.ScrappedQty).HasPrecision(18, 4);
|
||||
builder.Property(o => o.TransferredQty).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs)
|
||||
.HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunStageEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RunStageEvent> builder)
|
||||
{
|
||||
// Append-only history: the app never updates or deletes these rows.
|
||||
builder.ToTable("run_stage_events");
|
||||
builder.HasKey(e => e.EventId);
|
||||
|
||||
builder.Property(e => e.EventType).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(e => e.Note).HasMaxLength(500);
|
||||
builder.Property(e => e.Payload).HasColumnType("jsonb");
|
||||
builder.Property(e => e.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(e => e.Run).WithMany(r => r.Events)
|
||||
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// SetNull rather than Cascade: the run-level cascade above already removes these
|
||||
// rows, and a second cascade path (run → stage → event) is what EF rejects.
|
||||
builder.HasOne(e => e.RunStage).WithMany(s => s.Events)
|
||||
.HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// The run-detail timeline reads the whole run's history in one ordered pass.
|
||||
builder.HasIndex(e => new { e.RunId, e.EventId });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user