Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases. - Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation. - Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only. - Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities. - Implemented ItemContent service to validate and normalize content sizes. - Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations. - Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
This commit is contained in:
@@ -19,11 +19,9 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
@@ -34,11 +32,9 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
@@ -48,11 +44,9 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
_bundles = bundles;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
@@ -85,7 +79,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
template.CreatedAt,
|
||||
template.UpdatedAt,
|
||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
@@ -196,7 +190,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
|
||||
{
|
||||
ItemId = x.ItemId,
|
||||
UomId = x.UomId,
|
||||
WarehouseId = x.WarehouseId,
|
||||
Qty = x.Qty,
|
||||
UnitPrice = x.UnitPrice,
|
||||
@@ -214,17 +207,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = item.Name,
|
||||
Qty = qtyBase,
|
||||
UomId = item.BaseUomId,
|
||||
Qty = r.Qty,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = unitCostBase,
|
||||
UnitPrice = r.UnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
@@ -251,6 +242,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
||||
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
||||
x.CreatedAt, x.UpdatedAt,
|
||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
||||
|
||||
}
|
||||
|
||||
@@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<PoLine> _poLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -43,23 +41,21 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
_poLines = poLines;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_uomConverter = uomConverter;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
@@ -133,8 +129,6 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
@@ -150,6 +144,8 @@ public sealed class GrnService : IGrnService
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
// Both sides are counts of the item's base UOM — the GRN line no longer carries
|
||||
// a unit of its own — so this comparison and the accrual below are like-for-like.
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
@@ -174,7 +170,6 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
@@ -234,10 +229,11 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
|
||||
// enters stock value (docs/10 FR-GRN-06, revised).
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
|
||||
// enters stock value (docs/10 FR-GRN-06, revised). The line quantity is already
|
||||
// a count of the item's base UOM, so it layers exactly as entered.
|
||||
var qtyBase = line.Qty;
|
||||
var unitCostBase = line.NetUnitCost;
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
@@ -345,15 +341,6 @@ public sealed class GrnService : IGrnService
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
|
||||
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
|
||||
/// identical, so receive costing is unchanged.
|
||||
/// </summary>
|
||||
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
||||
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
|
||||
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return;
|
||||
@@ -392,7 +379,7 @@ public sealed class GrnService : IGrnService
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
|
||||
l.HoldStatus, l.BatchId)).ToList());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a production formula quantity to the only unit stock speaks: a count of the
|
||||
/// item's base UOM. Replaces the old per-item UOM conversion table — everything needed is
|
||||
/// on the item row, so this does no I/O.
|
||||
/// </summary>
|
||||
public interface IItemMeasure
|
||||
{
|
||||
/// <summary>
|
||||
/// Formula quantity → packs. <see cref="StageQtyUnit.Pack"/> passes straight through;
|
||||
/// <see cref="StageQtyUnit.Content"/> divides by the item's content size, so 2000 ml of
|
||||
/// a 500 ml bottle is 4 bottles and 300 ml is 0.6 of one.
|
||||
/// </summary>
|
||||
decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit);
|
||||
|
||||
/// <summary>Packs → formula quantity. The exact inverse of <see cref="ToPacks"/>, for display.</summary>
|
||||
decimal FromPacks(Item item, decimal packs, StageQtyUnit unit);
|
||||
|
||||
/// <summary>Whether the item carries a usable content size.</summary>
|
||||
bool HasContent(Item item);
|
||||
}
|
||||
@@ -24,6 +24,4 @@ public interface IItemService
|
||||
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ public interface ISalesDomainService
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
int lineUomId,
|
||||
int lineWarehouseId,
|
||||
decimal qty,
|
||||
decimal freeQty,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
|
||||
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
|
||||
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
|
||||
/// one place that bridges the two.</para>
|
||||
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
|
||||
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
|
||||
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
|
||||
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
|
||||
/// mis-cost the run).</para>
|
||||
/// </remarks>
|
||||
public interface IUomConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
|
||||
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
|
||||
/// conversion is defined for the item from that UOM to its base.
|
||||
/// </summary>
|
||||
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
|
||||
/// production stage input declares a quantity; its cost comes from the FIFO layers it
|
||||
/// consumes, not from the document).
|
||||
/// </summary>
|
||||
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item content-size rules: validate the (qty, unit) pair and normalise it to a base
|
||||
/// unit. Pure — no DI, no database — because everything it needs is on the item row.
|
||||
/// <para>
|
||||
/// Litres and kilograms exist only at the point of entry. Everything stored and every
|
||||
/// downstream calculation works in millilitres or grams, so no consumer ever has to ask
|
||||
/// which unit it is holding.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ItemContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Rejects a half-filled pair. Both null is valid and means "this item has no
|
||||
/// measurable content" — a screw, a label, a service.
|
||||
/// </summary>
|
||||
public static void ValidatePair(decimal? contentQty, MeasureUnit? contentUnit)
|
||||
{
|
||||
if (contentQty is null && contentUnit is null) return;
|
||||
|
||||
if (contentQty is null || contentUnit is null)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
"contentQty and contentUnit must be supplied together, or both left null.", 422);
|
||||
|
||||
if (contentQty <= 0)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation, "contentQty must be greater than zero.", 422);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an entered content size to its base unit: L → Ml and Kg → G, both ×1000;
|
||||
/// Ml and G pass through. Both-null in, both-null out.
|
||||
/// <para>
|
||||
/// Rounded to 4dp AwayFromZero to match the quantity columns' <c>(18,4)</c> scale and
|
||||
/// <c>ProductionRunService.Scale</c>, so a content size can never carry precision the
|
||||
/// database would silently drop.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static (decimal? BaseQty, MeasureUnit? BaseUnit) Normalize(
|
||||
decimal? contentQty, MeasureUnit? contentUnit)
|
||||
{
|
||||
if (contentQty is null || contentUnit is null) return (null, null);
|
||||
|
||||
var (factor, baseUnit) = contentUnit.Value switch
|
||||
{
|
||||
MeasureUnit.Ml => (1m, MeasureUnit.Ml),
|
||||
MeasureUnit.L => (1000m, MeasureUnit.Ml),
|
||||
MeasureUnit.G => (1m, MeasureUnit.G),
|
||||
MeasureUnit.Kg => (1000m, MeasureUnit.G),
|
||||
_ => throw new DomainException(
|
||||
ErrorCodes.Validation, $"Unsupported content unit '{contentUnit}'.", 422)
|
||||
};
|
||||
|
||||
return (Math.Round(contentQty.Value * factor, 4, MidpointRounding.AwayFromZero), baseUnit);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ public sealed class ItemService : IItemService
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<StockLedger> _stockLedger;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
@@ -42,6 +44,8 @@ public sealed class ItemService : IItemService
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Vendor> vendors,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLayer> stockLayers,
|
||||
IRepository<StockLedger> stockLedger,
|
||||
IProductConfigService config,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -52,6 +56,8 @@ public sealed class ItemService : IItemService
|
||||
_uoms = uoms;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_stockLayers = stockLayers;
|
||||
_stockLedger = stockLedger;
|
||||
_config = config;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -79,7 +85,9 @@ public sealed class ItemService : IItemService
|
||||
.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status))
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -89,7 +97,6 @@ public sealed class ItemService : IItemService
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
||||
@@ -104,6 +111,9 @@ public sealed class ItemService : IItemService
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
Sku = request.Sku.Trim(),
|
||||
@@ -118,6 +128,10 @@ public sealed class ItemService : IItemService
|
||||
TrackingMode = request.TrackingMode,
|
||||
TaxClass = request.TaxClass,
|
||||
SalePrice = request.SalePrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
ContentBaseUnit = contentBaseUnit,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -133,7 +147,6 @@ public sealed class ItemService : IItemService
|
||||
{
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
@@ -148,6 +161,18 @@ public sealed class ItemService : IItemService
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
// The base UOM is the sole meaning of every quantity recorded against this item —
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
// of that history exists, changing it would silently reinterpret every one of those
|
||||
// numbers (240 bottles becoming 240 cases), so it is frozen instead.
|
||||
if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct))
|
||||
throw new DomainException(
|
||||
ErrorCodes.MasterInUse,
|
||||
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
@@ -160,6 +185,10 @@ public sealed class ItemService : IItemService
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.TaxClass = request.TaxClass;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
item.ContentUnit = request.ContentUnit;
|
||||
item.ContentBaseQty = contentBaseQty;
|
||||
item.ContentBaseUnit = contentBaseUnit;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync(ct);
|
||||
@@ -224,52 +253,14 @@ public sealed class ItemService : IItemService
|
||||
return new ItemReorderSettingsDto(settings);
|
||||
}
|
||||
|
||||
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
|
||||
int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
|
||||
if (pairs.Distinct().Count() != pairs.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
|
||||
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
|
||||
|
||||
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
|
||||
item.UomConversions.Remove(stale);
|
||||
foreach (var input in request.Conversions)
|
||||
{
|
||||
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
|
||||
if (existing is null)
|
||||
{
|
||||
item.UomConversions.Add(new UomConversion
|
||||
{
|
||||
ItemId = itemId,
|
||||
FromUomId = input.FromUom,
|
||||
ToUomId = input.ToUom,
|
||||
Factor = input.Factor
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Factor = input.Factor;
|
||||
}
|
||||
}
|
||||
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
var conversions = item.UomConversions
|
||||
.OrderBy(c => c.ConversionId)
|
||||
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||
.ToList();
|
||||
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
||||
}
|
||||
/// <summary>
|
||||
/// Whether anything has ever been recorded against this item's stock. Checks the ledger
|
||||
/// as well as live layers, because a fully consumed item has no layer left but its
|
||||
/// history still reads in the old unit.
|
||||
/// </summary>
|
||||
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
|
||||
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|
||||
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates every FK on an item write, and gates the optional ones on the product
|
||||
@@ -350,14 +341,12 @@ public sealed class ItemService : IItemService
|
||||
private static ItemDetailDto ToDetail(Item i) => new(
|
||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status,
|
||||
i.ReorderSettings
|
||||
.OrderBy(r => r.WarehouseId)
|
||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||
.ToList(),
|
||||
i.UomConversions
|
||||
.OrderBy(c => c.ConversionId)
|
||||
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||
.ToList(),
|
||||
i.CreatedAt, i.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ namespace ERPCore.Services.Production;
|
||||
/// </remarks>
|
||||
public static class ProductionGraphValidator
|
||||
{
|
||||
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
|
||||
public sealed record InputDraft(
|
||||
int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit);
|
||||
|
||||
public sealed record OutputDraft(string Key, string Name, int? ItemId);
|
||||
public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId);
|
||||
|
||||
public sealed record StageDraft(
|
||||
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
|
||||
@@ -171,6 +172,12 @@ public static class ProductionGraphValidator
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
|
||||
}
|
||||
|
||||
// WIP has no content size — it is counted in whatever unit its source output
|
||||
// declares — so only a Stock input may be expressed in ml/g.
|
||||
if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack)
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +196,20 @@ public static class ProductionGraphValidator
|
||||
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
|
||||
throw Invalid(
|
||||
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
|
||||
|
||||
// 7 — output units. An item-bearing output already has a unit (the item's base UOM),
|
||||
// so carrying a second one could only contradict it. WIP has no item to ask, so it
|
||||
// must name its own — the label the run board and any downstream input display.
|
||||
foreach (var s in stages)
|
||||
foreach (var o in s.Outputs)
|
||||
{
|
||||
if (o.ItemId is null && o.UomId is null)
|
||||
throw new DomainException(ErrorCodes.WipUnitRequired,
|
||||
$"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422);
|
||||
if (o.ItemId is not null && o.UomId is not null)
|
||||
throw Invalid(
|
||||
$"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IItemMeasure _measure;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -35,7 +35,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
IRepository<ProductionRun> runs, IRepository<ProductionTemplate> templates,
|
||||
IRepository<Warehouse> warehouses, IRepository<Bin> bins,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes,
|
||||
IFifoCostingService fifo, IUomConverter uomConverter,
|
||||
IFifoCostingService fifo, IItemMeasure measure,
|
||||
INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser)
|
||||
{
|
||||
_runs = runs;
|
||||
@@ -45,7 +45,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_fifo = fifo;
|
||||
_uomConverter = uomConverter;
|
||||
_measure = measure;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
_currentUser = currentUser;
|
||||
@@ -245,7 +245,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
Source = i.Source,
|
||||
ItemId = i.ItemId,
|
||||
FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value],
|
||||
UomId = i.UomId,
|
||||
QtyUnit = i.QtyUnit,
|
||||
PlannedQty = Scale(i.QtyPerBatch, ratio)
|
||||
});
|
||||
}
|
||||
@@ -367,9 +367,11 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
|
||||
|
||||
// PlannedQty is in the input's declared UOM; ConsumedQty is in the item's
|
||||
// base UOM (the only unit FIFO and the ledger speak). Compare in base.
|
||||
var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token);
|
||||
// PlannedQty is in the input's declared unit — content (ml/g) for an item with
|
||||
// a content size, otherwise packs. ConsumedQty is always packs, the only unit
|
||||
// FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml
|
||||
// bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly.
|
||||
var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit);
|
||||
var delta = plannedBase - input.ConsumedQty;
|
||||
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
|
||||
|
||||
@@ -1057,7 +1059,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
$"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods "
|
||||
+ "are not supported in this phase.", 422);
|
||||
|
||||
var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct);
|
||||
// Output quantities are always a count of the finished item's base UOM — produced and
|
||||
// scrapped are recorded in whole bottles, not millilitres — so nothing to resolve.
|
||||
var qtyBase = good;
|
||||
|
||||
var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
||||
var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
||||
@@ -1223,7 +1227,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
|
||||
ParseJson(s.FieldValues),
|
||||
s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto(
|
||||
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId,
|
||||
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit,
|
||||
i.PlannedQty, i.ConsumedQty, i.ConsumedValue,
|
||||
i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(),
|
||||
s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto(
|
||||
|
||||
@@ -253,19 +253,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
ProductionGraphValidator.Validate(
|
||||
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
|
||||
s.Key, s.Name,
|
||||
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
|
||||
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
|
||||
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey, i.QtyUnit)).ToList(),
|
||||
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId, o.UomId)).ToList())).ToList(),
|
||||
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
|
||||
|
||||
var itemIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
|
||||
.OfType<int>().Distinct().ToList();
|
||||
|
||||
var contentByItem = new Dictionary<int, decimal?>();
|
||||
if (itemIds.Count > 0)
|
||||
{
|
||||
var found = await _items.Query().AsNoTracking()
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => new { i.ItemId, i.Status })
|
||||
.Select(i => new { i.ItemId, i.Status, i.ContentBaseQty })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
|
||||
@@ -277,19 +278,42 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
if (inactive.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
|
||||
|
||||
contentByItem = found.ToDictionary(f => f.ItemId, f => f.ContentBaseQty);
|
||||
}
|
||||
|
||||
// A Content quantity is only meaningful against an item that declares a content size,
|
||||
// and it must still land on a storable pack count: quantities persist at (18,4), so
|
||||
// anything under 0.0001 packs would round to zero and consume nothing at stage start.
|
||||
foreach (var s in request.Stages)
|
||||
foreach (var i in s.Inputs.Where(i => i.QtyUnit == StageQtyUnit.Content))
|
||||
{
|
||||
var contentBaseQty = contentByItem.GetValueOrDefault(i.ItemId ?? 0);
|
||||
if (contentBaseQty is not > 0m)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Stage '{s.Name}' has an input in content units, but item {i.ItemId} has no content size.", 422);
|
||||
|
||||
if (i.QtyPerBatch / contentBaseQty.Value < 0.0001m)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Stage '{s.Name}': {i.QtyPerBatch} is less than 0.0001 of item {i.ItemId}'s content size ({contentBaseQty}), which would round to no stock at all.", 422);
|
||||
}
|
||||
|
||||
// Only WIP outputs carry a UOM now; the validator has already rejected one on an
|
||||
// item-bearing output, so every non-null id here belongs to real work-in-progress.
|
||||
var uomIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
|
||||
.Distinct().ToList();
|
||||
.SelectMany(s => s.Outputs.Select(o => o.UomId))
|
||||
.OfType<int>().Distinct().ToList();
|
||||
|
||||
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||
if (uomIds.Count > 0)
|
||||
{
|
||||
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||
|
||||
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||
if (missingUoms.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||
if (missingUoms.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
}
|
||||
|
||||
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
|
||||
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||
@@ -428,7 +452,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
Source = i.Source,
|
||||
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
|
||||
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
|
||||
UomId = i.UomId,
|
||||
QtyUnit = i.QtyUnit,
|
||||
QtyPerBatch = i.QtyPerBatch
|
||||
});
|
||||
}
|
||||
@@ -476,7 +500,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
|
||||
i.InputId, i.Source, i.ItemId, i.FromOutputId,
|
||||
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
|
||||
i.UomId, i.QtyPerBatch)).ToList(),
|
||||
i.QtyUnit, i.QtyPerBatch)).ToList(),
|
||||
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
|
||||
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
|
||||
.ToList();
|
||||
|
||||
@@ -222,7 +222,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
UnitPrice = l.UnitPrice,
|
||||
@@ -258,7 +257,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
|
||||
|
||||
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
|
||||
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
|
||||
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
|
||||
}
|
||||
|
||||
@@ -276,5 +274,5 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
||||
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
||||
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
|
||||
public SalesDomainService(
|
||||
@@ -21,14 +20,12 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
ISalesPricingService pricing)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_pricing = pricing;
|
||||
}
|
||||
|
||||
@@ -48,7 +45,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
}
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
if (qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422);
|
||||
@@ -60,8 +57,6 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422);
|
||||
if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct))
|
||||
throw new NotFoundException($"Item {lineItemId} was not found.");
|
||||
if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct))
|
||||
throw new NotFoundException($"UOM {lineUomId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {lineWarehouseId} was not found.");
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -158,7 +158,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||
MapInvoiceTotals(invoice),
|
||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt,
|
||||
MapSlipTotals(slip),
|
||||
slip.Lines.Select(l => new SalesSlipLineDto(
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
@@ -31,7 +30,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -41,7 +39,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -141,7 +138,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
@@ -154,7 +151,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
@@ -167,9 +164,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||
// stored base quantity directly instead of converting again.
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
@@ -203,6 +198,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
|
||||
continue;
|
||||
|
||||
// Line quantities are already a count of the item's base UOM — sales documents
|
||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
@@ -214,5 +211,5 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -182,7 +182,6 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
@@ -222,8 +221,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
.Where(i => i.ItemId == line.ItemId)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId })
|
||||
.FirstOrDefault();
|
||||
var uom = line is null ? null : _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == line.UomId)
|
||||
// The line carries no unit of its own — its quantity is a count of the item's base
|
||||
// UOM — so the display name comes from there.
|
||||
var uom = item is null ? null : _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == item.BaseUomId)
|
||||
.Select(u => new { u.UomId, u.Name })
|
||||
.FirstOrDefault();
|
||||
var warehouse = _warehouses.Query().AsNoTracking()
|
||||
@@ -240,8 +241,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
line?.ItemId ?? 0,
|
||||
item?.Sku ?? $"SKU-{line?.ItemId ?? 0}",
|
||||
item?.Name ?? line?.Description ?? "—",
|
||||
line?.UomId ?? 0,
|
||||
uom?.Name ?? $"UOM {line?.UomId ?? 0}",
|
||||
uom?.Name ?? $"UOM {item?.BaseUomId ?? 0}",
|
||||
line?.Qty ?? 0m,
|
||||
line?.FreeQty ?? 0m,
|
||||
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
|
||||
@@ -263,7 +263,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
summary,
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
|
||||
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Content ↔ pack arithmetic (see <see cref="IItemMeasure"/>). Stateless and I/O-free:
|
||||
/// the content size is already on the <see cref="Item"/> every caller has loaded.
|
||||
/// </summary>
|
||||
public sealed class ItemMeasure : IItemMeasure
|
||||
{
|
||||
public bool HasContent(Item item) => item.ContentBaseQty is > 0m;
|
||||
|
||||
public decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit)
|
||||
{
|
||||
if (unit == StageQtyUnit.Pack) return formulaQty;
|
||||
|
||||
// Rounded to the quantity columns' (18,4) scale with the same mode as
|
||||
// ProductionRunService.Scale, so what FIFO consumes and what the cost pool divides
|
||||
// by are the same number to the last stored digit.
|
||||
return Math.Round(formulaQty / RequireContent(item), 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public decimal FromPacks(Item item, decimal packs, StageQtyUnit unit)
|
||||
{
|
||||
if (unit == StageQtyUnit.Pack) return packs;
|
||||
|
||||
return Math.Round(packs * RequireContent(item), 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal RequireContent(Item item)
|
||||
=> item.ContentBaseQty is > 0m
|
||||
? item.ContentBaseQty.Value
|
||||
: throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Item {item.ItemId} has no content size, so its quantity cannot be expressed in content units.", 422);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
|
||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||
/// receive path keeps costing exactly as before.
|
||||
/// </summary>
|
||||
public sealed class UomConverter : IUomConverter
|
||||
{
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
|
||||
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
|
||||
|
||||
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
// Quantity scales up by the factor, so the per-unit cost scales down by it —
|
||||
// total value is preserved.
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
}
|
||||
|
||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
||||
}
|
||||
Reference in New Issue
Block a user