changed name from Apollinare to Zentral
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/bom")]
|
||||
public class BillOfMaterialsController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public BillOfMaterialsController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<BillOfMaterialsDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _productionService.GetBillOfMaterialsAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> GetById(int id)
|
||||
{
|
||||
var bom = await _productionService.GetBillOfMaterialsByIdAsync(id);
|
||||
if (bom == null) return NotFound();
|
||||
return Ok(bom);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> Create(CreateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = await _productionService.CreateBillOfMaterialsAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = bom.Id }, bom);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> Update(int id, UpdateBillOfMaterialsDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bom = await _productionService.UpdateBillOfMaterialsAsync(id, dto);
|
||||
return Ok(bom);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
await _productionService.DeleteBillOfMaterialsAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/mrp")]
|
||||
public class MrpController : ControllerBase
|
||||
{
|
||||
private readonly IMrpService _mrpService;
|
||||
|
||||
public MrpController(IMrpService mrpService)
|
||||
{
|
||||
_mrpService = mrpService;
|
||||
}
|
||||
|
||||
[HttpPost("run")]
|
||||
public async Task<IActionResult> RunMrp([FromBody] MrpConfigurationDto config)
|
||||
{
|
||||
await _mrpService.RunMrpAsync(config);
|
||||
return Ok(new { message = "MRP Run completed successfully" });
|
||||
}
|
||||
|
||||
[HttpGet("suggestions")]
|
||||
public async Task<ActionResult<List<MrpSuggestion>>> GetSuggestions([FromQuery] bool includeProcessed = false)
|
||||
{
|
||||
return await _mrpService.GetSuggestionsAsync(includeProcessed);
|
||||
}
|
||||
|
||||
[HttpPost("suggestions/{id}/process")]
|
||||
public async Task<IActionResult> ProcessSuggestion(int id)
|
||||
{
|
||||
await _mrpService.ProcessSuggestionAsync(id);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/cycles")]
|
||||
public class ProductionCyclesController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public ProductionCyclesController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ProductionCycleDto>>> GetProductionCycles()
|
||||
{
|
||||
return await _productionService.GetProductionCyclesAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductionCycleDto>> GetProductionCycle(int id)
|
||||
{
|
||||
var cycle = await _productionService.GetProductionCycleByIdAsync(id);
|
||||
if (cycle == null) return NotFound();
|
||||
return cycle;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProductionCycleDto>> CreateProductionCycle(CreateProductionCycleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cycle = await _productionService.CreateProductionCycleAsync(dto);
|
||||
return CreatedAtAction(nameof(GetProductionCycle), new { id = cycle.Id }, cycle);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ProductionCycleDto>> UpdateProductionCycle(int id, UpdateProductionCycleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateProductionCycleAsync(id, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteProductionCycle(int id)
|
||||
{
|
||||
await _productionService.DeleteProductionCycleAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/orders")]
|
||||
public class ProductionOrdersController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public ProductionOrdersController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ProductionOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _productionService.GetProductionOrdersAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _productionService.GetProductionOrderByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProductionOrderDto>> Create(CreateProductionOrderDto dto)
|
||||
{
|
||||
var order = await _productionService.CreateProductionOrderAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> Update(int id, UpdateProductionOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _productionService.UpdateProductionOrderAsync(id, dto);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}/status")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> ChangeStatus(int id, [FromBody] ProductionOrderStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.ChangeProductionOrderStatusAsync(id, status);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}/phases/{phaseId}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> UpdatePhase(int id, int phaseId, UpdateProductionOrderPhaseDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateProductionOrderPhaseAsync(id, phaseId, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productionService.DeleteProductionOrderAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/work-centers")]
|
||||
public class WorkCentersController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public WorkCentersController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<WorkCenterDto>>> GetWorkCenters()
|
||||
{
|
||||
return await _productionService.GetWorkCentersAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<WorkCenterDto>> GetWorkCenter(int id)
|
||||
{
|
||||
var wc = await _productionService.GetWorkCenterByIdAsync(id);
|
||||
if (wc == null) return NotFound();
|
||||
return wc;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<WorkCenterDto>> CreateWorkCenter(CreateWorkCenterDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wc = await _productionService.CreateWorkCenterAsync(dto);
|
||||
return CreatedAtAction(nameof(GetWorkCenter), new { id = wc.Id }, wc);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<WorkCenterDto>> UpdateWorkCenter(int id, UpdateWorkCenterDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateWorkCenterAsync(id, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteWorkCenter(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productionService.DeleteWorkCenterAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class BillOfMaterialsDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<BillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BillOfMaterialsComponentDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ComponentArticleId { get; set; }
|
||||
public string ComponentArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class CreateBillOfMaterialsDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public List<CreateBillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateBillOfMaterialsComponentDto
|
||||
{
|
||||
public int ComponentArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class CreateProductionOrderDto
|
||||
{
|
||||
public int ArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? BillOfMaterialsId { get; set; } // Optional: create from BOM
|
||||
public bool CreateChildOrders { get; set; } = false; // Optional: recursively create orders for sub-assemblies
|
||||
public int? ParentProductionOrderId { get; set; } // Internal use for recursion
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class MrpConfigurationDto
|
||||
{
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public bool IncludeSafetyStock { get; set; } = true;
|
||||
public bool IncludeSalesOrders { get; set; } = true;
|
||||
public bool IncludeForecasts { get; set; } = false;
|
||||
public List<int>? WarehouseIds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class ProductionCycleDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<ProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProductionCyclePhaseDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public string WorkCenterName { get; set; } = string.Empty;
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class CreateProductionCycleDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public List<CreateProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateProductionCyclePhaseDto
|
||||
{
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProductionCycleDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UpdateProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateProductionCyclePhaseDto
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class ProductionOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public ProductionOrderStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ProductionOrderComponentDto> Components { get; set; } = new();
|
||||
public List<ProductionOrderPhaseDto> Phases { get; set; } = new();
|
||||
public int? ParentProductionOrderId { get; set; }
|
||||
public string? ParentProductionOrderCode { get; set; }
|
||||
public List<ProductionOrderDto> ChildOrders { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProductionOrderPhaseDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int WorkCenterId { get; set; }
|
||||
public string WorkCenterName { get; set; } = string.Empty;
|
||||
public ProductionPhaseStatus Status { get; set; }
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public decimal QuantityCompleted { get; set; }
|
||||
public decimal QuantityScrapped { get; set; }
|
||||
public int EstimatedDurationMinutes { get; set; }
|
||||
public int ActualDurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class ProductionOrderComponentDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal RequiredQuantity { get; set; }
|
||||
public decimal ConsumedQuantity { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProductionOrderPhaseDto
|
||||
{
|
||||
public ProductionPhaseStatus Status { get; set; }
|
||||
public decimal QuantityCompleted { get; set; }
|
||||
public decimal QuantityScrapped { get; set; }
|
||||
public int ActualDurationMinutes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class UpdateBillOfMaterialsDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UpdateBillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateBillOfMaterialsComponentDto
|
||||
{
|
||||
public int? Id { get; set; } // If null, it's a new component
|
||||
public int ComponentArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
public bool IsDeleted { get; set; } // To remove components
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class UpdateProductionOrderDto
|
||||
{
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ProductionOrderStatus Status { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
public class WorkCenterDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CreateWorkCenterDto
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateWorkCenterDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
|
||||
public interface IMrpService
|
||||
{
|
||||
Task RunMrpAsync(MrpConfigurationDto config);
|
||||
Task<List<MrpSuggestion>> GetSuggestionsAsync(bool includeProcessed = false);
|
||||
Task ProcessSuggestionAsync(int suggestionId);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
|
||||
public interface IProductionService
|
||||
{
|
||||
// Bill Of Materials
|
||||
Task<List<BillOfMaterialsDto>> GetBillOfMaterialsAsync();
|
||||
Task<BillOfMaterialsDto?> GetBillOfMaterialsByIdAsync(int id);
|
||||
Task<BillOfMaterialsDto> CreateBillOfMaterialsAsync(CreateBillOfMaterialsDto dto);
|
||||
Task<BillOfMaterialsDto> UpdateBillOfMaterialsAsync(int id, UpdateBillOfMaterialsDto dto);
|
||||
Task DeleteBillOfMaterialsAsync(int id);
|
||||
|
||||
// Production Orders
|
||||
Task<List<ProductionOrderDto>> GetProductionOrdersAsync();
|
||||
Task<ProductionOrderDto?> GetProductionOrderByIdAsync(int id);
|
||||
Task<ProductionOrderDto> CreateProductionOrderAsync(CreateProductionOrderDto dto);
|
||||
Task<ProductionOrderDto> UpdateProductionOrderAsync(int id, UpdateProductionOrderDto dto);
|
||||
Task<ProductionOrderDto> ChangeProductionOrderStatusAsync(int id, ProductionOrderStatus status);
|
||||
Task<ProductionOrderDto> UpdateProductionOrderPhaseAsync(int orderId, int phaseId, UpdateProductionOrderPhaseDto dto);
|
||||
Task DeleteProductionOrderAsync(int id);
|
||||
|
||||
// Work Centers
|
||||
Task<List<WorkCenterDto>> GetWorkCentersAsync();
|
||||
Task<WorkCenterDto?> GetWorkCenterByIdAsync(int id);
|
||||
Task<WorkCenterDto> CreateWorkCenterAsync(CreateWorkCenterDto dto);
|
||||
Task<WorkCenterDto> UpdateWorkCenterAsync(int id, UpdateWorkCenterDto dto);
|
||||
Task DeleteWorkCenterAsync(int id);
|
||||
|
||||
// Production Cycles
|
||||
Task<List<ProductionCycleDto>> GetProductionCyclesAsync();
|
||||
Task<ProductionCycleDto?> GetProductionCycleByIdAsync(int id);
|
||||
Task<ProductionCycleDto> CreateProductionCycleAsync(CreateProductionCycleDto dto);
|
||||
Task<ProductionCycleDto> UpdateProductionCycleAsync(int id, UpdateProductionCycleDto dto);
|
||||
Task DeleteProductionCycleAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
|
||||
public class MrpService : IMrpService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly ILogger<MrpService> _logger;
|
||||
|
||||
public MrpService(ZentralDbContext context, ILogger<MrpService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunMrpAsync(MrpConfigurationDto config)
|
||||
{
|
||||
_logger.LogInformation("Starting Multi-Level MRP Run with config: {@Config}", config);
|
||||
|
||||
// 1. Clear existing unprocessed suggestions
|
||||
var oldSuggestions = await _context.MrpSuggestions
|
||||
.Where(s => !s.IsProcessed)
|
||||
.ToListAsync();
|
||||
_context.MrpSuggestions.RemoveRange(oldSuggestions);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 2. Load Data
|
||||
|
||||
// 2.0 Article Details (Safety Stock, Lead Time)
|
||||
var articleDetails = await _context.WarehouseArticles
|
||||
.Select(a => new { a.Id, a.MinimumStock, a.LeadTimeDays })
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
// 2.1 Stock Levels (Supply)
|
||||
var stockQuery = _context.StockLevels.AsQueryable();
|
||||
if (config.WarehouseIds != null && config.WarehouseIds.Any())
|
||||
{
|
||||
stockQuery = stockQuery.Where(s => config.WarehouseIds.Contains(s.WarehouseId));
|
||||
}
|
||||
|
||||
var stockLevels = await stockQuery
|
||||
.GroupBy(s => s.ArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(s => s.Quantity) })
|
||||
.ToDictionaryAsync(x => x.ArticleId, x => x.Quantity);
|
||||
|
||||
// 2.2 Incoming Purchase Orders (Supply)
|
||||
var incomingPurchases = await _context.PurchaseOrderLines
|
||||
.Include(l => l.PurchaseOrder)
|
||||
.Where(l => l.PurchaseOrder.Status == Domain.Entities.Purchases.PurchaseOrderStatus.Confirmed ||
|
||||
l.PurchaseOrder.Status == Domain.Entities.Purchases.PurchaseOrderStatus.PartiallyReceived)
|
||||
.GroupBy(l => l.WarehouseArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(l => l.Quantity - l.ReceivedQuantity) })
|
||||
.ToListAsync();
|
||||
|
||||
// 2.3 Incoming Production Orders (Supply for Parent, Demand for Components)
|
||||
var incomingProduction = await _context.ProductionOrders
|
||||
.Where(o => o.Status == ProductionOrderStatus.Planned ||
|
||||
o.Status == ProductionOrderStatus.Released ||
|
||||
o.Status == ProductionOrderStatus.InProgress)
|
||||
.ToListAsync();
|
||||
|
||||
// 2.4 Sales Orders (Independent Demand)
|
||||
var salesDemand = new List<dynamic>();
|
||||
if (config.IncludeSalesOrders)
|
||||
{
|
||||
var salesQuery = _context.SalesOrderLines
|
||||
.Include(l => l.SalesOrder)
|
||||
.Where(l => l.SalesOrder.Status == Domain.Entities.Sales.SalesOrderStatus.Confirmed ||
|
||||
l.SalesOrder.Status == Domain.Entities.Sales.SalesOrderStatus.PartiallyShipped);
|
||||
|
||||
var salesList = await salesQuery
|
||||
.GroupBy(l => l.WarehouseArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(l => l.Quantity) })
|
||||
.ToListAsync();
|
||||
|
||||
salesDemand.AddRange(salesList);
|
||||
}
|
||||
|
||||
// 2.5 BOMs (Structure)
|
||||
var boms = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.Where(b => b.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
var bomDictionary = boms.GroupBy(b => b.ArticleId).ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// 3. Initialize In-Memory State
|
||||
var stockOnHand = new Dictionary<int, decimal>(stockLevels);
|
||||
|
||||
// Add Incoming Purchases to Stock
|
||||
foreach (var p in incomingPurchases)
|
||||
{
|
||||
if (!stockOnHand.ContainsKey(p.ArticleId)) stockOnHand[p.ArticleId] = 0;
|
||||
stockOnHand[p.ArticleId] += p.Quantity;
|
||||
}
|
||||
|
||||
// Add Incoming Production (Parent Items) to Stock
|
||||
foreach (var p in incomingProduction)
|
||||
{
|
||||
if (!stockOnHand.ContainsKey(p.ArticleId)) stockOnHand[p.ArticleId] = 0;
|
||||
stockOnHand[p.ArticleId] += p.Quantity;
|
||||
}
|
||||
|
||||
// 4. Process Demand
|
||||
var suggestions = new List<MrpSuggestion>();
|
||||
var calculationDate = DateTime.UtcNow;
|
||||
|
||||
// Helper function for recursive processing
|
||||
void ProcessRequirement(int articleId, decimal qtyNeeded, string sourceReason, DateTime neededByDate)
|
||||
{
|
||||
if (qtyNeeded <= 0) return;
|
||||
|
||||
// Safety Stock Logic
|
||||
decimal safetyStock = 0;
|
||||
int leadTimeDays = 0;
|
||||
if (articleDetails.TryGetValue(articleId, out var details))
|
||||
{
|
||||
if (config.IncludeSafetyStock && details.MinimumStock.HasValue)
|
||||
{
|
||||
safetyStock = details.MinimumStock.Value;
|
||||
}
|
||||
leadTimeDays = details.LeadTimeDays ?? 0;
|
||||
}
|
||||
|
||||
decimal currentStock = stockOnHand.ContainsKey(articleId) ? stockOnHand[articleId] : 0;
|
||||
decimal availableForDemand = currentStock - safetyStock;
|
||||
|
||||
if (availableForDemand >= qtyNeeded)
|
||||
{
|
||||
// Demand met by stock
|
||||
stockOnHand[articleId] = currentStock - qtyNeeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Consume remaining available stock
|
||||
decimal toConsume = Math.Max(0, availableForDemand);
|
||||
stockOnHand[articleId] = currentStock - toConsume;
|
||||
|
||||
var netRequirement = qtyNeeded - availableForDemand;
|
||||
|
||||
// Create Suggestion
|
||||
bool hasBom = bomDictionary.ContainsKey(articleId);
|
||||
var type = hasBom ? MrpSuggestionType.Production : MrpSuggestionType.Purchase;
|
||||
|
||||
var orderDate = neededByDate.AddDays(-leadTimeDays);
|
||||
if (orderDate < DateTime.UtcNow) orderDate = DateTime.UtcNow;
|
||||
|
||||
suggestions.Add(new MrpSuggestion
|
||||
{
|
||||
CalculationDate = calculationDate,
|
||||
ArticleId = articleId,
|
||||
Type = type,
|
||||
Quantity = netRequirement,
|
||||
SuggestionDate = orderDate,
|
||||
Reason = $"{sourceReason} (Net: {netRequirement:F2})",
|
||||
IsProcessed = false
|
||||
});
|
||||
|
||||
// Explode BOM if Production
|
||||
if (hasBom)
|
||||
{
|
||||
var bom = bomDictionary[articleId];
|
||||
foreach (var comp in bom.Components)
|
||||
{
|
||||
var compQtyNeeded = netRequirement * comp.Quantity;
|
||||
if (comp.ScrapPercentage > 0)
|
||||
{
|
||||
compQtyNeeded = compQtyNeeded * (1 + comp.ScrapPercentage / 100);
|
||||
}
|
||||
|
||||
ProcessRequirement(comp.ComponentArticleId, compQtyNeeded, $"Ref: {articleId}", orderDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4.1 Process Sales Orders
|
||||
foreach (var demand in salesDemand)
|
||||
{
|
||||
ProcessRequirement(demand.ArticleId, demand.Quantity, "Sales Order", DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// 4.2 Process Existing Production Order Components
|
||||
var existingOrderComponents = await _context.ProductionOrderComponents
|
||||
.Include(c => c.ProductionOrder)
|
||||
.Where(c => c.ProductionOrder.Status == ProductionOrderStatus.Planned ||
|
||||
c.ProductionOrder.Status == ProductionOrderStatus.Released ||
|
||||
c.ProductionOrder.Status == ProductionOrderStatus.InProgress)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var comp in existingOrderComponents)
|
||||
{
|
||||
var remainingNeeded = comp.RequiredQuantity - comp.ConsumedQuantity;
|
||||
if (remainingNeeded > 0)
|
||||
{
|
||||
var neededDate = comp.ProductionOrder.StartDate;
|
||||
ProcessRequirement(comp.ArticleId, remainingNeeded, $"Prod Order {comp.ProductionOrder.Code}", neededDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Save Suggestions
|
||||
if (suggestions.Any())
|
||||
{
|
||||
var groupedSuggestions = suggestions
|
||||
.GroupBy(s => new { s.ArticleId, s.Type })
|
||||
.Select(g => new MrpSuggestion
|
||||
{
|
||||
CalculationDate = calculationDate,
|
||||
ArticleId = g.Key.ArticleId,
|
||||
Type = g.Key.Type,
|
||||
Quantity = g.Sum(s => s.Quantity),
|
||||
SuggestionDate = g.Min(s => s.SuggestionDate),
|
||||
Reason = "Aggregated Demand",
|
||||
IsProcessed = false
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_context.MrpSuggestions.AddRange(groupedSuggestions);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MRP Run completed. Generated {Count} suggestions.", suggestions.Count);
|
||||
}
|
||||
|
||||
public async Task<List<MrpSuggestion>> GetSuggestionsAsync(bool includeProcessed = false)
|
||||
{
|
||||
var query = _context.MrpSuggestions
|
||||
.Include(s => s.Article)
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeProcessed)
|
||||
{
|
||||
query = query.Where(s => !s.IsProcessed);
|
||||
}
|
||||
|
||||
return await query.OrderBy(s => s.SuggestionDate).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task ProcessSuggestionAsync(int suggestionId)
|
||||
{
|
||||
var suggestion = await _context.MrpSuggestions.FindAsync(suggestionId);
|
||||
if (suggestion == null) return;
|
||||
|
||||
// Logic to auto-create orders based on suggestion
|
||||
if (suggestion.Type == MrpSuggestionType.Production)
|
||||
{
|
||||
// Create Production Order
|
||||
// We need to call ProductionService, but circular dependency might be an issue if we inject it.
|
||||
// Better to keep this logic in Controller or have a separate Orchestrator.
|
||||
// For now, just mark as processed. The Controller likely handles the actual creation.
|
||||
}
|
||||
|
||||
suggestion.IsProcessed = true;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
|
||||
public class ProductionService : IProductionService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public ProductionService(ZentralDbContext context, IWarehouseService warehouseService)
|
||||
{
|
||||
_context = context;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// BILL OF MATERIALS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<BillOfMaterialsDto>> GetBillOfMaterialsAsync()
|
||||
{
|
||||
return await _context.BillOfMaterials
|
||||
.Include(b => b.Article)
|
||||
.Include(b => b.Components)
|
||||
.ThenInclude(c => c.ComponentArticle)
|
||||
.Where(b => b.IsActive)
|
||||
.Select(b => new BillOfMaterialsDto
|
||||
{
|
||||
Id = b.Id,
|
||||
Name = b.Name,
|
||||
Description = b.Description,
|
||||
ArticleId = b.ArticleId,
|
||||
ArticleName = b.Article.Description,
|
||||
Quantity = b.Quantity,
|
||||
IsActive = b.IsActive,
|
||||
Components = b.Components.Select(c => new BillOfMaterialsComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ComponentArticleId = c.ComponentArticleId,
|
||||
ComponentArticleName = c.ComponentArticle.Description,
|
||||
Quantity = c.Quantity,
|
||||
ScrapPercentage = c.ScrapPercentage
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto?> GetBillOfMaterialsByIdAsync(int id)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Article)
|
||||
.Include(b => b.Components)
|
||||
.ThenInclude(c => c.ComponentArticle)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
if (bom == null) return null;
|
||||
|
||||
return new BillOfMaterialsDto
|
||||
{
|
||||
Id = bom.Id,
|
||||
Name = bom.Name,
|
||||
Description = bom.Description,
|
||||
ArticleId = bom.ArticleId,
|
||||
ArticleName = bom.Article.Description,
|
||||
Quantity = bom.Quantity,
|
||||
IsActive = bom.IsActive,
|
||||
Components = bom.Components.Select(c => new BillOfMaterialsComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ComponentArticleId = c.ComponentArticleId,
|
||||
ComponentArticleName = c.ComponentArticle.Description,
|
||||
Quantity = c.Quantity,
|
||||
ScrapPercentage = c.ScrapPercentage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto> CreateBillOfMaterialsAsync(CreateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = new BillOfMaterials
|
||||
{
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
ArticleId = dto.ArticleId,
|
||||
Quantity = dto.Quantity,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
foreach (var compDto in dto.Components)
|
||||
{
|
||||
bom.Components.Add(new BillOfMaterialsComponent
|
||||
{
|
||||
ComponentArticleId = compDto.ComponentArticleId,
|
||||
Quantity = compDto.Quantity,
|
||||
ScrapPercentage = compDto.ScrapPercentage
|
||||
});
|
||||
}
|
||||
|
||||
_context.BillOfMaterials.Add(bom);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetBillOfMaterialsByIdAsync(bom.Id) ?? throw new InvalidOperationException("Failed to retrieve created BOM");
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto> UpdateBillOfMaterialsAsync(int id, UpdateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
if (bom == null) throw new KeyNotFoundException($"BOM with ID {id} not found");
|
||||
|
||||
bom.Name = dto.Name;
|
||||
bom.Description = dto.Description;
|
||||
bom.Quantity = dto.Quantity;
|
||||
bom.IsActive = dto.IsActive;
|
||||
|
||||
// Update components
|
||||
foreach (var compDto in dto.Components)
|
||||
{
|
||||
if (compDto.IsDeleted)
|
||||
{
|
||||
if (compDto.Id.HasValue)
|
||||
{
|
||||
var compToDelete = bom.Components.FirstOrDefault(c => c.Id == compDto.Id.Value);
|
||||
if (compToDelete != null)
|
||||
{
|
||||
_context.BillOfMaterialsComponents.Remove(compToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (compDto.Id.HasValue)
|
||||
{
|
||||
var compToUpdate = bom.Components.FirstOrDefault(c => c.Id == compDto.Id.Value);
|
||||
if (compToUpdate != null)
|
||||
{
|
||||
compToUpdate.ComponentArticleId = compDto.ComponentArticleId;
|
||||
compToUpdate.Quantity = compDto.Quantity;
|
||||
compToUpdate.ScrapPercentage = compDto.ScrapPercentage;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New component
|
||||
bom.Components.Add(new BillOfMaterialsComponent
|
||||
{
|
||||
ComponentArticleId = compDto.ComponentArticleId,
|
||||
Quantity = compDto.Quantity,
|
||||
ScrapPercentage = compDto.ScrapPercentage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetBillOfMaterialsByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated BOM");
|
||||
}
|
||||
|
||||
public async Task DeleteBillOfMaterialsAsync(int id)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials.FindAsync(id);
|
||||
if (bom != null)
|
||||
{
|
||||
_context.BillOfMaterials.Remove(bom);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// PRODUCTION ORDERS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<ProductionOrderDto>> GetProductionOrdersAsync()
|
||||
{
|
||||
return await _context.ProductionOrders
|
||||
.Include(o => o.Article)
|
||||
.Include(o => o.Components)
|
||||
.ThenInclude(c => c.Article)
|
||||
.Include(o => o.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.OrderByDescending(o => o.StartDate)
|
||||
.Select(o => new ProductionOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
Code = o.Code,
|
||||
ArticleId = o.ArticleId,
|
||||
ArticleName = o.Article.Description,
|
||||
Quantity = o.Quantity,
|
||||
StartDate = o.StartDate,
|
||||
EndDate = o.EndDate,
|
||||
DueDate = o.DueDate,
|
||||
Status = o.Status,
|
||||
Notes = o.Notes,
|
||||
Components = o.Components.Select(c => new ProductionOrderComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
RequiredQuantity = c.RequiredQuantity,
|
||||
ConsumedQuantity = c.ConsumedQuantity
|
||||
}).ToList(),
|
||||
Phases = o.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionOrderPhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
Status = p.Status,
|
||||
StartDate = p.StartDate,
|
||||
EndDate = p.EndDate,
|
||||
QuantityCompleted = p.QuantityCompleted,
|
||||
QuantityScrapped = p.QuantityScrapped,
|
||||
EstimatedDurationMinutes = p.EstimatedDurationMinutes,
|
||||
ActualDurationMinutes = p.ActualDurationMinutes
|
||||
}).ToList(),
|
||||
ParentProductionOrderId = o.ParentProductionOrderId,
|
||||
ParentProductionOrderCode = o.ParentProductionOrder != null ? o.ParentProductionOrder.Code : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto?> GetProductionOrderByIdAsync(int id)
|
||||
{
|
||||
var order = await _context.ProductionOrders
|
||||
.Include(o => o.Article)
|
||||
.Include(o => o.Components)
|
||||
.ThenInclude(c => c.Article)
|
||||
.Include(o => o.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new ProductionOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
Code = order.Code,
|
||||
ArticleId = order.ArticleId,
|
||||
ArticleName = order.Article.Description,
|
||||
Quantity = order.Quantity,
|
||||
StartDate = order.StartDate,
|
||||
EndDate = order.EndDate,
|
||||
DueDate = order.DueDate,
|
||||
Status = order.Status,
|
||||
Notes = order.Notes,
|
||||
Components = order.Components.Select(c => new ProductionOrderComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
RequiredQuantity = c.RequiredQuantity,
|
||||
ConsumedQuantity = c.ConsumedQuantity
|
||||
}).ToList(),
|
||||
Phases = order.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionOrderPhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
Status = p.Status,
|
||||
StartDate = p.StartDate,
|
||||
EndDate = p.EndDate,
|
||||
QuantityCompleted = p.QuantityCompleted,
|
||||
QuantityScrapped = p.QuantityScrapped,
|
||||
EstimatedDurationMinutes = p.EstimatedDurationMinutes,
|
||||
ActualDurationMinutes = p.ActualDurationMinutes
|
||||
}).ToList(),
|
||||
ParentProductionOrderId = order.ParentProductionOrderId,
|
||||
ParentProductionOrderCode = order.ParentProductionOrder?.Code,
|
||||
ChildOrders = order.ChildProductionOrders.Select(c => new ProductionOrderDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Code = c.Code,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
Quantity = c.Quantity,
|
||||
StartDate = c.StartDate,
|
||||
DueDate = c.DueDate,
|
||||
Status = c.Status
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> CreateProductionOrderAsync(CreateProductionOrderDto dto)
|
||||
{
|
||||
var order = new ProductionOrder
|
||||
{
|
||||
Code = $"PO-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString().Substring(0, 4).ToUpper()}", // Simple code generation
|
||||
ArticleId = dto.ArticleId,
|
||||
Quantity = dto.Quantity,
|
||||
StartDate = dto.StartDate,
|
||||
DueDate = dto.DueDate,
|
||||
Status = ProductionOrderStatus.Draft,
|
||||
Notes = dto.Notes,
|
||||
ParentProductionOrderId = dto.ParentProductionOrderId
|
||||
};
|
||||
|
||||
// If BOM is provided, copy components
|
||||
if (dto.BillOfMaterialsId.HasValue)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.FirstOrDefaultAsync(b => b.Id == dto.BillOfMaterialsId.Value);
|
||||
|
||||
if (bom != null)
|
||||
{
|
||||
// Calculate ratio based on BOM quantity vs Order quantity
|
||||
var ratio = dto.Quantity / bom.Quantity;
|
||||
|
||||
foreach (var comp in bom.Components)
|
||||
{
|
||||
order.Components.Add(new ProductionOrderComponent
|
||||
{
|
||||
ArticleId = comp.ComponentArticleId,
|
||||
RequiredQuantity = comp.Quantity * ratio,
|
||||
ConsumedQuantity = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy default production cycle phases
|
||||
var defaultCycle = await _context.ProductionCycles
|
||||
.Include(c => c.Phases)
|
||||
.FirstOrDefaultAsync(c => c.ArticleId == dto.ArticleId && c.IsDefault && c.IsActive);
|
||||
|
||||
if (defaultCycle != null)
|
||||
{
|
||||
foreach (var phase in defaultCycle.Phases)
|
||||
{
|
||||
order.Phases.Add(new ProductionOrderPhase
|
||||
{
|
||||
Sequence = phase.Sequence,
|
||||
Name = phase.Name,
|
||||
WorkCenterId = phase.WorkCenterId,
|
||||
Status = ProductionPhaseStatus.Pending,
|
||||
EstimatedDurationMinutes = phase.SetupTimeMinutes + (phase.DurationPerUnitMinutes * (int)dto.Quantity),
|
||||
QuantityCompleted = 0,
|
||||
QuantityScrapped = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_context.ProductionOrders.Add(order);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Recursively create child orders if requested
|
||||
if (dto.CreateChildOrders && order.Components.Any())
|
||||
{
|
||||
foreach (var comp in order.Components)
|
||||
{
|
||||
// Check if component has a BOM (is manufactured)
|
||||
var compBom = await _context.BillOfMaterials
|
||||
.FirstOrDefaultAsync(b => b.ArticleId == comp.ArticleId && b.IsActive);
|
||||
|
||||
if (compBom != null)
|
||||
{
|
||||
// Create child order
|
||||
var childDto = new CreateProductionOrderDto
|
||||
{
|
||||
ArticleId = comp.ArticleId,
|
||||
Quantity = comp.RequiredQuantity,
|
||||
StartDate = dto.StartDate.AddDays(-1), // Simple scheduling: start 1 day earlier
|
||||
DueDate = dto.StartDate, // Must be ready by parent start
|
||||
Notes = $"Auto-generated for Parent Order {order.Code}",
|
||||
BillOfMaterialsId = compBom.Id,
|
||||
CreateChildOrders = true, // Recursive
|
||||
ParentProductionOrderId = order.Id // Link to parent
|
||||
};
|
||||
|
||||
await CreateProductionOrderAsync(childDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await GetProductionOrderByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> UpdateProductionOrderAsync(int id, UpdateProductionOrderDto dto)
|
||||
{
|
||||
var order = await _context.ProductionOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException($"Order with ID {id} not found");
|
||||
|
||||
if (order.Status == ProductionOrderStatus.Completed || order.Status == ProductionOrderStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot update completed or cancelled orders");
|
||||
}
|
||||
|
||||
order.Quantity = dto.Quantity;
|
||||
order.StartDate = dto.StartDate;
|
||||
order.DueDate = dto.DueDate;
|
||||
order.Notes = dto.Notes;
|
||||
|
||||
// Status change logic could be complex, for now just allow simple updates if not final
|
||||
// Ideally status change should be a separate method (which it is)
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionOrderByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> ChangeProductionOrderStatusAsync(int id, ProductionOrderStatus status)
|
||||
{
|
||||
var order = await _context.ProductionOrders
|
||||
.Include(o => o.Components)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException($"Order with ID {id} not found");
|
||||
|
||||
// Simple state machine check
|
||||
if (order.Status == ProductionOrderStatus.Completed)
|
||||
throw new InvalidOperationException("Order is already completed");
|
||||
|
||||
if (status == ProductionOrderStatus.Completed)
|
||||
{
|
||||
var defaultWarehouseId = await GetDefaultWarehouseIdAsync();
|
||||
|
||||
// Get reasons
|
||||
var prodReason = await _context.MovementReasons.FirstOrDefaultAsync(r => r.Code == "PROD");
|
||||
var consReason = await _context.MovementReasons.FirstOrDefaultAsync(r => r.Code == "CONS");
|
||||
|
||||
// 1. Create Inbound Movement for Finished Good
|
||||
var inboundMovement = new StockMovement
|
||||
{
|
||||
MovementDate = DateTime.UtcNow,
|
||||
Type = MovementType.Production, // Inbound from Production
|
||||
Status = MovementStatus.Draft, // Must be Draft to be confirmed
|
||||
ReasonId = prodReason?.Id,
|
||||
ExternalReference = order.Code,
|
||||
ExternalDocumentType = ExternalDocumentType.ProductionOrder,
|
||||
Notes = $"Production Order {order.Code} Completed",
|
||||
DestinationWarehouseId = defaultWarehouseId
|
||||
};
|
||||
|
||||
inboundMovement.Lines.Add(new StockMovementLine
|
||||
{
|
||||
ArticleId = order.ArticleId,
|
||||
Quantity = order.Quantity,
|
||||
LineNumber = 1
|
||||
});
|
||||
|
||||
await _warehouseService.CreateMovementAsync(inboundMovement);
|
||||
await _warehouseService.ConfirmMovementAsync(inboundMovement.Id);
|
||||
|
||||
// 2. Create Outbound Movement for Components (Consumption)
|
||||
if (order.Components.Any())
|
||||
{
|
||||
var outboundMovement = new StockMovement
|
||||
{
|
||||
MovementDate = DateTime.UtcNow,
|
||||
Type = MovementType.Consumption, // Outbound for Production
|
||||
Status = MovementStatus.Draft, // Must be Draft to be confirmed
|
||||
ReasonId = consReason?.Id,
|
||||
ExternalReference = order.Code,
|
||||
ExternalDocumentType = ExternalDocumentType.ProductionOrder,
|
||||
Notes = $"Consumption for Production Order {order.Code}",
|
||||
SourceWarehouseId = defaultWarehouseId
|
||||
};
|
||||
|
||||
int lineNum = 1;
|
||||
foreach (var comp in order.Components)
|
||||
{
|
||||
outboundMovement.Lines.Add(new StockMovementLine
|
||||
{
|
||||
ArticleId = comp.ArticleId,
|
||||
Quantity = comp.RequiredQuantity, // Consuming required quantity
|
||||
LineNumber = lineNum++
|
||||
});
|
||||
|
||||
// Update consumed quantity on the order component
|
||||
comp.ConsumedQuantity = comp.RequiredQuantity;
|
||||
}
|
||||
|
||||
await _warehouseService.CreateMovementAsync(outboundMovement);
|
||||
await _warehouseService.ConfirmMovementAsync(outboundMovement.Id);
|
||||
}
|
||||
|
||||
order.EndDate = DateTime.Now;
|
||||
}
|
||||
|
||||
order.Status = status;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetProductionOrderByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> UpdateProductionOrderPhaseAsync(int orderId, int phaseId, UpdateProductionOrderPhaseDto dto)
|
||||
{
|
||||
var phase = await _context.ProductionOrderPhases
|
||||
.FirstOrDefaultAsync(p => p.Id == phaseId && p.ProductionOrderId == orderId);
|
||||
|
||||
if (phase == null) throw new KeyNotFoundException($"Phase with ID {phaseId} not found in order {orderId}");
|
||||
|
||||
phase.Status = dto.Status;
|
||||
phase.QuantityCompleted = dto.QuantityCompleted;
|
||||
phase.QuantityScrapped = dto.QuantityScrapped;
|
||||
phase.ActualDurationMinutes = dto.ActualDurationMinutes;
|
||||
|
||||
if (dto.Status == ProductionPhaseStatus.InProgress && phase.StartDate == null)
|
||||
{
|
||||
phase.StartDate = DateTime.Now;
|
||||
}
|
||||
else if (dto.Status == ProductionPhaseStatus.Completed && phase.EndDate == null)
|
||||
{
|
||||
phase.EndDate = DateTime.Now;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionOrderByIdAsync(orderId) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task DeleteProductionOrderAsync(int id)
|
||||
{
|
||||
var order = await _context.ProductionOrders.FindAsync(id);
|
||||
if (order != null)
|
||||
{
|
||||
if (order.Status != ProductionOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Only draft orders can be deleted");
|
||||
|
||||
_context.ProductionOrders.Remove(order);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetDefaultWarehouseIdAsync()
|
||||
{
|
||||
var wh = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
return wh?.Id ?? throw new InvalidOperationException("No default warehouse found");
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// WORK CENTERS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<WorkCenterDto>> GetWorkCentersAsync()
|
||||
{
|
||||
return await _context.WorkCenters
|
||||
.Where(w => w.IsActive)
|
||||
.Select(w => new WorkCenterDto
|
||||
{
|
||||
Id = w.Id,
|
||||
Code = w.Code,
|
||||
Name = w.Name,
|
||||
Description = w.Description,
|
||||
CostPerHour = w.CostPerHour,
|
||||
IsActive = w.IsActive
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto?> GetWorkCenterByIdAsync(int id)
|
||||
{
|
||||
var w = await _context.WorkCenters.FindAsync(id);
|
||||
if (w == null) return null;
|
||||
|
||||
return new WorkCenterDto
|
||||
{
|
||||
Id = w.Id,
|
||||
Code = w.Code,
|
||||
Name = w.Name,
|
||||
Description = w.Description,
|
||||
CostPerHour = w.CostPerHour,
|
||||
IsActive = w.IsActive
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto> CreateWorkCenterAsync(CreateWorkCenterDto dto)
|
||||
{
|
||||
if (await _context.WorkCenters.AnyAsync(w => w.Code == dto.Code))
|
||||
throw new InvalidOperationException($"Work center with code {dto.Code} already exists");
|
||||
|
||||
var workCenter = new WorkCenter
|
||||
{
|
||||
Code = dto.Code,
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
CostPerHour = dto.CostPerHour,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.WorkCenters.Add(workCenter);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetWorkCenterByIdAsync(workCenter.Id) ?? throw new InvalidOperationException("Failed to retrieve created work center");
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto> UpdateWorkCenterAsync(int id, UpdateWorkCenterDto dto)
|
||||
{
|
||||
var workCenter = await _context.WorkCenters.FindAsync(id);
|
||||
if (workCenter == null) throw new KeyNotFoundException($"Work center with ID {id} not found");
|
||||
|
||||
workCenter.Name = dto.Name;
|
||||
workCenter.Description = dto.Description;
|
||||
workCenter.CostPerHour = dto.CostPerHour;
|
||||
workCenter.IsActive = dto.IsActive;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetWorkCenterByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated work center");
|
||||
}
|
||||
|
||||
public async Task DeleteWorkCenterAsync(int id)
|
||||
{
|
||||
var workCenter = await _context.WorkCenters.FindAsync(id);
|
||||
if (workCenter != null)
|
||||
{
|
||||
// Check if used in any cycle or order phase
|
||||
if (await _context.ProductionCyclePhases.AnyAsync(p => p.WorkCenterId == id))
|
||||
throw new InvalidOperationException("Cannot delete work center used in production cycles");
|
||||
|
||||
if (await _context.ProductionOrderPhases.AnyAsync(p => p.WorkCenterId == id))
|
||||
throw new InvalidOperationException("Cannot delete work center used in production orders");
|
||||
|
||||
_context.WorkCenters.Remove(workCenter);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// PRODUCTION CYCLES
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<ProductionCycleDto>> GetProductionCyclesAsync()
|
||||
{
|
||||
return await _context.ProductionCycles
|
||||
.Include(c => c.Article)
|
||||
.Include(c => c.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => new ProductionCycleDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
IsDefault = c.IsDefault,
|
||||
IsActive = c.IsActive,
|
||||
Phases = c.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionCyclePhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
DurationPerUnitMinutes = p.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = p.SetupTimeMinutes
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto?> GetProductionCycleByIdAsync(int id)
|
||||
{
|
||||
var c = await _context.ProductionCycles
|
||||
.Include(c => c.Article)
|
||||
.Include(c => c.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (c == null) return null;
|
||||
|
||||
return new ProductionCycleDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
IsDefault = c.IsDefault,
|
||||
IsActive = c.IsActive,
|
||||
Phases = c.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionCyclePhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
DurationPerUnitMinutes = p.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = p.SetupTimeMinutes
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto> CreateProductionCycleAsync(CreateProductionCycleDto dto)
|
||||
{
|
||||
var cycle = new ProductionCycle
|
||||
{
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
ArticleId = dto.ArticleId,
|
||||
IsDefault = dto.IsDefault,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
if (dto.IsDefault)
|
||||
{
|
||||
// Unset other defaults for this article
|
||||
var defaults = await _context.ProductionCycles
|
||||
.Where(c => c.ArticleId == dto.ArticleId && c.IsDefault)
|
||||
.ToListAsync();
|
||||
foreach (var d in defaults) d.IsDefault = false;
|
||||
}
|
||||
|
||||
foreach (var phaseDto in dto.Phases)
|
||||
{
|
||||
cycle.Phases.Add(new ProductionCyclePhase
|
||||
{
|
||||
Sequence = phaseDto.Sequence,
|
||||
Name = phaseDto.Name,
|
||||
Description = phaseDto.Description,
|
||||
WorkCenterId = phaseDto.WorkCenterId,
|
||||
DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = phaseDto.SetupTimeMinutes
|
||||
});
|
||||
}
|
||||
|
||||
_context.ProductionCycles.Add(cycle);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetProductionCycleByIdAsync(cycle.Id) ?? throw new InvalidOperationException("Failed to retrieve created cycle");
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto> UpdateProductionCycleAsync(int id, UpdateProductionCycleDto dto)
|
||||
{
|
||||
var cycle = await _context.ProductionCycles
|
||||
.Include(c => c.Phases)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cycle == null) throw new KeyNotFoundException($"Cycle with ID {id} not found");
|
||||
|
||||
cycle.Name = dto.Name;
|
||||
cycle.Description = dto.Description;
|
||||
cycle.IsActive = dto.IsActive;
|
||||
|
||||
if (dto.IsDefault && !cycle.IsDefault)
|
||||
{
|
||||
// Unset other defaults
|
||||
var defaults = await _context.ProductionCycles
|
||||
.Where(c => c.ArticleId == cycle.ArticleId && c.IsDefault && c.Id != id)
|
||||
.ToListAsync();
|
||||
foreach (var d in defaults) d.IsDefault = false;
|
||||
}
|
||||
cycle.IsDefault = dto.IsDefault;
|
||||
|
||||
// Update phases
|
||||
foreach (var phaseDto in dto.Phases)
|
||||
{
|
||||
if (phaseDto.IsDeleted)
|
||||
{
|
||||
if (phaseDto.Id.HasValue)
|
||||
{
|
||||
var phaseToDelete = cycle.Phases.FirstOrDefault(p => p.Id == phaseDto.Id.Value);
|
||||
if (phaseToDelete != null) _context.ProductionCyclePhases.Remove(phaseToDelete);
|
||||
}
|
||||
}
|
||||
else if (phaseDto.Id.HasValue)
|
||||
{
|
||||
var phaseToUpdate = cycle.Phases.FirstOrDefault(p => p.Id == phaseDto.Id.Value);
|
||||
if (phaseToUpdate != null)
|
||||
{
|
||||
phaseToUpdate.Sequence = phaseDto.Sequence;
|
||||
phaseToUpdate.Name = phaseDto.Name;
|
||||
phaseToUpdate.Description = phaseDto.Description;
|
||||
phaseToUpdate.WorkCenterId = phaseDto.WorkCenterId;
|
||||
phaseToUpdate.DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes;
|
||||
phaseToUpdate.SetupTimeMinutes = phaseDto.SetupTimeMinutes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cycle.Phases.Add(new ProductionCyclePhase
|
||||
{
|
||||
Sequence = phaseDto.Sequence,
|
||||
Name = phaseDto.Name,
|
||||
Description = phaseDto.Description,
|
||||
WorkCenterId = phaseDto.WorkCenterId,
|
||||
DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = phaseDto.SetupTimeMinutes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionCycleByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated cycle");
|
||||
}
|
||||
|
||||
public async Task DeleteProductionCycleAsync(int id)
|
||||
{
|
||||
var cycle = await _context.ProductionCycles.FindAsync(id);
|
||||
if (cycle != null)
|
||||
{
|
||||
_context.ProductionCycles.Remove(cycle);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/orders")]
|
||||
public class PurchaseOrdersController : ControllerBase
|
||||
{
|
||||
private readonly PurchaseService _service;
|
||||
|
||||
public PurchaseOrdersController(PurchaseService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<PurchaseOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _service.GetByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Create(CreatePurchaseOrderDto dto)
|
||||
{
|
||||
var order = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Update(int id, UpdatePurchaseOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.UpdateAsync(id, dto);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Confirm(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ConfirmOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/receive")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Receive(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ReceiveOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/suppliers")]
|
||||
public class SuppliersController : ControllerBase
|
||||
{
|
||||
private readonly SupplierService _service;
|
||||
|
||||
public SuppliersController(SupplierService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SupplierDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SupplierDto>> GetById(int id)
|
||||
{
|
||||
var supplier = await _service.GetByIdAsync(id);
|
||||
if (supplier == null) return NotFound();
|
||||
return Ok(supplier);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SupplierDto>> Create(CreateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = supplier.Id }, supplier);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SupplierDto>> Update(int id, UpdateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _service.UpdateAsync(id, dto);
|
||||
if (supplier == null) return NotFound();
|
||||
return Ok(supplier);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Dtos;
|
||||
|
||||
public class PurchaseOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int SupplierId { get; set; }
|
||||
public string SupplierName { get; set; } = string.Empty;
|
||||
public PurchaseOrderStatus Status { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? DestinationWarehouseName { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public decimal TotalNet { get; set; }
|
||||
public decimal TotalTax { get; set; }
|
||||
public decimal TotalGross { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public List<PurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PurchaseOrderLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PurchaseOrderId { get; set; }
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string ArticleCode { get; set; } = string.Empty;
|
||||
public string ArticleDescription { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ReceivedQuantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class CreatePurchaseOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int SupplierId { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<CreatePurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreatePurchaseOrderLineDto
|
||||
{
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; } // Optional override
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
}
|
||||
|
||||
public class UpdatePurchaseOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<UpdatePurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdatePurchaseOrderLineDto
|
||||
{
|
||||
public int? Id { get; set; } // Null if new line
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public bool IsDeleted { get; set; } // To mark for deletion
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Dtos;
|
||||
|
||||
public class SupplierDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSupplierDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; } = "Italia";
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSupplierDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Services;
|
||||
|
||||
public class PurchaseService
|
||||
{
|
||||
private readonly ZentralDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public PurchaseService(ZentralDbContext db, AutoCodeService autoCodeService, IWarehouseService warehouseService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
public async Task<List<PurchaseOrderDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.PurchaseOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Supplier)
|
||||
.Include(o => o.DestinationWarehouse)
|
||||
.OrderByDescending(o => o.OrderDate)
|
||||
.Select(o => new PurchaseOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
OrderNumber = o.OrderNumber,
|
||||
OrderDate = o.OrderDate,
|
||||
ExpectedDeliveryDate = o.ExpectedDeliveryDate,
|
||||
SupplierId = o.SupplierId,
|
||||
SupplierName = o.Supplier!.Name,
|
||||
Status = o.Status,
|
||||
DestinationWarehouseId = o.DestinationWarehouseId,
|
||||
DestinationWarehouseName = o.DestinationWarehouse != null ? o.DestinationWarehouse.Name : null,
|
||||
Notes = o.Notes,
|
||||
TotalNet = o.TotalNet,
|
||||
TotalTax = o.TotalTax,
|
||||
TotalGross = o.TotalGross,
|
||||
CreatedAt = o.CreatedAt,
|
||||
UpdatedAt = o.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Supplier)
|
||||
.Include(o => o.DestinationWarehouse)
|
||||
.Include(o => o.Lines)
|
||||
.ThenInclude(l => l.WarehouseArticle)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new PurchaseOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
OrderNumber = order.OrderNumber,
|
||||
OrderDate = order.OrderDate,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
SupplierId = order.SupplierId,
|
||||
SupplierName = order.Supplier!.Name,
|
||||
Status = order.Status,
|
||||
DestinationWarehouseId = order.DestinationWarehouseId,
|
||||
DestinationWarehouseName = order.DestinationWarehouse?.Name,
|
||||
Notes = order.Notes,
|
||||
TotalNet = order.TotalNet,
|
||||
TotalTax = order.TotalTax,
|
||||
TotalGross = order.TotalGross,
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
Lines = order.Lines.Select(l => new PurchaseOrderLineDto
|
||||
{
|
||||
Id = l.Id,
|
||||
PurchaseOrderId = l.PurchaseOrderId,
|
||||
WarehouseArticleId = l.WarehouseArticleId,
|
||||
ArticleCode = l.WarehouseArticle!.Code,
|
||||
ArticleDescription = l.WarehouseArticle.Description,
|
||||
Description = l.Description,
|
||||
Quantity = l.Quantity,
|
||||
ReceivedQuantity = l.ReceivedQuantity,
|
||||
UnitPrice = l.UnitPrice,
|
||||
TaxRate = l.TaxRate,
|
||||
DiscountPercent = l.DiscountPercent,
|
||||
LineTotal = l.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> CreateAsync(CreatePurchaseOrderDto dto)
|
||||
{
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("purchase_order");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
code = $"ODA{DateTime.Now:yyyy}-{Guid.NewGuid().ToString().Substring(0, 5).ToUpper()}";
|
||||
}
|
||||
|
||||
var order = new PurchaseOrder
|
||||
{
|
||||
OrderNumber = code,
|
||||
OrderDate = dto.OrderDate,
|
||||
ExpectedDeliveryDate = dto.ExpectedDeliveryDate,
|
||||
SupplierId = dto.SupplierId,
|
||||
DestinationWarehouseId = dto.DestinationWarehouseId,
|
||||
Notes = dto.Notes,
|
||||
Status = PurchaseOrderStatus.Draft
|
||||
};
|
||||
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
var line = new PurchaseOrderLine
|
||||
{
|
||||
WarehouseArticleId = lineDto.WarehouseArticleId,
|
||||
Description = lineDto.Description ?? string.Empty,
|
||||
Quantity = lineDto.Quantity,
|
||||
UnitPrice = lineDto.UnitPrice,
|
||||
TaxRate = lineDto.TaxRate,
|
||||
DiscountPercent = lineDto.DiscountPercent
|
||||
};
|
||||
|
||||
// If description is empty, fetch from article
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
_db.PurchaseOrders.Add(order);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto?> UpdateAsync(int id, UpdatePurchaseOrderDto dto)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
if (order.Status != PurchaseOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza possono essere modificati");
|
||||
|
||||
order.OrderDate = dto.OrderDate;
|
||||
order.ExpectedDeliveryDate = dto.ExpectedDeliveryDate;
|
||||
order.DestinationWarehouseId = dto.DestinationWarehouseId;
|
||||
order.Notes = dto.Notes;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
// Update lines
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
if (lineDto.IsDeleted)
|
||||
{
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
var lineToDelete = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value);
|
||||
if (lineToDelete != null) order.Lines.Remove(lineToDelete);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
PurchaseOrderLine line;
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
line = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value)
|
||||
?? throw new KeyNotFoundException($"Line {lineDto.Id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
line = new PurchaseOrderLine();
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
line.WarehouseArticleId = lineDto.WarehouseArticleId;
|
||||
line.Description = lineDto.Description ?? string.Empty;
|
||||
line.Quantity = lineDto.Quantity;
|
||||
line.UnitPrice = lineDto.UnitPrice;
|
||||
line.TaxRate = lineDto.TaxRate;
|
||||
line.DiscountPercent = lineDto.DiscountPercent;
|
||||
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders.FindAsync(id);
|
||||
if (order == null) return false;
|
||||
if (order.Status != PurchaseOrderStatus.Draft && order.Status != PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza o annullati possono essere eliminati");
|
||||
|
||||
_db.PurchaseOrders.Remove(order);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> ConfirmOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != PurchaseOrderStatus.Draft) throw new InvalidOperationException("Solo gli ordini in bozza possono essere confermati");
|
||||
|
||||
order.Status = PurchaseOrderStatus.Confirmed;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> ReceiveOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != PurchaseOrderStatus.Confirmed && order.Status != PurchaseOrderStatus.PartiallyReceived)
|
||||
throw new InvalidOperationException("L'ordine deve essere confermato per essere ricevuto");
|
||||
|
||||
// Create Stock Movement (Inbound)
|
||||
var warehouseId = order.DestinationWarehouseId;
|
||||
if (!warehouseId.HasValue)
|
||||
{
|
||||
var defaultWarehouse = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
warehouseId = defaultWarehouse?.Id;
|
||||
}
|
||||
|
||||
if (!warehouseId.HasValue) throw new InvalidOperationException("Nessun magazzino di destinazione specificato o di default");
|
||||
|
||||
// Genera numero documento movimento
|
||||
var docNumber = await _warehouseService.GenerateDocumentNumberAsync(MovementType.Inbound);
|
||||
|
||||
// Trova causale di default per acquisto (se esiste, altrimenti null o crea)
|
||||
var reason = (await _warehouseService.GetMovementReasonsAsync(MovementType.Inbound))
|
||||
.FirstOrDefault(r => r.Code == "ACQ" || r.Description.Contains("Acquisto"));
|
||||
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = docNumber,
|
||||
MovementDate = DateTime.Now,
|
||||
Type = MovementType.Inbound,
|
||||
Status = MovementStatus.Draft,
|
||||
DestinationWarehouseId = warehouseId,
|
||||
ReasonId = reason?.Id,
|
||||
ExternalReference = order.OrderNumber,
|
||||
Notes = $"Ricevimento merce da ordine {order.OrderNumber}"
|
||||
};
|
||||
|
||||
movement = await _warehouseService.CreateMovementAsync(movement);
|
||||
|
||||
// Add lines to movement
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
var remainingQty = line.Quantity - line.ReceivedQuantity;
|
||||
if (remainingQty <= 0) continue;
|
||||
|
||||
// Update received quantity on order line
|
||||
line.ReceivedQuantity += remainingQty;
|
||||
|
||||
// Add movement line directly via DbContext since IWarehouseService doesn't expose AddLine (it exposes UpdateMovement)
|
||||
// Or better, construct the movement with lines initially if possible.
|
||||
// Since CreateMovementAsync saves, we need to add lines and save again.
|
||||
|
||||
var movementLine = new StockMovementLine
|
||||
{
|
||||
MovementId = movement.Id,
|
||||
ArticleId = line.WarehouseArticleId,
|
||||
Quantity = remainingQty,
|
||||
UnitCost = line.UnitPrice * (1 - line.DiscountPercent / 100),
|
||||
LineValue = Math.Round(remainingQty * (line.UnitPrice * (1 - line.DiscountPercent / 100)), 2)
|
||||
};
|
||||
|
||||
_db.StockMovementLines.Add(movementLine);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Confirm movement to update stock
|
||||
await _warehouseService.ConfirmMovementAsync(movement.Id);
|
||||
|
||||
// Update order status
|
||||
var allReceived = order.Lines.All(l => l.ReceivedQuantity >= l.Quantity);
|
||||
order.Status = allReceived ? PurchaseOrderStatus.Received : PurchaseOrderStatus.PartiallyReceived;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
private void CalculateOrderTotals(PurchaseOrder order)
|
||||
{
|
||||
order.TotalNet = 0;
|
||||
order.TotalTax = 0;
|
||||
order.TotalGross = 0;
|
||||
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
order.TotalNet += line.LineTotal;
|
||||
var taxAmount = line.LineTotal * (line.TaxRate / 100);
|
||||
order.TotalTax += taxAmount;
|
||||
}
|
||||
|
||||
order.TotalGross = order.TotalNet + order.TotalTax;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Services;
|
||||
|
||||
public class SupplierService
|
||||
{
|
||||
private readonly ZentralDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
|
||||
public SupplierService(ZentralDbContext db, AutoCodeService autoCodeService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
}
|
||||
|
||||
public async Task<List<SupplierDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.Suppliers
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new SupplierDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Code = s.Code,
|
||||
Name = s.Name,
|
||||
VatNumber = s.VatNumber,
|
||||
FiscalCode = s.FiscalCode,
|
||||
Address = s.Address,
|
||||
City = s.City,
|
||||
Province = s.Province,
|
||||
ZipCode = s.ZipCode,
|
||||
Country = s.Country,
|
||||
Email = s.Email,
|
||||
Pec = s.Pec,
|
||||
Phone = s.Phone,
|
||||
Website = s.Website,
|
||||
PaymentTerms = s.PaymentTerms,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SupplierDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var supplier = await _db.Suppliers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (supplier == null) return null;
|
||||
|
||||
return new SupplierDto
|
||||
{
|
||||
Id = supplier.Id,
|
||||
Code = supplier.Code,
|
||||
Name = supplier.Name,
|
||||
VatNumber = supplier.VatNumber,
|
||||
FiscalCode = supplier.FiscalCode,
|
||||
Address = supplier.Address,
|
||||
City = supplier.City,
|
||||
Province = supplier.Province,
|
||||
ZipCode = supplier.ZipCode,
|
||||
Country = supplier.Country,
|
||||
Email = supplier.Email,
|
||||
Pec = supplier.Pec,
|
||||
Phone = supplier.Phone,
|
||||
Website = supplier.Website,
|
||||
PaymentTerms = supplier.PaymentTerms,
|
||||
Notes = supplier.Notes,
|
||||
IsActive = supplier.IsActive,
|
||||
CreatedAt = supplier.CreatedAt,
|
||||
UpdatedAt = supplier.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SupplierDto> CreateAsync(CreateSupplierDto dto)
|
||||
{
|
||||
// Genera codice automatico
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("supplier");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
// Fallback se disabilitato
|
||||
code = $"FOR-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString().Substring(0, 4).ToUpper()}";
|
||||
}
|
||||
|
||||
var supplier = new Supplier
|
||||
{
|
||||
Code = code,
|
||||
Name = dto.Name,
|
||||
VatNumber = dto.VatNumber,
|
||||
FiscalCode = dto.FiscalCode,
|
||||
Address = dto.Address,
|
||||
City = dto.City,
|
||||
Province = dto.Province,
|
||||
ZipCode = dto.ZipCode,
|
||||
Country = dto.Country,
|
||||
Email = dto.Email,
|
||||
Pec = dto.Pec,
|
||||
Phone = dto.Phone,
|
||||
Website = dto.Website,
|
||||
PaymentTerms = dto.PaymentTerms,
|
||||
Notes = dto.Notes,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
|
||||
_db.Suppliers.Add(supplier);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(supplier.Id) ?? throw new InvalidOperationException("Failed to retrieve created supplier");
|
||||
}
|
||||
|
||||
public async Task<SupplierDto?> UpdateAsync(int id, UpdateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _db.Suppliers.FindAsync(id);
|
||||
if (supplier == null) return null;
|
||||
|
||||
supplier.Name = dto.Name;
|
||||
supplier.VatNumber = dto.VatNumber;
|
||||
supplier.FiscalCode = dto.FiscalCode;
|
||||
supplier.Address = dto.Address;
|
||||
supplier.City = dto.City;
|
||||
supplier.Province = dto.Province;
|
||||
supplier.ZipCode = dto.ZipCode;
|
||||
supplier.Country = dto.Country;
|
||||
supplier.Email = dto.Email;
|
||||
supplier.Pec = dto.Pec;
|
||||
supplier.Phone = dto.Phone;
|
||||
supplier.Website = dto.Website;
|
||||
supplier.PaymentTerms = dto.PaymentTerms;
|
||||
supplier.Notes = dto.Notes;
|
||||
supplier.IsActive = dto.IsActive;
|
||||
supplier.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var supplier = await _db.Suppliers.FindAsync(id);
|
||||
if (supplier == null) return false;
|
||||
|
||||
// Check if used in purchase orders
|
||||
var hasOrders = await _db.PurchaseOrders.AnyAsync(o => o.SupplierId == id);
|
||||
if (hasOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Impossibile eliminare il fornitore perché ha ordini associati.");
|
||||
}
|
||||
|
||||
_db.Suppliers.Remove(supplier);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Sales.Dtos;
|
||||
using Zentral.API.Modules.Sales.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales/orders")]
|
||||
public class SalesOrdersController : ControllerBase
|
||||
{
|
||||
private readonly SalesService _service;
|
||||
|
||||
public SalesOrdersController(SalesService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SalesOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SalesOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _service.GetByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SalesOrderDto>> Create(CreateSalesOrderDto dto)
|
||||
{
|
||||
var order = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Update(int id, UpdateSalesOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.UpdateAsync(id, dto);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Confirm(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ConfirmOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/ship")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Ship(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ShipOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/backend/Zentral.API/Modules/Sales/Dtos/SalesOrderDtos.cs
Normal file
78
src/backend/Zentral.API/Modules/Sales/Dtos/SalesOrderDtos.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Dtos;
|
||||
|
||||
public class SalesOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public SalesOrderStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public decimal TotalNet { get; set; }
|
||||
public decimal TotalTax { get; set; }
|
||||
public decimal TotalGross { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public List<SalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SalesOrderLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SalesOrderId { get; set; }
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string ArticleCode { get; set; } = string.Empty;
|
||||
public string ArticleDescription { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ShippedQuantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSalesOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<CreateSalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateSalesOrderLineDto
|
||||
{
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; } // Optional override
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSalesOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<UpdateSalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateSalesOrderLineDto
|
||||
{
|
||||
public int? Id { get; set; } // Null if new line
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public bool IsDeleted { get; set; } // To mark for deletion
|
||||
}
|
||||
324
src/backend/Zentral.API/Modules/Sales/Services/SalesService.cs
Normal file
324
src/backend/Zentral.API/Modules/Sales/Services/SalesService.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Sales.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Services;
|
||||
|
||||
public class SalesService
|
||||
{
|
||||
private readonly ZentralDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public SalesService(ZentralDbContext db, AutoCodeService autoCodeService, IWarehouseService warehouseService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
public async Task<List<SalesOrderDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.SalesOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Customer)
|
||||
.OrderByDescending(o => o.OrderDate)
|
||||
.Select(o => new SalesOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
OrderNumber = o.OrderNumber,
|
||||
OrderDate = o.OrderDate,
|
||||
ExpectedDeliveryDate = o.ExpectedDeliveryDate,
|
||||
CustomerId = o.CustomerId,
|
||||
CustomerName = o.Customer!.RagioneSociale,
|
||||
Status = o.Status,
|
||||
Notes = o.Notes,
|
||||
TotalNet = o.TotalNet,
|
||||
TotalTax = o.TotalTax,
|
||||
TotalGross = o.TotalGross,
|
||||
CreatedAt = o.CreatedAt,
|
||||
UpdatedAt = o.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Lines)
|
||||
.ThenInclude(l => l.WarehouseArticle)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new SalesOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
OrderNumber = order.OrderNumber,
|
||||
OrderDate = order.OrderDate,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
CustomerId = order.CustomerId,
|
||||
CustomerName = order.Customer!.RagioneSociale,
|
||||
Status = order.Status,
|
||||
Notes = order.Notes,
|
||||
TotalNet = order.TotalNet,
|
||||
TotalTax = order.TotalTax,
|
||||
TotalGross = order.TotalGross,
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
Lines = order.Lines.Select(l => new SalesOrderLineDto
|
||||
{
|
||||
Id = l.Id,
|
||||
SalesOrderId = l.SalesOrderId,
|
||||
WarehouseArticleId = l.WarehouseArticleId,
|
||||
ArticleCode = l.WarehouseArticle!.Code,
|
||||
ArticleDescription = l.WarehouseArticle.Description,
|
||||
Description = l.Description,
|
||||
Quantity = l.Quantity,
|
||||
ShippedQuantity = l.ShippedQuantity,
|
||||
UnitPrice = l.UnitPrice,
|
||||
TaxRate = l.TaxRate,
|
||||
DiscountPercent = l.DiscountPercent,
|
||||
LineTotal = l.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> CreateAsync(CreateSalesOrderDto dto)
|
||||
{
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("sales_order");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
code = $"ODV{DateTime.Now:yyyy}-{Guid.NewGuid().ToString().Substring(0, 5).ToUpper()}";
|
||||
}
|
||||
|
||||
var order = new SalesOrder
|
||||
{
|
||||
OrderNumber = code,
|
||||
OrderDate = dto.OrderDate,
|
||||
ExpectedDeliveryDate = dto.ExpectedDeliveryDate,
|
||||
CustomerId = dto.CustomerId,
|
||||
Notes = dto.Notes,
|
||||
Status = SalesOrderStatus.Draft
|
||||
};
|
||||
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
var line = new SalesOrderLine
|
||||
{
|
||||
WarehouseArticleId = lineDto.WarehouseArticleId,
|
||||
Description = lineDto.Description ?? string.Empty,
|
||||
Quantity = lineDto.Quantity,
|
||||
UnitPrice = lineDto.UnitPrice,
|
||||
TaxRate = lineDto.TaxRate,
|
||||
DiscountPercent = lineDto.DiscountPercent
|
||||
};
|
||||
|
||||
// If description is empty, fetch from article
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
_db.SalesOrders.Add(order);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto?> UpdateAsync(int id, UpdateSalesOrderDto dto)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
if (order.Status != SalesOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza possono essere modificati");
|
||||
|
||||
order.OrderDate = dto.OrderDate;
|
||||
order.ExpectedDeliveryDate = dto.ExpectedDeliveryDate;
|
||||
order.Notes = dto.Notes;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
// Update lines
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
if (lineDto.IsDeleted)
|
||||
{
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
var lineToDelete = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value);
|
||||
if (lineToDelete != null) order.Lines.Remove(lineToDelete);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
SalesOrderLine line;
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
line = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value)
|
||||
?? throw new KeyNotFoundException($"Line {lineDto.Id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
line = new SalesOrderLine();
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
line.WarehouseArticleId = lineDto.WarehouseArticleId;
|
||||
line.Description = lineDto.Description ?? string.Empty;
|
||||
line.Quantity = lineDto.Quantity;
|
||||
line.UnitPrice = lineDto.UnitPrice;
|
||||
line.TaxRate = lineDto.TaxRate;
|
||||
line.DiscountPercent = lineDto.DiscountPercent;
|
||||
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders.FindAsync(id);
|
||||
if (order == null) return false;
|
||||
if (order.Status != SalesOrderStatus.Draft && order.Status != SalesOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza o annullati possono essere eliminati");
|
||||
|
||||
_db.SalesOrders.Remove(order);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> ConfirmOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != SalesOrderStatus.Draft) throw new InvalidOperationException("Solo gli ordini in bozza possono essere confermati");
|
||||
|
||||
order.Status = SalesOrderStatus.Confirmed;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> ShipOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != SalesOrderStatus.Confirmed && order.Status != SalesOrderStatus.PartiallyShipped)
|
||||
throw new InvalidOperationException("L'ordine deve essere confermato per essere spedito");
|
||||
|
||||
// Create Stock Movement (Outbound)
|
||||
var defaultWarehouse = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
var warehouseId = defaultWarehouse?.Id;
|
||||
|
||||
if (!warehouseId.HasValue) throw new InvalidOperationException("Nessun magazzino di default trovato per la spedizione");
|
||||
|
||||
// Genera numero documento movimento
|
||||
var docNumber = await _warehouseService.GenerateDocumentNumberAsync(MovementType.Outbound);
|
||||
|
||||
// Trova causale di default per vendita
|
||||
var reason = (await _warehouseService.GetMovementReasonsAsync(MovementType.Outbound))
|
||||
.FirstOrDefault(r => r.Code == "VEN" || r.Description.Contains("Vendita"));
|
||||
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = docNumber,
|
||||
MovementDate = DateTime.Now,
|
||||
Type = MovementType.Outbound,
|
||||
Status = MovementStatus.Draft,
|
||||
SourceWarehouseId = warehouseId,
|
||||
ReasonId = reason?.Id,
|
||||
ExternalReference = order.OrderNumber,
|
||||
Notes = $"Spedizione merce ordine {order.OrderNumber}"
|
||||
};
|
||||
|
||||
movement = await _warehouseService.CreateMovementAsync(movement);
|
||||
|
||||
// Add lines to movement
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
var remainingQty = line.Quantity - line.ShippedQuantity;
|
||||
if (remainingQty <= 0) continue;
|
||||
|
||||
// Update shipped quantity on order line
|
||||
line.ShippedQuantity += remainingQty;
|
||||
|
||||
var movementLine = new StockMovementLine
|
||||
{
|
||||
MovementId = movement.Id,
|
||||
ArticleId = line.WarehouseArticleId,
|
||||
Quantity = remainingQty,
|
||||
UnitCost = 0, // Outbound movement cost is calculated by FIFO/LIFO/Avg logic usually, but here we just set 0 or let the system handle it during confirmation
|
||||
LineValue = 0
|
||||
};
|
||||
|
||||
_db.StockMovementLines.Add(movementLine);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Confirm movement to update stock
|
||||
await _warehouseService.ConfirmMovementAsync(movement.Id);
|
||||
|
||||
// Update order status
|
||||
var allShipped = order.Lines.All(l => l.ShippedQuantity >= l.Quantity);
|
||||
order.Status = allShipped ? SalesOrderStatus.Shipped : SalesOrderStatus.PartiallyShipped;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
private void CalculateOrderTotals(SalesOrder order)
|
||||
{
|
||||
order.TotalNet = 0;
|
||||
order.TotalTax = 0;
|
||||
order.TotalGross = 0;
|
||||
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
order.TotalNet += line.LineTotal;
|
||||
var taxAmount = line.LineTotal * (line.TaxRate / 100);
|
||||
order.TotalTax += taxAmount;
|
||||
}
|
||||
|
||||
order.TotalGross = order.TotalNet + order.TotalTax;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle partite/lotti
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/batches")]
|
||||
public class BatchesController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<BatchesController> _logger;
|
||||
|
||||
public BatchesController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<BatchesController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista delle partite con filtri opzionali
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<BatchDto>>> GetBatches(
|
||||
[FromQuery] int? articleId = null,
|
||||
[FromQuery] BatchStatus? status = null)
|
||||
{
|
||||
var batches = await _warehouseService.GetBatchesAsync(articleId, status);
|
||||
return Ok(batches.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene una partita per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<BatchDto>> GetBatch(int id)
|
||||
{
|
||||
var batch = await _warehouseService.GetBatchByIdAsync(id);
|
||||
if (batch == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(batch));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene una partita per articolo e numero lotto
|
||||
/// </summary>
|
||||
[HttpGet("by-number/{articleId}/{batchNumber}")]
|
||||
public async Task<ActionResult<BatchDto>> GetBatchByNumber(int articleId, string batchNumber)
|
||||
{
|
||||
var batch = await _warehouseService.GetBatchByNumberAsync(articleId, batchNumber);
|
||||
if (batch == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(batch));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea una nuova partita
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<BatchDto>> CreateBatch([FromBody] CreateBatchDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = new ArticleBatch
|
||||
{
|
||||
ArticleId = dto.ArticleId,
|
||||
BatchNumber = dto.BatchNumber,
|
||||
ProductionDate = dto.ProductionDate,
|
||||
ExpiryDate = dto.ExpiryDate,
|
||||
SupplierBatch = dto.SupplierBatch,
|
||||
SupplierId = dto.SupplierId,
|
||||
UnitCost = dto.UnitCost,
|
||||
InitialQuantity = dto.InitialQuantity,
|
||||
CurrentQuantity = dto.InitialQuantity,
|
||||
Status = BatchStatus.Available,
|
||||
Certifications = dto.Certifications,
|
||||
Notes = dto.Notes
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateBatchAsync(batch);
|
||||
return CreatedAtAction(nameof(GetBatch), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna una partita esistente
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<BatchDto>> UpdateBatch(int id, [FromBody] UpdateBatchDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetBatchByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
if (dto.ProductionDate.HasValue)
|
||||
existing.ProductionDate = dto.ProductionDate;
|
||||
if (dto.ExpiryDate.HasValue)
|
||||
existing.ExpiryDate = dto.ExpiryDate;
|
||||
if (dto.SupplierBatch != null)
|
||||
existing.SupplierBatch = dto.SupplierBatch;
|
||||
if (dto.UnitCost.HasValue)
|
||||
existing.UnitCost = dto.UnitCost;
|
||||
if (dto.Certifications != null)
|
||||
existing.Certifications = dto.Certifications;
|
||||
if (dto.Notes != null)
|
||||
existing.Notes = dto.Notes;
|
||||
|
||||
var updated = await _warehouseService.UpdateBatchAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna lo stato di una partita
|
||||
/// </summary>
|
||||
[HttpPut("{id}/status")]
|
||||
public async Task<ActionResult> UpdateBatchStatus(int id, [FromBody] UpdateBatchStatusDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.UpdateBatchStatusAsync(id, dto.Status);
|
||||
return Ok();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le partite in scadenza
|
||||
/// </summary>
|
||||
[HttpGet("expiring")]
|
||||
public async Task<ActionResult<List<BatchDto>>> GetExpiringBatches([FromQuery] int daysThreshold = 30)
|
||||
{
|
||||
var batches = await _warehouseService.GetExpiringBatchesAsync(daysThreshold);
|
||||
return Ok(batches.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra un controllo qualità sulla partita
|
||||
/// </summary>
|
||||
[HttpPost("{id}/quality-check")]
|
||||
public async Task<ActionResult<BatchDto>> RecordQualityCheck(int id, [FromBody] QualityCheckDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = await _warehouseService.GetBatchByIdAsync(id);
|
||||
if (batch == null)
|
||||
return NotFound();
|
||||
|
||||
batch.QualityStatus = dto.QualityStatus;
|
||||
batch.LastQualityCheckDate = DateTime.UtcNow;
|
||||
|
||||
// Aggiorna lo stato del lotto in base al risultato
|
||||
if (dto.QualityStatus == QualityStatus.Rejected)
|
||||
{
|
||||
batch.Status = BatchStatus.Blocked;
|
||||
}
|
||||
else if (dto.QualityStatus == QualityStatus.Approved && batch.Status == BatchStatus.Quarantine)
|
||||
{
|
||||
batch.Status = BatchStatus.Available;
|
||||
}
|
||||
|
||||
var updated = await _warehouseService.UpdateBatchAsync(batch);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record BatchDto(
|
||||
int Id,
|
||||
int ArticleId,
|
||||
string? ArticleCode,
|
||||
string? ArticleDescription,
|
||||
string BatchNumber,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? ExpiryDate,
|
||||
string? SupplierBatch,
|
||||
int? SupplierId,
|
||||
decimal? UnitCost,
|
||||
decimal InitialQuantity,
|
||||
decimal CurrentQuantity,
|
||||
decimal ReservedQuantity,
|
||||
decimal AvailableQuantity,
|
||||
BatchStatus Status,
|
||||
QualityStatus? QualityStatus,
|
||||
DateTime? LastQualityCheckDate,
|
||||
string? Certifications,
|
||||
string? Notes,
|
||||
bool IsExpired,
|
||||
int? DaysToExpiry,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateBatchDto(
|
||||
int ArticleId,
|
||||
string BatchNumber,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? ExpiryDate,
|
||||
string? SupplierBatch,
|
||||
int? SupplierId,
|
||||
decimal? UnitCost,
|
||||
decimal InitialQuantity,
|
||||
string? Certifications,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateBatchDto(
|
||||
DateTime? ProductionDate,
|
||||
DateTime? ExpiryDate,
|
||||
string? SupplierBatch,
|
||||
decimal? UnitCost,
|
||||
string? Certifications,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateBatchStatusDto(BatchStatus Status);
|
||||
|
||||
public record QualityCheckDto(QualityStatus QualityStatus, string? Notes);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static BatchDto MapToDto(ArticleBatch batch)
|
||||
{
|
||||
var isExpired = batch.ExpiryDate.HasValue && batch.ExpiryDate.Value < DateTime.UtcNow;
|
||||
var daysToExpiry = batch.ExpiryDate.HasValue
|
||||
? (int?)Math.Max(0, (batch.ExpiryDate.Value - DateTime.UtcNow).Days)
|
||||
: null;
|
||||
|
||||
return new BatchDto(
|
||||
batch.Id,
|
||||
batch.ArticleId,
|
||||
batch.Article?.Code,
|
||||
batch.Article?.Description,
|
||||
batch.BatchNumber,
|
||||
batch.ProductionDate,
|
||||
batch.ExpiryDate,
|
||||
batch.SupplierBatch,
|
||||
batch.SupplierId,
|
||||
batch.UnitCost,
|
||||
batch.InitialQuantity,
|
||||
batch.CurrentQuantity,
|
||||
batch.ReservedQuantity,
|
||||
batch.CurrentQuantity - batch.ReservedQuantity,
|
||||
batch.Status,
|
||||
batch.QualityStatus,
|
||||
batch.LastQualityCheckDate,
|
||||
batch.Certifications,
|
||||
batch.Notes,
|
||||
isExpired,
|
||||
daysToExpiry,
|
||||
batch.CreatedAt,
|
||||
batch.UpdatedAt
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione degli inventari fisici
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/inventory")]
|
||||
public class InventoryController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<InventoryController> _logger;
|
||||
|
||||
public InventoryController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<InventoryController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista degli inventari
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<InventoryCountDto>>> GetInventoryCounts([FromQuery] InventoryStatus? status = null)
|
||||
{
|
||||
var inventories = await _warehouseService.GetInventoryCountsAsync(status);
|
||||
return Ok(inventories.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un inventario per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<InventoryCountDetailDto>> GetInventoryCount(int id)
|
||||
{
|
||||
var inventory = await _warehouseService.GetInventoryCountByIdAsync(id);
|
||||
if (inventory == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDetailDto(inventory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo inventario
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<InventoryCountDto>> CreateInventoryCount([FromBody] CreateInventoryCountDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inventory = new InventoryCount
|
||||
{
|
||||
Code = dto.Code ?? "",
|
||||
Description = dto.Description,
|
||||
InventoryDate = dto.InventoryDate ?? DateTime.UtcNow.Date,
|
||||
WarehouseId = dto.WarehouseId,
|
||||
CategoryId = dto.CategoryId,
|
||||
Type = dto.Type,
|
||||
Notes = dto.Notes,
|
||||
Status = InventoryStatus.Draft
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateInventoryCountAsync(inventory);
|
||||
return CreatedAtAction(nameof(GetInventoryCount), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna un inventario esistente (solo bozze)
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<InventoryCountDto>> UpdateInventoryCount(int id, [FromBody] UpdateInventoryCountDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetInventoryCountByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
if (dto.Description != null)
|
||||
existing.Description = dto.Description;
|
||||
if (dto.InventoryDate.HasValue)
|
||||
existing.InventoryDate = dto.InventoryDate.Value;
|
||||
if (dto.WarehouseId.HasValue)
|
||||
existing.WarehouseId = dto.WarehouseId;
|
||||
if (dto.CategoryId.HasValue)
|
||||
existing.CategoryId = dto.CategoryId;
|
||||
if (dto.Type.HasValue)
|
||||
existing.Type = dto.Type.Value;
|
||||
if (dto.Notes != null)
|
||||
existing.Notes = dto.Notes;
|
||||
|
||||
var updated = await _warehouseService.UpdateInventoryCountAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avvia un inventario (genera righe da contare)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/start")]
|
||||
public async Task<ActionResult<InventoryCountDetailDto>> StartInventoryCount(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inventory = await _warehouseService.StartInventoryCountAsync(id);
|
||||
return Ok(MapToDetailDto(inventory));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completa un inventario (tutti i conteggi effettuati)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/complete")]
|
||||
public async Task<ActionResult<InventoryCountDetailDto>> CompleteInventoryCount(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inventory = await _warehouseService.CompleteInventoryCountAsync(id);
|
||||
return Ok(MapToDetailDto(inventory));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conferma un inventario (applica rettifiche)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<InventoryCountDetailDto>> ConfirmInventoryCount(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inventory = await _warehouseService.ConfirmInventoryCountAsync(id);
|
||||
return Ok(MapToDetailDto(inventory));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annulla un inventario
|
||||
/// </summary>
|
||||
[HttpPost("{id}/cancel")]
|
||||
public async Task<ActionResult<InventoryCountDto>> CancelInventoryCount(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inventory = await _warehouseService.CancelInventoryCountAsync(id);
|
||||
return Ok(MapToDto(inventory));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra il conteggio di una riga
|
||||
/// </summary>
|
||||
[HttpPut("lines/{lineId}/count")]
|
||||
public async Task<ActionResult<InventoryCountLineDto>> UpdateCountLine(int lineId, [FromBody] UpdateCountLineDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var line = await _warehouseService.UpdateCountLineAsync(lineId, dto.CountedQuantity, dto.CountedBy);
|
||||
return Ok(MapLineToDto(line));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra conteggi multipli in batch
|
||||
/// </summary>
|
||||
[HttpPut("{id}/count-batch")]
|
||||
public async Task<ActionResult> UpdateCountLinesBatch(int id, [FromBody] UpdateCountLinesBatchDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = new List<InventoryCountLineDto>();
|
||||
foreach (var lineUpdate in dto.Lines)
|
||||
{
|
||||
var line = await _warehouseService.UpdateCountLineAsync(
|
||||
lineUpdate.LineId,
|
||||
lineUpdate.CountedQuantity,
|
||||
dto.CountedBy);
|
||||
results.Add(MapLineToDto(line));
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record InventoryCountDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Description,
|
||||
DateTime InventoryDate,
|
||||
int? WarehouseId,
|
||||
string? WarehouseCode,
|
||||
string? WarehouseName,
|
||||
int? CategoryId,
|
||||
string? CategoryName,
|
||||
InventoryType Type,
|
||||
InventoryStatus Status,
|
||||
DateTime? StartDate,
|
||||
DateTime? EndDate,
|
||||
DateTime? ConfirmedDate,
|
||||
int? AdjustmentMovementId,
|
||||
decimal? PositiveDifferenceValue,
|
||||
decimal? NegativeDifferenceValue,
|
||||
int LineCount,
|
||||
int CountedLineCount,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt
|
||||
);
|
||||
|
||||
public record InventoryCountDetailDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Description,
|
||||
DateTime InventoryDate,
|
||||
int? WarehouseId,
|
||||
string? WarehouseCode,
|
||||
string? WarehouseName,
|
||||
int? CategoryId,
|
||||
string? CategoryName,
|
||||
InventoryType Type,
|
||||
InventoryStatus Status,
|
||||
DateTime? StartDate,
|
||||
DateTime? EndDate,
|
||||
DateTime? ConfirmedDate,
|
||||
string? ConfirmedBy,
|
||||
int? AdjustmentMovementId,
|
||||
decimal? PositiveDifferenceValue,
|
||||
decimal? NegativeDifferenceValue,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
List<InventoryCountLineDto> Lines
|
||||
);
|
||||
|
||||
public record InventoryCountLineDto(
|
||||
int Id,
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
int WarehouseId,
|
||||
string WarehouseCode,
|
||||
int? BatchId,
|
||||
string? BatchNumber,
|
||||
string? LocationCode,
|
||||
decimal TheoreticalQuantity,
|
||||
decimal? CountedQuantity,
|
||||
decimal? Difference,
|
||||
decimal? UnitCost,
|
||||
decimal? DifferenceValue,
|
||||
DateTime? CountedAt,
|
||||
string? CountedBy,
|
||||
decimal? SecondCountQuantity,
|
||||
string? SecondCountBy,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record CreateInventoryCountDto(
|
||||
string? Code,
|
||||
string Description,
|
||||
DateTime? InventoryDate,
|
||||
int? WarehouseId,
|
||||
int? CategoryId,
|
||||
InventoryType Type,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateInventoryCountDto(
|
||||
string? Description,
|
||||
DateTime? InventoryDate,
|
||||
int? WarehouseId,
|
||||
int? CategoryId,
|
||||
InventoryType? Type,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateCountLineDto(
|
||||
decimal CountedQuantity,
|
||||
string? CountedBy
|
||||
);
|
||||
|
||||
public record UpdateCountLinesBatchDto(
|
||||
string? CountedBy,
|
||||
List<CountLineUpdate> Lines
|
||||
);
|
||||
|
||||
public record CountLineUpdate(
|
||||
int LineId,
|
||||
decimal CountedQuantity
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static InventoryCountDto MapToDto(InventoryCount inventory) => new(
|
||||
inventory.Id,
|
||||
inventory.Code,
|
||||
inventory.Description,
|
||||
inventory.InventoryDate,
|
||||
inventory.WarehouseId,
|
||||
inventory.Warehouse?.Code,
|
||||
inventory.Warehouse?.Name,
|
||||
inventory.CategoryId,
|
||||
inventory.Category?.Name,
|
||||
inventory.Type,
|
||||
inventory.Status,
|
||||
inventory.StartDate,
|
||||
inventory.EndDate,
|
||||
inventory.ConfirmedDate,
|
||||
inventory.AdjustmentMovementId,
|
||||
inventory.PositiveDifferenceValue,
|
||||
inventory.NegativeDifferenceValue,
|
||||
inventory.Lines.Count,
|
||||
inventory.Lines.Count(l => l.CountedQuantity.HasValue),
|
||||
inventory.Notes,
|
||||
inventory.CreatedAt
|
||||
);
|
||||
|
||||
private static InventoryCountDetailDto MapToDetailDto(InventoryCount inventory) => new(
|
||||
inventory.Id,
|
||||
inventory.Code,
|
||||
inventory.Description,
|
||||
inventory.InventoryDate,
|
||||
inventory.WarehouseId,
|
||||
inventory.Warehouse?.Code,
|
||||
inventory.Warehouse?.Name,
|
||||
inventory.CategoryId,
|
||||
inventory.Category?.Name,
|
||||
inventory.Type,
|
||||
inventory.Status,
|
||||
inventory.StartDate,
|
||||
inventory.EndDate,
|
||||
inventory.ConfirmedDate,
|
||||
inventory.ConfirmedBy,
|
||||
inventory.AdjustmentMovementId,
|
||||
inventory.PositiveDifferenceValue,
|
||||
inventory.NegativeDifferenceValue,
|
||||
inventory.Notes,
|
||||
inventory.CreatedAt,
|
||||
inventory.UpdatedAt,
|
||||
inventory.Lines.Select(MapLineToDto).ToList()
|
||||
);
|
||||
|
||||
private static InventoryCountLineDto MapLineToDto(InventoryCountLine line) => new(
|
||||
line.Id,
|
||||
line.ArticleId,
|
||||
line.Article?.Code ?? "",
|
||||
line.Article?.Description ?? "",
|
||||
line.WarehouseId,
|
||||
line.Warehouse?.Code ?? "",
|
||||
line.BatchId,
|
||||
line.Batch?.BatchNumber,
|
||||
line.LocationCode,
|
||||
line.TheoreticalQuantity,
|
||||
line.CountedQuantity,
|
||||
line.Difference,
|
||||
line.UnitCost,
|
||||
line.DifferenceValue,
|
||||
line.CountedAt,
|
||||
line.CountedBy,
|
||||
line.SecondCountQuantity,
|
||||
line.SecondCountBy,
|
||||
line.Notes
|
||||
);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei seriali/matricole
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/serials")]
|
||||
public class SerialsController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<SerialsController> _logger;
|
||||
|
||||
public SerialsController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<SerialsController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista dei seriali con filtri opzionali
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SerialDto>>> GetSerials(
|
||||
[FromQuery] int? articleId = null,
|
||||
[FromQuery] SerialStatus? status = null)
|
||||
{
|
||||
var serials = await _warehouseService.GetSerialsAsync(articleId, status);
|
||||
return Ok(serials.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un seriale per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SerialDto>> GetSerial(int id)
|
||||
{
|
||||
var serial = await _warehouseService.GetSerialByIdAsync(id);
|
||||
if (serial == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(serial));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un seriale per articolo e numero seriale
|
||||
/// </summary>
|
||||
[HttpGet("by-number/{articleId}/{serialNumber}")]
|
||||
public async Task<ActionResult<SerialDto>> GetSerialByNumber(int articleId, string serialNumber)
|
||||
{
|
||||
var serial = await _warehouseService.GetSerialByNumberAsync(articleId, serialNumber);
|
||||
if (serial == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(serial));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo seriale
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SerialDto>> CreateSerial([FromBody] CreateSerialDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var serial = new ArticleSerial
|
||||
{
|
||||
ArticleId = dto.ArticleId,
|
||||
BatchId = dto.BatchId,
|
||||
SerialNumber = dto.SerialNumber,
|
||||
ManufacturerSerial = dto.ManufacturerSerial,
|
||||
ProductionDate = dto.ProductionDate,
|
||||
WarrantyExpiryDate = dto.WarrantyExpiryDate,
|
||||
CurrentWarehouseId = dto.WarehouseId,
|
||||
UnitCost = dto.UnitCost,
|
||||
SupplierId = dto.SupplierId,
|
||||
Attributes = dto.Attributes,
|
||||
Notes = dto.Notes,
|
||||
Status = SerialStatus.Available
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateSerialAsync(serial);
|
||||
return CreatedAtAction(nameof(GetSerial), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea più seriali in batch
|
||||
/// </summary>
|
||||
[HttpPost("bulk")]
|
||||
public async Task<ActionResult<List<SerialDto>>> CreateSerialsBulk([FromBody] CreateSerialsBulkDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var createdSerials = new List<ArticleSerial>();
|
||||
|
||||
foreach (var serialNumber in dto.SerialNumbers)
|
||||
{
|
||||
var serial = new ArticleSerial
|
||||
{
|
||||
ArticleId = dto.ArticleId,
|
||||
BatchId = dto.BatchId,
|
||||
SerialNumber = serialNumber,
|
||||
ProductionDate = dto.ProductionDate,
|
||||
WarrantyExpiryDate = dto.WarrantyExpiryDate,
|
||||
CurrentWarehouseId = dto.WarehouseId,
|
||||
UnitCost = dto.UnitCost,
|
||||
SupplierId = dto.SupplierId,
|
||||
Status = SerialStatus.Available
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateSerialAsync(serial);
|
||||
createdSerials.Add(created);
|
||||
}
|
||||
|
||||
return Ok(createdSerials.Select(MapToDto));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna un seriale esistente
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SerialDto>> UpdateSerial(int id, [FromBody] UpdateSerialDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetSerialByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
if (dto.ManufacturerSerial != null)
|
||||
existing.ManufacturerSerial = dto.ManufacturerSerial;
|
||||
if (dto.ProductionDate.HasValue)
|
||||
existing.ProductionDate = dto.ProductionDate;
|
||||
if (dto.WarrantyExpiryDate.HasValue)
|
||||
existing.WarrantyExpiryDate = dto.WarrantyExpiryDate;
|
||||
if (dto.UnitCost.HasValue)
|
||||
existing.UnitCost = dto.UnitCost;
|
||||
if (dto.Attributes != null)
|
||||
existing.Attributes = dto.Attributes;
|
||||
if (dto.Notes != null)
|
||||
existing.Notes = dto.Notes;
|
||||
|
||||
var updated = await _warehouseService.UpdateSerialAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna lo stato di un seriale
|
||||
/// </summary>
|
||||
[HttpPut("{id}/status")]
|
||||
public async Task<ActionResult> UpdateSerialStatus(int id, [FromBody] UpdateSerialStatusDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.UpdateSerialStatusAsync(id, dto.Status);
|
||||
return Ok();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra la vendita di un seriale
|
||||
/// </summary>
|
||||
[HttpPost("{id}/sell")]
|
||||
public async Task<ActionResult<SerialDto>> RegisterSale(int id, [FromBody] RegisterSaleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var serial = await _warehouseService.GetSerialByIdAsync(id);
|
||||
if (serial == null)
|
||||
return NotFound();
|
||||
|
||||
if (serial.Status != SerialStatus.Available && serial.Status != SerialStatus.Reserved)
|
||||
return BadRequest(new { error = "Il seriale non è disponibile per la vendita" });
|
||||
|
||||
serial.Status = SerialStatus.Sold;
|
||||
serial.CustomerId = dto.CustomerId;
|
||||
serial.SoldDate = DateTime.UtcNow;
|
||||
serial.SalesReference = dto.SalesReference;
|
||||
serial.CurrentWarehouseId = null;
|
||||
|
||||
var updated = await _warehouseService.UpdateSerialAsync(serial);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra un reso di un seriale
|
||||
/// </summary>
|
||||
[HttpPost("{id}/return")]
|
||||
public async Task<ActionResult<SerialDto>> RegisterReturn(int id, [FromBody] RegisterReturnDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var serial = await _warehouseService.GetSerialByIdAsync(id);
|
||||
if (serial == null)
|
||||
return NotFound();
|
||||
|
||||
if (serial.Status != SerialStatus.Sold)
|
||||
return BadRequest(new { error = "Solo i seriali venduti possono essere resi" });
|
||||
|
||||
serial.Status = dto.IsDefective ? SerialStatus.Defective : SerialStatus.Returned;
|
||||
serial.CurrentWarehouseId = dto.WarehouseId;
|
||||
|
||||
var updated = await _warehouseService.UpdateSerialAsync(serial);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record SerialDto(
|
||||
int Id,
|
||||
int ArticleId,
|
||||
string? ArticleCode,
|
||||
string? ArticleDescription,
|
||||
int? BatchId,
|
||||
string? BatchNumber,
|
||||
string SerialNumber,
|
||||
string? ManufacturerSerial,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? WarrantyExpiryDate,
|
||||
int? CurrentWarehouseId,
|
||||
string? CurrentWarehouseCode,
|
||||
string? CurrentWarehouseName,
|
||||
SerialStatus Status,
|
||||
decimal? UnitCost,
|
||||
int? SupplierId,
|
||||
int? CustomerId,
|
||||
DateTime? SoldDate,
|
||||
string? SalesReference,
|
||||
string? Attributes,
|
||||
string? Notes,
|
||||
bool IsWarrantyValid,
|
||||
int? DaysToWarrantyExpiry,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateSerialDto(
|
||||
int ArticleId,
|
||||
int? BatchId,
|
||||
string SerialNumber,
|
||||
string? ManufacturerSerial,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? WarrantyExpiryDate,
|
||||
int? WarehouseId,
|
||||
decimal? UnitCost,
|
||||
int? SupplierId,
|
||||
string? Attributes,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record CreateSerialsBulkDto(
|
||||
int ArticleId,
|
||||
int? BatchId,
|
||||
List<string> SerialNumbers,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? WarrantyExpiryDate,
|
||||
int? WarehouseId,
|
||||
decimal? UnitCost,
|
||||
int? SupplierId
|
||||
);
|
||||
|
||||
public record UpdateSerialDto(
|
||||
string? ManufacturerSerial,
|
||||
DateTime? ProductionDate,
|
||||
DateTime? WarrantyExpiryDate,
|
||||
decimal? UnitCost,
|
||||
string? Attributes,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateSerialStatusDto(SerialStatus Status);
|
||||
|
||||
public record RegisterSaleDto(
|
||||
int? CustomerId,
|
||||
string? SalesReference
|
||||
);
|
||||
|
||||
public record RegisterReturnDto(
|
||||
int WarehouseId,
|
||||
bool IsDefective
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static SerialDto MapToDto(ArticleSerial serial)
|
||||
{
|
||||
var isWarrantyValid = serial.WarrantyExpiryDate.HasValue && serial.WarrantyExpiryDate.Value > DateTime.UtcNow;
|
||||
var daysToWarrantyExpiry = serial.WarrantyExpiryDate.HasValue
|
||||
? (int?)Math.Max(0, (serial.WarrantyExpiryDate.Value - DateTime.UtcNow).Days)
|
||||
: null;
|
||||
|
||||
return new SerialDto(
|
||||
serial.Id,
|
||||
serial.ArticleId,
|
||||
serial.Article?.Code,
|
||||
serial.Article?.Description,
|
||||
serial.BatchId,
|
||||
serial.Batch?.BatchNumber,
|
||||
serial.SerialNumber,
|
||||
serial.ManufacturerSerial,
|
||||
serial.ProductionDate,
|
||||
serial.WarrantyExpiryDate,
|
||||
serial.CurrentWarehouseId,
|
||||
serial.CurrentWarehouse?.Code,
|
||||
serial.CurrentWarehouse?.Name,
|
||||
serial.Status,
|
||||
serial.UnitCost,
|
||||
serial.SupplierId,
|
||||
serial.CustomerId,
|
||||
serial.SoldDate,
|
||||
serial.SalesReference,
|
||||
serial.Attributes,
|
||||
serial.Notes,
|
||||
isWarrantyValid,
|
||||
daysToWarrantyExpiry,
|
||||
serial.CreatedAt,
|
||||
serial.UpdatedAt
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle giacenze e valorizzazione
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/stock")]
|
||||
public class StockLevelsController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<StockLevelsController> _logger;
|
||||
|
||||
public StockLevelsController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<StockLevelsController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le giacenze con filtri opzionali
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<StockLevelDto>>> GetStockLevels([FromQuery] StockLevelFilterDto? filter)
|
||||
{
|
||||
var stockFilter = filter != null ? new StockLevelFilter
|
||||
{
|
||||
ArticleId = filter.ArticleId,
|
||||
WarehouseId = filter.WarehouseId,
|
||||
BatchId = filter.BatchId,
|
||||
CategoryId = filter.CategoryId,
|
||||
OnlyWithStock = filter.OnlyWithStock,
|
||||
OnlyLowStock = filter.OnlyLowStock,
|
||||
Skip = filter.Skip,
|
||||
Take = filter.Take
|
||||
} : null;
|
||||
|
||||
var stockLevels = await _warehouseService.GetStockLevelsAsync(stockFilter);
|
||||
return Ok(stockLevels.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la giacenza per articolo/magazzino/batch
|
||||
/// </summary>
|
||||
[HttpGet("{articleId}/{warehouseId}")]
|
||||
public async Task<ActionResult<StockLevelDto>> GetStockLevel(int articleId, int warehouseId, [FromQuery] int? batchId = null)
|
||||
{
|
||||
var stockLevel = await _warehouseService.GetStockLevelAsync(articleId, warehouseId, batchId);
|
||||
if (stockLevel == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(stockLevel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene gli articoli sotto scorta
|
||||
/// </summary>
|
||||
[HttpGet("low-stock")]
|
||||
public async Task<ActionResult<List<StockLevelDto>>> GetLowStockArticles()
|
||||
{
|
||||
var lowStock = await _warehouseService.GetLowStockArticlesAsync();
|
||||
return Ok(lowStock.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il riepilogo giacenze per articolo
|
||||
/// </summary>
|
||||
[HttpGet("summary/{articleId}")]
|
||||
public async Task<ActionResult<StockSummaryDto>> GetStockSummary(int articleId)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(articleId);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
var totalStock = await _warehouseService.GetTotalStockAsync(articleId);
|
||||
var availableStock = await _warehouseService.GetAvailableStockAsync(articleId);
|
||||
var stockLevels = await _warehouseService.GetStockLevelsAsync(new StockLevelFilter { ArticleId = articleId });
|
||||
|
||||
return Ok(new StockSummaryDto(
|
||||
articleId,
|
||||
article.Code,
|
||||
article.Description,
|
||||
article.UnitOfMeasure,
|
||||
totalStock,
|
||||
availableStock,
|
||||
article.MinimumStock,
|
||||
article.MaximumStock,
|
||||
article.ReorderPoint,
|
||||
article.MinimumStock.HasValue && totalStock <= article.MinimumStock.Value,
|
||||
stockLevels.Sum(s => s.StockValue ?? 0),
|
||||
stockLevels.Count,
|
||||
stockLevels.Select(MapToDto).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola la valorizzazione di un articolo
|
||||
/// </summary>
|
||||
[HttpGet("valuation/{articleId}")]
|
||||
public async Task<ActionResult<ArticleValuationDto>> GetArticleValuation(int articleId, [FromQuery] ValuationMethod? method = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(articleId);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
var effectiveMethod = method ?? article.ValuationMethod ?? ValuationMethod.WeightedAverage;
|
||||
var totalValue = await _warehouseService.CalculateArticleValueAsync(articleId, effectiveMethod);
|
||||
var totalStock = await _warehouseService.GetTotalStockAsync(articleId);
|
||||
var avgCost = await _warehouseService.GetWeightedAverageCostAsync(articleId);
|
||||
|
||||
return Ok(new ArticleValuationDto(
|
||||
articleId,
|
||||
article.Code,
|
||||
article.Description,
|
||||
effectiveMethod,
|
||||
totalStock,
|
||||
article.UnitOfMeasure,
|
||||
avgCost,
|
||||
article.StandardCost,
|
||||
article.LastPurchaseCost,
|
||||
totalValue
|
||||
));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola la valorizzazione di periodo
|
||||
/// </summary>
|
||||
[HttpGet("valuation/period/{period}")]
|
||||
public async Task<ActionResult<List<PeriodValuationDto>>> GetPeriodValuation(int period, [FromQuery] int? warehouseId = null)
|
||||
{
|
||||
var valuations = await _warehouseService.GetValuationsAsync(period, warehouseId);
|
||||
return Ok(valuations.Select(v => new PeriodValuationDto(
|
||||
v.Id,
|
||||
v.Period,
|
||||
v.ValuationDate,
|
||||
v.ArticleId,
|
||||
v.Article?.Code ?? "",
|
||||
v.Article?.Description ?? "",
|
||||
v.WarehouseId,
|
||||
v.Warehouse?.Code,
|
||||
v.Warehouse?.Name,
|
||||
v.Quantity,
|
||||
v.Method,
|
||||
v.UnitCost,
|
||||
v.TotalValue,
|
||||
v.InboundQuantity,
|
||||
v.InboundValue,
|
||||
v.OutboundQuantity,
|
||||
v.OutboundValue,
|
||||
v.IsClosed
|
||||
)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera la valorizzazione per un articolo e periodo
|
||||
/// </summary>
|
||||
[HttpPost("valuation/calculate")]
|
||||
public async Task<ActionResult<PeriodValuationDto>> CalculatePeriodValuation([FromBody] CalculateValuationDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var valuation = await _warehouseService.CalculatePeriodValuationAsync(dto.ArticleId, dto.Period, dto.WarehouseId);
|
||||
return Ok(new PeriodValuationDto(
|
||||
valuation.Id,
|
||||
valuation.Period,
|
||||
valuation.ValuationDate,
|
||||
valuation.ArticleId,
|
||||
valuation.Article?.Code ?? "",
|
||||
valuation.Article?.Description ?? "",
|
||||
valuation.WarehouseId,
|
||||
valuation.Warehouse?.Code,
|
||||
valuation.Warehouse?.Name,
|
||||
valuation.Quantity,
|
||||
valuation.Method,
|
||||
valuation.UnitCost,
|
||||
valuation.TotalValue,
|
||||
valuation.InboundQuantity,
|
||||
valuation.InboundValue,
|
||||
valuation.OutboundQuantity,
|
||||
valuation.OutboundValue,
|
||||
valuation.IsClosed
|
||||
));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chiude un periodo (blocca modifiche)
|
||||
/// </summary>
|
||||
[HttpPost("valuation/close-period/{period}")]
|
||||
public async Task<ActionResult> ClosePeriod(int period)
|
||||
{
|
||||
await _warehouseService.ClosePeriodAsync(period);
|
||||
return Ok(new { message = $"Periodo {period} chiuso correttamente" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ricalcola il costo medio ponderato di un articolo
|
||||
/// </summary>
|
||||
[HttpPost("recalculate-average/{articleId}")]
|
||||
public async Task<ActionResult> RecalculateAverageCost(int articleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.UpdateWeightedAverageCostAsync(articleId);
|
||||
var avgCost = await _warehouseService.GetWeightedAverageCostAsync(articleId);
|
||||
return Ok(new { articleId, weightedAverageCost = avgCost });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record StockLevelFilterDto(
|
||||
int? ArticleId,
|
||||
int? WarehouseId,
|
||||
int? BatchId,
|
||||
int? CategoryId,
|
||||
bool? OnlyWithStock,
|
||||
bool? OnlyLowStock,
|
||||
int Skip = 0,
|
||||
int Take = 100
|
||||
);
|
||||
|
||||
public record StockLevelDto(
|
||||
int Id,
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
string? CategoryName,
|
||||
int WarehouseId,
|
||||
string WarehouseCode,
|
||||
string WarehouseName,
|
||||
int? BatchId,
|
||||
string? BatchNumber,
|
||||
DateTime? BatchExpiryDate,
|
||||
decimal Quantity,
|
||||
decimal ReservedQuantity,
|
||||
decimal AvailableQuantity,
|
||||
decimal OnOrderQuantity,
|
||||
decimal? UnitCost,
|
||||
decimal? StockValue,
|
||||
string? LocationCode,
|
||||
DateTime? LastMovementDate,
|
||||
DateTime? LastInventoryDate,
|
||||
decimal? MinimumStock,
|
||||
bool IsLowStock
|
||||
);
|
||||
|
||||
public record StockSummaryDto(
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
string UnitOfMeasure,
|
||||
decimal TotalStock,
|
||||
decimal AvailableStock,
|
||||
decimal? MinimumStock,
|
||||
decimal? MaximumStock,
|
||||
decimal? ReorderPoint,
|
||||
bool IsLowStock,
|
||||
decimal TotalValue,
|
||||
int WarehouseCount,
|
||||
List<StockLevelDto> StockByWarehouse
|
||||
);
|
||||
|
||||
public record ArticleValuationDto(
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
ValuationMethod Method,
|
||||
decimal TotalQuantity,
|
||||
string UnitOfMeasure,
|
||||
decimal WeightedAverageCost,
|
||||
decimal? StandardCost,
|
||||
decimal? LastPurchaseCost,
|
||||
decimal TotalValue
|
||||
);
|
||||
|
||||
public record PeriodValuationDto(
|
||||
int Id,
|
||||
int Period,
|
||||
DateTime ValuationDate,
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
int? WarehouseId,
|
||||
string? WarehouseCode,
|
||||
string? WarehouseName,
|
||||
decimal Quantity,
|
||||
ValuationMethod Method,
|
||||
decimal UnitCost,
|
||||
decimal TotalValue,
|
||||
decimal InboundQuantity,
|
||||
decimal InboundValue,
|
||||
decimal OutboundQuantity,
|
||||
decimal OutboundValue,
|
||||
bool IsClosed
|
||||
);
|
||||
|
||||
public record CalculateValuationDto(
|
||||
int ArticleId,
|
||||
int Period,
|
||||
int? WarehouseId
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static StockLevelDto MapToDto(StockLevel stock)
|
||||
{
|
||||
var isLowStock = stock.Article?.MinimumStock.HasValue == true &&
|
||||
stock.Quantity <= stock.Article.MinimumStock.Value;
|
||||
|
||||
return new StockLevelDto(
|
||||
stock.Id,
|
||||
stock.ArticleId,
|
||||
stock.Article?.Code ?? "",
|
||||
stock.Article?.Description ?? "",
|
||||
stock.Article?.Category?.Name,
|
||||
stock.WarehouseId,
|
||||
stock.Warehouse?.Code ?? "",
|
||||
stock.Warehouse?.Name ?? "",
|
||||
stock.BatchId,
|
||||
stock.Batch?.BatchNumber,
|
||||
stock.Batch?.ExpiryDate,
|
||||
stock.Quantity,
|
||||
stock.ReservedQuantity,
|
||||
stock.AvailableQuantity,
|
||||
stock.OnOrderQuantity,
|
||||
stock.UnitCost,
|
||||
stock.StockValue,
|
||||
stock.LocationCode,
|
||||
stock.LastMovementDate,
|
||||
stock.LastInventoryDate,
|
||||
stock.Article?.MinimumStock,
|
||||
isLowStock
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei movimenti di magazzino
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/movements")]
|
||||
public class StockMovementsController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<StockMovementsController> _logger;
|
||||
|
||||
public StockMovementsController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<StockMovementsController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista dei movimenti con filtri opzionali
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<MovementDto>>> GetMovements([FromQuery] MovementFilterDto? filter)
|
||||
{
|
||||
var movementFilter = filter != null ? new MovementFilter
|
||||
{
|
||||
DateFrom = filter.DateFrom,
|
||||
DateTo = filter.DateTo,
|
||||
Type = filter.Type,
|
||||
Status = filter.Status,
|
||||
WarehouseId = filter.WarehouseId,
|
||||
ArticleId = filter.ArticleId,
|
||||
ReasonId = filter.ReasonId,
|
||||
ExternalReference = filter.ExternalReference,
|
||||
Skip = filter.Skip,
|
||||
Take = filter.Take,
|
||||
OrderBy = filter.OrderBy,
|
||||
OrderDescending = filter.OrderDescending
|
||||
} : null;
|
||||
|
||||
var movements = await _warehouseService.GetMovementsAsync(movementFilter);
|
||||
return Ok(movements.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un movimento per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<MovementDetailDto>> GetMovement(int id)
|
||||
{
|
||||
var movement = await _warehouseService.GetMovementByIdAsync(id);
|
||||
if (movement == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDetailDto(movement));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un movimento per numero documento
|
||||
/// </summary>
|
||||
[HttpGet("by-document/{documentNumber}")]
|
||||
public async Task<ActionResult<MovementDetailDto>> GetMovementByDocumentNumber(string documentNumber)
|
||||
{
|
||||
var movement = await _warehouseService.GetMovementByDocumentNumberAsync(documentNumber);
|
||||
if (movement == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDetailDto(movement));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo movimento (carico)
|
||||
/// </summary>
|
||||
[HttpPost("inbound")]
|
||||
public async Task<ActionResult<MovementDetailDto>> CreateInboundMovement([FromBody] CreateMovementDto dto)
|
||||
{
|
||||
return await CreateMovement(dto, MovementType.Inbound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo movimento (scarico)
|
||||
/// </summary>
|
||||
[HttpPost("outbound")]
|
||||
public async Task<ActionResult<MovementDetailDto>> CreateOutboundMovement([FromBody] CreateMovementDto dto)
|
||||
{
|
||||
return await CreateMovement(dto, MovementType.Outbound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo movimento (trasferimento)
|
||||
/// </summary>
|
||||
[HttpPost("transfer")]
|
||||
public async Task<ActionResult<MovementDetailDto>> CreateTransferMovement([FromBody] CreateTransferDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = dto.DocumentNumber ?? "",
|
||||
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
|
||||
Type = MovementType.Transfer,
|
||||
ReasonId = dto.ReasonId,
|
||||
SourceWarehouseId = dto.SourceWarehouseId,
|
||||
DestinationWarehouseId = dto.DestinationWarehouseId,
|
||||
ExternalReference = dto.ExternalReference,
|
||||
Notes = dto.Notes,
|
||||
Status = MovementStatus.Draft,
|
||||
Lines = dto.Lines.Select((l, i) => new StockMovementLine
|
||||
{
|
||||
LineNumber = i + 1,
|
||||
ArticleId = l.ArticleId,
|
||||
BatchId = l.BatchId,
|
||||
SerialId = l.SerialId,
|
||||
Quantity = l.Quantity,
|
||||
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
|
||||
Notes = l.Notes
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateMovementAsync(movement);
|
||||
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo movimento (rettifica)
|
||||
/// </summary>
|
||||
[HttpPost("adjustment")]
|
||||
public async Task<ActionResult<MovementDetailDto>> CreateAdjustmentMovement([FromBody] CreateAdjustmentDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = dto.DocumentNumber ?? "",
|
||||
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonId = dto.ReasonId,
|
||||
DestinationWarehouseId = dto.WarehouseId, // Per rettifiche positive
|
||||
SourceWarehouseId = dto.WarehouseId, // Per rettifiche negative
|
||||
ExternalReference = dto.ExternalReference,
|
||||
Notes = dto.Notes,
|
||||
Status = MovementStatus.Draft,
|
||||
Lines = dto.Lines.Select((l, i) => new StockMovementLine
|
||||
{
|
||||
LineNumber = i + 1,
|
||||
ArticleId = l.ArticleId,
|
||||
BatchId = l.BatchId,
|
||||
SerialId = l.SerialId,
|
||||
Quantity = l.Quantity, // Positiva o negativa
|
||||
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
|
||||
UnitCost = l.UnitCost,
|
||||
LineValue = l.Quantity * (l.UnitCost ?? 0),
|
||||
Notes = l.Notes
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateMovementAsync(movement);
|
||||
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ActionResult<MovementDetailDto>> CreateMovement(CreateMovementDto dto, MovementType type)
|
||||
{
|
||||
try
|
||||
{
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = dto.DocumentNumber ?? "",
|
||||
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
|
||||
Type = type,
|
||||
ReasonId = dto.ReasonId,
|
||||
SourceWarehouseId = type == MovementType.Outbound ? dto.WarehouseId : null,
|
||||
DestinationWarehouseId = type == MovementType.Inbound ? dto.WarehouseId : null,
|
||||
ExternalReference = dto.ExternalReference,
|
||||
ExternalDocumentType = dto.ExternalDocumentType,
|
||||
SupplierId = dto.SupplierId,
|
||||
CustomerId = dto.CustomerId,
|
||||
Notes = dto.Notes,
|
||||
Status = MovementStatus.Draft,
|
||||
Lines = dto.Lines.Select((l, i) => new StockMovementLine
|
||||
{
|
||||
LineNumber = i + 1,
|
||||
ArticleId = l.ArticleId,
|
||||
BatchId = l.BatchId,
|
||||
SerialId = l.SerialId,
|
||||
Quantity = l.Quantity,
|
||||
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
|
||||
UnitCost = l.UnitCost,
|
||||
LineValue = l.Quantity * (l.UnitCost ?? 0),
|
||||
SourceLocationCode = l.SourceLocationCode,
|
||||
DestinationLocationCode = l.DestinationLocationCode,
|
||||
ExternalLineReference = l.ExternalLineReference,
|
||||
Notes = l.Notes
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateMovementAsync(movement);
|
||||
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna un movimento esistente (solo bozze)
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<MovementDetailDto>> UpdateMovement(int id, [FromBody] UpdateMovementDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetMovementByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
existing.MovementDate = dto.MovementDate ?? existing.MovementDate;
|
||||
existing.ReasonId = dto.ReasonId ?? existing.ReasonId;
|
||||
existing.ExternalReference = dto.ExternalReference ?? existing.ExternalReference;
|
||||
existing.Notes = dto.Notes ?? existing.Notes;
|
||||
|
||||
if (dto.Lines != null)
|
||||
{
|
||||
existing.Lines = dto.Lines.Select((l, i) => new StockMovementLine
|
||||
{
|
||||
MovementId = id,
|
||||
LineNumber = i + 1,
|
||||
ArticleId = l.ArticleId,
|
||||
BatchId = l.BatchId,
|
||||
SerialId = l.SerialId,
|
||||
Quantity = l.Quantity,
|
||||
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
|
||||
UnitCost = l.UnitCost,
|
||||
LineValue = l.Quantity * (l.UnitCost ?? 0),
|
||||
SourceLocationCode = l.SourceLocationCode,
|
||||
DestinationLocationCode = l.DestinationLocationCode,
|
||||
ExternalLineReference = l.ExternalLineReference,
|
||||
Notes = l.Notes
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
var updated = await _warehouseService.UpdateMovementAsync(existing);
|
||||
return Ok(MapToDetailDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conferma un movimento (applica alle giacenze)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<MovementDetailDto>> ConfirmMovement(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var movement = await _warehouseService.ConfirmMovementAsync(id);
|
||||
return Ok(MapToDetailDto(movement));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annulla un movimento
|
||||
/// </summary>
|
||||
[HttpPost("{id}/cancel")]
|
||||
public async Task<ActionResult<MovementDetailDto>> CancelMovement(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var movement = await _warehouseService.CancelMovementAsync(id);
|
||||
return Ok(MapToDetailDto(movement));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera un nuovo numero documento
|
||||
/// </summary>
|
||||
[HttpGet("generate-number/{type}")]
|
||||
public async Task<ActionResult<string>> GenerateDocumentNumber(MovementType type)
|
||||
{
|
||||
var number = await _warehouseService.GenerateDocumentNumberAsync(type);
|
||||
return Ok(new { documentNumber = number });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le causali movimento
|
||||
/// </summary>
|
||||
[HttpGet("reasons")]
|
||||
public async Task<ActionResult<List<MovementReasonDto>>> GetMovementReasons([FromQuery] MovementType? type = null, [FromQuery] bool includeInactive = false)
|
||||
{
|
||||
var reasons = await _warehouseService.GetMovementReasonsAsync(type, includeInactive);
|
||||
return Ok(reasons.Select(r => new MovementReasonDto(
|
||||
r.Id,
|
||||
r.Code,
|
||||
r.Description,
|
||||
r.MovementType,
|
||||
r.StockSign,
|
||||
r.RequiresExternalReference,
|
||||
r.RequiresValuation,
|
||||
r.UpdatesAverageCost,
|
||||
r.IsSystem,
|
||||
r.IsActive
|
||||
)));
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record MovementFilterDto(
|
||||
DateTime? DateFrom,
|
||||
DateTime? DateTo,
|
||||
MovementType? Type,
|
||||
MovementStatus? Status,
|
||||
int? WarehouseId,
|
||||
int? ArticleId,
|
||||
int? ReasonId,
|
||||
string? ExternalReference,
|
||||
int Skip = 0,
|
||||
int Take = 100,
|
||||
string? OrderBy = null,
|
||||
bool OrderDescending = true
|
||||
);
|
||||
|
||||
public record MovementDto(
|
||||
int Id,
|
||||
string DocumentNumber,
|
||||
DateTime MovementDate,
|
||||
MovementType Type,
|
||||
MovementStatus Status,
|
||||
int? SourceWarehouseId,
|
||||
string? SourceWarehouseCode,
|
||||
string? SourceWarehouseName,
|
||||
int? DestinationWarehouseId,
|
||||
string? DestinationWarehouseCode,
|
||||
string? DestinationWarehouseName,
|
||||
int? ReasonId,
|
||||
string? ReasonDescription,
|
||||
string? ExternalReference,
|
||||
decimal? TotalValue,
|
||||
int LineCount,
|
||||
DateTime? ConfirmedDate,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt
|
||||
);
|
||||
|
||||
public record MovementDetailDto(
|
||||
int Id,
|
||||
string DocumentNumber,
|
||||
DateTime MovementDate,
|
||||
MovementType Type,
|
||||
MovementStatus Status,
|
||||
int? SourceWarehouseId,
|
||||
string? SourceWarehouseCode,
|
||||
string? SourceWarehouseName,
|
||||
int? DestinationWarehouseId,
|
||||
string? DestinationWarehouseCode,
|
||||
string? DestinationWarehouseName,
|
||||
int? ReasonId,
|
||||
string? ReasonDescription,
|
||||
string? ExternalReference,
|
||||
ExternalDocumentType? ExternalDocumentType,
|
||||
int? SupplierId,
|
||||
int? CustomerId,
|
||||
decimal? TotalValue,
|
||||
DateTime? ConfirmedDate,
|
||||
string? ConfirmedBy,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
List<MovementLineDto> Lines
|
||||
);
|
||||
|
||||
public record MovementLineDto(
|
||||
int Id,
|
||||
int LineNumber,
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
int? BatchId,
|
||||
string? BatchNumber,
|
||||
int? SerialId,
|
||||
string? SerialNumber,
|
||||
decimal Quantity,
|
||||
string UnitOfMeasure,
|
||||
decimal? UnitCost,
|
||||
decimal? LineValue,
|
||||
string? SourceLocationCode,
|
||||
string? DestinationLocationCode,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record CreateMovementDto(
|
||||
string? DocumentNumber,
|
||||
DateTime? MovementDate,
|
||||
int? ReasonId,
|
||||
int WarehouseId,
|
||||
string? ExternalReference,
|
||||
ExternalDocumentType? ExternalDocumentType,
|
||||
int? SupplierId,
|
||||
int? CustomerId,
|
||||
string? Notes,
|
||||
List<CreateMovementLineDto> Lines
|
||||
);
|
||||
|
||||
public record CreateTransferDto(
|
||||
string? DocumentNumber,
|
||||
DateTime? MovementDate,
|
||||
int? ReasonId,
|
||||
int SourceWarehouseId,
|
||||
int DestinationWarehouseId,
|
||||
string? ExternalReference,
|
||||
string? Notes,
|
||||
List<CreateMovementLineDto> Lines
|
||||
);
|
||||
|
||||
public record CreateAdjustmentDto(
|
||||
string? DocumentNumber,
|
||||
DateTime? MovementDate,
|
||||
int? ReasonId,
|
||||
int WarehouseId,
|
||||
string? ExternalReference,
|
||||
string? Notes,
|
||||
List<CreateMovementLineDto> Lines
|
||||
);
|
||||
|
||||
public record CreateMovementLineDto(
|
||||
int ArticleId,
|
||||
int? BatchId,
|
||||
int? SerialId,
|
||||
decimal Quantity,
|
||||
string? UnitOfMeasure,
|
||||
decimal? UnitCost,
|
||||
string? SourceLocationCode,
|
||||
string? DestinationLocationCode,
|
||||
string? ExternalLineReference,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateMovementDto(
|
||||
DateTime? MovementDate,
|
||||
int? ReasonId,
|
||||
string? ExternalReference,
|
||||
string? Notes,
|
||||
List<CreateMovementLineDto>? Lines
|
||||
);
|
||||
|
||||
public record MovementReasonDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Description,
|
||||
MovementType MovementType,
|
||||
int StockSign,
|
||||
bool RequiresExternalReference,
|
||||
bool RequiresValuation,
|
||||
bool UpdatesAverageCost,
|
||||
bool IsSystem,
|
||||
bool IsActive
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static MovementDto MapToDto(StockMovement movement) => new(
|
||||
movement.Id,
|
||||
movement.DocumentNumber,
|
||||
movement.MovementDate,
|
||||
movement.Type,
|
||||
movement.Status,
|
||||
movement.SourceWarehouseId,
|
||||
movement.SourceWarehouse?.Code,
|
||||
movement.SourceWarehouse?.Name,
|
||||
movement.DestinationWarehouseId,
|
||||
movement.DestinationWarehouse?.Code,
|
||||
movement.DestinationWarehouse?.Name,
|
||||
movement.ReasonId,
|
||||
movement.Reason?.Description,
|
||||
movement.ExternalReference,
|
||||
movement.TotalValue,
|
||||
movement.Lines.Count,
|
||||
movement.ConfirmedDate,
|
||||
movement.Notes,
|
||||
movement.CreatedAt
|
||||
);
|
||||
|
||||
private static MovementDetailDto MapToDetailDto(StockMovement movement) => new(
|
||||
movement.Id,
|
||||
movement.DocumentNumber,
|
||||
movement.MovementDate,
|
||||
movement.Type,
|
||||
movement.Status,
|
||||
movement.SourceWarehouseId,
|
||||
movement.SourceWarehouse?.Code,
|
||||
movement.SourceWarehouse?.Name,
|
||||
movement.DestinationWarehouseId,
|
||||
movement.DestinationWarehouse?.Code,
|
||||
movement.DestinationWarehouse?.Name,
|
||||
movement.ReasonId,
|
||||
movement.Reason?.Description,
|
||||
movement.ExternalReference,
|
||||
movement.ExternalDocumentType,
|
||||
movement.SupplierId,
|
||||
movement.CustomerId,
|
||||
movement.TotalValue,
|
||||
movement.ConfirmedDate,
|
||||
movement.ConfirmedBy,
|
||||
movement.Notes,
|
||||
movement.CreatedAt,
|
||||
movement.UpdatedAt,
|
||||
movement.Lines.Select(l => new MovementLineDto(
|
||||
l.Id,
|
||||
l.LineNumber,
|
||||
l.ArticleId,
|
||||
l.Article?.Code ?? "",
|
||||
l.Article?.Description ?? "",
|
||||
l.BatchId,
|
||||
l.Batch?.BatchNumber,
|
||||
l.SerialId,
|
||||
l.Serial?.SerialNumber,
|
||||
l.Quantity,
|
||||
l.UnitOfMeasure,
|
||||
l.UnitCost,
|
||||
l.LineValue,
|
||||
l.SourceLocationCode,
|
||||
l.DestinationLocationCode,
|
||||
l.Notes
|
||||
)).ToList()
|
||||
);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione degli articoli di magazzino
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/articles")]
|
||||
public class WarehouseArticlesController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<WarehouseArticlesController> _logger;
|
||||
|
||||
public WarehouseArticlesController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<WarehouseArticlesController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista degli articoli con filtri opzionali
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ArticleDto>>> GetArticles([FromQuery] ArticleFilterDto? filter)
|
||||
{
|
||||
var articleFilter = filter != null ? new ArticleFilter
|
||||
{
|
||||
SearchText = filter.Search,
|
||||
CategoryId = filter.CategoryId,
|
||||
IsActive = filter.IsActive,
|
||||
IsBatchManaged = filter.IsBatchManaged,
|
||||
IsSerialManaged = filter.IsSerialManaged,
|
||||
Skip = filter.Skip,
|
||||
Take = filter.Take,
|
||||
OrderBy = filter.OrderBy,
|
||||
OrderDescending = filter.OrderDescending
|
||||
} : null;
|
||||
|
||||
var articles = await _warehouseService.GetArticlesAsync(articleFilter);
|
||||
return Ok(articles.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un articolo per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ArticleDto>> GetArticle(int id)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(id);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(article));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un articolo per codice
|
||||
/// </summary>
|
||||
[HttpGet("by-code/{code}")]
|
||||
public async Task<ActionResult<ArticleDto>> GetArticleByCode(string code)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByCodeAsync(code);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(article));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un articolo per barcode
|
||||
/// </summary>
|
||||
[HttpGet("by-barcode/{barcode}")]
|
||||
public async Task<ActionResult<ArticleDto>> GetArticleByBarcode(string barcode)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByBarcodeAsync(barcode);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(article));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo articolo
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ArticleDto>> CreateArticle([FromBody] CreateArticleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var article = MapFromDto(dto);
|
||||
var created = await _warehouseService.CreateArticleAsync(article);
|
||||
return CreatedAtAction(nameof(GetArticle), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna un articolo esistente
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ArticleDto>> UpdateArticle(int id, [FromBody] UpdateArticleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetArticleByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
UpdateFromDto(existing, dto);
|
||||
var updated = await _warehouseService.UpdateArticleAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina un articolo
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> DeleteArticle(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.DeleteArticleAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica l'immagine di un articolo
|
||||
/// </summary>
|
||||
[HttpPost("{id}/image")]
|
||||
public async Task<ActionResult> UploadImage(int id, IFormFile file)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(id);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
if (file.Length > 5 * 1024 * 1024) // 5MB max
|
||||
return BadRequest(new { error = "Il file è troppo grande (max 5MB)" });
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream);
|
||||
|
||||
article.Image = memoryStream.ToArray();
|
||||
article.ImageMimeType = file.ContentType;
|
||||
|
||||
await _warehouseService.UpdateArticleAsync(article);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene l'immagine di un articolo
|
||||
/// </summary>
|
||||
[HttpGet("{id}/image")]
|
||||
public async Task<ActionResult> GetImage(int id)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(id);
|
||||
if (article == null || article.Image == null)
|
||||
return NotFound();
|
||||
|
||||
return File(article.Image, article.ImageMimeType ?? "image/jpeg");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la giacenza totale di un articolo
|
||||
/// </summary>
|
||||
[HttpGet("{id}/stock")]
|
||||
public async Task<ActionResult<ArticleStockDto>> GetArticleStock(int id)
|
||||
{
|
||||
var article = await _warehouseService.GetArticleByIdAsync(id);
|
||||
if (article == null)
|
||||
return NotFound();
|
||||
|
||||
var totalStock = await _warehouseService.GetTotalStockAsync(id);
|
||||
var availableStock = await _warehouseService.GetAvailableStockAsync(id);
|
||||
var stockLevels = await _warehouseService.GetStockLevelsAsync(new StockLevelFilter { ArticleId = id });
|
||||
|
||||
return Ok(new ArticleStockDto(
|
||||
ArticleId: id,
|
||||
ArticleCode: article.Code,
|
||||
ArticleDescription: article.Description,
|
||||
TotalStock: totalStock,
|
||||
AvailableStock: availableStock,
|
||||
UnitOfMeasure: article.UnitOfMeasure,
|
||||
MinimumStock: article.MinimumStock,
|
||||
MaximumStock: article.MaximumStock,
|
||||
ReorderPoint: article.ReorderPoint,
|
||||
IsLowStock: article.MinimumStock.HasValue && totalStock <= article.MinimumStock.Value,
|
||||
StockByWarehouse: stockLevels.Select(s => new WarehouseStockDto(
|
||||
WarehouseId: s.WarehouseId,
|
||||
WarehouseCode: s.Warehouse?.Code ?? "",
|
||||
WarehouseName: s.Warehouse?.Name ?? "",
|
||||
Quantity: s.Quantity,
|
||||
ReservedQuantity: s.ReservedQuantity,
|
||||
AvailableQuantity: s.AvailableQuantity,
|
||||
UnitCost: s.UnitCost,
|
||||
StockValue: s.StockValue,
|
||||
BatchId: s.BatchId,
|
||||
BatchNumber: s.Batch?.BatchNumber
|
||||
)).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record ArticleFilterDto(
|
||||
string? Search,
|
||||
int? CategoryId,
|
||||
bool? IsActive,
|
||||
bool? IsBatchManaged,
|
||||
bool? IsSerialManaged,
|
||||
int Skip = 0,
|
||||
int Take = 100,
|
||||
string? OrderBy = null,
|
||||
bool OrderDescending = false
|
||||
);
|
||||
|
||||
public record ArticleDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string? AlternativeCode,
|
||||
string Description,
|
||||
string? ShortDescription,
|
||||
string? Barcode,
|
||||
string? ManufacturerCode,
|
||||
int? CategoryId,
|
||||
string? CategoryName,
|
||||
string UnitOfMeasure,
|
||||
string? SecondaryUnitOfMeasure,
|
||||
decimal? UnitConversionFactor,
|
||||
StockManagementType StockManagement,
|
||||
bool IsBatchManaged,
|
||||
bool IsSerialManaged,
|
||||
bool HasExpiry,
|
||||
int? ExpiryWarningDays,
|
||||
decimal? MinimumStock,
|
||||
decimal? MaximumStock,
|
||||
decimal? ReorderPoint,
|
||||
decimal? ReorderQuantity,
|
||||
int? LeadTimeDays,
|
||||
ValuationMethod? ValuationMethod,
|
||||
decimal? StandardCost,
|
||||
decimal? LastPurchaseCost,
|
||||
decimal? WeightedAverageCost,
|
||||
decimal? BaseSellingPrice,
|
||||
decimal? Weight,
|
||||
decimal? Volume,
|
||||
bool IsActive,
|
||||
string? Notes,
|
||||
bool HasImage,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateArticleDto(
|
||||
string Description,
|
||||
string UnitOfMeasure,
|
||||
string? AlternativeCode = null,
|
||||
string? ShortDescription = null,
|
||||
string? Barcode = null,
|
||||
string? ManufacturerCode = null,
|
||||
int? CategoryId = null,
|
||||
string? SecondaryUnitOfMeasure = null,
|
||||
decimal? UnitConversionFactor = null,
|
||||
StockManagementType StockManagement = StockManagementType.Standard,
|
||||
bool IsBatchManaged = false,
|
||||
bool IsSerialManaged = false,
|
||||
bool HasExpiry = false,
|
||||
int? ExpiryWarningDays = null,
|
||||
decimal? MinimumStock = null,
|
||||
decimal? MaximumStock = null,
|
||||
decimal? ReorderPoint = null,
|
||||
decimal? ReorderQuantity = null,
|
||||
int? LeadTimeDays = null,
|
||||
ValuationMethod? ValuationMethod = null,
|
||||
decimal? StandardCost = null,
|
||||
decimal? BaseSellingPrice = null,
|
||||
decimal? Weight = null,
|
||||
decimal? Volume = null,
|
||||
string? Notes = null
|
||||
);
|
||||
|
||||
public record UpdateArticleDto(
|
||||
string Description,
|
||||
string? AlternativeCode,
|
||||
string? ShortDescription,
|
||||
string? Barcode,
|
||||
string? ManufacturerCode,
|
||||
int? CategoryId,
|
||||
string UnitOfMeasure,
|
||||
string? SecondaryUnitOfMeasure,
|
||||
decimal? UnitConversionFactor,
|
||||
StockManagementType StockManagement,
|
||||
bool IsBatchManaged,
|
||||
bool IsSerialManaged,
|
||||
bool HasExpiry,
|
||||
int? ExpiryWarningDays,
|
||||
decimal? MinimumStock,
|
||||
decimal? MaximumStock,
|
||||
decimal? ReorderPoint,
|
||||
decimal? ReorderQuantity,
|
||||
int? LeadTimeDays,
|
||||
ValuationMethod? ValuationMethod,
|
||||
decimal? StandardCost,
|
||||
decimal? BaseSellingPrice,
|
||||
decimal? Weight,
|
||||
decimal? Volume,
|
||||
bool IsActive,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record ArticleStockDto(
|
||||
int ArticleId,
|
||||
string ArticleCode,
|
||||
string ArticleDescription,
|
||||
decimal TotalStock,
|
||||
decimal AvailableStock,
|
||||
string UnitOfMeasure,
|
||||
decimal? MinimumStock,
|
||||
decimal? MaximumStock,
|
||||
decimal? ReorderPoint,
|
||||
bool IsLowStock,
|
||||
List<WarehouseStockDto> StockByWarehouse
|
||||
);
|
||||
|
||||
public record WarehouseStockDto(
|
||||
int WarehouseId,
|
||||
string WarehouseCode,
|
||||
string WarehouseName,
|
||||
decimal Quantity,
|
||||
decimal ReservedQuantity,
|
||||
decimal AvailableQuantity,
|
||||
decimal? UnitCost,
|
||||
decimal? StockValue,
|
||||
int? BatchId,
|
||||
string? BatchNumber
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static ArticleDto MapToDto(WarehouseArticle article) => new(
|
||||
article.Id,
|
||||
article.Code,
|
||||
article.AlternativeCode,
|
||||
article.Description,
|
||||
article.ShortDescription,
|
||||
article.Barcode,
|
||||
article.ManufacturerCode,
|
||||
article.CategoryId,
|
||||
article.Category?.Name,
|
||||
article.UnitOfMeasure,
|
||||
article.SecondaryUnitOfMeasure,
|
||||
article.UnitConversionFactor,
|
||||
article.StockManagement,
|
||||
article.IsBatchManaged,
|
||||
article.IsSerialManaged,
|
||||
article.HasExpiry,
|
||||
article.ExpiryWarningDays,
|
||||
article.MinimumStock,
|
||||
article.MaximumStock,
|
||||
article.ReorderPoint,
|
||||
article.ReorderQuantity,
|
||||
article.LeadTimeDays,
|
||||
article.ValuationMethod,
|
||||
article.StandardCost,
|
||||
article.LastPurchaseCost,
|
||||
article.WeightedAverageCost,
|
||||
article.BaseSellingPrice,
|
||||
article.Weight,
|
||||
article.Volume,
|
||||
article.IsActive,
|
||||
article.Notes,
|
||||
article.Image != null,
|
||||
article.CreatedAt,
|
||||
article.UpdatedAt
|
||||
);
|
||||
|
||||
private static WarehouseArticle MapFromDto(CreateArticleDto dto) => new()
|
||||
{
|
||||
// Code viene generato automaticamente da WarehouseService.CreateArticleAsync
|
||||
AlternativeCode = dto.AlternativeCode,
|
||||
Description = dto.Description,
|
||||
ShortDescription = dto.ShortDescription,
|
||||
Barcode = dto.Barcode,
|
||||
ManufacturerCode = dto.ManufacturerCode,
|
||||
CategoryId = dto.CategoryId,
|
||||
UnitOfMeasure = dto.UnitOfMeasure,
|
||||
SecondaryUnitOfMeasure = dto.SecondaryUnitOfMeasure,
|
||||
UnitConversionFactor = dto.UnitConversionFactor,
|
||||
StockManagement = dto.StockManagement,
|
||||
IsBatchManaged = dto.IsBatchManaged,
|
||||
IsSerialManaged = dto.IsSerialManaged,
|
||||
HasExpiry = dto.HasExpiry,
|
||||
ExpiryWarningDays = dto.ExpiryWarningDays,
|
||||
MinimumStock = dto.MinimumStock,
|
||||
MaximumStock = dto.MaximumStock,
|
||||
ReorderPoint = dto.ReorderPoint,
|
||||
ReorderQuantity = dto.ReorderQuantity,
|
||||
LeadTimeDays = dto.LeadTimeDays,
|
||||
ValuationMethod = dto.ValuationMethod,
|
||||
StandardCost = dto.StandardCost,
|
||||
BaseSellingPrice = dto.BaseSellingPrice,
|
||||
Weight = dto.Weight,
|
||||
Volume = dto.Volume,
|
||||
Notes = dto.Notes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
private static void UpdateFromDto(WarehouseArticle article, UpdateArticleDto dto)
|
||||
{
|
||||
// Code non viene aggiornato - è generato automaticamente e immutabile
|
||||
article.AlternativeCode = dto.AlternativeCode;
|
||||
article.Description = dto.Description;
|
||||
article.ShortDescription = dto.ShortDescription;
|
||||
article.Barcode = dto.Barcode;
|
||||
article.ManufacturerCode = dto.ManufacturerCode;
|
||||
article.CategoryId = dto.CategoryId;
|
||||
article.UnitOfMeasure = dto.UnitOfMeasure;
|
||||
article.SecondaryUnitOfMeasure = dto.SecondaryUnitOfMeasure;
|
||||
article.UnitConversionFactor = dto.UnitConversionFactor;
|
||||
article.StockManagement = dto.StockManagement;
|
||||
article.IsBatchManaged = dto.IsBatchManaged;
|
||||
article.IsSerialManaged = dto.IsSerialManaged;
|
||||
article.HasExpiry = dto.HasExpiry;
|
||||
article.ExpiryWarningDays = dto.ExpiryWarningDays;
|
||||
article.MinimumStock = dto.MinimumStock;
|
||||
article.MaximumStock = dto.MaximumStock;
|
||||
article.ReorderPoint = dto.ReorderPoint;
|
||||
article.ReorderQuantity = dto.ReorderQuantity;
|
||||
article.LeadTimeDays = dto.LeadTimeDays;
|
||||
article.ValuationMethod = dto.ValuationMethod;
|
||||
article.StandardCost = dto.StandardCost;
|
||||
article.BaseSellingPrice = dto.BaseSellingPrice;
|
||||
article.Weight = dto.Weight;
|
||||
article.Volume = dto.Volume;
|
||||
article.IsActive = dto.IsActive;
|
||||
article.Notes = dto.Notes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle categorie articoli
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/categories")]
|
||||
public class WarehouseCategoriesController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<WarehouseCategoriesController> _logger;
|
||||
|
||||
public WarehouseCategoriesController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<WarehouseCategoriesController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista delle categorie
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<CategoryDto>>> GetCategories([FromQuery] bool includeInactive = false)
|
||||
{
|
||||
var categories = await _warehouseService.GetCategoriesAsync(includeInactive);
|
||||
return Ok(categories.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le categorie in formato albero
|
||||
/// </summary>
|
||||
[HttpGet("tree")]
|
||||
public async Task<ActionResult<List<CategoryTreeDto>>> GetCategoryTree()
|
||||
{
|
||||
var categories = await _warehouseService.GetCategoryTreeAsync();
|
||||
return Ok(categories.Select(MapToTreeDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene una categoria per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<CategoryDto>> GetCategory(int id)
|
||||
{
|
||||
var category = await _warehouseService.GetCategoryByIdAsync(id);
|
||||
if (category == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(category));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea una nuova categoria
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<CategoryDto>> CreateCategory([FromBody] CreateCategoryDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var category = new WarehouseArticleCategory
|
||||
{
|
||||
Code = dto.Code,
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
ParentCategoryId = dto.ParentCategoryId,
|
||||
Icon = dto.Icon,
|
||||
Color = dto.Color,
|
||||
DefaultValuationMethod = dto.DefaultValuationMethod,
|
||||
SortOrder = dto.SortOrder,
|
||||
Notes = dto.Notes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var created = await _warehouseService.CreateCategoryAsync(category);
|
||||
return CreatedAtAction(nameof(GetCategory), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna una categoria esistente
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<CategoryDto>> UpdateCategory(int id, [FromBody] UpdateCategoryDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetCategoryByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
existing.Code = dto.Code;
|
||||
existing.Name = dto.Name;
|
||||
existing.Description = dto.Description;
|
||||
existing.Icon = dto.Icon;
|
||||
existing.Color = dto.Color;
|
||||
existing.DefaultValuationMethod = dto.DefaultValuationMethod;
|
||||
existing.SortOrder = dto.SortOrder;
|
||||
existing.IsActive = dto.IsActive;
|
||||
existing.Notes = dto.Notes;
|
||||
|
||||
var updated = await _warehouseService.UpdateCategoryAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina una categoria
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> DeleteCategory(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.DeleteCategoryAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record CategoryDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
int? ParentCategoryId,
|
||||
string? ParentCategoryName,
|
||||
int Level,
|
||||
string? FullPath,
|
||||
string? Icon,
|
||||
string? Color,
|
||||
ValuationMethod? DefaultValuationMethod,
|
||||
int SortOrder,
|
||||
bool IsActive,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
public record CategoryTreeDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
int Level,
|
||||
string? FullPath,
|
||||
string? Icon,
|
||||
string? Color,
|
||||
bool IsActive,
|
||||
List<CategoryTreeDto> Children
|
||||
);
|
||||
|
||||
public record CreateCategoryDto(
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
int? ParentCategoryId,
|
||||
string? Icon,
|
||||
string? Color,
|
||||
ValuationMethod? DefaultValuationMethod,
|
||||
int SortOrder,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateCategoryDto(
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Icon,
|
||||
string? Color,
|
||||
ValuationMethod? DefaultValuationMethod,
|
||||
int SortOrder,
|
||||
bool IsActive,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static CategoryDto MapToDto(WarehouseArticleCategory category) => new(
|
||||
category.Id,
|
||||
category.Code,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.ParentCategoryId,
|
||||
category.ParentCategory?.Name,
|
||||
category.Level,
|
||||
category.FullPath,
|
||||
category.Icon,
|
||||
category.Color,
|
||||
category.DefaultValuationMethod,
|
||||
category.SortOrder,
|
||||
category.IsActive,
|
||||
category.Notes,
|
||||
category.CreatedAt,
|
||||
category.UpdatedAt
|
||||
);
|
||||
|
||||
private static CategoryTreeDto MapToTreeDto(WarehouseArticleCategory category) => new(
|
||||
category.Id,
|
||||
category.Code,
|
||||
category.Name,
|
||||
category.Description,
|
||||
category.Level,
|
||||
category.FullPath,
|
||||
category.Icon,
|
||||
category.Color,
|
||||
category.IsActive,
|
||||
category.ChildCategories.Select(MapToTreeDto).ToList()
|
||||
);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei magazzini
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/warehouse/locations")]
|
||||
public class WarehouseLocationsController : ControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
private readonly ILogger<WarehouseLocationsController> _logger;
|
||||
|
||||
public WarehouseLocationsController(
|
||||
IWarehouseService warehouseService,
|
||||
ILogger<WarehouseLocationsController> logger)
|
||||
{
|
||||
_warehouseService = warehouseService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista dei magazzini
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<WarehouseLocationDto>>> GetWarehouses([FromQuery] bool includeInactive = false)
|
||||
{
|
||||
var warehouses = await _warehouseService.GetWarehousesAsync(includeInactive);
|
||||
return Ok(warehouses.Select(MapToDto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un magazzino per ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<WarehouseLocationDto>> GetWarehouse(int id)
|
||||
{
|
||||
var warehouse = await _warehouseService.GetWarehouseByIdAsync(id);
|
||||
if (warehouse == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(MapToDto(warehouse));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il magazzino predefinito
|
||||
/// </summary>
|
||||
[HttpGet("default")]
|
||||
public async Task<ActionResult<WarehouseLocationDto>> GetDefaultWarehouse()
|
||||
{
|
||||
var warehouse = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
if (warehouse == null)
|
||||
return NotFound(new { error = "Nessun magazzino predefinito configurato" });
|
||||
|
||||
return Ok(MapToDto(warehouse));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuovo magazzino
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<WarehouseLocationDto>> CreateWarehouse([FromBody] CreateWarehouseDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var warehouse = MapFromDto(dto);
|
||||
var created = await _warehouseService.CreateWarehouseAsync(warehouse);
|
||||
return CreatedAtAction(nameof(GetWarehouse), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna un magazzino esistente
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<WarehouseLocationDto>> UpdateWarehouse(int id, [FromBody] UpdateWarehouseDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _warehouseService.GetWarehouseByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound();
|
||||
|
||||
UpdateFromDto(existing, dto);
|
||||
var updated = await _warehouseService.UpdateWarehouseAsync(existing);
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina un magazzino
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> DeleteWarehouse(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.DeleteWarehouseAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imposta un magazzino come predefinito
|
||||
/// </summary>
|
||||
[HttpPut("{id}/set-default")]
|
||||
public async Task<ActionResult> SetDefaultWarehouse(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _warehouseService.SetDefaultWarehouseAsync(id);
|
||||
return Ok();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
public record WarehouseLocationDto(
|
||||
int Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Address,
|
||||
string? City,
|
||||
string? Province,
|
||||
string? PostalCode,
|
||||
string? Country,
|
||||
WarehouseType Type,
|
||||
bool IsDefault,
|
||||
bool IsActive,
|
||||
int SortOrder,
|
||||
string? Notes,
|
||||
DateTime? CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateWarehouseDto(
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Address,
|
||||
string? City,
|
||||
string? Province,
|
||||
string? PostalCode,
|
||||
string? Country,
|
||||
WarehouseType Type,
|
||||
bool IsDefault,
|
||||
int SortOrder,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateWarehouseDto(
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Address,
|
||||
string? City,
|
||||
string? Province,
|
||||
string? PostalCode,
|
||||
string? Country,
|
||||
WarehouseType Type,
|
||||
bool IsDefault,
|
||||
bool IsActive,
|
||||
int SortOrder,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static WarehouseLocationDto MapToDto(WarehouseLocation warehouse) => new(
|
||||
warehouse.Id,
|
||||
warehouse.Code,
|
||||
warehouse.Name,
|
||||
warehouse.Description,
|
||||
warehouse.Address,
|
||||
warehouse.City,
|
||||
warehouse.Province,
|
||||
warehouse.PostalCode,
|
||||
warehouse.Country,
|
||||
warehouse.Type,
|
||||
warehouse.IsDefault,
|
||||
warehouse.IsActive,
|
||||
warehouse.SortOrder,
|
||||
warehouse.Notes,
|
||||
warehouse.CreatedAt,
|
||||
warehouse.UpdatedAt
|
||||
);
|
||||
|
||||
private static WarehouseLocation MapFromDto(CreateWarehouseDto dto) => new()
|
||||
{
|
||||
Code = dto.Code,
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
Address = dto.Address,
|
||||
City = dto.City,
|
||||
Province = dto.Province,
|
||||
PostalCode = dto.PostalCode,
|
||||
Country = dto.Country ?? "Italia",
|
||||
Type = dto.Type,
|
||||
IsDefault = dto.IsDefault,
|
||||
SortOrder = dto.SortOrder,
|
||||
Notes = dto.Notes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
private static void UpdateFromDto(WarehouseLocation warehouse, UpdateWarehouseDto dto)
|
||||
{
|
||||
warehouse.Code = dto.Code;
|
||||
warehouse.Name = dto.Name;
|
||||
warehouse.Description = dto.Description;
|
||||
warehouse.Address = dto.Address;
|
||||
warehouse.City = dto.City;
|
||||
warehouse.Province = dto.Province;
|
||||
warehouse.PostalCode = dto.PostalCode;
|
||||
warehouse.Country = dto.Country;
|
||||
warehouse.Type = dto.Type;
|
||||
warehouse.IsDefault = dto.IsDefault;
|
||||
warehouse.IsActive = dto.IsActive;
|
||||
warehouse.SortOrder = dto.SortOrder;
|
||||
warehouse.Notes = dto.Notes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia servizio principale per il modulo Magazzino
|
||||
/// </summary>
|
||||
public interface IWarehouseService
|
||||
{
|
||||
// ===============================================
|
||||
// ARTICOLI
|
||||
// ===============================================
|
||||
Task<List<WarehouseArticle>> GetArticlesAsync(ArticleFilter? filter = null);
|
||||
Task<WarehouseArticle?> GetArticleByIdAsync(int id);
|
||||
Task<WarehouseArticle?> GetArticleByCodeAsync(string code);
|
||||
Task<WarehouseArticle?> GetArticleByBarcodeAsync(string barcode);
|
||||
Task<WarehouseArticle> CreateArticleAsync(WarehouseArticle article);
|
||||
Task<WarehouseArticle> UpdateArticleAsync(WarehouseArticle article);
|
||||
Task DeleteArticleAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// CATEGORIE
|
||||
// ===============================================
|
||||
Task<List<WarehouseArticleCategory>> GetCategoriesAsync(bool includeInactive = false);
|
||||
Task<List<WarehouseArticleCategory>> GetCategoryTreeAsync();
|
||||
Task<WarehouseArticleCategory?> GetCategoryByIdAsync(int id);
|
||||
Task<WarehouseArticleCategory> CreateCategoryAsync(WarehouseArticleCategory category);
|
||||
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
|
||||
Task DeleteCategoryAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// MAGAZZINI
|
||||
// ===============================================
|
||||
Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false);
|
||||
Task<WarehouseLocation?> GetWarehouseByIdAsync(int id);
|
||||
Task<WarehouseLocation?> GetDefaultWarehouseAsync();
|
||||
Task<WarehouseLocation> CreateWarehouseAsync(WarehouseLocation warehouse);
|
||||
Task<WarehouseLocation> UpdateWarehouseAsync(WarehouseLocation warehouse);
|
||||
Task DeleteWarehouseAsync(int id);
|
||||
Task SetDefaultWarehouseAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// PARTITE (BATCH)
|
||||
// ===============================================
|
||||
Task<List<ArticleBatch>> GetBatchesAsync(int? articleId = null, BatchStatus? status = null);
|
||||
Task<ArticleBatch?> GetBatchByIdAsync(int id);
|
||||
Task<ArticleBatch?> GetBatchByNumberAsync(int articleId, string batchNumber);
|
||||
Task<ArticleBatch> CreateBatchAsync(ArticleBatch batch);
|
||||
Task<ArticleBatch> UpdateBatchAsync(ArticleBatch batch);
|
||||
Task<List<ArticleBatch>> GetExpiringBatchesAsync(int daysThreshold = 30);
|
||||
Task UpdateBatchStatusAsync(int id, BatchStatus status);
|
||||
|
||||
// ===============================================
|
||||
// SERIALI
|
||||
// ===============================================
|
||||
Task<List<ArticleSerial>> GetSerialsAsync(int? articleId = null, SerialStatus? status = null);
|
||||
Task<ArticleSerial?> GetSerialByIdAsync(int id);
|
||||
Task<ArticleSerial?> GetSerialByNumberAsync(int articleId, string serialNumber);
|
||||
Task<ArticleSerial> CreateSerialAsync(ArticleSerial serial);
|
||||
Task<ArticleSerial> UpdateSerialAsync(ArticleSerial serial);
|
||||
Task UpdateSerialStatusAsync(int id, SerialStatus status);
|
||||
|
||||
// ===============================================
|
||||
// GIACENZE
|
||||
// ===============================================
|
||||
Task<List<StockLevel>> GetStockLevelsAsync(StockLevelFilter? filter = null);
|
||||
Task<StockLevel?> GetStockLevelAsync(int articleId, int warehouseId, int? batchId = null);
|
||||
Task<decimal> GetTotalStockAsync(int articleId);
|
||||
Task<decimal> GetAvailableStockAsync(int articleId, int? warehouseId = null);
|
||||
Task<List<StockLevel>> GetLowStockArticlesAsync();
|
||||
Task UpdateStockLevelAsync(int articleId, int warehouseId, decimal quantity, int? batchId = null, decimal? unitCost = null);
|
||||
|
||||
// ===============================================
|
||||
// MOVIMENTI
|
||||
// ===============================================
|
||||
Task<List<StockMovement>> GetMovementsAsync(MovementFilter? filter = null);
|
||||
Task<StockMovement?> GetMovementByIdAsync(int id);
|
||||
Task<StockMovement?> GetMovementByDocumentNumberAsync(string documentNumber);
|
||||
Task<StockMovement> CreateMovementAsync(StockMovement movement);
|
||||
Task<StockMovement> UpdateMovementAsync(StockMovement movement);
|
||||
Task<StockMovement> ConfirmMovementAsync(int id);
|
||||
Task<StockMovement> CancelMovementAsync(int id);
|
||||
Task<string> GenerateDocumentNumberAsync(MovementType type);
|
||||
|
||||
// ===============================================
|
||||
// CAUSALI
|
||||
// ===============================================
|
||||
Task<List<MovementReason>> GetMovementReasonsAsync(MovementType? type = null, bool includeInactive = false);
|
||||
Task<MovementReason?> GetMovementReasonByIdAsync(int id);
|
||||
Task<MovementReason> CreateMovementReasonAsync(MovementReason reason);
|
||||
Task<MovementReason> UpdateMovementReasonAsync(MovementReason reason);
|
||||
|
||||
// ===============================================
|
||||
// VALORIZZAZIONE
|
||||
// ===============================================
|
||||
Task<decimal> CalculateArticleValueAsync(int articleId, ValuationMethod? method = null);
|
||||
Task<StockValuation> CalculatePeriodValuationAsync(int articleId, int period, int? warehouseId = null);
|
||||
Task<List<StockValuation>> GetValuationsAsync(int period, int? warehouseId = null);
|
||||
Task ClosePeriodAsync(int period);
|
||||
Task<decimal> GetWeightedAverageCostAsync(int articleId);
|
||||
Task UpdateWeightedAverageCostAsync(int articleId);
|
||||
|
||||
// ===============================================
|
||||
// INVENTARIO
|
||||
// ===============================================
|
||||
Task<List<InventoryCount>> GetInventoryCountsAsync(InventoryStatus? status = null);
|
||||
Task<InventoryCount?> GetInventoryCountByIdAsync(int id);
|
||||
Task<InventoryCount> CreateInventoryCountAsync(InventoryCount inventory);
|
||||
Task<InventoryCount> UpdateInventoryCountAsync(InventoryCount inventory);
|
||||
Task<InventoryCount> StartInventoryCountAsync(int id);
|
||||
Task<InventoryCount> CompleteInventoryCountAsync(int id);
|
||||
Task<InventoryCount> ConfirmInventoryCountAsync(int id);
|
||||
Task<InventoryCount> CancelInventoryCountAsync(int id);
|
||||
Task<InventoryCountLine> UpdateCountLineAsync(int lineId, decimal countedQuantity, string? countedBy = null);
|
||||
|
||||
// ===============================================
|
||||
// SEED DATA
|
||||
// ===============================================
|
||||
Task SeedDefaultDataAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filtro per ricerca articoli
|
||||
/// </summary>
|
||||
public class ArticleFilter
|
||||
{
|
||||
public string? SearchText { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public bool? IsBatchManaged { get; set; }
|
||||
public bool? IsSerialManaged { get; set; }
|
||||
public StockManagementType? StockManagement { get; set; }
|
||||
public bool? HasLowStock { get; set; }
|
||||
public int Skip { get; set; } = 0;
|
||||
public int Take { get; set; } = 100;
|
||||
public string? OrderBy { get; set; }
|
||||
public bool OrderDescending { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filtro per ricerca giacenze
|
||||
/// </summary>
|
||||
public class StockLevelFilter
|
||||
{
|
||||
public int? ArticleId { get; set; }
|
||||
public int? WarehouseId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public bool? OnlyWithStock { get; set; }
|
||||
public bool? OnlyLowStock { get; set; }
|
||||
public int Skip { get; set; } = 0;
|
||||
public int Take { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filtro per ricerca movimenti
|
||||
/// </summary>
|
||||
public class MovementFilter
|
||||
{
|
||||
public DateTime? DateFrom { get; set; }
|
||||
public DateTime? DateTo { get; set; }
|
||||
public MovementType? Type { get; set; }
|
||||
public MovementStatus? Status { get; set; }
|
||||
public int? WarehouseId { get; set; }
|
||||
public int? ArticleId { get; set; }
|
||||
public int? ReasonId { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int Skip { get; set; } = 0;
|
||||
public int Take { get; set; } = 100;
|
||||
public string? OrderBy { get; set; }
|
||||
public bool OrderDescending { get; set; } = true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user