product and sales fixers
This commit is contained in:
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Every save a user makes writes an AuditLog row against their UserId, so almost any
|
||||
// real user is still referenced by history and can't be hard-deleted. The login is
|
||||
// still permanently removed: the row is kept only as a tombstone so historical
|
||||
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
|
||||
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
|
||||
// Update() flips the entity back from Deleted, or the next save would retry the delete.
|
||||
_users.Update(user);
|
||||
user.Username = User.DeletedUsernamePrefix + user.UserId;
|
||||
user.Email = null;
|
||||
user.RoleId = null;
|
||||
user.Status = EntityStatus.Inactive;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
|
||||
}
|
||||
|
||||
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
|
||||
|
||||
@@ -24,10 +24,16 @@ public interface IFifoCostingService
|
||||
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
|
||||
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
|
||||
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
|
||||
/// and ledger costing). Must run inside a UoW transaction.
|
||||
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
|
||||
/// earlier over-stock sale are settled against issuable stock first.
|
||||
/// </summary>
|
||||
/// <param name="allowShortfall">
|
||||
/// 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>
|
||||
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false);
|
||||
|
||||
/// <summary>
|
||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
|
||||
public interface IItemImageService
|
||||
{
|
||||
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
|
||||
|
||||
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
|
||||
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public interface IItemService
|
||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
@@ -9,5 +9,4 @@ public interface IProductConfigService
|
||||
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Sales configuration business logic. Singleton.</summary>
|
||||
public interface ISalesConfigService
|
||||
{
|
||||
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
|
||||
Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -12,6 +12,19 @@ public interface ISalesDomainService
|
||||
bool requireCashierUser,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
|
||||
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
|
||||
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
|
||||
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
|
||||
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
|
||||
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Custom warranty period master. Never hard-deleted (FR-MD-08).</summary>
|
||||
public interface IWarrantyPeriodService
|
||||
{
|
||||
Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>1–600 months, not a default length. Re-adding an inactive length reactivates it;
|
||||
/// an active duplicate is a 409.</summary>
|
||||
Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default);
|
||||
|
||||
Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
|
||||
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
|
||||
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
|
||||
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
|
||||
/// images added earlier remain manageable.
|
||||
/// </summary>
|
||||
public sealed class ItemImageService : IItemImageService
|
||||
{
|
||||
public const int MaxImagesPerItem = 10;
|
||||
public const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".jpg"] = "image/jpeg",
|
||||
[".jpeg"] = "image/jpeg",
|
||||
[".png"] = "image/png",
|
||||
[".webp"] = "image/webp"
|
||||
};
|
||||
|
||||
private readonly IRepository<ItemImage> _images;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IFileStorageService _storage;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemImageService(
|
||||
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
|
||||
IFileStorageService storage, IUnitOfWork uow)
|
||||
{
|
||||
_images = images;
|
||||
_items = items;
|
||||
_config = config;
|
||||
_storage = storage;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
return await _images.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
|
||||
throw new DomainException(ErrorCodes.ConfigDisabled,
|
||||
"Item images are disabled in the product configuration.", 422);
|
||||
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|
||||
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
|
||||
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
|
||||
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
|
||||
|
||||
if (content.Length > MaxSizeBytes)
|
||||
throw new DomainException(ErrorCodes.FileTooLarge,
|
||||
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
|
||||
|
||||
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
if (existing.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
|
||||
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
|
||||
|
||||
var image = new ItemImage
|
||||
{
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _images.AddAsync(image, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
|
||||
image.SizeBytes, image.SortOrder, image.UploadedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
_images.Remove(image);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
// File removed only after the row is gone, so a failed save never orphans a row.
|
||||
await _storage.DeleteAsync(image.RelativePath, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
@@ -127,6 +128,24 @@ public sealed class ItemService : IItemService
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
|
||||
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
|
||||
var layers = _stockLayers.Query();
|
||||
switch (stock)
|
||||
{
|
||||
case ItemStockFilter.InStock:
|
||||
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
|
||||
break;
|
||||
case ItemStockFilter.OutOfStock:
|
||||
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
|
||||
break;
|
||||
case ItemStockFilter.LowStock:
|
||||
q = q.Where(i => i.ReorderSettings.Any(rs =>
|
||||
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
|
||||
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
|
||||
break;
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
||||
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
||||
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
|
||||
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
|
||||
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
|
||||
/// just the shared presets, and an item may also carry a length of its own entered on the
|
||||
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
|
||||
/// is also accepted, so switching the feature off never blocks editing an item that already
|
||||
/// uses a custom length. A non-warranty item carries none — any value sent for one is
|
||||
/// silently dropped rather than trusted from the client.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
private static int? ValidateWarrantyPeriod(
|
||||
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
var valid = warrantyPeriodMonths is not null
|
||||
&& (warrantyPeriodMonths == currentMonths
|
||||
|| (config.CustomWarrantyPeriodsEnabled
|
||||
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
|
||||
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
|
||||
|
||||
if (!valid)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
config.CustomWarrantyPeriodsEnabled
|
||||
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
|
||||
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
|
||||
422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
// Custom variants live in the item-variants section - switching product variants
|
||||
// off switches this off too, so it can never be left on by itself.
|
||||
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
|
||||
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||
c.CustomVariantsEnabled, c.ItemImagesEnabled,
|
||||
c.CustomWarrantyPeriodsEnabled,
|
||||
c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
|
||||
/// generic "Cash Customer" record — created the first time cash sales are allowed.
|
||||
/// </summary>
|
||||
public sealed class SalesConfigService : ISalesConfigService
|
||||
{
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
private const string CashCustomerName = "Cash Customer";
|
||||
|
||||
private readonly IRepository<SalesConfig> _config;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesConfigService(
|
||||
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
|
||||
// later throw would otherwise leave the row half-updated.
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
|
||||
if (config.AllowCashCustomer && config.CashCustomerId is null)
|
||||
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
|
||||
config.AllowFreeItems = request.AllowFreeItems!.Value;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
|
||||
// creating a new one on the deployment's default branch.
|
||||
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
|
||||
{
|
||||
var existingId = await _customers.Query().AsNoTracking()
|
||||
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
|
||||
if (existingId != 0) return existingId;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CustomerCode = CashCustomerCode,
|
||||
CustomerType = CustomerType.WalkIn,
|
||||
Name = CashCustomerName,
|
||||
DisplayName = CashCustomerName,
|
||||
CreditLimit = 0m,
|
||||
CreditDays = 0,
|
||||
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customers.AddAsync(customer, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return customer.CustomerId;
|
||||
}
|
||||
|
||||
private static SalesConfigDto Map(SalesConfig c) => new(
|
||||
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
|
||||
public SalesDomainService(
|
||||
IRepository<Customer> customers,
|
||||
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<TaxRate> taxRates,
|
||||
ISalesPricingService pricing)
|
||||
ISalesPricingService pricing,
|
||||
IRepository<SalesConfig> salesConfig)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
_items = items;
|
||||
_taxRates = taxRates;
|
||||
_pricing = pricing;
|
||||
_salesConfig = salesConfig;
|
||||
}
|
||||
|
||||
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
// The generic cash customer is only usable while Sales Configuration allows it.
|
||||
var cashConfig = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (requireCashierUser)
|
||||
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
}
|
||||
}
|
||||
|
||||
// A missing config row means the option predates it — free items stay allowed.
|
||||
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowFreeItems)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
// A missing config row means the option predates it — discounts stay allowed.
|
||||
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowDiscounts)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (config is null) return null;
|
||||
// A saved default sales warehouse always wins — sales use only that warehouse.
|
||||
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||
if (config.AllowWarehouseSelection) return null;
|
||||
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
|
||||
}
|
||||
|
||||
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.AllowOverStockSale)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
@@ -122,6 +123,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
{
|
||||
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
|
||||
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
invoice.CustomerId = request.CustomerId;
|
||||
invoice.WarehouseId = request.WarehouseId;
|
||||
@@ -157,9 +159,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
|
||||
// and every line use the configured default sales warehouse.
|
||||
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
|
||||
{
|
||||
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
|
||||
foreach (var line in lines) line.WarehouseId = forced;
|
||||
return forced;
|
||||
}
|
||||
|
||||
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesInvoiceLine>();
|
||||
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
|
||||
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);
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
|
||||
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
||||
throw new ConflictException(ensureDraftMessage(doc));
|
||||
|
||||
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
|
||||
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
|
||||
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var totalCogs = 0m;
|
||||
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
// 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);
|
||||
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,
|
||||
|
||||
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
|
||||
}
|
||||
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
|
||||
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
|
||||
// sits quarantined until a Purchase Return consumes it directly (see
|
||||
// PurchaseReturnService), never through this general-issue path. No LINQ is
|
||||
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
|
||||
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
|
||||
// parameter — without it a null batch filter fails with 42P18.
|
||||
var layers = await _db.StockLayers
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
|
||||
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
|
||||
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
|
||||
// Return consumes it directly (see PurchaseReturnService), never through this
|
||||
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
|
||||
// reaches the database intact.
|
||||
var locked = await _db.StockLayers
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM stock_layers sl
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
|
||||
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
|
||||
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
|
||||
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
|
||||
ORDER BY sl."ReceiptDate", sl."LayerId"
|
||||
FOR UPDATE
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 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.
|
||||
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)
|
||||
{
|
||||
if (shortfall.QtyRemaining >= 0) break;
|
||||
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= settle;
|
||||
shortfall.QtyRemaining += settle;
|
||||
}
|
||||
}
|
||||
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase)
|
||||
if (available < qtyBase && !allowShortfall)
|
||||
{
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
|
||||
}
|
||||
|
||||
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
|
||||
// below zero), costed at the item's last known unit cost — the next issue from this
|
||||
// warehouse settles it against the stock received in the meantime (see above).
|
||||
if (remaining > 0)
|
||||
{
|
||||
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
|
||||
var shortfall = new StockLayer
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = warehouseId,
|
||||
QtyReceived = 0m,
|
||||
QtyRemaining = -remaining,
|
||||
UnitCost = unitCost,
|
||||
ReceiptDate = now
|
||||
};
|
||||
await _layers.AddAsync(shortfall, ct);
|
||||
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
|
||||
return await layers.Where(l => l.WarehouseId == warehouseId)
|
||||
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? 0m;
|
||||
}
|
||||
|
||||
public async Task<StockLedger> PostLedgerAsync(
|
||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var open = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
|
||||
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
|
||||
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — shared presets offered on every item's form on top of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
|
||||
/// item may also carry a one-off length of its own. Nothing references these rows (items
|
||||
/// store months), so deactivating is always safe and there is no in-use check.
|
||||
/// </summary>
|
||||
public sealed class WarrantyPeriodService : IWarrantyPeriodService
|
||||
{
|
||||
public const int MaxMonths = 600;
|
||||
|
||||
private readonly IRepository<WarrantyPeriod> _periods;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
|
||||
{
|
||||
_periods = periods;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _periods.Query().AsNoTracking();
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
return await q.OrderBy(p => p.Months)
|
||||
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
|
||||
{
|
||||
if (months < 1 || months > MaxMonths)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
|
||||
if (WarrantyPeriods.AllowedMonths.Contains(months))
|
||||
throw new ConflictException($"{months} months is already a default warranty period.");
|
||||
|
||||
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status == EntityStatus.Active)
|
||||
throw new ConflictException($"A {months}-month warranty period already exists.");
|
||||
|
||||
// Unique by length: re-adding a deactivated one brings it back rather than failing.
|
||||
existing.Status = EntityStatus.Active;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(existing);
|
||||
}
|
||||
|
||||
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
await _periods.AddAsync(period, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(period);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
|
||||
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
|
||||
|
||||
period.Status = status;
|
||||
period.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
Reference in New Issue
Block a user