serial numbers

This commit is contained in:
2026-10-07 14:00:32 +05:30
committed by ImanThiyanga
parent 3abcfa6e6f
commit a788349138
89 changed files with 15320 additions and 216 deletions
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
[Route("api/v1/serials")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class SerialsController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SerialSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
[HttpGet("lookup")]
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> Lookup([FromQuery] string serialNo, CancellationToken ct)
=> Ok(await _tracking.FindSerialAsync(serialNo, ct));
[HttpGet("{serialId:int}")]
[ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
{
var dto = await _tracking.GetSerialAsync(serialId, ct);
return dto is null ? NotFound() : Ok(dto);
}
}
+14 -1
View File
@@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
private readonly IReorderService _reorder;
private readonly IItemTrackingService _tracking;
public StockController(IStockService stock, IReorderService reorder)
public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{
_stock = stock;
_reorder = reorder;
_tracking = tracking;
}
/// <summary>
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
/// </summary>
[HttpGet("batches")]
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BatchStockDto>>> Batches(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
[HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Serial/batch pickers used across GRN, sales, returns and stock screens
/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
/// only ERP access — not one module's nav permission — as every one of those screens calls them.
/// </summary>
[Route("api/v1/tracking")]
public sealed class TrackingController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
private readonly ISerialTrackingService _serialTracking;
public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
{
_tracking = tracking;
_serialTracking = serialTracking;
}
/// <summary>
/// Which tracking features are switched on (Product Configuration) — readable by every
/// screen, unlike <c>/product-config</c> which needs the Products permission.
/// </summary>
[HttpGet("settings")]
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
/// <summary>
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
/// uniqueness when it is saved and confirmed.
/// </summary>
[HttpGet("serials/next-numbers")]
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
[HttpGet("serials/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> AvailableSerials(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
=> Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
[HttpGet("batches/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
[HttpGet("batches/suggest")]
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
/// posting a sale, never directly — this controller only reads.
/// </summary>
[Route("api/v1/warranties")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class WarrantiesController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> List(
[FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
[FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct));
}
@@ -65,4 +65,12 @@ public class GrnLine
/// Empty for a non-warranty item.
/// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
/// <summary>
/// One row per received unit when <see cref="Item.TrackingMode"/> is
/// <see cref="TrackingMode.Serial"/> — count must equal <see cref="Qty"/>. For a serial item
/// under warranty the serial is the warranty identity, so it carries no
/// <see cref="WarrantyNumbers"/>. Empty otherwise.
/// </summary>
public ICollection<GrnLineSerial> Serials { get; set; } = new List<GrnLineSerial>();
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A serial number captured on a Draft GRN line for a serial-tracked item
/// (<see cref="Item.TrackingMode"/> = <see cref="Enums.TrackingMode.Serial"/>). The line must
/// carry exactly <see cref="GrnLine.Qty"/> of these. The draft holds the numbers only — the
/// <see cref="Serial"/> rows (and their qty-1 FIFO layers) are created when the GRN is
/// confirmed, so a draft that is never confirmed reserves nothing (docs/ITEM_TRACKING_PLAN.md Phase 2).
/// </summary>
public class GrnLineSerial
{
public int GrnLineSerialId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string SerialNo { get; set; } = string.Empty;
/// <summary>Set on confirm — the <see cref="Serial"/> created for this number.</summary>
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
}
@@ -19,6 +19,13 @@ public class SalesInvoiceLine
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Pins the line to one batch; null lets posting pick batches FEFO.</summary>
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesInvoiceLineSerial> Serials { get; set; } = new List<SalesInvoiceLineSerial>();
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// What a posted sales line actually issued: one row per consumed FIFO segment, carrying the
/// batch (FEFO-picked when the line named none) and, for serial items, the serial. The source
/// line is referenced polymorphically (<see cref="SourceDocType"/> is a
/// <see cref="Domain.DocumentTypes"/> value) — same convention as <see cref="StockLedger"/> —
/// so invoices, slips and bundles share one table. Returns read it to restock into the same
/// batch/serial (docs/ITEM_TRACKING_PLAN.md Phases 4 and 6).
/// </summary>
public class SalesLineAllocation
{
public int SalesLineAllocationId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public int SourceLineId { get; set; }
public int ItemId { get; set; }
public int WarehouseId { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
/// <summary>Quantity of this allocation already taken back by sales returns.</summary>
public decimal QtyReturned { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,29 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A serial chosen on a Draft sales-invoice line for a serial-tracked item — one row per unit
/// (count = Qty + FreeQty). Choosing does not reserve: posting re-checks each serial is still
/// in stock and consumes exactly its layer (docs/ITEM_TRACKING_PLAN.md Phase 4, D4).
/// </summary>
public class SalesInvoiceLineSerial
{
public int SalesInvoiceLineSerialId { get; set; }
public int SalesInvoiceLineId { get; set; }
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
}
/// <summary>Sales-slip counterpart of <see cref="SalesInvoiceLineSerial"/>.</summary>
public class SalesSlipLineSerial
{
public int SalesSlipLineSerialId { get; set; }
public int SalesSlipLineId { get; set; }
public SalesSlipLine? SalesSlipLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
}
@@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM.
/// <see cref="Qty"/> is in base UOM. <see cref="BatchId"/> is the batch the goods are restocked
/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6); <see cref="Serials"/>
/// lists the returned units of a serial-tracked item.
/// </summary>
public class SalesReturnLine
{
@@ -18,4 +20,21 @@ public class SalesReturnLine
public Item? Item { get; set; }
public decimal Qty { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public ICollection<SalesReturnLineSerial> Serials { get; set; } = new List<SalesReturnLineSerial>();
}
/// <summary>One serial taken back on a <see cref="SalesReturnLine"/>.</summary>
public class SalesReturnLineSerial
{
public int SalesReturnLineSerialId { get; set; }
public int ReturnLineId { get; set; }
public SalesReturnLine? ReturnLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
}
@@ -22,9 +22,16 @@ public class SalesSlipLine
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>
/// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and
/// still set when exactly one serial is chosen; <see cref="Serials"/> is authoritative.
/// </summary>
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesSlipLineSerial> Serials { get; set; } = new List<SalesSlipLineSerial>();
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
+32 -2
View File
@@ -1,8 +1,13 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
/// Model: docs/10 Part C.4.
/// Created when the GRN that received it is confirmed; each in-stock serial owns exactly one
/// FIFO layer of qty 1 (<see cref="StockLayer.SerialId"/>), so issuing a serial consumes that
/// layer. <see cref="WarehouseId"/>/<see cref="BinId"/> are its current location and are
/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1.
/// </summary>
public class Serial
{
@@ -12,8 +17,33 @@ public class Serial
public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock";
public SerialStatus Status { get; set; } = SerialStatus.InStock;
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
public string? Barcode { get; set; }
/// <summary>Current warehouse while in stock; null once sold/returned to vendor/scrapped.</summary>
public int? WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
/// <summary>The GRN line that received this unit (provenance); null for count/adjustment finds.</summary>
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public DateTime? ReceivedAt { get; set; }
/// <summary>
/// Warranty coverage snapshotted from <see cref="Item.WarrantyPeriodMonths"/> at receipt
/// (same rule as <see cref="GrnLineWarrantyNumber.WarrantyPeriodMonths"/>); null for a
/// non-warranty item. The clock does not start here — it starts at sale
/// (<see cref="WarrantyRegistration"/>).
/// </summary>
public int? WarrantyPeriodMonths { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Append-only history of one serial: a row each time a document changes its status or
/// location (receipt, sale, return, transfer, adjustment, count). <see cref="Status"/> and
/// <see cref="WarehouseId"/> are the serial's state <em>after</em> the movement. The source
/// document is referenced polymorphically, as on <see cref="StockLedger"/>. This is where serial
/// history is read from — the stock ledger keeps one row per document line, not per unit
/// (docs/ITEM_TRACKING_PLAN.md Phases 2 and 9).
/// </summary>
public class SerialMovement
{
public int SerialMovementId { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public SerialStatus Status { get; set; }
public int? FromWarehouseId { get; set; }
public int? WarehouseId { get; set; }
public int UserId { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Records <paramref name="serial"/>'s current state — call after updating it. Linked by
/// navigation, so it works for a serial inserted in the same save.
/// </summary>
public static SerialMovement Of(Serial serial, int? fromWarehouseId, string sourceDocType, int sourceDocId, int userId, DateTime at) => new()
{
Serial = serial,
SerialId = serial.SerialId,
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
Status = serial.Status,
FromWarehouseId = fromWarehouseId,
WarehouseId = serial.WarehouseId,
UserId = userId,
CreatedAt = at
};
}
@@ -18,6 +18,7 @@ public class StockAdjustmentLine
public int? BinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public decimal QtyDelta { get; set; }
}
@@ -32,4 +32,27 @@ public class StockTransferLine
/// <summary>Quantity already received at the destination (partial-receive support).</summary>
public decimal QtyReceived { get; set; }
/// <summary>The units moved, for a serial-tracked item (one per unit of <see cref="Qty"/>).</summary>
public ICollection<StockTransferLineSerial> Serials { get; set; } = new List<StockTransferLineSerial>();
}
/// <summary>
/// One serial on a transfer line. <see cref="UnitCost"/>/<see cref="BatchId"/> are captured from
/// its own source layer at dispatch, so the destination layer is recreated exactly;
/// <see cref="ReceivedAt"/> is set when it arrives (partial receive names which serials).
/// </summary>
public class StockTransferLineSerial
{
public int StockTransferLineSerialId { get; set; }
public int TransferLineId { get; set; }
public StockTransferLine? TransferLine { get; set; }
public int SerialId { get; set; }
public Serial? Serial { get; set; }
public int? BatchId { get; set; }
public decimal? UnitCost { get; set; }
public DateTime? ReceivedAt { get; set; }
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A customer's warranty, started when the sale that issued the goods is posted
/// (docs/ITEM_TRACKING_PLAN.md Phase 5, D3/D7) — stock waiting in a warehouse never consumes
/// warranty. For a serial item there is one row per serial (<see cref="SerialId"/> set,
/// <see cref="Qty"/> = 1); for a warranty item that is not serial-tracked there is one row per
/// sales line covering its <see cref="Qty"/>. <see cref="PeriodMonths"/> is the coverage
/// snapshotted at receipt (<see cref="Serial.WarrantyPeriodMonths"/>), else the item's period.
/// </summary>
public class WarrantyRegistration
{
public int WarrantyRegistrationId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public int SourceDocId { get; set; }
public int SourceLineId { get; set; }
public decimal Qty { get; set; }
public DateOnly StartDate { get; set; }
public int PeriodMonths { get; set; }
public DateOnly EndDate { get; set; }
public WarrantyStatus Status { get; set; } = WarrantyStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,25 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values
/// <c>InStock</c>/<c>Sold</c> written before this enum existed stay valid as-is.
/// Only <see cref="InStock"/> is issuable.
/// </summary>
public enum SerialStatus
{
InStock,
Reserved,
/// <summary>Dispatched on a stock transfer, not yet received — in no warehouse.</summary>
InTransit,
Sold,
ReturnedByCustomer,
ReturnedToVendor,
Damaged,
Scrapped,
/// <summary>
/// Left stock without being named, while serial numbers were switched off in Product
/// Configuration — the unit was issued oldest-first like untracked stock, so which
/// document took it is not recorded against the serial.
/// </summary>
IssuedUntracked
}
@@ -0,0 +1,10 @@
namespace ERPCore.Domain.Enums;
/// <summary>State of a <see cref="Entities.WarrantyRegistration"/>. Stored as a string.</summary>
public enum WarrantyStatus
{
Active,
/// <summary>The unit came back on a sales return — the warranty no longer applies.</summary>
Voided,
Claimed
}
+12 -2
View File
@@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
/// <summary>A serial captured on a GRN line; <see cref="SerialId"/> is set once the GRN is confirmed.</summary>
public sealed record GrnLineSerialDto(string SerialNo, int? SerialId);
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers,
string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList<GrnLineSerialDto> Serials);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
@@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput
public BatchInput? Batch { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>) and not serial-tracked —
/// a serial item's serial is its warranty identity. Ignored otherwise.
/// </summary>
public List<string>? WarrantyNumbers { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>, so Qty must be whole), when the
/// item is serial-tracked (<c>Item.TrackingMode == Serial</c>). Rejected for any other item.
/// </summary>
public List<string>? SerialNumbers { get; set; }
}
public sealed class CreateGrnRequest
@@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput
public int? GrnLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// Serials sent back, for a serial-tracked item — one per unit, each at the return warehouse
/// (InStock or Damaged; for a rejected GRN line, received on that line). They become
/// <c>ReturnedToVendor</c>. Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
}
public sealed class CreatePurchaseReturnRequest
+12 -1
View File
@@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto(
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
int? BatchId, IReadOnlyList<SalesLineSerialDto> Serials);
/// <summary>A serial chosen on (or sold by) a sales line.</summary>
public sealed record SalesLineSerialDto(int SerialId, string SerialNo);
public sealed record SalesInvoiceTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
@@ -95,6 +99,13 @@ public sealed class CreateSalesInvoiceLineRequest
[Range(0, 100)] public decimal? TaxPct { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
/// <summary>Pins the line to one batch (must belong to the item, not expired). Omit to let posting pick FEFO.</summary>
public int? BatchId { get; set; }
/// <summary>
/// Serials sold on this line — required for a serial-tracked item, one per unit of
/// Qty + FreeQty, each InStock at the line warehouse. Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
}
public sealed class CreateSalesInvoiceRequest
+28 -2
View File
@@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales;
// Responses -----------------------------------------------------------------
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
public sealed record SalesReturnLineDto(
int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty,
int? BatchId, IReadOnlyList<SalesLineSerialDto> Serials);
/// <summary>
/// What one posted sales invoice line issued, net of returns — the batches/serials a return
/// against it can take back.
/// </summary>
public sealed record SalesLineAllocationDto(
int? BatchId, string? BatchNo, DateOnly? ExpiryDate, int? SerialId, string? SerialNo,
decimal Qty, decimal QtyReturned);
public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
@@ -22,7 +32,8 @@ public sealed record SalesReturnSummaryDto(
/// whatever has already been returned against it. The invoice line's own <c>Qty</c>
/// is never mutated by a return, so this is computed on read from return history.
/// </summary>
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
public sealed record SalesInvoiceLineRemainingDto(
int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList<SalesLineAllocationDto> Allocations);
// Requests --------------------------------------------------------------------
@@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput
public int? SalesInvoiceLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// Batch to restock into. Optional when the invoice line issued a single batch (it is used);
/// required for a batch-tracked item otherwise, and must be one the line issued.
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Returned serials of a serial-tracked item — one per unit, each Sold (and, with an invoice
/// line, sold on that line). Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
}
public sealed class CreateSalesReturnRequest
@@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest
[Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; }
/// <summary>
/// Returned serials become <c>Damaged</c> (not resaleable) instead of <c>InStock</c>. Omit to
/// derive it from the reason: <c>DEF</c> (Defective) and <c>QREJ</c> (Quality Reject) mean damaged.
/// </summary>
public bool? MarkDamaged { get; set; }
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
}
+7 -2
View File
@@ -8,7 +8,7 @@ public sealed record SalesSlipLineDto(
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
int? BatchId, int? SerialId);
int? BatchId, int? SerialId, IReadOnlyList<SalesLineSerialDto> Serials);
public sealed record SalesSlipTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
@@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest
public int? ParentLineId { get; set; }
/// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary>
public int? BatchId { get; set; }
/// <summary>Pins the line to a specific serial (must belong to <see cref="ItemId"/>); marked Sold on posting.</summary>
/// <summary>Legacy single-serial pin — treated as <c>SerialIds = [SerialId]</c> when <see cref="SerialIds"/> is empty.</summary>
public int? SerialId { get; set; }
/// <summary>
/// Serials sold on this line — required for a serial-tracked item, one per unit of
/// Qty + FreeQty, each InStock at the line warehouse. Marked Sold on posting.
/// </summary>
public List<int>? SerialIds { get; set; }
}
public sealed class CreateSalesSlipRequest
+11 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId);
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
@@ -26,6 +26,16 @@ public sealed class CreateAdjustmentLineInput
public int? BatchId { get; set; }
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
public decimal QtyDelta { get; set; }
/// <summary>
/// Negative delta on a serial-tracked item: the serials written off (InStock or Damaged at
/// the warehouse, one per unit) — they become <c>Scrapped</c>.
/// </summary>
public List<int>? SerialIds { get; set; }
/// <summary>
/// Positive delta on a serial-tracked item: the serial numbers found (one per unit) — new
/// numbers are created, a known serial that is out of stock is brought back InStock.
/// </summary>
public List<string>? SerialNos { get; set; }
}
public sealed class CreateAdjustmentRequest
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Item tracking lookups and registers (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9).
/// <summary>Product Configuration tracking switches, as every screen needs them.</summary>
public sealed record TrackingSettingsDto(bool SerialNumbersEnabled);
/// <summary>An issuable serial, for sale/transfer/return pickers.</summary>
public sealed record AvailableSerialDto(int SerialId, string SerialNo, int? BatchId, string? BatchNo, SerialStatus Status);
/// <summary>Issuable stock of one batch at a warehouse (BatchId null = unbatched stock).</summary>
public sealed record AvailableBatchDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry);
/// <summary>A FEFO pick: <see cref="Qty"/> of <see cref="BatchId"/>.</summary>
public sealed record BatchSuggestionDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty);
public sealed record SerialSummaryDto(
int SerialId, int ItemId, string Sku, string ItemName, string SerialNo, SerialStatus Status,
int? WarehouseId, string? WarehouseName, int? BinId, DateTime? ReceivedAt,
int? GrnId, string? GrnDocNo, int? WarrantyPeriodMonths);
public sealed record SerialMovementDto(
int SerialMovementId, string SourceDocType, int SourceDocId, SerialStatus Status,
int? FromWarehouseId, int? WarehouseId, int UserId, DateTime CreatedAt);
public sealed record SerialDetailDto(
SerialSummaryDto Serial, IReadOnlyList<SerialMovementDto> History, IReadOnlyList<WarrantyRegistrationDto> Warranties);
public sealed record WarrantyRegistrationDto(
int WarrantyRegistrationId, int ItemId, string Sku, string ItemName, int? SerialId, string? SerialNo,
int CustomerId, string CustomerName, string SourceDocType, int SourceDocId, int SourceLineId,
decimal Qty, DateOnly StartDate, int PeriodMonths, DateOnly EndDate, WarrantyStatus Status,
int DaysRemaining, bool IsExpired);
/// <summary>Stock on hand per batch (with expiry), for the batch / expiry report.</summary>
public sealed record BatchStockDto(
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseName,
int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry, bool IsExpired);
+13 -1
View File
@@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived,
IReadOnlyList<TransferLineSerialDto> Serials);
/// <summary>A serial on a transfer line; <see cref="Received"/> once it has arrived.</summary>
public sealed record TransferLineSerialDto(int SerialId, string SerialNo, bool Received);
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
@@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput
[Required] public int ItemId { get; set; }
public int? SrcBinId { get; set; }
public int? DestBinId { get; set; }
/// <summary>Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches.</summary>
public int? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Serials to move — required for a serial-tracked item, one per unit, each InStock at the source.</summary>
public List<int>? SerialIds { get; set; }
}
public sealed class CreateTransferRequest
@@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput
{
[Required] public int TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// For a serial line: which in-transit serials arrived (count = Qty). May be omitted when
/// receiving everything still outstanding on the line.
/// </summary>
public List<int>? SerialIds { get; set; }
}
public sealed class ReceiveTransferRequest
@@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
builder.HasKey(s => s.SerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
// String-stored enum: rows written as "InStock"/"Sold" before SerialStatus existed
// read back unchanged.
builder.Property(s => s.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.Property(s => s.Barcode).HasMaxLength(100);
builder.HasIndex(s => s.Barcode).IsUnique();
@@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
.WithMany()
.HasForeignKey(s => s.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.Warehouse).WithMany().HasForeignKey(s => s.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.Bin).WithMany().HasForeignKey(s => s.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(s => s.GrnLine).WithMany().HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
builder.HasIndex(s => new { s.ItemId, s.Status });
builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status });
}
}
@@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
}
}
public sealed class GrnLineSerialConfiguration : IEntityTypeConfiguration<GrnLineSerial>
{
public void Configure(EntityTypeBuilder<GrnLineSerial> builder)
{
builder.ToTable("grn_line_serials");
builder.HasKey(s => s.GrnLineSerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.HasOne(s => s.GrnLine).WithMany(l => l.Serials)
.HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Serial).WithMany()
.HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict);
// Global uniqueness lives on serials (ItemId, SerialNo); this only stops a number
// being entered twice on one line.
builder.HasIndex(s => new { s.GrnLineId, s.SerialNo }).IsUnique();
}
}
@@ -0,0 +1,118 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
// Sales-side serial/batch tracking and warranty (docs/ITEM_TRACKING_PLAN.md Phases 4–5).
public sealed class SalesInvoiceLineSerialConfiguration : IEntityTypeConfiguration<SalesInvoiceLineSerial>
{
public void Configure(EntityTypeBuilder<SalesInvoiceLineSerial> builder)
{
builder.ToTable("sales_invoice_line_serials");
builder.HasKey(x => x.SalesInvoiceLineSerialId);
builder.HasOne(x => x.SalesInvoiceLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.SalesInvoiceLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SalesInvoiceLineId, x.SerialId }).IsUnique();
}
}
public sealed class SalesSlipLineSerialConfiguration : IEntityTypeConfiguration<SalesSlipLineSerial>
{
public void Configure(EntityTypeBuilder<SalesSlipLineSerial> builder)
{
builder.ToTable("sales_slip_line_serials");
builder.HasKey(x => x.SalesSlipLineSerialId);
builder.HasOne(x => x.SalesSlipLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.SalesSlipLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SalesSlipLineId, x.SerialId }).IsUnique();
}
}
public sealed class SalesLineAllocationConfiguration : IEntityTypeConfiguration<SalesLineAllocation>
{
public void Configure(EntityTypeBuilder<SalesLineAllocation> builder)
{
builder.ToTable("sales_line_allocations");
builder.HasKey(x => x.SalesLineAllocationId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.QtyReturned).HasPrecision(18, 4);
builder.Property(x => x.UnitCost).HasPrecision(18, 6);
builder.HasOne(x => x.Batch).WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId, x.SourceLineId });
builder.HasIndex(x => x.SerialId);
builder.HasIndex(x => x.BatchId);
}
}
public sealed class StockTransferLineSerialConfiguration : IEntityTypeConfiguration<StockTransferLineSerial>
{
public void Configure(EntityTypeBuilder<StockTransferLineSerial> builder)
{
builder.ToTable("stock_transfer_line_serials");
builder.HasKey(x => x.StockTransferLineSerialId);
builder.Property(x => x.UnitCost).HasPrecision(18, 6);
builder.HasOne(x => x.TransferLine).WithMany(l => l.Serials)
.HasForeignKey(x => x.TransferLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Serial).WithMany()
.HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.TransferLineId, x.SerialId }).IsUnique();
}
}
public sealed class SerialMovementConfiguration : IEntityTypeConfiguration<SerialMovement>
{
public void Configure(EntityTypeBuilder<SerialMovement> builder)
{
builder.ToTable("serial_movements");
builder.HasKey(x => x.SerialMovementId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => new { x.SerialId, x.SerialMovementId });
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId });
}
}
public sealed class WarrantyRegistrationConfiguration : IEntityTypeConfiguration<WarrantyRegistration>
{
public void Configure(EntityTypeBuilder<WarrantyRegistration> builder)
{
builder.ToTable("warranty_registrations");
builder.HasKey(x => x.WarrantyRegistrationId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.SerialId);
builder.HasIndex(x => x.CustomerId);
builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId });
builder.HasIndex(x => new { x.Status, x.EndDate });
}
}
@@ -111,6 +111,11 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Batch)
.WithMany()
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
}
}
@@ -37,5 +37,20 @@ public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<Sale
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class SalesReturnLineSerialConfiguration : IEntityTypeConfiguration<SalesReturnLineSerial>
{
public void Configure(EntityTypeBuilder<SalesReturnLineSerial> builder)
{
builder.ToTable("sales_return_line_serials");
builder.HasKey(s => s.SalesReturnLineSerialId);
builder.HasOne(s => s.ReturnLine).WithMany(l => l.Serials).HasForeignKey(s => s.ReturnLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Serial).WithMany().HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ReturnLineId, s.SerialId }).IsUnique();
}
}
@@ -40,6 +40,6 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -93,6 +93,14 @@ public class ErpDbContext : DbContext
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>();
public DbSet<Serial> Serials => Set<Serial>();
public DbSet<GrnLineSerial> GrnLineSerials => Set<GrnLineSerial>();
public DbSet<SalesInvoiceLineSerial> SalesInvoiceLineSerials => Set<SalesInvoiceLineSerial>();
public DbSet<SalesSlipLineSerial> SalesSlipLineSerials => Set<SalesSlipLineSerial>();
public DbSet<SalesLineAllocation> SalesLineAllocations => Set<SalesLineAllocation>();
public DbSet<WarrantyRegistration> WarrantyRegistrations => Set<WarrantyRegistration>();
public DbSet<SalesReturnLineSerial> SalesReturnLineSerials => Set<SalesReturnLineSerial>();
public DbSet<SerialMovement> SerialMovements => Set<SerialMovement>();
public DbSet<StockTransferLineSerial> StockTransferLineSerials => Set<StockTransferLineSerial>();
// --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
public DbSet<StockLayer> StockLayers => Set<StockLayer>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTrackingSerialsWarranty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BinId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "serials",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "GrnLineId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ReceivedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<uint>(
name: "RowVersion",
table: "serials",
type: "int unsigned",
nullable: false,
defaultValue: 0u);
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarehouseId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_return_lines",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_invoice_lines",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_serials",
columns: table => new
{
GrnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GrnLineId = table.Column<int>(type: "int", nullable: false),
SerialNo = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
SerialId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_serials", x => x.GrnLineSerialId);
table.ForeignKey(
name: "FK_grn_line_serials_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_grn_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_invoice_line_serials",
columns: table => new
{
SalesInvoiceLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesInvoiceLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_invoice_line_serials", x => x.SalesInvoiceLineSerialId);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_sales_invoice_lines_SalesInvoiceL~",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_line_allocations",
columns: table => new
{
SalesLineAllocationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
WarehouseId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
SerialId = table.Column<int>(type: "int", nullable: true),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
QtyReturned = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_line_allocations", x => x.SalesLineAllocationId);
table.ForeignKey(
name: "FK_sales_line_allocations_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_line_allocations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_return_line_serials",
columns: table => new
{
SalesReturnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ReturnLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_line_serials", x => x.SalesReturnLineSerialId);
table.ForeignKey(
name: "FK_sales_return_line_serials_sales_return_lines_ReturnLineId",
column: x => x.ReturnLineId,
principalTable: "sales_return_lines",
principalColumn: "ReturnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_return_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_slip_line_serials",
columns: table => new
{
SalesSlipLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesSlipLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_slip_line_serials", x => x.SalesSlipLineSerialId);
table.ForeignKey(
name: "FK_sales_slip_line_serials_sales_slip_lines_SalesSlipLineId",
column: x => x.SalesSlipLineId,
principalTable: "sales_slip_lines",
principalColumn: "SalesSlipLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_slip_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "serial_movements",
columns: table => new
{
SerialMovementId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SerialId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
FromWarehouseId = table.Column<int>(type: "int", nullable: true),
WarehouseId = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_serial_movements", x => x.SerialMovementId);
table.ForeignKey(
name: "FK_serial_movements_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "stock_transfer_line_serials",
columns: table => new
{
StockTransferLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TransferLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_transfer_line_serials", x => x.StockTransferLineSerialId);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_stock_transfer_lines_TransferLin~",
column: x => x.TransferLineId,
principalTable: "stock_transfer_lines",
principalColumn: "TransferLineId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "warranty_registrations",
columns: table => new
{
WarrantyRegistrationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ItemId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: true),
CustomerId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
PeriodMonths = table.Column<int>(type: "int", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_warranty_registrations", x => x.WarrantyRegistrationId);
table.ForeignKey(
name: "FK_warranty_registrations_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_serials_BinId",
table: "serials",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_serials_GrnLineId",
table: "serials",
column: "GrnLineId");
migrationBuilder.CreateIndex(
name: "IX_serials_ItemId_Status",
table: "serials",
columns: new[] { "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials",
columns: new[] { "WarehouseId", "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_GrnLineId_SerialNo",
table: "grn_line_serials",
columns: new[] { "GrnLineId", "SerialNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_SerialId",
table: "grn_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SalesInvoiceLineId_SerialId",
table: "sales_invoice_line_serials",
columns: new[] { "SalesInvoiceLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SerialId",
table: "sales_invoice_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_BatchId",
table: "sales_line_allocations",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SerialId",
table: "sales_line_allocations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SourceDocType_SourceDocId_SourceLineId",
table: "sales_line_allocations",
columns: new[] { "SourceDocType", "SourceDocId", "SourceLineId" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_ReturnLineId_SerialId",
table: "sales_return_line_serials",
columns: new[] { "ReturnLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_SerialId",
table: "sales_return_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SalesSlipLineId_SerialId",
table: "sales_slip_line_serials",
columns: new[] { "SalesSlipLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SerialId",
table: "sales_slip_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SerialId_SerialMovementId",
table: "serial_movements",
columns: new[] { "SerialId", "SerialMovementId" });
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SourceDocType_SourceDocId",
table: "serial_movements",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_BatchId",
table: "stock_transfer_line_serials",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_SerialId",
table: "stock_transfer_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_TransferLineId_SerialId",
table: "stock_transfer_line_serials",
columns: new[] { "TransferLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_CustomerId",
table: "warranty_registrations",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_ItemId",
table: "warranty_registrations",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SerialId",
table: "warranty_registrations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SourceDocType_SourceDocId",
table: "warranty_registrations",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_Status_EndDate",
table: "warranty_registrations",
columns: new[] { "Status", "EndDate" });
migrationBuilder.AddForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_bins_BinId",
table: "serials",
column: "BinId",
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials",
column: "GrnLineId",
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials",
column: "WarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
// Backfill serial rows that predate this migration: a real CreatedAt, a live
// concurrency token (0 is never live — see ErpDbContext.BumpRowVersions), and the
// warehouse of an in-stock serial's own open layer, if it has one.
migrationBuilder.Sql("""
UPDATE `serials` SET `CreatedAt` = UTC_TIMESTAMP(6) WHERE `CreatedAt` < '1900-01-01';
UPDATE `serials` SET `RowVersion` = 1 WHERE `RowVersion` = 0;
UPDATE `serials` s
JOIN `stock_layers` l ON l.`SerialId` = s.`SerialId` AND l.`QtyRemaining` > 0
SET s.`WarehouseId` = l.`WarehouseId`
WHERE s.`Status` = 'InStock' AND s.`WarehouseId` IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines");
migrationBuilder.DropForeignKey(
name: "FK_serials_bins_BinId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials");
migrationBuilder.DropTable(
name: "grn_line_serials");
migrationBuilder.DropTable(
name: "sales_invoice_line_serials");
migrationBuilder.DropTable(
name: "sales_line_allocations");
migrationBuilder.DropTable(
name: "sales_return_line_serials");
migrationBuilder.DropTable(
name: "sales_slip_line_serials");
migrationBuilder.DropTable(
name: "serial_movements");
migrationBuilder.DropTable(
name: "stock_transfer_line_serials");
migrationBuilder.DropTable(
name: "warranty_registrations");
migrationBuilder.DropIndex(
name: "IX_serials_BinId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_GrnLineId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines");
migrationBuilder.DropIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropColumn(
name: "BinId",
table: "serials");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "GrnLineId",
table: "serials");
migrationBuilder.DropColumn(
name: "ReceivedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "serials");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "WarehouseId",
table: "serials");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "serials");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_return_lines");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_invoice_lines");
}
}
}
@@ -1693,6 +1693,35 @@ namespace ERPCore.Migrations
b.ToTable("grn_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b =>
{
b.Property<int>("GrnLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("GrnLineSerialId"));
b.Property<int>("GrnLineId")
.HasColumnType("int");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<string>("SerialNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("GrnLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("GrnLineId", "SerialNo")
.IsUnique();
b.ToTable("grn_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{
b.Property<int>("GrnLineWarrantyNumberId")
@@ -4740,6 +4769,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
@@ -4810,6 +4842,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceLineId");
b.HasIndex("BatchId");
b.HasIndex("ItemId");
b.HasIndex("SalesInvoiceId");
@@ -4819,6 +4853,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
{
b.Property<int>("SalesInvoiceLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesInvoiceLineSerialId"));
b.Property<int>("SalesInvoiceLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesInvoiceLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("SalesInvoiceLineId", "SerialId")
.IsUnique();
b.ToTable("sales_invoice_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{
b.Property<int>("SalesInvoicePaymentId")
@@ -4868,6 +4926,63 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b =>
{
b.Property<int>("SalesLineAllocationId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesLineAllocationId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("QtyReturned")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("SourceLineId")
.HasColumnType("int");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("WarehouseId")
.HasColumnType("int");
b.HasKey("SalesLineAllocationId");
b.HasIndex("BatchId");
b.HasIndex("SerialId");
b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId");
b.ToTable("sales_line_allocations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Property<int>("ReturnId")
@@ -4932,6 +5047,9 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ReturnLineId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<int>("ItemId")
.HasColumnType("int");
@@ -4947,6 +5065,8 @@ namespace ERPCore.Migrations
b.HasKey("ReturnLineId");
b.HasIndex("BatchId");
b.HasIndex("ItemId");
b.HasIndex("ReturnId");
@@ -4956,6 +5076,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_return_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
{
b.Property<int>("SalesReturnLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesReturnLineSerialId"));
b.Property<int>("ReturnLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesReturnLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("ReturnLineId", "SerialId")
.IsUnique();
b.ToTable("sales_return_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.Property<int>("SalesSlipId")
@@ -5151,6 +5295,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_slip_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b =>
{
b.Property<int>("SalesSlipLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesSlipLineSerialId"));
b.Property<int>("SalesSlipLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesSlipLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("SalesSlipLineId", "SerialId")
.IsUnique();
b.ToTable("sales_slip_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.Property<int>("SerialId")
@@ -5163,9 +5331,25 @@ namespace ERPCore.Migrations
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int?>("BinId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("GrnLineId")
.HasColumnType("int");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<DateTime?>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("SerialNo")
.IsRequired()
.HasMaxLength(100)
@@ -5176,17 +5360,79 @@ namespace ERPCore.Migrations
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("WarehouseId")
.HasColumnType("int");
b.Property<int?>("WarrantyPeriodMonths")
.HasColumnType("int");
b.HasKey("SerialId");
b.HasIndex("Barcode")
.IsUnique();
b.HasIndex("BinId");
b.HasIndex("GrnLineId");
b.HasIndex("ItemId", "SerialNo")
.IsUnique();
b.HasIndex("ItemId", "Status");
b.HasIndex("WarehouseId", "ItemId", "Status");
b.ToTable("serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
{
b.Property<int>("SerialMovementId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SerialMovementId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("FromWarehouseId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int?>("WarehouseId")
.HasColumnType("int");
b.HasKey("SerialMovementId");
b.HasIndex("SerialId", "SerialMovementId");
b.HasIndex("SourceDocType", "SourceDocId");
b.ToTable("serial_movements", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
{
b.Property<int>("EdgeId")
@@ -5744,6 +5990,42 @@ namespace ERPCore.Migrations
b.ToTable("stock_transfer_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b =>
{
b.Property<int>("StockTransferLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("StockTransferLineSerialId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<DateTime?>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<int>("SerialId")
.HasColumnType("int");
b.Property<int>("TransferLineId")
.HasColumnType("int");
b.Property<decimal?>("UnitCost")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.HasKey("StockTransferLineSerialId");
b.HasIndex("BatchId");
b.HasIndex("SerialId");
b.HasIndex("TransferLineId", "SerialId")
.IsUnique();
b.ToTable("stock_transfer_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.Property<int>("SubCategoryId")
@@ -6787,6 +7069,77 @@ namespace ERPCore.Migrations
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b =>
{
b.Property<int>("WarrantyRegistrationId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("WarrantyRegistrationId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CustomerId")
.HasColumnType("int");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<int>("PeriodMonths")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("SourceLineId")
.HasColumnType("int");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("WarrantyRegistrationId");
b.HasIndex("CustomerId");
b.HasIndex("ItemId");
b.HasIndex("SerialId");
b.HasIndex("SourceDocType", "SourceDocId");
b.HasIndex("Status", "EndDate");
b.ToTable("warranty_registrations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{
b.Property<int>("WorkShiftId")
@@ -7306,6 +7659,24 @@ namespace ERPCore.Migrations
b.Navigation("PoLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
.WithMany("Serials")
.HasForeignKey("GrnLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GrnLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
@@ -8143,6 +8514,11 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
@@ -8161,6 +8537,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
b.Navigation("Item");
b.Navigation("SalesInvoice");
@@ -8168,6 +8546,25 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
.WithMany("Serials")
.HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SalesInvoiceLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -8187,6 +8584,23 @@ namespace ERPCore.Migrations
b.Navigation("SalesInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Batch");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -8224,6 +8638,11 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
@@ -8241,6 +8660,8 @@ namespace ERPCore.Migrations
.HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Batch");
b.Navigation("Item");
b.Navigation("Return");
@@ -8248,6 +8669,25 @@ namespace ERPCore.Migrations
b.Navigation("SalesInvoiceLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine")
.WithMany("Serials")
.HasForeignKey("ReturnLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ReturnLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -8323,15 +8763,66 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine")
.WithMany("Serials")
.HasForeignKey("SalesSlipLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SalesSlipLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Bin", "Bin")
.WithMany()
.HasForeignKey("BinId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
.WithMany()
.HasForeignKey("GrnLineId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Bin");
b.Navigation("GrnLine");
b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
{
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
@@ -8470,7 +8961,7 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", null)
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
@@ -8478,6 +8969,8 @@ namespace ERPCore.Migrations
b.Navigation("Adjustment");
b.Navigation("Item");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
@@ -8695,6 +9188,30 @@ namespace ERPCore.Migrations
b.Navigation("Transfer");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", null)
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine")
.WithMany("Serials")
.HasForeignKey("TransferLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Serial");
b.Navigation("TransferLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
@@ -8809,6 +9326,32 @@ namespace ERPCore.Migrations
b.Navigation("Branch");
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Customer");
b.Navigation("Item");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
@@ -8841,6 +9384,8 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{
b.Navigation("Serials");
b.Navigation("WarrantyNumbers");
});
@@ -8944,16 +9489,31 @@ namespace ERPCore.Migrations
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.Navigation("Lines");
@@ -8969,6 +9529,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b =>
{
b.Navigation("Messages");
+2
View File
@@ -166,6 +166,8 @@ builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
// Stock transactions + reference data (docs/11 §5–6)
builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
builder.Services.AddScoped<ITransferService, TransferService>();
+29 -8
View File
@@ -36,6 +36,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly ISerialTrackingService _serialTracking;
private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts;
@@ -45,6 +46,7 @@ public sealed class AdjustmentService : IAdjustmentService
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_serialTracking = serialTracking;
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
@@ -155,17 +157,24 @@ public sealed class AdjustmentService : IAdjustmentService
if (reason.Context != ReasonContext.Adjustment)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
// Serials per line: outbound ones are validated as on hand; inbound ones are created (or
// re-found) in the context now and get their ids on the flush below.
var serialsByLine = new List<IReadOnlyList<Serial>>();
var documentSerials = new HashSet<int>();
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in request.Lines)
{
if (line.QtyDelta == 0)
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
serialsByLine.Add(line.QtyDelta < 0
? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds,
documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct)
: await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
}
var now = DateTime.UtcNow;
var deltas = request.Lines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
{
@@ -179,19 +188,31 @@ public sealed class AdjustmentService : IAdjustmentService
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new StockAdjustmentLine
Lines = request.Lines.Select((l, i) => new StockAdjustmentLine
{
ItemId = l.ItemId,
BinId = l.BinId,
BatchId = l.BatchId,
QtyDelta = l.QtyDelta
QtyDelta = l.QtyDelta,
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null
}).ToList()
};
await _adjustments.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid
var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
for (var i = 0; i < request.Lines.Count; i++)
{
var line = request.Lines[i];
await _serialTracking.MoveAsync(serialsByLine[i],
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
DocumentTypes.Adjustment, entity.AdjustmentId, now, token);
}
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
@@ -207,6 +228,6 @@ public sealed class AdjustmentService : IAdjustmentService
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(),
ledgerRefs);
}
+38 -5
View File
@@ -50,6 +50,7 @@ public sealed class CountService : ICountService
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{
_serialTracking = serialTracking;
_counts = counts;
_adjustments = adjustments;
_warehouses = warehouses;
@@ -171,17 +172,32 @@ public sealed class CountService : ICountService
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty });
}
// Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line.
var unbatched = await _stockLayers.Query().AsNoTracking()
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (unbatched != 0)
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched });
}
else if (item.TrackingMode == TrackingMode.Serial)
else if (await _serialTracking.IsSerialTrackedAsync(item, ct)) // off: counted as one aggregate line
{
// Serials that should be at this warehouse (one line each, counted 0 or 1), plus
// one aggregate line for any pre-tracking serial-less stock still here (D6).
var serials = await _serials.Query().AsNoTracking()
.Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct);
.Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
.OrderBy(s => s.SerialNo).ToListAsync(ct);
foreach (var serial in serials)
{
var open = await _stockLayers.Query().AsNoTracking().AnyAsync(
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
}
var untracked = await _stockLayers.Query().AsNoTracking()
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked != 0 || serials.Count == 0)
lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked });
}
else
{
@@ -225,6 +241,8 @@ public sealed class CountService : ICountService
{
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
if (line.SerialId is not null && input.CountedQty is not (0m or 1m))
throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422);
line.CountedQty = input.CountedQty;
line.Variance = input.CountedQty - line.SystemQty;
}
@@ -246,9 +264,11 @@ public sealed class CountService : ICountService
if (count.Status != CountStatus.Counted)
throw new ConflictException($"Count {countId} has no entered counts to post.");
var deltas = count.Lines
.Where(l => l.Variance.HasValue && l.Variance.Value != 0)
.Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value))
// Batch and serial lines adjust exactly their own lot/unit.
var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
var deltas = varianceLines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value,
l.SerialId is int serialId ? new List<int> { serialId } : null))
.ToList();
// No variances → just close the count, no adjustment.
@@ -280,6 +300,8 @@ public sealed class CountService : ICountService
{
ItemId = d.ItemId,
BinId = d.BinId,
BatchId = d.BatchId,
SerialId = d.SerialIds?.FirstOrDefault(),
QtyDelta = d.QtyDelta
}).ToList()
};
@@ -288,6 +310,17 @@ public sealed class CountService : ICountService
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
// A serial counted missing is written off (Scrapped); one found keeps/regains InStock here.
foreach (var line in varianceLines.Where(l => l.SerialId is not null))
{
var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token);
var found = line.Variance > 0;
await _serialTracking.MoveAsync(new[] { serial },
found ? SerialStatus.InStock : SerialStatus.Scrapped,
found ? count.WarehouseId : null, found ? line.BinId : null,
DocumentTypes.Count, count.CountId, now, token);
}
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
+121 -10
View File
@@ -34,6 +34,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<TaxRate> _taxRates;
@@ -50,7 +53,7 @@ public sealed class GrnService : IGrnService
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
@@ -63,6 +66,9 @@ public sealed class GrnService : IGrnService
_bins = bins;
_vendors = vendors;
_batches = batches;
_serials = serials;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_layers = layers;
_ledger = ledger;
_taxRates = taxRates;
@@ -110,6 +116,8 @@ public sealed class GrnService : IGrnService
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.Lines).ThenInclude(l => l.Serials)
.Include(g => g.Lines).ThenInclude(l => l.Batch)
.Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
@@ -155,6 +163,7 @@ public sealed class GrnService : IGrnService
var lines = new List<GrnLine>();
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
var requestSerials = new HashSet<(int ItemId, string SerialNo)>();
foreach (var input in request.Lines)
{
@@ -203,7 +212,9 @@ public sealed class GrnService : IGrnService
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty,
serialTracked: await _serialTracking.IsSerialTrackedAsync(item, ct));
var serials = await ResolveSerialNumbersAsync(item, input.SerialNumbers, input.Qty, requestSerials, ct);
lines.Add(new GrnLine
{
@@ -221,7 +232,8 @@ public sealed class GrnService : IGrnService
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus,
WarrantyNumbers = warrantyNumbers
WarrantyNumbers = warrantyNumbers,
Serials = serials
});
}
@@ -252,7 +264,7 @@ public sealed class GrnService : IGrnService
public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
{
var grn = await _grns.Query()
.Include(g => g.Lines)
.Include(g => g.Lines).ThenInclude(l => l.Serials)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
@@ -278,10 +290,21 @@ public sealed class GrnService : IGrnService
var qtyBase = line.Qty;
var unitCostBase = line.NetUnitCost;
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
qtyBase, unitCostBase, now, token);
createdLayers.Add(layer);
if (line.Serials.Count > 0)
{
// Serial-tracked: one Serial and one qty-1 layer per unit, so a sale can later
// consume exactly the serial it names. The ledger stays one row per line
// (below): every ledger row also posts a GL entry, and per-unit rows would mean
// one GL call per serial — serial history is read from the document tables.
createdLayers.AddRange(await ReceiveSerialsAsync(grn, line, unitCostBase, now, token));
}
else
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
qtyBase, unitCostBase, now, token);
createdLayers.Add(layer);
}
var key = (line.ItemId, grn.WarehouseId);
if (!runningBalance.TryGetValue(key, out var bal))
@@ -411,12 +434,16 @@ public sealed class GrnService : IGrnService
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
/// </summary>
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty, bool serialTracked)
{
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
if (item.WarrantyPeriodMonths is null)
throw new DomainException(
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
// A serial item's serial number is its warranty identity (docs/ITEM_TRACKING_PLAN.md D1);
// its coverage is snapshotted onto each Serial at confirm instead. With serial numbers
// switched off the item has no serials, so it takes warranty numbers like any other.
if (serialTracked) return new List<GrnLineWarrantyNumber>();
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (trimmed.Count != qty)
@@ -429,6 +456,88 @@ public sealed class GrnService : IGrnService
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
}
/// <summary>
/// A serial-tracked item requires exactly one serial number per received unit — so the
/// quantity must be whole. Numbers must be unique within the request and must not already
/// exist for the item. Any other item must not carry serial numbers.
/// </summary>
private async Task<List<GrnLineSerial>> ResolveSerialNumbersAsync(
Item item, List<string>? numbers, decimal qty, HashSet<(int, string)> requestSerials, CancellationToken ct)
{
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (!await _serialTracking.IsSerialTrackedAsync(item, ct))
{
_serialTracking.RejectSerialsWhileDisabled(item, trimmed.Count);
if (trimmed.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422);
return new List<GrnLineSerial>();
}
if (qty != decimal.Truncate(qty))
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422);
if (trimmed.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; provide exactly {qty} serial number(s), got {trimmed.Count}.", 422);
if (trimmed.Any(n => n.Length > 100))
throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422);
// Case-insensitive, matching the utf8mb4_unicode_ci unique index on serials (ItemId, SerialNo).
foreach (var n in trimmed)
if (!requestSerials.Add((item.ItemId, n.ToUpperInvariant())))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} for item {item.Sku} is entered more than once.", 422);
await EnsureSerialsFreeAsync(item.ItemId, item.Sku, trimmed, ct);
return trimmed.Select(n => new GrnLineSerial { SerialNo = n }).ToList();
}
private async Task EnsureSerialsFreeAsync(int itemId, string sku, List<string> numbers, CancellationToken ct)
{
var taken = await _serials.Query().AsNoTracking()
.Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo))
.Select(s => s.SerialNo).Take(5).ToListAsync(ct);
if (taken.Count > 0)
throw new DomainException(ErrorCodes.SerialDuplicate,
$"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409);
}
/// <summary>
/// Confirm-time half of serial capture: re-checks the numbers are still free (another GRN
/// may have confirmed them since this draft was created — the unique index is the final
/// guard), then creates each <see cref="Serial"/> with its own qty-1 FIFO layer.
/// </summary>
private async Task<List<StockLayer>> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct);
var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null;
var layers = new List<StockLayer>();
foreach (var captured in line.Serials.OrderBy(s => s.GrnLineSerialId))
{
var serial = new Serial
{
ItemId = line.ItemId,
SerialNo = captured.SerialNo,
Status = SerialStatus.InStock,
WarehouseId = grn.WarehouseId,
BinId = line.BinId,
GrnLineId = line.GrnLineId,
ReceivedAt = now,
CreatedAt = now,
WarrantyPeriodMonths = warrantyMonths
};
await _serials.AddAsync(serial, ct);
captured.Serial = serial; // FK fixed up on SaveChanges
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct);
layer.Serial = serial;
layers.Add(layer);
}
return layers;
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
@@ -472,5 +581,7 @@ public sealed class GrnService : IGrnService
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,
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList());
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList(),
l.Batch?.BatchNo, l.Batch?.ExpiryDate,
l.Serials.OrderBy(s => s.GrnLineSerialId).Select(s => new GrnLineSerialDto(s.SerialNo, s.SerialId)).ToList())).ToList());
}
@@ -31,9 +31,26 @@ public interface IFifoCostingService
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param>
/// <param name="serialIds">
/// Serial-tracked issue: consume exactly these serials' qty-1 layers (count must equal
/// <paramref name="qtyBase"/>), or throw <c>SERIAL_NOT_AVAILABLE</c>. When null, serial layers
/// are never touched — serial stock only leaves by naming its serial — so only untracked
/// (legacy, pre-serial-capture) layers are issued. Shortfall is never allowed with serials.
/// </param>
/// <remarks>
/// Issue order is FEFO then FIFO: layers whose batch expires soonest go first, then oldest
/// receipt. Items without batches therefore keep plain FIFO.
/// </remarks>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false);
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null);
/// <summary>
/// Read-only (no locks) FEFO preview of how <paramref name="qtyBase"/> would be issued across
/// batches — for UI suggestions. Expired, on-hold and serial layers are excluded.
/// </summary>
Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(
int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default);
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -60,4 +77,7 @@ public interface IFifoCostingService
}
/// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary>
public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost);
public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost, int? BatchId = null, int? SerialId = null);
/// <summary>A suggested batch pick: <see cref="Qty"/> of <see cref="BatchId"/> (null = unbatched stock).</summary>
public sealed record BatchAllocation(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty);
@@ -0,0 +1,46 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Read side of item tracking: pickers, serial and warranty registers, batch stock.</summary>
public interface IItemTrackingService
{
/// <summary>
/// The next <paramref name="count"/> free serial numbers for an item under
/// <paramref name="prefix"/>: continues after the highest number already used with that
/// prefix (received serials and numbers on draft GRNs), zero-padded to at least 4 digits —
/// e.g. prefix "PH-" after PH-0041 gives PH-0042, PH-0043, …
/// </summary>
Task<IReadOnlyList<string>> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default);
/// <summary>In-stock serials of an item at a warehouse, optionally filtered by number.</summary>
Task<IReadOnlyList<AvailableSerialDto>> GetAvailableSerialsAsync(int itemId, int warehouseId, string? q, int take, CancellationToken ct = default);
/// <summary>Issuable (not expired, not on hold) stock per batch, soonest expiry first.</summary>
Task<IReadOnlyList<AvailableBatchDto>> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>How posting would pick <paramref name="qty"/> FEFO across batches.</summary>
Task<IReadOnlyList<BatchSuggestionDto>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default);
Task<PagedResponse<SerialSummaryDto>> ListSerialsAsync(
PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default);
Task<SerialDetailDto?> GetSerialAsync(int serialId, CancellationToken ct = default);
/// <summary>Exact serial-number lookup (all items) — e.g. a warranty desk scanning a unit.</summary>
Task<IReadOnlyList<SerialDetailDto>> FindSerialAsync(string serialNo, CancellationToken ct = default);
/// <summary>
/// Warranty register. <paramref name="expiringWithinDays"/> keeps active warranties ending within
/// that many days; <paramref name="expired"/> true/false keeps only expired / unexpired ones.
/// </summary>
Task<PagedResponse<WarrantyRegistrationDto>> ListWarrantiesAsync(
PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status,
int? expiringWithinDays, bool? expired, CancellationToken ct = default);
/// <summary>Batch stock on hand (positive), optionally only batches expiring within N days (expired included).</summary>
Task<PagedResponse<BatchStockDto>> ListBatchStockAsync(
PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
@@ -63,6 +64,18 @@ public interface ISalesDomainService
bool isFreeIssue);
Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
/// <summary>
/// Batch/serial rules for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 4). A serial-tracked
/// item needs one serial per unit of <paramref name="totalQty"/>, each InStock at
/// <paramref name="warehouseId"/> and not already used on another line of the same document
/// (<paramref name="documentSerials"/>) — unless enough pre-tracking (serial-less) stock is
/// still on hand there (D6). A pinned batch must belong to the item and not be expired.
/// Returns the chosen serials as tracked entities, for the line's serial rows.
/// </summary>
Task<IReadOnlyList<Serial>> ValidateLineTrackingAsync(
int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, CancellationToken ct = default);
}
public sealed record SalesLineComputation(
@@ -0,0 +1,55 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Shared serial rules for stock documents other than GRN and sales — purchase returns,
/// transfers, adjustments and counts (docs/ITEM_TRACKING_PLAN.md Phases 6–7). Validation is
/// done before the document's transaction; <see cref="MoveAsync"/> runs inside it.
/// </summary>
public interface ISerialTrackingService
{
/// <summary>
/// Product Configuration "Serial numbers" switch (<see cref="ProductConfig.SerialNumbersEnabled"/>).
/// Off: no document asks for or accepts serials, and serial-mode items are handled as
/// untracked stock. Defaults to on when the config row is missing.
/// </summary>
Task<bool> IsEnabledAsync(CancellationToken ct = default);
/// <summary>True when <paramref name="item"/> is serial-tracked and the switch is on.</summary>
Task<bool> IsSerialTrackedAsync(Item item, CancellationToken ct = default);
/// <summary>
/// Throws <c>CONFIG_DISABLED</c> when serials were sent for a serial-mode item while the switch
/// is off — they are refused rather than silently dropped.
/// </summary>
void RejectSerialsWhileDisabled(Item item, int serialCount);
/// <summary>
/// Serials leaving <paramref name="warehouseId"/>. For a serial-tracked item: one per unit of
/// <paramref name="qty"/>, each currently at that warehouse in one of
/// <paramref name="allowedStatuses"/> and not used elsewhere on the same document
/// (<paramref name="documentSerials"/>); with none named, only pre-tracking (serial-less)
/// stock may go (D6). Any other item must name none. Returns tracked entities.
/// </summary>
Task<IReadOnlyList<Serial>> ResolveOutboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, IReadOnlyCollection<SerialStatus> allowedStatuses, CancellationToken ct = default);
/// <summary>
/// Serials arriving by adjustment or count: each number must be new for the item (it is
/// added to the context, unsaved and without a location — save before building
/// <see cref="StockDelta"/>s, which need its id) or an existing serial
/// that is not currently in stock anywhere (it is brought back). One per unit of
/// <paramref name="qty"/>; required for a serial-tracked item, rejected otherwise.
/// </summary>
Task<IReadOnlyList<Serial>> ResolveInboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<string>? serialNos,
ISet<string> documentSerialNos, CancellationToken ct = default);
/// <summary>Sets status and location on each serial and appends its <see cref="SerialMovement"/>.</summary>
Task MoveAsync(
IEnumerable<Serial> serials, SerialStatus status, int? warehouseId, int? binId,
string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default);
}
@@ -2,8 +2,16 @@ using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>A signed base-UOM change to an item's stock at one warehouse.</summary>
public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
/// <summary>
/// A signed base-UOM change to an item's stock at one warehouse.
/// <see cref="SerialIds"/> (serial-tracked items): an outbound delta consumes exactly those
/// serials' layers; an inbound delta creates one qty-1 layer per serial. Either way the count
/// must equal |<see cref="QtyDelta"/>|, and the caller owns the serials' status/location.
/// <see cref="UnitCost"/> overrides the inbound cost (default: last cost at the warehouse).
/// </summary>
public sealed record StockDelta(
int ItemId, int? BinId, int? BatchId, decimal QtyDelta,
IReadOnlyList<int>? SerialIds = null, decimal? UnitCost = null);
/// <summary>
/// Shared poster for stock-affecting documents (adjustments, count variances,
+12 -7
View File
@@ -298,7 +298,7 @@ public sealed class ItemService : IItemService
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config);
ValidateFeatureConfiguration(request, config, item.TrackingMode);
// 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
@@ -490,15 +490,20 @@ public sealed class ItemService : IItemService
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
/// <summary>
/// An item keeps the tracking mode it already has even after that feature is switched off —
/// otherwise it could not be edited at all; only a change <em>to</em> a disabled mode is refused.
/// </summary>
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
request.TrackingMode, request.Warranty, config, currentTrackingMode);
private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null)
{
var trackingUnchanged = trackingMode == currentTrackingMode;
if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException(
@@ -506,13 +511,13 @@ public sealed class ItemService : IItemService
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
422);
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial && !trackingUnchanged)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
422);
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch && !trackingUnchanged)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
@@ -620,7 +625,7 @@ public sealed class ItemService : IItemService
private static ItemScanResultDto ToScanResult(Serial s) => new(
s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial",
null, null, null, s.SerialId, s.SerialNo, s.Status);
null, null, null, s.SerialId, s.SerialNo, s.Status.ToString());
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
@@ -38,6 +38,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo;
private readonly IRepository<Serial> _serials;
private readonly ISerialTrackingService _serialTracking;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
@@ -49,9 +51,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
IRepository<Serial> serials, ISerialTrackingService serialTracking,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_serials = serials;
_serialTracking = serialTracking;
_returns = returns;
_vendors = vendors;
_warehouses = warehouses;
@@ -130,11 +135,24 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
// layer(s) below, not the generic mutator — track which by GrnLineId so the two
// paths don't double-handle the same line.
var rejectedGrnLineIds = new HashSet<int>();
var documentSerials = new HashSet<int>();
var serialsByLine = new Dictionary<CreatePurchaseReturnLineInput, IReadOnlyList<Serial>>();
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
// Rejected-line serials are checked against that line's own layers below; anything
// else must name serials actually on hand (D6: or leave only pre-tracking stock).
var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct);
if (!isRejectedLine || line.SerialIds is { Count: > 0 })
serialsByLine[line] = await _serialTracking.ResolveOutboundAsync(
item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials,
new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct);
if (isRejectedLine && serialsByLine.TryGetValue(line, out var named) && named.Any(s => s.GrnLineId != line.GrnLineId))
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials were not received on GRN line {line.GrnLineId}.", 422);
if (line.GrnLineId is not null)
{
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
@@ -158,7 +176,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
var actor = _currentUser.AuditUserId;
var deltas = request.Lines
.Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value))
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty,
serialsByLine.TryGetValue(l, out var s) ? s.Select(x => x.SerialId).ToList() : null)).ToList();
var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
@@ -196,10 +215,31 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToListAsync(token);
// A serial line's layers are one per unit: named serials take exactly theirs;
// with none named, the whole remaining line may go back (every serial on it).
var named = serialsByLine.TryGetValue(line, out var lineSerials) ? lineSerials : Array.Empty<Serial>();
if (named.Count > 0)
{
var ids = named.Select(s => s.SerialId).ToHashSet();
layers = layers.Where(l => l.SerialId != null && ids.Contains(l.SerialId.Value)).ToList();
if (layers.Count != named.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials are no longer on GRN line {line.GrnLineId}'s rejected stock.", 409);
}
else if (layers.Any(l => l.SerialId != null) && await _serialTracking.IsEnabledAsync(token))
{
if (line.Qty != layers.Sum(l => l.QtyRemaining))
throw new DomainException(ErrorCodes.TrackingRequired,
$"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422);
named = await _serials.Query()
.Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token);
serialsByLine[line] = named;
}
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token);
var remaining = line.Qty;
var takenSerialIds = new List<int>();
foreach (var layer in layers)
{
if (remaining <= 0) break;
@@ -207,6 +247,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
layer.QtyRemaining -= take;
remaining -= take;
bal -= take;
if (layer.SerialId is int takenSerial && layer.QtyRemaining == 0) takenSerialIds.Add(takenSerial);
var entry = await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor,
@@ -214,8 +255,17 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
refs.Add(entry);
}
balances[line.ItemId] = bal;
// Serial numbers switched off: serial units went back oldest-first without being
// named — still record them as returned so the register doesn't show them in stock.
if (named.Count == 0 && takenSerialIds.Count > 0)
serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token);
}
foreach (var serials in serialsByLine.Values)
await _serialTracking.MoveAsync(serials, SerialStatus.ReturnedToVendor, null, null,
DocumentTypes.PurchaseReturn, ret.ReturnId, now, token);
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
@@ -20,7 +20,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
public async Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines)
var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
@@ -32,7 +32,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
public async Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default)
{
var slip = await _slips.Query().Include(x => x.Lines)
var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.RowVersion != expectedRowVersion)
+73 -1
View File
@@ -16,6 +16,10 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<StockLayer> _layers;
private readonly ISerialTrackingService _serialTracking;
public SalesDomainService(
IRepository<Customer> customers,
@@ -24,8 +28,16 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig)
IRepository<SalesConfig> salesConfig,
IRepository<Batch> batches,
IRepository<Serial> serials,
IRepository<StockLayer> layers,
ISerialTrackingService serialTracking)
{
_serialTracking = serialTracking;
_batches = batches;
_serials = serials;
_layers = layers;
_customers = customers;
_warehouses = warehouses;
_users = users;
@@ -154,6 +166,66 @@ public sealed class SalesDomainService : ISalesDomainService
.Select(x => x.StockNature == StockNature.Stocked)
.FirstAsync(ct);
public async Task<IReadOnlyList<Serial>> ValidateLineTrackingAsync(
int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct);
var ids = serialIds?.ToList() ?? new List<int>();
if (batchId is not null)
{
var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BatchId == batchId && b.ItemId == itemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422);
if (batch.ExpiryDate is DateOnly expiry && expiry < DateOnly.FromDateTime(DateTime.UtcNow))
throw new DomainException(ErrorCodes.ExpiredBatchBlocked, $"Batch {batch.BatchNo} expired on {expiry:yyyy-MM-dd} and cannot be sold.", 422);
}
// Serial numbers switched off in Product Configuration: a serial-mode item sells like
// untracked stock (no serials asked, none accepted).
if (!await _serialTracking.IsSerialTrackedAsync(item, ct))
{
_serialTracking.RejectSerialsWhileDisabled(item, ids.Count);
if (ids.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
return Array.Empty<Serial>();
}
if (ids.Count == 0)
{
// D6: stock received before serial capture has no serials — it can still be sold
// without naming any, but only while enough of it is left at this warehouse.
if (item.StockNature != StockNature.Stocked) return Array.Empty<Serial>();
var untracked = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked < totalQty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select {totalQty} serial number(s).", 422);
return Array.Empty<Serial>();
}
if (ids.Count != totalQty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select exactly {totalQty} serial number(s), got {ids.Count}.", 422);
foreach (var id in ids)
if (!documentSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is selected more than once on this document.", 422);
// Tracked on purpose: the caller hangs these off the new line's serial rows as navigations.
var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in ids)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != itemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.Status != SerialStatus.InStock || serial.WarehouseId != warehouseId)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} is {serial.Status}{(serial.Status == SerialStatus.InStock ? " at another warehouse" : string.Empty)} and cannot be sold from warehouse {warehouseId}.", 422);
}
return serials;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{
var computed = mode == SalesDiscountMode.Amount
@@ -81,7 +81,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
@@ -155,14 +155,14 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
{
await _posting.PostInvoiceAsync(salesInvoiceId, ct);
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return _mapping.MapInvoice(invoice);
}
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled.");
@@ -188,10 +188,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
var documentSerials = new HashSet<int>();
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.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, r.SerialIds, documentSerials, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
@@ -217,7 +219,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
TaxPct = taxPct,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
ParentLineId = r.ParentLineId,
BatchId = r.BatchId,
Serials = serials.Select(s => new SalesInvoiceLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
});
}
return lines;
@@ -38,7 +38,8 @@ public sealed class SalesMappingService : ISalesMappingService
invoice.Lines.Select(l => new SalesInvoiceLineDto(
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());
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
l.BatchId, l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
public SalesSlipDto MapSlip(SalesSlip slip)
=> new(
@@ -49,5 +50,6 @@ public sealed class SalesMappingService : ISalesMappingService
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,
l.BatchId, l.SerialId)).ToList());
l.BatchId, l.SerialId,
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
}
+148 -17
View File
@@ -21,6 +21,10 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Item> _items;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SalesLineAllocation> _allocations;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly ICurrentUser _currentUser;
@@ -34,6 +38,10 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository<BundleSale> bundles,
IRepository<Item> items,
IRepository<Serial> serials,
IRepository<SalesLineAllocation> allocations,
IRepository<WarrantyRegistration> warranties,
IRepository<SerialMovement> serialMovements,
ISerialTrackingService serialTracking,
IFifoCostingService fifo,
ISalesDomainService sales,
ICurrentUser currentUser,
@@ -46,6 +54,10 @@ public sealed class SalesPostingService : ISalesPostingService
_bundles = bundles;
_items = items;
_serials = serials;
_allocations = allocations;
_warranties = warranties;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_fifo = fifo;
_sales = sales;
_currentUser = currentUser;
@@ -150,15 +162,17 @@ public sealed class SalesPostingService : ISalesPostingService
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
=> PostAsync(
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
load: () => _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
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.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)),
getLines: x => x.Lines.Select(l => new PostingLine(l.SalesInvoiceLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty,
l.BatchId, l.Serials.Select(s => s.SerialId).ToList())),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
getDocId: x => x.SalesInvoiceId,
getCustomerId: x => x.CustomerId,
ct: ct,
afterConsumption: PostInvoiceRevenueAsync);
@@ -208,15 +222,19 @@ public sealed class SalesPostingService : ISalesPostingService
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync(
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
load: () => _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
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.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)),
// An old draft may still carry only the legacy single SerialId.
getLines: x => x.Lines.Select(l => new PostingLine(l.SalesSlipLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId,
l.Serials.Count > 0 ? l.Serials.Select(s => s.SerialId).ToList()
: l.SerialId is int single ? new List<int> { single } : new List<int>())),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
getDocId: x => x.SalesSlipId,
getCustomerId: x => x.CustomerId,
ct: ct);
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -225,11 +243,12 @@ 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.",
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)),
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.BundleSaleLineId, l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, new List<int>())),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale,
getDocId: x => x.BundleSaleId,
getCustomerId: x => x.CustomerId,
ct: ct);
private async Task PostAsync<T>(
@@ -242,6 +261,7 @@ public sealed class SalesPostingService : ISalesPostingService
Action<T> setUpdated,
string sourceDocType,
Func<T, int> getDocId,
Func<T, int> getCustomerId,
CancellationToken ct,
/// <summary>Run inside the same transaction, after consumption and status flip, with the
/// total COGS this call just consumed — the invoice-only revenue-recognition hook.
@@ -262,27 +282,65 @@ public sealed class SalesPostingService : ISalesPostingService
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
var now = DateTime.UtcNow;
var docId = getDocId(doc);
foreach (var line in getLines(doc))
{
if (line.Qty <= 0) continue;
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
if (item.StockNature != StockNature.Stocked)
continue;
var serials = await LoadSerialsForPostingAsync(item, line, token);
// 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, line.BatchId, line.Qty, token, allowShortfall);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
// Serial lines consume exactly their serials' layers and can never go short; batch
// lines with no pinned batch are picked FEFO (docs/ITEM_TRACKING_PLAN.md Phase 3).
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token,
// With serial numbers switched off, a serial-mode item is plain stock and may go short.
allowShortfall && serials.Count == 0 && !await _serialTracking.IsSerialTrackedAsync(item, token),
serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
totalCogs += consumed.Sum(x => x.Qty * x.UnitCost);
// ConsumeAsync has no serial parameter — closing the loop on a serial-pinned
// line means flipping the serial's own status once its stock is actually gone.
if (line.SerialId.HasValue)
// One ledger row per batch actually issued (usually one) — a FEFO pick can span
// several, and each ledger row must carry the batch it moved.
foreach (var group in consumed.GroupBy(x => x.BatchId))
{
var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token);
if (serial is not null) serial.Status = "Sold";
var qty = group.Sum(x => x.Qty);
var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key,
serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId,
Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token);
}
foreach (var segment in consumed)
{
await _allocations.AddAsync(new SalesLineAllocation
{
SourceDocType = sourceDocType,
SourceDocId = docId,
SourceLineId = line.LineId,
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
BatchId = segment.BatchId,
SerialId = segment.SerialId,
Qty = segment.Qty,
UnitCost = segment.UnitCost,
CreatedAt = now
}, token);
}
foreach (var serial in serials)
{
serial.Status = SerialStatus.Sold;
serial.WarehouseId = null;
serial.BinId = null;
serial.UpdatedAt = now;
await _serialMovements.AddAsync(SerialMovement.Of(serial, line.WarehouseId, sourceDocType, docId, _currentUser.AuditUserId, now), token);
}
await RegisterWarrantyAsync(item, line, serials, getCustomerId(doc), sourceDocType, docId, now, token);
}
setPosted(doc);
@@ -292,5 +350,78 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct);
}
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId);
/// <summary>
/// Re-checks a line's chosen serials at post time — the draft did not reserve them, so another
/// document may have sold one since — and returns them tracked for the status flip. A
/// serial-tracked line with none chosen may only post from pre-tracking stock (D6); the
/// consume call enforces that by never touching serial layers without serial ids. A bundle
/// line has nowhere to choose serials, so a serial item in a bundle surfaces the same way.
/// </summary>
private async Task<IReadOnlyList<Serial>> LoadSerialsForPostingAsync(Item item, PostingLine line, CancellationToken ct)
{
if (line.SerialIds.Count == 0)
{
// Same D6 rule the draft was checked against (enough serial-less stock left here),
// re-run because that stock may have been issued since.
if (item.TrackingMode == TrackingMode.Serial)
await _sales.ValidateLineTrackingAsync(item.ItemId, line.WarehouseId, line.Qty, null, null, new HashSet<int>(), ct);
return Array.Empty<Serial>();
}
if (line.SerialIds.Count != line.Qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} needs exactly {line.Qty} serial number(s); {line.SerialIds.Count} are selected.", 422);
var serials = await _serials.Query().Where(s => line.SerialIds.Contains(s.SerialId)).ToListAsync(ct);
var unavailable = serials.Where(s => s.ItemId != item.ItemId || s.Status != SerialStatus.InStock || s.WarehouseId != line.WarehouseId)
.Select(s => s.SerialNo).ToList();
if (unavailable.Count > 0 || serials.Count != line.SerialIds.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", unavailable)} of item {item.Sku} are no longer in stock at this warehouse.", 409);
return serials;
}
/// <summary>
/// Starts the customer's warranty at posting (docs/ITEM_TRACKING_PLAN.md Phase 5): one
/// registration per serial, or one per line for a warranty item that is not serial-tracked.
/// Coverage is the period snapshotted on the serial at receipt, else the item's own.
/// </summary>
private async Task RegisterWarrantyAsync(
Item item, PostingLine line, IReadOnlyList<Serial> serials, int customerId,
string sourceDocType, int docId, DateTime now, CancellationToken ct)
{
if (item.Warranty != Warranty.Warranty) return;
var start = DateOnly.FromDateTime(now);
WarrantyRegistration Build(int? serialId, decimal qty, int months) => new()
{
ItemId = item.ItemId,
SerialId = serialId,
CustomerId = customerId,
SourceDocType = sourceDocType,
SourceDocId = docId,
SourceLineId = line.LineId,
Qty = qty,
StartDate = start,
PeriodMonths = months,
EndDate = start.AddMonths(months),
Status = WarrantyStatus.Active,
CreatedAt = now
};
if (serials.Count > 0)
{
foreach (var serial in serials)
if ((serial.WarrantyPeriodMonths ?? item.WarrantyPeriodMonths) is int months)
await _warranties.AddAsync(Build(serial.SerialId, 1m, months), ct);
return;
}
if (item.WarrantyPeriodMonths is int itemMonths)
await _warranties.AddAsync(Build(null, line.Qty, itemMonths), ct);
}
private sealed record PostingLine(
int LineId, int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty,
int? BatchId, IReadOnlyList<int> SerialIds);
}
+222 -12
View File
@@ -17,15 +17,24 @@ namespace ERPCore.Services;
/// <summary>
/// Sales-return service. Auto-posts with a mandatory Return reason code and
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
/// (positive delta — creates an inbound FIFO layer). Single UoW
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
/// reversed. Also posts a real GL journal entry reversing Inventory/COGS for the
/// stock movement's value (see <see cref="SalesReturn.GlJournalNo"/> for why revenue/tax
/// aren't part of it) — reuses the Sales Day End accounts since that's the module
/// whose COGS this reverses.
/// <para>
/// Item tracking (docs/ITEM_TRACKING_PLAN.md Phase 6): a return against an invoice line restocks
/// into the batch(es)/serials that line actually issued (<see cref="SalesLineAllocation"/>), at
/// the cost they were issued at; returned serials go back to InStock — or Damaged — at the return
/// warehouse, and their warranty is voided.
/// </para>
/// </summary>
public sealed class SalesReturnService : ISalesReturnService
{
// Return reasons whose goods are not resaleable (see DataSeeder.StandardReasonCodes).
private static readonly HashSet<string> DamagedReasonCodes = new(StringComparer.OrdinalIgnoreCase) { "DEF", "QREJ" };
private readonly IRepository<SalesReturn> _returns;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
@@ -34,6 +43,12 @@ public sealed class SalesReturnService : ISalesReturnService
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
private readonly IRepository<SalesReturnLine> _returnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SalesLineAllocation> _allocations;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -44,7 +59,9 @@ public sealed class SalesReturnService : ISalesReturnService
public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger,
IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SalesLineAllocation> allocations,
IRepository<WarrantyRegistration> warranties, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
@@ -56,6 +73,12 @@ public sealed class SalesReturnService : ISalesReturnService
_salesInvoiceLines = salesInvoiceLines;
_returnLines = returnLines;
_ledger = ledger;
_batches = batches;
_serials = serials;
_allocations = allocations;
_warranties = warranties;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
@@ -91,7 +114,7 @@ public sealed class SalesReturnService : ISalesReturnService
public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.Include(r => r.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
@@ -119,15 +142,19 @@ public sealed class SalesReturnService : ISalesReturnService
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
var damaged = request.MarkDamaged ?? DamagedReasonCodes.Contains(reason.Code);
// Original sold qty is never mutated — "remaining returnable" is computed from
// return history instead, so the invoice keeps recording what was actually sold.
var pendingByInvoiceLine = new Dictionary<int, decimal>();
var requestSerials = new HashSet<int>();
var resolved = new List<ResolvedReturnLine>();
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var allocations = new List<SalesLineAllocation>();
if (line.SalesInvoiceLineId is not null)
{
var invoiceLineId = line.SalesInvoiceLineId.Value;
@@ -145,11 +172,20 @@ public sealed class SalesReturnService : ISalesReturnService
if (line.Qty > remaining)
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
// Tracked: QtyReturned is updated below, inside the transaction.
allocations = await _allocations.Query()
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceLineId == invoiceLineId)
.OrderBy(a => a.SalesLineAllocationId)
.ToListAsync(ct);
}
resolved.Add(await ResolveTrackingAsync(item, line, allocations, requestSerials, ct));
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList();
var deltas = resolved.Select(r => new StockDelta(
r.Input.ItemId, null, r.BatchId, r.Input.Qty, r.Serials.Select(s => s.SerialId).ToList(), r.UnitCost)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
@@ -163,11 +199,13 @@ public sealed class SalesReturnService : ISalesReturnService
Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new SalesReturnLine
Lines = resolved.Select(r => new SalesReturnLine
{
SalesInvoiceLineId = l.SalesInvoiceLineId,
ItemId = l.ItemId,
Qty = l.Qty
SalesInvoiceLineId = r.Input.SalesInvoiceLineId,
ItemId = r.Input.ItemId,
Qty = r.Input.Qty,
BatchId = r.BatchId,
Serials = r.Serials.Select(s => new SalesReturnLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
}).ToList()
};
await _returns.AddAsync(ret, token);
@@ -175,6 +213,13 @@ public sealed class SalesReturnService : ISalesReturnService
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
foreach (var r in resolved)
{
await ApplyReturnEffectsAsync(r, request.WarehouseId, damaged, now, token);
foreach (var serial in r.Serials)
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.SalesReturn, ret.ReturnId, _currentUser.AuditUserId, now), token);
}
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
@@ -218,17 +263,182 @@ public sealed class SalesReturnService : ISalesReturnService
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
var allocations = await _allocations.Query().AsNoTracking()
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceDocId == salesInvoiceId)
.Select(a => new
{
a.SourceLineId,
Dto = new SalesLineAllocationDto(
a.BatchId, a.Batch != null ? a.Batch.BatchNo : null, a.Batch != null ? a.Batch.ExpiryDate : null,
a.SerialId, a.Serial != null ? a.Serial.SerialNo : null, a.Qty, a.QtyReturned)
})
.ToListAsync(ct);
return lines
.Select(l => new SalesInvoiceLineRemainingDto(
l.SalesInvoiceLineId,
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m)))
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m),
allocations.Where(a => a.SourceLineId == l.SalesInvoiceLineId).Select(a => a.Dto).ToList()))
.ToList();
}
/// <summary>
/// Works out which batch/serials one return line restocks, and at what cost. Against an
/// invoice line, both must come from what that line issued; free-standing, a serial item
/// must name serials that are Sold and a batch item must name its batch. Legacy invoice lines
/// posted before allocations existed (no allocation rows) return untracked, as before.
/// </summary>
private async Task<ResolvedReturnLine> ResolveTrackingAsync(
Item item, CreateSalesReturnLineInput line, List<SalesLineAllocation> allocations,
HashSet<int> requestSerials, CancellationToken ct)
{
var serialIds = line.SerialIds?.ToList() ?? new List<int>();
var batchId = line.BatchId;
var serials = new List<Serial>();
var hasAllocations = allocations.Count > 0;
// Serial numbers switched off: a serial-mode item comes back as untracked stock.
var serialTracked = await _serialTracking.IsSerialTrackedAsync(item, ct);
if (!serialTracked) _serialTracking.RejectSerialsWhileDisabled(item, serialIds.Count);
if (!serialTracked && serialIds.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
if (serialTracked)
{
var lineSoldSerials = hasAllocations && allocations.Any(a => a.SerialId != null);
if (serialIds.Count == 0)
{
if (line.SalesInvoiceLineId is null || lineSoldSerials)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select the {line.Qty} returned serial number(s).", 422);
}
else
{
if (serialIds.Count != line.Qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku}: returning {line.Qty} unit(s) needs exactly that many serials, got {serialIds.Count}.", 422);
foreach (var id in serialIds)
if (!requestSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is returned more than once on this document.", 422);
serials = await _serials.Query().Where(s => serialIds.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in serialIds)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != item.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.Status != SerialStatus.Sold)
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Serial {serial.SerialNo} is {serial.Status}, not Sold — it cannot be returned.", 422);
if (line.SalesInvoiceLineId is not null
&& !allocations.Any(a => a.SerialId == id && a.QtyReturned < a.Qty))
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} was not sold on sales invoice line {line.SalesInvoiceLineId} (or is already returned).", 422);
}
// A serial goes back into the batch it was sold from.
batchId ??= allocations.FirstOrDefault(a => a.SerialId == serialIds[0])?.BatchId;
}
}
else if (hasAllocations)
{
var issuedBatches = allocations.Where(a => a.QtyReturned < a.Qty).Select(a => a.BatchId).Distinct().ToList();
if (batchId is null)
{
if (issuedBatches.Count > 1)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Sales invoice line {line.SalesInvoiceLineId} issued several batches of item {item.Sku}; choose the batch being returned.", 422);
batchId = issuedBatches.FirstOrDefault();
}
else if (!issuedBatches.Contains(batchId))
{
throw new DomainException(ErrorCodes.Validation,
$"Batch {batchId} was not issued on sales invoice line {line.SalesInvoiceLineId}.", 422);
}
}
else if (item.TrackingMode == TrackingMode.Batch && batchId is null && line.SalesInvoiceLineId is null)
{
throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is batch-tracked; choose the batch being returned.", 422);
}
if (batchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == batchId && b.ItemId == item.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422);
// Restock at what the goods were issued at, so the COGS reversal mirrors the sale.
var costed = allocations.Where(a => serials.Count > 0
? a.SerialId != null && serialIds.Contains(a.SerialId.Value)
: a.BatchId == batchId)
.ToList();
decimal? unitCost = costed.Sum(a => a.Qty) > 0
? Math.Round(costed.Sum(a => a.Qty * a.UnitCost) / costed.Sum(a => a.Qty), 6, MidpointRounding.AwayFromZero)
: null;
return new ResolvedReturnLine(line, item, batchId, serials, allocations, unitCost);
}
/// <summary>
/// After the stock is back: serials return to stock (or Damaged) at the return warehouse, the
/// issuing allocations record what came back, and the matching warranty is voided — per
/// serial, or reduced by the returned quantity for a non-serial warranty line.
/// </summary>
private async Task ApplyReturnEffectsAsync(ResolvedReturnLine r, int warehouseId, bool damaged, DateTime now, CancellationToken ct)
{
foreach (var serial in r.Serials)
{
serial.Status = damaged ? SerialStatus.Damaged : SerialStatus.InStock;
serial.WarehouseId = warehouseId;
serial.BinId = null;
serial.UpdatedAt = now;
}
if (r.Serials.Count > 0)
{
var ids = r.Serials.Select(s => s.SerialId).ToHashSet();
foreach (var a in r.Allocations.Where(a => a.SerialId != null && ids.Contains(a.SerialId.Value)))
a.QtyReturned = a.Qty;
var registrations = await _warranties.Query()
.Where(w => w.SerialId != null && ids.Contains(w.SerialId.Value) && w.Status == WarrantyStatus.Active)
.ToListAsync(ct);
foreach (var w in registrations)
{
w.Status = WarrantyStatus.Voided;
w.UpdatedAt = now;
}
return;
}
var left = r.Input.Qty;
foreach (var a in r.Allocations.Where(a => a.BatchId == r.BatchId && a.QtyReturned < a.Qty))
{
if (left <= 0) break;
var take = Math.Min(left, a.Qty - a.QtyReturned);
a.QtyReturned += take;
left -= take;
}
if (r.Item.Warranty == Warranty.Warranty && r.Input.SalesInvoiceLineId is int invoiceLineId)
{
var registration = await _warranties.Query()
.FirstOrDefaultAsync(w => w.SourceDocType == DocumentTypes.SalesInvoice && w.SourceLineId == invoiceLineId
&& w.SerialId == null && w.Status == WarrantyStatus.Active, ct);
if (registration is not null)
{
registration.Qty = Math.Max(0m, registration.Qty - r.Input.Qty);
if (registration.Qty == 0m) registration.Status = WarrantyStatus.Voided;
registration.UpdatedAt = now;
}
}
}
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.GlJournalNo, r.GlPostedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty, l.BatchId,
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList()))
.ToList(),
ledgerRefs);
private sealed record ResolvedReturnLine(
CreateSalesReturnLineInput Input, Item Item, int? BatchId, List<Serial> Serials,
List<SalesLineAllocation> Allocations, decimal? UnitCost);
}
+12 -11
View File
@@ -79,7 +79,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
@@ -104,7 +104,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
}
@@ -180,7 +180,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
{
await _posting.PostSlipAsync(salesSlipId, ct);
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct);
return _mapping.MapSlip(slip);
@@ -188,7 +188,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
{
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.Status != SalesSlipStatus.Draft)
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled.");
@@ -201,10 +201,15 @@ public sealed class SalesSlipService : ISalesSlipService
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesSlipLine>();
var documentSerials = new HashSet<int>();
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.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
// The legacy single SerialId is a one-element SerialIds.
var serialIds = r.SerialIds is { Count: > 0 } ? r.SerialIds
: r.SerialId is int single ? new List<int> { single } : null;
var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, serialIds, documentSerials, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
@@ -212,11 +217,6 @@ public sealed class SalesSlipService : ISalesSlipService
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue);
if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422);
if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422);
lines.Add(new SalesSlipLine
{
ItemId = r.ItemId,
@@ -225,7 +225,8 @@ public sealed class SalesSlipService : ISalesSlipService
FreeQty = r.FreeQty,
WarehouseId = r.WarehouseId,
BatchId = r.BatchId,
SerialId = r.SerialId,
SerialId = serials.Count == 1 ? serials[0].SerialId : null,
Serials = serials.Select(s => new SalesSlipLineSerial { SerialId = s.SerialId, Serial = s }).ToList(),
UnitPrice = unitPrice,
BaseCost = unitPrice,
PriceSource = priceSource,
@@ -319,7 +320,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.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, l.BatchId, l.SerialId)).ToList());
_mapping.MapSlip(x).Lines);
}
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
@@ -63,11 +63,28 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false)
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null)
{
var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now);
if (serialIds is not null)
{
if (serialIds.Distinct().Count() != serialIds.Count || serialIds.Count != qtyBase)
throw new DomainException(ErrorCodes.Validation,
$"Issuing {qtyBase} of item {itemId} requires exactly {qtyBase} distinct serial(s), got {serialIds.Count}.", 422);
allowShortfall = false; // a serial cannot be sold before it exists
}
// Serial layers are held back from an issue that names no serials — but only while the
// item is serial-tracked and Product Configuration has serial numbers switched on. With
// the switch off (or the item moved off Serial), they are ordinary stock: issued
// oldest-first, and their serial records closed out below.
var holdSerialLayers = serialIds is null
&& await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId && i.TrackingMode == TrackingMode.Serial, ct)
&& (await _db.ProductConfig.AsNoTracking().Where(c => c.ConfigId == ProductConfig.SingletonId)
.Select(c => (bool?)c.SerialNumbersEnabled).FirstOrDefaultAsync(ct) ?? true);
if (batchId is not null)
{
var expiry = await _db.Batches.AsNoTracking()
@@ -107,6 +124,18 @@ public sealed class FifoCostingService : IFifoCostingService
""")
.ToListAsync(ct);
// A serial item holds one layer per unit, so don't lock them all: keep only the named
// serials' layers (or, with none named, the untracked ones) plus shortfall layers.
if (candidateIds.Count > 0 && (serialIds is not null || holdSerialLayers))
{
var ids = candidateIds;
candidateIds = await _db.StockLayers.AsNoTracking()
.Where(l => ids.Contains(l.LayerId) && (l.QtyRemaining < 0 || (serialIds == null
? l.SerialId == null
: l.SerialId != null && serialIds.Contains(l.SerialId.Value))))
.Select(l => l.LayerId).ToListAsync(ct);
}
// `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path.
List<StockLayer> locked;
if (candidateIds.Count == 0)
@@ -167,11 +196,13 @@ public sealed class FifoCostingService : IFifoCostingService
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
// Serial layers are excluded from settlement: netting a shortfall against one would empty
// a serial's layer while the serial itself still reads InStock.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
{
foreach (var layer in open)
foreach (var layer in open.Where(l => l.SerialId == null))
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
@@ -182,7 +213,38 @@ public sealed class FifoCostingService : IFifoCostingService
// Only now apply the caller's batch filter: settlement above is deliberately
// batch-agnostic (a shortfall layer has no batch), but the issue itself is not.
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
// Serial filter: named serials only, or — with none named — no serial layer while they
// are held back (see holdSerialLayers).
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)
&& (serialIds == null
? !holdSerialLayers || l.SerialId == null
: l.SerialId != null && serialIds.Contains(l.SerialId.Value)))
.ToList();
if (serialIds is not null)
{
var found = layers.Select(l => l.SerialId!.Value).ToHashSet();
var missing = serialIds.Where(id => !found.Contains(id)).ToList();
if (missing.Count > 0)
{
var numbers = await _db.Serials.AsNoTracking().Where(s => missing.Contains(s.SerialId))
.Select(s => s.SerialNo).ToListAsync(ct);
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", numbers.Count > 0 ? numbers : missing.Select(m => m.ToString()))} are not available to issue from warehouse {warehouseId} (not in stock here, on hold, or expired).", 409);
}
}
// FEFO then FIFO: soonest-expiring batch first; unbatched/no-expiry stock after.
var layerBatchIds = layers.Where(l => l.BatchId != null).Select(l => l.BatchId!.Value).Distinct().ToList();
if (layerBatchIds.Count > 0)
{
var expiries = await _db.Batches.AsNoTracking().Where(b => layerBatchIds.Contains(b.BatchId))
.ToDictionaryAsync(b => b.BatchId, b => b.ExpiryDate, ct);
layers = layers
.OrderBy(l => l.BatchId is int b && expiries.TryGetValue(b, out var e) && e is not null ? e.Value : DateOnly.MaxValue)
.ThenBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToList();
}
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase && !allowShortfall)
@@ -190,6 +252,7 @@ public sealed class FifoCostingService : IFifoCostingService
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
&& (batchId == null || l.BatchId == batchId)
&& (serialIds == null ? l.SerialId == null : serialIds.Contains(l.SerialId ?? 0))
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct);
if (heldExists)
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
@@ -207,7 +270,27 @@ public sealed class FifoCostingService : IFifoCostingService
var take = Math.Min(remaining, layer.QtyRemaining);
layer.QtyRemaining -= take;
remaining -= take;
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost, layer.BatchId, layer.SerialId));
}
// Serial units issued without being named (serial numbers switched off): close their
// records so the register doesn't keep showing them in stock.
if (serialIds is null)
{
var issuedSerialIds = layers.Where(l => l.SerialId != null && l.QtyRemaining == 0
&& segments.Any(s => s.LayerId == l.LayerId))
.Select(l => l.SerialId!.Value).ToList();
if (issuedSerialIds.Count > 0)
{
var issued = await _db.Serials.Where(s => issuedSerialIds.Contains(s.SerialId)).ToListAsync(ct);
foreach (var serial in issued)
{
serial.Status = SerialStatus.IssuedUntracked;
serial.WarehouseId = null;
serial.BinId = null;
serial.UpdatedAt = now;
}
}
}
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
@@ -232,6 +315,32 @@ public sealed class FifoCostingService : IFifoCostingService
return segments;
}
public async Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(
int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var open = await _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today))
.Select(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null, l.ReceiptDate, l.LayerId, l.QtyRemaining })
.ToListAsync(ct);
var result = new List<BatchAllocation>();
var remaining = qtyBase;
foreach (var group in open
.GroupBy(l => new { l.BatchId, l.BatchNo, l.Expiry })
.OrderBy(g => g.Key.Expiry ?? DateOnly.MaxValue)
.ThenBy(g => g.Min(l => l.ReceiptDate)))
{
if (remaining <= 0) break;
var take = Math.Min(remaining, group.Sum(l => l.QtyRemaining));
result.Add(new BatchAllocation(group.Key.BatchId, group.Key.BatchNo, group.Key.Expiry, take));
remaining -= take;
}
return result;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{
@@ -0,0 +1,263 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <inheritdoc cref="IItemTrackingService"/>
public sealed class ItemTrackingService : IItemTrackingService
{
private readonly ErpDbContext _db;
private readonly IFifoCostingService _fifo;
public ItemTrackingService(ErpDbContext db, IFifoCostingService fifo)
{
_db = db;
_fifo = fifo;
}
private static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow);
public const int MaxGeneratedSerials = 1000;
public async Task<IReadOnlyList<string>> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default)
{
prefix = prefix.Trim();
if (count < 1 || count > MaxGeneratedSerials)
throw new DomainException(ErrorCodes.Validation, $"Generate between 1 and {MaxGeneratedSerials} serial numbers at a time.", 422);
if (prefix.Length > 80)
throw new DomainException(ErrorCodes.Validation, "The serial prefix cannot be longer than 80 characters.", 422);
if (!await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
// Everything already taken under this prefix: received serials of the item, plus numbers
// sitting on draft GRN lines (they become serials when the GRN is confirmed).
var used = await _db.Serials.AsNoTracking()
.Where(s => s.ItemId == itemId && s.SerialNo.StartsWith(prefix))
.Select(s => s.SerialNo)
.Concat(_db.GrnLineSerials.AsNoTracking()
.Where(s => s.GrnLine!.ItemId == itemId && s.SerialId == null && s.SerialNo.StartsWith(prefix))
.Select(s => s.SerialNo))
.ToListAsync(ct);
// Highest numeric tail after the prefix ("PH-0041" → 41); width follows the widest seen.
long max = 0;
var width = 4;
foreach (var number in used)
{
var tail = number[prefix.Length..];
if (tail.Length == 0 || tail.Length > 18 || !tail.All(char.IsAsciiDigit)) continue;
max = Math.Max(max, long.Parse(tail));
width = Math.Max(width, tail.Length);
}
var taken = used.ToHashSet(StringComparer.OrdinalIgnoreCase);
var result = new List<string>(count);
for (var n = max + 1; result.Count < count; n++)
{
var candidate = prefix + n.ToString().PadLeft(width, '0');
if (candidate.Length > 100)
throw new DomainException(ErrorCodes.Validation, "Generated serial numbers would exceed 100 characters — use a shorter prefix.", 422);
if (taken.Add(candidate)) result.Add(candidate);
}
return result;
}
public async Task<IReadOnlyList<AvailableSerialDto>> GetAvailableSerialsAsync(
int itemId, int warehouseId, string? q, int take, CancellationToken ct = default)
{
var today = Today;
// Issuable = InStock here AND its own layer is open, not on hold, not in an expired batch —
// the same test posting applies, so the picker never offers a serial posting would refuse.
var query = _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId != null && l.QtyRemaining > 0
&& l.Serial!.Status == SerialStatus.InStock && l.Serial.WarehouseId == warehouseId
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today));
if (!string.IsNullOrWhiteSpace(q))
{
var term = q.Trim();
query = query.Where(l => EF.Functions.Like(l.Serial!.SerialNo, $"%{term}%") || l.Serial.Barcode == term);
}
return await query
.OrderBy(l => l.Serial!.SerialNo)
.Take(Math.Clamp(take, 1, 500))
.Select(l => new AvailableSerialDto(l.SerialId!.Value, l.Serial!.SerialNo, l.BatchId,
l.Batch != null ? l.Batch.BatchNo : null, l.Serial.Status))
.ToListAsync(ct);
}
public async Task<IReadOnlyList<AvailableBatchDto>> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var today = Today;
var rows = await _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today))
.GroupBy(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null })
.Select(g => new { g.Key.BatchId, g.Key.BatchNo, g.Key.Expiry, Qty = g.Sum(l => l.QtyRemaining) })
.ToListAsync(ct);
return rows
.OrderBy(r => r.Expiry ?? DateOnly.MaxValue).ThenBy(r => r.BatchNo)
.Select(r => new AvailableBatchDto(r.BatchId, r.BatchNo, r.Expiry, r.Qty,
r.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null))
.ToList();
}
public async Task<IReadOnlyList<BatchSuggestionDto>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default)
=> (await _fifo.SuggestBatchesAsync(itemId, warehouseId, qty, ct))
.Select(a => new BatchSuggestionDto(a.BatchId, a.BatchNo, a.ExpiryDate, a.Qty)).ToList();
public async Task<PagedResponse<SerialSummaryDto>> ListSerialsAsync(
PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default)
{
var q = _db.Serials.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(s => EF.Functions.Like(s.SerialNo, $"%{term}%") || s.Barcode == term
|| EF.Functions.Like(s.Item!.Sku, $"%{term}%") || EF.Functions.Like(s.Item.Name, $"%{term}%"));
}
if (itemId is not null) q = q.Where(s => s.ItemId == itemId);
if (warehouseId is not null) q = q.Where(s => s.WarehouseId == warehouseId);
if (status is not null) q = q.Where(s => s.Status == status);
var total = await q.CountAsync(ct);
var rows = await ProjectSerials(q.OrderByDescending(s => s.SerialId).Skip(query.Skip).Take(query.PageSize)).ToListAsync(ct);
return PagedResponse<SerialSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<SerialDetailDto?> GetSerialAsync(int serialId, CancellationToken ct = default)
{
var serial = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialId == serialId)).FirstOrDefaultAsync(ct);
return serial is null ? null : await BuildDetailAsync(serial, ct);
}
public async Task<IReadOnlyList<SerialDetailDto>> FindSerialAsync(string serialNo, CancellationToken ct = default)
{
var term = serialNo.Trim();
var serials = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialNo == term || s.Barcode == term))
.Take(20).ToListAsync(ct);
var result = new List<SerialDetailDto>();
foreach (var s in serials) result.Add(await BuildDetailAsync(s, ct));
return result;
}
public async Task<PagedResponse<WarrantyRegistrationDto>> ListWarrantiesAsync(
PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status,
int? expiringWithinDays, bool? expired, CancellationToken ct = default)
{
var today = Today;
var q = _db.WarrantyRegistrations.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => (w.Serial != null && EF.Functions.Like(w.Serial.SerialNo, $"%{term}%"))
|| EF.Functions.Like(w.Item!.Sku, $"%{term}%") || EF.Functions.Like(w.Item.Name, $"%{term}%"));
}
if (customerId is not null) q = q.Where(w => w.CustomerId == customerId);
if (itemId is not null) q = q.Where(w => w.ItemId == itemId);
if (!string.IsNullOrWhiteSpace(serialNo))
{
var term = serialNo.Trim();
q = q.Where(w => w.Serial != null && (w.Serial.SerialNo == term || w.Serial.Barcode == term));
}
if (status is not null) q = q.Where(w => w.Status == status);
if (expiringWithinDays is int days)
{
var until = today.AddDays(days);
q = q.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= until);
}
if (expired == true) q = q.Where(w => w.EndDate < today);
if (expired == false) q = q.Where(w => w.EndDate >= today);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.EndDate).ThenBy(w => w.WarrantyRegistrationId)
.Skip(query.Skip).Take(query.PageSize)
.Select(WarrantyProjection())
.ToListAsync(ct);
return PagedResponse<WarrantyRegistrationDto>.Create(rows.Select(r => WithDays(r, today)).ToList(), query.Page, query.PageSize, total);
}
public async Task<PagedResponse<BatchStockDto>> ListBatchStockAsync(
PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default)
{
var today = Today;
var layers = _db.StockLayers.AsNoTracking().Where(l => l.QtyRemaining > 0 && l.BatchId != null);
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
if (expiringWithinDays is int days)
{
var until = today.AddDays(days);
layers = layers.Where(l => l.Batch!.ExpiryDate != null && l.Batch.ExpiryDate <= until);
}
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
layers = layers.Where(l => EF.Functions.Like(l.Batch!.BatchNo, $"%{term}%")
|| EF.Functions.Like(l.Item!.Sku, $"%{term}%") || EF.Functions.Like(l.Item.Name, $"%{term}%"));
}
var grouped = layers.GroupBy(l => new
{
l.ItemId, Sku = l.Item!.Sku, ItemName = l.Item.Name,
l.WarehouseId, WarehouseName = l.Warehouse != null ? l.Warehouse.Name : null,
l.BatchId, BatchNo = l.Batch!.BatchNo, Expiry = l.Batch.ExpiryDate
}).Select(g => new { g.Key, Qty = g.Sum(l => l.QtyRemaining) });
var total = await grouped.CountAsync(ct);
var rows = await grouped
.OrderBy(g => g.Key.Expiry == null).ThenBy(g => g.Key.Expiry).ThenBy(g => g.Key.Sku)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<BatchStockDto>.Create(rows.Select(r => new BatchStockDto(
r.Key.ItemId, r.Key.Sku, r.Key.ItemName, r.Key.WarehouseId, r.Key.WarehouseName ?? $"Warehouse {r.Key.WarehouseId}",
r.Key.BatchId, r.Key.BatchNo, r.Key.Expiry, r.Qty,
r.Key.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null,
r.Key.Expiry is DateOnly x && x < today)).ToList(), query.Page, query.PageSize, total);
}
private static IQueryable<SerialSummaryDto> ProjectSerials(IQueryable<Serial> q) => q.Select(s => new SerialSummaryDto(
s.SerialId, s.ItemId, s.Item!.Sku, s.Item.Name, s.SerialNo, s.Status,
s.WarehouseId, s.Warehouse != null ? s.Warehouse.Name : null, s.BinId, s.ReceivedAt,
s.GrnLine != null ? s.GrnLine.GrnId : null, s.GrnLine != null && s.GrnLine.Grn != null ? s.GrnLine.Grn.DocNo : null,
s.WarrantyPeriodMonths));
private async Task<SerialDetailDto> BuildDetailAsync(SerialSummaryDto serial, CancellationToken ct)
{
var today = Today;
var history = await _db.SerialMovements.AsNoTracking()
.Where(m => m.SerialId == serial.SerialId)
.OrderBy(m => m.SerialMovementId)
.Select(m => new SerialMovementDto(m.SerialMovementId, m.SourceDocType, m.SourceDocId, m.Status,
m.FromWarehouseId, m.WarehouseId, m.UserId, m.CreatedAt))
.ToListAsync(ct);
var warranties = await _db.WarrantyRegistrations.AsNoTracking()
.Where(w => w.SerialId == serial.SerialId)
.OrderByDescending(w => w.WarrantyRegistrationId)
.Select(WarrantyProjection())
.ToListAsync(ct);
return new SerialDetailDto(serial, history, warranties.Select(w => WithDays(w, today)).ToList());
}
private static global::System.Linq.Expressions.Expression<Func<WarrantyRegistration, WarrantyRegistrationDto>> WarrantyProjection()
=> w => new WarrantyRegistrationDto(
w.WarrantyRegistrationId, w.ItemId, w.Item!.Sku, w.Item.Name, w.SerialId, w.Serial != null ? w.Serial.SerialNo : null,
w.CustomerId, w.Customer != null ? (w.Customer.DisplayName ?? w.Customer.Name) : string.Empty,
w.SourceDocType, w.SourceDocId, w.SourceLineId, w.Qty, w.StartDate, w.PeriodMonths, w.EndDate, w.Status,
0, false);
private static WarrantyRegistrationDto WithDays(WarrantyRegistrationDto w, DateOnly today) => w with
{
DaysRemaining = Math.Max(0, w.EndDate.DayNumber - today.DayNumber),
IsExpired = w.EndDate < today
};
}
@@ -0,0 +1,156 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <inheritdoc cref="ISerialTrackingService"/>
public sealed class SerialTrackingService : ISerialTrackingService
{
private readonly IRepository<Serial> _serials;
private readonly IRepository<SerialMovement> _movements;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<ProductConfig> _config;
private readonly ICurrentUser _currentUser;
private bool? _enabled; // per request (scoped service) — one config read
public SerialTrackingService(
IRepository<Serial> serials, IRepository<SerialMovement> movements,
IRepository<StockLayer> layers, IRepository<ProductConfig> config, ICurrentUser currentUser)
{
_serials = serials;
_movements = movements;
_layers = layers;
_config = config;
_currentUser = currentUser;
}
public async Task<bool> IsEnabledAsync(CancellationToken ct = default)
=> _enabled ??= await _config.Query().AsNoTracking()
.Where(c => c.ConfigId == ProductConfig.SingletonId)
.Select(c => (bool?)c.SerialNumbersEnabled)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<bool> IsSerialTrackedAsync(Item item, CancellationToken ct = default)
=> item.TrackingMode == TrackingMode.Serial && await IsEnabledAsync(ct);
public void RejectSerialsWhileDisabled(Item item, int serialCount)
{
if (item.TrackingMode == TrackingMode.Serial && serialCount > 0)
throw new DomainException(ErrorCodes.ConfigDisabled,
$"Serial numbers are switched off in Product Configuration; item {item.Sku} is handled without serials.", 422);
}
public async Task<IReadOnlyList<Serial>> ResolveOutboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, IReadOnlyCollection<SerialStatus> allowedStatuses, CancellationToken ct = default)
{
var ids = serialIds?.ToList() ?? new List<int>();
if (!await IsSerialTrackedAsync(item, ct))
{
RejectSerialsWhileDisabled(item, ids.Count);
if (ids.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
return Array.Empty<Serial>();
}
if (ids.Count == 0)
{
var untracked = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == item.ItemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked < qty)
throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is serial-tracked; select {qty} serial number(s).", 422);
return Array.Empty<Serial>();
}
if (ids.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku}: {qty} unit(s) need exactly that many serials, got {ids.Count}.", 422);
foreach (var id in ids)
if (!documentSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is used more than once on this document.", 422);
var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in ids)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != item.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.WarehouseId != warehouseId || !allowedStatuses.Contains(serial.Status))
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} is {serial.Status}{(serial.WarehouseId is int w && w != warehouseId ? $" at warehouse {w}" : string.Empty)} and cannot leave warehouse {warehouseId}.", 422);
}
return serials;
}
public async Task<IReadOnlyList<Serial>> ResolveInboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<string>? serialNos,
ISet<string> documentSerialNos, CancellationToken ct = default)
{
var numbers = (serialNos ?? Array.Empty<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (!await IsSerialTrackedAsync(item, ct))
{
RejectSerialsWhileDisabled(item, numbers.Count);
if (numbers.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422);
return Array.Empty<Serial>();
}
if (qty != decimal.Truncate(qty))
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422);
if (numbers.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; enter exactly {qty} serial number(s), got {numbers.Count}.", 422);
if (numbers.Any(n => n.Length > 100))
throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422);
foreach (var n in numbers)
if (!documentSerialNos.Add($"{item.ItemId}:{n.ToUpperInvariant()}"))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} is entered more than once.", 422);
var existing = await _serials.Query().Where(s => s.ItemId == item.ItemId && numbers.Contains(s.SerialNo)).ToListAsync(ct);
var inStock = existing.Where(s => s.WarehouseId != null).Select(s => s.SerialNo).ToList();
if (inStock.Count > 0)
throw new DomainException(ErrorCodes.SerialDuplicate,
$"Serial number(s) {string.Join(", ", inStock)} of item {item.Sku} are already in stock.", 409);
var now = DateTime.UtcNow;
var result = new List<Serial>(existing);
foreach (var n in numbers.Where(n => !existing.Any(e => string.Equals(e.SerialNo, n, StringComparison.OrdinalIgnoreCase))))
{
var serial = new Serial
{
ItemId = item.ItemId,
SerialNo = n,
// Location is set by MoveAsync once the stock actually lands. Callers must save
// (flush) before building StockDeltas, which need the generated SerialId.
Status = SerialStatus.InStock,
ReceivedAt = now,
CreatedAt = now,
WarrantyPeriodMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null
};
await _serials.AddAsync(serial, ct);
result.Add(serial);
}
return result;
}
public async Task MoveAsync(
IEnumerable<Serial> serials, SerialStatus status, int? warehouseId, int? binId,
string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default)
{
foreach (var serial in serials)
{
var from = serial.WarehouseId;
serial.Status = status;
serial.WarehouseId = warehouseId;
serial.BinId = binId;
serial.UpdatedAt = now;
await _movements.AddAsync(SerialMovement.Of(serial, from, sourceDocType, sourceDocId, _currentUser.AuditUserId, now), ct);
}
}
}
+21 -5
View File
@@ -3,6 +3,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
@@ -41,24 +42,39 @@ public sealed class StockMutator : IStockMutator
if (!balances.TryGetValue(d.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
var serialIds = d.SerialIds is { Count: > 0 } ? d.SerialIds : null;
if (serialIds is not null && serialIds.Count != Math.Abs(d.QtyDelta))
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {d.ItemId}: {Math.Abs(d.QtyDelta)} unit(s) need exactly that many serials, got {serialIds.Count}.", 422);
// The ledger carries a serial only when the movement is exactly one unit.
int? ledgerSerialId = serialIds is { Count: 1 } ? serialIds[0] : null;
StockLedger entry;
if (d.QtyDelta < 0)
{
var qty = -d.QtyDelta;
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct);
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct, serialIds: serialIds);
var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty;
bal -= qty;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
}
else
{
var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct);
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
var unitCost = d.UnitCost ?? await LastCostAsync(d.ItemId, warehouseId, ct);
if (serialIds is not null)
{
foreach (var serialId in serialIds)
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, serialId, null, 1m, unitCost, now, ct);
}
else
{
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
}
bal += d.QtyDelta;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
}
+112 -19
View File
@@ -37,12 +37,15 @@ public sealed class TransferService : ITransferService
private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly ISerialTrackingService _serialTracking;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow)
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
ISerialTrackingService serialTracking)
{
_serialTracking = serialTracking;
_transfers = transfers;
_warehouses = warehouses;
_items = items;
@@ -83,7 +86,8 @@ public sealed class TransferService : ITransferService
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
var t = await _transfers.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.TransferId == transferId, ct);
return t is null ? null : Map(t);
}
@@ -95,9 +99,42 @@ public sealed class TransferService : ITransferService
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve tracking up front: serial lines carry their serials; a batch-tracked line with
// no batch is split FEFO into one line per batch, so batch identity survives the move.
var lines = new List<StockTransferLine>();
var documentSerials = new HashSet<int>();
foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var serials = await _serialTracking.ResolveOutboundAsync(
item, request.SrcWarehouseId, line.Qty, line.SerialIds, documentSerials, new[] { SerialStatus.InStock }, ct);
StockTransferLine NewLine(int? batchId, decimal qty) => new()
{
ItemId = line.ItemId,
SrcBinId = line.SrcBinId,
DestBinId = line.DestBinId,
BatchId = batchId,
Qty = qty,
SerialId = serials.Count == 1 ? serials[0].SerialId : null,
Serials = serials.Select(s => new StockTransferLineSerial { SerialId = s.SerialId }).ToList()
};
if (item.TrackingMode == TrackingMode.Batch && line.BatchId is null)
{
var picks = await _fifo.SuggestBatchesAsync(line.ItemId, request.SrcWarehouseId, line.Qty, ct);
var picked = picks.Sum(p => p.Qty);
foreach (var pick in picks) lines.Add(NewLine(pick.BatchId, pick.Qty));
// Whatever FEFO could not place stays unbatched — dispatch then reports the shortfall.
if (picked < line.Qty) lines.Add(NewLine(null, line.Qty - picked));
}
else
{
lines.Add(NewLine(line.BatchId, line.Qty));
}
}
var actor = _currentUser.AuditUserId;
var transfer = await _uow.ExecuteInTransactionAsync(async token =>
@@ -112,14 +149,7 @@ public sealed class TransferService : ITransferService
Status = TransferStatus.Draft,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new StockTransferLine
{
ItemId = l.ItemId,
SrcBinId = l.SrcBinId,
DestBinId = l.DestBinId,
BatchId = l.BatchId,
Qty = l.Qty
}).ToList()
Lines = lines
};
await _transfers.AddAsync(entity, token);
return entity;
@@ -132,7 +162,8 @@ public sealed class TransferService : ITransferService
public async Task<DispatchResultDto> DispatchAsync(int transferId, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
var transfer = await _transfers.Query()
.Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.Draft)
@@ -148,10 +179,28 @@ public sealed class TransferService : ITransferService
{
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
{
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token);
// The draft didn't reserve its serials — re-check they are still here and in stock.
var serials = line.Serials.Select(s => s.Serial!).ToList();
var gone = serials.Where(s => s.Status != SerialStatus.InStock || s.WarehouseId != transfer.SrcWarehouseId).Select(s => s.SerialNo).ToList();
if (gone.Count > 0)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", gone)} are no longer in stock at the source warehouse.", 409);
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token,
serialIds: serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
var value = segments.Sum(s => s.Qty * s.UnitCost);
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
// Each serial keeps its own cost and batch for its destination layer.
foreach (var lineSerial in line.Serials)
{
var segment = segments.First(s => s.SerialId == lineSerial.SerialId);
lineSerial.UnitCost = segment.UnitCost;
lineSerial.BatchId = segment.BatchId;
}
await _serialTracking.MoveAsync(serials, SerialStatus.InTransit, null, null,
DocumentTypes.Transfer, transfer.TransferId, now, token);
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
bal -= line.Qty;
@@ -174,7 +223,8 @@ public sealed class TransferService : ITransferService
public async Task<ReceiveResultDto> ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
var transfer = await _transfers.Query()
.Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.InTransit)
@@ -199,9 +249,26 @@ public sealed class TransferService : ITransferService
$"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422);
var unitCost = line.UnitCost ?? 0m;
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
createdLayers.Add(layer);
if (line.Serials.Count > 0)
{
var arriving = ResolveArrivingSerials(line, input);
foreach (var lineSerial in arriving)
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, lineSerial.BatchId ?? line.BatchId, lineSerial.SerialId, null,
1m, lineSerial.UnitCost ?? unitCost, now, token);
createdLayers.Add(layer);
lineSerial.ReceivedAt = now;
}
await _serialTracking.MoveAsync(arriving.Select(s => s.Serial!), SerialStatus.InStock,
transfer.DestWarehouseId, line.DestBinId, DocumentTypes.Transfer, transfer.TransferId, now, token);
}
else
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
createdLayers.Add(layer);
}
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
@@ -234,8 +301,34 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
}
/// <summary>
/// Which in-transit serials a receive line covers: the ones it names (each still outstanding
/// on the line), or — with none named — every outstanding one, which must then be the full qty.
/// </summary>
private static List<StockTransferLineSerial> ResolveArrivingSerials(StockTransferLine line, ReceiveTransferLineInput input)
{
var outstanding = line.Serials.Where(s => s.ReceivedAt == null).ToList();
if (input.SerialIds is not { Count: > 0 })
{
if (input.Qty != outstanding.Count)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Transfer line {line.TransferLineId} is serial-tracked; name which {input.Qty} of its {outstanding.Count} in-transit serials arrived.", 422);
return outstanding;
}
if (input.SerialIds.Count != input.Qty || input.SerialIds.Distinct().Count() != input.SerialIds.Count)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Receiving {input.Qty} on transfer line {line.TransferLineId} needs exactly that many distinct serials.", 422);
var arriving = outstanding.Where(s => input.SerialIds.Contains(s.SerialId)).ToList();
if (arriving.Count != input.SerialIds.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Some serials are not in transit on transfer line {line.TransferLineId}.", 422);
return arriving;
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived,
l.Serials.Select(s => new TransferLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty, s.ReceivedAt != null)).ToList())).ToList());
}
@@ -30,6 +30,11 @@ public static class ErrorCodes
public const string ConfigDisabled = "CONFIG_DISABLED";
public const string RoleInUse = "ROLE_IN_USE";
// Item tracking (docs/ITEM_TRACKING_PLAN.md).
public const string SerialDuplicate = "SERIAL_DUPLICATE";
public const string SerialNotAvailable = "SERIAL_NOT_AVAILABLE";
public const string TrackingRequired = "TRACKING_REQUIRED";
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE";