feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates. - Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs. - Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user