Add invoice print and finalize flow
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.LoadingEvent;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.ShopPO;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
@@ -10,6 +11,7 @@ public class DeliveryTaskService(
|
|||||||
IDeliveryTaskRepository repo,
|
IDeliveryTaskRepository repo,
|
||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IShopPORepository shopPORepo,
|
IShopPORepository shopPORepo,
|
||||||
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
|
|||||||
i.UnitPrice)).ToList(),
|
i.UnitPrice)).ToList(),
|
||||||
t.CreatedAt);
|
t.CreatedAt);
|
||||||
|
|
||||||
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
|
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||||
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
|
{
|
||||||
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
|
||||||
|
if (confirmedSheets.Count == 0)
|
||||||
|
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
|
||||||
|
|
||||||
|
var shopPOIds = confirmedSheets
|
||||||
|
.SelectMany(s => s.Orders)
|
||||||
|
.Select(o => o.FK_ShopPOId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existingTasks = await repo.GetByRideIdAsync(rideId);
|
||||||
|
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
|
||||||
|
|
||||||
|
var newTasks = new List<DeliveryTask>();
|
||||||
|
foreach (var shopPOId in shopPOIds)
|
||||||
|
{
|
||||||
|
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
|
||||||
|
|
||||||
|
newTasks.Add(new DeliveryTask
|
||||||
|
{
|
||||||
|
FK_RideId = rideId,
|
||||||
|
FK_ShopPOId = shopPOId,
|
||||||
|
Sequence = nextSequence++,
|
||||||
|
Status = DeliveryTaskStatus.Pending
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTasks.Count > 0)
|
||||||
|
{
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var task in newTasks) await repo.AddAsync(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await repo.GetByRideIdAsync(rideId);
|
||||||
|
return saved.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Invoice;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/invoices")]
|
[Route("api/invoices")]
|
||||||
public class InvoiceController(InvoiceService service) : ControllerBase
|
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
@@ -26,4 +27,30 @@ public class InvoiceController(InvoiceService service) : ControllerBase
|
|||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/print")]
|
||||||
|
public async Task<IActionResult> Print(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PrintAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/finalize")]
|
||||||
|
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Distribution.Features.Invoice;
|
|||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
|
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null);
|
||||||
|
|
||||||
public record InvoiceResponse(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
|
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
|
||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,61 @@ public class InvoiceService(
|
|||||||
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
|
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
|
||||||
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Invoice not found");
|
||||||
|
|
||||||
|
if (invoice.Status != InvoiceStatus.Draft)
|
||||||
|
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
invoice.Status = InvoiceStatus.Printed;
|
||||||
|
invoice.PrintedAt = DateTime.Now;
|
||||||
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Invoice not found");
|
||||||
|
|
||||||
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var adjustments = request.Adjustments ?? [];
|
||||||
|
if (adjustments.Count > 0)
|
||||||
|
{
|
||||||
|
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||||
|
decimal total = 0;
|
||||||
|
foreach (var item in poItems)
|
||||||
|
{
|
||||||
|
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
||||||
|
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
||||||
|
|
||||||
|
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
||||||
|
|
||||||
|
total += billedQuantity * item.UnitPrice;
|
||||||
|
}
|
||||||
|
invoice.TotalAmount = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(invoice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user