Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 919c76746d | |||
| 76c08855e4 | |||
| 1ed78b9932 | |||
| 62de2ddce1 | |||
| 8bc895cbc5 | |||
| 792d7c0140 | |||
| 54931ef110 | |||
| 8575034714 | |||
| 710fc15253 | |||
| db24c66d77 | |||
| 0cd4508d50 | |||
| c8f86163f7 | |||
| 02c673328e | |||
| c137aae7a5 | |||
| e3d4837379 | |||
| de5dcf99b3 | |||
| 2b68e2cc04 | |||
| faaa1fe6a5 | |||
| f42809a4e3 | |||
| 4b56443d1a | |||
| 72f850ea4d | |||
| 9f04242dc7 | |||
| d06d434143 | |||
| 248129900d | |||
| ea8f952f14 | |||
| 4d63b3e720 | |||
| 2f9661fafc | |||
| 7d740efabb | |||
| 222279b478 | |||
| 70fd4a9150 | |||
| 15b3e43fe8 | |||
| 8fd31a9b20 |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dotnet build *)",
|
||||||
|
"PowerShell(Get-Command mysql -ErrorAction SilentlyContinue)",
|
||||||
|
"PowerShell(Get-Command mysql.exe -ErrorAction SilentlyContinue)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6c40421b2d23630d4249841425ec7906d93d5985")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
|
00c7acd66c7d7d5bdbb8d4468924c7202fbdc15259175676556b482252622db8
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
ce3b0b0b2776e1f5cfcf61d517b9f6ce1c89ed303e7e35ab0b7261f5686d0ddb
|
b5689108dc90f1afbeded25902110c1295d20ccadf0aaae3dfd42fcd421eba23
|
||||||
|
|||||||
@@ -125,3 +125,13 @@ C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\n
|
|||||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
|
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
|
||||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
|
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
|
||||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
|
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\.msCoverageSourceRootsMapping_Distribution.Tests
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\CoverletSourceRootsMapping_Distribution.Tests
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.AssemblyReference.cache
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.GeneratedMSBuildEditorConfig.editorconfig
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfoInputs.cache
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfo.cs
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.CoreCompileInputs.cache
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.dll
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\refint\Distribution.Tests.dll
|
||||||
|
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<Sale> Sales { get; set; }
|
public DbSet<Sale> Sales { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<Order> Orders { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<OrderItem> OrderItems { get; set; }
|
||||||
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
public DbSet<Role> Roles { get; set; }
|
public DbSet<Role> Roles { get; set; }
|
||||||
@@ -64,8 +65,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
// OrderItem uses its inherited Template.Id as PK (no composite key on
|
||||||
|
// {FK_OrderId, FK_ItemId}) so that editing a PO's items can soft-delete the current
|
||||||
|
// row and insert a fresh one for the same item — versioning per order_invoice_lifecycle.md.
|
||||||
modelBuilder.Entity<OrderItem>()
|
modelBuilder.Entity<OrderItem>()
|
||||||
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
|
.HasIndex(oi => oi.FK_OrderId);
|
||||||
|
|
||||||
modelBuilder.Entity<RolePermission>()
|
modelBuilder.Entity<RolePermission>()
|
||||||
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
||||||
@@ -143,8 +147,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasOne(o => o.Sale)
|
.HasOne(o => o.Sale)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(o => o.FK_SaleId)
|
.HasForeignKey(o => o.FK_SaleId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasOne(o => o.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(o => o.CreatedByUserId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasOne(o => o.ConfirmedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(o => o.ConfirmedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
// Order.InvoiceId is a plain column with no DB-level FK constraint — deliberately
|
||||||
|
// unmapped as a relationship, per order_invoice_lifecycle.md.
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.Property(o => o.InvoiceId)
|
||||||
|
.HasColumnName("InvoiceId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<Invoice>()
|
||||||
|
.HasOne(i => i.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Invoice>()
|
||||||
|
.HasOne(i => i.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Invoice>()
|
||||||
|
.HasOne(i => i.PrintedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.PrintedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Invoice>()
|
||||||
|
.HasOne(i => i.FinalizedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FinalizedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Scheme)
|
.HasOne(r => r.Scheme)
|
||||||
.WithMany(s => s.Redemptions)
|
.WithMany(s => s.Redemptions)
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ public static class OperationsDataSeed
|
|||||||
FK_SaleId = Sale1Id,
|
FK_SaleId = Sale1Id,
|
||||||
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
FK_StoreId = MasterDataSeed.FortStoreId,
|
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||||
|
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
|
||||||
TotalAmount = 3000m,
|
TotalAmount = 3000m,
|
||||||
Status = OrderStatus.Fulfilled,
|
Status = OrderStatus.Fulfilled,
|
||||||
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
||||||
@@ -116,6 +117,7 @@ public static class OperationsDataSeed
|
|||||||
FK_SaleId = Sale2Id,
|
FK_SaleId = Sale2Id,
|
||||||
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
FK_StoreId = MasterDataSeed.KandyStoreId,
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
|
||||||
TotalAmount = 1200m,
|
TotalAmount = 1200m,
|
||||||
Status = OrderStatus.Pending,
|
Status = OrderStatus.Pending,
|
||||||
FulfilledAt = null,
|
FulfilledAt = null,
|
||||||
|
|||||||
@@ -187,9 +187,9 @@ public class DeliveryTaskService(
|
|||||||
repo.Update(task);
|
repo.Update(task);
|
||||||
orderRepo.Update(order);
|
orderRepo.Update(order);
|
||||||
|
|
||||||
if (fullyDelivered)
|
if (fullyDelivered && order.FK_SaleId is Guid saleId)
|
||||||
{
|
{
|
||||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
var sale = await saleRepo.GetByIdAsync(saleId)
|
||||||
?? throw new KeyNotFoundException("Sale not found");
|
?? throw new KeyNotFoundException("Sale not found");
|
||||||
sale.Status = SaleStatus.Completed;
|
sale.Status = SaleStatus.Completed;
|
||||||
sale.CompletedAt = DateTime.Now;
|
sale.CompletedAt = DateTime.Now;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
|
public interface IInvoiceRepository
|
||||||
|
{
|
||||||
|
Task<Models.Invoice?> GetByIdAsync(Guid id);
|
||||||
|
Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
|
||||||
|
Task AddAsync(Models.Invoice entity);
|
||||||
|
void Update(Models.Invoice entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/invoices")]
|
||||||
|
public class InvoiceController(InvoiceService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(CreateInvoiceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/print")]
|
||||||
|
public async Task<IActionResult> Print(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PrintAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/finalize")]
|
||||||
|
public async Task<IActionResult> Finalize(Guid id, FinalizeInvoiceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.FinalizeAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
|
public record CreateInvoiceRequest(Guid OrderId);
|
||||||
|
|
||||||
|
public record FinalizeInvoiceItemRequest(Guid OrderItemId, decimal DeliveredQuantity, decimal FreeQuantity);
|
||||||
|
|
||||||
|
public record FinalizeInvoiceReturnRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
|
||||||
|
|
||||||
|
public record FinalizeInvoiceDiscountRequest(DiscountType Type, decimal Value, string? Reason);
|
||||||
|
|
||||||
|
public record FinalizeInvoiceRequest(
|
||||||
|
List<FinalizeInvoiceItemRequest> Items,
|
||||||
|
List<FinalizeInvoiceReturnRequest>? Returns,
|
||||||
|
FinalizeInvoiceDiscountRequest? Discount);
|
||||||
|
|
||||||
|
public record InvoiceResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid OrderId,
|
||||||
|
InvoiceStatus Status,
|
||||||
|
Guid CreatedByUserId,
|
||||||
|
DateTime? PrintedAt,
|
||||||
|
Guid? PrintedByUserId,
|
||||||
|
DateTime? FinalizedAt,
|
||||||
|
Guid? FinalizedByUserId,
|
||||||
|
decimal TotalAmount,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
|
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Invoices.FindAsync(id);
|
||||||
|
|
||||||
|
public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
|
||||||
|
await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Invoice entity) =>
|
||||||
|
await context.Invoices.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Invoice entity) =>
|
||||||
|
context.Invoices.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,12 @@ public interface IOrderRepository
|
|||||||
Task AddAsync(Models.Order entity);
|
Task AddAsync(Models.Order entity);
|
||||||
void Update(Models.Order entity);
|
void Update(Models.Order entity);
|
||||||
void Remove(Models.Order entity);
|
void Remove(Models.Order entity);
|
||||||
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false);
|
||||||
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
||||||
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
||||||
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
||||||
|
|
||||||
|
/// <summary>Confirmed POs (ConfirmedAt set, not cancelled) matching the optional filters —
|
||||||
|
/// backs Mode B's "office browses Confirmed POs" step per order_invoice_lifecycle.md.</summary>
|
||||||
|
Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
@@ -10,6 +10,10 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
|
[HttpGet("confirmed")]
|
||||||
|
public async Task<IActionResult> GetConfirmed([FromQuery] Guid? agentId, [FromQuery] Guid? routeId, [FromQuery] Guid? shopId) =>
|
||||||
|
Ok(await service.GetConfirmedAsync(agentId, routeId, shopId));
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -35,6 +39,39 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/items")]
|
||||||
|
public async Task<IActionResult> ReplaceItems(Guid id, UpdateOrderItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ReplaceItemsAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/confirm")]
|
||||||
|
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/confirm-and-create-invoice")]
|
||||||
|
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmAndCreateInvoiceAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -46,6 +83,13 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cancelled = await service.DeleteAsync(id, ct);
|
||||||
|
return cancelled ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ using Distribution.Models;
|
|||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
public record CreateOrderRequest(Guid SaleId);
|
public record CreateOrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice);
|
||||||
|
|
||||||
|
public record CreateOrderRequest(Guid StoreId, List<CreateOrderItemRequest> Items);
|
||||||
|
|
||||||
|
public record UpdateOrderItemsRequest(List<CreateOrderItemRequest> Items);
|
||||||
|
|
||||||
public record UpdateOrderRequest(OrderStatus Status);
|
public record UpdateOrderRequest(OrderStatus Status);
|
||||||
|
|
||||||
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
public record OrderItemResponse(Guid Id, Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
public record OrderResponse(
|
public record OrderResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SaleId,
|
Guid? SaleId,
|
||||||
|
Guid? InvoiceId,
|
||||||
DateTime OrderDate,
|
DateTime OrderDate,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
|
Guid CreatedByUserId,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
OrderStatus Status,
|
OrderStatus Status,
|
||||||
|
DateTime? ConfirmedAt,
|
||||||
DateTime? FulfilledAt,
|
DateTime? FulfilledAt,
|
||||||
List<OrderItemResponse> Items,
|
List<OrderItemResponse> Items,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
context.Orders.Update(entity);
|
context.Orders.Update(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId) =>
|
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false) =>
|
||||||
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).ToListAsync();
|
await context.OrderItems
|
||||||
|
.Where(oi => oi.FK_OrderId == orderId && (includeHistory || oi.IsActive))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
|
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
|
||||||
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
|
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
|
||||||
@@ -40,10 +42,21 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
|
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
|
||||||
await context.Orders
|
await context.Orders
|
||||||
.Include(o => o.Store)
|
.Include(o => o.Store)
|
||||||
.Include(o => o.OrderItems)
|
.Include(o => o.OrderItems.Where(i => i.IsActive))
|
||||||
.ThenInclude(i => i.Item)
|
.ThenInclude(i => i.Item)
|
||||||
.Where(o => o.IsActive
|
.Where(o => o.IsActive
|
||||||
&& o.Store.FK_RouteId == routeId
|
&& o.Store.FK_RouteId == routeId
|
||||||
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
|
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId) =>
|
||||||
|
await context.Orders
|
||||||
|
.Include(o => o.Store)
|
||||||
|
.Where(o => o.IsActive
|
||||||
|
&& o.ConfirmedAt != null
|
||||||
|
&& o.Status != OrderStatus.Cancelled
|
||||||
|
&& (agentId == null || o.CreatedByUserId == agentId)
|
||||||
|
&& (routeId == null || o.Store.FK_RouteId == routeId)
|
||||||
|
&& (storeId == null || o.FK_StoreId == storeId))
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Distribution.Features.Sale;
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
@@ -16,19 +18,30 @@ public class OrderService(
|
|||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
ISaleRepository saleRepo,
|
ISaleRepository saleRepo,
|
||||||
|
IInvoiceRepository invoiceRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IScopeAccessService scopeAccess)
|
IScopeAccessService scopeAccess,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
|
|
||||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||||
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
|
new(o.Id, o.FK_SaleId, o.InvoiceId, o.OrderDate, o.FK_StoreId, o.CreatedByUserId, o.TotalAmount, o.Status,
|
||||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
o.ConfirmedAt, o.FulfilledAt,
|
||||||
|
items.Select(i => new OrderItemResponse(i.Id, i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||||
o.IsActive, o.CreatedAt);
|
o.IsActive, o.CreatedAt);
|
||||||
|
|
||||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
|
|
||||||
|
/// <summary>The active InvoiceCreationMode ("A" = combined confirm+create-invoice,
|
||||||
|
/// "B" = separate confirm then create-invoice) per order_invoice_lifecycle.md. Exactly one is
|
||||||
|
/// reachable at a time.</summary>
|
||||||
|
private InvoiceCreationMode ActiveMode =>
|
||||||
|
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") == "A"
|
||||||
|
? InvoiceCreationMode.A
|
||||||
|
: InvoiceCreationMode.B;
|
||||||
|
|
||||||
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
||||||
{
|
{
|
||||||
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
||||||
@@ -76,17 +89,206 @@ public class OrderService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Confirmed POs matching the given filters — Mode B's "office browses Confirmed POs"
|
||||||
|
/// step. Also usable as a general Confirmed-PO listing regardless of active mode.</summary>
|
||||||
|
public async Task<List<OrderResponse>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId)
|
||||||
|
{
|
||||||
|
var orders = await repo.GetConfirmedAsync(agentId, routeId, storeId);
|
||||||
|
var result = new List<OrderResponse>();
|
||||||
|
foreach (var order in orders)
|
||||||
|
{
|
||||||
|
var items = await repo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
result.Add(ToResponse(order, items));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<decimal> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Guid storeId, DateTime orderDate)
|
||||||
|
{
|
||||||
|
if (requestedUnitPrice.HasValue) return requestedUnitPrice.Value;
|
||||||
|
var resolution = await priceListService.ResolveByStoreAsync(itemId, storeId, orderDate);
|
||||||
|
if (resolution is not null) return resolution.Price;
|
||||||
|
var item = await itemRepo.GetByIdAsync(itemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {itemId} not found");
|
||||||
|
return item.SalePrice
|
||||||
|
?? throw new InvalidOperationException($"Could not resolve a price for item {itemId} — no price list covers it and it has no catalogue SalePrice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a Shop PO independent of any Sale — the entry point to the pipeline per
|
||||||
|
/// order_invoice_lifecycle.md. Items are freely editable until Confirm.</summary>
|
||||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var sale = await saleRepo.GetByIdAsync(request.SaleId);
|
var store = await storeRepo.GetByIdAsync(request.StoreId);
|
||||||
if (sale is null || !sale.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A PO must have at least one line item.");
|
||||||
|
|
||||||
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
var order = new Models.Order
|
||||||
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
{
|
||||||
|
FK_StoreId = request.StoreId,
|
||||||
|
OrderDate = DateTime.Now,
|
||||||
|
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
|
||||||
|
Status = OrderStatus.Pending,
|
||||||
|
};
|
||||||
|
|
||||||
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
var items = new List<Models.OrderItem>();
|
||||||
return ToResponse(existingOrder, items);
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, request.StoreId, order.OrderDate);
|
||||||
|
items.Add(new Models.OrderItem
|
||||||
|
{
|
||||||
|
FK_OrderId = order.Id,
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
order.TotalAmount = items.Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(order);
|
||||||
|
await repo.AddItemsAsync(items);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToResponse(order, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rewrites a Shop PO's line items — every edit soft-deletes the current active rows
|
||||||
|
/// and writes a fresh set (active = latest version, soft-deleted = history). Only allowed before
|
||||||
|
/// confirmation.</summary>
|
||||||
|
public async Task<OrderResponse?> ReplaceItemsAsync(Guid id, UpdateOrderItemsRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var order = await repo.GetByIdAsync(id);
|
||||||
|
if (order is null || !order.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||||
|
if (order.ConfirmedAt is not null)
|
||||||
|
throw new InvalidOperationException("Items are locked once the PO is confirmed.");
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A PO must have at least one line item.");
|
||||||
|
|
||||||
|
var currentItems = await repo.GetItemsByOrderIdAsync(id);
|
||||||
|
foreach (var item in currentItems)
|
||||||
|
item.IsActive = false;
|
||||||
|
|
||||||
|
var newItems = new List<Models.OrderItem>();
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, order.FK_StoreId, order.OrderDate);
|
||||||
|
newItems.Add(new Models.OrderItem
|
||||||
|
{
|
||||||
|
FK_OrderId = order.Id,
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
order.TotalAmount = newItems.Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// currentItems came from the tracked DbContext (OrderRepository does not use
|
||||||
|
// AsNoTracking), so flipping IsActive above is enough for SaveChanges to persist it —
|
||||||
|
// no explicit repo.Update call needed for the soft-deleted rows.
|
||||||
|
await repo.AddItemsAsync(newItems);
|
||||||
|
repo.Update(order);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToResponse(order, newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mode B — confirms the PO (locks items) without creating an Invoice. The Invoice is
|
||||||
|
/// created separately later via InvoiceService.CreateAsync.</summary>
|
||||||
|
public async Task<OrderResponse?> ConfirmAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ActiveMode != InvoiceCreationMode.B)
|
||||||
|
throw new InvalidOperationException("Separate confirm is not enabled — this system uses InvoiceCreationMode A.");
|
||||||
|
|
||||||
|
var order = await repo.GetByIdAsync(id);
|
||||||
|
if (order is null || !order.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||||
|
if (order.Status == OrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot confirm a cancelled PO.");
|
||||||
|
if (order.ConfirmedAt is not null) return ToResponse(order, await repo.GetItemsByOrderIdAsync(id));
|
||||||
|
|
||||||
|
order.ConfirmedAt = DateTime.Now;
|
||||||
|
order.ConfirmedByUserId = currentUser.UserId;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(order);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
|
return ToResponse(order, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mode A — confirms the PO and creates its Invoice (Draft) atomically in one
|
||||||
|
/// transaction.</summary>
|
||||||
|
public async Task<OrderResponse?> ConfirmAndCreateInvoiceAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ActiveMode != InvoiceCreationMode.A)
|
||||||
|
throw new InvalidOperationException("Combined confirm-and-create-invoice is not enabled — this system uses InvoiceCreationMode B.");
|
||||||
|
|
||||||
|
var order = await repo.GetByIdAsync(id);
|
||||||
|
if (order is null || !order.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||||
|
if (order.Status == OrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot confirm a cancelled PO.");
|
||||||
|
if (order.InvoiceId is not null)
|
||||||
|
throw new InvalidOperationException("An Invoice already exists for this PO.");
|
||||||
|
|
||||||
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
|
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.ConfirmedAt ??= DateTime.Now;
|
||||||
|
order.ConfirmedByUserId ??= currentUser.UserId;
|
||||||
|
order.InvoiceId = invoice.Id;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await invoiceRepo.AddAsync(invoice);
|
||||||
|
repo.Update(order);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToResponse(order, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||||
@@ -96,12 +298,13 @@ public class OrderService(
|
|||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return null;
|
if (order is null || !order.IsActive) return null;
|
||||||
|
|
||||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
|
||||||
?? throw new InvalidOperationException("Sale not found for this order");
|
|
||||||
|
|
||||||
order.Status = request.Status;
|
order.Status = request.Status;
|
||||||
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||||
|
|
||||||
|
if (order.FK_SaleId is Guid saleId)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(saleId)
|
||||||
|
?? throw new InvalidOperationException("Sale not found for this order");
|
||||||
sale.Status = request.Status switch
|
sale.Status = request.Status switch
|
||||||
{
|
{
|
||||||
OrderStatus.Fulfilled => SaleStatus.Completed,
|
OrderStatus.Fulfilled => SaleStatus.Completed,
|
||||||
@@ -122,11 +325,29 @@ public class OrderService(
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(order);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
return ToResponse(order, items);
|
return ToResponse(order, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels a Shop PO or Confirmed PO. If an Invoice was already created for this PO,
|
||||||
|
/// the Invoice/loading-sheet cascade is an explicitly unresolved open question in
|
||||||
|
/// order_invoice_lifecycle.md (Open Question 5) — the Invoice is left untouched here.</summary>
|
||||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
|
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
|
||||||
@@ -134,7 +355,7 @@ public class OrderService(
|
|||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return false;
|
if (order is null || !order.IsActive) return false;
|
||||||
|
|
||||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
|
var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ public class ReturnService(
|
|||||||
if (request.Items is null || request.Items.Count == 0)
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
throw new InvalidOperationException("A return must have at least one line item.");
|
throw new InvalidOperationException("A return must have at least one line item.");
|
||||||
|
|
||||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
if (order.FK_SaleId is null)
|
||||||
|
throw new InvalidOperationException("Sale not found for this order.");
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId.Value)
|
||||||
?? throw new InvalidOperationException("Sale not found for this order.");
|
?? throw new InvalidOperationException("Sale not found for this order.");
|
||||||
if (sale.FK_RideId is null)
|
if (sale.FK_RideId is null)
|
||||||
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ public class SaleService(
|
|||||||
FK_SaleId = sale.Id,
|
FK_SaleId = sale.Id,
|
||||||
FK_StoreId = request.StoreId,
|
FK_StoreId = request.StoreId,
|
||||||
OrderDate = request.SaleDate,
|
OrderDate = request.SaleDate,
|
||||||
|
CreatedByUserId = userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var orderItem in orderItems)
|
foreach (var orderItem in orderItems)
|
||||||
|
|||||||
+4175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOrderInvoiceLifecycle : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Must run before DropPrimaryKey below: OrderItems.FK_OrderId has a foreign key to
|
||||||
|
// Orders, and MySQL requires a supporting index on that column at all times. The old
|
||||||
|
// composite PK (FK_OrderId, FK_ItemId) was providing that support; without this index
|
||||||
|
// created first, dropping the PK leaves FK_OrderId's constraint without an index and
|
||||||
|
// MySQL rejects the DROP PRIMARY KEY (errno 150).
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OrderItems_FK_OrderId",
|
||||||
|
table: "OrderItems",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_OrderItems",
|
||||||
|
table: "OrderItems");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||||
|
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||||
|
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||||
|
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)")
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ConfirmedAt",
|
||||||
|
table: "Orders",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ConfirmedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CreatedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
// Backfill every existing Order's new CreatedByUserId from its linked Sale (Order.FK_SaleId
|
||||||
|
// was required until this migration, so every pre-existing row has one). Covers every
|
||||||
|
// row generically, not just the two rows seeded via HasData below — otherwise any Order
|
||||||
|
// created outside the EF seed data would keep the temporary AddColumn default
|
||||||
|
// (Guid.Empty) and fail the FK_Orders_Users_CreatedByUserId constraint added at the end
|
||||||
|
// of this migration.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE `Orders` o
|
||||||
|
INNER JOIN `Sales` s ON s.`Id` = o.`FK_SaleId`
|
||||||
|
SET o.`CreatedByUserId` = s.`CreatedByUserId`
|
||||||
|
WHERE o.`FK_SaleId` IS NOT NULL;");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "InvoiceId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true,
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_OrderItems",
|
||||||
|
table: "OrderItems",
|
||||||
|
column: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Invoices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
PrintedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
FinalizedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Invoices", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Invoices_Orders_FK_OrderId",
|
||||||
|
column: x => x.FK_OrderId,
|
||||||
|
principalTable: "Orders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Invoices_Users_CreatedByUserId",
|
||||||
|
column: x => x.CreatedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Invoices_Users_FinalizedByUserId",
|
||||||
|
column: x => x.FinalizedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Invoices_Users_PrintedByUserId",
|
||||||
|
column: x => x.PrintedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "OrderItems",
|
||||||
|
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_OrderId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("34000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
|
||||||
|
{ new Guid("34000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), true, 24, 24, 50m },
|
||||||
|
{ new Guid("34000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), true, 10, 0, 120m }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Orders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
|
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
|
||||||
|
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000002"), null });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "Orders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("33000000-0000-0000-0000-000000000002"),
|
||||||
|
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
|
||||||
|
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000003"), null });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Orders_ConfirmedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "ConfirmedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Orders_CreatedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Invoices_CreatedByUserId",
|
||||||
|
table: "Invoices",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Invoices_FinalizedByUserId",
|
||||||
|
table: "Invoices",
|
||||||
|
column: "FinalizedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Invoices_FK_OrderId",
|
||||||
|
table: "Invoices",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Invoices_PrintedByUserId",
|
||||||
|
table: "Invoices",
|
||||||
|
column: "PrintedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Orders_Users_ConfirmedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "ConfirmedByUserId",
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Orders_Users_CreatedByUserId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "CreatedByUserId",
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Orders_Users_ConfirmedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Orders_Users_CreatedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Invoices");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Orders_ConfirmedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Orders_CreatedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_OrderItems",
|
||||||
|
table: "OrderItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_OrderItems_FK_OrderId",
|
||||||
|
table: "OrderItems");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34000000-0000-0000-0000-000000000002"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "OrderItems",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("34000000-0000-0000-0000-000000000003"));
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ConfirmedAt",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ConfirmedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedByUserId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "InvoiceId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci",
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "char(36)",
|
||||||
|
oldNullable: true)
|
||||||
|
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_OrderItems",
|
||||||
|
table: "OrderItems",
|
||||||
|
columns: new[] { "FK_OrderId", "FK_ItemId" });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "OrderItems",
|
||||||
|
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
|
||||||
|
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
|
||||||
|
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -536,6 +536,55 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_OrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinalizedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FinalizedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PrintedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PrintedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_OrderId");
|
||||||
|
|
||||||
|
b.HasIndex("FinalizedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("PrintedByUserId");
|
||||||
|
|
||||||
|
b.ToTable("Invoices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -768,10 +817,19 @@ namespace Distribution.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ConfirmedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ConfirmedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_SaleId")
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_SaleId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_StoreId")
|
b.Property<Guid>("FK_StoreId")
|
||||||
@@ -780,6 +838,10 @@ namespace Distribution.Migrations
|
|||||||
b.Property<DateTime?>("FulfilledAt")
|
b.Property<DateTime?>("FulfilledAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InvoiceId")
|
||||||
|
.HasColumnType("char(36)")
|
||||||
|
.HasColumnName("InvoiceId");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
@@ -794,6 +856,10 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConfirmedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
b.HasIndex("FK_SaleId")
|
b.HasIndex("FK_SaleId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -806,6 +872,7 @@ namespace Distribution.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("33000000-0000-0000-0000-000000000001"),
|
Id = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000002"),
|
||||||
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000001"),
|
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000001"),
|
||||||
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000001"),
|
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000001"),
|
||||||
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified),
|
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
@@ -818,6 +885,7 @@ namespace Distribution.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("33000000-0000-0000-0000-000000000002"),
|
Id = new Guid("33000000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000003"),
|
||||||
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000002"),
|
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000002"),
|
||||||
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000005"),
|
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000005"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -829,16 +897,17 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.OrderItem", b =>
|
modelBuilder.Entity("Distribution.Models.OrderItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("FK_OrderId")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("char(36)");
|
.ValueGeneratedOnAdd()
|
||||||
|
|
||||||
b.Property<Guid>("FK_ItemId")
|
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_OrderId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
@@ -853,19 +922,21 @@ namespace Distribution.Migrations
|
|||||||
b.Property<decimal>("UnitPrice")
|
b.Property<decimal>("UnitPrice")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
b.HasKey("FK_OrderId", "FK_ItemId");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FK_ItemId");
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_OrderId");
|
||||||
|
|
||||||
b.ToTable("OrderItems");
|
b.ToTable("OrderItems");
|
||||||
|
|
||||||
b.HasData(
|
b.HasData(
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
|
|
||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("34000000-0000-0000-0000-000000000001"),
|
Id = new Guid("34000000-0000-0000-0000-000000000001"),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||||
|
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Quantity = 30,
|
Quantity = 30,
|
||||||
QuantityFulfilled = 30,
|
QuantityFulfilled = 30,
|
||||||
@@ -873,10 +944,10 @@ namespace Distribution.Migrations
|
|||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
|
|
||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("34000000-0000-0000-0000-000000000002"),
|
Id = new Guid("34000000-0000-0000-0000-000000000002"),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||||
|
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Quantity = 24,
|
Quantity = 24,
|
||||||
QuantityFulfilled = 24,
|
QuantityFulfilled = 24,
|
||||||
@@ -884,10 +955,10 @@ namespace Distribution.Migrations
|
|||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
|
|
||||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
Id = new Guid("34000000-0000-0000-0000-000000000003"),
|
Id = new Guid("34000000-0000-0000-0000-000000000003"),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||||
|
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Quantity = 10,
|
Quantity = 10,
|
||||||
QuantityFulfilled = 0,
|
QuantityFulfilled = 0,
|
||||||
@@ -3300,6 +3371,39 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("PurchaseOrderItem");
|
b.Navigation("PurchaseOrderItem");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "CreatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Order", "Order")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_OrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "FinalizedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FinalizedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "PrintedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PrintedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("FinalizedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Order");
|
||||||
|
|
||||||
|
b.Navigation("PrintedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
||||||
@@ -3371,11 +3475,21 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Order", b =>
|
modelBuilder.Entity("Distribution.Models.Order", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "ConfirmedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ConfirmedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "CreatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Sale", "Sale")
|
b.HasOne("Distribution.Models.Sale", "Sale")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_SaleId")
|
.HasForeignKey("FK_SaleId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Store", "Store")
|
b.HasOne("Distribution.Models.Store", "Store")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -3383,6 +3497,10 @@ namespace Distribution.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ConfirmedByUser");
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
b.Navigation("Sale");
|
b.Navigation("Sale");
|
||||||
|
|
||||||
b.Navigation("Store");
|
b.Navigation("Store");
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ namespace Distribution.Models
|
|||||||
|
|
||||||
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
|
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||||
|
|
||||||
|
public enum InvoiceCreationMode { A, B }
|
||||||
|
|
||||||
|
public enum DiscountType { Amount, Percentage }
|
||||||
|
|
||||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
public enum PaymentMethod { Cash, Credit }
|
public enum PaymentMethod { Cash, Credit }
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class Invoice : Template
|
||||||
|
{
|
||||||
|
public Guid FK_OrderId { get; set; }
|
||||||
|
[ForeignKey("FK_OrderId")]
|
||||||
|
public Order Order { get; set; }
|
||||||
|
|
||||||
|
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
|
||||||
|
|
||||||
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
[ForeignKey("CreatedByUserId")]
|
||||||
|
public User CreatedByUser { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PrintedAt { get; set; }
|
||||||
|
public Guid? PrintedByUserId { get; set; }
|
||||||
|
[ForeignKey("PrintedByUserId")]
|
||||||
|
public User? PrintedByUser { get; set; }
|
||||||
|
|
||||||
|
public DateTime? FinalizedAt { get; set; }
|
||||||
|
public Guid? FinalizedByUserId { get; set; }
|
||||||
|
[ForeignKey("FinalizedByUserId")]
|
||||||
|
public User? FinalizedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reflects current PO line items through Draft/Printed; locked at Finalized.</summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Distribution.Models
|
namespace Distribution.Models
|
||||||
{
|
{
|
||||||
public class Order : Template
|
public class Order : Template
|
||||||
{
|
{
|
||||||
public Guid FK_SaleId { get; set; }
|
public Guid? FK_SaleId { get; set; }
|
||||||
[ForeignKey("FK_SaleId")]
|
[ForeignKey("FK_SaleId")]
|
||||||
public Sale Sale { get; set; }
|
public Sale? Sale { get; set; }
|
||||||
|
|
||||||
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
[ForeignKey("CreatedByUserId")]
|
||||||
|
public User CreatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set once an Invoice is created for this PO (either mode). No DB-level FK
|
||||||
|
/// constraint — configured explicitly in Fluent API per order_invoice_lifecycle.md.</summary>
|
||||||
|
public Guid? InvoiceId { get; set; }
|
||||||
|
|
||||||
public DateTime OrderDate { get; set; }
|
public DateTime OrderDate { get; set; }
|
||||||
public Guid FK_StoreId { get; set; }
|
public Guid FK_StoreId { get; set; }
|
||||||
@@ -16,6 +24,15 @@ namespace Distribution.Models
|
|||||||
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
||||||
public DateTime? FulfilledAt { get; set; }
|
public DateTime? FulfilledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null = Shop PO (items editable). Set = Confirmed PO (items locked). Not modeled
|
||||||
|
/// as an explicit field in order_invoice_lifecycle.md's diagram, but required to implement
|
||||||
|
/// its Business Rule 1 (item edits gated on confirmation) independent of Invoice existing —
|
||||||
|
/// Mode B confirms a PO with no Invoice at all.</summary>
|
||||||
|
public DateTime? ConfirmedAt { get; set; }
|
||||||
|
public Guid? ConfirmedByUserId { get; set; }
|
||||||
|
[ForeignKey("ConfirmedByUserId")]
|
||||||
|
public User? ConfirmedByUser { get; set; }
|
||||||
|
|
||||||
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
|
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Distribution.Features.DriverComplianceConfig;
|
|||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.Order;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
@@ -79,6 +80,7 @@ builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
|
|||||||
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
||||||
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
||||||
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||||
|
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
|
||||||
|
|
||||||
builder.Services.AddScoped<RegionService>();
|
builder.Services.AddScoped<RegionService>();
|
||||||
builder.Services.AddScoped<RouteService>();
|
builder.Services.AddScoped<RouteService>();
|
||||||
@@ -112,6 +114,7 @@ builder.Services.AddScoped<ClaimService>();
|
|||||||
builder.Services.AddScoped<SupplierClaimService>();
|
builder.Services.AddScoped<SupplierClaimService>();
|
||||||
builder.Services.AddScoped<DeliveryTaskService>();
|
builder.Services.AddScoped<DeliveryTaskService>();
|
||||||
builder.Services.AddScoped<PaymentService>();
|
builder.Services.AddScoped<PaymentService>();
|
||||||
|
builder.Services.AddScoped<InvoiceService>();
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
"StoreManagement": {
|
"StoreManagement": {
|
||||||
"UnverifiedKycOrderThreshold": 0
|
"UnverifiedKycOrderThreshold": 0
|
||||||
},
|
},
|
||||||
|
"OrderInvoice": {
|
||||||
|
"InvoiceCreationMode": "B"
|
||||||
|
},
|
||||||
"AuthService": {
|
"AuthService": {
|
||||||
"BaseUrl": ""
|
"BaseUrl": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ enters any edits the agent made into the system and submits.
|
|||||||
Confirmed PO that already has a linked Invoice: should the Invoice be voided automatically?
|
Confirmed PO that already has a linked Invoice: should the Invoice be voided automatically?
|
||||||
If the PO's items were already pulled into a loading sheet: what happens to those loading sheet
|
If the PO's items were already pulled into a loading sheet: what happens to those loading sheet
|
||||||
items? Cascade rules needed before implementation.
|
items? Cascade rules needed before implementation.
|
||||||
4. **Exact adjustment payload for `POST /api/invoices/{id}/finalize`** — the fields the office
|
4. ~~**Exact adjustment payload for `POST /api/invoices/{id}/finalize`**~~ — **Resolved**, see
|
||||||
worker can edit (returns, damage, discounts) are assumed but not fully specified. "Free issues"
|
[order_invoice_lifecycle.md Open Question 6](./order_invoice_lifecycle.md#open-questions):
|
||||||
handling (items given to the shop at no charge) is a known case needing its own payload field(s).
|
`items[]` (`deliveredQuantity` + `freeQuantity` per line), `returns[]` (reusing the
|
||||||
|
`SaleReturn`/`CreditNote` shape from `18_returns_claims.md`, `Damaged` as a `ReasonCode` rather
|
||||||
|
than a separate field), and a single invoice-level `discount`.
|
||||||
5. **Stage 3 and Stage 4 ordering** — Invoice printing (Stage 3) and loading sheet population
|
5. **Stage 3 and Stage 4 ordering** — Invoice printing (Stage 3) and loading sheet population
|
||||||
(Stage 4) are independent flows. They are not strictly sequential — both depend on a Confirmed
|
(Stage 4) are independent flows. They are not strictly sequential — both depend on a Confirmed
|
||||||
PO existing but neither requires the other to complete first.
|
PO existing but neither requires the other to complete first.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Status**: Proposed (design only — no code changes yet)
|
**Status**: Proposed (design only — no code changes yet)
|
||||||
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md) — this document only applies
|
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md) — this document only applies
|
||||||
once an `Invoice` reaches `Finalized`; it does not touch Pending/Draft.
|
once an `Invoice` reaches `Finalized`; it does not touch Draft/Printed.
|
||||||
**Refactors**: `Models/Payment.cs`, `Models/Enums.cs` (`PaymentMethod`, `PaymentStatus`),
|
**Refactors**: `Models/Payment.cs`, `Models/Enums.cs` (`PaymentMethod`, `PaymentStatus`),
|
||||||
`Sale.PaymentStatus`, and the payment recording flow in
|
`Sale.PaymentStatus`, and the payment recording flow in
|
||||||
[06_sale_ride.md](../06_sale_ride.md) (`POST /api/sales/{saleId}/payments`). Also resolves
|
[06_sale_ride.md](../06_sale_ride.md) (`POST /api/sales/{saleId}/payments`). Also resolves
|
||||||
@@ -107,13 +107,13 @@ audit and for spotting collectors who never enter anything in real time.
|
|||||||
|
|
||||||
```
|
```
|
||||||
Invoice
|
Invoice
|
||||||
├── ... (existing fields from order_invoice_lifecycle.md: Status, ConfirmedAt, FinalizedAt, ...)
|
├── ... (existing fields from order_invoice_lifecycle.md: Status, PrintedAt, PrintedBy, FinalizedAt, FinalizedBy, ...)
|
||||||
├── PaymentStatus (PaymentStatus) — NEW: Pending, PartiallyPaid, Paid — mirrors the existing
|
├── PaymentStatus (PaymentStatus) — NEW: Pending, PartiallyPaid, Paid — mirrors the existing
|
||||||
│ Sale.PaymentStatus pattern from 06_sale_ride.md, recomputed on
|
│ Sale.PaymentStatus pattern from 06_sale_ride.md, recomputed on
|
||||||
│ every Payment insert (see Business Rules)
|
│ every Payment insert (see Business Rules)
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a deliberate second axis alongside `Invoice.Status` (`Pending/Draft/Finalized`, the document
|
This is a deliberate second axis alongside `Invoice.Status` (`Draft/Printed/Finalized`, the document
|
||||||
lifecycle) — `PaymentStatus` only starts being meaningful once `Status = Finalized`, and answers
|
lifecycle) — `PaymentStatus` only starts being meaningful once `Status = Finalized`, and answers
|
||||||
`order_invoice_lifecycle.md`'s Open Question 3 (whether the two axes should merge): they stay
|
`order_invoice_lifecycle.md`'s Open Question 3 (whether the two axes should merge): they stay
|
||||||
separate. `PaymentStatus` is the same axis `19_finance_accounting.md` proposed baking into
|
separate. `PaymentStatus` is the same axis `19_finance_accounting.md` proposed baking into
|
||||||
@@ -130,7 +130,7 @@ has a linked Order/Invoice — see Impact section.
|
|||||||
|
|
||||||
1. A `Payment` can only be recorded against an `Invoice` whose `Status = Finalized` — matches
|
1. A `Payment` can only be recorded against an `Invoice` whose `Status = Finalized` — matches
|
||||||
`order_invoice_lifecycle.md`'s framing that Finalize is "the record used for day-end calculations."
|
`order_invoice_lifecycle.md`'s framing that Finalize is "the record used for day-end calculations."
|
||||||
Attempting to record a `Payment` against a Pending/Draft invoice is rejected (409).
|
Attempting to record a `Payment` against a Draft/Printed invoice is rejected (409).
|
||||||
2. Multiple `Payment` rows are allowed per `Invoice` — collection is not required to happen in a
|
2. Multiple `Payment` rows are allowed per `Invoice` — collection is not required to happen in a
|
||||||
single action. Each row has exactly one `Method`; a mixed cash+cheque collection on one visit is
|
single action. Each row has exactly one `Method`; a mixed cash+cheque collection on one visit is
|
||||||
two rows with the same `CollectedAt`/visit context, not one split row.
|
two rows with the same `CollectedAt`/visit context, not one split row.
|
||||||
|
|||||||
@@ -1,12 +1,89 @@
|
|||||||
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
|
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
|
||||||
|
|
||||||
**Status**: Proposed (design only — no code changes yet)
|
**Status**: Partially implemented — schema, API, and DB migration live on branch
|
||||||
|
`feat/order-invoice-lifecycle`. Frontend, `LoadingSheet` wiring, and the cancellation cascade
|
||||||
|
(Open Question 5) are not done. See [Implementation Status](#implementation-status) below for the
|
||||||
|
as-built schema, which differs in a few small ways from the original design further down this
|
||||||
|
document.
|
||||||
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
|
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
|
||||||
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
|
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
|
||||||
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
|
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Everything below reflects what is actually in the database and codebase right now, not just the
|
||||||
|
plan. The rest of this document (Data Model Changes onward) is kept as the original design record —
|
||||||
|
where the two disagree, this section wins.
|
||||||
|
|
||||||
|
### `Orders` (existing table, altered)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `FK_SaleId` | `char(36)`, nullable | Changed from required to nullable, as designed. |
|
||||||
|
| `CreatedByUserId` | `char(36)`, required | **Added — not in the original diagram below.** The
|
||||||
|
original design had no way to know who created a PO or to answer the `agentId` filter Mode B's own
|
||||||
|
endpoints require once a PO can exist without a Sale. Backfilled for every pre-existing row from its
|
||||||
|
linked Sale's `CreatedByUserId`. FK → `Users`. |
|
||||||
|
| `ConfirmedAt` | `datetime`, nullable | **Added — not in the original diagram below.** The design
|
||||||
|
never named a field for "is this PO confirmed" independent of an Invoice existing (Mode B confirms
|
||||||
|
with no Invoice at all), so this was added to actually gate item-editing per Business Rule 1. Null =
|
||||||
|
Shop PO, set = Confirmed PO. |
|
||||||
|
| `ConfirmedByUserId` | `char(36)`, nullable | **Added**, alongside `ConfirmedAt`. FK → `Users`. |
|
||||||
|
| `InvoiceId` | `char(36)`, nullable | As designed — plain column, no DB-level FK constraint. |
|
||||||
|
|
||||||
|
### `OrderItems` (existing table, altered)
|
||||||
|
|
||||||
|
| Change | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Primary key | Changed from the composite `(FK_OrderId, FK_ItemId)` to the single inherited `Id`
|
||||||
|
column. **Required for versioning to work at all** — the original composite key made it impossible
|
||||||
|
for two rows to exist for the same PO+item (one soft-deleted, one active), which item-editing needs.
|
||||||
|
Not called out explicitly in the original design below. |
|
||||||
|
| `IsActive` | Already existed via the `Template` base class; now actually used to mark old versions
|
||||||
|
inactive on every edit. |
|
||||||
|
| New index | `IX_OrderItems_FK_OrderId` (standalone — previously implied by the composite key). |
|
||||||
|
|
||||||
|
### `Invoices` (new table)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Id` | `char(36)` | PK |
|
||||||
|
| `FK_OrderId` | `char(36)`, required | FK → `Orders` |
|
||||||
|
| `Status` | `int` (`InvoiceStatus`) | `Draft` / `Printed` / `Finalized` |
|
||||||
|
| `CreatedByUserId` | `char(36)`, required | FK → `Users`. Named `CreatedByUserId` in code, not
|
||||||
|
`CreatedBy` as in the diagram below — matches this codebase's existing naming convention
|
||||||
|
(`Sale.CreatedByUserId`, etc.). |
|
||||||
|
| `PrintedAt` | `datetime`, nullable | |
|
||||||
|
| `PrintedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
|
||||||
|
| `FinalizedAt` | `datetime`, nullable | |
|
||||||
|
| `FinalizedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
|
||||||
|
| `TotalAmount` | `decimal` | |
|
||||||
|
| `IsActive`, `CreatedAt` | from `Template` | |
|
||||||
|
|
||||||
|
### API endpoints implemented
|
||||||
|
|
||||||
|
`POST /api/orders` (independent PO creation, replacing the old "created from Sale" behaviour),
|
||||||
|
`PUT /api/orders/{id}/items` (versioned rewrite), `POST /api/orders/{id}/confirm` (Mode B),
|
||||||
|
`POST /api/orders/{id}/confirm-and-create-invoice` (Mode A), `GET /api/orders/confirmed`
|
||||||
|
(filtered by `agentId`/`routeId`/`shopId`), `POST /api/orders/{id}/cancel`; `POST /api/invoices`
|
||||||
|
(Mode B), `POST /api/invoices/{id}/print`, `POST /api/invoices/{id}/finalize` (using the resolved
|
||||||
|
payload from Open Question 6 below). Both modes exist side by side, gated at runtime by the
|
||||||
|
`OrderInvoice:InvoiceCreationMode` config flag (`appsettings.json`, defaulted to `"B"`).
|
||||||
|
|
||||||
|
### Not implemented yet
|
||||||
|
|
||||||
|
- Frontend (no UI changes made).
|
||||||
|
- `LoadingSheet` population still needs its own wiring per [loading_event.md](./loading_event.md) —
|
||||||
|
untouched by this pass.
|
||||||
|
- Cancellation cascade to `Invoice`/`LoadingSheet` (Open Question 5) — still unresolved; cancelling a
|
||||||
|
Confirmed PO with a linked Invoice today leaves the Invoice untouched.
|
||||||
|
- `Payment`/`Invoice` wiring from [invoice_collection.md](./invoice_collection.md) — separate doc,
|
||||||
|
not started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
|
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
|
||||||
@@ -201,9 +278,38 @@ Draft → Printed → Finalized
|
|||||||
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
||||||
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
||||||
also unresolved. Both cases need explicit cascade rules before implementation.
|
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
6. ~~**Exact adjustment payload for `POST /finalize`**~~ — **Resolved:**
|
||||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
|
||||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
```
|
||||||
|
POST /api/invoices/{id}/finalize
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "orderItemId": Guid, "deliveredQuantity": decimal, "freeQuantity": decimal }
|
||||||
|
],
|
||||||
|
"returns": [
|
||||||
|
{ "itemId": Guid, "quantity": decimal, "reasonCode": ReturnReasonCode }
|
||||||
|
],
|
||||||
|
"discount": { "type": "Amount" | "Percentage", "value": decimal, "reason": string? } | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `items[].deliveredQuantity` — what the shop actually received per line, replacing the ordered
|
||||||
|
`OrderItem.Quantity` for billing purposes. Captures shortages/quantity edits the agent
|
||||||
|
handwrote on the printed copy.
|
||||||
|
- `items[].freeQuantity` — **the free-issue field this document previously flagged as missing.**
|
||||||
|
Units given to the shop at no charge on top of `deliveredQuantity`. Contributes `0` to
|
||||||
|
`Invoice.TotalAmount` but is still recorded per line for stock/audit — it moves stock the same
|
||||||
|
as a sold unit, it just isn't billed.
|
||||||
|
- `returns` — **reuses the existing `SaleReturn`/`SaleReturnItem`/`CreditNote` shape from
|
||||||
|
[18_returns_claims.md](../18_returns_claims.md) instead of inventing a parallel mechanism.**
|
||||||
|
Finalize creates a `SaleReturn` (`Mode = CreditDeduction`) keyed to the PO's `Order` with one
|
||||||
|
`SaleReturnItem` per entry, which produces a `CreditNote` the normal way. "Damage" is not a
|
||||||
|
separate field — it's `ReasonCode = Damaged` on a return line, since `ReturnReasonCode` already
|
||||||
|
covers it (`Expired, Damaged, OrderError, Other`).
|
||||||
|
- `discount` — a single amount-or-percentage adjustment applied to the invoice subtotal, with an
|
||||||
|
optional free-text reason for audit. Not per-line; one discount per invoice for v1.
|
||||||
|
- `Invoice.TotalAmount` at Finalize = `Σ(items[].deliveredQuantity × OrderItem.UnitPrice)`
|
||||||
|
− discount − `Σ(CreditNote.Amount from returns)`. `freeQuantity` never enters this sum.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user