Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea6158cd9 |
+5
-8
@@ -31,11 +31,8 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# ── Migrations ─────────────────────────────────────────────────────────
|
# ── Migrations ─────────────────────────────────────────────────────────
|
||||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
# New EF Core migrations are not committed. Note the 4 migrations already in
|
||||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
|
||||||
# migrations add` after the initial 4 silently produced a migration git would
|
# not apply to tracked files — so edits to those still get committed as normal.
|
||||||
# never see, while the (tracked) snapshot's changes committed normally —
|
# Untracking them too takes `git rm --cached`.
|
||||||
# so the snapshot kept claiming tables existed that no migration in git
|
**/Migrations/
|
||||||
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
|
|
||||||
# tables were missing from the actual database for exactly this reason.
|
|
||||||
# Migrations now stay tracked like any other source file — commit them.
|
|
||||||
|
|||||||
@@ -1,79 +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] int? customerId,
|
|
||||||
[FromQuery] int? warehouseId,
|
|
||||||
CancellationToken ct)
|
|
||||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
|
||||||
|
|
||||||
[HttpGet("{bundleSaleId:int}")]
|
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await _bundles.GetAsync(bundleSaleId, ct);
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{bundleSaleId:int}/posting-check")]
|
|
||||||
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
|
|
||||||
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
|
|
||||||
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await _bundles.CreateAsync(request, ct);
|
|
||||||
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{bundleSaleId:int}")]
|
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{bundleSaleId:int}/post")]
|
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
|
|
||||||
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
|
|
||||||
|
|
||||||
[HttpPost("{bundleSaleId:int}/cancel")]
|
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
|
|
||||||
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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-invoices")]
|
|
||||||
public sealed class SalesInvoicesController : ApiControllerBase
|
|
||||||
{
|
|
||||||
private readonly ISalesInvoiceService _invoices;
|
|
||||||
|
|
||||||
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
|
|
||||||
[FromQuery] PageQuery query,
|
|
||||||
[FromQuery] SalesInvoiceStatus? status,
|
|
||||||
[FromQuery] int? customerId,
|
|
||||||
[FromQuery] int? warehouseId,
|
|
||||||
CancellationToken ct)
|
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
|
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await _invoices.GetAsync(salesInvoiceId, ct);
|
|
||||||
if (result is null) return NotFound();
|
|
||||||
SetETag(result.RowVersion);
|
|
||||||
return Ok(result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}/posting-check")]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
|
|
||||||
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await _invoices.CreateAsync(request, ct);
|
|
||||||
SetETag(result.RowVersion);
|
|
||||||
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{salesInvoiceId:int}")]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var expected = RequireIfMatch();
|
|
||||||
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
|
|
||||||
SetETag(result.RowVersion);
|
|
||||||
return Ok(result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{salesInvoiceId:int}/post")]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
|
|
||||||
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
|
|
||||||
|
|
||||||
[HttpPost("{salesInvoiceId:int}/cancel")]
|
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
|
||||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
|
||||||
}
|
|
||||||
@@ -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,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,10 +14,4 @@ public static class DocumentTypes
|
|||||||
public const string Adjustment = "ADJ";
|
public const string Adjustment = "ADJ";
|
||||||
public const string Count = "CNT";
|
public const string Count = "CNT";
|
||||||
public const string PurchaseReturn = "PRET";
|
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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +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; }
|
|
||||||
|
|
||||||
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
|
||||||
}
|
|
||||||
@@ -1,24 +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 UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
|
||||||
public Warehouse? Warehouse { get; set; }
|
|
||||||
public decimal UnitPrice { get; set; }
|
|
||||||
public decimal LineTotal { get; set; }
|
|
||||||
public bool IncludeInBundle { get; set; } = true;
|
|
||||||
public bool IsComponent { get; set; } = true;
|
|
||||||
public int? ParentLineId { get; set; }
|
|
||||||
}
|
|
||||||
@@ -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,20 +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 UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
|
||||||
public Warehouse? Warehouse { get; set; }
|
|
||||||
|
|
||||||
public decimal Qty { get; set; }
|
|
||||||
public decimal UnitPrice { get; set; }
|
|
||||||
public bool IncludeInBundle { get; set; } = true;
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
@@ -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,55 +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; }
|
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</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,43 +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;
|
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Scaled at creation; per-run editable until the stage starts.</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,41 +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; }
|
|
||||||
|
|
||||||
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>();
|
|
||||||
}
|
|
||||||
@@ -1,38 +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 UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
|
||||||
public Warehouse? Warehouse { get; set; }
|
|
||||||
|
|
||||||
public decimal UnitPrice { get; set; }
|
|
||||||
public decimal BaseCost { get; set; }
|
|
||||||
public string PriceSource { get; set; } = string.Empty;
|
|
||||||
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
|
||||||
public decimal DiscountPct { get; set; }
|
|
||||||
public decimal DiscountAmount { get; set; }
|
|
||||||
public decimal NetUnitPrice { get; set; }
|
|
||||||
public decimal LineTotal { get; set; }
|
|
||||||
public decimal TaxPct { get; set; }
|
|
||||||
public decimal TaxAmount { get; set; }
|
|
||||||
public bool IsFreeIssue { get; set; }
|
|
||||||
public int? ParentLineId { get; set; }
|
|
||||||
|
|
||||||
public uint RowVersion { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,36 +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; }
|
|
||||||
|
|
||||||
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,38 +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 UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
public int WarehouseId { get; set; }
|
|
||||||
public Warehouse? Warehouse { get; set; }
|
|
||||||
|
|
||||||
public decimal UnitPrice { get; set; }
|
|
||||||
public decimal BaseCost { get; set; }
|
|
||||||
public string PriceSource { get; set; } = string.Empty;
|
|
||||||
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
|
||||||
public decimal DiscountPct { get; set; }
|
|
||||||
public decimal DiscountAmount { get; set; }
|
|
||||||
public decimal NetUnitPrice { get; set; }
|
|
||||||
public decimal LineTotal { get; set; }
|
|
||||||
public decimal TaxPct { get; set; }
|
|
||||||
public decimal TaxAmount { get; set; }
|
|
||||||
public bool IsFreeIssue { get; set; }
|
|
||||||
public int? ParentLineId { get; set; }
|
|
||||||
|
|
||||||
public uint RowVersion { get; set; }
|
|
||||||
}
|
|
||||||
@@ -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,39 +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; }
|
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
public decimal QtyPerBatch { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,27 +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;
|
|
||||||
|
|
||||||
public int UomId { get; set; }
|
|
||||||
public Uom? Uom { get; set; }
|
|
||||||
|
|
||||||
public decimal QtyPerBatch { get; set; }
|
|
||||||
}
|
|
||||||
@@ -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,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,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,
|
Adjustment,
|
||||||
Return,
|
Return,
|
||||||
Count,
|
Count
|
||||||
|
|
||||||
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
|
|
||||||
Production
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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; }
|
|
||||||
}
|
|
||||||
@@ -1,113 +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);
|
|
||||||
|
|
||||||
public sealed record RunStageInputDto(
|
|
||||||
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
|
|
||||||
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.
|
|
||||||
/// </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,177 +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, int UomId, decimal QtyPerBatch);
|
|
||||||
|
|
||||||
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; }
|
|
||||||
|
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int UomId { get; set; }
|
|
||||||
|
|
||||||
[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;
|
|
||||||
|
|
||||||
[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,83 +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 UomId, int WarehouseId,
|
|
||||||
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
|
|
||||||
|
|
||||||
public sealed record BundleSaleDto(
|
|
||||||
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
|
||||||
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
|
|
||||||
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
|
|
||||||
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
|
|
||||||
IReadOnlyList<BundleSaleLineDto> Lines);
|
|
||||||
|
|
||||||
public sealed record BundleSaleSummaryDto(
|
|
||||||
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
|
||||||
int WarehouseId, string BundleName, string BundleCode, BundleSaleStatus Status,
|
|
||||||
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
|
|
||||||
|
|
||||||
public sealed record BundleSaleTemplateLineDto(
|
|
||||||
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
|
|
||||||
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
|
||||||
|
|
||||||
public sealed record BundleSaleTemplateDto(
|
|
||||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
|
||||||
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
|
|
||||||
|
|
||||||
public sealed record BundleSaleTemplateSummaryDto(
|
|
||||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
|
||||||
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
|
||||||
|
|
||||||
public sealed record BundleSalePostingIssueDto(
|
|
||||||
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
|
||||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
|
|
||||||
|
|
||||||
public sealed record BundleSalePostingCheckDto(
|
|
||||||
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
|
||||||
IReadOnlyList<BundleSalePostingIssueDto> Issues);
|
|
||||||
|
|
||||||
public sealed class CreateBundleSaleTemplateLineRequest
|
|
||||||
{
|
|
||||||
[Required] public int ItemId { get; set; }
|
|
||||||
[Required] public int UomId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
|
||||||
public bool IncludeInBundle { get; set; } = true;
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CreateBundleSaleTemplateRequest
|
|
||||||
{
|
|
||||||
[Required] public string TemplateCode { get; set; } = string.Empty;
|
|
||||||
[Required] public string TemplateName { get; set; } = string.Empty;
|
|
||||||
public string? Description { get; set; }
|
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CreateBundleSaleRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required] public int CashierUserId { get; set; }
|
|
||||||
[Required] public int BundleSaleTemplateId { get; set; }
|
|
||||||
[Required] public string BundleName { get; set; } = string.Empty;
|
|
||||||
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
|
||||||
public bool AllowPriceOverride { get; set; }
|
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateBundleSaleRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required] public int CashierUserId { get; set; }
|
|
||||||
[Required] public int BundleSaleTemplateId { get; set; }
|
|
||||||
[Required] public string BundleName { get; set; } = string.Empty;
|
|
||||||
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
|
||||||
public bool AllowPriceOverride { get; set; }
|
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
@@ -1,67 +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 UomId,
|
|
||||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
|
||||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
|
||||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
|
||||||
|
|
||||||
public sealed record SalesInvoiceTotalsDto(
|
|
||||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
|
||||||
decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount);
|
|
||||||
|
|
||||||
public sealed record SalesInvoiceDto(
|
|
||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
|
||||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
|
||||||
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
|
||||||
|
|
||||||
public sealed record SalesInvoiceSummaryDto(
|
|
||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
|
||||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
|
||||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
|
||||||
|
|
||||||
public sealed record SalesInvoicePostingIssueDto(
|
|
||||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
|
||||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
|
||||||
|
|
||||||
public sealed record SalesInvoicePostingCheckDto(
|
|
||||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
|
||||||
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
|
|
||||||
|
|
||||||
public sealed class CreateSalesInvoiceLineRequest
|
|
||||||
{
|
|
||||||
[Required] public int ItemId { get; set; }
|
|
||||||
[Required] public int UomId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
|
|
||||||
public bool AllowManualPriceOverride { get; set; }
|
|
||||||
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
|
||||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
|
|
||||||
[Range(0, 100)] public decimal TaxPct { get; set; }
|
|
||||||
public bool IsFreeIssue { get; set; }
|
|
||||||
public int? ParentLineId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CreateSalesInvoiceRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
|
||||||
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateSalesInvoiceRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
|
|
||||||
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
@@ -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,77 +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 UomId,
|
|
||||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
|
||||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
|
||||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
|
||||||
|
|
||||||
public sealed record SalesSlipTotalsDto(
|
|
||||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
|
||||||
decimal PaidAmount, decimal BalanceAmount);
|
|
||||||
|
|
||||||
public sealed record SalesSlipDto(
|
|
||||||
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
|
||||||
string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status,
|
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList<SalesSlipLineDto> Lines);
|
|
||||||
|
|
||||||
public sealed record SalesSlipSummaryDto(
|
|
||||||
int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
|
|
||||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
|
||||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
|
||||||
|
|
||||||
public sealed record SalesSlipPostingIssueDto(
|
|
||||||
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
|
||||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
|
||||||
|
|
||||||
public sealed record SalesSlipPostingCheckDto(
|
|
||||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
|
||||||
IReadOnlyList<SalesSlipPostingIssueDto> Issues);
|
|
||||||
|
|
||||||
public sealed record FreeIssueSummaryDto(
|
|
||||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
|
|
||||||
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
|
|
||||||
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
|
||||||
|
|
||||||
public sealed record FreeIssueDto(
|
|
||||||
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
|
|
||||||
int CustomerId, string CustomerSnapshotName, int WarehouseId, string WarehouseName,
|
|
||||||
int CashierUserId, DateTime CreatedAt, DateTime? UpdatedAt, FreeIssueSummaryDto Summary,
|
|
||||||
IReadOnlyList<SalesSlipLineDto> Lines);
|
|
||||||
|
|
||||||
public sealed class CreateSalesSlipLineRequest
|
|
||||||
{
|
|
||||||
[Required] public int ItemId { get; set; }
|
|
||||||
[Required] public int UomId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
|
|
||||||
public bool AllowManualPriceOverride { get; set; }
|
|
||||||
[Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
|
|
||||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
|
|
||||||
[Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
|
|
||||||
[Range(0, 100)] public decimal TaxPct { get; set; }
|
|
||||||
public bool IsFreeIssue { get; set; }
|
|
||||||
public int? ParentLineId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CreateSalesSlipRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required] public int CashierUserId { get; set; }
|
|
||||||
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateSalesSlipRequest
|
|
||||||
{
|
|
||||||
[Required] public int CustomerId { get; set; }
|
|
||||||
[Required] public int WarehouseId { get; set; }
|
|
||||||
[Required] public int CashierUserId { get; set; }
|
|
||||||
[Required, MinLength(1)] public List<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
|
||||||
}
|
|
||||||
@@ -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,24 +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.Uom).WithMany().HasForeignKey(x => x.UomId).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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +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.Uom).WithMany().HasForeignKey(x => x.UomId).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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = 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 = 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 = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
|
||||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
|
||||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,26 +42,10 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
|||||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
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;
|
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||||
// these procurement rows were never actually migrated into the database before now.
|
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||||
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
|
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||||
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
|
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||||
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 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.QtyPerBatch).HasPrecision(18, 4);
|
|
||||||
|
|
||||||
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
|
|
||||||
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
|
||||||
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
|
||||||
builder.HasOne(i => i.FromOutput).WithMany()
|
|
||||||
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.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.Uom).WithMany().HasForeignKey(i => i.UomId).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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +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 SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesInvoice>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<SalesInvoice> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("sales_invoices");
|
|
||||||
builder.HasKey(x => x.SalesInvoiceId);
|
|
||||||
|
|
||||||
builder.Property(x => x.InvoiceNo).IsRequired().HasMaxLength(50);
|
|
||||||
builder.HasIndex(x => x.InvoiceNo).IsUnique();
|
|
||||||
|
|
||||||
builder.Property(x => x.InvoiceDate).IsRequired();
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Customer)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.CustomerId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
|
|
||||||
builder.Property(x => x.CustomerSnapshotTaxNo).HasMaxLength(50);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Warehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.WarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.InvoiceType)
|
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
|
||||||
.HasDefaultValue(SalesInvoiceType.B2C);
|
|
||||||
|
|
||||||
builder.Property(x => x.Status)
|
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
|
||||||
.HasDefaultValue(SalesInvoiceStatus.Draft);
|
|
||||||
|
|
||||||
foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) })
|
|
||||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
|
||||||
|
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
|
||||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
|
||||||
|
|
||||||
builder.HasIndex(x => x.Status);
|
|
||||||
builder.HasIndex(x => x.InvoiceDate);
|
|
||||||
|
|
||||||
builder.HasMany(x => x.Lines)
|
|
||||||
.WithOne(x => x.SalesInvoice)
|
|
||||||
.HasForeignKey(x => x.SalesInvoiceId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<SalesInvoiceLine>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("sales_invoice_lines");
|
|
||||||
builder.HasKey(x => x.SalesInvoiceLineId);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Item)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.ItemId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
|
||||||
|
|
||||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
|
||||||
builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
|
||||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Uom)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.UomId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Warehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.WarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.PriceSource).HasMaxLength(50);
|
|
||||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +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 SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<SalesSlip> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("sales_slips");
|
|
||||||
builder.HasKey(x => x.SalesSlipId);
|
|
||||||
|
|
||||||
builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50);
|
|
||||||
builder.HasIndex(x => x.SlipNo).IsUnique();
|
|
||||||
|
|
||||||
builder.Property(x => x.SlipDate).IsRequired();
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Customer)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.CustomerId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Warehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.WarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.CashierUser)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.CashierUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.Status)
|
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
|
||||||
.HasDefaultValue(SalesSlipStatus.Draft);
|
|
||||||
|
|
||||||
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
|
|
||||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
|
||||||
|
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
|
||||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
|
||||||
|
|
||||||
builder.HasIndex(x => x.Status);
|
|
||||||
builder.HasIndex(x => x.SlipDate);
|
|
||||||
|
|
||||||
builder.HasMany(x => x.Lines)
|
|
||||||
.WithOne(x => x.SalesSlip)
|
|
||||||
.HasForeignKey(x => x.SalesSlipId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesSlipLine>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<SalesSlipLine> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("sales_slip_lines");
|
|
||||||
builder.HasKey(x => x.SalesSlipLineId);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Item)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.ItemId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
|
||||||
|
|
||||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
|
||||||
builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
|
||||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
|
||||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Uom)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.UomId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasOne(x => x.Warehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(x => x.WarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.Property(x => x.PriceSource).HasMaxLength(50);
|
|
||||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,27 +33,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
|
||||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||||
// these procurement rows were never actually migrated into the database before now.
|
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
|
||||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
|
||||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
|
||||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
|
||||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
|
||||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
|
||||||
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
|
|
||||||
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
|
|
||||||
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
|
|
||||||
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
|
|
||||||
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
|
|
||||||
// rather than delete+recreate, so any role that already had this permission granted
|
|
||||||
// doesn't silently lose it just because the section it lives under changed.
|
|
||||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
|
||||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
|
||||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain;
|
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -31,23 +30,13 @@ public static class DataSeeder
|
|||||||
("WRONG", "Wrong Item", ReasonContext.Return),
|
("WRONG", "Wrong Item", ReasonContext.Return),
|
||||||
("OVER", "Over-supply", ReasonContext.Return),
|
("OVER", "Over-supply", ReasonContext.Return),
|
||||||
("QREJ", "Quality Reject", ReasonContext.Return),
|
("QREJ", "Quality Reject", ReasonContext.Return),
|
||||||
// Manufacturing (docs/30 §A.2) — scrap at stage complete, leftover return before
|
|
||||||
// receipt, and the mandatory reason on a run cancel.
|
|
||||||
("PRD-SCRAP", "Production Scrap", ReasonContext.Production),
|
|
||||||
("PRD-LEFTOVER", "Production Leftover Return", ReasonContext.Production),
|
|
||||||
("PRD-CANCEL", "Production Run Cancelled", ReasonContext.Production),
|
|
||||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
dirty |= await SeedItemTypesAsync(db, ct);
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
|
||||||
dirty |= await SeedSalesStockAsync(db, ct);
|
|
||||||
dirty |= await SeedSalesAsync(db, ct);
|
|
||||||
|
|
||||||
if (dirty) await db.SaveChangesAsync(ct);
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -104,754 +93,4 @@ public static class DataSeeder
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
|
||||||
/// These values are intentionally editable later through the API.
|
|
||||||
/// </summary>
|
|
||||||
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
//{
|
|
||||||
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
|
||||||
|
|
||||||
// db.CompanyProfiles.Add(new CompanyProfile
|
|
||||||
// {
|
|
||||||
// CompanyProfileId = CompanyProfile.SingletonId,
|
|
||||||
// LegalName = "ERP Core Trading (Pvt) Ltd",
|
|
||||||
// TradeName = "ERP Core Trading",
|
|
||||||
// TaxRegistrationNo = "TAX-DEFAULT-001",
|
|
||||||
// VatRegistrationNo = "VAT-DEFAULT-001",
|
|
||||||
// AddressLine1 = "1 Demo Street",
|
|
||||||
// City = "Colombo",
|
|
||||||
// Country = "Sri Lanka",
|
|
||||||
// Phone = "+94 11 000 0000",
|
|
||||||
// Email = "accounts@example.com",
|
|
||||||
// BankName = "Demo Bank",
|
|
||||||
// BankBranch = "Colombo Main",
|
|
||||||
// AccountName = "ERP Core Trading (Pvt) Ltd",
|
|
||||||
// AccountNumber = "000123456789",
|
|
||||||
// SwiftCode = "DEMO1234",
|
|
||||||
// FooterNote = "Thank you for your business."
|
|
||||||
// });
|
|
||||||
// return true;
|
|
||||||
//}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds the minimum catalog data required for the sales demo rows to exist.
|
|
||||||
/// These are safe additive rows and do not alter any existing data.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<bool> SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var dirty = false;
|
|
||||||
|
|
||||||
dirty |= await SeedWarehousesAsync(db, ct);
|
|
||||||
dirty |= await SeedUomsAsync(db, ct);
|
|
||||||
dirty |= await SeedCategoriesAsync(db, ct);
|
|
||||||
dirty |= await SeedItemsAsync(db, ct);
|
|
||||||
|
|
||||||
return dirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds a simple on-hand FIFO layer for the demo sales item so the sample
|
|
||||||
/// invoices can be posted without immediately failing stock validation.
|
|
||||||
/// This keeps the stock-check and posting flows testable on a fresh database.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<bool> SeedSalesStockAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var warehouse = await db.Warehouses.AsNoTracking()
|
|
||||||
.OrderBy(w => w.WarehouseId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var item = await db.Items.AsNoTracking()
|
|
||||||
.OrderBy(i => i.ItemId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var secondItem = await db.Items.AsNoTracking()
|
|
||||||
.OrderByDescending(i => i.ItemId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (warehouse is null || item is null || secondItem is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var existing = await db.StockLayers.AnyAsync(
|
|
||||||
l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m,
|
|
||||||
ct);
|
|
||||||
if (existing) return false;
|
|
||||||
|
|
||||||
db.StockLayers.Add(new StockLayer
|
|
||||||
{
|
|
||||||
ItemId = item.ItemId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
QtyReceived = 100m,
|
|
||||||
QtyRemaining = 100m,
|
|
||||||
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
|
|
||||||
ReceiptDate = DateTime.UtcNow.AddDays(-7)
|
|
||||||
});
|
|
||||||
db.StockLayers.Add(new StockLayer
|
|
||||||
{
|
|
||||||
ItemId = secondItem.ItemId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
QtyReceived = 5m,
|
|
||||||
QtyRemaining = 5m,
|
|
||||||
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
|
|
||||||
ReceiptDate = DateTime.UtcNow.AddDays(-6)
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedWarehousesAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct);
|
|
||||||
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new Warehouse { Code = "MAIN", Name = "Main Warehouse" },
|
|
||||||
new Warehouse { Code = "SHOP", Name = "Sales Counter" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.Warehouses.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedUomsAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct);
|
|
||||||
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new Uom { Name = "PCS" },
|
|
||||||
new Uom { Name = "BOX" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.Uoms.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedCategoriesAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct);
|
|
||||||
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow },
|
|
||||||
new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.Categories.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedItemsAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct);
|
|
||||||
var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var category = await db.Categories.AsNoTracking()
|
|
||||||
.OrderBy(c => c.CategoryId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var uom = await db.Uoms.AsNoTracking()
|
|
||||||
.OrderBy(u => u.UomId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (category is null || uom is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new Item
|
|
||||||
{
|
|
||||||
Sku = "SKU-DEMO-001",
|
|
||||||
Name = "Demo Item 1",
|
|
||||||
Description = "Seeded sample item for sales documents",
|
|
||||||
CategoryId = category.CategoryId,
|
|
||||||
BaseUomId = uom.UomId,
|
|
||||||
StockNature = StockNature.Stocked,
|
|
||||||
TrackingMode = TrackingMode.None,
|
|
||||||
SalePrice = 100m,
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = now
|
|
||||||
},
|
|
||||||
new Item
|
|
||||||
{
|
|
||||||
Sku = "SKU-DEMO-002",
|
|
||||||
Name = "Demo Item 2",
|
|
||||||
Description = "Secondary seeded sample item for sales documents",
|
|
||||||
CategoryId = category.CategoryId,
|
|
||||||
BaseUomId = uom.UomId,
|
|
||||||
StockNature = StockNature.Stocked,
|
|
||||||
TrackingMode = TrackingMode.None,
|
|
||||||
SalePrice = 50m,
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = now
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.Items.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
|
|
||||||
/// a couple of customer rows, current-year document counters, and a few draft
|
|
||||||
/// invoice/slip samples when the required master data already exists.
|
|
||||||
/// This intentionally never clears or rewrites any existing rows.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var dirty = false;
|
|
||||||
|
|
||||||
dirty |= await SeedSalesCustomersAsync(db, ct);
|
|
||||||
dirty |= await SeedSalesSequencesAsync(db, ct);
|
|
||||||
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
dirty |= await SeedBundleSalesAsync(db, ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Bundle demo data is best-effort only; never block startup because of seed drift.
|
|
||||||
}
|
|
||||||
|
|
||||||
return dirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct);
|
|
||||||
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new Customer
|
|
||||||
{
|
|
||||||
CustomerCode = "CUST-WALKIN",
|
|
||||||
CustomerType = CustomerType.B2C,
|
|
||||||
Name = "Walk-in Customer",
|
|
||||||
DisplayName = "Walk-in Customer",
|
|
||||||
CreditLimit = 0m,
|
|
||||||
CreditDays = 0,
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
},
|
|
||||||
new Customer
|
|
||||||
{
|
|
||||||
CustomerCode = "CUST-DEMO",
|
|
||||||
CustomerType = CustomerType.B2B,
|
|
||||||
Name = "Demo Retail Ltd",
|
|
||||||
DisplayName = "Demo Retail Ltd",
|
|
||||||
Phone = "+94 11 000 0000",
|
|
||||||
Email = "sales@example.com",
|
|
||||||
AddressLine1 = "1 Demo Street",
|
|
||||||
City = "Colombo",
|
|
||||||
Country = "Sri Lanka",
|
|
||||||
TaxRegistrationNo = "VAT-DEMO-001",
|
|
||||||
CreditLimit = 250000m,
|
|
||||||
CreditDays = 30,
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.Customers.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var year = DateTime.UtcNow.Year;
|
|
||||||
var existing = await db.NumberSequences
|
|
||||||
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale))
|
|
||||||
.Select(s => s.DocType)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var seeds = new[]
|
|
||||||
{
|
|
||||||
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
|
|
||||||
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 },
|
|
||||||
new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 }
|
|
||||||
};
|
|
||||||
|
|
||||||
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
|
|
||||||
if (toAdd.Count == 0) return false;
|
|
||||||
|
|
||||||
db.NumberSequences.AddRange(toAdd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var customer = await db.Customers.AsNoTracking()
|
|
||||||
.OrderBy(c => c.CustomerId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var warehouse = await db.Warehouses.AsNoTracking()
|
|
||||||
.OrderBy(w => w.WarehouseId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
|
|
||||||
.OrderByDescending(w => w.WarehouseId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var items = await db.Items.AsNoTracking()
|
|
||||||
.OrderBy(i => i.ItemId)
|
|
||||||
.Take(2)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var uom = await db.Uoms.AsNoTracking()
|
|
||||||
.OrderBy(u => u.UomId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var user = await db.Users.AsNoTracking()
|
|
||||||
.OrderBy(u => u.UserId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var template = new BundleSaleTemplate
|
|
||||||
{
|
|
||||||
TemplateCode = "BND-DEMO-001",
|
|
||||||
TemplateName = "Demo Bundle Pack",
|
|
||||||
Description = "Seeded fixed bundle template for integration testing",
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = now,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new BundleSaleTemplateLine
|
|
||||||
{
|
|
||||||
ItemId = items[0].ItemId,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
Qty = 1m,
|
|
||||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
SortOrder = 1
|
|
||||||
},
|
|
||||||
new BundleSaleTemplateLine
|
|
||||||
{
|
|
||||||
ItemId = items[1].ItemId,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
Qty = 1m,
|
|
||||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
SortOrder = 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
db.BundleSaleTemplates.Add(template);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var bundleSales = new[]
|
|
||||||
{
|
|
||||||
new BundleSale
|
|
||||||
{
|
|
||||||
BundleNo = $"BND-{now:yyyy}-00001",
|
|
||||||
BundleDate = now.Date.AddDays(-2),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
CashierUserId = user.UserId,
|
|
||||||
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
|
||||||
BundleName = "Demo Bundle Draft",
|
|
||||||
BundleCode = "BND-DEMO-001",
|
|
||||||
Status = BundleSaleStatus.Draft,
|
|
||||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
BundlePrice = 0m,
|
|
||||||
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
|
|
||||||
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 0m,
|
|
||||||
CreatedAt = now.AddDays(-2),
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = items[0].ItemId,
|
|
||||||
Description = items[0].Name,
|
|
||||||
Qty = 1m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
IsComponent = true
|
|
||||||
},
|
|
||||||
new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = items[1].ItemId,
|
|
||||||
Description = items[1].Name,
|
|
||||||
Qty = 1m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
IsComponent = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
new BundleSale
|
|
||||||
{
|
|
||||||
BundleNo = $"BND-{now:yyyy}-00002",
|
|
||||||
BundleDate = now.Date.AddDays(-1),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
CashierUserId = user.UserId,
|
|
||||||
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
|
||||||
BundleName = "Demo Bundle Posted",
|
|
||||||
BundleCode = "BND-DEMO-001",
|
|
||||||
Status = BundleSaleStatus.Posted,
|
|
||||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
|
||||||
MarginAmount = -10m,
|
|
||||||
DiscountTotal = 10m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
|
||||||
CreatedAt = now.AddDays(-1),
|
|
||||||
UpdatedAt = now.AddHours(-2),
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = items[0].ItemId,
|
|
||||||
Description = items[0].Name,
|
|
||||||
Qty = 1m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
IsComponent = true
|
|
||||||
},
|
|
||||||
new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = items[1].ItemId,
|
|
||||||
Description = items[1].Name,
|
|
||||||
Qty = 1m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
IsComponent = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
new BundleSale
|
|
||||||
{
|
|
||||||
BundleNo = $"BND-{now:yyyy}-00003",
|
|
||||||
BundleDate = now.Date,
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
CashierUserId = user.UserId,
|
|
||||||
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
|
||||||
BundleName = "Demo Bundle Cancelled",
|
|
||||||
BundleCode = "BND-DEMO-001",
|
|
||||||
Status = BundleSaleStatus.Cancelled,
|
|
||||||
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
MarginAmount = 0m,
|
|
||||||
DiscountTotal = 0m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
CreatedAt = now,
|
|
||||||
UpdatedAt = now,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = items[0].ItemId,
|
|
||||||
Description = items[0].Name,
|
|
||||||
Qty = 1m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
|
||||||
IncludeInBundle = true,
|
|
||||||
IsComponent = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
db.BundleSales.AddRange(bundleSales);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var customer = await db.Customers.AsNoTracking()
|
|
||||||
.OrderBy(c => c.CustomerId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var warehouse = await db.Warehouses.AsNoTracking()
|
|
||||||
.OrderBy(w => w.WarehouseId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
|
|
||||||
.OrderByDescending(w => w.WarehouseId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var items = await db.Items.AsNoTracking()
|
|
||||||
.OrderBy(i => i.ItemId)
|
|
||||||
.Take(2)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var uom = await db.Uoms.AsNoTracking()
|
|
||||||
.OrderBy(u => u.UomId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
var user = await db.Users.AsNoTracking()
|
|
||||||
.OrderBy(u => u.UserId)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var postableItem = items[0];
|
|
||||||
var shortageItem = items[1];
|
|
||||||
var today = DateTime.UtcNow.Date;
|
|
||||||
var createdAt = DateTime.UtcNow.AddDays(-1);
|
|
||||||
|
|
||||||
db.SalesInvoices.AddRange(
|
|
||||||
new SalesInvoice
|
|
||||||
{
|
|
||||||
InvoiceNo = $"SI-{today:yyyy}-00001",
|
|
||||||
InvoiceDate = today.AddDays(-2),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.Name,
|
|
||||||
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
InvoiceType = SalesInvoiceType.B2C,
|
|
||||||
Status = SalesInvoiceStatus.Draft,
|
|
||||||
Subtotal = 200m,
|
|
||||||
DiscountTotal = 0m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 200m,
|
|
||||||
RoundOff = 0m,
|
|
||||||
NetPayable = 200m,
|
|
||||||
PaidAmount = 0m,
|
|
||||||
BalanceAmount = 200m,
|
|
||||||
CreatedBy = user.UserId,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new SalesInvoiceLine
|
|
||||||
{
|
|
||||||
ItemId = postableItem.ItemId,
|
|
||||||
Description = postableItem.Name,
|
|
||||||
Qty = 2m,
|
|
||||||
FreeQty = 0m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = 100m,
|
|
||||||
BaseCost = 0m,
|
|
||||||
PriceSource = "seed",
|
|
||||||
DiscountMode = SalesDiscountMode.Percentage,
|
|
||||||
DiscountPct = 0m,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
NetUnitPrice = 100m,
|
|
||||||
LineTotal = 200m,
|
|
||||||
TaxPct = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
IsFreeIssue = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
new SalesInvoice
|
|
||||||
{
|
|
||||||
InvoiceNo = $"SI-{today:yyyy}-00002",
|
|
||||||
InvoiceDate = today.AddDays(-1),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.Name,
|
|
||||||
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
InvoiceType = SalesInvoiceType.B2B,
|
|
||||||
Status = SalesInvoiceStatus.Draft,
|
|
||||||
Subtotal = 300m,
|
|
||||||
DiscountTotal = 0m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 300m,
|
|
||||||
RoundOff = 0m,
|
|
||||||
NetPayable = 300m,
|
|
||||||
PaidAmount = 0m,
|
|
||||||
BalanceAmount = 300m,
|
|
||||||
CreatedBy = user.UserId,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new SalesInvoiceLine
|
|
||||||
{
|
|
||||||
ItemId = shortageItem.ItemId,
|
|
||||||
Description = shortageItem.Name,
|
|
||||||
Qty = 6m,
|
|
||||||
FreeQty = 0m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
UnitPrice = 50m,
|
|
||||||
BaseCost = 0m,
|
|
||||||
PriceSource = "seed",
|
|
||||||
DiscountMode = SalesDiscountMode.Percentage,
|
|
||||||
DiscountPct = 0m,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
NetUnitPrice = 50m,
|
|
||||||
LineTotal = 300m,
|
|
||||||
TaxPct = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
IsFreeIssue = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
new SalesInvoice
|
|
||||||
{
|
|
||||||
InvoiceNo = $"SI-{today:yyyy}-00003",
|
|
||||||
InvoiceDate = today,
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.Name,
|
|
||||||
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
InvoiceType = SalesInvoiceType.B2C,
|
|
||||||
Status = SalesInvoiceStatus.Posted,
|
|
||||||
Subtotal = 100m,
|
|
||||||
DiscountTotal = 0m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 100m,
|
|
||||||
RoundOff = 0m,
|
|
||||||
NetPayable = 100m,
|
|
||||||
PaidAmount = 100m,
|
|
||||||
BalanceAmount = 0m,
|
|
||||||
CreatedBy = user.UserId,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new SalesInvoiceLine
|
|
||||||
{
|
|
||||||
ItemId = postableItem.ItemId,
|
|
||||||
Description = postableItem.Name,
|
|
||||||
Qty = 1m,
|
|
||||||
FreeQty = 0m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = 100m,
|
|
||||||
BaseCost = 0m,
|
|
||||||
PriceSource = "seed",
|
|
||||||
DiscountMode = SalesDiscountMode.Percentage,
|
|
||||||
DiscountPct = 0m,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
NetUnitPrice = 100m,
|
|
||||||
LineTotal = 100m,
|
|
||||||
TaxPct = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
IsFreeIssue = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
db.SalesSlips.AddRange(
|
|
||||||
new SalesSlip
|
|
||||||
{
|
|
||||||
SlipNo = $"SSL-{today:yyyy}-00001",
|
|
||||||
SlipDate = today.AddDays(-2),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.Name,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
CashierUserId = user.UserId,
|
|
||||||
Status = SalesSlipStatus.Draft,
|
|
||||||
Subtotal = 50m,
|
|
||||||
DiscountTotal = 0m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 50m,
|
|
||||||
PaidAmount = 0m,
|
|
||||||
BalanceAmount = 50m,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new SalesSlipLine
|
|
||||||
{
|
|
||||||
ItemId = postableItem.ItemId,
|
|
||||||
Description = postableItem.Name,
|
|
||||||
Qty = 1m,
|
|
||||||
FreeQty = 0m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = warehouse.WarehouseId,
|
|
||||||
UnitPrice = 50m,
|
|
||||||
BaseCost = 0m,
|
|
||||||
PriceSource = "seed",
|
|
||||||
DiscountMode = SalesDiscountMode.Percentage,
|
|
||||||
DiscountPct = 0m,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
NetUnitPrice = 50m,
|
|
||||||
LineTotal = 50m,
|
|
||||||
TaxPct = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
IsFreeIssue = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
new SalesSlip
|
|
||||||
{
|
|
||||||
SlipNo = $"SSL-{today:yyyy}-00002",
|
|
||||||
SlipDate = today.AddDays(-1),
|
|
||||||
CustomerId = customer.CustomerId,
|
|
||||||
CustomerSnapshotName = customer.Name,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
CashierUserId = user.UserId,
|
|
||||||
Status = SalesSlipStatus.Draft,
|
|
||||||
Subtotal = 150m,
|
|
||||||
DiscountTotal = 15m,
|
|
||||||
TaxTotal = 0m,
|
|
||||||
GrandTotal = 135m,
|
|
||||||
PaidAmount = 0m,
|
|
||||||
BalanceAmount = 135m,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
Lines =
|
|
||||||
[
|
|
||||||
new SalesSlipLine
|
|
||||||
{
|
|
||||||
ItemId = shortageItem.ItemId,
|
|
||||||
Description = shortageItem.Name,
|
|
||||||
Qty = 3m,
|
|
||||||
FreeQty = 0m,
|
|
||||||
UomId = uom.UomId,
|
|
||||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
|
||||||
UnitPrice = 50m,
|
|
||||||
BaseCost = 0m,
|
|
||||||
PriceSource = "seed",
|
|
||||||
DiscountMode = SalesDiscountMode.Percentage,
|
|
||||||
DiscountPct = 10m,
|
|
||||||
DiscountAmount = 15m,
|
|
||||||
NetUnitPrice = 45m,
|
|
||||||
LineTotal = 135m,
|
|
||||||
TaxPct = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
IsFreeIssue = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ namespace ERPCore.Infra.Persistence;
|
|||||||
public class ErpDbContext : DbContext
|
public class ErpDbContext : DbContext
|
||||||
{
|
{
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private bool _writingAuditLogs;
|
|
||||||
|
|
||||||
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
||||||
{
|
{
|
||||||
@@ -23,7 +22,6 @@ public class ErpDbContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Master Data (docs/10 Part C.1) ---
|
// --- Master Data (docs/10 Part C.1) ---
|
||||||
public DbSet<Customer> Customers => Set<Customer>();
|
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||||
public DbSet<Brand> Brands => Set<Brand>();
|
public DbSet<Brand> Brands => Set<Brand>();
|
||||||
@@ -36,7 +34,6 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||||
public DbSet<Bin> Bins => Set<Bin>();
|
public DbSet<Bin> Bins => Set<Bin>();
|
||||||
|
|
||||||
/// <summary>Singleton row (FR-MD-11).</summary>
|
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||||
|
|
||||||
@@ -85,16 +82,6 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
||||||
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
||||||
|
|
||||||
// --- Sales (Phase 1) ---
|
|
||||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
|
||||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
|
||||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
|
||||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
|
||||||
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
|
||||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
|
||||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
|
||||||
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
|
||||||
|
|
||||||
// --- Reference data (docs/10 Part C.7) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|
||||||
@@ -139,21 +126,6 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
|
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
|
||||||
public DbSet<Payslip> Payslips => Set<Payslip>();
|
public DbSet<Payslip> Payslips => Set<Payslip>();
|
||||||
|
|
||||||
// --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) ---
|
|
||||||
public DbSet<ProductionTemplate> ProductionTemplates => Set<ProductionTemplate>();
|
|
||||||
public DbSet<TemplateStage> TemplateStages => Set<TemplateStage>();
|
|
||||||
public DbSet<StageEdge> StageEdges => Set<StageEdge>();
|
|
||||||
public DbSet<StageInput> StageInputs => Set<StageInput>();
|
|
||||||
public DbSet<StageOutput> StageOutputs => Set<StageOutput>();
|
|
||||||
|
|
||||||
// --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) ---
|
|
||||||
public DbSet<ProductionRun> ProductionRuns => Set<ProductionRun>();
|
|
||||||
public DbSet<RunStage> RunStages => Set<RunStage>();
|
|
||||||
public DbSet<RunEdge> RunEdges => Set<RunEdge>();
|
|
||||||
public DbSet<RunStageInput> RunStageInputs => Set<RunStageInput>();
|
|
||||||
public DbSet<RunStageOutput> RunStageOutputs => Set<RunStageOutput>();
|
|
||||||
public DbSet<RunStageEvent> RunStageEvents => Set<RunStageEvent>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -191,44 +163,24 @@ public class ErpDbContext : DbContext
|
|||||||
// persists the logs without re-auditing them.
|
// persists the logs without re-auditing them.
|
||||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
|
var pending = AuditScribe.Capture(ChangeTracker);
|
||||||
? Array.Empty<PendingAudit>()
|
|
||||||
: AuditScribe.Capture(ChangeTracker);
|
|
||||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||||
if (pending.Count > 0)
|
if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
try
|
WriteAuditLogs(pending);
|
||||||
{
|
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||||
_writingAuditLogs = true;
|
|
||||||
WriteAuditLogs(pending);
|
|
||||||
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_writingAuditLogs = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
{
|
{
|
||||||
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
|
var pending = AuditScribe.Capture(ChangeTracker);
|
||||||
? Array.Empty<PendingAudit>()
|
|
||||||
: AuditScribe.Capture(ChangeTracker);
|
|
||||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||||
if (pending.Count > 0)
|
if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
try
|
WriteAuditLogs(pending);
|
||||||
{
|
base.SaveChanges(acceptAllChangesOnSuccess);
|
||||||
_writingAuditLogs = true;
|
|
||||||
WriteAuditLogs(pending);
|
|
||||||
base.SaveChanges(acceptAllChangesOnSuccess);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_writingAuditLogs = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+2229
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2454
File diff suppressed because it is too large
Load Diff
+442
@@ -0,0 +1,442 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
|
||||||
|
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
|
||||||
|
/// Category → SubCategory hierarchy (docs/10 Part C.1).
|
||||||
|
/// <para>
|
||||||
|
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||||
|
/// <c>categories.ParentId</c> outright, which would have silently flattened every
|
||||||
|
/// child category into a root and left items pointing at what is now a top-level
|
||||||
|
/// category — losing the parent entirely. The hand-written steps below (marked
|
||||||
|
/// "data migration") move child categories into <c>subcategories</c> and repoint items
|
||||||
|
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
|
||||||
|
// the data migration reads it. Order here is load-bearing.
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "ItemType",
|
||||||
|
table: "items",
|
||||||
|
newName: "StockNature");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BrandId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "categories",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "categories",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Active");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "categories",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<uint>(
|
||||||
|
name: "xmin",
|
||||||
|
table: "categories",
|
||||||
|
type: "xid",
|
||||||
|
rowVersion: true,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0u);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "brands",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "item_types",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "product_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_product_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_product_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subcategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subcategories_categories_CategoryId",
|
||||||
|
column: x => x.CategoryId,
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DATA MIGRATION — must run before ParentId is dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Existing categories predate CreatedAt; the added column defaulted them to
|
||||||
|
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
|
||||||
|
");
|
||||||
|
|
||||||
|
// Carry the old category id alongside each new subcategory so items can be
|
||||||
|
// repointed by join below. Dropped again once the repoint is done.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
|
||||||
|
// but the new one is exactly two levels — so a category at any depth below the
|
||||||
|
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
|
||||||
|
// become a subcategory of its immediate parent, since that parent is itself
|
||||||
|
// ceasing to be a category).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
WITH RECURSIVE tree AS (
|
||||||
|
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
|
||||||
|
FROM categories
|
||||||
|
WHERE ""ParentId"" IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
|
||||||
|
FROM categories c
|
||||||
|
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
|
||||||
|
)
|
||||||
|
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
|
||||||
|
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
|
||||||
|
FROM tree t
|
||||||
|
WHERE t.""ParentId"" IS NOT NULL;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Repoint items: an item that pointed at a child category now carries the root
|
||||||
|
// category plus the subcategory it actually meant.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE items i
|
||||||
|
SET ""SubCategoryId"" = s.""SubCategoryId"",
|
||||||
|
""CategoryId"" = s.""CategoryId""
|
||||||
|
FROM subcategories s
|
||||||
|
WHERE s.legacy_category_id = i.""CategoryId"";
|
||||||
|
");
|
||||||
|
|
||||||
|
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
|
||||||
|
// whose own child row is still present.
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_categories_categories_ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
// Every non-root category now lives in `subcategories`, and no item references
|
||||||
|
// one any more (repointed above), so the rows can go.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
|
||||||
|
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
|
||||||
|
// row, so it must exist before the app serves a single request.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
|
||||||
|
VALUES (1, TRUE, TRUE, TRUE)
|
||||||
|
ON CONFLICT (""ConfigId"") DO NOTHING;
|
||||||
|
");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_items_BrandId",
|
||||||
|
table: "items",
|
||||||
|
column: "BrandId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_items_SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
column: "SubCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_Name",
|
||||||
|
table: "categories",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_Status",
|
||||||
|
table: "categories",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_brands_Name",
|
||||||
|
table: "brands",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_brands_Status",
|
||||||
|
table: "brands",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_types_Name",
|
||||||
|
table: "item_types",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_types_Status",
|
||||||
|
table: "item_types",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_product_config_UpdatedBy",
|
||||||
|
table: "product_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subcategories_CategoryId_Name",
|
||||||
|
table: "subcategories",
|
||||||
|
columns: new[] { "CategoryId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subcategories_Status",
|
||||||
|
table: "subcategories",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_items_brands_BrandId",
|
||||||
|
table: "items",
|
||||||
|
column: "BrandId",
|
||||||
|
principalTable: "brands",
|
||||||
|
principalColumn: "BrandId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_items_subcategories_SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
column: "SubCategoryId",
|
||||||
|
principalTable: "subcategories",
|
||||||
|
principalColumn: "SubCategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||||
|
/// <para>
|
||||||
|
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||||
|
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
|
||||||
|
/// restored as a child category and its items are repointed back onto it. This is
|
||||||
|
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
|
||||||
|
/// back as a direct child of its root), and Brand data cannot survive a schema that
|
||||||
|
/// has nowhere to put it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_items_brands_BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_items_subcategories_SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
// Restore the parent column + self-FK first so subcategories have somewhere to
|
||||||
|
// land, then move them back before the table is dropped.
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentId",
|
||||||
|
table: "categories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Each subcategory becomes a child category again under the same parent.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
|
||||||
|
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
|
||||||
|
FROM subcategories s;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Items that carried a subcategory point back at the restored child category.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE items i
|
||||||
|
SET ""CategoryId"" = c.""CategoryId""
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
|
||||||
|
");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "brands");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "item_types");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "product_config");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subcategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_items_BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_items_SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_Name",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_Status",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "xmin",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "StockNature",
|
||||||
|
table: "items",
|
||||||
|
newName: "ItemType");
|
||||||
|
|
||||||
|
// ParentId itself was re-added at the top of this method, ahead of the reverse
|
||||||
|
// data migration that populates it.
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_ParentId",
|
||||||
|
table: "categories",
|
||||||
|
column: "ParentId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_categories_categories_ParentId",
|
||||||
|
table: "categories",
|
||||||
|
column: "ParentId",
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2454
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
namespace ERPCore.Infra.Persistence.Migrations
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class SyncCurrentModel : Migration
|
public partial class ini2 : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
Generated
+3001
File diff suppressed because it is too large
Load Diff
+303
@@ -0,0 +1,303 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRolesNavPermissions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RoleId",
|
||||||
|
table: "users",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "nav_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "roles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
RoleId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_roles", x => x.RoleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sub_nav_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_sub_nav_items_nav_items_NavItemId",
|
||||||
|
column: x => x.NavItemId,
|
||||||
|
principalTable: "nav_items",
|
||||||
|
principalColumn: "NavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_permissions", x => x.PermissionId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_permissions_nav_items_NavItemId",
|
||||||
|
column: x => x.NavItemId,
|
||||||
|
principalTable: "nav_items",
|
||||||
|
principalColumn: "NavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_permissions_sub_nav_items_SubNavItemId",
|
||||||
|
column: x => x.SubNavItemId,
|
||||||
|
principalTable: "sub_nav_items",
|
||||||
|
principalColumn: "SubNavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "role_permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
RoleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_role_permissions_permissions_PermissionId",
|
||||||
|
column: x => x.PermissionId,
|
||||||
|
principalTable: "permissions",
|
||||||
|
principalColumn: "PermissionId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_role_permissions_roles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "roles",
|
||||||
|
principalColumn: "RoleId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "nav_items",
|
||||||
|
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
|
||||||
|
{ 2, "products", "/dashboard/products", null, "Products", 2 },
|
||||||
|
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
|
||||||
|
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
|
||||||
|
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
|
||||||
|
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
|
||||||
|
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
|
||||||
|
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
|
||||||
|
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
||||||
|
{ 10, "help", "/dashboard/help", null, "Help", 10 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "users",
|
||||||
|
keyColumn: "UserId",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "RoleId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "NAV:dashboard", 1, null },
|
||||||
|
{ 2, "NAV:products", 2, null },
|
||||||
|
{ 3, "NAV:vendors", 3, null },
|
||||||
|
{ 4, "NAV:procurement", 4, null },
|
||||||
|
{ 5, "NAV:receiving", 5, null },
|
||||||
|
{ 6, "NAV:stock", 6, null },
|
||||||
|
{ 7, "NAV:warehouses", 7, null },
|
||||||
|
{ 8, "NAV:orders", 8, null },
|
||||||
|
{ 9, "NAV:settings", 9, null },
|
||||||
|
{ 10, "NAV:help", 10, null }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
|
||||||
|
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
|
||||||
|
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
|
||||||
|
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
|
||||||
|
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
|
||||||
|
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
|
||||||
|
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
|
||||||
|
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 11, "NAV:products.item", null, 1 },
|
||||||
|
{ 12, "NAV:products.category", null, 2 },
|
||||||
|
{ 13, "NAV:products.brand", null, 3 },
|
||||||
|
{ 14, "NAV:products.item-type", null, 4 },
|
||||||
|
{ 15, "NAV:products.uom", null, 5 },
|
||||||
|
{ 16, "NAV:products.configuration", null, 6 },
|
||||||
|
{ 17, "NAV:settings.roles", null, 7 },
|
||||||
|
{ 18, "NAV:settings.users", null, 8 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_users_RoleId",
|
||||||
|
table: "users",
|
||||||
|
column: "RoleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_nav_items_Code",
|
||||||
|
table: "nav_items",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_Code",
|
||||||
|
table: "permissions",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_NavItemId",
|
||||||
|
table: "permissions",
|
||||||
|
column: "NavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_SubNavItemId",
|
||||||
|
table: "permissions",
|
||||||
|
column: "SubNavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_role_permissions_PermissionId",
|
||||||
|
table: "role_permissions",
|
||||||
|
column: "PermissionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_roles_auth_role_id",
|
||||||
|
table: "roles",
|
||||||
|
column: "auth_role_id",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_roles_Code",
|
||||||
|
table: "roles",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_sub_nav_items_Code",
|
||||||
|
table: "sub_nav_items",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_sub_nav_items_NavItemId",
|
||||||
|
table: "sub_nav_items",
|
||||||
|
column: "NavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_users_roles_RoleId",
|
||||||
|
table: "users",
|
||||||
|
column: "RoleId",
|
||||||
|
principalTable: "roles",
|
||||||
|
principalColumn: "RoleId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_users_roles_RoleId",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "role_permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "roles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sub_nav_items");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "nav_items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_users_RoleId",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RoleId",
|
||||||
|
table: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-6696
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-6696
File diff suppressed because it is too large
Load Diff
@@ -1,139 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations;
|
|
||||||
|
|
||||||
public partial class AddBundleSalesModule : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_templates",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
TemplateCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
TemplateName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
|
||||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table => table.PrimaryKey("PK_bundle_sale_templates", x => x.BundleSaleTemplateId));
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sales",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
BundleDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
CustomerSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
CashierUserId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
BundleName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
BundleCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
|
||||||
ComponentSubtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
BundlePrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
MarginAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sales", x => x.BundleSaleId);
|
|
||||||
table.ForeignKey("FK_bundle_sales_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_customers_CustomerId", x => x.CustomerId, "customers", "CustomerId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_users_CashierUserId", x => x.CashierUserId, "users", "UserId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sales_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_template_lines",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleTemplateLineId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleSaleTemplateId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sale_template_lines", x => x.BundleSaleTemplateLineId);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_bundle_sale_templates_BundleSaleTemplateId", x => x.BundleSaleTemplateId, "bundle_sale_templates", "BundleSaleTemplateId", onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_template_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "bundle_sale_lines",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
BundleSaleLineId = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
BundleSaleId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
UomId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
|
||||||
IncludeInBundle = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
IsComponent = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
ParentLineId = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_bundle_sale_lines", x => x.BundleSaleLineId);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_bundle_sales_BundleSaleId", x => x.BundleSaleId, "bundle_sales", "BundleSaleId", onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_items_ItemId", x => x.ItemId, "items", "ItemId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_uoms_UomId", x => x.UomId, "uoms", "UomId", onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey("FK_bundle_sale_lines_warehouses_WarehouseId", x => x.WarehouseId, "warehouses", "WarehouseId", onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_templates_TemplateCode", table: "bundle_sale_templates", column: "TemplateCode", unique: true);
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleNo", table: "bundle_sales", column: "BundleNo", unique: true);
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_BundleSaleTemplateId", table: "bundle_sales", column: "BundleSaleTemplateId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CashierUserId", table: "bundle_sales", column: "CashierUserId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_CustomerId", table: "bundle_sales", column: "CustomerId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_Status", table: "bundle_sales", column: "Status");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sales_WarehouseId", table: "bundle_sales", column: "WarehouseId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_BundleSaleTemplateId", table: "bundle_sale_template_lines", column: "BundleSaleTemplateId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_ItemId", table: "bundle_sale_template_lines", column: "ItemId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_UomId", table: "bundle_sale_template_lines", column: "UomId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_template_lines_WarehouseId", table: "bundle_sale_template_lines", column: "WarehouseId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_BundleSaleId", table: "bundle_sale_lines", column: "BundleSaleId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_ItemId", table: "bundle_sale_lines", column: "ItemId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_UomId", table: "bundle_sale_lines", column: "UomId");
|
|
||||||
migrationBuilder.CreateIndex(name: "IX_bundle_sale_lines_WarehouseId", table: "bundle_sale_lines", column: "WarehouseId");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable("bundle_sale_lines");
|
|
||||||
migrationBuilder.DropTable("bundle_sale_template_lines");
|
|
||||||
migrationBuilder.DropTable("bundle_sales");
|
|
||||||
migrationBuilder.DropTable("bundle_sale_templates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ERPCore.Infra.Persistence.Migrations;
|
|
||||||
|
|
||||||
public partial class AddBundleSalesConcurrencyStamp : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sale_templates",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sales",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sales");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ConcurrencyStamp",
|
|
||||||
table: "bundle_sale_templates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,20 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.Auth.AuthHex;
|
using ERPCore.Infra.Auth.AuthHex;
|
||||||
using ERPCore.Infra.Gl;
|
|
||||||
using ERPCore.Infra.Persistence;
|
using ERPCore.Infra.Persistence;
|
||||||
using ERPCore.Infra.Storage;
|
using ERPCore.Infra.Storage;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories;
|
using ERPCore.Repositories;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services;
|
using ERPCore.Services;
|
||||||
using ERPCore.Services.Interfaces;
|
|
||||||
using ERPCore.Services.Auth;
|
using ERPCore.Services.Auth;
|
||||||
using ERPCore.Services.Hrm;
|
using ERPCore.Services.Hrm;
|
||||||
using ERPCore.Services.Production;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Npgsql;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -34,10 +30,7 @@ builder.Services.AddControllers()
|
|||||||
|
|
||||||
// EF Core + PostgreSQL
|
// EF Core + PostgreSQL
|
||||||
builder.Services.AddDbContext<ErpDbContext>(o =>
|
builder.Services.AddDbContext<ErpDbContext>(o =>
|
||||||
{
|
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
|
|
||||||
o.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ProblemDetails (RFC 7807) + domain-exception mapping
|
// ProblemDetails (RFC 7807) + domain-exception mapping
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
@@ -57,15 +50,6 @@ builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
|||||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||||
|
|
||||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
|
||||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
|
||||||
{
|
|
||||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
|
||||||
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
|
||||||
c.BaseAddress = new Uri(baseUrl);
|
|
||||||
});
|
|
||||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
|
||||||
|
|
||||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
@@ -77,13 +61,11 @@ builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
|||||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||||
|
|
||||||
// Master-data services (docs/11 §2)
|
// Master-data services (docs/11 §2)
|
||||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
|
||||||
builder.Services.AddScoped<IItemService, ItemService>();
|
builder.Services.AddScoped<IItemService, ItemService>();
|
||||||
builder.Services.AddScoped<IUomService, UomService>();
|
builder.Services.AddScoped<IUomService, UomService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
|
||||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
@@ -99,23 +81,10 @@ builder.Services.AddScoped<IRfqService, RfqService>();
|
|||||||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||||||
|
|
||||||
// Stock core + goods receipt (docs/11 §4–5)
|
// Stock core + goods receipt (docs/11 §4–5)
|
||||||
builder.Services.AddScoped<IUomConverter, UomConverter>();
|
|
||||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||||
builder.Services.AddScoped<IStockService, StockService>();
|
builder.Services.AddScoped<IStockService, StockService>();
|
||||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||||
|
|
||||||
// Sales (Phase 1)
|
|
||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
|
||||||
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
|
||||||
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
|
||||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
|
||||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
|
||||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
|
||||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
|
||||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
|
||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||||
@@ -167,22 +136,12 @@ builder.Services.AddScoped<IPayslipService, PayslipService>();
|
|||||||
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
|
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
|
||||||
builder.Services.AddScoped<IHrReportService, HrReportService>();
|
builder.Services.AddScoped<IHrReportService, HrReportService>();
|
||||||
|
|
||||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand
|
|
||||||
// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator
|
|
||||||
// is deliberately unregistered — it is a pure static algorithm, not an injected service.
|
|
||||||
builder.Services.AddScoped<IProductionTemplateService, ProductionTemplateService>();
|
|
||||||
builder.Services.AddScoped<IProductionRunService, ProductionRunService>();
|
|
||||||
|
|
||||||
// Health checks (EF Core DB)
|
// Health checks (EF Core DB)
|
||||||
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
||||||
|
|
||||||
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
|
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(o =>
|
builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
|
||||||
{
|
|
||||||
o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" });
|
|
||||||
o.CustomSchemaIds(t => t.FullName!.Replace("+", "."));
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -190,16 +149,7 @@ var app = builder.Build();
|
|||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||||||
// await EnsureMigrationBaselineAsync(db);
|
await DataSeeder.SeedAsync(db);
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await DataSeeder.SeedAsync(db);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
@@ -216,4 +166,3 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
using ERPCore.Domain;
|
|
||||||
using ERPCore.Domain.Entities;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Sales;
|
|
||||||
using ERPCore.Infra.Auth;
|
|
||||||
using ERPCore.Infra.UoW;
|
|
||||||
using ERPCore.Repositories.Interfaces;
|
|
||||||
using ERPCore.Services.Interfaces;
|
|
||||||
using ERPCore.Services.Stock;
|
|
||||||
using ERPCore.System.Errors;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
|
||||||
|
|
||||||
public sealed class BundleSaleService : IBundleSaleService
|
|
||||||
{
|
|
||||||
private readonly IRepository<BundleSaleTemplate> _templates;
|
|
||||||
private readonly IRepository<BundleSale> _bundles;
|
|
||||||
private readonly IRepository<Customer> _customers;
|
|
||||||
private readonly IRepository<Item> _items;
|
|
||||||
private readonly IRepository<Uom> _uoms;
|
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
|
||||||
private readonly IRepository<User> _users;
|
|
||||||
private readonly ISalesDomainService _sales;
|
|
||||||
private readonly ISalesPostingService _posting;
|
|
||||||
private readonly ICurrentUser _currentUser;
|
|
||||||
private readonly INumberSequenceService _numbers;
|
|
||||||
private readonly IUnitOfWork _uow;
|
|
||||||
|
|
||||||
public BundleSaleService(
|
|
||||||
IRepository<BundleSale> bundles,
|
|
||||||
IRepository<BundleSaleTemplate> templates,
|
|
||||||
IRepository<Customer> customers,
|
|
||||||
IRepository<Item> items,
|
|
||||||
IRepository<Uom> uoms,
|
|
||||||
IRepository<Warehouse> warehouses,
|
|
||||||
IRepository<User> users,
|
|
||||||
ISalesDomainService sales,
|
|
||||||
ISalesPostingService posting,
|
|
||||||
ICurrentUser currentUser,
|
|
||||||
INumberSequenceService numbers,
|
|
||||||
IUnitOfWork uow)
|
|
||||||
{
|
|
||||||
_templates = templates;
|
|
||||||
_bundles = bundles;
|
|
||||||
_customers = customers;
|
|
||||||
_items = items;
|
|
||||||
_uoms = uoms;
|
|
||||||
_warehouses = warehouses;
|
|
||||||
_users = users;
|
|
||||||
_sales = sales;
|
|
||||||
_posting = posting;
|
|
||||||
_currentUser = currentUser;
|
|
||||||
_numbers = numbers;
|
|
||||||
_uow = uow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines);
|
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
|
||||||
{
|
|
||||||
var term = query.Q.Trim();
|
|
||||||
q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%"));
|
|
||||||
}
|
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
|
||||||
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
|
|
||||||
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
|
|
||||||
return template is null ? null : new BundleSaleTemplateDto(
|
|
||||||
template.BundleSaleTemplateId,
|
|
||||||
template.TemplateCode,
|
|
||||||
template.TemplateName,
|
|
||||||
template.Description,
|
|
||||||
template.Status,
|
|
||||||
template.CreatedAt,
|
|
||||||
template.UpdatedAt,
|
|
||||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
|
||||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
|
||||||
{
|
|
||||||
var term = query.Q.Trim();
|
|
||||||
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
|
||||||
}
|
|
||||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
|
||||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
|
||||||
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
|
||||||
return bundle is null ? null : Map(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
|
||||||
=> _posting.CheckBundleAsync(bundleSaleId, ct);
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
|
||||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
|
||||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
|
||||||
var bundle = new BundleSale
|
|
||||||
{
|
|
||||||
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
|
|
||||||
BundleDate = DateTime.UtcNow,
|
|
||||||
CustomerId = request.CustomerId,
|
|
||||||
WarehouseId = request.WarehouseId,
|
|
||||||
CashierUserId = request.CashierUserId,
|
|
||||||
BundleSaleTemplateId = request.BundleSaleTemplateId,
|
|
||||||
BundleName = request.BundleName,
|
|
||||||
BundleCode = string.Empty,
|
|
||||||
Status = BundleSaleStatus.Draft,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
|
||||||
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
|
|
||||||
Recalculate(bundle, request.BundlePrice);
|
|
||||||
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
|
||||||
await _bundles.AddAsync(bundle, ct);
|
|
||||||
bundle.ConcurrencyStamp = 1;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return Map(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
|
||||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
|
||||||
if (bundle.Status != BundleSaleStatus.Draft)
|
|
||||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
|
|
||||||
|
|
||||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
|
||||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
|
||||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
|
||||||
bundle.CustomerId = request.CustomerId;
|
|
||||||
bundle.WarehouseId = request.WarehouseId;
|
|
||||||
bundle.CashierUserId = request.CashierUserId;
|
|
||||||
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
|
|
||||||
bundle.BundleName = request.BundleName;
|
|
||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
|
||||||
bundle.Lines.Clear();
|
|
||||||
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
|
|
||||||
Recalculate(bundle, request.BundlePrice);
|
|
||||||
bundle.UpdatedAt = DateTime.UtcNow;
|
|
||||||
bundle.ConcurrencyStamp++;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return Map(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await _posting.PostBundleAsync(bundleSaleId, ct);
|
|
||||||
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
|
|
||||||
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
|
||||||
return Map(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
|
||||||
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
|
||||||
if (bundle.Status != BundleSaleStatus.Draft)
|
|
||||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
|
|
||||||
bundle.Status = BundleSaleStatus.Cancelled;
|
|
||||||
bundle.UpdatedAt = DateTime.UtcNow;
|
|
||||||
bundle.ConcurrencyStamp++;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return Map(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<BundleSaleLine>> BuildLinesAsync(
|
|
||||||
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var lines = new List<BundleSaleLine>();
|
|
||||||
var sourceLines = requestLines.Count > 0
|
|
||||||
? requestLines.OrderBy(x => x.SortOrder).ToList()
|
|
||||||
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
|
|
||||||
{
|
|
||||||
ItemId = x.ItemId,
|
|
||||||
UomId = x.UomId,
|
|
||||||
WarehouseId = x.WarehouseId,
|
|
||||||
Qty = x.Qty,
|
|
||||||
UnitPrice = x.UnitPrice,
|
|
||||||
IncludeInBundle = x.IncludeInBundle,
|
|
||||||
SortOrder = x.SortOrder
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
foreach (var r in sourceLines)
|
|
||||||
{
|
|
||||||
if (r.Qty <= 0)
|
|
||||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
|
||||||
if (r.WarehouseId != warehouseId)
|
|
||||||
throw new DomainException(ErrorCodes.Validation,
|
|
||||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
|
||||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
|
||||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
|
||||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
|
||||||
lines.Add(new BundleSaleLine
|
|
||||||
{
|
|
||||||
ItemId = r.ItemId,
|
|
||||||
Description = item.Name,
|
|
||||||
Qty = r.Qty,
|
|
||||||
UomId = r.UomId,
|
|
||||||
WarehouseId = r.WarehouseId,
|
|
||||||
UnitPrice = resolved.UnitPrice,
|
|
||||||
LineTotal = calc.LineTotal,
|
|
||||||
IncludeInBundle = r.IncludeInBundle,
|
|
||||||
IsComponent = true,
|
|
||||||
ParentLineId = null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
|
|
||||||
{
|
|
||||||
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
|
|
||||||
bundle.BundlePrice = bundlePrice;
|
|
||||||
bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal;
|
|
||||||
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
|
|
||||||
bundle.TaxTotal = 0m;
|
|
||||||
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
|
|
||||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
|
|
||||||
|
|
||||||
private static BundleSaleDto Map(BundleSale x) => new(
|
|
||||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
|
||||||
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
|
||||||
x.CreatedAt, x.UpdatedAt,
|
|
||||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Domain.Entities;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Customers;
|
|
||||||
using ERPCore.Infra.UoW;
|
|
||||||
using ERPCore.Repositories.Interfaces;
|
|
||||||
using ERPCore.Services.Interfaces;
|
|
||||||
using ERPCore.System.Errors;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
|
||||||
|
|
||||||
public sealed class CustomerService : ICustomerService
|
|
||||||
{
|
|
||||||
private readonly IRepository<Customer> _customers;
|
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
|
||||||
private readonly IUnitOfWork _uow;
|
|
||||||
|
|
||||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IUnitOfWork uow)
|
|
||||||
{
|
|
||||||
_customers = customers;
|
|
||||||
_warehouses = warehouses;
|
|
||||||
_uow = uow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var q = _customers.Query().AsNoTracking();
|
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
|
||||||
{
|
|
||||||
var term = query.Q.Trim();
|
|
||||||
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")
|
|
||||||
|| EF.Functions.ILike(c.CustomerCode, $"%{term}%")
|
|
||||||
|| (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%")));
|
|
||||||
}
|
|
||||||
if (status is not null) q = q.Where(c => c.Status == status);
|
|
||||||
if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
|
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var rows = await q.OrderBy(c => c.Name)
|
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
|
||||||
.Select(c => new CustomerDto(
|
|
||||||
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
|
|
||||||
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
|
|
||||||
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt))
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var customer = await _customers.Query().AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
|
|
||||||
return customer is null ? null : new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var code = request.CustomerCode.Trim();
|
|
||||||
var name = request.Name.Trim();
|
|
||||||
|
|
||||||
if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct))
|
|
||||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Email))
|
|
||||||
{
|
|
||||||
var email = request.Email.Trim();
|
|
||||||
if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct))
|
|
||||||
throw new ConflictException($"A customer with email '{email}' already exists.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.DefaultWarehouseId is not null)
|
|
||||||
{
|
|
||||||
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
|
|
||||||
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customer = new Customer
|
|
||||||
{
|
|
||||||
CustomerCode = code,
|
|
||||||
CustomerType = request.CustomerType,
|
|
||||||
Name = name,
|
|
||||||
DisplayName = Normalize(request.DisplayName),
|
|
||||||
Phone = Normalize(request.Phone),
|
|
||||||
Email = Normalize(request.Email),
|
|
||||||
AddressLine1 = Normalize(request.AddressLine1),
|
|
||||||
AddressLine2 = Normalize(request.AddressLine2),
|
|
||||||
City = Normalize(request.City),
|
|
||||||
Country = Normalize(request.Country),
|
|
||||||
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
|
|
||||||
CreditLimit = request.CreditLimit,
|
|
||||||
CreditDays = request.CreditDays,
|
|
||||||
DefaultWarehouseId = request.DefaultWarehouseId,
|
|
||||||
Status = EntityStatus.Active,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
await _customers.AddAsync(customer, ct);
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var customer = await _customers.GetByIdAsync(customerId, ct)
|
|
||||||
?? throw new NotFoundException($"Customer {customerId} was not found.");
|
|
||||||
|
|
||||||
if (customer.RowVersion != expectedRowVersion)
|
|
||||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412);
|
|
||||||
|
|
||||||
var code = request.CustomerCode.Trim();
|
|
||||||
var name = request.Name.Trim();
|
|
||||||
|
|
||||||
if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
|
|
||||||
&& await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct))
|
|
||||||
throw new ConflictException($"A customer code '{code}' already exists.");
|
|
||||||
|
|
||||||
if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !string.IsNullOrWhiteSpace(request.Email)
|
|
||||||
&& await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct))
|
|
||||||
throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
|
|
||||||
|
|
||||||
if (request.DefaultWarehouseId is not null)
|
|
||||||
{
|
|
||||||
var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
|
|
||||||
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
customer.CustomerCode = code;
|
|
||||||
customer.CustomerType = request.CustomerType;
|
|
||||||
customer.Name = name;
|
|
||||||
customer.DisplayName = Normalize(request.DisplayName);
|
|
||||||
customer.Phone = Normalize(request.Phone);
|
|
||||||
customer.Email = Normalize(request.Email);
|
|
||||||
customer.AddressLine1 = Normalize(request.AddressLine1);
|
|
||||||
customer.AddressLine2 = Normalize(request.AddressLine2);
|
|
||||||
customer.City = Normalize(request.City);
|
|
||||||
customer.Country = Normalize(request.Country);
|
|
||||||
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
|
|
||||||
customer.CreditLimit = request.CreditLimit;
|
|
||||||
customer.CreditDays = request.CreditDays;
|
|
||||||
customer.DefaultWarehouseId = request.DefaultWarehouseId;
|
|
||||||
customer.UpdatedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var customer = await _customers.GetByIdAsync(customerId, ct)
|
|
||||||
?? throw new NotFoundException($"Customer {customerId} was not found.");
|
|
||||||
|
|
||||||
customer.Status = status;
|
|
||||||
customer.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CustomerDto Map(Customer c) => new(
|
|
||||||
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
|
|
||||||
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
|
|
||||||
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt);
|
|
||||||
|
|
||||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using ERPCore.Infra.Gl;
|
|
||||||
using ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
|
||||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
|
||||||
{
|
|
||||||
private readonly IGeneralLedgerClient _client;
|
|
||||||
|
|
||||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
|
||||||
|
|
||||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
|
||||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
|
||||||
}
|
|
||||||
@@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService
|
|||||||
private readonly IRepository<Bin> _bins;
|
private readonly IRepository<Bin> _bins;
|
||||||
private readonly IRepository<Vendor> _vendors;
|
private readonly IRepository<Vendor> _vendors;
|
||||||
private readonly IRepository<Batch> _batches;
|
private readonly IRepository<Batch> _batches;
|
||||||
|
private readonly IRepository<UomConversion> _conversions;
|
||||||
private readonly IRepository<StockLayer> _layers;
|
private readonly IRepository<StockLayer> _layers;
|
||||||
private readonly IRepository<StockLedger> _ledger;
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IUomConverter _uomConverter;
|
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly IFifoCostingService _fifo;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -45,7 +45,7 @@ public sealed class GrnService : IGrnService
|
|||||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
|
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_grns = grns;
|
_grns = grns;
|
||||||
@@ -57,9 +57,9 @@ public sealed class GrnService : IGrnService
|
|||||||
_bins = bins;
|
_bins = bins;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
|
_conversions = conversions;
|
||||||
_layers = layers;
|
_layers = layers;
|
||||||
_ledger = ledger;
|
_ledger = ledger;
|
||||||
_uomConverter = uomConverter;
|
|
||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
@@ -345,14 +345,19 @@ public sealed class GrnService : IGrnService
|
|||||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||||
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
|
|
||||||
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
|
|
||||||
/// identical, so receive costing is unchanged.
|
|
||||||
/// </summary>
|
|
||||||
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
|
||||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
||||||
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
|
{
|
||||||
|
if (uomId == item.BaseUomId)
|
||||||
|
return (qty, unitCostPerUom);
|
||||||
|
|
||||||
|
var conv = await _conversions.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||||
|
|
||||||
|
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Sales;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface IBundleSaleService
|
|
||||||
{
|
|
||||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
|
||||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
|
||||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
|
|
||||||
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Customers;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface ICustomerService
|
|
||||||
{
|
|
||||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
|
||||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
|
||||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
|
||||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
|
||||||
Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -29,22 +29,11 @@ public interface IFifoCostingService
|
|||||||
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
|
||||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="valueOverride">
|
|
||||||
/// Posts this exact value instead of deriving it from qty × unit cost. Needed when the
|
|
||||||
/// authoritative figure is a total rather than a rate: a production receipt must carry the
|
|
||||||
/// run's cost pool exactly, but <c>unitCost = pool / goodQty</c> rounds to 6 dp, and at
|
|
||||||
/// 100+ units that rounding error exceeds the ledger's 4 dp tick — so the derived value
|
|
||||||
/// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose
|
|
||||||
/// value is the exact consumed residual. Omit for every rate-driven movement.
|
|
||||||
/// </param>
|
|
||||||
Task<StockLedger> PostLedgerAsync(
|
Task<StockLedger> PostLedgerAsync(
|
||||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||||
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
|
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default);
|
||||||
decimal? valueOverride = null);
|
|
||||||
|
|
||||||
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
|
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
|
||||||
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
using ERPCore.Infra.Gl;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single entry point into the external General Ledger service — the one function
|
|
||||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
|
||||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
|
||||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
|
||||||
/// No business logic lives here yet; this pass only connects the transport.
|
|
||||||
/// </summary>
|
|
||||||
public interface IGeneralLedgerService
|
|
||||||
{
|
|
||||||
Task<GeneralLedgerResponse> ForwardAsync(
|
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Production;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19). Every stock-affecting
|
|
||||||
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
|
|
||||||
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductionRunService
|
|
||||||
{
|
|
||||||
Task<PagedResponse<RunSummaryDto>> ListAsync(
|
|
||||||
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
|
||||||
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
|
||||||
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
|
|
||||||
/// </summary>
|
|
||||||
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Per-run quantity override on a stage that has not started
|
|
||||||
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
|
|
||||||
/// since raising an upstream input's planned quantity can un-ready it.
|
|
||||||
/// </summary>
|
|
||||||
Task<RunStageDto> UpdateStageQuantitiesAsync(
|
|
||||||
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
|
|
||||||
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
|
|
||||||
/// <para>Consumes <c>max(0, plannedBase − consumedQty)</c> per input, 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).</para>
|
|
||||||
/// </summary>
|
|
||||||
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
|
|
||||||
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
|
|
||||||
/// the previous figures rather than adding to them.
|
|
||||||
/// </summary>
|
|
||||||
Task<RunStageDto> CompleteStageAsync(
|
|
||||||
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
|
|
||||||
/// defaulting to the full available quantity. Terminal: posts the production receipt —
|
|
||||||
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
|
|
||||||
/// </summary>
|
|
||||||
Task<ApproveStageResultDto> ApproveStageAsync(
|
|
||||||
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
|
|
||||||
/// never exceeding produced − scrapped − already transferred.
|
|
||||||
/// </summary>
|
|
||||||
Task<TransferResultDto> TransferAsync(
|
|
||||||
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
|
|
||||||
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
|
|
||||||
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
|
|
||||||
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
|
|
||||||
/// </summary>
|
|
||||||
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
|
|
||||||
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
|
|
||||||
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
|
|
||||||
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
|
|
||||||
/// </summary>
|
|
||||||
Task<RejectIntakeResultDto> RejectIntakeAsync(
|
|
||||||
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
|
|
||||||
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
|
|
||||||
/// Already-consumed material remains in the cost pool.
|
|
||||||
/// </summary>
|
|
||||||
Task<TerminalRejectResultDto> RejectTerminalAsync(
|
|
||||||
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
|
|
||||||
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
|
|
||||||
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
|
|
||||||
/// </summary>
|
|
||||||
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Production;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductionTemplateService
|
|
||||||
{
|
|
||||||
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
|
|
||||||
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
|
|
||||||
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
|
|
||||||
/// </summary>
|
|
||||||
Task<ETagged<TemplateGraphDto>> UpdateAsync(
|
|
||||||
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
|
|
||||||
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
|
|
||||||
/// being started, so it must stay available while runs are in flight.
|
|
||||||
/// </summary>
|
|
||||||
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using ERPCore.Domain.Entities;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface ISalesDocumentWorkflowService
|
|
||||||
{
|
|
||||||
Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default);
|
|
||||||
Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using ERPCore.Domain.Enums;
|
|
||||||
using ERPCore.Dtos.Sales;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface ISalesDomainService
|
|
||||||
{
|
|
||||||
Task ValidateSalesHeaderAsync(
|
|
||||||
int customerId,
|
|
||||||
int warehouseId,
|
|
||||||
int? cashierUserId,
|
|
||||||
bool requireCashierUser,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task ValidateSalesLineAsync(
|
|
||||||
int headerWarehouseId,
|
|
||||||
int lineItemId,
|
|
||||||
int lineUomId,
|
|
||||||
int lineWarehouseId,
|
|
||||||
decimal qty,
|
|
||||||
decimal freeQty,
|
|
||||||
int? parentLineId,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<SalesPriceResolution> ResolveLinePriceAsync(
|
|
||||||
int itemId,
|
|
||||||
int warehouseId,
|
|
||||||
decimal? requestedUnitPrice,
|
|
||||||
bool allowManualOverride,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
SalesLineComputation ComputeLine(
|
|
||||||
decimal qty,
|
|
||||||
decimal freeQty,
|
|
||||||
decimal unitPrice,
|
|
||||||
SalesDiscountMode discountMode,
|
|
||||||
decimal discountPct,
|
|
||||||
decimal discountValue,
|
|
||||||
decimal discountAmount,
|
|
||||||
decimal taxPct,
|
|
||||||
bool isFreeIssue);
|
|
||||||
|
|
||||||
Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SalesLineComputation(
|
|
||||||
decimal Gross,
|
|
||||||
decimal DiscountTotal,
|
|
||||||
decimal NetUnitPrice,
|
|
||||||
decimal LineTotal,
|
|
||||||
decimal TaxAmount);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using ERPCore.Common.Http;
|
|
||||||
using ERPCore.Dtos.Common;
|
|
||||||
using ERPCore.Dtos.Sales;
|
|
||||||
using ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface ISalesInvoiceService
|
|
||||||
{
|
|
||||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
|
||||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
|
||||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
|
||||||
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
|
||||||
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
|
||||||
Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using ERPCore.Domain.Entities;
|
|
||||||
using ERPCore.Dtos.Sales;
|
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
|
||||||
|
|
||||||
public interface ISalesMappingService
|
|
||||||
{
|
|
||||||
SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice);
|
|
||||||
SalesSlipTotalsDto MapSlipTotals(SalesSlip slip);
|
|
||||||
SalesInvoiceDto MapInvoice(SalesInvoice invoice);
|
|
||||||
SalesSlipDto MapSlip(SalesSlip slip);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user