341 lines
13 KiB
C#
341 lines
13 KiB
C#
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;
|
|
}
|
|
}
|