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:
@@ -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" });
|
||||
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);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
@@ -13,20 +13,36 @@ public record GrnItemRequest(
|
||||
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
|
||||
List<string>? SerialNumbers = null,
|
||||
// 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(
|
||||
Guid WarehouseId,
|
||||
List<GrnItemRequest> Items,
|
||||
// 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(
|
||||
Guid PurchaseOrderId,
|
||||
Guid WarehouseId,
|
||||
List<GrnItemRequest> Items,
|
||||
// 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>
|
||||
public record CreateDirectGrnRequest(
|
||||
@@ -34,7 +50,10 @@ public record CreateDirectGrnRequest(
|
||||
List<GrnItemRequest> Items,
|
||||
Guid? SupplierId = null,
|
||||
// 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);
|
||||
|
||||
@@ -47,7 +66,19 @@ public record GrnItemResponse(
|
||||
int AcceptedQuantity,
|
||||
int RejectedQuantity,
|
||||
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(
|
||||
Guid Id,
|
||||
@@ -59,4 +90,15 @@ public record GrnResponse(
|
||||
string? SupplierName,
|
||||
DateTime ReceivedAt,
|
||||
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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -11,22 +12,86 @@ public class GrnService(
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow,
|
||||
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)
|
||||
{
|
||||
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
|
||||
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,
|
||||
i.AcceptedQuantity, i.RejectedQuantity,
|
||||
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) =>
|
||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
|
||||
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.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)
|
||||
{
|
||||
@@ -55,9 +120,9 @@ public class GrnService(
|
||||
{
|
||||
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(
|
||||
$"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)
|
||||
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
|
||||
@@ -68,11 +133,11 @@ public class GrnService(
|
||||
}
|
||||
else if (item.TracksBatches || item.TracksExpiryDate)
|
||||
{
|
||||
if (line.AcceptedQuantity > 0)
|
||||
if (StockQty(line) > 0)
|
||||
{
|
||||
if (line.Batches is null || line.Batches.Count == 0)
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -96,7 +161,8 @@ public class GrnService(
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// PartiallyReceived/Received. All in one unit of work.
|
||||
/// </summary>
|
||||
@@ -115,12 +181,18 @@ public class GrnService(
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
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
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
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)>();
|
||||
@@ -136,7 +208,7 @@ public class GrnService(
|
||||
if (poItem.IsCancelled)
|
||||
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.");
|
||||
|
||||
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||
@@ -160,8 +232,10 @@ public class GrnService(
|
||||
{
|
||||
FK_PurchaseOrderItemId = poItem.Id,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
FreeQuantity = line.FreeQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
};
|
||||
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
|
||||
goodsReceipt.Items.Add(entity);
|
||||
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 })
|
||||
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
|
||||
{
|
||||
FK_PurchaseOrderId = null,
|
||||
FK_SupplierId = request.SupplierId,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
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)>();
|
||||
@@ -206,10 +286,10 @@ public class GrnService(
|
||||
if (item is null || !item.IsActive)
|
||||
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.");
|
||||
|
||||
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.");
|
||||
|
||||
ValidateLineShape(item, line);
|
||||
@@ -228,9 +308,11 @@ public class GrnService(
|
||||
var entity = new Models.GoodsReceiptItem
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
FreeQuantity = line.FreeQuantity,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
};
|
||||
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
|
||||
goodsReceipt.Items.Add(entity);
|
||||
grLines.Add((entity, line, item));
|
||||
}
|
||||
@@ -255,7 +337,7 @@ public class GrnService(
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -369,12 +451,12 @@ public class GrnService(
|
||||
FK_ItemId = item.Id,
|
||||
LocationId = warehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity
|
||||
Quantity = StockQty(line)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.AcceptedQuantity;
|
||||
balance.Quantity += StockQty(line);
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
@@ -386,7 +468,7 @@ public class GrnService(
|
||||
FromLocationType = LocationType.External,
|
||||
ToLocationId = warehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity,
|
||||
Quantity = StockQty(line),
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
|
||||
@@ -28,5 +28,7 @@ public interface IStockRepository
|
||||
|
||||
// Serial numbers
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
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")]
|
||||
public async Task<IActionResult> GetMovements(
|
||||
[FromQuery] Guid? locationId,
|
||||
|
||||
@@ -107,6 +107,26 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
public Task<bool> SerialNumberExistsAsync(Guid itemId, string 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) =>
|
||||
await db.ItemSerialNumbers.AddAsync(serial);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@ public class StockService(
|
||||
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The next unused sequence number for generating serials like "<prefix>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)
|
||||
{
|
||||
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 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 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 =
|
||||
[
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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 _))
|
||||
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))
|
||||
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")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("DiscountPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<Guid?>("FK_PurchaseOrderId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -442,9 +446,17 @@ namespace Distribution.Migrations
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("NbtPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<DateTime>("ReceivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("VatPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_PurchaseOrderId");
|
||||
@@ -460,11 +472,14 @@ namespace Distribution.Migrations
|
||||
{
|
||||
Id = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_PurchaseOrderId = new Guid("41000000-0000-0000-0000-000000000001"),
|
||||
FK_ReceivedByUserId = new Guid("29000000-0000-0000-0000-000000000001"),
|
||||
FK_WarehouseId = new Guid("27000000-0000-0000-0000-000000000001"),
|
||||
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")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("DiscountPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<Guid>("FK_GoodsReceiptId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -489,12 +508,27 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid?>("FK_PurchaseOrderItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("FreeQuantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("NbtPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<int>("RejectedQuantity")
|
||||
.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.HasIndex("FK_GoodsReceiptId");
|
||||
@@ -511,20 +545,30 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
AcceptedQuantity = 200,
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000001"),
|
||||
FreeQuantity = 0,
|
||||
IsActive = true,
|
||||
RejectedQuantity = 0
|
||||
NbtPercent = 0m,
|
||||
RejectedQuantity = 0,
|
||||
UnitCost = 0m,
|
||||
VatPercent = 0m
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
AcceptedQuantity = 98,
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000002"),
|
||||
FreeQuantity = 0,
|
||||
IsActive = true,
|
||||
RejectedQuantity = 2
|
||||
NbtPercent = 0m,
|
||||
RejectedQuantity = 2,
|
||||
UnitCost = 0m,
|
||||
VatPercent = 0m
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1500,17 +1544,17 @@ namespace Distribution.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("24000000-0000-0000-0000-000000000001"),
|
||||
Code = "REG-0001",
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
IsActive = true,
|
||||
Code = "REG-0001",
|
||||
Name = "Western Province"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("24000000-0000-0000-0000-000000000002"),
|
||||
Code = "REG-0002",
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
IsActive = true,
|
||||
Code = "REG-0002",
|
||||
Name = "Central Province"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,5 +45,8 @@ namespace Distribution.Models
|
||||
/// </summary>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
@@ -27,6 +28,18 @@ namespace Distribution.Models
|
||||
|
||||
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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
@@ -24,6 +25,25 @@ namespace Distribution.Models
|
||||
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
||||
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>
|
||||
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];
|
||||
|
||||
|
||||
@@ -97,11 +97,18 @@ PurchaseOrderItem
|
||||
├── 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
|
||||
├── 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_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into.
|
||||
│ Not anticipated in the original design — added because
|
||||
@@ -116,9 +123,13 @@ GoodsReceipt
|
||||
GoodsReceiptItem
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── 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
|
||||
├── 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
|
||||
@@ -154,11 +165,16 @@ gives one receiving warehouse per PO in practice; see Open Questions for the mul
|
||||
|
||||
## Migrations
|
||||
|
||||
One migration, applied:
|
||||
1. **AddProcurement** (`20260824110925_AddProcurement`) — created `PurchaseOrders`,
|
||||
`PurchaseOrderItems`, `GoodsReceipts`, `GoodsReceiptItems`, `PurchaseReturns`,
|
||||
`PurchaseReturnItems`, with FKs to `Suppliers`/`Items`/`Warehouses` and indexes on every FK
|
||||
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` |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
| `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}` |
|
||||
| `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` |
|
||||
| `listGoodsReceipts(page)` | `GET /api/goods-receipts` |
|
||||
|
||||
### Purchase Returns (`Features/PurchaseReturn`)
|
||||
| Function reference | Endpoint |
|
||||
@@ -215,8 +237,12 @@ One migration, applied:
|
||||
{ "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 }
|
||||
] }
|
||||
```
|
||||
`rejectedQuantity` defaults to 0 if omitted; only `acceptedQuantity` is credited to
|
||||
`StockBalance`.
|
||||
`rejectedQuantity` defaults to 0 if omitted. `acceptedQuantity` **plus `freeQuantity`** (free-issue
|
||||
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`
|
||||
```json
|
||||
@@ -297,9 +323,10 @@ above) per 14's existing target design.
|
||||
- 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
|
||||
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 —
|
||||
`PurchaseOrderItem.UnitCost` is the only cost basis captured. Defer unless accurate per-unit cost
|
||||
is needed sooner.
|
||||
- Landed cost (freight, duty allocated onto received stock) is out of scope. The GRN now records
|
||||
unit cost, discount, NBT and VAT per line (see
|
||||
[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
|
||||
stays fully manual until Notifications & Alerts exists.
|
||||
- `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
|
||||
`Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden
|
||||
entirely, not just disabled, to avoid confusing item editors.
|
||||
- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct:
|
||||
`/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial
|
||||
- **GRN entry** — now **one page**, `/procurement/grn/new`, with an *Against PO / Direct GRN* switch
|
||||
(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).
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
pages and both share the same serial/batch entry panel.
|
||||
update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as one
|
||||
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
|
||||
- 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),
|
||||
or type/paste one per line; every entry stays editable; duplicates are flagged.
|
||||
- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units;
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+22
-25
@@ -45,7 +45,7 @@ function CapabilityToggle({ id, label, description, checked, disabled, onChecked
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -119,25 +119,24 @@ export function ProductCapabilitiesCard() {
|
||||
return loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<Card className="max-w-4xl rounded-3xl border-border/60">
|
||||
<CardHeader className="border-b border-border/60 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<HugeiconsIcon icon={Package01Icon} size={18} />
|
||||
<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={Package01Icon} size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Products</CardTitle>
|
||||
<CardDescription className="text-xs">Item tracking options</CardDescription>
|
||||
</div>
|
||||
<CardTitle className="text-lg">Products</CardTitle>
|
||||
</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's edit form.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
|
||||
<CapabilityToggle
|
||||
id="serialNumberTracking"
|
||||
label="Serial number tracking"
|
||||
description="Track individual units of an item by a unique serial number."
|
||||
description="One serial per unit"
|
||||
checked={form.serialNumberTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
|
||||
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
|
||||
@@ -146,7 +145,7 @@ export function ProductCapabilitiesCard() {
|
||||
<CapabilityToggle
|
||||
id="batchTracking"
|
||||
label="Batch / lot tracking"
|
||||
description="Group stock into batches or lots for traceability."
|
||||
description="Group stock by batch"
|
||||
checked={form.batchTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
|
||||
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
|
||||
@@ -155,7 +154,7 @@ export function ProductCapabilitiesCard() {
|
||||
<CapabilityToggle
|
||||
id="expiryDateTracking"
|
||||
label="Expiry date tracking"
|
||||
description="Record and monitor expiry dates on perishable stock."
|
||||
description="Record expiry dates"
|
||||
checked={form.expiryDateTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
|
||||
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
|
||||
@@ -164,7 +163,7 @@ export function ProductCapabilitiesCard() {
|
||||
<CapabilityToggle
|
||||
id="warrantyTracking"
|
||||
label="Warranty periods"
|
||||
description="Attach a warranty period to items at the time of sale."
|
||||
description="Warranty on sale"
|
||||
checked={form.warrantyTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
|
||||
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
|
||||
@@ -173,24 +172,24 @@ export function ProductCapabilitiesCard() {
|
||||
|
||||
{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">
|
||||
Warranty defaults
|
||||
</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
|
||||
id="customWarrantyOverride"
|
||||
label="Per-item custom warranty"
|
||||
description="Allow individual items to override the default warranty period."
|
||||
description="Items can override the default"
|
||||
checked={form.customWarrantyOverride}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
|
||||
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">
|
||||
Default warranty period
|
||||
</FieldLabel>
|
||||
@@ -206,9 +205,7 @@ export function ProductCapabilitiesCard() {
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
<FieldDescription className="text-xs">
|
||||
Applied to items that don't set their own warranty period.
|
||||
</FieldDescription>
|
||||
<FieldDescription className="text-xs">Used when an item sets none</FieldDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||
import { GrnTaxCard } from './_components/grn-tax-card'
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,16 +124,10 @@ export function GrnClient() {
|
||||
Stock received into a warehouse, against a purchase order or directly
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
Direct Receipt
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/procurement/grn/po/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
Receipt Against PO
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/procurement/grn/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
New GRN
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { stock as stockApi } from '@/lib/api/stock'
|
||||
import type { GrnBatchLineBody } from '@/lib/api/procurement'
|
||||
import type { ItemSummaryResponse } from '@/types/api'
|
||||
|
||||
@@ -71,6 +72,8 @@ interface LineTrackingProps {
|
||||
batches: BatchRow[]
|
||||
onSerialText: (v: string) => 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
|
||||
* 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 [scanValue, setScanValue] = useState('')
|
||||
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
|
||||
const [genStart, setGenStart] = useState(1)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const progress = trackingProgress(info, accepted, serialText, batches)
|
||||
if (!progress) return null
|
||||
|
||||
@@ -98,19 +111,38 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
|
||||
setScanValue('')
|
||||
}
|
||||
|
||||
// Appends generated serials (prefix + zero-padded sequence) for the units still missing,
|
||||
// skipping any value already in the list. They stay editable in the list below.
|
||||
const generateSerials = () => {
|
||||
// Appends generated serials (prefix + zero-padded sequence) for the units still missing. Numbering
|
||||
// continues after the highest number already saved for this item (asked from the server), so a
|
||||
// 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 taken = new Set(existing)
|
||||
const taken = new Set([...existing, ...otherSerials])
|
||||
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[] = []
|
||||
for (let n = genStart; generated.length < missing; n++) {
|
||||
let n = start
|
||||
while (generated.length < missing) {
|
||||
const sn = `${genPrefix}${String(n).padStart(4, '0')}`
|
||||
if (!taken.has(sn)) generated.push(sn)
|
||||
n++
|
||||
}
|
||||
onSerialText([...existing, ...generated].join('\n'))
|
||||
setGenStart(genStart + missing)
|
||||
setGenStart(n)
|
||||
}
|
||||
|
||||
const isSerial = progress.kind === 'serial'
|
||||
@@ -145,7 +177,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{name} ({info.sku}) — accepting {accepted}
|
||||
{name} ({info.sku}) — receiving {accepted}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -180,7 +212,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={accepted - progress.done <= 0}
|
||||
disabled={accepted - progress.done <= 0 || generating}
|
||||
onClick={generateSerials}
|
||||
>
|
||||
Auto-generate {Math.max(0, accepted - progress.done)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { GrnResponse } from '@/types/api'
|
||||
import { money } from './grn-costing'
|
||||
|
||||
const fmtDateTime = (v: string) =>
|
||||
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) {
|
||||
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 (
|
||||
<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>
|
||||
<DialogTitle>Goods Receipt Details</DialogTitle>
|
||||
</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-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">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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -94,11 +103,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
||||
</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.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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export { stock } from './stock'
|
||||
export { productReports } from './reports'
|
||||
export { shopPos, invoices, payments } from './sales'
|
||||
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 type { PageParams } from './master'
|
||||
|
||||
@@ -77,6 +77,14 @@ export interface GrnItemBody {
|
||||
batches?: GrnBatchLineBody[]
|
||||
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
|
||||
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 {
|
||||
@@ -84,6 +92,10 @@ export interface GrnBody {
|
||||
warehouseId: string
|
||||
receivedAt: string
|
||||
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.
|
||||
receivedByUserId: string
|
||||
}
|
||||
@@ -92,6 +104,9 @@ export interface DirectGrnBody {
|
||||
warehouseId: string
|
||||
supplierId?: string
|
||||
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.
|
||||
receivedByUserId: string
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ export const stock = {
|
||||
adjust: (body: AdjustmentBody) =>
|
||||
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) =>
|
||||
api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -22,3 +22,53 @@ export const sysConfig = {
|
||||
set: (key: string, value: string, userId?: string | null) =>
|
||||
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])))),
|
||||
}
|
||||
|
||||
@@ -418,6 +418,19 @@ export interface GrnItemResponse {
|
||||
rejectedQuantity: number
|
||||
batches: GrnBatchLineResponse[]
|
||||
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 {
|
||||
@@ -432,6 +445,16 @@ export interface GrnResponse {
|
||||
receivedAt: string
|
||||
createdAt: string
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user