204 lines
8.0 KiB
C#
204 lines
8.0 KiB
C#
using Distribution.Features.Item;
|
|
using Distribution.Features.Order;
|
|
using Distribution.Features.Returns;
|
|
using Distribution.Infra.Authorization;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Distribution.Features.Invoice;
|
|
|
|
public class InvoiceService(
|
|
IInvoiceRepository repo,
|
|
IOrderRepository orderRepo,
|
|
IReturnRepository returnRepo,
|
|
IItemRepository itemRepo,
|
|
IUnitOfWork uow,
|
|
ICurrentUserService currentUser,
|
|
IConfiguration configuration)
|
|
{
|
|
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
|
new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId,
|
|
i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt);
|
|
|
|
private bool IsModeB =>
|
|
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") != "A";
|
|
|
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
|
|
}
|
|
|
|
/// <summary>Mode B only — creates the Invoice (Draft) for an already-Confirmed PO as a
|
|
/// separate action. Mode A creates the Invoice atomically via
|
|
/// OrderService.ConfirmAndCreateInvoiceAsync instead.</summary>
|
|
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, CancellationToken ct = default)
|
|
{
|
|
if (!IsModeB)
|
|
throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation.");
|
|
|
|
var order = await orderRepo.GetByIdAsync(request.OrderId);
|
|
if (order is null || !order.IsActive)
|
|
throw new InvalidOperationException("Order not found.");
|
|
if (order.ConfirmedAt is null)
|
|
throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it.");
|
|
if (order.InvoiceId is not null)
|
|
throw new InvalidOperationException("An Invoice already exists for this PO.");
|
|
|
|
var invoice = new Models.Invoice
|
|
{
|
|
FK_OrderId = order.Id,
|
|
Status = InvoiceStatus.Draft,
|
|
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
|
|
TotalAmount = order.TotalAmount,
|
|
};
|
|
order.InvoiceId = invoice.Id;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(invoice);
|
|
orderRepo.Update(order);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
return ToResponse(invoice);
|
|
}
|
|
|
|
/// <summary>Draft → Printed. The agent takes the physical copy to the field and may make
|
|
/// handwritten edits during the ride.</summary>
|
|
public async Task<InvoiceResponse?> PrintAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
if (invoice is null || !invoice.IsActive) return null;
|
|
if (invoice.Status != InvoiceStatus.Draft)
|
|
throw new InvalidOperationException("Only a Draft invoice can be printed.");
|
|
|
|
invoice.Status = InvoiceStatus.Printed;
|
|
invoice.PrintedAt = DateTime.Now;
|
|
invoice.PrintedByUserId = currentUser.UserId;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(invoice);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
return ToResponse(invoice);
|
|
}
|
|
|
|
/// <summary>Printed → Finalized. The office worker enters whatever the agent handwrote on the
|
|
/// returned printed copy — delivered/free quantities per line, returns/damage (reusing the
|
|
/// SaleReturn/CreditNote shape from 18_returns_claims.md), and an optional invoice-level
|
|
/// discount. Payload shape resolved in order_invoice_lifecycle.md Open Question 6.</summary>
|
|
public async Task<InvoiceResponse?> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, CancellationToken ct = default)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
if (invoice is null || !invoice.IsActive) return null;
|
|
if (invoice.Status != InvoiceStatus.Printed)
|
|
throw new InvalidOperationException("An invoice can only be finalized from Printed — Draft → Finalized directly is not a valid transition.");
|
|
if (request.Items is null || request.Items.Count == 0)
|
|
throw new InvalidOperationException("Finalize must include at least one line item.");
|
|
|
|
var order = await orderRepo.GetByIdAsync(invoice.FK_OrderId)
|
|
?? throw new InvalidOperationException("Order not found for this Invoice.");
|
|
var orderItems = (await orderRepo.GetItemsByOrderIdAsync(order.Id)).ToDictionary(oi => oi.Id);
|
|
|
|
decimal subtotal = 0;
|
|
foreach (var line in request.Items)
|
|
{
|
|
if (!orderItems.TryGetValue(line.OrderItemId, out var orderItem))
|
|
throw new InvalidOperationException($"OrderItem {line.OrderItemId} does not belong to this Invoice's PO.");
|
|
if (line.DeliveredQuantity < 0 || line.FreeQuantity < 0)
|
|
throw new InvalidOperationException("Delivered/free quantity cannot be negative.");
|
|
|
|
subtotal += line.DeliveredQuantity * orderItem.UnitPrice; // freeQuantity never bills
|
|
orderItem.QuantityFulfilled = (int)(line.DeliveredQuantity + line.FreeQuantity);
|
|
}
|
|
|
|
Models.SaleReturn? saleReturn = null;
|
|
decimal creditNoteAmount = 0;
|
|
if (request.Returns is { Count: > 0 })
|
|
{
|
|
saleReturn = new Models.SaleReturn
|
|
{
|
|
FK_OrderId = order.Id,
|
|
Mode = ReturnMode.CreditDeduction,
|
|
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
|
|
};
|
|
|
|
foreach (var line in request.Returns)
|
|
{
|
|
if (line.Quantity <= 0)
|
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
|
|
|
var matchingOrderItem = orderItems.Values.FirstOrDefault(oi => oi.FK_ItemId == line.ItemId);
|
|
var unitPrice = matchingOrderItem?.UnitPrice
|
|
?? (await itemRepo.GetByIdAsync(line.ItemId))?.SalePrice
|
|
?? 0;
|
|
|
|
saleReturn.Items.Add(new SaleReturnItem
|
|
{
|
|
FK_ItemId = line.ItemId,
|
|
Quantity = line.Quantity,
|
|
ReasonCode = line.ReasonCode,
|
|
UnitPrice = unitPrice,
|
|
});
|
|
}
|
|
|
|
creditNoteAmount = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
|
|
saleReturn.TotalCreditAmount = creditNoteAmount;
|
|
}
|
|
|
|
decimal discountAmount = request.Discount switch
|
|
{
|
|
{ Type: DiscountType.Amount } d => d.Value,
|
|
{ Type: DiscountType.Percentage } d => subtotal * d.Value / 100m,
|
|
null => 0,
|
|
_ => 0,
|
|
};
|
|
|
|
invoice.TotalAmount = Math.Max(0, subtotal - discountAmount - creditNoteAmount);
|
|
invoice.Status = InvoiceStatus.Finalized;
|
|
invoice.FinalizedAt = DateTime.Now;
|
|
invoice.FinalizedByUserId = currentUser.UserId;
|
|
order.TotalAmount = invoice.TotalAmount;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(invoice);
|
|
orderRepo.Update(order);
|
|
if (saleReturn is not null)
|
|
{
|
|
await returnRepo.AddAsync(saleReturn);
|
|
await returnRepo.AddCreditNoteAsync(new CreditNote
|
|
{
|
|
FK_SaleReturnId = saleReturn.Id,
|
|
Amount = creditNoteAmount,
|
|
});
|
|
}
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
return ToResponse(invoice);
|
|
}
|
|
}
|