changed name from Apollinare to Zentral

This commit is contained in:
2025-12-03 00:07:55 +01:00
parent 490cd2730d
commit 66077d6077
157 changed files with 1895 additions and 1887 deletions

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View 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
}

View 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;
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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