This commit is contained in:
2025-11-29 14:52:39 +01:00
parent bb2d0729e1
commit c7dbcde5dd
49 changed files with 23088 additions and 5 deletions

View File

@@ -0,0 +1,288 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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,460 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 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 Code,
string Description,
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,
string? Notes
);
public record UpdateArticleDto(
string Code,
string Description,
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.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 = dto.Code,
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)
{
article.Code = dto.Code;
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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.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 Apollinare.Domain.Entities.Warehouse;
namespace Apollinare.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