ca81e9bc42
- Implemented General Ledger client component for managing GL integration. - Created a dedicated page for General Ledger configuration. - Developed Finance Report client component for generating various financial reports. - Added report blocks for structured display of financial data. - Introduced API endpoints for fetching GL accounts and financial reports. - Updated navigation to include links for GL integration and financial reports. - Enhanced permissions to manage GL and view financial reports. - Updated appsettings to include General Ledger API configuration.
491 lines
24 KiB
C#
491 lines
24 KiB
C#
using Distribution.Features.GeneralLedger;
|
|
using Distribution.Features.Po;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Features.SysConfig;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Grn;
|
|
|
|
public class GrnService(
|
|
IGrnRepository repo,
|
|
IPoRepository poRepo,
|
|
IStockRepository stockRepo,
|
|
IUnitOfWork uow,
|
|
Distribution.Features.Item.IItemRepository itemRepo,
|
|
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
|
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
|
GlPostingService gl)
|
|
{
|
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
|
{
|
|
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
|
|
var item = i.Item ?? i.PurchaseOrderItem!.Item;
|
|
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
|
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
|
|
i.AcceptedQuantity, i.RejectedQuantity,
|
|
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
|
|
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
|
|
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
|
|
i.NbtPercent, a.Nbt);
|
|
}
|
|
|
|
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
|
|
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
|
|
|
|
private static GrnResponse Map(Models.GoodsReceipt gr)
|
|
{
|
|
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
|
|
var subTotal = items.Sum(i => i.GrossAmount);
|
|
var discount = items.Sum(i => i.DiscountAmount);
|
|
var vat = items.Sum(i => i.VatAmount);
|
|
var nbt = items.Sum(i => i.NbtAmount);
|
|
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
|
gr.FK_SupplierId, gr.Supplier?.Name,
|
|
gr.ReceivedAt, gr.CreatedAt, items,
|
|
gr.DiscountPercent, gr.VatPercent,
|
|
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
|
|
gr.NbtPercent, nbt);
|
|
}
|
|
|
|
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
|
|
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
|
|
|
|
/// <summary>
|
|
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
|
|
/// forced to 0 whatever the client sent.
|
|
/// </summary>
|
|
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
|
|
{
|
|
var discount = discountPercent ?? 0m;
|
|
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
|
|
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
|
|
GrnPricing.ValidatePercent(discount, "GRN discount %");
|
|
GrnPricing.ValidatePercent(vat, "GRN VAT %");
|
|
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
|
|
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
|
|
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
|
|
/// </summary>
|
|
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
|
|
{
|
|
if (mode == TaxMode.Disabled) return 0m;
|
|
var value = lineValue ?? headerValue;
|
|
if (value is null)
|
|
{
|
|
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
|
|
return 0m;
|
|
}
|
|
GrnPricing.ValidatePercent(value.Value, label);
|
|
return value.Value;
|
|
}
|
|
|
|
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
|
|
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
|
|
GrnHeader header, GrnTaxSettings tax, string sku)
|
|
{
|
|
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
|
|
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
|
|
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
|
|
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
|
|
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
|
|
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
|
|
}
|
|
|
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
|
{
|
|
var total = await repo.GetCountAsync(purchaseOrderId);
|
|
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
|
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
|
}
|
|
|
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var gr = await repo.GetByIdAsync(id);
|
|
if (gr is null || !gr.IsActive) return null;
|
|
return Map(gr);
|
|
}
|
|
|
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
|
|
|
/// <summary>
|
|
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
|
|
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
|
|
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
|
|
/// yet linked into the ledger (follow-up work).
|
|
/// </summary>
|
|
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
|
|
{
|
|
if (item.TracksSerialNumbers)
|
|
{
|
|
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
|
|
throw new InvalidOperationException(
|
|
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
|
|
|
|
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
|
|
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
|
|
}
|
|
else if (line.SerialNumbers is { Count: > 0 })
|
|
{
|
|
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
|
|
}
|
|
else if (item.TracksBatches || item.TracksExpiryDate)
|
|
{
|
|
if (StockQty(line) > 0)
|
|
{
|
|
if (line.Batches is null || line.Batches.Count == 0)
|
|
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
|
|
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
|
|
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
|
|
}
|
|
|
|
foreach (var b in line.Batches ?? [])
|
|
{
|
|
if (b.Quantity <= 0)
|
|
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
|
|
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
|
|
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
|
|
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
|
|
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
|
|
if (item.TracksExpiryDate && b.ExpiryDate is null)
|
|
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
|
|
}
|
|
}
|
|
else if (line.Batches is { Count: > 0 })
|
|
{
|
|
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
|
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
|
|
/// units don't count against the PO's ordered quantity), creates
|
|
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
|
|
/// PartiallyReceived/Received. All in one unit of work.
|
|
/// </summary>
|
|
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
|
{
|
|
var po = await poRepo.GetByIdAsync(purchaseOrderId)
|
|
?? throw new InvalidOperationException("Purchase order not found.");
|
|
|
|
if (!po.IsActive)
|
|
throw new InvalidOperationException("Purchase order not found.");
|
|
|
|
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
|
|
throw new InvalidOperationException(
|
|
$"Cannot receive goods against a purchase order in {po.Status} status.");
|
|
|
|
if (request.Items is null || request.Items.Count == 0)
|
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
|
|
|
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
|
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
|
|
|
var goodsReceipt = new Models.GoodsReceipt
|
|
{
|
|
FK_PurchaseOrderId = po.Id,
|
|
FK_ReceivedByUserId = receivedByUserId,
|
|
FK_WarehouseId = request.WarehouseId,
|
|
ReceivedAt = DateTime.Now,
|
|
DiscountPercent = header.Discount,
|
|
VatPercent = header.Vat,
|
|
NbtPercent = header.Nbt
|
|
};
|
|
|
|
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
if (line.PurchaseOrderItemId is null)
|
|
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
|
|
|
|
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
|
|
|
if (poItem.IsCancelled)
|
|
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
|
|
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
|
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
|
|
|
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
|
throw new InvalidOperationException(
|
|
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
|
|
|
ValidateLineShape(poItem.Item, line);
|
|
|
|
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
|
{
|
|
foreach (var serialNumber in line.SerialNumbers)
|
|
{
|
|
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
|
|
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
|
|
}
|
|
}
|
|
|
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
|
|
|
var entity = new Models.GoodsReceiptItem
|
|
{
|
|
FK_PurchaseOrderItemId = poItem.Id,
|
|
AcceptedQuantity = line.AcceptedQuantity,
|
|
FreeQuantity = line.FreeQuantity,
|
|
RejectedQuantity = line.RejectedQuantity
|
|
};
|
|
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
|
|
goodsReceipt.Items.Add(entity);
|
|
grLines.Add((entity, line, poItem.Item));
|
|
}
|
|
|
|
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
|
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
|
|
? PurchaseOrderStatus.Received
|
|
: PurchaseOrderStatus.PartiallyReceived;
|
|
|
|
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
|
|
}
|
|
/// <summary>
|
|
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
|
|
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
|
|
/// </summary>
|
|
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
|
{
|
|
if (request.Items is null || request.Items.Count == 0)
|
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
|
|
|
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
|
|
throw new InvalidOperationException("Supplier not found.");
|
|
|
|
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
|
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
|
|
|
var goodsReceipt = new Models.GoodsReceipt
|
|
{
|
|
FK_PurchaseOrderId = null,
|
|
FK_SupplierId = request.SupplierId,
|
|
FK_ReceivedByUserId = receivedByUserId,
|
|
FK_WarehouseId = request.WarehouseId,
|
|
ReceivedAt = DateTime.Now,
|
|
DiscountPercent = header.Discount,
|
|
VatPercent = header.Vat,
|
|
NbtPercent = header.Nbt
|
|
};
|
|
|
|
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
|
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
if (line.ItemId is null)
|
|
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
|
|
|
|
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
|
|
if (item is null || !item.IsActive)
|
|
throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
|
|
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
|
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
|
|
|
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
|
|
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
|
|
|
|
ValidateLineShape(item, line);
|
|
|
|
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
|
{
|
|
foreach (var serialNumber in line.SerialNumbers)
|
|
{
|
|
if (!serialsInRequest.Add((item.Id, serialNumber)))
|
|
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
|
|
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
|
|
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
|
|
}
|
|
}
|
|
|
|
var entity = new Models.GoodsReceiptItem
|
|
{
|
|
FK_ItemId = item.Id,
|
|
FreeQuantity = line.FreeQuantity,
|
|
AcceptedQuantity = line.AcceptedQuantity,
|
|
RejectedQuantity = line.RejectedQuantity
|
|
};
|
|
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
|
|
goodsReceipt.Items.Add(entity);
|
|
grLines.Add((entity, line, item));
|
|
}
|
|
|
|
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
|
|
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
|
|
/// </summary>
|
|
private async Task<GrnResponse> PostReceiptAsync(
|
|
Models.GoodsReceipt goodsReceipt,
|
|
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
|
|
Guid warehouseId,
|
|
Guid receivedByUserId,
|
|
Models.PurchaseOrder? po,
|
|
CancellationToken ct)
|
|
{
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(goodsReceipt);
|
|
|
|
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
|
|
{
|
|
if (item.TracksSerialNumbers)
|
|
{
|
|
foreach (var serialNumber in line.SerialNumbers!)
|
|
{
|
|
var serial = new ItemSerialNumber
|
|
{
|
|
FK_ItemId = item.Id,
|
|
SerialNumber = serialNumber,
|
|
Status = SerialStatus.InStock,
|
|
CurrentLocationId = warehouseId,
|
|
CurrentLocationType = LocationType.Warehouse,
|
|
ReceivedAt = goodsReceipt.ReceivedAt
|
|
};
|
|
|
|
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
|
|
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
|
|
// stays null until the sale/delivery flow sets it.
|
|
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
|
|
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
|
|
|
|
await stockRepo.AddItemSerialNumberAsync(serial);
|
|
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
|
|
|
|
await stockRepo.AddBalanceAsync(new StockBalance
|
|
{
|
|
FK_ItemId = item.Id,
|
|
LocationId = warehouseId,
|
|
LocationType = LocationType.Warehouse,
|
|
FK_ItemSerialNumberId = serial.Id,
|
|
Quantity = 1
|
|
});
|
|
|
|
await stockRepo.AddMovementAsync(new StockMovement
|
|
{
|
|
FK_ItemId = item.Id,
|
|
FromLocationId = StockService.ExternalLocationId,
|
|
FromLocationType = LocationType.External,
|
|
ToLocationId = warehouseId,
|
|
ToLocationType = LocationType.Warehouse,
|
|
FK_ItemSerialNumberId = serial.Id,
|
|
Quantity = 1,
|
|
Type = MovementType.PurchaseReceipt,
|
|
ReferenceId = goodsReceipt.Id,
|
|
CreatedBy = receivedByUserId
|
|
});
|
|
}
|
|
}
|
|
else if (item.TracksBatches || item.TracksExpiryDate)
|
|
{
|
|
foreach (var b in line.Batches!)
|
|
{
|
|
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
|
|
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
|
|
: null;
|
|
|
|
if (batch is null)
|
|
{
|
|
batch = new ItemBatch
|
|
{
|
|
FK_ItemId = item.Id,
|
|
BatchNumber = b.BatchNumber,
|
|
ExpiryDate = b.ExpiryDate
|
|
};
|
|
await stockRepo.AddItemBatchAsync(batch);
|
|
}
|
|
|
|
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
|
|
|
|
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
|
|
if (balance is null)
|
|
{
|
|
await stockRepo.AddBalanceAsync(new StockBalance
|
|
{
|
|
FK_ItemId = item.Id,
|
|
LocationId = warehouseId,
|
|
LocationType = LocationType.Warehouse,
|
|
FK_ItemBatchId = batch.Id,
|
|
Quantity = b.Quantity
|
|
});
|
|
}
|
|
else
|
|
{
|
|
balance.Quantity += b.Quantity;
|
|
balance.UpdatedAt = DateTime.Now;
|
|
stockRepo.UpdateBalance(balance);
|
|
}
|
|
|
|
await stockRepo.AddMovementAsync(new StockMovement
|
|
{
|
|
FK_ItemId = item.Id,
|
|
FromLocationId = StockService.ExternalLocationId,
|
|
FromLocationType = LocationType.External,
|
|
ToLocationId = warehouseId,
|
|
ToLocationType = LocationType.Warehouse,
|
|
FK_ItemBatchId = batch.Id,
|
|
Quantity = b.Quantity,
|
|
Type = MovementType.PurchaseReceipt,
|
|
ReferenceId = goodsReceipt.Id,
|
|
CreatedBy = receivedByUserId
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
|
|
if (balance is null)
|
|
{
|
|
await stockRepo.AddBalanceAsync(new StockBalance
|
|
{
|
|
FK_ItemId = item.Id,
|
|
LocationId = warehouseId,
|
|
LocationType = LocationType.Warehouse,
|
|
Quantity = StockQty(line)
|
|
});
|
|
}
|
|
else
|
|
{
|
|
balance.Quantity += StockQty(line);
|
|
balance.UpdatedAt = DateTime.Now;
|
|
stockRepo.UpdateBalance(balance);
|
|
}
|
|
|
|
await stockRepo.AddMovementAsync(new StockMovement
|
|
{
|
|
FK_ItemId = item.Id,
|
|
FromLocationId = StockService.ExternalLocationId,
|
|
FromLocationType = LocationType.External,
|
|
ToLocationId = warehouseId,
|
|
ToLocationType = LocationType.Warehouse,
|
|
Quantity = StockQty(line),
|
|
Type = MovementType.PurchaseReceipt,
|
|
ReferenceId = goodsReceipt.Id,
|
|
CreatedBy = receivedByUserId
|
|
});
|
|
}
|
|
}
|
|
|
|
if (po is not null) poRepo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
|
await gl.PostGrnAsync(saved!, ct);
|
|
return Map(saved!);
|
|
}
|
|
}
|