Merge pull request 'Dev' (#57) from dev into production

Reviewed-on: #57
This commit was merged in pull request #57.
This commit is contained in:
2026-10-08 07:36:24 +00:00
42 changed files with 15821 additions and 792 deletions
+2 -1
View File
@@ -32,7 +32,8 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" }); if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try try
{ {
var inner = new CreateGrnRequest(request.WarehouseId, request.Items); var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct); var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
+48 -6
View File
@@ -13,20 +13,36 @@ public record GrnItemRequest(
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries. // Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null, List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order). // Set for a direct receipt line (no purchase order).
Guid? ItemId = null); Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest( public record CreateGrnRequest(
Guid WarehouseId, Guid WarehouseId,
List<GrnItemRequest> Items, List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null); Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest( public record CreateFlatGrnRequest(
Guid PurchaseOrderId, Guid PurchaseOrderId,
Guid WarehouseId, Guid WarehouseId,
List<GrnItemRequest> Items, List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null); Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary> /// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest( public record CreateDirectGrnRequest(
@@ -34,7 +50,10 @@ public record CreateDirectGrnRequest(
List<GrnItemRequest> Items, List<GrnItemRequest> Items,
Guid? SupplierId = null, Guid? SupplierId = null,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null); Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity); public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
@@ -47,7 +66,19 @@ public record GrnItemResponse(
int AcceptedQuantity, int AcceptedQuantity,
int RejectedQuantity, int RejectedQuantity,
List<GrnBatchLineResponse> Batches, List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers); List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse( public record GrnResponse(
Guid Id, Guid Id,
@@ -59,4 +90,15 @@ public record GrnResponse(
string? SupplierName, string? SupplierName,
DateTime ReceivedAt, DateTime ReceivedAt,
DateTime CreatedAt, DateTime CreatedAt,
List<GrnItemResponse> Items); List<GrnItemResponse> Items,
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
+37
View File
@@ -0,0 +1,37 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+101 -19
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -11,22 +12,86 @@ public class GrnService(
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow, IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo, Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo) Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
{ {
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{ {
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line. // Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item; var item = i.Item ?? i.PurchaseOrderItem!.Item;
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name, return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity, i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(), i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList()); i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
i.NbtPercent, a.Nbt);
} }
private static GrnResponse Map(Models.GoodsReceipt gr) => /// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty, private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name, gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList()); gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
}
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{ {
@@ -55,9 +120,9 @@ public class GrnService(
{ {
if (item.TracksSerialNumbers) if (item.TracksSerialNumbers)
{ {
if (line.AcceptedQuantity > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != line.AcceptedQuantity)) if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
throw new InvalidOperationException( throw new InvalidOperationException(
$"Item {item.Sku} tracks serial numbers — expected {line.AcceptedQuantity} serial number(s)."); $"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count) if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}."); throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
@@ -68,11 +133,11 @@ public class GrnService(
} }
else if (item.TracksBatches || item.TracksExpiryDate) else if (item.TracksBatches || item.TracksExpiryDate)
{ {
if (line.AcceptedQuantity > 0) if (StockQty(line) > 0)
{ {
if (line.Batches is null || line.Batches.Count == 0) if (line.Batches is null || line.Batches.Count == 0)
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information."); throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
if (line.Batches.Sum(b => b.Quantity) != line.AcceptedQuantity) if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity."); throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
} }
@@ -96,7 +161,8 @@ public class GrnService(
/// <summary> /// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates /// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
/// units don't count against the PO's ordered quantity), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to /// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work. /// PartiallyReceived/Received. All in one unit of work.
/// </summary> /// </summary>
@@ -115,12 +181,18 @@ public class GrnService(
if (request.Items is null || request.Items.Count == 0) if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item."); throw new InvalidOperationException("A goods receipt must have at least one line item.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt var goodsReceipt = new Models.GoodsReceipt
{ {
FK_PurchaseOrderId = po.Id, FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId, FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId, FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -136,7 +208,7 @@ public class GrnService(
if (poItem.IsCancelled) if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock."); throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0) if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative."); throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity) if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
@@ -160,8 +232,10 @@ public class GrnService(
{ {
FK_PurchaseOrderItemId = poItem.Id, FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity, AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity RejectedQuantity = line.RejectedQuantity
}; };
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
goodsReceipt.Items.Add(entity); goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item)); grLines.Add((entity, line, poItem.Item));
} }
@@ -185,13 +259,19 @@ public class GrnService(
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true }) if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found."); throw new InvalidOperationException("Supplier not found.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt var goodsReceipt = new Models.GoodsReceipt
{ {
FK_PurchaseOrderId = null, FK_PurchaseOrderId = null,
FK_SupplierId = request.SupplierId, FK_SupplierId = request.SupplierId,
FK_ReceivedByUserId = receivedByUserId, FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId, FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -206,10 +286,10 @@ public class GrnService(
if (item is null || !item.IsActive) if (item is null || !item.IsActive)
throw new InvalidOperationException($"Item {line.ItemId} not found."); throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0) if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative."); throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity == 0) if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity."); throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
ValidateLineShape(item, line); ValidateLineShape(item, line);
@@ -228,9 +308,11 @@ public class GrnService(
var entity = new Models.GoodsReceiptItem var entity = new Models.GoodsReceiptItem
{ {
FK_ItemId = item.Id, FK_ItemId = item.Id,
FreeQuantity = line.FreeQuantity,
AcceptedQuantity = line.AcceptedQuantity, AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity RejectedQuantity = line.RejectedQuantity
}; };
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity); goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item)); grLines.Add((entity, line, item));
} }
@@ -255,7 +337,7 @@ public class GrnService(
{ {
await repo.AddAsync(goodsReceipt); await repo.AddAsync(goodsReceipt);
foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0)) foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
{ {
if (item.TracksSerialNumbers) if (item.TracksSerialNumbers)
{ {
@@ -369,12 +451,12 @@ public class GrnService(
FK_ItemId = item.Id, FK_ItemId = item.Id,
LocationId = warehouseId, LocationId = warehouseId,
LocationType = LocationType.Warehouse, LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity Quantity = StockQty(line)
}); });
} }
else else
{ {
balance.Quantity += line.AcceptedQuantity; balance.Quantity += StockQty(line);
balance.UpdatedAt = DateTime.Now; balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance); stockRepo.UpdateBalance(balance);
} }
@@ -386,7 +468,7 @@ public class GrnService(
FromLocationType = LocationType.External, FromLocationType = LocationType.External,
ToLocationId = warehouseId, ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse, ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity, Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt, Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id, ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId CreatedBy = receivedByUserId
@@ -28,5 +28,7 @@ public interface IStockRepository
// Serial numbers // Serial numbers
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber); Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
Task AddItemSerialNumberAsync(ItemSerialNumber serial); Task AddItemSerialNumberAsync(ItemSerialNumber serial);
} }
@@ -37,6 +37,14 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result); return Ok(result);
} }
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
[HttpGet("serials/next-sequence")]
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
{
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
return Ok(new { next });
}
[HttpGet("movements")] [HttpGet("movements")]
public async Task<IActionResult> GetMovements( public async Task<IActionResult> GetMovements(
[FromQuery] Guid? locationId, [FromQuery] Guid? locationId,
@@ -107,6 +107,26 @@ public class StockRepository(AppDBContext db) : IStockRepository
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) => public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber); db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
public async Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix)
{
// Only serials of the form <prefix><digits> count; anything else (a supplier's own serial that
// happens to start with the prefix) is ignored for numbering.
var serials = await db.ItemSerialNumbers
.AsNoTracking()
.Where(s => s.FK_ItemId == itemId && s.SerialNumber.StartsWith(prefix))
.Select(s => s.SerialNumber)
.ToListAsync();
var max = 0L;
foreach (var serial in serials)
{
var suffix = serial[prefix.Length..];
if (suffix.Length is > 0 and <= 9 && suffix.All(char.IsDigit) && long.TryParse(suffix, out var n))
max = Math.Max(max, n);
}
return (int)max + 1;
}
public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) => public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) =>
await db.ItemSerialNumbers.AddAsync(serial); await db.ItemSerialNumbers.AddAsync(serial);
} }
@@ -96,6 +96,10 @@ public class StockService(
b.Quantity, b.Item.ReorderLevel!.Value)).ToList(); b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
} }
/// <summary>The next unused sequence number for generating serials like "&lt;prefix&gt;0001" for an item.</summary>
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId) public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{ {
var item = await itemRepo.GetByIdAsync(itemId); var item = await itemRepo.GetByIdAsync(itemId);
@@ -7,3 +7,6 @@ public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Gu
public record SetSysConfigRequest(string Value, Guid? UserId); public record SetSysConfigRequest(string Value, Guid? UserId);
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode); public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
/// <summary>Which taxes apply to a GRN, whether each must be filled in, and the rate new GRNs start with.</summary>
public record GrnTaxSettings(TaxMode VatMode, decimal VatDefaultRate, TaxMode NbtMode, decimal NbtDefaultRate);
@@ -14,6 +14,14 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride"; public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride";
public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays"; public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays";
public const string KeyGrnVatMode = "Grn.Tax.Vat.Mode";
public const string KeyGrnVatRate = "Grn.Tax.Vat.DefaultRate";
public const string KeyGrnNbtMode = "Grn.Tax.Nbt.Mode";
public const string KeyGrnNbtRate = "Grn.Tax.Nbt.DefaultRate";
private static readonly string[] TaxModeKeys = [KeyGrnVatMode, KeyGrnNbtMode];
private static readonly string[] TaxRateKeys = [KeyGrnVatRate, KeyGrnNbtRate];
private static readonly string[] BooleanFeatureKeys = private static readonly string[] BooleanFeatureKeys =
[ [
KeySerialNumberTrackingEnabled, KeySerialNumberTrackingEnabled,
@@ -83,6 +91,33 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
return config is not null && int.TryParse(config.Value, out var days) ? days : 0; return config is not null && int.TryParse(config.Value, out var days) ? days : 0;
} }
/// <summary>
/// GRN tax settings. Unconfigured, VAT is Optional (how GRNs behaved before this setting existed)
/// and NBT is Disabled.
/// </summary>
public async Task<GrnTaxSettings> GetGrnTaxSettingsAsync() => new(
await GetTaxModeAsync(KeyGrnVatMode, TaxMode.Optional),
await GetRateAsync(KeyGrnVatRate),
await GetTaxModeAsync(KeyGrnNbtMode, TaxMode.Disabled),
await GetRateAsync(KeyGrnNbtRate));
private async Task<TaxMode> GetTaxModeAsync(string key, TaxMode fallback)
{
var config = await repo.GetByKeyAsync(key);
return config is not null && Enum.TryParse<TaxMode>(config.Value, out var mode) && Enum.IsDefined(mode)
? mode
: fallback;
}
private async Task<decimal> GetRateAsync(string key)
{
var config = await repo.GetByKeyAsync(key);
return config is not null
&& decimal.TryParse(config.Value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
? rate
: 0m;
}
private async Task<bool> GetBoolAsync(string key) private async Task<bool> GetBoolAsync(string key)
{ {
var config = await repo.GetByKeyAsync(key); var config = await repo.GetByKeyAsync(key);
@@ -98,6 +133,15 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _)) if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false."); throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false.");
if (TaxModeKeys.Contains(key) && (!Enum.TryParse<TaxMode>(value, out var taxMode) || !Enum.IsDefined(taxMode)))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<TaxMode>())}.");
if (TaxRateKeys.Contains(key)
&& (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
|| rate < 0 || rate > 100))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a percentage from 0 to 100.");
if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0)) if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer."); throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer.");
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddGrnPricing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "DiscountPercent",
table: "GoodsReceipts",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "VatPercent",
table: "GoodsReceipts",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "DiscountPercent",
table: "GoodsReceiptItems",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "UnitCost",
table: "GoodsReceiptItems",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "VatPercent",
table: "GoodsReceiptItems",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
values: new object[] { 0m, 0m, 0m });
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
values: new object[] { 0m, 0m, 0m });
migrationBuilder.UpdateData(
table: "GoodsReceipts",
keyColumn: "Id",
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
columns: new[] { "DiscountPercent", "VatPercent" },
values: new object[] { 0m, 0m });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DiscountPercent",
table: "GoodsReceipts");
migrationBuilder.DropColumn(
name: "VatPercent",
table: "GoodsReceipts");
migrationBuilder.DropColumn(
name: "DiscountPercent",
table: "GoodsReceiptItems");
migrationBuilder.DropColumn(
name: "UnitCost",
table: "GoodsReceiptItems");
migrationBuilder.DropColumn(
name: "VatPercent",
table: "GoodsReceiptItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddGrnFreeQuantity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FreeQuantity",
table: "GoodsReceiptItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
column: "FreeQuantity",
value: 0);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
column: "FreeQuantity",
value: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FreeQuantity",
table: "GoodsReceiptItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddGrnNbt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "NbtPercent",
table: "GoodsReceipts",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "NbtPercent",
table: "GoodsReceiptItems",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
column: "NbtPercent",
value: 0m);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
column: "NbtPercent",
value: 0m);
migrationBuilder.UpdateData(
table: "GoodsReceipts",
keyColumn: "Id",
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
column: "NbtPercent",
value: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NbtPercent",
table: "GoodsReceipts");
migrationBuilder.DropColumn(
name: "NbtPercent",
table: "GoodsReceiptItems");
}
}
}
@@ -427,6 +427,10 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<decimal>("DiscountPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid?>("FK_PurchaseOrderId") b.Property<Guid?>("FK_PurchaseOrderId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -442,9 +446,17 @@ namespace Distribution.Migrations
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<decimal>("NbtPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<DateTime>("ReceivedAt") b.Property<DateTime>("ReceivedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<decimal>("VatPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FK_PurchaseOrderId"); b.HasIndex("FK_PurchaseOrderId");
@@ -460,11 +472,14 @@ namespace Distribution.Migrations
{ {
Id = new Guid("43000000-0000-0000-0000-000000000001"), Id = new Guid("43000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_PurchaseOrderId = new Guid("41000000-0000-0000-0000-000000000001"), FK_PurchaseOrderId = new Guid("41000000-0000-0000-0000-000000000001"),
FK_ReceivedByUserId = new Guid("29000000-0000-0000-0000-000000000001"), FK_ReceivedByUserId = new Guid("29000000-0000-0000-0000-000000000001"),
FK_WarehouseId = new Guid("27000000-0000-0000-0000-000000000001"), FK_WarehouseId = new Guid("27000000-0000-0000-0000-000000000001"),
IsActive = true, IsActive = true,
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified) NbtPercent = 0m,
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
VatPercent = 0m
}); });
}); });
@@ -480,6 +495,10 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<decimal>("DiscountPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid>("FK_GoodsReceiptId") b.Property<Guid>("FK_GoodsReceiptId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -489,12 +508,27 @@ namespace Distribution.Migrations
b.Property<Guid?>("FK_PurchaseOrderItemId") b.Property<Guid?>("FK_PurchaseOrderItemId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<int>("FreeQuantity")
.HasColumnType("int");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<decimal>("NbtPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<int>("RejectedQuantity") b.Property<int>("RejectedQuantity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("VatPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FK_GoodsReceiptId"); b.HasIndex("FK_GoodsReceiptId");
@@ -511,20 +545,30 @@ namespace Distribution.Migrations
Id = new Guid("44000000-0000-0000-0000-000000000001"), Id = new Guid("44000000-0000-0000-0000-000000000001"),
AcceptedQuantity = 200, AcceptedQuantity = 200,
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"), FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000001"), FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000001"),
FreeQuantity = 0,
IsActive = true, IsActive = true,
RejectedQuantity = 0 NbtPercent = 0m,
RejectedQuantity = 0,
UnitCost = 0m,
VatPercent = 0m
}, },
new new
{ {
Id = new Guid("44000000-0000-0000-0000-000000000002"), Id = new Guid("44000000-0000-0000-0000-000000000002"),
AcceptedQuantity = 98, AcceptedQuantity = 98,
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"), FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000002"), FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000002"),
FreeQuantity = 0,
IsActive = true, IsActive = true,
RejectedQuantity = 2 NbtPercent = 0m,
RejectedQuantity = 2,
UnitCost = 0m,
VatPercent = 0m
}); });
}); });
@@ -1500,17 +1544,17 @@ namespace Distribution.Migrations
new new
{ {
Id = new Guid("24000000-0000-0000-0000-000000000001"), Id = new Guid("24000000-0000-0000-0000-000000000001"),
Code = "REG-0001",
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true, IsActive = true,
Code = "REG-0001",
Name = "Western Province" Name = "Western Province"
}, },
new new
{ {
Id = new Guid("24000000-0000-0000-0000-000000000002"), Id = new Guid("24000000-0000-0000-0000-000000000002"),
Code = "REG-0002",
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true, IsActive = true,
Code = "REG-0002",
Name = "Central Province" Name = "Central Province"
}); });
}); });
+3
View File
@@ -45,5 +45,8 @@ namespace Distribution.Models
/// </summary> /// </summary>
public enum InvoiceCreationMode { ModeA, ModeB } public enum InvoiceCreationMode { ModeA, ModeB }
/// <summary>How a tax applies on a document: not at all, may be left at 0, or must be filled in.</summary>
public enum TaxMode { Disabled, Optional, Mandatory }
public enum SerialStatus { InStock, Sold, Returned, Damaged } public enum SerialStatus { InStock, Sold, Returned, Damaged }
} }
+13
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models namespace Distribution.Models
{ {
@@ -27,6 +28,18 @@ namespace Distribution.Models
public DateTime ReceivedAt { get; set; } = DateTime.Now; public DateTime ReceivedAt { get; set; } = DateTime.Now;
/// <summary>GRN-wide default discount %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal DiscountPercent { get; set; }
/// <summary>GRN-wide default VAT %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal VatPercent { get; set; }
/// <summary>GRN-wide default NBT %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal NbtPercent { get; set; }
public ICollection<GoodsReceiptItem> Items { get; set; } = []; public ICollection<GoodsReceiptItem> Items { get; set; } = [];
} }
} }
+20
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models namespace Distribution.Models
{ {
@@ -24,6 +25,25 @@ namespace Distribution.Models
/// <summary>Damaged/short at receipt, not added to stock.</summary> /// <summary>Damaged/short at receipt, not added to stock.</summary>
public int RejectedQuantity { get; set; } = 0; public int RejectedQuantity { get; set; } = 0;
/// <summary>
/// Units the supplier gave at no charge. Received into stock like accepted units, but never
/// costed and never counted against the purchase order's ordered quantity.
/// </summary>
public int FreeQuantity { get; set; } = 0;
/// <summary>Cost per base-UOM unit. Only accepted units are costed.</summary>
[Precision(18, 2)]
public decimal UnitCost { get; set; }
[Precision(5, 2)]
public decimal DiscountPercent { get; set; }
[Precision(5, 2)]
public decimal VatPercent { get; set; }
[Precision(5, 2)]
public decimal NbtPercent { get; set; }
/// <summary>Populated when the received item tracks batches and/or expiry dates.</summary> /// <summary>Populated when the received item tracks batches and/or expiry dates.</summary>
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = []; public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];
+36 -9
View File
@@ -97,11 +97,18 @@ PurchaseOrderItem
├── IsCancelled (bool) — true once this line is individually cancelled, default false ├── IsCancelled (bool) — true once this line is individually cancelled, default false
``` ```
> **Update (GRN rework):** a goods receipt no longer requires a PO — see "Direct goods receipt" and
> the costing / free-issue / tax additions in
> [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md). The tree
> below shows the original design plus those changes, marked *(later)*.
### GoodsReceipt ### GoodsReceipt
``` ```
GoodsReceipt GoodsReceipt
├── Id (Guid) — PK, from Template ├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required ├── FK_PurchaseOrderId (Guid?) — nullable *(later)*: null = direct receipt (no PO)
├── FK_SupplierId (Guid?) — *(later)* set on a direct receipt; a PO receipt reaches the supplier via the PO
├── DiscountPercent, VatPercent, NbtPercent (decimal 5,2) — *(later)* GRN-wide defaults for lines
├── FK_ReceivedByUserId (Guid) — required ├── FK_ReceivedByUserId (Guid) — required
├── FK_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into. ├── FK_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into.
│ Not anticipated in the original design — added because │ Not anticipated in the original design — added because
@@ -116,9 +123,13 @@ GoodsReceipt
GoodsReceiptItem GoodsReceiptItem
├── Id (Guid) — PK, from Template ├── Id (Guid) — PK, from Template
├── FK_GoodsReceiptId (Guid) — required ├── FK_GoodsReceiptId (Guid) — required
├── FK_PurchaseOrderItemId (Guid) — required, ties the receipt line back to its ordered line ├── FK_PurchaseOrderItemId (Guid?) — ties the receipt line back to its ordered line; null on a direct receipt
├── FK_ItemId (Guid?) — *(later)* the item, on a direct receipt line
├── AcceptedQuantity (int) — added to warehouse stock ├── AcceptedQuantity (int) — added to warehouse stock
├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock ├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock
├── FreeQuantity (int) — *(later)* free-issue units: added to stock, not costed, not counted against the PO
├── UnitCost (decimal 18,2) — *(later)* cost per unit; PO receipts default to the PO line's UnitCost
├── DiscountPercent, NbtPercent, VatPercent (decimal 5,2) — *(later)* per-line rates
``` ```
### PurchaseReturn ### PurchaseReturn
@@ -154,11 +165,16 @@ gives one receiving warehouse per PO in practice; see Open Questions for the mul
## Migrations ## Migrations
One migration, applied:
1. **AddProcurement** (`20260824110925_AddProcurement`) — created `PurchaseOrders`, 1. **AddProcurement** (`20260824110925_AddProcurement`) — created `PurchaseOrders`,
`PurchaseOrderItems`, `GoodsReceipts`, `GoodsReceiptItems`, `PurchaseReturns`, `PurchaseOrderItems`, `GoodsReceipts`, `GoodsReceiptItems`, `PurchaseReturns`,
`PurchaseReturnItems`, with FKs to `Suppliers`/`Items`/`Warehouses` and indexes on every FK `PurchaseReturnItems`, with FKs to `Suppliers`/`Items`/`Warehouses` and indexes on every FK
column. Applied via `dotnet ef database update`. column. Applied via `dotnet ef database update`.
2. **AddDirectGoodsReceipt** (`20261006070042`) — made the PO links nullable and added the supplier /
item links for direct receipts.
3. **AddGrnPricing**, **AddGrnFreeQuantity**, **AddGrnNbt** (`20261007…`, `20261008…`) — GRN costing,
free issues and NBT. Pricing and free quantity are applied; **AddGrnNbt was still pending** when
last checked, and GRN reads/writes fail until it is applied — see
[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §7.
--- ---
@@ -182,11 +198,17 @@ One migration, applied:
| `resolveInvoiceVariance(poId, variance, resolution)` | `POST /api/purchase-orders/{id}/resolve-variance` | | `resolveInvoiceVariance(poId, variance, resolution)` | `POST /api/purchase-orders/{id}/resolve-variance` |
### Goods Receipt (`Features/Grn`) ### Goods Receipt (`Features/Grn`)
> A PO is **not mandatory** for a goods receipt: `POST /api/goods-receipts/direct` receives stock
> with no PO (no ordered-quantity limit, no PO status, no purchase returns / invoice matching for
> it). See [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §1 for
> when to use which.
| Function reference | Endpoint | | Function reference | Endpoint |
|---|---| |---|---|
| `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` | | `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` (also `POST /api/goods-receipts` with `purchaseOrderId` in the body) |
| `createDirectGoodsReceipt(warehouseId, supplierId?, items[])` | `POST /api/goods-receipts/direct` |
| `getGoodsReceipt(grnId)` | `GET /api/goods-receipts/{id}` | | `getGoodsReceipt(grnId)` | `GET /api/goods-receipts/{id}` |
| `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` | | `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` |
| `listGoodsReceipts(page)` | `GET /api/goods-receipts` |
### Purchase Returns (`Features/PurchaseReturn`) ### Purchase Returns (`Features/PurchaseReturn`)
| Function reference | Endpoint | | Function reference | Endpoint |
@@ -215,8 +237,12 @@ One migration, applied:
{ "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 } { "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 }
] } ] }
``` ```
`rejectedQuantity` defaults to 0 if omitted; only `acceptedQuantity` is credited to `rejectedQuantity` defaults to 0 if omitted. `acceptedQuantity` **plus `freeQuantity`** (free-issue
`StockBalance`. units, default 0) is credited to `StockBalance`; only accepted units are costed and only accepted
units advance the PO line's `ReceivedQuantity`. Optional pricing fields (`unitCost`,
`discountPercent`, `vatPercent`, `nbtPercent` per line; `discountPercent`, `vatPercent`,
`nbtPercent` for the whole GRN) are described in
[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md).
### POST `/api/purchase-orders/{id}/returns` ### POST `/api/purchase-orders/{id}/returns`
```json ```json
@@ -297,9 +323,10 @@ above) per 14's existing target design.
- Where should cost-vs-sale-price margin reporting (`PurchaseOrderItem.UnitCost` vs - Where should cost-vs-sale-price margin reporting (`PurchaseOrderItem.UnitCost` vs
`PriceListItem.Price`/`Item.SalePrice`) live — a read-only endpoint/report in this step, or a `PriceListItem.Price`/`Item.SalePrice`) live — a read-only endpoint/report in this step, or a
later analytics/reporting step? Not a blocker for the core PO→GRN flow. later analytics/reporting step? Not a blocker for the core PO→GRN flow.
- Landed cost (freight, duty, taxes allocated onto received stock) is out of scope — - Landed cost (freight, duty allocated onto received stock) is out of scope. The GRN now records
`PurchaseOrderItem.UnitCost` is the only cost basis captured. Defer unless accurate per-unit cost unit cost, discount, NBT and VAT per line (see
is needed sooner. [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md)), but these do
not feed stock valuation or `Item.CostPrice`, and the PO itself carries no discount or tax.
- Reorder triggers (`setReorderPoint`/`sendLowStockAlert`) are not wired to procurement — restocking - Reorder triggers (`setReorderPoint`/`sendLowStockAlert`) are not wired to procurement — restocking
stays fully manual until Notifications & Alerts exists. stays fully manual until Notifications & Alerts exists.
- `createPurchaseReturn` assumes a single receiving warehouse per PO (uses the most recent - `createPurchaseReturn` assumes a single receiving warehouse per PO (uses the most recent
@@ -0,0 +1,166 @@
# GRN: single entry screen, costing, free issues and configurable taxes
**Status**: Implemented in code. Three migrations are written but must be applied with
`dotnet ef database update` before GRNs can be saved or listed (see Migrations).
**Touches**: `Features/Grn`, `Features/SysConfig`, `Models/GoodsReceipt*.cs`, the Configuration page,
`distribution_fe/app/(dashboard)/procurement/grn/*`.
**Related**: [15_procurement.md](../15_procurement.md) (PO → GRN flow),
[item_tracking_config.md](./item_tracking_config.md) (serial / batch / expiry capture).
---
## 1. One screen for both kinds of receipt
**Is a purchase order mandatory? No.** A PO is the order placed with a supplier *before* goods
arrive; a GRN records what *actually* arrived and increases stock. Stock posting is identical either
way. What a PO adds: auto-filled lines and costs, a hard stop on over-receiving, PO status tracking
(Issued → Partially Received → Received), and the features that hang off a PO (purchase returns,
invoice matching, supplier evaluation). A Direct GRN has none of these controls. Use a PO for normal
ordering; use Direct GRN for unplanned deliveries, walk-in purchases or opening stock. Nothing
currently restricts Direct GRN (no permission, no switch) — add one if every receipt must have an order.
The old two pages (`/procurement/grn/po/new`, `/procurement/grn/direct/new`) are replaced by
**`/procurement/grn/new`**. The GRN list has a single **New GRN** button.
A **Receipt Type** switch at the top picks the behaviour:
| | Against PO (default) | Direct GRN |
|---|---|---|
| Purchase order | required dropdown (Issued / PartiallyReceived only) | none |
| Supplier | read-only, taken from the PO | optional dropdown |
| Received date | shown | not shown (server stamps the time) |
| Lines | prefilled from the PO: remaining qty, PO unit cost | any catalogue item, added by hand |
| **Add Item** | adds a line chosen from **that PO's items** (e.g. a second batch of the same PO line) | adds a line with any item |
| Endpoint | `POST /api/goods-receipts` | `POST /api/goods-receipts/direct` |
| Service method | `GrnService.CreateAsync` | `GrnService.CreateDirectAsync` |
Switching the type resets the lines. A receipt against a PO can only contain items that are on the PO;
anything else must be a Direct GRN. Several lines may point at the same PO line; the over-receipt
check runs against the running total as each line is applied.
## 2. The items table
Columns: **Item · Batch No · Accepted · Rejected · Free Qty · Unit Cost · Disc % · NBT % · VAT % ·
Line Total** (NBT % / VAT % only when enabled in Configuration), plus a delete icon.
- **Batch No** summarises the lots (`B1 ×5, B2 ×3`), a serial count, or an expiry-lot count. The
existing panel under the row still edits the serials / batches.
- **Totals card** under the table: Subtotal, Discount, Taxable amount, NBT, VAT, Grand total.
- **GRN Discount %, NBT %, VAT %** at the top are *defaults*: picking or typing a value (0–100,
decimals allowed) fills every line; any line can still be edited afterwards.
## 3. Costing rules (server is the source of truth)
Per line, rounded to 2 dp (half away from zero), in `Features/Grn/GrnPricing.cs`:
```
gross = acceptedQty x unitCost
discount = gross x disc%
net = gross - discount
NBT = net x NBT%
VAT = (net + NBT) x VAT% // Sri Lanka order: VAT is charged on the NBT-inclusive amount
total = net + NBT + VAT
```
- Only **accepted** units are costed. Rejected units never are; **free** units are stocked but not
costed.
- Unit cost: the request's value, else the PO line's `UnitCost` (PO receipts), else 0 (direct).
- Disc % / NBT % / VAT %: the line's value, else the GRN-wide value.
- Percentages must be 0–100, cost cannot be negative; violations are `409` with a message.
- GRN totals are **not stored**. They are recomputed from the stored line percentages whenever a GRN
is read, so they cannot drift. The frontend mirrors the same formula (`grn-costing.tsx`).
## 4. Free issues
`GoodsReceiptItem.FreeQuantity`: units the supplier gave at no charge.
- Added to stock together with accepted units (`StockBalance` / `StockMovement`).
- Not costed and **not** counted against the PO line's ordered quantity (`ReceivedQuantity` grows by
accepted units only).
- Serial / batch entries must cover **accepted + free** (10 accepted + 2 free = 12 serials, or batch
quantities summing to 12).
- A line may be entirely free. A direct line is rejected only if accepted + rejected + free is 0.
## 5. Configurable GRN taxes (Configuration → GRN Taxes)
Stored in the existing `SysConfig` key/value table (no table change):
| Key | Values | Unconfigured default |
|---|---|---|
| `Grn.Tax.Vat.Mode` | `Disabled` / `Optional` / `Mandatory` | `Optional` (how GRNs behaved before) |
| `Grn.Tax.Vat.DefaultRate` | 0–100 | 0 |
| `Grn.Tax.Nbt.Mode` | `Disabled` / `Optional` / `Mandatory` | `Disabled` |
| `Grn.Tax.Nbt.DefaultRate` | 0–100 | 0 |
Set through the generic `PUT /api/sys-config/{key}`; the service validates modes and rates.
`SysConfigService.GetGrnTaxSettingsAsync()` reads them. The GRN screen reads the same keys from
`GET /api/sys-config`.
| Mode | GRN form | Server |
|---|---|---|
| Disabled ("Not applied") | field and column hidden | percentage forced to 0, client values ignored |
| Optional | shown; may stay at 0 | missing value = 0 |
| Mandatory | shown, red `*`; the GRN-wide field cannot be left blank (0% is allowed); Save is disabled while blank | request rejected (`409`, "… is mandatory") if neither the line nor the GRN supplies the value |
Applies to **GRNs only**. The default rate seeds new GRNs (header and lines); users can change it.
## 6. Data model changes
| Table | New columns |
|---|---|
| `GoodsReceipts` | `DiscountPercent`, `VatPercent`, `NbtPercent` (`decimal(5,2)`, default 0) |
| `GoodsReceiptItems` | `UnitCost` (`decimal(18,2)`), `DiscountPercent`, `VatPercent`, `NbtPercent` (`decimal(5,2)`), `FreeQuantity` (`int`), all default 0 |
New enum `TaxMode { Disabled, Optional, Mandatory }`.
### API surface
Requests (`POST /api/goods-receipts`, `/api/goods-receipts/direct`, `/api/purchase-orders/{id}/goods-receipts`):
header `discountPercent`, `vatPercent`, `nbtPercent`; per line `unitCost`, `discountPercent`,
`vatPercent`, `nbtPercent`, `freeQuantity` (all optional).
Responses add the same fields plus per-line `grossAmount`, `discountAmount`, `nbtAmount`,
`vatAmount`, `lineTotal` and GRN-level `subTotal`, `discountAmount`, `taxableAmount`, `nbtAmount`,
`vatAmount`, `totalAmount`. See `sharedDocs/BACKEND_API_REFERENCE.md` §6.19.
## 7. Migrations
1. `20261007063956_AddGrnPricing` — header + line discount / VAT, line unit cost. *Applied.*
2. `20261007065602_AddGrnFreeQuantity` — line free quantity. *Applied.*
3. `20261008060840_AddGrnNbt` — header + line NBT. ***Pending** when last checked
(`dotnet ef migrations list`).*
All only add columns with default 0 (existing GRNs are unaffected). Run
`dotnet ef database update` from `Distribution/` (stop a running API first, it locks the build).
The app does not migrate on startup, so until the NBT migration is applied every call that reads or
writes GRNs fails with `Unknown column 'g.NbtPercent'` (surfacing on save as a `DbUpdateException`).
Re-check with `dotnet ef migrations list` before assuming a database is current.
## 8. Auto-generated serial numbers continue from what is saved
The panel's **Auto-generate** used to start at 1 each time, so a second receipt of the same item
regenerated `BEV-Fanta-100-0001…` and the server rejected it ("Serial number … already exists for
item …"). Now:
- `GET /api/stock/serials/next-sequence?itemId=&prefix=` returns `{ "next": n }`: one past the highest
numeric suffix among the item's saved serials that look like `<prefix><digits>` (1 when none).
Serials that don't fit the pattern (e.g. a supplier's own numbers) are ignored.
(`StockRepository.GetNextSerialSequenceAsync`.)
- Auto-generate calls it, then starts at `max(start field, next)`: first 5 units → `0001–0005`, next
receipt → `0006–0010`. The "start" field is updated after each run.
- It also skips serials already in the same list and serials on **other lines of the same GRN for the
same item**, so two lines of one item cannot collide.
- If the lookup fails, it warns and falls back to the start field; the server still rejects any
duplicate at save.
- Serials already sitting in an open form are not rewritten: clear the list and generate again.
## 9. Known limits / follow-ups
- The PO receipt request carries no received date; the server stamps `DateTime.Now`. The date field on
the screen is sent but ignored.
- In the over-receipt check, a PO line's *rejected* units from earlier lines / receipts are not
carried forward (only accepted units advance `ReceivedQuantity`). Pre-existing behaviour.
- The GRN list table does not show costs or totals; the details dialog does (NBT only when present).
- Mandatory means "must be filled in"; there is no rule forcing a rate above 0.
- Tests: `Distribution.Tests/Services/GrnPricingTests.cs` covers the arithmetic and validation. The
server-side tax-mode handling, free-quantity stock posting and serial numbering have no automated
tests yet.
@@ -188,8 +188,10 @@ an item that doesn't track batches, or a missing expiry date on an item that req
warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an
`Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden `Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden
entirely, not just disabled, to avoid confusing item editors. entirely, not just disabled, to avoid confusing item editors.
- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct: - **GRN entry** — now **one page**, `/procurement/grn/new`, with an *Against PO / Direct GRN* switch
`/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial (the former `/procurement/grn/po/new` and `/procurement/grn/direct/new` pages were merged; see
[grn_costing_and_taxes.md](./grn_costing_and_taxes.md)). Serial / batch counts on a line must cover
*accepted + free* units. Tracked lines show a status chip that opens a panel for serial
entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty). entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty).
Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12. Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12.
- **configuration page**: a single Products card on `/configuration` holds the switches and warranty - **configuration page**: a single Products card on `/configuration` holds the switches and warranty
@@ -233,13 +235,17 @@ Rejected quantity never touches stock. Everything posts in one unit of work and
failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected. failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected.
**Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status **Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status
update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as two update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as one
pages and both share the same serial/batch entry panel. page with a receipt-type switch, sharing the same serial/batch entry panel. Free-issue units are
stocked like accepted ones, so the serial / batch totals include them.
### 3. Entering serials / batches in the UI ### 3. Entering serials / batches in the UI
- Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel. - Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel.
- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units), - **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units;
or type/paste one per line; every entry stays editable; duplicates are flagged. numbering continues after the highest `<prefix><digits>` already saved for the item, so a later
receipt gets `0006–0010` rather than repeating `0001–0005` — see
[grn_costing_and_taxes.md](./grn_costing_and_taxes.md) §8), or type/paste one per line; every
entry stays editable; duplicates are flagged.
- **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if - **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if
expired or within 30 days; quantities must add up to the accepted quantity. expired or within 30 days; quantities must add up to the accepted quantity.
- Save is disabled until all tracked lines are complete; the server re-validates. - Save is disabled until all tracked lines are complete; the server re-validates.
@@ -0,0 +1,142 @@
# Plan: Product Report
## Status — implemented (Phases 1–3)
Built as `Features/ProductReport` (`api/reports/products`: `stock`, `sales`, `returns`, `summary`)
and the page `/reports/products` (Stock / Sales / Returns tabs, summary cards). Not built: Excel/PDF
export, a `reports.view` permission and region/route/store scoping (the page reuses
`warehouses.view`), purchase-return figures, the slow-stock / expiry phase (4).
Decisions taken for the open questions below: **sales** = fulfilled quantity on non-cancelled shop
POs whose invoice is `Finalized`, dated by `Invoice.FinalizedAt`; **margin** uses `Item.CostPrice`.
Implementation notes (the database is **MySQL** via Pomelo, which constrains the queries):
- EF's `GroupBy` + `Distinct().Count()` over a navigation (distinct stores per item) generates a
derived table that references the outer query, which MySQL rejects (`Unknown column … in 'WHERE'`).
Distinct stores are therefore counted in a second small query for the items on the current page.
- Grouped queries project to an anonymous type, are ordered and paged on that, then mapped to the
response records. The stock report counts warehouse / vehicle quantity with per-item subqueries
instead of a `GroupBy` + `DefaultIfEmpty` join.
- Limits: `pageSize` ≤ 200, date range ≤ 366 days.
---
## Context
There is no reporting module yet. "Product" in this codebase is `Item`
(`Distribution/Models/Item.cs`). A product report is a read-only aggregation over data that
already exists, so **no new tables or migrations** are needed for the first phases.
Source data (verified in the models):
| Area | Entity | Useful fields |
|---|---|---|
| Master | `Item` | `Sku`, `Name`, `FK_CategoryId`, `FK_SubCategoryId`, `FK_BrandId`, `FK_BaseUomId`, `SalePrice`, `CostPrice`, `ReorderLevel`, tracking flags, `IsActive` (via `Template`) |
| Stock | `StockBalance` | `FK_ItemId`, `LocationId` + `LocationType` (Warehouse/Vehicle/External), batch/serial, `Quantity` |
| Stock history | `StockMovement` | `Type` (Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn), `Quantity`, `CreatedAt` |
| Sales | `ShopPOItem` | `Quantity`, `QuantityFulfilled`, `UnitPrice`; parent `ShopPO` (Status, ConfirmedAt, store) |
| Invoice | `Invoice` | Has **no line items**. Links to `ShopPO` via `FK_ShopPOId`; `Status` (Draft/Printed/Finalized), `TotalAmount` |
| Purchase | `PurchaseOrderItem`, `GoodsReceiptItem` | `UnitCost`, ordered qty; `AcceptedQuantity`, `RejectedQuantity` |
| Returns | `SaleReturnItem`, `PurchaseReturnItem` | `Quantity`, `ReasonCode` (Expired/Damaged/OrderError/Other), `UnitPrice` |
Important consequence: because `Invoice` has no line items, **product-level sales must come from
`ShopPOItem` joined to a finalized `Invoice`** (via `ShopPO`), not from invoice rows.
## Scope (phased)
Each phase is independently shippable. Build in order.
### Phase 1: Product Stock Report (no new data)
One row per item (optionally per warehouse):
- Sku, name, brand, category, sub-category, base UOM
- Sale price, cost price, stock value (`Quantity * CostPrice`)
- On-hand qty (sum of `StockBalance` for `LocationType.Warehouse`), on-vehicle qty
(`LocationType.Vehicle`), total
- Low-stock flag (`Quantity <= ReorderLevel`), reuses the logic behind `GET /api/stock/low-stock`
- Active/inactive
Filters: brand, category, sub-category, warehouse, active only, low-stock only, search (sku/name).
### Phase 2: Product Sales Report
Per item for a date range:
- Qty ordered, qty fulfilled, gross sales (`QuantityFulfilled * UnitPrice`)
- Number of distinct shops/orders
- Rank (top/bottom N)
- Breakdown by region/route/store (grouping option)
Rules:
- Count only `ShopPO` rows where `Status != Cancelled`.
- **Decision needed:** define "sales" once. Recommended: fulfilled quantity on shop POs whose
invoice is `Finalized` (matches the pipeline in `shoppo_invoice_pipeline.md`).
- Date basis: `Invoice.FinalizedAt`.
### Phase 3: Returns and Margin
- Sales returns per item: qty, value, reason breakdown (`ReturnReasonCode`), return % of sold qty
- Purchase returns per item
- Gross margin: `(avg selling price - CostPrice) * qty`. Use `Item.CostPrice` first; later switch
to latest `PurchaseOrderItem.UnitCost` if cost history matters.
### Phase 4: Movement / ageing (optional)
- Slow/dead stock: items with no `StockMovement` of type `Sale`/`Load` in N days
- Movement ledger per item (reuse `GET /api/stock/movements`)
- Batch/expiry report for items with `TracksBatches`/`TracksExpiryDate`
## Backend design
Follow the existing feature layout (`Controller`, `Service`, `IRepository`, `Repository`, `DTOs`).
New feature folder: `Distribution/Features/ProductReport/`
- `ProductReportController.cs`, route `api/reports/products`
- `GET /stock` (Phase 1)
- `GET /sales` (Phase 2)
- `GET /returns` (Phase 3)
- `GET /summary`: top-line totals for the report header
- `GET /export?format=xlsx` (last step)
- `IProductReportRepository` / `ProductReportRepository`: EF Core grouped queries
(`GroupBy` on `FK_ItemId`, `AsNoTracking`, projection straight into DTOs). No in-memory loops.
- `ProductReportService`: applies filters, scope and paging; computes derived fields
(stock value, return %, margin).
- `ProductReportDTOs.cs`: `ProductReportQuery` (filters, `From`, `To`, `Page`, `PageSize`, `SortBy`),
row DTOs per report, `PagedResult<T>` (reuse whatever the other features already use).
- Register in `Program.cs` the same way as the other feature services.
Cross-cutting:
- **Authorization:** add a `reports.view` permission (see `Permission.cs`/`RolePermission.cs`) and
honour the existing `UserRegionScope`/`UserRouteScope`/`UserStoreScope` so users only see data
in their scope.
- **Paging and limits:** default page size 50; cap date range (e.g. 366 days).
- **Indexes:** check `ShopPOItem(FK_ItemId)`, `StockMovement(FK_ItemId, CreatedAt)`,
`StockBalance(FK_ItemId, LocationId)`; add only if the query plans need them.
## Frontend design
- Page: `distribution_fe/app/(dashboard)/reports/products/page.tsx`
(tabs: Stock, Sales, Returns)
- API client: `distribution_fe/lib/api/reports.ts`, exported from `lib/api/index.ts`
(same pattern as `stock.ts`, `sales.ts`)
- Filter bar (date range, brand, category, warehouse, region/route), summary tiles, table with
server-side paging and sorting, Export button.
- Add a "Reports" entry to the dashboard nav.
## Open questions
1. Who is the primary reader (management, warehouse, sales)? This sets Phase 1 vs 2 priority.
2. Definition of "sales": fulfilled qty on finalized invoices, or all confirmed shop POs?
3. Cost basis for margin: `Item.CostPrice` or latest PO cost?
4. Export format: Excel only, or PDF too?
5. Do reports need to respect region/route/store scope for all roles?
## Verification
- Unit tests in `Distribution.Tests` for the service (filter, paging, derived fields) with seeded data.
- Cross-check: Phase 1 on-hand totals equal `GET /api/stock/balances/{itemId}/total`.
- Cross-check: Phase 2 sales total for a date range equals the sum of finalized `Invoice.TotalAmount`
for the same range (excluding any invoice-level adjustments).
- Run with a large seed and check query time and that exports match the on-screen table.
@@ -0,0 +1,227 @@
# Plan: Close the Ride → Loading Sheet → Shop PO → Delivery → Invoice → Payment pipeline
## Context
The goal was a step-by-step frontend flow for Operations (Rides/Loading Sheets/Delivery Tasks)
and for Shop POs/Invoices, grounded in what the backend actually implements.
Investigation found the pipeline was **not end-to-end**, even though most of the frontend UI for it
already existed:
- **Ride** (`RideController`) and **LoadingSheet** (`LoadingSheetController`) were fully implemented,
backend and frontend, and already worked (create → set items → confirm → stock moves warehouse→vehicle).
- **ShopPO** (`ShopPOController`) was **GET-only** on the backend — no create/update-items/confirm/
confirm-and-create-invoice/cancel logic existed — despite the frontend
(`distribution_fe/app/(dashboard)/sales/new/page.tsx`, `lib/api/sales.ts`'s `shopPos` client)
already calling all five of those routes. Those calls previously 404'd.
- **Invoice** (`InvoiceController`) was likewise **GET-only** — no print/finalize logic — despite the
frontend (`sales/orders/_components/orders-client.tsx`) already calling `invoices.print()` /
`invoices.finalize()`.
- **DeliveryTask** generation (`DeliveryTaskService.GenerateAsync`) was stubbed:
`throw new NotImplementedException(...)`. No frontend trigger for it existed either.
- **LoadingSheetOrder** (the link table between a `LoadingSheet` and the `ShopPO`s it's carrying) was
never populated anywhere in code — only by seed data — even though `LoadingSheetItem.FK_ShopPOItemId`
(optional, already wired end-to-end) was exactly the data needed to derive it.
- **Payment** (`PaymentController`/`PaymentService`) was fully implemented on both ends already —
no changes needed there.
The DTOs for the missing ShopPO endpoints (`CreateShopPORequest`, `UpdateShopPOItemsRequest`,
`ShopPOItemRequest`) already existed in `Distribution/Features/ShopPO/ShopPODTOs.cs`, unused — the
frontend and DTOs were clearly built ahead of the service/controller logic. This plan closes that
gap: implement the missing backend pieces, wire the one still-missing frontend action (delivery task
generation trigger), and document the resulting step-by-step flow.
## Backend work
### 1. `ShopPOService` — Create / UpdateItems / Confirm / ConfirmAndCreateInvoice / Cancel
File: `Distribution/Features/ShopPO/ShopPOService.cs` (previously only had `GetAllAsync`/`GetByIdAsync`).
New endpoints on `ShopPOController.cs` (previously GET-only), matching the routes the frontend
already calls:
- `POST /api/shop-pos` → `CreateAsync(CreateShopPORequest, Guid raisedById, ct)`
- Creates `ShopPO` with `Status = Pending`, `TotalAmount` computed from line items
(`Quantity * UnitPrice`, resolving `UnitPrice` server-side via `PriceListService.ResolveByStoreAsync`
when not supplied — the same resolution `sales/new/page.tsx` calls client-side at
`priceListsApi.resolve`).
- Creates `ShopPOItem` rows, `QuantityFulfilled = 0`.
- `PUT /api/shop-pos/{id}/items` → `UpdateItemsAsync(id, UpdateShopPOItemsRequest, ct)`
- Guard: only allowed while `ConfirmedAt is null` (mirrors `LoadingSheetService.SetItemsAsync`'s
Planned-only guard) — same replace-all-items pattern (`ReplaceItemsAsync`).
- Recomputes `TotalAmount`.
- `POST /api/shop-pos/{id}/confirm` → `ConfirmAsync(id, Guid confirmedBy, ct)`
- Guard: `ConfirmedAt is null` and `Status != Cancelled`.
- Sets `ConfirmedAt = DateTime.Now`, `ConfirmedBy = confirmedBy`. Status stays `Pending`
(fulfillment tracking is a separate axis, same pattern as `DeliveryTaskService.ConfirmAsync`
already updating `ShopPO.Status` independently of `ConfirmedAt`).
- Does **not** create an Invoice (delivery-later path — invoice creation trigger for that path
remains an open question, see below).
- `POST /api/shop-pos/{id}/confirm-and-create-invoice` → `ConfirmAndCreateInvoiceAsync(id, Guid userId, ct)`
- Does everything `ConfirmAsync` does, plus creates an `Invoice` row (`FK_ShopPOId`, `Status = Draft`,
`PaymentStatus = Pending`, `CreatedBy = userId`, `TotalAmount = po.TotalAmount`) and sets
`po.InvoiceId`. This is the on-the-spot sale path (rep+driver together).
- `POST /api/shop-pos/{id}/cancel` → `CancelAsync(id, ct)`
- Guard: `Status != Cancelled` and not already `PartiallyFulfilled`/`Fulfilled`.
- Sets `Status = Cancelled`.
Follows the existing service pattern used by `RideService.CloseAsync`/`CancelAsync` and
`LoadingSheetService.ConfirmAsync`: `uow.BeginAsync` → mutate → `repo.Update` →
`uow.CommitAsync`/`RollbackAsync` on exception; controller catches `KeyNotFoundException` → 404,
`InvalidOperationException` → 400.
### 2. `LoadingSheetService.SetItemsAsync` — derive `LoadingSheetOrder` rows
File: `Distribution/Features/LoadingEvent/LoadingSheetService.cs`. After replacing items, the
distinct `FK_ShopPOId` values are looked up from each item's `FK_ShopPOItemId` (via the new
`ILoadingSheetRepository.GetShopPOIdsByItemIdsAsync`), and `LoadingSheetOrder` rows for the sheet are
replaced accordingly (`ReplaceOrdersAsync` — same replace-all-on-each-call approach as the items
themselves). This makes `LoadingSheetResponse.Orders` (already returned, previously always empty
outside seed data) actually populate, and is the data `DeliveryTaskService.GenerateAsync` needs.
### 3. `DeliveryTaskService.GenerateAsync` — implemented
File: `Distribution/Features/DeliveryRide/DeliveryTaskService.cs`. For the given `rideId`: finds the
ride's confirmed `LoadingSheet`(s), reads their `LoadingSheetOrder` rows, and creates one
`DeliveryTask` per distinct `ShopPO` referenced — `Status = Pending`, `Sequence` assigned in creation
order, `CustomerConfirmed = false`. Skips POs that already have a `DeliveryTask` for this ride
(idempotent, via `IDeliveryTaskRepository.ExistsForOrderAndRideAsync`). Guard: throws if the ride has
no confirmed loading sheet.
### 4. `InvoiceService` — Print / Finalize
File: `Distribution/Features/Invoice/InvoiceService.cs` (previously only Get/GetAll). Adds:
- `POST /api/invoices/{id}/print` → `PrintAsync(id, Guid printedBy, ct)`
- Guard: `Status == Draft`. Sets `Status = Printed`, `PrintedAt = Now`, `PrintedBy = printedBy`.
- `POST /api/invoices/{id}/finalize` → `FinalizeAsync(id, FinalizeInvoiceRequest, Guid finalizedBy, ct)`
- Guard: `Status == Printed`. Sets `Status = Finalized`, `FinalizedAt = Now`, `FinalizedBy = finalizedBy`.
- Uses the pre-existing `FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments)` DTO
(was already defined, unused, in `InvoiceDTOs.cs` — `Adjustments` was made optional/nullable so
the current no-body frontend call still binds). When adjustments are supplied, each
`ShopPOItemId`'s billed quantity is overridden (validated to be between 0 and the item's ordered
`Quantity`) and `Invoice.TotalAmount` is recomputed as `Σ billedQuantity × UnitPrice`; with no
adjustments, `TotalAmount` is left as-is. This does **not** touch `ShopPOItem.QuantityFulfilled`
(that field is owned by the delivery-confirmation flow) — adjustments are billing-only. The
deeper CreditNote-based deduction design from `order_invoice_lifecycle.md` remains unbuilt; this
is a minimal, non-persisted (no adjustment audit trail) implementation.
- `InvoiceRepository.GetByIdAsync` was extended to also include `ShopPO.Items` (previously only
`ShopPO.Store`), needed to compute the adjustment total.
- Matches the frontend's existing `visible` guards in `orders-client.tsx` (`print` shown only when
`Draft`, `finalize` only when `Printed`) — no frontend change needed there, since the frontend
currently calls `finalize` with no body/adjustments.
`PaymentService.CreateAsync` already blocks payments once `Status == Finalized` — consistent with
this state machine.
## Frontend work
Almost everything needed already existed and was correctly wired to the (previously missing) backend
routes above — once the backend work lands, `sales/new`, `sales/orders`, `sales/payments`,
`operations/rides`, `operations/loading` all start working without changes. The one gap:
- **Trigger for `DeliveryTaskService.GenerateAsync`**: no frontend call existed anywhere. Added a
`generate: (rideId: string) => api.post<DeliveryTaskResponse[]>(\`/rides/${rideId}/tasks/generate\`)`
method to `deliveryTasks` in `distribution_fe/lib/api/operations.ts` (route already defined in
`DeliveryTaskController.cs`), and a "Generate delivery tasks" **row action** on `Confirmed` sheets
in the Loading Sheets table (`operations/loading/_components/loading-sheets-client.tsx`) — not the
read-only `loading-sheet-view-sheet.tsx`, which stayed untouched — since that's where every other
sheet action (Set items, Confirm) already lives as a row action, and that's the point at which
`LoadingSheetOrder` data becomes available. Also required adding `'generate'` to the row-action
icon map, which turned out to be duplicated in two places
(`components/shared/row-actions.tsx`'s `ACTION_ICONS` and `components/shared/data-table/types.ts`'s
`RowActionKind` union) — both needed updating.
## End-to-end flow (once the above lands)
1. **Sales rep**: `/sales/new` → creates a `ShopPO` (`Pending`, unconfirmed) for a store with line items.
2. **Confirm PO**: either `confirm` (delivery-later — no invoice yet) or `confirm-and-create-invoice`
(on-the-spot sale — `Invoice` created immediately, `Draft`).
3. **Operations**: `/operations/rides` → create a `Ride` (`Open`) for a vehicle+driver.
4. `/operations/loading` → create a `LoadingSheet` for that ride, set items (optionally tagging each
with the `ShopPOItem` it's fulfilling via `FK_ShopPOItemId`), confirm → stock moves warehouse→vehicle,
`LoadingSheetOrder` rows derived.
5. Trigger **Generate delivery tasks** for the ride → one `DeliveryTask` per PO on the sheet.
6. `/operations/deliveries` → for each task, set delivered quantities, confirm (stock moves
vehicle→external, `ShopPO.Status` synced to `Fulfilled`/`PartiallyFulfilled`) or fail.
7. If not already created in step 2, an `Invoice` should exist by now for delivery-later POs —
**open question, not resolved by this plan**: no endpoint currently creates an Invoice for the
delivery-later path after fulfillment; flagged as a follow-up.
8. `/sales/orders` (Invoices list) → `print` (`Draft → Printed`) → `finalize` (`Printed → Finalized`).
9. `/sales/payments` → record payments against the invoice until `PaymentStatus = Paid` (blocked once
`Finalized`).
10. Back in `/operations/rides` → **close** the ride (requires all vehicle stock cleared, i.e. every
loaded item delivered or otherwise moved off) or **cancel** it.
## Addendum: Loading Sheet "Set Items" screen (region/route-filtered PO picker)
While implementing the above, a further gap was found: the frontend never had any UI to actually set
items on a `LoadingSheet` — `LoadingSheetCreateDialog` only picks a ride and creates an empty
`Planned` sheet; `loadingSheets.updateItems()` existed in the API client but nothing called it.
Practically, a loading sheet should be built *from* confirmed Shop POs (what to load = what's been
ordered), narrowed by Region/Route so the rep isn't picking through every PO in the system.
`Ride`/`Vehicle`/`DriverProfile` carry no route assignment in this app (rides/drivers/vehicles are
not fixed to a route — confirmed as intentional, so a "derive route from the driver" auto-filter was
rejected in favor of a manual filter). The resolution:
- **Backend**: `ShopPOController`/`ShopPOService`/`ShopPORepository` `GetAll`/`GetCount` gained
`regionId` and `status` query filters (previously only `agentId`/`routeId`/`shopId`/`confirmed`),
filtering on `Store.Route.FK_RegionId` and `ShopPO.Status`. `GetAllAsync` now also includes each
PO's active `ShopPOItem`s (with `Item`) so the picker can render line items without N+1 calls.
- **Frontend**: new `LoadingSheetItemsDialog`
(`operations/loading/_components/loading-sheet-items-dialog.tsx`), opened via a "Set items" row
action on `Planned` sheets in `loading-sheets-client.tsx`. It has manual Region and Route filter
dropdowns (Route list narrows to the selected Region via the existing `routes.list({ regionId })`),
fetches confirmed `Pending`/`PartiallyFulfilled` POs matching the filter, lists their outstanding
line items (`Quantity - QuantityFulfilled`) with a checkbox and quantity input per line, a single
Warehouse selector applied to all picked lines, and saves via the existing
`loadingSheets.updateItems()` call — which already tags each `LoadingSheetItem` with its source
`FK_ShopPOItemId`, so `LoadingSheetService.SetItemsAsync`'s `LoadingSheetOrder` derivation (see
above) picks it up automatically.
- The "Confirm & commit stock" row action now also requires `sheet.items.length > 0`, since
confirming an item-less sheet was possible before but meaningless.
## Addendum: Loading Sheets list filterable by Region/Route
The Loading Sheets list (`loading-sheets-client.tsx`) previously only filtered by Ride and Status.
Since a `LoadingSheet` has no direct region/route of its own, filtering is derived through
`LoadingSheetOrder → ShopPO → Store → Route(.FK_RegionId)` — i.e. "sheets carrying at least one PO
for a store on this route/region." `ILoadingSheetRepository`/`LoadingSheetService`/
`LoadingSheetController` `GetAll`/`GetCount` gained `routeId`/`regionId` query params
(`s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)`, same pattern for region). Frontend added
matching Region/Route filter dropdowns (Route list narrows to the selected Region), consistent with
the `LoadingSheetItemsDialog` filter UX added above.
## Open questions (not resolved by this pass)
- No endpoint creates an `Invoice` for the delivery-later path (`ConfirmAsync`, not
`ConfirmAndCreateInvoiceAsync`) once fulfillment completes. `IShopPORepository.GetConfirmedWithoutInvoiceAsync`
already exists and looks purpose-built for this — likely intended for a future
"generate invoices for confirmed-but-unbilled POs" job or endpoint, not built in this pass.
- Multi-invoice-per-PO, partial invoicing, and Invoice/OrderStatus axis merging remain unresolved,
consistent with `order_invoice_lifecycle.md`.
## Known environment issue (unrelated to this pipeline's code)
While verifying this pipeline live, every non-GET request (`POST`/`OPTIONS`, with or without a body,
including to nonexistent routes and endpoints untouched by this work like `/api/auth/login`) hung
indefinitely against the local backend, while every `GET` responded instantly. Since even a `POST` to
a route that doesn't exist hangs before reaching routing/controller code, this rules out an
application bug and points to something intercepting/filtering non-GET loopback HTTP traffic on the
machine (antivirus/EDR web/HTTP inspection, or a proxy/VPN). Restarting the backend process did not
resolve it. Not a defect in the code documented above — flagged here so it isn't mistaken for one
during future verification.
## Verification
- `dotnet build Distribution/Distribution.csproj` clean after each service/controller addition.
- Manually exercise the flow above via the existing frontend screens end-to-end against the local
backend (fresh `ShopPO` through to `Payment`), confirming each previously-404ing action (`create`,
`updateItems`, `confirm`, `confirmAndCreateInvoice`, `cancel`, `print`, `finalize`, `generate`) now
succeeds and the UI's existing `visible` guards match the state machine.
- `npx tsc --noEmit -p tsconfig.json` in `distribution_fe` after the frontend addition (generate
trigger + api client method).
@@ -0,0 +1,105 @@
# Shop PO → Invoice → Operations — Actual Flow (as implemented)
**Status**: Reference — describes current code behavior, not a plan
**Related docs**: 03_ride_lifecycle.md, 04_loading_event.md, 05_delivery_ride.md, 06_sale_ride.md (original specs — some details below diverge from those specs; this doc reflects what the code actually does today)
---
## Goal
Trace the end-to-end lifecycle of a customer order from the moment a Shop PO is raised through
billing (Invoice) and physical fulfillment (Ride → Loading Sheet → Delivery Task), including every
status transition and stock movement along the way.
---
## 1. Shop PO (raised by a sales agent, on behalf of a store)
**Pages**: `/sales` (list), `/sales/new` (create), `/sales/[id]` (detail)
**Backend**: `Features/ShopPO`
- **Create** (`ShopPOService.CreateAsync`): agent picks a store + items. Unit price resolves from
the active `PriceList` for that store's region, falling back to `Item.SalePrice` if none is
active. Status starts `Pending`.
- **Confirm** (`ConfirmAsync`): sets `ConfirmedAt`/`ConfirmedBy`. Status **stays `Pending`** —
confirming does not change `Status`, it only marks the PO as ready to act on (load/invoice).
- **Cancel**: only allowed while `Status == Pending` (not `PartiallyFulfilled`/`Fulfilled`).
- A confirmed PO with no invoice yet (`ConfirmedAt != null && InvoiceId == null`) is the exact set
`GetConfirmedWithoutInvoiceAsync` and the Loading Sheet picker (`GET /shop-pos?confirmed=true`)
query for.
## 2. Invoice (billing document, one per Shop PO)
**Page**: `/sales/orders` (labeled "Orders" in the nav, but it is actually the Invoices list)
**Backend**: `Features/Invoice`
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
- Lifecycle: `Draft → Printed → Finalized`.
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
quantity) and recomputes `TotalAmount` from the adjusted lines.
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
`Pending → PartiallyPaid → Paid`.
## 3. Operations: Ride → Loading Sheet → Confirm → Delivery Tasks
**Pages**: `/operations/rides`, `/operations/loading`, `/operations/loading/new`
**Backend**: `Features/Ride`, `Features/LoadingEvent`, `Features/DeliveryRide`
1. **Ride** created with `Status: Open`. A Ride is just a Vehicle + Driver pairing — it has **no**
Route field in the current data model (unlike the original 03/06 specs' "ride type" framing).
2. **Loading Sheet** created against an Open ride (`/operations/loading/new`):
- Pick a **Route** (required, first) → confirmed Shop POs for stores on that route load
(`ConfirmedAt != null`, status `Pending`/`PartiallyFulfilled`, remaining quantity > 0).
- Optionally narrow by **Shop** (store) within that route.
- Pick items/quantities to load, a **Warehouse** (single warehouse per sheet), and finally the
**Ride** to load onto.
- Sheet starts `Status: Planned`.
3. **Set items** (`PUT /loading-sheets/{id}/items`) — editable only while `Planned`. Behavior is
**additive/upsert**, keyed by `(FK_ItemId, FK_WarehouseId, FK_ShopPOItemId)`: a matching row's
`QuantityDispatched` is updated in place, a new combination is added, and rows for combinations
not present in the request are left untouched. This lets a shortfall in one warehouse be topped
up from a second warehouse in a later save without losing the first line.
4. **Confirm sheet** (`LoadingSheetService.ConfirmAsync`): for every line, deducts stock from its
Warehouse and credits it to the Ride's Vehicle (`StockMovement.Type = Load`,
`Warehouse → Vehicle`). Sheet → `Status: Confirmed`. This is the only point stock actually
leaves the warehouse. (Note: unlike the 04_loading_event.md spec, confirming a sheet does **not**
transition the Ride's own status — `RideStatus` only has `Open`/`Cancelled`/`Closed`, no
`Active`.)
5. **Generate delivery tasks** (`DeliveryTaskService.GenerateAsync`): once a ride has at least one
`Confirmed` loading sheet, one `DeliveryTask` (`Status: Pending`) is created per distinct Shop PO
that sheet's `LoadingSheetOrder` rows reference.
6. **Set quantities** on each task (`SetQuantitiesAsync`) — how much of each `ShopPOItem` was
actually delivered, capped at the item's ordered quantity. Only allowed while the task is
`Pending`.
7. **Confirm task** (`DeliveryTaskService.ConfirmAsync`) — closes the loop back to the Shop PO:
- Moves stock **off the vehicle** (`StockMovement.Type = Sale`, `Vehicle → External`) for
whatever was fulfilled, failing if the vehicle doesn't actually have enough stock.
- Task → `Status: Delivered` (all items fully fulfilled) or `PartiallyDelivered`.
- Writes back to the Shop PO: `Status = Fulfilled`/`PartiallyFulfilled`, `FulfilledAt` set, and
each `ShopPOItem.QuantityFulfilled` bumped. This is exactly the field the Loading Sheet
picker's "remaining quantity" (`quantity - quantityFulfilled`) depends on — a fully fulfilled
PO drops out of the picker entirely.
8. **Fail task**: marks `Status: Failed`. No stock movement, no Shop PO status change.
---
## Stock ledger across the whole flow
```
Warehouse --(Loading Sheet confirm, Type=Load)--> Vehicle --(Delivery Task confirm, Type=Sale)--> External/Customer
```
Every step writes a `StockMovement` row, so `StockBalance` for each Warehouse/Vehicle stays
reconciled from Shop PO creation all the way through delivery.
---
## Known gaps at time of writing
- No frontend UI calls `ConfirmAndCreateInvoiceAsync` — only the plain `Confirm` action (no
invoice) is wired up on the Shop PO screens.
- `ShopPOController`/`LoadingSheetController` Create/Confirm endpoints, like most other
create/confirm endpoints in this codebase, rely on the TEMPORARY `currentUser.UserId ?? request.XxxUserId`
fallback pattern documented in `testbug.md` (#28) until the real auth middleware is fixed.
+2
View File
@@ -54,6 +54,7 @@ Running log of backend API failures found during testing and the corresponding f
| 12 | Pricing → Schemes (action icons) | Same recurring pattern as Price Lists (#45): `DeleteAsync`'s `repo.Remove` already soft-deleted (`IsActive = false`), but `GetAllAsync`/`GetByIdAsync`/`UpdateAsync` filtered/blocked on `IsActive`, hiding a deleted scheme from the list entirely and blocking viewing/editing/reactivating it. `EvaluateEligibilityAsync` relied on that same repo-level filter to only ever consider active schemes as redemption candidates — removing the filter without care would have silently let inactive/deleted schemes be evaluated as eligible. Also `GetAll` returns a plain `List<SchemeResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added View (details modal — rule type, region, segment, date range, trigger/free-goods payout, bundle components) and Delete (soft-deactivate, reuses the existing "Active" checkbox in the Edit form to reactivate) icons | Removed the `IsActive` filter from `SchemeRepository.GetAllAsync`; moved the active-only filter into `SchemeService.EvaluateEligibilityAsync` itself (`.Where(s => s.IsActive)` on the fetched candidates) so eligibility evaluation is unaffected; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdateSchemeRequest`; switched the frontend to client-side pagination (same fix as Price Lists/Orders/Brands); added `schemes.remove()`; created `SchemeViewSheet` | 2026-09-09 | | 12 | Pricing → Schemes (action icons) | Same recurring pattern as Price Lists (#45): `DeleteAsync`'s `repo.Remove` already soft-deleted (`IsActive = false`), but `GetAllAsync`/`GetByIdAsync`/`UpdateAsync` filtered/blocked on `IsActive`, hiding a deleted scheme from the list entirely and blocking viewing/editing/reactivating it. `EvaluateEligibilityAsync` relied on that same repo-level filter to only ever consider active schemes as redemption candidates — removing the filter without care would have silently let inactive/deleted schemes be evaluated as eligible. Also `GetAll` returns a plain `List<SchemeResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added View (details modal — rule type, region, segment, date range, trigger/free-goods payout, bundle components) and Delete (soft-deactivate, reuses the existing "Active" checkbox in the Edit form to reactivate) icons | Removed the `IsActive` filter from `SchemeRepository.GetAllAsync`; moved the active-only filter into `SchemeService.EvaluateEligibilityAsync` itself (`.Where(s => s.IsActive)` on the fetched candidates) so eligibility evaluation is unaffected; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdateSchemeRequest`; switched the frontend to client-side pagination (same fix as Price Lists/Orders/Brands); added `schemes.remove()`; created `SchemeViewSheet` | 2026-09-09 |
| 14 | Suppliers (action icons) | Exact same recurring pattern again: `SupplierRepository.GetAllAsync` filtered `.Where(s => s.IsActive)` (hiding a deleted supplier from the list instead of showing it Inactive), `GetByIdAsync`/`UpdateAsync` blocked viewing/editing an inactive one, and `UpdateSupplierRequest` had no `IsActive` field so the Edit form's existing "Active" checkbox couldn't reactivate one. Also `suppliers.list()` was typed as `PaginatedResponse` against a backend `GetAll` that returns a plain array, same pagination-shape bug as Price Lists/Schemes/Orders/Brands | Added View (details modal — name, contact, status, performance score, created date) and Delete (soft-deactivate, reuses the existing "Active" checkbox in Edit to reactivate) icons | Removed the `IsActive` filter from `SupplierRepository.GetAllAsync`; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync` (kept on `EvaluateAsync`, a real business guard — no point recomputing a performance score for a deleted supplier); added `IsActive` to `UpdateSupplierRequest`; switched the frontend to client-side pagination; added `suppliers.remove()`; created `SupplierViewSheet` | 2026-09-09 | | 14 | Suppliers (action icons) | Exact same recurring pattern again: `SupplierRepository.GetAllAsync` filtered `.Where(s => s.IsActive)` (hiding a deleted supplier from the list instead of showing it Inactive), `GetByIdAsync`/`UpdateAsync` blocked viewing/editing an inactive one, and `UpdateSupplierRequest` had no `IsActive` field so the Edit form's existing "Active" checkbox couldn't reactivate one. Also `suppliers.list()` was typed as `PaginatedResponse` against a backend `GetAll` that returns a plain array, same pagination-shape bug as Price Lists/Schemes/Orders/Brands | Added View (details modal — name, contact, status, performance score, created date) and Delete (soft-deactivate, reuses the existing "Active" checkbox in Edit to reactivate) icons | Removed the `IsActive` filter from `SupplierRepository.GetAllAsync`; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync` (kept on `EvaluateAsync`, a real business guard — no point recomputing a performance score for a deleted supplier); added `IsActive` to `UpdateSupplierRequest`; switched the frontend to client-side pagination; added `suppliers.remove()`; created `SupplierViewSheet` | 2026-09-09 |
| 48 | Pricing → Price Lists (new fields) | — | Added `RetailPrice` and `AgencyMarginPercent` to `PriceListItem`, alongside the existing `Price`/`MinPrice` band — see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) for the full business rules. `RetailPrice` is the MRP the store is expected to charge the end consumer (must be `>= Price`); `AgencyMarginPercent` is the regional agency's commission `%` of `Price` (must be `0-100`). Both are informational only — carried through `PriceResolution` for display/reporting, never enforced on order pricing | Added both columns to the Items table in the Price List Edit/View pages (add-row + inline edit + view-only table), and a "Retail price: Rs. X" hint under the unit price on Create Sale when the resolved price came from a real price list | Added `RetailPrice`/`AgencyMarginPercent` to `PriceListItem`, migration `AddRetailPriceAndAgencyMargin`; extended `UpsertPriceListItemRequest`/`PriceListItemResponse`/`PriceResolution` with validation (`RetailPrice >= Price`, `AgencyMarginPercent` in `[0,100]`); updated `PriceListItemsSection`, the View page's items table, and `priceLists.upsertItem()`; updated `docs/14_pricing_and_suppliers.md` | 2026-09-09 | | 48 | Pricing → Price Lists (new fields) | — | Added `RetailPrice` and `AgencyMarginPercent` to `PriceListItem`, alongside the existing `Price`/`MinPrice` band — see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) for the full business rules. `RetailPrice` is the MRP the store is expected to charge the end consumer (must be `>= Price`); `AgencyMarginPercent` is the regional agency's commission `%` of `Price` (must be `0-100`). Both are informational only — carried through `PriceResolution` for display/reporting, never enforced on order pricing | Added both columns to the Items table in the Price List Edit/View pages (add-row + inline edit + view-only table), and a "Retail price: Rs. X" hint under the unit price on Create Sale when the resolved price came from a real price list | Added `RetailPrice`/`AgencyMarginPercent` to `PriceListItem`, migration `AddRetailPriceAndAgencyMargin`; extended `UpsertPriceListItemRequest`/`PriceListItemResponse`/`PriceResolution` with validation (`RetailPrice >= Price`, `AgencyMarginPercent` in `[0,100]`); updated `PriceListItemsSection`, the View page's items table, and `priceLists.upsertItem()`; updated `docs/14_pricing_and_suppliers.md` | 2026-09-09 |
| 49 | Configuration → Products (Product Capabilities toggles) — TEMPORARY workaround | `PUT /api/sys-config/{key}` had the same unhandled `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` crash as #10/#36/#40/#8, surfaced the moment a toggle tried to save. A first attempt added `Guid? UserId = null` as a record constructor default, which broke `System.Text.Json` deserialization entirely (`NotSupportedException` — a record constructor parameter with a default value reads to STJ as an ambiguous/parameterless-like constructor) | Rebuilt the Configuration area end to end: sidebar "Configuration" is now a standalone link (no sub-nav/collapsible) straight to `/configuration`, a card-grid landing page; its "Products" card opens `/configuration/products`, a redesigned "Product Capabilities" card (two-column toggle grid, ON/OFF `Switch`, no "Enable…" wording). Removed the page's "Save Changes" button entirely — each toggle (and the default-warranty-days input, on blur) now calls the API immediately on change, optimistically updating and reverting with a toast on failure. Also renamed "Items" → "Products" in the Catalogue nav label, page heading, and the Add/Edit/View dialogs (left every other module's "Item" line-item wording — Sales/Procurement/GRN/Loading Sheets/Returns — untouched, since those are document line items, not the catalogue) | TEMP: added `UserId` (no default value, to keep STJ happy) to `SetSysConfigRequest`; `SysConfigController.Set` now does `currentUser.UserId ?? request.UserId`, returning a clean `BadRequest` instead of throwing when both are null. Frontend `sysConfig.set()` takes an optional `userId` and the Product Configuration page passes `useAuth().user.id` (the mock-auth user) on every toggle/save call. Revert once auth (#28) is fixed | 2026-10-02 |
| 8 | Stock Balances | No way to correct a balance — stock is architecturally never edited directly, only through movement transactions (load, sale, return, damage, adjustment); the `POST /stock/adjustment` endpoint existed but its controller action always returned `Unauthorized()` because `currentUser.UserId` is unset (#28) | Added a View icon (details modal) and an Edit icon that opens an "Adjust Stock" dialog (signed quantity delta + reason + Adjusted By) which posts to `/stock/adjustment` — this is the correct way to change a balance, not a direct field edit | TEMP: added `AdjustedByUserId` fallback to `AdjustmentRequest`, same pattern as #36/#40; wired `StockBalanceViewSheet` + `StockAdjustDialog` into Stock Balances table | 2026-09-08 | | 8 | Stock Balances | No way to correct a balance — stock is architecturally never edited directly, only through movement transactions (load, sale, return, damage, adjustment); the `POST /stock/adjustment` endpoint existed but its controller action always returned `Unauthorized()` because `currentUser.UserId` is unset (#28) | Added a View icon (details modal) and an Edit icon that opens an "Adjust Stock" dialog (signed quantity delta + reason + Adjusted By) which posts to `/stock/adjustment` — this is the correct way to change a balance, not a direct field edit | TEMP: added `AdjustedByUserId` fallback to `AdjustmentRequest`, same pattern as #36/#40; wired `StockBalanceViewSheet` + `StockAdjustDialog` into Stock Balances table | 2026-09-08 |
| 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Added View and Edit icons. Movements are an immutable audit-trail ledger by design — quantity, locations, and type can never change once recorded (the running balance is the sum of all movements), so Edit only lets you correct the Reason/note via a new `PATCH /stock/movements/{id}/reason` endpoint; a wrong quantity/location must be corrected with a new Adjustment (see #8), not by editing history | Wired `StockMovementViewSheet` + `StockMovementEditDialog` into Stock Movements table | 2026-09-08 | | 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Added View and Edit icons. Movements are an immutable audit-trail ledger by design — quantity, locations, and type can never change once recorded (the running balance is the sum of all movements), so Edit only lets you correct the Reason/note via a new `PATCH /stock/movements/{id}/reason` endpoint; a wrong quantity/location must be corrected with a new Adjustment (see #8), not by editing history | Wired `StockMovementViewSheet` + `StockMovementEditDialog` into Stock Movements table | 2026-09-08 |
| 41 | Stock Balances / Stock Movements | Balances and Movements tables/view-sheets showed raw location GUIDs instead of names; Movements also had a genuinely missing side — Sale/PurchaseReceipt/PurchaseReturn/CustomerReturn/Damage/Adjustment each only ever wrote one of From/To, since the other side (customer, supplier, write-off, adjustment) has no real Warehouse/Vehicle record. Existing seeded movement rows still showed blank From/To after the code fix since it only affects newly-created movements | — | Added `LocationName` to `StockBalanceResponse` and `FromLocationName`/`ToLocationName` to `StockMovementResponse`, resolved server-side (`StockService`) by looking up the real Warehouse/Vehicle. Added a new `LocationType.External` plus a fixed `Guid.Empty` sentinel location so every movement's missing side is now always populated and labeled contextually ("Customer", "Supplier", "Written Off", "Stock Adjustment") instead of being left null. Updated all 6 movement-creation call sites (StockService Damage/CustomerReturn/Adjustment, SaleService, DeliveryTaskService, GrnService, PurchaseReturnService, ReturnService), and added migration `FixSeedStockMovementExternalLocations` to backfill the 2 affected seeded rows | 2026-09-08 | | 41 | Stock Balances / Stock Movements | Balances and Movements tables/view-sheets showed raw location GUIDs instead of names; Movements also had a genuinely missing side — Sale/PurchaseReceipt/PurchaseReturn/CustomerReturn/Damage/Adjustment each only ever wrote one of From/To, since the other side (customer, supplier, write-off, adjustment) has no real Warehouse/Vehicle record. Existing seeded movement rows still showed blank From/To after the code fix since it only affects newly-created movements | — | Added `LocationName` to `StockBalanceResponse` and `FromLocationName`/`ToLocationName` to `StockMovementResponse`, resolved server-side (`StockService`) by looking up the real Warehouse/Vehicle. Added a new `LocationType.External` plus a fixed `Guid.Empty` sentinel location so every movement's missing side is now always populated and labeled contextually ("Customer", "Supplier", "Written Off", "Stock Adjustment") instead of being left null. Updated all 6 movement-creation call sites (StockService Damage/CustomerReturn/Adjustment, SaleService, DeliveryTaskService, GrnService, PurchaseReturnService, ReturnService), and added migration `FixSeedStockMovementExternalLocations` to backfill the 2 affected seeded rows | 2026-09-08 |
@@ -75,6 +76,7 @@ Everything below is a stopgap that only exists because `Distribution/Middleware/
11. **`CreateSaleReturnRequest.CreatedByUserId`** (`ReturnDTOs.cs`) and the "Created By" dropdown in the Record Order Return dialog (#23) — remove once `ReturnController.Create` can resolve `currentUser.UserId` on its own. 11. **`CreateSaleReturnRequest.CreatedByUserId`** (`ReturnDTOs.cs`) and the "Created By" dropdown in the Record Order Return dialog (#23) — remove once `ReturnController.Create` can resolve `currentUser.UserId` on its own.
12. **`SettleClaimRequest.SettledByUserId`** (`ClaimDTOs.cs`) and the "Settled By" dropdown in the Settle Claim dialog (#23) — remove once `ClaimController.Settle` can resolve `currentUser.UserId` on its own. 12. **`SettleClaimRequest.SettledByUserId`** (`ClaimDTOs.cs`) and the "Settled By" dropdown in the Settle Claim dialog (#23) — remove once `ClaimController.Settle` can resolve `currentUser.UserId` on its own.
13. **`CreateSupplierClaimRequest.RaisedByUserId`/`ApproveSupplierClaimRequest.ApprovedByUserId`/`RejectSupplierClaimRequest.RejectedByUserId`** (`SupplierClaimDTOs.cs`) and the corresponding user dropdowns in the Raise/Approve/Reject Supplier Claim dialogs (#23) — remove once `SupplierClaimController.Create`/`Approve`/`Reject` can resolve `currentUser.UserId` on their own. 13. **`CreateSupplierClaimRequest.RaisedByUserId`/`ApproveSupplierClaimRequest.ApprovedByUserId`/`RejectSupplierClaimRequest.RejectedByUserId`** (`SupplierClaimDTOs.cs`) and the corresponding user dropdowns in the Raise/Approve/Reject Supplier Claim dialogs (#23) — remove once `SupplierClaimController.Create`/`Approve`/`Reject` can resolve `currentUser.UserId` on their own.
14. **`SetSysConfigRequest.UserId`** (`SysConfigDTOs.cs`) and the `userId` argument threaded through `sysConfig.set()` from the Product Configuration toggles page (`app/(dashboard)/configuration/products/page.tsx`, #49) — remove once `SysConfigController.Set` can resolve `currentUser.UserId` on its own; drop the `?? request.UserId` fallback and the `BadRequest` guard around it.
## Implementation Guide ## Implementation Guide
@@ -0,0 +1,171 @@
'use client'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Discount01Icon } from '@hugeicons/core-free-icons'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { FieldLabel, FieldDescription } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import {
sysConfig,
grnTaxSettings,
GRN_TAX_KEYS,
DEFAULT_GRN_TAX_SETTINGS,
getApiError,
} from '@/lib/api/index'
import type { GrnTaxSettings, TaxMode } from '@/lib/api/sys-config'
import { useAuth } from '@/contexts/auth-context'
const MODE_LABELS: Record<TaxMode, string> = {
Disabled: 'Not applied',
Optional: 'Optional',
Mandatory: 'Mandatory',
}
const MODE_HINTS: Record<TaxMode, string> = {
Disabled: 'Hidden on GRN',
Optional: 'Shown, can be 0',
Mandatory: 'Shown, must be filled',
}
interface TaxRowProps {
id: string
title: string
description: string
mode: TaxMode
rate: number
pending: boolean
onModeChange: (mode: TaxMode) => void
onRateCommit: (rate: number) => void
}
function TaxRow({ id, title, description, mode, rate, pending, onModeChange, onRateCommit }: TaxRowProps) {
// Edit the rate as text and only save when it is valid and the field loses focus.
const [text, setText] = useState(String(rate))
useEffect(() => setText(String(rate)), [rate])
function commit() {
const n = Number(text)
if (text.trim() === '' || !Number.isFinite(n) || n < 0 || n > 100) {
setText(String(rate))
return
}
if (n !== rate) onRateCommit(n)
}
return (
<div className="grid gap-2.5 rounded-xl border border-border/60 bg-background px-3 py-2.5 sm:grid-cols-2">
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{title} <span className="text-xs font-normal text-muted-foreground">· {description}</span>
</FieldLabel>
<FieldDescription className="text-xs">{MODE_HINTS[mode]}</FieldDescription>
</div>
<div className="flex flex-col gap-1">
<FieldLabel className="text-xs text-muted-foreground">Applies on GRN</FieldLabel>
<Select value={mode} onValueChange={v => v && onModeChange(v as TaxMode)} disabled={pending}>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(MODE_LABELS) as TaxMode[]).map(m => (
<SelectItem key={m} value={m}>{MODE_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<FieldLabel className="text-xs text-muted-foreground">Default rate</FieldLabel>
<div className="relative">
<Input
type="number"
inputMode="decimal"
min={0}
max={100}
step={0.01}
value={text}
disabled={mode === 'Disabled' || pending}
onChange={e => setText(e.target.value)}
onBlur={commit}
className="pr-8"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">%</span>
</div>
</div>
</div>
)
}
/** GRN taxes: whether VAT and/or NBT apply to goods receipts, whether each is mandatory, and its default rate. */
export function GrnTaxCard() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [settings, setSettings] = useState<GrnTaxSettings>(DEFAULT_GRN_TAX_SETTINGS)
const [pending, setPending] = useState<Record<string, boolean>>({})
useEffect(() => {
grnTaxSettings.get()
.then(setSettings)
.catch(err => toast.add({ title: 'Failed to load GRN tax settings', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
// Saves one key immediately and rolls the field back if the server refuses it.
async function save(field: keyof GrnTaxSettings, configKey: string, value: TaxMode | number) {
const previous = settings[field]
setSettings(s => ({ ...s, [field]: value }))
setPending(p => ({ ...p, [configKey]: true }))
try {
await sysConfig.set(configKey, String(value), user.id)
} catch (err) {
setSettings(s => ({ ...s, [field]: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPending(p => ({ ...p, [configKey]: false }))
}
}
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Discount01Icon} size={16} />
</div>
<div>
<CardTitle className="text-base">GRN Taxes</CardTitle>
<CardDescription className="text-xs">VAT and NBT on goods receipts</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
<TaxRow
id="grnVatMode"
title="VAT"
description="Value added tax"
mode={settings.vatMode}
rate={settings.vatDefaultRate}
pending={!!pending[GRN_TAX_KEYS.vatMode] || !!pending[GRN_TAX_KEYS.vatRate]}
onModeChange={m => save('vatMode', GRN_TAX_KEYS.vatMode, m)}
onRateCommit={r => save('vatDefaultRate', GRN_TAX_KEYS.vatRate, r)}
/>
<TaxRow
id="grnNbtMode"
title="NBT"
description="Nation building tax"
mode={settings.nbtMode}
rate={settings.nbtDefaultRate}
pending={!!pending[GRN_TAX_KEYS.nbtMode] || !!pending[GRN_TAX_KEYS.nbtRate]}
onModeChange={m => save('nbtMode', GRN_TAX_KEYS.nbtMode, m)}
onRateCommit={r => save('nbtDefaultRate', GRN_TAX_KEYS.nbtRate, r)}
/>
</CardContent>
</Card>
)
}
@@ -45,7 +45,7 @@ function CapabilityToggle({ id, label, description, checked, disabled, onChecked
return ( return (
<div <div
className={cn( className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4', 'flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-background px-3 py-2.5',
className, className,
)} )}
> >
@@ -119,25 +119,24 @@ export function ProductCapabilitiesCard() {
return loading ? ( return loading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
) : ( ) : (
<Card className="max-w-4xl rounded-3xl border-border/60"> <Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
<CardHeader className="border-b border-border/60 pb-5"> <CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Package01Icon} size={18} /> <HugeiconsIcon icon={Package01Icon} size={16} />
</div>
<div>
<CardTitle className="text-base">Products</CardTitle>
<CardDescription className="text-xs">Item tracking options</CardDescription>
</div> </div>
<CardTitle className="text-lg">Products</CardTitle>
</div> </div>
<CardDescription className="mt-3">
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
items then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-5"> <CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
<CapabilityToggle <CapabilityToggle
id="serialNumberTracking" id="serialNumberTracking"
label="Serial number tracking" label="Serial number tracking"
description="Track individual units of an item by a unique serial number." description="One serial per unit"
checked={form.serialNumberTracking} checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
@@ -146,7 +145,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="batchTracking" id="batchTracking"
label="Batch / lot tracking" label="Batch / lot tracking"
description="Group stock into batches or lots for traceability." description="Group stock by batch"
checked={form.batchTracking} checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
@@ -155,7 +154,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="expiryDateTracking" id="expiryDateTracking"
label="Expiry date tracking" label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock." description="Record expiry dates"
checked={form.expiryDateTracking} checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
@@ -164,7 +163,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="warrantyTracking" id="warrantyTracking"
label="Warranty periods" label="Warranty periods"
description="Attach a warranty period to items at the time of sale." description="Warranty on sale"
checked={form.warrantyTracking} checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
@@ -173,24 +172,24 @@ export function ProductCapabilitiesCard() {
{form.warrantyTracking && ( {form.warrantyTracking && (
<> <>
<Separator className="my-5" /> <Separator className="my-3" />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2.5">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults Warranty defaults
</p> </p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
<CapabilityToggle <CapabilityToggle
id="customWarrantyOverride" id="customWarrantyOverride"
label="Per-item custom warranty" label="Per-item custom warranty"
description="Allow individual items to override the default warranty period." description="Items can override the default"
checked={form.customWarrantyOverride} checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]} disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/> />
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4"> <div className="flex flex-col justify-center gap-1.5 rounded-xl border border-border/60 bg-background px-3 py-2.5">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground"> <FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period Default warranty period
</FieldLabel> </FieldLabel>
@@ -206,9 +205,7 @@ export function ProductCapabilitiesCard() {
/> />
<span className="text-sm text-muted-foreground">days</span> <span className="text-sm text-muted-foreground">days</span>
</div> </div>
<FieldDescription className="text-xs"> <FieldDescription className="text-xs">Used when an item sets none</FieldDescription>
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
'use client' 'use client'
import { ProductCapabilitiesCard } from './_components/product-capabilities-card' import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
import { GrnTaxCard } from './_components/grn-tax-card'
export default function ConfigurationPage() { export default function ConfigurationPage() {
return ( return (
@@ -10,7 +11,15 @@ export default function ConfigurationPage() {
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p> <p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div> </div>
<ProductCapabilitiesCard /> {/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
<div className="grid grid-cols-12 items-stretch gap-6">
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<ProductCapabilitiesCard />
</div>
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<GrnTaxCard />
</div>
</div>
</div> </div>
) )
} }
@@ -124,16 +124,10 @@ export function GrnClient() {
Stock received into a warehouse, against a purchase order or directly Stock received into a warehouse, against a purchase order or directly
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <Button onClick={() => router.push('/procurement/grn/new')}>
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}> <HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} /> New GRN
Direct Receipt </Button>
</Button>
<Button onClick={() => router.push('/procurement/grn/po/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Receipt Against PO
</Button>
</div>
</div> </div>
<DataTable <DataTable
@@ -0,0 +1,215 @@
'use client'
import { useEffect, useId, useState } from 'react'
import type { Control, FieldPath, FieldValues } from 'react-hook-form'
import { FormField } from '@/components/shared/form-field'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { ItemSummaryResponse } from '@/types/api'
import type { BatchRow } from './grn-tracking'
/** Preset GRN-wide percentages. Any line can still be typed to a different value. */
export const DISCOUNT_OPTIONS = [0, 2.5, 5, 10, 15, 20, 25]
export const VAT_OPTIONS = [0, 5, 8, 12, 15, 18]
export const NBT_OPTIONS = [0, 2]
// Same rule as the server (GrnPricing): 2 dp, halves round away from zero, rounded per line.
const round2 = (n: number) => Math.round(Number((n * 100).toPrecision(12))) / 100
export interface CostedLine {
acceptedQuantity: number
unitCost: number
discountPercent: number
nbtPercent: number
vatPercent: number
}
/**
* gross = qty x cost; discount on gross; NBT on (gross - discount); VAT on (gross - discount + NBT);
* total = gross - discount + NBT + VAT. Mirrors the server (GrnPricing.Line).
*/
export function lineAmounts(l: CostedLine) {
const gross = round2((l.acceptedQuantity || 0) * (l.unitCost || 0))
const discount = round2((gross * (l.discountPercent || 0)) / 100)
const net = gross - discount
const nbt = round2((net * (l.nbtPercent || 0)) / 100)
const vat = round2(((net + nbt) * (l.vatPercent || 0)) / 100)
return { gross, discount, nbt, vat, total: net + nbt + vat }
}
export function grnTotals(lines: CostedLine[]) {
const amounts = lines.map(lineAmounts)
const subTotal = amounts.reduce((s, a) => s + a.gross, 0)
const discount = amounts.reduce((s, a) => s + a.discount, 0)
const nbt = amounts.reduce((s, a) => s + a.nbt, 0)
const vat = amounts.reduce((s, a) => s + a.vat, 0)
return { subTotal, discount, taxable: subTotal - discount, nbt, vat, total: subTotal - discount + nbt + vat }
}
export const money = (n: number) =>
n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** Text for the Batch No column: lot numbers for batch items, a count for serial/expiry items. */
export function batchSummary(
info: ItemSummaryResponse | undefined,
batches: BatchRow[],
serialText: string,
): string {
if (!info) return '—'
if (info.tracksSerialNumbers) {
const n = serialText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean).length
return n === 0 ? 'Serials not entered' : `${n} serial${n === 1 ? '' : 's'}`
}
if (info.tracksBatches) {
const named = batches.filter(b => b.batchNumber.trim())
return named.length === 0 ? 'Not entered' : named.map(b => `${b.batchNumber.trim()} ×${b.quantity}`).join(', ')
}
if (info.tracksExpiryDate) {
return batches.length === 0 ? 'Not entered' : `${batches.length} expiry lot${batches.length === 1 ? '' : 's'}`
}
return '—'
}
interface PercentSelectProps {
label: string
value: number
options: number[]
onChange: (value: number) => void
/** A required percentage can't be left blank (0 is fine); optional ones fall back to the last value. */
required?: boolean
/** Reports whether a required field is currently blank, so the form can block saving. */
onBlankChange?: (blank: boolean) => void
}
/**
* GRN-wide percentage: pick a preset from the list or type any value from 0 to 100. The text is kept
* locally while typing so "2." or "" don't get rewritten under the cursor; the number is only pushed
* up when it is valid.
*/
export function PercentSelect({ label, value, options, onChange, required, onBlankChange }: PercentSelectProps) {
const listId = useId()
const [text, setText] = useState(String(value))
// Follow outside changes (e.g. a reset) without fighting what the user is typing.
useEffect(() => {
setText(prev => (prev.trim() !== '' && Number(prev) === value ? prev : String(value)))
onBlankChange?.(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const blank = text.trim() === ''
function handleChange(raw: string) {
setText(raw)
const n = Number(raw)
if (raw.trim() === '') {
onBlankChange?.(!!required)
} else if (Number.isFinite(n) && n >= 0 && n <= 100) {
onChange(n)
onBlankChange?.(false)
}
}
// An out-of-range entry falls back to the last valid value on blur; a blank one does too, unless
// the field is required (then it stays blank so the "Required" message and blocked save are visible).
function handleBlur() {
const n = Number(text)
if (blank && required) return
if (blank || !Number.isFinite(n) || n < 0 || n > 100) setText(String(value))
}
return (
<Field>
<FieldLabel>
{label}
{required && <span className="text-destructive"> *</span>}
</FieldLabel>
<div className="relative">
<Input
type="number"
inputMode="decimal"
min={0}
max={100}
step={0.01}
list={listId}
placeholder="Select or type"
value={text}
onChange={e => handleChange(e.target.value)}
onBlur={handleBlur}
aria-invalid={required && blank ? true : undefined}
className="pr-8"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
%
</span>
<datalist id={listId}>
{options.map(o => (
<option key={o} value={o} />
))}
</datalist>
</div>
{required && blank && <p className="text-xs text-destructive">{label} is required</p>}
</Field>
)
}
interface NumberCellProps<T extends FieldValues> {
control: Control<T>
name: FieldPath<T>
step?: number
max?: number
}
/** Numeric table-cell input bound to a form field. */
export function NumberCell<T extends FieldValues>({ control, name, step = 1, max }: NumberCellProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
max={max}
step={step}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
)
}
/** Totals block under the items table. */
export function GrnTotals({ lines, showNbt, showVat }: { lines: CostedLine[]; showNbt: boolean; showVat: boolean }) {
const t = grnTotals(lines)
const rows: [string, string][] = [
['Subtotal', money(t.subTotal)],
['Discount', `- ${money(t.discount)}`],
['Taxable amount', money(t.taxable)],
...(showNbt ? ([['NBT', money(t.nbt)]] as [string, string][]) : []),
...(showVat ? ([['VAT', money(t.vat)]] as [string, string][]) : []),
]
return (
<div className="flex justify-end">
<Card className="w-full max-w-sm rounded-2xl border-border/60">
<CardContent className="space-y-1.5 text-sm">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
))}
<div className="flex justify-between border-t pt-2 text-base font-semibold">
<span>Grand total</span>
<span>{money(t.total)}</span>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -12,6 +12,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { stock as stockApi } from '@/lib/api/stock'
import type { GrnBatchLineBody } from '@/lib/api/procurement' import type { GrnBatchLineBody } from '@/lib/api/procurement'
import type { ItemSummaryResponse } from '@/types/api' import type { ItemSummaryResponse } from '@/types/api'
@@ -71,6 +72,8 @@ interface LineTrackingProps {
batches: BatchRow[] batches: BatchRow[]
onSerialText: (v: string) => void onSerialText: (v: string) => void
onBatches: (v: BatchRow[]) => void onBatches: (v: BatchRow[]) => void
/** Serials already typed on other lines of this GRN for the same item, so generation never repeats them. */
otherSerials?: string[]
} }
/** /**
@@ -78,11 +81,21 @@ interface LineTrackingProps {
* entry, so long serial lists don't stretch the form. Serial tracking takes precedence over * entry, so long serial lists don't stretch the form. Serial tracking takes precedence over
* batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale. * batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale.
*/ */
export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) { export function LineTracking({
info,
name,
accepted,
serialText,
batches,
onSerialText,
onBatches,
otherSerials = [],
}: LineTrackingProps) {
const [panelOpen, setPanelOpen] = useState(false) const [panelOpen, setPanelOpen] = useState(false)
const [scanValue, setScanValue] = useState('') const [scanValue, setScanValue] = useState('')
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`) const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
const [genStart, setGenStart] = useState(1) const [genStart, setGenStart] = useState(1)
const [generating, setGenerating] = useState(false)
const progress = trackingProgress(info, accepted, serialText, batches) const progress = trackingProgress(info, accepted, serialText, batches)
if (!progress) return null if (!progress) return null
@@ -98,19 +111,38 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
setScanValue('') setScanValue('')
} }
// Appends generated serials (prefix + zero-padded sequence) for the units still missing, // Appends generated serials (prefix + zero-padded sequence) for the units still missing. Numbering
// skipping any value already in the list. They stay editable in the list below. // continues after the highest number already saved for this item (asked from the server), so a
const generateSerials = () => { // second receipt of the same item gets 0006–0010 instead of repeating 0001–0005. Values already in
// this list, or on other lines of this GRN, are skipped. They stay editable in the list below.
const generateSerials = async () => {
const existing = parseSerials(serialText) const existing = parseSerials(serialText)
const taken = new Set(existing) const taken = new Set([...existing, ...otherSerials])
const missing = Math.max(0, accepted - existing.length) const missing = Math.max(0, accepted - existing.length)
setGenerating(true)
let start = genStart
try {
start = Math.max(genStart, await stockApi.nextSerialSequence(info.id, genPrefix))
} catch {
toast.add({
title: 'Could not check existing serial numbers',
description: 'Numbering starts from the value shown; the server will reject any serial that already exists.',
type: 'error',
})
} finally {
setGenerating(false)
}
const generated: string[] = [] const generated: string[] = []
for (let n = genStart; generated.length < missing; n++) { let n = start
while (generated.length < missing) {
const sn = `${genPrefix}${String(n).padStart(4, '0')}` const sn = `${genPrefix}${String(n).padStart(4, '0')}`
if (!taken.has(sn)) generated.push(sn) if (!taken.has(sn)) generated.push(sn)
n++
} }
onSerialText([...existing, ...generated].join('\n')) onSerialText([...existing, ...generated].join('\n'))
setGenStart(genStart + missing) setGenStart(n)
} }
const isSerial = progress.kind === 'serial' const isSerial = progress.kind === 'serial'
@@ -145,7 +177,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
<DialogHeader> <DialogHeader>
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle> <DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
<DialogDescription> <DialogDescription>
{name} ({info.sku}) — accepting {accepted} {name} ({info.sku}) — receiving {accepted}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -180,7 +212,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={accepted - progress.done <= 0} disabled={accepted - progress.done <= 0 || generating}
onClick={generateSerials} onClick={generateSerials}
> >
Auto-generate {Math.max(0, accepted - progress.done)} Auto-generate {Math.max(0, accepted - progress.done)}
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import type { GrnResponse } from '@/types/api' import type { GrnResponse } from '@/types/api'
import { money } from './grn-costing'
const fmtDateTime = (v: string) => const fmtDateTime = (v: string) =>
new Date(v).toLocaleString('en-GB', { new Date(v).toLocaleString('en-GB', {
@@ -34,10 +35,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) { export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
if (!grn) return null if (!grn) return null
// NBT only appears for receipts that were taxed with it, so older GRNs keep the same layout.
const hasNbt = grn.nbtPercent > 0 || grn.nbtAmount > 0 || grn.items.some(i => i.nbtPercent > 0)
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl"> <DialogContent className="overflow-y-auto sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Goods Receipt Details</DialogTitle> <DialogTitle>Goods Receipt Details</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -63,6 +66,12 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<th className="px-3 py-2 text-left font-medium">Item</th> <th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-right font-medium">Accepted</th> <th className="px-3 py-2 text-right font-medium">Accepted</th>
<th className="px-3 py-2 text-right font-medium">Rejected</th> <th className="px-3 py-2 text-right font-medium">Rejected</th>
<th className="px-3 py-2 text-right font-medium">Free</th>
<th className="px-3 py-2 text-right font-medium">Unit Cost</th>
<th className="px-3 py-2 text-right font-medium">Disc %</th>
{hasNbt && <th className="px-3 py-2 text-right font-medium">NBT %</th>}
<th className="px-3 py-2 text-right font-medium">VAT %</th>
<th className="px-3 py-2 text-right font-medium">Line Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -94,11 +103,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
</td> </td>
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td> <td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td> <td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
<td className="px-3 py-2 text-right">{item.freeQuantity}</td>
<td className="px-3 py-2 text-right">{money(item.unitCost)}</td>
<td className="px-3 py-2 text-right">{item.discountPercent}%</td>
{hasNbt && <td className="px-3 py-2 text-right">{item.nbtPercent}%</td>}
<td className="px-3 py-2 text-right">{item.vatPercent}%</td>
<td className="px-3 py-2 text-right font-medium">{money(item.lineTotal)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="mt-3 ml-auto flex max-w-xs flex-col">
<Row label="Subtotal" value={money(grn.subTotal)} />
<Row label={`Discount (GRN ${grn.discountPercent}%)`} value={`- ${money(grn.discountAmount)}`} />
<Row label="Taxable amount" value={money(grn.taxableAmount)} />
{hasNbt && <Row label={`NBT (GRN ${grn.nbtPercent}%)`} value={money(grn.nbtAmount)} />}
<Row label={`VAT (GRN ${grn.vatPercent}%)`} value={money(grn.vatAmount)} />
<Row label="Grand total" value={<span className="text-base font-semibold">{money(grn.totalAmount)}</span>} />
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,316 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import {
grn as grnApi,
warehouses as warehousesApi,
users as usersApi,
suppliers as suppliersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { WarehouseResponse, UserResponse, SupplierResponse, ItemSummaryResponse } from '@/types/api'
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
const NO_SUPPLIER = 'none'
const lineSchema = z.object({
itemId: z.string().min(1, 'Select an item'),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
serialText: z.string(),
batches: z.array(z.object({ batchNumber: z.string(), expiryDate: z.string(), quantity: z.number() })),
})
const schema = z.object({
warehouseId: z.string().min(1, 'Warehouse is required'),
supplierId: z.string(),
receivedByUserId: z.string().min(1, 'Received by is required'),
items: z.array(lineSchema).min(1, 'Add at least one item'),
})
type FormValues = z.infer<typeof schema>
const emptyLine = { itemId: '', acceptedQuantity: 1, rejectedQuantity: 0, serialText: '', batches: [] }
/** Receive goods into a warehouse with no purchase order. */
export default function NewDirectGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { warehouseId: '', supplierId: NO_SUPPLIER, receivedByUserId: '', items: [{ ...emptyLine }] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
useEffect(() => {
Promise.all([
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
suppliersApi.list(),
itemsApi.list({ pageSize: 200 }),
])
.then(([ws, us, ss, its]) => {
setWarehousesList(getPaginatedItems(ws))
setUsersList(getPaginatedItems(us))
setSuppliersList(ss)
setItemsList(getPaginatedItems(its))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [])
const itemById = (id: string) => itemsList.find(i => i.id === id)
const watchedItems = form.watch('items')
const trackingIncomplete = watchedItems.some(v => {
const p = trackingProgress(itemById(v.itemId), v.acceptedQuantity, v.serialText, v.batches)
return p !== null && !p.complete
})
async function onSubmit(values: FormValues) {
const items: GrnItemBody[] = []
for (const v of values.items) {
const info = itemById(v.itemId)
if (v.acceptedQuantity + v.rejectedQuantity <= 0) {
toast.add({ title: `${info?.sku ?? 'Line'}: enter a quantity.`, type: 'error' })
return
}
const payload = trackingPayload(info, v)
if ('error' in payload) {
toast.add({ title: `${info?.sku ?? 'Line'}: ${payload.error}`, type: 'error' })
return
}
items.push({
itemId: v.itemId,
acceptedQuantity: v.acceptedQuantity,
rejectedQuantity: v.rejectedQuantity,
...payload,
})
}
try {
await grnApi.createDirect({
warehouseId: values.warehouseId,
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
receivedByUserId: values.receivedByUserId,
items,
})
toast.add({ title: 'Direct receipt recorded', type: 'success' })
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex max-w-4xl flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Direct Receipt</h1>
<p className="text-sm text-muted-foreground">Receive stock into a warehouse without a purchase order.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="supplierId"
label="Supplier (optional)"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="No supplier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SUPPLIER}>No supplier</SelectItem>
{suppliersList.filter(s => s.isActive).map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<FormField
control={form.control}
name="receivedByUserId"
label="Received By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{usersList.map(user => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Items</p>
{fields.map((f, index) => {
const v = watchedItems[index]
const info = v ? itemById(v.itemId) : undefined
return (
<div key={f.id} className="grid grid-cols-[1fr_90px_90px_auto] items-start gap-3 rounded-xl border px-3 py-3">
<FormField
control={form.control}
name={`items.${index}.itemId`}
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={val => {
field.onChange(val)
// A different item has different tracking rules — drop stale entries.
form.setValue(`items.${index}.serialText`, '')
form.setValue(`items.${index}.batches`, [])
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.filter(i => i.isActive).map(i => (
<SelectItem key={i.id} value={i.id}>{i.sku} — {i.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Accepted"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Rejected"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<Button
type="button"
variant="ghost"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
Remove
</Button>
{info && v && (
<LineTracking
info={info}
name={info.name}
accepted={v.acceptedQuantity}
serialText={v.serialText}
batches={v.batches}
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
/>
)}
</div>
)
})}
<div>
<Button type="button" variant="outline" onClick={() => append({ ...emptyLine })}>
Add item
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,737 @@
'use client'
import { Fragment, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import type { GrnTaxSettings } from '@/lib/api/sys-config'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
users as usersApi,
suppliers as suppliersApi,
items as itemsApi,
grnTaxSettings,
DEFAULT_GRN_TAX_SETTINGS,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type {
PurchaseOrderResponse,
WarehouseResponse,
UserResponse,
SupplierResponse,
ItemSummaryResponse,
} from '@/types/api'
import { LineTracking, parseSerials, trackingPayload, trackingProgress } from '../_components/grn-tracking'
import {
DISCOUNT_OPTIONS,
VAT_OPTIONS,
NBT_OPTIONS,
GrnTotals,
NumberCell,
PercentSelect,
batchSummary,
lineAmounts,
money,
} from '../_components/grn-costing'
const NO_SUPPLIER = 'none'
type GrnMode = 'po' | 'direct'
const percent = z.number().min(0, 'Min 0').max(100, 'Max 100')
const lineSchema = z.object({
// Set when the line comes from a purchase order; empty on a direct line.
purchaseOrderItemId: z.string(),
itemId: z.string().min(1, 'Select an item'),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
freeQuantity: z.number().min(0, 'Must be 0 or more'),
unitCost: z.number().min(0, 'Must be 0 or more'),
discountPercent: percent,
nbtPercent: percent,
vatPercent: percent,
// One serial number per line (newline or comma separated) — used for serial-tracked items.
serialText: z.string(),
// Used for batch- and/or expiry-tracked items.
batches: z.array(z.object({ batchNumber: z.string(), expiryDate: z.string(), quantity: z.number() })),
})
const schema = z.object({
warehouseId: z.string().min(1, 'Warehouse is required'),
supplierId: z.string(),
receivedAt: z.string().min(1, 'Received date is required'),
receivedByUserId: z.string().min(1, 'Received by is required'),
discountPercent: percent,
nbtPercent: percent,
vatPercent: percent,
items: z.array(lineSchema).min(1, 'Add at least one item'),
})
type FormValues = z.infer<typeof schema>
const emptyLine = {
purchaseOrderItemId: '',
itemId: '',
acceptedQuantity: 1,
rejectedQuantity: 0,
freeQuantity: 0,
unitCost: 0,
discountPercent: 0,
nbtPercent: 0,
vatPercent: 0,
serialText: '',
batches: [],
}
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
/**
* One GRN form for both ways of receiving goods. "Against PO" loads the order's lines and lets more
* lines be added from that order's items (e.g. a second batch); "Direct GRN" takes any catalogue item.
*/
export default function NewGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [mode, setMode] = useState<GrnMode>('po')
const [poId, setPoId] = useState('')
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
// Which taxes apply to a GRN, and whether each is mandatory (Configuration → GRN Taxes).
const [tax, setTax] = useState<GrnTaxSettings>(DEFAULT_GRN_TAX_SETTINGS)
const [taxBlank, setTaxBlank] = useState({ vat: false, nbt: false })
// Tracking flags for items loaded from a PO, keyed by itemId (the PO line response doesn't carry them).
const [poItemInfo, setPoItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
warehouseId: '',
supplierId: NO_SUPPLIER,
receivedAt: todayIso(),
receivedByUserId: '',
discountPercent: 0,
nbtPercent: 0,
vatPercent: 0,
items: [{ ...emptyLine }],
},
})
const { fields, append, remove, replace } = useFieldArray({ control: form.control, name: 'items' })
useEffect(() => {
Promise.all([
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
suppliersApi.list(),
itemsApi.list({ pageSize: 200 }),
purchaseOrdersApi.list({ pageSize: 200 }),
])
.then(([ws, us, ss, its, pos]) => {
setWarehousesList(getPaginatedItems(ws))
setUsersList(getPaginatedItems(us))
setSuppliersList(ss)
setItemsList(getPaginatedItems(its))
// Only POs that can still receive goods
const all = getPaginatedItems(pos) as PurchaseOrderResponse[]
setEligiblePOs(all.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
// New GRNs start from the configured default rates; a disabled tax stays at 0.
grnTaxSettings
.get()
.then(t => {
setTax(t)
applyToAllLines('vatPercent', t.vatMode === 'Disabled' ? 0 : t.vatDefaultRate)
applyToAllLines('nbtPercent', t.nbtMode === 'Disabled' ? 0 : t.nbtDefaultRate)
})
.catch(err =>
toast.add({ title: 'Failed to load GRN tax settings', description: getApiError(err), type: 'error' })
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isPoMode = mode === 'po'
const vatOn = tax.vatMode !== 'Disabled'
const nbtOn = tax.nbtMode !== 'Disabled'
const colCount = 9 + (vatOn ? 1 : 0) + (nbtOn ? 1 : 0)
const infoFor = (itemId: string) => poItemInfo[itemId] ?? itemsList.find(i => i.id === itemId)
const poLines = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
const poLine = (poItemId: string) => poLines.find(i => i.id === poItemId)
const watchedItems = form.watch('items')
const headerDiscount = form.watch('discountPercent')
const headerVat = form.watch('vatPercent')
const headerNbt = form.watch('nbtPercent')
// Block Save until every tracked line has its serials/batches fully entered (accepted + free units).
const trackingIncomplete = watchedItems.some(v => {
const p = trackingProgress(infoFor(v.itemId), v.acceptedQuantity + v.freeQuantity, v.serialText, v.batches)
return p !== null && !p.complete
})
// The GRN-wide percentage is a default: choosing one fills every line, and a line can still be edited.
function applyToAllLines(field: 'discountPercent' | 'vatPercent' | 'nbtPercent', value: number) {
form.setValue(field, value, { shouldDirty: true })
form.getValues('items').forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
}
function changeMode(next: GrnMode) {
if (next === mode) return
setMode(next)
setPoId('')
setSelectedPO(null)
const discountPercent = form.getValues('discountPercent')
const vatPercent = form.getValues('vatPercent')
const nbtPercent = form.getValues('nbtPercent')
// Direct starts with one blank line; Against PO stays empty until an order is picked.
form.setValue('supplierId', NO_SUPPLIER)
replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent, nbtPercent }] : [])
}
// A line in PO mode points at one of the order's lines; re-picking resets the cost and tracking.
function selectPoLine(index: number, poItemId: string) {
const line = poLine(poItemId)
if (!line) return
form.setValue(`items.${index}.purchaseOrderItemId`, poItemId, { shouldValidate: true })
form.setValue(`items.${index}.itemId`, line.itemId, { shouldValidate: true })
form.setValue(`items.${index}.unitCost`, line.unitCost)
form.setValue(`items.${index}.serialText`, '')
form.setValue(`items.${index}.batches`, [])
}
async function handlePOChange(nextId: string) {
setPoId(nextId)
const discountPercent = form.getValues('discountPercent')
const vatPercent = form.getValues('vatPercent')
const nbtPercent = form.getValues('nbtPercent')
setLoadingPO(true)
try {
const po = await purchaseOrdersApi.get(nextId)
const activeLines = (po.items ?? []).filter(item => !item.isCancelled)
const itemIds = [...new Set(activeLines.map(l => l.itemId))]
const details = await Promise.all(itemIds.map(id => itemsApi.get(id)))
setPoItemInfo(Object.fromEntries(details.map(d => [d.id, d])))
setSelectedPO(po)
form.setValue('supplierId', po.supplierId)
// Pre-fill each non-cancelled line with its remaining quantity and the PO's unit cost.
replace(
activeLines.map(item => ({
...emptyLine,
purchaseOrderItemId: item.id,
itemId: item.itemId,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
unitCost: item.unitCost,
discountPercent,
vatPercent,
nbtPercent,
})),
)
} catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
setPoId('')
setSelectedPO(null)
replace([])
} finally {
setLoadingPO(false)
}
}
async function onSubmit(values: FormValues) {
if (isPoMode && !selectedPO) {
toast.add({ title: 'Select a purchase order.', type: 'error' })
return
}
const items: GrnItemBody[] = []
for (const v of values.items) {
const info = infoFor(v.itemId)
const label = info?.sku ?? 'Line'
if (isPoMode && !v.purchaseOrderItemId) {
toast.add({ title: 'Select an item for every line.', type: 'error' })
return
}
if (v.acceptedQuantity + v.rejectedQuantity + v.freeQuantity <= 0) {
toast.add({ title: `${label}: enter a quantity.`, type: 'error' })
return
}
const payload = trackingPayload(info, { ...v, acceptedQuantity: v.acceptedQuantity + v.freeQuantity })
if ('error' in payload) {
toast.add({ title: `${label}: ${payload.error}`, type: 'error' })
return
}
items.push({
...(isPoMode ? { purchaseOrderItemId: v.purchaseOrderItemId } : { itemId: v.itemId }),
acceptedQuantity: v.acceptedQuantity,
rejectedQuantity: v.rejectedQuantity,
freeQuantity: v.freeQuantity,
unitCost: v.unitCost,
discountPercent: v.discountPercent,
...(vatOn ? { vatPercent: v.vatPercent } : {}),
...(nbtOn ? { nbtPercent: v.nbtPercent } : {}),
...payload,
})
}
try {
if (isPoMode && selectedPO) {
await grnApi.create({
purchaseOrderId: selectedPO.id,
warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items,
discountPercent: values.discountPercent,
...(vatOn ? { vatPercent: values.vatPercent } : {}),
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
receivedByUserId: values.receivedByUserId,
})
} else {
await grnApi.createDirect({
warehouseId: values.warehouseId,
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
receivedByUserId: values.receivedByUserId,
discountPercent: values.discountPercent,
...(vatOn ? { vatPercent: values.vatPercent } : {}),
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
items,
})
}
toast.add({ title: 'Goods receipt recorded', type: 'success' })
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex w-full flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">New GRN</h1>
<p className="text-sm text-muted-foreground">
Receive stock into a warehouse. Pick a purchase order to receive against it, or leave it as direct.
</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<Field>
<FieldLabel>Receipt Type</FieldLabel>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={isPoMode ? 'default' : 'outline'}
onClick={() => changeMode('po')}
>
Against PO
</Button>
<Button
type="button"
size="sm"
variant={!isPoMode ? 'default' : 'outline'}
onClick={() => changeMode('direct')}
>
Direct GRN
</Button>
</div>
</Field>
{isPoMode && (
<Field>
<FieldLabel>
Purchase Order<span className="text-destructive"> *</span>
</FieldLabel>
<Select value={poId} onValueChange={v => v && handlePOChange(v)}>
<SelectTrigger>
<SelectValue placeholder="Select purchase order..." />
</SelectTrigger>
<SelectContent>
{eligiblePOs.map(po => (
<SelectItem key={po.id} value={po.id}>
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{isPoMode ? (
<Field>
<FieldLabel>Supplier</FieldLabel>
<Input value={selectedPO?.supplierName ?? ''} disabled readOnly />
</Field>
) : (
<FormField
control={form.control}
name="supplierId"
label="Supplier (optional)"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="No supplier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SUPPLIER}>No supplier</SelectItem>
{suppliersList.filter(s => s.isActive).map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
{isPoMode && (
<FormField
control={form.control}
name="receivedAt"
label="Received Date"
required
render={({ field, invalid }) => (
<Input
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
)}
<PercentSelect
label="GRN Discount %"
value={headerDiscount}
options={DISCOUNT_OPTIONS}
onChange={v => applyToAllLines('discountPercent', v)}
/>
{nbtOn && (
<PercentSelect
label="GRN NBT %"
value={headerNbt}
options={NBT_OPTIONS}
required={tax.nbtMode === 'Mandatory'}
onBlankChange={blank => setTaxBlank(b => ({ ...b, nbt: blank }))}
onChange={v => applyToAllLines('nbtPercent', v)}
/>
)}
{vatOn && (
<PercentSelect
label="GRN VAT %"
value={headerVat}
options={VAT_OPTIONS}
required={tax.vatMode === 'Mandatory'}
onBlankChange={blank => setTaxBlank(b => ({ ...b, vat: blank }))}
onChange={v => applyToAllLines('vatPercent', v)}
/>
)}
</div>
<FormField
control={form.control}
name="receivedByUserId"
label="Received By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{usersList.map(user => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<Separator />
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">Items</p>
<p className="text-sm text-muted-foreground">
{isPoMode
? 'Lines start from the purchase order. Add Item adds another line from the same order (e.g. a second batch). Unit cost starts at the PO price; change it if the supplier invoiced differently.'
: 'Add each received item as its own line.'}{' '}
GRN discount %{nbtOn ? ', NBT %' : ''}{vatOn ? ' and VAT %' : ''} fill every line; edit a line to override it. Only accepted units are costed;
free units are stocked at no cost.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPoMode && !selectedPO}
onClick={() =>
append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat, nbtPercent: headerNbt })
}
>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
</Button>
</div>
{loadingPO ? (
<p className="text-sm text-muted-foreground">Loading items…</p>
) : isPoMode && !selectedPO ? (
<p className="text-sm text-muted-foreground">Select a purchase order to load its items.</p>
) : fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
{isPoMode ? 'No receivable items left. Use Add Item to add a line.' : 'Add an item to begin.'}
</p>
) : (
<>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="min-w-[120px] px-3 py-2 text-left font-medium">Batch No</th>
<th className="w-24 px-3 py-2 text-left font-medium">Accepted</th>
<th className="w-24 px-3 py-2 text-left font-medium">Rejected</th>
<th className="w-24 px-3 py-2 text-left font-medium">Free Qty</th>
<th className="w-28 px-3 py-2 text-left font-medium">Unit Cost</th>
<th className="w-24 px-3 py-2 text-left font-medium">Disc %</th>
{nbtOn && <th className="w-24 px-3 py-2 text-left font-medium">NBT %</th>}
{vatOn && <th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>}
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
<th className="w-16 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{fields.map((f, index) => {
const v = watchedItems[index]
const info = v ? infoFor(v.itemId) : undefined
const po = v?.purchaseOrderItemId ? poLine(v.purchaseOrderItemId) : undefined
const amounts = lineAmounts({
acceptedQuantity: v?.acceptedQuantity ?? 0,
unitCost: v?.unitCost ?? 0,
discountPercent: v?.discountPercent ?? 0,
nbtPercent: nbtOn ? (v?.nbtPercent ?? 0) : 0,
vatPercent: vatOn ? (v?.vatPercent ?? 0) : 0,
})
return (
<Fragment key={f.id}>
<tr className="border-t align-top">
<td className="px-3 py-2">
{isPoMode ? (
<FormField
control={form.control}
name={`items.${index}.itemId`}
render={() => (
<Select
value={v?.purchaseOrderItemId ?? ''}
onValueChange={val => val && selectPoLine(index, val)}
>
<SelectTrigger>
<SelectValue placeholder="Select PO item...">
{po ? `${po.sku} — ${po.itemName}` : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{poLines.map(l => (
<SelectItem key={l.id} value={l.id}>
{l.sku} — {l.itemName} (ordered {l.quantity}, received {l.receivedQuantity})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<FormField
control={form.control}
name={`items.${index}.itemId`}
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={val => {
field.onChange(val)
// A different item has different tracking rules — drop stale entries.
form.setValue(`items.${index}.serialText`, '')
form.setValue(`items.${index}.batches`, [])
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.filter(i => i.isActive).map(i => (
<SelectItem key={i.id} value={i.id}>{i.sku} — {i.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
</td>
<td className="px-3 py-3 text-muted-foreground">
{batchSummary(info, v?.batches ?? [], v?.serialText ?? '')}
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.acceptedQuantity`} />
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.rejectedQuantity`} />
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.freeQuantity`} />
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.unitCost`} step={0.01} />
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.discountPercent`} step={0.01} max={100} />
</td>
{nbtOn && (
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.nbtPercent`} step={0.01} max={100} />
</td>
)}
{vatOn && (
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
</td>
)}
<td className="px-3 py-3 text-right font-medium">{money(amounts.total)}</td>
<td className="px-3 py-2 text-right">
<Button
type="button"
variant="outline"
size="sm"
aria-label="Delete item"
title="Delete item"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</td>
</tr>
{info && v && (
<tr>
<td colSpan={colCount} className="px-3 pb-3">
<LineTracking
info={info}
name={info.name}
accepted={v.acceptedQuantity + v.freeQuantity}
otherSerials={watchedItems.flatMap((o, j) =>
j !== index && o.itemId === v.itemId ? parseSerials(o.serialText) : [],
)}
serialText={v.serialText}
batches={v.batches}
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
/>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
<GrnTotals
lines={watchedItems.map(v => ({
...v,
nbtPercent: nbtOn ? v.nbtPercent : 0,
vatPercent: vatOn ? v.vatPercent : 0,
}))}
showNbt={nbtOn}
showVat={vatOn}
/>
</>
)}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button
type="submit"
disabled={
form.formState.isSubmitting ||
loadingPO ||
fields.length === 0 ||
trackingIncomplete ||
(tax.vatMode === 'Mandatory' && taxBlank.vat) ||
(tax.nbtMode === 'Mandatory' && taxBlank.nbt)
}
>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</div>
</form>
</div>
)
}
@@ -1,383 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
users as usersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type {
PurchaseOrderResponse,
WarehouseResponse,
UserResponse,
ItemSummaryResponse,
} from '@/types/api'
const batchLineSchema = z.object({
batchNumber: z.string(),
expiryDate: z.string(),
quantity: z.number(),
})
const grnItemSchema = z.object({
purchaseOrderItemId: z.string().min(1),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
// One serial number per line (newline or comma separated) — used for serial-tracked items.
serialText: z.string(),
// Used for batch- and/or expiry-tracked items.
batches: z.array(batchLineSchema),
})
const schema = z.object({
purchaseOrderId: z.string().min(1, 'Purchase order is required'),
warehouseId: z.string().min(1, 'Warehouse is required'),
receivedAt: z.string().min(1, 'Received date is required'),
receivedByUserId: z.string().min(1, 'Received by is required'),
items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
})
type FormValues = z.infer<typeof schema>
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
export default function NewPoGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
// Tracking flags per catalogue item, keyed by itemId — the PO line response doesn't carry them.
const [itemInfo, setItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
purchaseOrderId: '',
warehouseId: '',
receivedAt: todayIso(),
receivedByUserId: '',
items: [],
},
})
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
// Block Save until every tracked line has its serials/batches fully entered.
const watchedItems = form.watch('items')
const trackingIncomplete = watchedItems.some((v, i) => {
const info = poItems[i] ? itemInfo[poItems[i].itemId] : undefined
const p = trackingProgress(info, v.acceptedQuantity, v.serialText, v.batches)
return p !== null && !p.complete
})
// Load reference data on mount
useEffect(() => {
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([pos, ws, usersRes]) => {
// Only POs that can still receive goods
const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
setWarehousesList(getPaginatedItems(ws))
setUsersList(getPaginatedItems(usersRes))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
form.setValue('purchaseOrderId', poId, { shouldValidate: true })
if (!poId) {
setSelectedPO(null)
replace([])
return
}
setLoadingPO(true)
try {
const po = await purchaseOrdersApi.get(poId)
const activeLines = (po.items ?? []).filter(item => !item.isCancelled)
const itemIds = [...new Set(activeLines.map(l => l.itemId))]
const details = await Promise.all(itemIds.map(id => itemsApi.get(id)))
setItemInfo(Object.fromEntries(details.map(d => [d.id, d])))
setSelectedPO(po)
// Pre-fill items: remaining quantity for each non-cancelled line
const lineItems = activeLines.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
serialText: '',
batches: [],
}))
replace(lineItems)
} catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
} finally {
setLoadingPO(false)
}
}
function buildItems(values: FormValues, lines: typeof poItems): GrnItemBody[] | null {
const result: GrnItemBody[] = []
for (let i = 0; i < values.items.length; i++) {
const v = values.items[i]
const line = lines[i]
const payload = trackingPayload(line ? itemInfo[line.itemId] : undefined, v)
if ('error' in payload) {
toast.add({ title: `${line?.sku ?? `line ${i + 1}`}: ${payload.error}`, type: 'error' })
return null
}
result.push({
purchaseOrderItemId: v.purchaseOrderItemId,
acceptedQuantity: v.acceptedQuantity,
rejectedQuantity: v.rejectedQuantity,
...payload,
})
}
return result
}
async function onSubmit(values: FormValues) {
const items = buildItems(values, poItems)
if (!items) return
try {
await grnApi.create({
purchaseOrderId: values.purchaseOrderId,
warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items,
receivedByUserId: values.receivedByUserId,
})
toast.add({ title: 'Goods receipt recorded', type: 'success' })
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex max-w-4xl flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Receipt Against PO</h1>
<p className="text-sm text-muted-foreground">Record stock received into a warehouse against an issued purchase order.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6 rounded-2xl border p-6">
<FieldGroup>
<FormField
control={form.control}
name="purchaseOrderId"
label="Purchase Order"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={v => handlePOChange(v ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Select purchase order..." />
</SelectTrigger>
<SelectContent>
{eligiblePOs.map(po => (
<SelectItem key={po.id} value={po.id}>
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="receivedAt"
label="Received Date"
required
render={({ field, invalid }) => (
<Input
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
<FormField
control={form.control}
name="receivedByUserId"
label="Received By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
{/* Line items — populated from the selected PO */}
{selectedPO && (
<>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Line Items</p>
{loadingPO ? (
<p className="text-sm text-muted-foreground">Loading items…</p>
) : poItems.length === 0 ? (
<p className="text-sm text-muted-foreground">No receivable items on this order.</p>
) : (
<>
{/* Header row */}
<div className="grid grid-cols-[1fr_100px_100px] gap-3 px-1">
<p className="text-xs font-medium text-muted-foreground">Item</p>
<p className="text-xs font-medium text-muted-foreground">Accepted</p>
<p className="text-xs font-medium text-muted-foreground">Rejected</p>
</div>
{fields.map((f, index) => {
const poItem = poItems[index]
return (
<div key={f.id} className="grid grid-cols-[1fr_100px_100px] items-start gap-3 rounded-xl border px-3 py-3">
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-sm font-medium">{poItem?.itemName}</span>
<span className="text-xs text-muted-foreground">
{poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity}
</span>
</div>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
{poItem && itemInfo[poItem.itemId] && (
<LineTracking
info={itemInfo[poItem.itemId]}
name={poItem.itemName}
accepted={form.watch(`items.${index}.acceptedQuantity`)}
serialText={form.watch(`items.${index}.serialText`)}
batches={form.watch(`items.${index}.batches`)}
onSerialText={v => form.setValue(`items.${index}.serialText`, v)}
onBatches={v => form.setValue(`items.${index}.batches`, v, { shouldDirty: true })}
/>
)}
</div>
)
})}
</>
)}
</div>
</>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0 || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</div>
</form>
</div>
)
}
+1 -1
View File
@@ -11,7 +11,7 @@ export { stock } from './stock'
export { productReports } from './reports' export { productReports } from './reports'
export { shopPos, invoices, payments } from './sales' export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims' export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config' export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
export { getPaginatedItems } from './paginated' export { getPaginatedItems } from './paginated'
export type { PageParams } from './master' export type { PageParams } from './master'
+15
View File
@@ -77,6 +77,14 @@ export interface GrnItemBody {
batches?: GrnBatchLineBody[] batches?: GrnBatchLineBody[]
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */ /** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
serialNumbers?: string[] serialNumbers?: string[]
/** Cost per base-UOM unit. Omitted on a PO line falls back to the PO line's cost. */
unitCost?: number
/** Omitted falls back to the GRN-wide percentage. */
discountPercent?: number
vatPercent?: number
nbtPercent?: number
/** Free-issue units: stocked but not costed. Serials/batches must cover accepted + free. */
freeQuantity?: number
} }
export interface GrnBody { export interface GrnBody {
@@ -84,6 +92,10 @@ export interface GrnBody {
warehouseId: string warehouseId: string
receivedAt: string receivedAt: string
items: GrnItemBody[] items: GrnItemBody[]
/** GRN-wide defaults for lines that don't set their own. */
discountPercent?: number
vatPercent?: number
nbtPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string receivedByUserId: string
} }
@@ -92,6 +104,9 @@ export interface DirectGrnBody {
warehouseId: string warehouseId: string
supplierId?: string supplierId?: string
items: GrnItemBody[] items: GrnItemBody[]
discountPercent?: number
vatPercent?: number
nbtPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string receivedByUserId: string
} }
+6
View File
@@ -47,6 +47,12 @@ export const stock = {
adjust: (body: AdjustmentBody) => adjust: (body: AdjustmentBody) =>
api.post('/stock/adjustment', body).then(r => r.data), api.post('/stock/adjustment', body).then(r => r.data),
/** Next free number for auto-generated serials: one past the highest `<prefix><digits>` already saved for the item. */
nextSerialSequence: (itemId: string, prefix: string) =>
api
.get<{ next: number }>('/stock/serials/next-sequence', { params: { itemId, prefix } })
.then(r => r.data.next),
updateMovementReason: (id: string, reasonCode: string) => updateMovementReason: (id: string, reasonCode: string) =>
api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data), api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data),
} }
+50
View File
@@ -22,3 +22,53 @@ export const sysConfig = {
set: (key: string, value: string, userId?: string | null) => set: (key: string, value: string, userId?: string | null) =>
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data), api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
} }
// ─── GRN tax settings (must match Distribution.Features.SysConfig.SysConfigService) ──────────
export type TaxMode = 'Disabled' | 'Optional' | 'Mandatory'
export const GRN_TAX_KEYS = {
vatMode: 'Grn.Tax.Vat.Mode',
vatRate: 'Grn.Tax.Vat.DefaultRate',
nbtMode: 'Grn.Tax.Nbt.Mode',
nbtRate: 'Grn.Tax.Nbt.DefaultRate',
} as const
export interface GrnTaxSettings {
vatMode: TaxMode
vatDefaultRate: number
nbtMode: TaxMode
nbtDefaultRate: number
}
const TAX_MODES: TaxMode[] = ['Disabled', 'Optional', 'Mandatory']
/** Unconfigured, VAT is Optional (as GRNs behaved before the setting existed) and NBT is off. */
export const DEFAULT_GRN_TAX_SETTINGS: GrnTaxSettings = {
vatMode: 'Optional',
vatDefaultRate: 0,
nbtMode: 'Disabled',
nbtDefaultRate: 0,
}
export function parseGrnTaxSettings(byKey: Record<string, string>): GrnTaxSettings {
const mode = (raw: string | undefined, fallback: TaxMode): TaxMode =>
TAX_MODES.includes(raw as TaxMode) ? (raw as TaxMode) : fallback
const rate = (raw: string | undefined) => {
const n = Number(raw)
return raw !== undefined && Number.isFinite(n) && n >= 0 && n <= 100 ? n : 0
}
return {
vatMode: mode(byKey[GRN_TAX_KEYS.vatMode], DEFAULT_GRN_TAX_SETTINGS.vatMode),
vatDefaultRate: rate(byKey[GRN_TAX_KEYS.vatRate]),
nbtMode: mode(byKey[GRN_TAX_KEYS.nbtMode], DEFAULT_GRN_TAX_SETTINGS.nbtMode),
nbtDefaultRate: rate(byKey[GRN_TAX_KEYS.nbtRate]),
}
}
export const grnTaxSettings = {
get: () =>
sysConfig
.getAll()
.then(all => parseGrnTaxSettings(Object.fromEntries(all.map(c => [c.key, c.value])))),
}
+23
View File
@@ -418,6 +418,19 @@ export interface GrnItemResponse {
rejectedQuantity: number rejectedQuantity: number
batches: GrnBatchLineResponse[] batches: GrnBatchLineResponse[]
serialNumbers: string[] serialNumbers: string[]
unitCost: number
discountPercent: number
vatPercent: number
/** Accepted qty x unit cost, before discount. */
grossAmount: number
discountAmount: number
vatAmount: number
/** Gross - discount + VAT. */
lineTotal: number
/** Free-issue units received at no charge (in stock, not costed). */
freeQuantity: number
nbtPercent: number
nbtAmount: number
} }
export interface GrnResponse { export interface GrnResponse {
@@ -432,6 +445,16 @@ export interface GrnResponse {
receivedAt: string receivedAt: string
createdAt: string createdAt: string
items: GrnItemResponse[] items: GrnItemResponse[]
discountPercent: number
vatPercent: number
subTotal: number
discountAmount: number
/** Subtotal - discount; the base NBT is charged on (VAT is charged on this plus NBT). */
taxableAmount: number
vatAmount: number
totalAmount: number
nbtPercent: number
nbtAmount: number
} }
export interface PurchaseReturnItemResponse { export interface PurchaseReturnItemResponse {