Fix changes #70

Merged
DeepnaPooja merged 38 commits from fix-changes into dev 2026-10-19 16:45:55 +05:30
42 changed files with 15821 additions and 792 deletions
Showing only changes of commit f5967497be - Show all commits
+2 -1
View File
@@ -32,7 +32,8 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
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);
}
+48 -6
View File
@@ -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);
+37
View File
@@ -0,0 +1,37 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+101 -19
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po;
using Distribution.Features.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 "&lt;prefix&gt;0001" for an item.</summary>
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{
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"
});
});
+3
View File
@@ -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 }
}
+13
View File
@@ -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; } = [];
}
}
+20
View File
@@ -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; } = [];
+36 -9
View File
@@ -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.
+2
View File
@@ -54,6 +54,7 @@ Running log of backend API failures found during testing and the corresponding f
| 12 | Pricing → Schemes (action icons) | Same recurring pattern as Price Lists (#45): `DeleteAsync`'s `repo.Remove` already soft-deleted (`IsActive = false`), but `GetAllAsync`/`GetByIdAsync`/`UpdateAsync` filtered/blocked on `IsActive`, hiding a deleted scheme from the list entirely and blocking viewing/editing/reactivating it. `EvaluateEligibilityAsync` relied on that same repo-level filter to only ever consider active schemes as redemption candidates — removing the filter without care would have silently let inactive/deleted schemes be evaluated as eligible. Also `GetAll` returns a plain `List<SchemeResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added View (details modal — rule type, region, segment, date range, trigger/free-goods payout, bundle components) and Delete (soft-deactivate, reuses the existing "Active" checkbox in the Edit form to reactivate) icons | Removed the `IsActive` filter from `SchemeRepository.GetAllAsync`; moved the active-only filter into `SchemeService.EvaluateEligibilityAsync` itself (`.Where(s => s.IsActive)` on the fetched candidates) so eligibility evaluation is unaffected; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdateSchemeRequest`; switched the frontend to client-side pagination (same fix as Price Lists/Orders/Brands); added `schemes.remove()`; created `SchemeViewSheet` | 2026-09-09 |
| 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>
)
}
@@ -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&apos;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&apos;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>
)
}
+1 -1
View File
@@ -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'
+15
View File
@@ -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
}
+6
View File
@@ -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),
}
+50
View File
@@ -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])))),
}
+23
View File
@@ -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 {