price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
+303 -64
View File
@@ -1,9 +1,11 @@
using System.Text.Json;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
@@ -15,27 +17,36 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
/// decrease, layer create on an increase) + ledger posting is delegated to
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05). Also posts
/// a real GL journal entry — increases and decreases post as separate Inventory/Gain and
/// Loss/Inventory lines respectively (never netted against each other), so a count that's
/// simultaneously over on one item and under on another shows both, not a false net.
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). An adjustment is a
/// request with a mandatory reason code; it moves stock only once approved by one or two people
/// other than the requester (docs/DUAL_APPROVAL_PLAN.md §5, rules in <see cref="IApprovalService"/>).
/// With approvals switched off it posts on create, as it always used to. Posting — FIFO consume on a
/// decrease, layer create on an increase, ledger, serial moves and the GL journal — is one
/// transaction (NFR-02/05) and is re-validated at approval time, since stock and serials may have
/// moved while the request waited. Increases and decreases post as separate Inventory/Gain and
/// Loss/Inventory GL lines (never netted), so a count over on one item and under on another shows both.
/// </summary>
public sealed class AdjustmentService : IAdjustmentService
{
public const string ApprovalDocType = "ADJ";
public const string ApprovalNavCode = "stock.adjustments";
private static readonly IReadOnlyCollection<SerialStatus> WriteOffStatuses = [SerialStatus.InStock, SerialStatus.Damaged];
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications;
private readonly IApprovalService _approvals;
private readonly IUnitOfWork _uow;
private readonly ErpDbContext _db;
private readonly ISerialTrackingService _serialTracking;
private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts;
@@ -53,18 +64,19 @@ public sealed class AdjustmentService : IAdjustmentService
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_notifications = notifications;
_approvals = approvals;
_uow = uow;
_db = db;
_gl = gl;
_glAccounts = glAccounts;
}
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.</summary>
private async Task<string?> PostAdjustmentJournalAsync(
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
{
@@ -99,7 +111,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default)
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
@@ -112,13 +124,15 @@ public sealed class AdjustmentService : IAdjustmentService
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
if (status is not null) q = q.Where(a => a.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo, a.RequiredApprovals,
a.Status == AdjustmentStatus.PendingApproval ? 1 : a.Status == AdjustmentStatus.PendingSecondApproval ? 2 : null))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -129,20 +143,22 @@ public sealed class AdjustmentService : IAdjustmentService
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
var (adjustment, refs) = await SubmitAsync(request, null, ct);
return await ToDtoAsync(adjustment, ct, refs.Select(l => l.LedgerId).ToList());
}
/// <summary>
/// Validates and saves an adjustment request; posts it straight away when no approval is
/// required. <paramref name="alsoInTransaction"/> lets a caller (a stock count) commit its own
/// change together with the adjustment. Returns the ledger entries posted (none while pending).
/// </summary>
internal async Task<(StockAdjustment Adjustment, IReadOnlyList<StockLedger> Refs)> SubmitAsync(
CreateAdjustmentRequest request, Func<CancellationToken, Task>? alsoInTransaction, CancellationToken ct)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
@@ -157,77 +173,300 @@ 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);
// Validate every line (and its serials) now, so a bad request is refused up front rather
// than at approval time. The value drives the approval threshold.
var value = 0m;
foreach (var line in request.Lines)
{
if (line.QtyDelta == 0)
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 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));
value += Math.Abs(line.QtyDelta) * await UnitValueAsync(line.ItemId, request.WarehouseId, ct);
}
await ResolveSerialsAsync(request.WarehouseId, request.Lines.Select(ToPlan).ToList(), ct);
// Validated only: posting resolves them again and creates any new ones exactly once.
DiscardUnsavedSerials();
var required = await _approvals.RequiredForStockAdjustmentAsync(value, ct);
var now = DateTime.UtcNow;
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
var (adjustment, refs) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var entity = new StockAdjustment
{
DocNo = docNo,
DocNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token),
WarehouseId = request.WarehouseId,
BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
Status = required == 0 ? AdjustmentStatus.Posted : AdjustmentStatus.PendingApproval,
RequiredApprovals = required,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select((l, i) => new StockAdjustmentLine
Lines = request.Lines.Select(l => new StockAdjustmentLine
{
ItemId = l.ItemId,
BinId = l.BinId,
BatchId = l.BatchId,
QtyDelta = l.QtyDelta,
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null
RequestedSerialIds = l.SerialIds is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialIds) : null,
RequestedSerialNos = l.SerialNos is { Count: > 0 } ? JsonSerializer.Serialize(l.SerialNos) : null
}).ToList()
};
await _adjustments.AddAsync(entity, token);
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++)
IReadOnlyList<StockLedger> posted = [];
if (required == 0)
{
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);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
posted = await PostInCurrentTransactionAsync(entity, token);
}
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
return (entity, refs);
if (alsoInTransaction is not null) await alsoInTransaction(token);
return (entity, posted);
}, ct);
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
if (required == 0)
{
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
}
else
{
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
}
return (adjustment, refs);
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
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, l.SerialId)).ToList(),
ledgerRefs);
public async Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
var target = Target(adjustment);
var now = DateTime.UtcNow;
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
IReadOnlyList<StockLedger> refs = [];
try
{
refs = await _uow.ExecuteInTransactionAsync(async token =>
{
if (target.CurrentLevel == 1)
{
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
adjustment.ApprovedLevel1At = now;
}
else
{
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
adjustment.ApprovedLevel2At = now;
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
adjustment.Status = AdjustmentStatus.PendingSecondApproval;
return (IReadOnlyList<StockLedger>)[];
}
adjustment.Status = AdjustmentStatus.Posted;
return await PostInCurrentTransactionAsync(adjustment, token);
}, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
await _approvals.NotifyPendingAsync(Target(adjustment), await SummaryAsync(adjustment, ct), ct);
}
else
{
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
$"Approved: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was approved and posted to stock.",
ApprovalDocType, adjustment.AdjustmentId, ct);
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was posted.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
}
return await ToDtoAsync(adjustment, ct, refs.Select(r => r.LedgerId).ToList());
}
public async Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
adjustment.Status = AdjustmentStatus.Rejected;
adjustment.RejectedBy = _currentUser.AuditUserId;
adjustment.RejectedAt = DateTime.UtcNow;
adjustment.RejectReason = reason.Trim();
await SaveDecisionAsync(adjustment, ct);
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
$"Rejected: {adjustment.DocNo}", $"Stock adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
ApprovalDocType, adjustment.AdjustmentId, ct);
return await ToDtoAsync(adjustment, ct);
}
public async Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(adjustmentId, ct);
if (adjustment.CreatedBy != _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
adjustment.Status = AdjustmentStatus.Cancelled;
adjustment.CancelledAt = DateTime.UtcNow;
await SaveDecisionAsync(adjustment, ct);
return await ToDtoAsync(adjustment, ct);
}
// --- Posting ---------------------------------------------------------------------------------
/// <summary>What a line asks for, from a request or from a saved pending line.</summary>
private sealed record LinePlan(int ItemId, int? BinId, int? BatchId, decimal QtyDelta, List<int>? SerialIds, List<string>? SerialNos);
private static LinePlan ToPlan(CreateAdjustmentLineInput l) => new(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialIds, l.SerialNos);
private static LinePlan ToPlan(StockAdjustmentLine l) => new(
l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
l.RequestedSerialIds is null ? null : JsonSerializer.Deserialize<List<int>>(l.RequestedSerialIds),
l.RequestedSerialNos is null ? null : JsonSerializer.Deserialize<List<string>>(l.RequestedSerialNos));
/// <summary>
/// Outbound serials must be at the warehouse (InStock or Damaged); inbound numbers are new or
/// out of stock (new ones are added to the context, unsaved). Same rules on request and posting.
/// </summary>
private async Task<List<IReadOnlyList<Serial>>> ResolveSerialsAsync(int warehouseId, IReadOnlyList<LinePlan> lines, CancellationToken ct)
{
var result = new List<IReadOnlyList<Serial>>();
var documentSerials = new HashSet<int>();
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
result.Add(line.QtyDelta < 0
? await _serialTracking.ResolveOutboundAsync(item, warehouseId, -line.QtyDelta, line.SerialIds, documentSerials, WriteOffStatuses, ct)
: await _serialTracking.ResolveInboundAsync(item, warehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
}
return result;
}
/// <summary>Drops serials a pending request's validation added, so they aren't created early.</summary>
private void DiscardUnsavedSerials()
{
foreach (var entry in _db.ChangeTracker.Entries<Serial>().Where(e => e.State == EntityState.Added).ToList())
entry.State = EntityState.Detached;
}
/// <summary>
/// Applies a saved adjustment to stock: re-resolves its serials, FIFO-consumes / adds layers,
/// writes the ledger, moves serials and posts the GL journal. Runs inside the caller's transaction;
/// throws (rolling everything back) if stock or serials no longer allow it.
/// </summary>
private async Task<IReadOnlyList<StockLedger>> PostInCurrentTransactionAsync(StockAdjustment entity, CancellationToken ct)
{
var now = DateTime.UtcNow;
var lines = entity.Lines.OrderBy(l => l.AdjLineId).ToList();
var plans = lines.Select(ToPlan).ToList();
var serialsByLine = await ResolveSerialsAsync(entity.WarehouseId, plans, ct);
for (var i = 0; i < lines.Count; i++)
lines[i].Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null;
await _uow.SaveChangesAsync(ct); // new serials get their ids
var deltas = plans.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(entity.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, ct);
for (var i = 0; i < plans.Count; i++)
{
var line = plans[i];
await _serialTracking.MoveAsync(serialsByLine[i],
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
line.QtyDelta < 0 ? null : entity.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
DocumentTypes.Adjustment, entity.AdjustmentId, now, ct);
}
entity.GlJournalNo = await PostAdjustmentJournalAsync(entity.DocNo, now, refs, ct);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
entity.PostedAt = now;
return refs;
}
// --- Helpers ---------------------------------------------------------------------------------
/// <summary>Average FIFO cost per unit at the warehouse — prices the request for the approval threshold.</summary>
private async Task<decimal> UnitValueAsync(int itemId, int warehouseId, CancellationToken ct)
{
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
}
private async Task<StockAdjustment> LoadPendingAsync(int adjustmentId, CancellationToken ct)
{
var adjustment = await _adjustments.Query().Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct)
?? throw new NotFoundException($"Stock adjustment {adjustmentId} was not found.");
if (adjustment.Status is not (AdjustmentStatus.PendingApproval or AdjustmentStatus.PendingSecondApproval))
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
return adjustment;
}
private async Task SaveDecisionAsync(StockAdjustment adjustment, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Stock adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
}
private static int? PendingLevel(StockAdjustment a) => a.Status switch
{
AdjustmentStatus.PendingApproval => 1,
AdjustmentStatus.PendingSecondApproval => 2,
_ => null
};
private static ApprovalTarget Target(StockAdjustment a) => new(
ApprovalDocType, a.AdjustmentId, a.DocNo, ApprovalNavCode, a.CreatedBy, a.RequiredApprovals,
PendingLevel(a) ?? 1, a.ApprovedLevel1By);
private async Task<string> SummaryAsync(StockAdjustment a, CancellationToken ct)
{
var warehouse = await _warehouses.Query().AsNoTracking().Where(w => w.WarehouseId == a.WarehouseId).Select(w => w.Code).FirstOrDefaultAsync(ct);
var reason = await _reasonCodes.Query().AsNoTracking().Where(r => r.ReasonCodeId == a.ReasonCodeId).Select(r => r.Description).FirstOrDefaultAsync(ct);
return $"Stock adjustment {a.DocNo} ({a.Lines.Count} line{(a.Lines.Count == 1 ? "" : "s")}, {warehouse}, {reason})";
}
private async Task<AdjustmentDto> ToDtoAsync(StockAdjustment a, CancellationToken ct, IReadOnlyList<int>? ledgerRefs = null)
{
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
ledgerRefs ??= await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == a.AdjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
var pending = PendingLevel(a);
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(a), ct);
return new AdjustmentDto(
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 =>
{
var plan = ToPlan(l);
return new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId, plan.SerialIds, plan.SerialNos);
}).ToList(),
ledgerRefs,
a.RequiredApprovals, pending,
a.ApprovedLevel1By, a.ApprovedLevel1At, a.ApprovedLevel2By, a.ApprovedLevel2At,
a.RejectedBy, a.RejectedAt, a.RejectReason, a.PostedAt,
await _approvals.HistoryAsync(ApprovalDocType, a.AdjustmentId, ct),
whyNot is null, whyNot,
pending is not null && a.CreatedBy == _currentUser.AuditUserId,
await _db.Users.AsNoTracking().Where(u => u.UserId == a.CreatedBy).Select(u => u.DisplayName).FirstOrDefaultAsync(ct));
}
}
@@ -0,0 +1,93 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// The Approvals inbox (docs/DUAL_APPROVAL_PLAN.md §10): every pending stock and price adjustment
/// the current user can approve, plus their own requests (shown as waiting for others). A branch
/// user only sees their branch's stock adjustments; price changes are company-wide.
/// </summary>
public sealed class ApprovalInboxService
{
private readonly IRepository<StockAdjustment> _stock;
private readonly IRepository<PriceAdjustment> _prices;
private readonly IApprovalService _approvals;
private readonly INavPermissionService _nav;
private readonly ICurrentUser _currentUser;
private readonly ICurrentBranchContext _branch;
public ApprovalInboxService(
IRepository<StockAdjustment> stock, IRepository<PriceAdjustment> prices, IApprovalService approvals,
INavPermissionService nav, ICurrentUser currentUser, ICurrentBranchContext branch)
{
_stock = stock;
_prices = prices;
_approvals = approvals;
_nav = nav;
_currentUser = currentUser;
_branch = branch;
}
public async Task<PendingApprovalsDto> PendingAsync(CancellationToken ct = default)
{
var me = _currentUser.AuditUserId;
var canStock = await CanApproveScreenAsync(AdjustmentService.ApprovalNavCode, ct);
var canPrice = await CanApproveScreenAsync(PriceAdjustmentService.ApprovalNavCode, ct);
var items = new List<PendingApprovalDto>();
var stockQuery = _stock.Query().AsNoTracking()
.Where(a => a.Status == AdjustmentStatus.PendingApproval || a.Status == AdjustmentStatus.PendingSecondApproval)
.Where(a => canStock || a.CreatedBy == me);
if (_branch.IsActive) stockQuery = stockQuery.Where(a => a.BranchId == _branch.BranchId);
var stock = await stockQuery
.Select(a => new
{
a.AdjustmentId, a.DocNo, a.Status, a.RequiredApprovals, a.CreatedBy, a.CreatedAt, a.ApprovedLevel1By,
RequestedByName = a.Creator!.DisplayName, Warehouse = a.Warehouse!.Code, Reason = a.ReasonCode!.Description, Lines = a.Lines.Count
})
.ToListAsync(ct);
foreach (var a in stock)
{
var level = a.Status == AdjustmentStatus.PendingApproval ? 1 : 2;
var target = new ApprovalTarget(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo, AdjustmentService.ApprovalNavCode,
a.CreatedBy, a.RequiredApprovals, level, a.ApprovedLevel1By);
var why = canStock ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
items.Add(new PendingApprovalDto(AdjustmentService.ApprovalDocType, a.AdjustmentId, a.DocNo,
$"Stock adjustment — {a.Lines} line{(a.Lines == 1 ? "" : "s")}, {a.Warehouse}, {a.Reason}",
level, a.RequiredApprovals, a.CreatedBy, a.RequestedByName, a.CreatedAt, why is null, why));
}
var prices = await _prices.Query().AsNoTracking()
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
.Where(p => canPrice || p.CreatedBy == me)
.Select(p => new
{
p.PriceAdjustmentId, p.DocNo, p.Status, p.RequiredApprovals, p.CreatedBy, p.CreatedAt, p.ApprovedLevel1By,
RequestedByName = p.Creator!.DisplayName, p.Reason, Lines = p.Lines.Count
})
.ToListAsync(ct);
foreach (var p in prices)
{
var level = p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : 2;
var target = new ApprovalTarget(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo, PriceAdjustmentService.ApprovalNavCode,
p.CreatedBy, p.RequiredApprovals, level, p.ApprovedLevel1By);
var why = canPrice ? await _approvals.WhyCannotDecideAsync(target, ct) : "Waiting for an approver.";
items.Add(new PendingApprovalDto(PriceAdjustmentService.ApprovalDocType, p.PriceAdjustmentId, p.DocNo,
$"Price adjustment — {p.Lines} item{(p.Lines == 1 ? "" : "s")}: {p.Reason}",
level, p.RequiredApprovals, p.CreatedBy, p.RequestedByName, p.CreatedAt, why is null, why));
}
// Actionable first, then oldest first — the longest-waiting request is what needs attention.
var ordered = items.OrderByDescending(i => i.CanApprove).ThenBy(i => i.RequestedAt).ToList();
return new PendingApprovalsDto(ordered, ordered.Count(i => i.CanApprove));
}
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct);
}
+178
View File
@@ -0,0 +1,178 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
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>
/// Shared approval engine for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4). Holds the
/// maker–checker rules in one place; the document services call <see cref="DecideAsync"/> and apply
/// their effect only on <see cref="ApprovalOutcome.FullyApproved"/>. There is no Admin bypass of the
/// two-person rule — an Admin can approve, but never their own request and never both levels.
/// </summary>
public sealed class ApprovalService : IApprovalService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<ApprovalConfig> _config;
private readonly IRepository<DocumentApproval> _approvals;
private readonly IRepository<User> _users;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ApprovalService(
IRepository<ApprovalConfig> config, IRepository<DocumentApproval> approvals, IRepository<User> users,
IRepository<RolePermission> rolePermissions, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow)
{
_config = config;
_approvals = approvals;
_users = users;
_rolePermissions = rolePermissions;
_currentUser = currentUser;
_notifications = notifications;
_uow = uow;
}
public async Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default) => Map(await LoadConfigAsync(ct));
public async Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default)
{
var config = await _config.Query().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct);
if (config is null)
{
config = new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
await _config.AddAsync(config, ct);
}
config.StockAdjustmentLevels = Math.Clamp(request.StockAdjustmentLevels, 0, 2);
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
var roles = request.Level2RoleCodes.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
config.Level2RoleCodes = roles.Count == 0 ? null : string.Join(",", roles);
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
await _uow.SaveChangesAsync(ct);
return Map(config);
}
public async Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default)
{
var c = await LoadConfigAsync(ct);
return Levels(c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue, absoluteValue);
}
public async Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default)
{
var c = await LoadConfigAsync(ct);
return Levels(c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent, largestChangePercent);
}
/// <summary>Two levels drop to one below a positive threshold; 0 and 1 are taken as configured.</summary>
public static int Levels(int configured, decimal threshold, decimal measure)
=> configured >= 2 && threshold > 0 && measure < threshold ? 1 : Math.Clamp(configured, 0, 2);
public async Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default)
{
var (why, code) = await CheckAsync(target, ct);
if (why is not null) throw new DomainException(code!, why, 403);
var note = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
if (!approve && note is null)
throw new DomainException(ErrorCodes.ApprovalReasonRequired, "Give a reason for rejecting.", 400);
await _approvals.AddAsync(new DocumentApproval
{
DocType = target.DocType,
DocId = target.DocId,
Level = target.CurrentLevel,
Decision = approve ? ApprovalDecision.Approved : ApprovalDecision.Rejected,
DecidedBy = _currentUser.AuditUserId,
DecidedAt = DateTime.UtcNow,
Comment = note
}, ct);
if (!approve) return ApprovalOutcome.Rejected;
return target.CurrentLevel >= target.RequiredApprovals ? ApprovalOutcome.FullyApproved : ApprovalOutcome.AdvancedToLevel2;
}
public async Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default)
=> (await CheckAsync(target, ct)).Why;
private async Task<(string? Why, string? Code)> CheckAsync(ApprovalTarget target, CancellationToken ct)
{
var actor = _currentUser.AuditUserId;
if (actor == target.RequestedBy)
return ("You made this request — someone else has to approve it.", ErrorCodes.ApprovalForbidden);
if (target.CurrentLevel >= 2 && target.Level1ApprovedBy == actor)
return ("You gave the first approval — a different person has to give the second.", ErrorCodes.ApprovalSamePerson);
if (target.CurrentLevel >= 2)
{
var roles = Level2Roles(await LoadConfigAsync(ct));
if (roles.Count > 0 && !_currentUser.IsAdmin && !roles.Contains(_currentUser.RoleCode ?? string.Empty))
return ($"Only {string.Join(", ", roles)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
}
return (null, null);
}
public async Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default)
=> await _approvals.Query().AsNoTracking()
.Where(a => a.DocType == docType && a.DocId == docId)
.OrderBy(a => a.DocumentApprovalId)
.Select(a => new ApprovalStepDto(a.Level, a.Decision, a.DecidedBy, a.Decider!.DisplayName, a.DecidedAt, a.Comment))
.ToListAsync(ct);
public async Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default)
{
var permissionCode = "NAV:" + target.NavCode;
var grants = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.Permission!.Code == permissionCode)
.Select(rp => new { rp.RoleId, rp.Actions })
.ToListAsync(ct);
var approverRoleIds = grants.Where(g => g.Actions.HasFlag(RolePermissionAction.Approve)).Select(g => g.RoleId).ToHashSet();
var candidates = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleCode = u.Role!.Code })
.ToListAsync(ct);
var level2Roles = target.CurrentLevel >= 2 ? Level2Roles(await LoadConfigAsync(ct)) : [];
return candidates
.Where(u => string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase) || approverRoleIds.Contains(u.RoleId))
.Where(u => u.UserId != target.RequestedBy && u.UserId != target.Level1ApprovedBy)
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleCode) || string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase))
.Select(u => u.UserId)
.ToList();
}
public async Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default)
{
var approvers = await ApproverIdsAsync(target, ct);
var step = target.RequiredApprovals > 1 ? $" (approval {target.CurrentLevel} of {target.RequiredApprovals})" : string.Empty;
await _notifications.NotifyUsersAsync(approvers, "approval.pending",
$"Approval needed: {target.DocNo}", $"{summary} is waiting for your approval{step}.", target.DocType, target.DocId, ct);
}
private async Task<ApprovalConfig> LoadConfigAsync(CancellationToken ct)
=> await _config.Query().AsNoTracking().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct)
?? new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
private static HashSet<string> Level2Roles(ApprovalConfig c)
=> (c.Level2RoleCodes ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
Level2Roles(c).ToList(), c.UpdatedAt);
}
@@ -0,0 +1,283 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2). Saving a deposit posts a
/// real GL entry (Debit the bank account / Credit the cash account) before anything is committed —
/// the same call-GL-before-commit pattern as <see cref="ExpensePaymentService"/>, so a GL rejection
/// rolls the deposit back. A deposit may not exceed the branch's cash in hand
/// (<see cref="ICashInHandService"/>); the check runs inside the transaction after locking the
/// branch row, so two cashiers can't both deposit the same cash. Cancelling posts the reversing entry.
/// </summary>
public sealed class BankDepositService : IBankDepositService
{
private const string SourceModule = "BANK_DEPOSIT";
private readonly IRepository<BankDeposit> _deposits;
private readonly IRepository<Branch> _branches;
private readonly ICashInHandService _cash;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly ICurrentBranchContext _currentBranch;
private readonly INumberSequenceService _numbers;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly ErpDbContext _db;
private readonly BusinessDayClock _clock;
public BankDepositService(
IRepository<BankDeposit> deposits, IRepository<Branch> branches, ICashInHandService cash,
IGeneralLedgerService gl, ICurrentUser currentUser, ICurrentBranchContext currentBranch,
INumberSequenceService numbers, IBranchConfigService branchConfig, IUnitOfWork uow,
ErpDbContext db, BusinessDayClock clock)
{
_deposits = deposits;
_branches = branches;
_cash = cash;
_gl = gl;
_currentUser = currentUser;
_currentBranch = currentBranch;
_numbers = numbers;
_branchConfig = branchConfig;
_uow = uow;
_db = db;
_clock = clock;
}
public async Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default)
{
IQueryable<BankDeposit> q = _deposits.Query().AsNoTracking().Include(x => x.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|| (x.DepositSlipNo != null && EF.Functions.Like(x.DepositSlipNo, $"%{term}%"))
|| (x.DepositedBy != null && EF.Functions.Like(x.DepositedBy, $"%{term}%")));
}
if (filter.From is not null) q = q.Where(x => x.BusinessDate >= filter.From);
if (filter.To is not null) q = q.Where(x => x.BusinessDate <= filter.To);
if (filter.BranchId is not null) q = q.Where(x => x.BranchId == filter.BranchId);
if (filter.BankAccountId is not null) q = q.Where(x => x.ToGlBankAccountId == filter.BankAccountId);
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
var total = await q.CountAsync(ct);
var totalAmount = await q.SumAsync(x => x.Amount, ct);
var rows = await q.OrderByDescending(x => x.BusinessDate).ThenByDescending(x => x.BankDepositId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var page = PagedResponse<BankDepositDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
return new BankDepositListDto(page.Items, page.Pagination, totalAmount);
}
public async Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default)
{
var deposit = await _deposits.Query().AsNoTracking().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct);
return deposit is null ? null : Map(deposit);
}
public async Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default)
{
if (request.Amount <= 0)
throw new DomainException(ErrorCodes.Validation, "The deposit amount must be greater than zero.", 400);
var (branchId, cashScope) = await ResolveBranchAsync(request.BranchId, ct);
var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Branch {branchId} does not exist.", 422);
var depositDate = ValidateDates(request);
var (cashAccount, bankAccount) = await ResolveAccountsAsync(request, ct);
var deposit = await _uow.ExecuteInTransactionAsync(async token =>
{
await LockBranchAsync(branchId, token);
// Re-read inside the lock: a deposit committed a moment ago by someone else is now visible.
var available = await _cash.GetAvailableToDepositAsync(request.BusinessDate, cashScope, token);
if (request.Amount > available)
throw new DomainException(ErrorCodes.BankDepositExceedsCashInHand,
$"Amount {request.Amount:N2} exceeds the cash in hand available for {request.BusinessDate:yyyy-MM-dd} ({available:N2}).", 400);
var docNo = await _numbers.NextAsync(DocumentTypes.BankDeposit, branchId, token);
var narration = $"Cash deposit {docNo} — {request.BusinessDate:yyyy-MM-dd}";
var period = await _gl.GetPeriodByDateAsync(depositDate, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = depositDate,
SourceModule = SourceModule,
Reference = docNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(bankAccount.GlAccountCode, request.Amount, 0m, narration),
new GlJournalEntryLineRequest(cashAccount.GlAccountCode, 0m, request.Amount, narration)
]
}, token);
var now = DateTime.UtcNow;
var entity = new BankDeposit
{
DocNo = docNo,
BranchId = branchId,
BusinessDate = request.BusinessDate,
DepositDate = depositDate,
Amount = request.Amount,
FromGlCashAccountId = cashAccount.AccountId,
FromCashAccountCode = cashAccount.GlAccountCode,
FromCashAccountName = cashAccount.AccountName,
ToGlBankAccountId = bankAccount.AccountId,
ToBankAccountCode = bankAccount.GlAccountCode,
ToBankAccountName = bankAccount.AccountName,
ToBankName = bankAccount.BankName,
ToAccountNumber = bankAccount.AccountNumber,
DepositSlipNo = Normalize(request.DepositSlipNo),
DepositedBy = Normalize(request.DepositedBy),
Remarks = Normalize(request.Remarks),
Status = BankDepositStatus.Posted,
GlJournalNo = posted.JournalNo,
GlPostedAt = now,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now
};
await _deposits.AddAsync(entity, token);
return entity;
}, ct);
return Map(deposit, branch.Name);
}
public async Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default)
{
var deposit = await _deposits.Query().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.BankDepositId == bankDepositId, ct)
?? throw new NotFoundException($"Bank deposit {bankDepositId} was not found.");
if (deposit.Status != BankDepositStatus.Posted)
throw new DomainException(ErrorCodes.BankDepositNotCancellable, $"Bank deposit {deposit.DocNo} is already {deposit.Status}.", 409);
// Cancelling only puts cash back in hand, so there is no balance to check.
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
var today = _clock.Today;
var narration = $"Cancellation of cash deposit {deposit.DocNo}";
var period = await _gl.GetPeriodByDateAsync(today, token);
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = today,
SourceModule = SourceModule,
Reference = deposit.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(deposit.FromCashAccountCode, deposit.Amount, 0m, narration),
new GlJournalEntryLineRequest(deposit.ToBankAccountCode, 0m, deposit.Amount, narration)
]
}, token);
deposit.Status = BankDepositStatus.Cancelled;
deposit.GlReversalJournalNo = reversal.JournalNo;
deposit.CancelReason = request.Reason.Trim();
deposit.CancelledBy = _currentUser.AuditUserId;
deposit.CancelledAt = now;
return 0;
}, ct);
return Map(deposit);
}
/// <summary>
/// The branch the deposit belongs to and the branch scope its cash in hand is measured over. A
/// branch user always deposits for their own branch. With branching off every document lives on
/// the one default branch, so the cash is measured across all of them (scope null).
/// </summary>
private async Task<(int BranchId, int? CashScope)> ResolveBranchAsync(int requested, CancellationToken ct)
{
if (_currentBranch.IsActive && _currentBranch.BranchId is { } own)
return (own, own);
var branchId = await _branchConfig.ResolveBranchIdAsync(requested, ct);
return await _branchConfig.IsBranchingEnabledAsync(ct) ? (branchId, branchId) : (branchId, null);
}
private DateOnly ValidateDates(CreateBankDepositRequest request)
{
var today = _clock.Today;
if (request.BusinessDate > today)
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The business date cannot be in the future.", 400);
if (_cash.TrackingStartDate is { } start && request.BusinessDate < start)
throw new DomainException(ErrorCodes.BankDepositDateInvalid,
$"Cash in hand is tracked from {start:yyyy-MM-dd}; earlier days cannot be deposited.", 400);
var depositDate = request.DepositDate ?? request.BusinessDate;
if (depositDate < request.BusinessDate)
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be before the business date.", 400);
// One day of slack, as with payment dates: the client sends its local date.
if (depositDate > today.AddDays(1))
throw new DomainException(ErrorCodes.BankDepositDateInvalid, "The deposit date cannot be in the future.", 400);
return depositDate;
}
private async Task<(GlBankAccount Cash, GlBankAccount Bank)> ResolveAccountsAsync(CreateBankDepositRequest request, CancellationToken ct)
{
var accounts = await _gl.ListBankAccountsAsync(ct);
// GL keeps cash and bank accounts in separate tables with their own ids, so cash account 1 and
// bank account 1 can both exist — an id only means something together with its type.
var cash = accounts.FirstOrDefault(a => a.AccountId == request.FromGlCashAccountId && IsType(a, "Cash"))
?? throw new DomainException(ErrorCodes.BankDepositCashAccountInvalid,
$"Cash account {request.FromGlCashAccountId} was not found — deposits must come out of a cash account.", 400);
var bank = accounts.FirstOrDefault(a => a.AccountId == request.ToGlBankAccountId && IsType(a, "Bank"));
if (bank is null)
throw new DomainException(ErrorCodes.BankDepositBankAccountInvalid,
$"Bank account {request.ToGlBankAccountId} was not found — deposits must go into a bank account.", 400);
return (cash, bank);
}
private static bool IsType(GlBankAccount account, string type)
=> string.Equals(account.AccountType, type, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Serializes deposits per branch for the rest of the transaction: the second of two concurrent
/// deposits waits here, then sees the first one's committed row when it re-reads the balance.
/// The in-memory test provider has no row locks (or concurrency), so it is skipped there.
/// </summary>
private async Task LockBranchAsync(int branchId, CancellationToken ct)
{
if (!_db.Database.IsRelational()) return;
await _db.Database.ExecuteSqlInterpolatedAsync(
$"SELECT `BranchId` FROM `hr_branches` WHERE `BranchId` = {branchId} FOR UPDATE", ct);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary><paramref name="branchName"/> stands in when the Branch navigation isn't loaded (just created).</summary>
private static BankDepositDto Map(BankDeposit x) => Map(x, x.Branch?.Name);
private static BankDepositDto Map(BankDeposit x, string? branchName) => new(
x.BankDepositId, x.DocNo, x.BranchId, branchName,
x.BusinessDate, x.DepositDate, x.Amount,
x.FromGlCashAccountId, x.FromCashAccountCode, x.FromCashAccountName,
x.ToGlBankAccountId, x.ToBankAccountCode, x.ToBankAccountName, x.ToBankName, x.ToAccountNumber,
x.DepositSlipNo, x.DepositedBy, x.Remarks,
x.Status, x.GlJournalNo, x.GlPostedAt,
x.GlReversalJournalNo, x.CancelReason, x.CancelledAt,
x.CreatedBy, x.CreatedAt);
}
+13 -4
View File
@@ -178,7 +178,7 @@ public sealed class BundleSaleService : IBundleSaleService
};
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct);
Recalculate(bundle, request.BundlePrice);
Recalculate(bundle, request.BundlePrice, request.CardAmount);
bundle.BundleCode = $"{bundle.BundleNo}-B";
await _bundles.AddAsync(bundle, ct);
await _uow.SaveChangesAsync(ct);
@@ -203,7 +203,7 @@ public sealed class BundleSaleService : IBundleSaleService
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice);
Recalculate(bundle, request.BundlePrice, request.CardAmount);
bundle.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(bundle);
@@ -273,7 +273,8 @@ public sealed class BundleSaleService : IBundleSaleService
return lines;
}
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
/// <summary>A bundle sale is always fully paid; <paramref name="cardAmount"/> null means all cash.</summary>
private static void Recalculate(BundleSale bundle, decimal bundlePrice, decimal? cardAmount)
{
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
bundle.BundlePrice = bundlePrice;
@@ -281,6 +282,13 @@ public sealed class BundleSaleService : IBundleSaleService
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
bundle.TaxTotal = 0m;
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
var card = cardAmount ?? 0m;
if (card < 0 || card > bundle.GrandTotal)
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
$"cardAmount must be between 0 and the bundle's total {bundle.GrandTotal}.", 400);
bundle.CardAmount = card;
bundle.CashAmount = bundle.GrandTotal - card;
}
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
@@ -290,6 +298,7 @@ public sealed class BundleSaleService : IBundleSaleService
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList(),
x.CashAmount, x.CardAmount);
}
@@ -0,0 +1,34 @@
namespace ERPCore.Services;
/// <summary>
/// The business calendar day shared by the Day-End Sales Report and the Bank Deposit module
/// (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1). Sales documents are stamped in UTC, so a business day
/// is the <c>DayEndReport:TimeZone</c> calendar day converted to a UTC <c>[start, end)</c> window.
/// One instance per app — the zone is resolved once from configuration.
/// </summary>
public sealed class BusinessDayClock
{
public BusinessDayClock(IConfiguration configuration)
=> Zone = ResolveZone(configuration["DayEndReport:TimeZone"]);
public TimeZoneInfo Zone { get; }
/// <summary>Today in the business time zone.</summary>
public DateOnly Today => LocalDay(DateTime.UtcNow);
/// <summary>The UTC instant the local day starts at.</summary>
public DateTime ToUtc(DateOnly localDay)
=> TimeZoneInfo.ConvertTimeToUtc(localDay.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified), Zone);
/// <summary>The local business day a UTC timestamp falls on.</summary>
public DateOnly LocalDay(DateTime utc)
=> DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), Zone));
private static TimeZoneInfo ResolveZone(string? id)
{
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
catch (InvalidTimeZoneException) { return TimeZoneInfo.Utc; }
}
}
@@ -0,0 +1,196 @@
using System.Globalization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1). Only cash counts: slip and bundle
/// <c>CashAmount</c> and cash invoice payments come in, cash expense payments go out, and Posted bank
/// deposits take it to the bank. The balance carries from day to day starting at
/// <c>BankDeposit:CashTrackingStartDate</c>; the opening balance before a window is a handful of
/// database sums, and the days inside it are bucketed by local day here (same reason as
/// <see cref="DayEndReportService"/>: MariaDB can't portably group by a zone-shifted date).
/// </summary>
public sealed class CashInHandService : ICashInHandService
{
private const int MaxRangeDays = 92;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<ExpensePayment> _expensePayments;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
private readonly string? _defaultCashAccountCode;
public CashInHandService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments,
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> deposits,
ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
{
_slips = slips;
_bundles = bundles;
_invoicePayments = invoicePayments;
_expensePayments = expensePayments;
_deposits = deposits;
_currentBranch = currentBranch;
_clock = clock;
_defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"];
TrackingStartDate = DateOnly.TryParse(configuration["BankDeposit:CashTrackingStartDate"], CultureInfo.InvariantCulture, out var start)
? start
: null;
}
public DateOnly? TrackingStartDate { get; }
public async Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default)
{
var date = day ?? _clock.Today;
var branch = EffectiveBranch(branchId);
var today = _clock.Today;
// One pass from the day to today serves both the day's own figures and what is still depositable.
var days = await ComputeAsync(date, date > today ? date : today, branch, ct);
return new CashInHandDto(days[0], Available(days, date, today), branch, TrackingStartDate, _clock.Zone.Id, _defaultCashAccountCode);
}
public async Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
{
if (to < from)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
var branch = EffectiveBranch(branchId);
var days = await ComputeAsync(from, to, branch, ct);
return new CashInHandDailyDto(from, to, days, branch, TrackingStartDate, _clock.Zone.Id);
}
public async Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
=> await ComputeAsync(from, to, EffectiveBranch(branchId), ct);
public async Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default)
{
var today = _clock.Today;
if (day > today || !IsTracked(day)) return 0m;
return Available(await ComputeAsync(day, today, branchScope, ct), day, today);
}
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
private bool IsTracked(DateOnly day) => TrackingStartDate is not { } start || day >= start;
/// <summary>Lowest closing balance from <paramref name="day"/> to today: depositing more would overdraw a later day.</summary>
private decimal Available(IReadOnlyList<CashPositionDto> days, DateOnly day, DateOnly today)
{
if (day > today || !IsTracked(day)) return 0m;
var min = days.Where(d => d.Date >= day && d.Date <= today).Min(d => d.Closing);
return Math.Max(0m, min);
}
private async Task<List<CashPositionDto>> ComputeAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
{
// Opening: everything tracked before the window — [start, from).
var opening = 0m;
if (TrackingStartDate is not { } trackStart || trackStart < from)
{
DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null;
var hi = _clock.ToUtc(from);
var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct)
+ await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct)
+ await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
var cashOut = await ExpensePayments(lo, hi, branch).SumAsync(p => p.Amount, ct);
var deposited = await Deposits(TrackingStartDate, from, branch).SumAsync(d => d.Amount, ct);
opening = cashIn - cashOut - deposited;
}
// The window itself, bucketed by local day.
var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1));
var inRows = new List<(DateTime At, decimal Amount)>();
inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct))
.Select(r => (r.SlipDate, r.CashAmount)));
inRows.AddRange((await Bundles(start, end, branch).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct))
.Select(r => (r.BundleDate, r.CashAmount)));
inRows.AddRange((await InvoicePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct))
.Select(r => (r.PaymentDate, r.Amount)));
var outRows = await ExpensePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct);
var depositRows = await Deposits(from, to.AddDays(1), branch).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct);
var inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.PaymentDate)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var depositByDay = depositRows.GroupBy(r => r.BusinessDate).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
var days = new List<CashPositionDto>(to.DayNumber - from.DayNumber + 1);
var running = opening;
for (var day = from; day <= to; day = day.AddDays(1))
{
var cashIn = inByDay.GetValueOrDefault(day);
var cashOut = outByDay.GetValueOrDefault(day);
var deposited = depositByDay.GetValueOrDefault(day);
var net = cashIn - cashOut;
// Before tracking starts nothing carries in or out; the day stands alone.
var tracked = IsTracked(day);
var open = tracked ? running : 0m;
var close = open + net - deposited;
if (tracked) running = close;
days.Add(new CashPositionDto(day, open, cashIn, cashOut, net, deposited, close));
}
return days;
}
// --- Sources. lo is inclusive (null = no lower bound), hi exclusive. ---
private IQueryable<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
{
var q = _slips.Query().AsNoTracking()
.Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi);
if (lo is not null) q = q.Where(s => s.SlipDate >= lo);
return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch);
}
private IQueryable<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
{
var q = _bundles.Query().AsNoTracking()
.Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi);
if (lo is not null) q = q.Where(b => b.BundleDate >= lo);
return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch);
}
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _invoicePayments.Query().AsNoTracking()
.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi);
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch);
}
private IQueryable<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _expensePayments.Query().AsNoTracking()
.Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi);
if (lo is not null) q = q.Where(p => p.PaymentDate >= lo);
return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch);
}
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
{
var q = _deposits.Query().AsNoTracking()
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi);
if (lo is not null) q = q.Where(d => d.BusinessDate >= lo);
return branch is null ? q : q.Where(d => d.BranchId == branch);
}
}
+29 -59
View File
@@ -16,53 +16,47 @@ namespace ERPCore.Services;
/// <summary>
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
/// and closes the count — all in one UoW transaction. GL posting for that variance
/// mirrors <see cref="AdjustmentService"/>'s (same account config, same gain/loss
/// split) since it's the exact same kind of document under the hood.
/// 02-SECURITY C.7); posting closes the count and hands its variances to
/// <see cref="AdjustmentService"/> as one adjustment (a variance is an adjustment in disguise,
/// C.7). That adjustment follows the same approval rules as any other
/// (docs/DUAL_APPROVAL_PLAN.md §8, decision 3) — a count is not a way around approval — and
/// moves stock when it is approved, or at once when approvals are switched off.
/// </summary>
public sealed class CountService : ICountService
{
private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts;
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IGlAccountConfigService _glAccounts;
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
IRepository<StockCount> counts, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{
_serialTracking = serialTracking;
_counts = counts;
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_stockLayers = stockLayers;
_batches = batches;
_serials = serials;
@@ -266,13 +260,9 @@ public sealed class CountService : ICountService
// 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.
if (deltas.Count == 0)
if (varianceLines.Count == 0)
{
count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct);
@@ -283,53 +273,33 @@ public sealed class CountService : ICountService
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
var now = DateTime.UtcNow;
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
// A serial counted missing is written off (Scrapped); one found is brought back InStock —
// the adjustment's own serial rules, applied when it posts.
var request = new CreateAdjustmentRequest
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var adjustment = new StockAdjustment
WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId,
Lines = varianceLines.Select(l => new CreateAdjustmentLineInput
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = deltas.Select(d => new StockAdjustmentLine
{
ItemId = d.ItemId,
BinId = d.BinId,
BatchId = d.BatchId,
SerialId = d.SerialIds?.FirstOrDefault(),
QtyDelta = d.QtyDelta
}).ToList()
};
await _adjustments.AddAsync(adjustment, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
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;
ItemId = l.ItemId,
BinId = l.BinId,
BatchId = l.BatchId,
QtyDelta = l.Variance!.Value,
SerialIds = l.SerialId is int missing && l.Variance < 0 ? [missing] : null,
SerialNos = l.Serial is not null && l.Variance > 0 ? [l.Serial.SerialNo] : null
}).ToList()
};
var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ =>
{
count.Status = CountStatus.Posted;
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
return Task.CompletedTask;
}, ct);
// Map ledger ids after commit so they are populated.
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList());
return new CountPostResultDto(
count.CountId, count.Status, adjustment.AdjustmentId, adjustment.GlJournalNo, refs.Select(r => r.LedgerId).ToList(),
adjustment.Status, adjustment.DocNo);
}
private static CountDto Map(StockCount c) => new(
+40 -2
View File
@@ -17,6 +17,8 @@ public sealed class CustomerService : ICustomerService
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IUnitOfWork _uow;
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
@@ -24,12 +26,15 @@ public sealed class CustomerService : ICustomerService
public CustomerService(
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_salesConfig = salesConfig;
_invoices = invoices;
_invoicePayments = invoicePayments;
_uow = uow;
}
@@ -70,7 +75,40 @@ public sealed class CustomerService : ICustomerService
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
return PagedResponse<CustomerDto>.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total);
}
/// <summary>
/// Adds each customer's receivable position — the unpaid balance and count of their Posted sales
/// invoices, and their latest invoice payment — for just the customers on this page.
/// </summary>
private async Task<List<CustomerDto>> WithReceivablesAsync(List<CustomerDto> rows, CancellationToken ct)
{
if (rows.Count == 0) return rows;
var ids = rows.Select(r => r.CustomerId).ToList();
var dues = await _invoices.Query().AsNoTracking()
.Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0)
.GroupBy(i => i.CustomerId)
.Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() })
.ToDictionaryAsync(x => x.CustomerId, ct);
var lastPaid = await _invoicePayments.Query().AsNoTracking()
.Where(p => ids.Contains(p.SalesInvoice!.CustomerId))
.GroupBy(p => p.SalesInvoice!.CustomerId)
.Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) })
.ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct);
return rows.Select(r =>
{
var due = dues.GetValueOrDefault(r.CustomerId);
return r with
{
OutstandingAmount = due?.Amount ?? 0m,
OutstandingInvoiceCount = due?.Count ?? 0,
LastPaymentDate = lastPaid.TryGetValue(r.CustomerId, out var last) ? last : null
};
}).ToList();
}
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
@@ -0,0 +1,284 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Reports;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in
/// UTC, so each business day is the <see cref="BusinessDayClock"/> calendar day converted to a UTC
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections; the
/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date
/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's
/// Out value per sale document — the same source <see cref="SalesDayEndService"/> posts COGS from.
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
/// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
/// </summary>
public sealed class DayEndReportService : IDayEndReportService
{
private const int MaxRangeDays = 92;
private const int IdChunk = 1000;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Expense> _expenses;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICashInHandService _cash;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
public DayEndReportService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
IRepository<Expense> expenses, IRepository<BankDeposit> deposits, ICashInHandService cash,
ICurrentBranchContext currentBranch, BusinessDayClock clock)
{
_slips = slips;
_bundles = bundles;
_invoices = invoices;
_invoicePayments = invoicePayments;
_ledger = ledger;
_expenses = expenses;
_deposits = deposits;
_cash = cash;
_currentBranch = currentBranch;
_clock = clock;
}
public async Task<DayEndReportDto> GetDayAsync(DateOnly? requestedDate, int? branchId, CancellationToken ct = default)
{
var date = requestedDate ?? _clock.Today;
var branch = EffectiveBranch(branchId);
var data = await LoadAsync(date, date, branch, ct);
var sales = data.Sales
.OrderBy(s => s.At)
.Select(s => new DayEndReportSaleDto(
s.DocType, s.DocId, s.DocNo, s.At, s.Customer, s.Total, s.Paid, s.Total - s.Paid, s.PaidBy, s.Cost, s.Total - s.Cost))
.ToList();
var expenses = await PostedExpenses(date, date, branch)
.OrderBy(e => e.ExpenseId)
.Select(e => new DayEndReportExpenseDto(
e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
.ToListAsync(ct);
var depositsQuery = _deposits.Query().AsNoTracking()
.Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == date);
if (branch is not null) depositsQuery = depositsQuery.Where(d => d.BranchId == branch);
var deposits = await depositsQuery
.OrderBy(d => d.BankDepositId)
.Select(d => new DayEndReportDepositDto(
d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount))
.ToListAsync(ct);
return new DayEndReportDto(data.Days[0], sales, expenses, deposits, branch, _clock.Zone.Id);
}
public async Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
{
if (to < from)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, "'to' must be on or after 'from'.", 400);
if (to.DayNumber - from.DayNumber + 1 > MaxRangeDays)
throw new DomainException(ErrorCodes.DayEndReportRangeInvalid, $"The range can be at most {MaxRangeDays} days.", 400);
var branch = EffectiveBranch(branchId);
var data = await LoadAsync(from, to, branch, ct);
return new DayEndDailyReportDto(from, to, data.Days, Sum(data.Days), branch, _clock.Zone.Id);
}
/// <summary>A branch-scoped (non-admin) caller always sees only their own branch, whatever they ask for.</summary>
private int? EffectiveBranch(int? requested) => _currentBranch.IsActive ? _currentBranch.BranchId : requested;
/// <summary>One sale, whatever document it is. Cash/Card/Other are how its Paid amount was paid.</summary>
private sealed record Sale(
string DocType, int DocId, string DocNo, DateTime At, DateOnly Day, string Customer,
decimal Total, decimal Paid, decimal Cash, decimal Card, decimal Other, string PaidBy, decimal Cost);
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales);
private async Task<Loaded> LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
{
var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1));
var slipsQuery = _slips.Query().AsNoTracking()
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end);
if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch);
var slips = await slipsQuery
.Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount })
.ToListAsync(ct);
var bundlesQuery = _bundles.Query().AsNoTracking()
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end);
if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch);
var bundles = await bundlesQuery
.Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount })
.ToListAsync(ct);
var invoicesQuery = _invoices.Query().AsNoTracking()
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end);
if (branch is not null) invoicesQuery = invoicesQuery.Where(i => i.BranchId == branch);
var invoices = await invoicesQuery
.Select(i => new { i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount })
.ToListAsync(ct);
// Every payment that matters to the window: those against its invoices (the method split of
// Paid), and those received inside it against earlier invoices (collections / net cash).
var paymentsQuery = _invoicePayments.Query().AsNoTracking()
.Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted
&& ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end)
|| (p.PaymentDate >= start && p.PaymentDate < end)));
if (branch is not null) paymentsQuery = paymentsQuery.Where(p => p.SalesInvoice!.BranchId == branch);
var payments = await paymentsQuery
.Select(p => new { p.SalesInvoiceId, InvoiceDate = p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount })
.ToListAsync(ct);
var slipCost = await CostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
var bundleCost = await CostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
var invoiceCost = await CostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
var expenses = await PostedExpenses(from, to, branch)
.Select(e => new { e.ExpenseDate, e.Amount })
.ToListAsync(ct);
// Branch is already the effective one, so the cash service scopes it the same way.
var cashByDay = (await _cash.GetRangeAsync(from, to, branch, ct)).ToDictionary(c => c.Date);
var paymentsByInvoice = payments.GroupBy(p => p.SalesInvoiceId).ToDictionary(g => g.Key, g => g.ToList());
var sales = new List<Sale>(slips.Count + bundles.Count + invoices.Count);
sales.AddRange(slips.Select(s => new Sale(
"Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerSnapshotName,
s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null),
slipCost.GetValueOrDefault(s.SalesSlipId))));
sales.AddRange(bundles.Select(b => new Sale(
"Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerSnapshotName,
b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null),
bundleCost.GetValueOrDefault(b.BundleSaleId))));
foreach (var i in invoices)
{
var own = paymentsByInvoice.GetValueOrDefault(i.SalesInvoiceId) ?? [];
var cash = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Cash).Sum(p => p.Amount);
var card = own.Where(p => p.PaymentMethod == SalesPaymentMethod.Card).Sum(p => p.Amount);
var others = own.Where(p => p.PaymentMethod is not (SalesPaymentMethod.Cash or SalesPaymentMethod.Card)).ToList();
var other = others.Sum(p => p.Amount);
var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList();
sales.Add(new Sale(
"Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerSnapshotName,
i.NetPayable, i.PaidAmount, cash, card, other,
PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null),
invoiceCost.GetValueOrDefault(i.SalesInvoiceId)));
}
var days = new List<DayEndReportDayDto>(to.DayNumber - from.DayNumber + 1);
for (var day = from; day <= to; day = day.AddDays(1))
{
var d = day;
var acc = new DayAccumulator();
foreach (var s in sales.Where(s => s.Day == d)) acc.AddSale(s);
var receivedToday = payments.Where(p => LocalDay(p.PaymentDate) == d).ToList();
acc.EarlierCollections = receivedToday.Where(p => LocalDay(p.InvoiceDate) < d).Sum(p => p.Amount);
acc.ExpenseTotal = expenses.Where(e => e.ExpenseDate == d).Sum(e => e.Amount);
acc.Cash = cashByDay[d];
days.Add(acc.ToDto(d));
}
return new Loaded(days, sales);
}
/// <summary>Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed.</summary>
private async Task<Dictionary<int, decimal>> CostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct)
{
var result = new Dictionary<int, decimal>();
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
{
var rows = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId))
.GroupBy(l => l.SourceDocId)
.Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) })
.ToListAsync(ct);
foreach (var r in rows) result[r.DocId] = r.Cost;
}
return result;
}
private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther)
{
var ways = (cash > 0 ? 1 : 0) + (card > 0 ? 1 : 0) + (other > 0 ? 1 : 0);
if (ways == 0) return "Unpaid";
if (ways > 1 || (other > 0 && singleOther is null)) return "Mixed";
if (cash > 0) return "Cash";
if (card > 0) return "Card";
return singleOther == SalesPaymentMethod.Cheque ? "Cheque" : "Bank transfer";
}
private IQueryable<Expense> PostedExpenses(DateOnly from, DateOnly to, int? branch)
{
var q = _expenses.Query().AsNoTracking()
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to);
return branch is null ? q : q.Where(e => e.BranchId == branch);
}
private DateOnly LocalDay(DateTime utc) => _clock.LocalDay(utc);
/// <summary>Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last.</summary>
private static DayEndReportDayDto Sum(IReadOnlyList<DayEndReportDayDto> days) => new(
null,
days.Sum(d => d.TotalSales), days.Sum(d => d.PaidAmount), days.Sum(d => d.DueAmount),
days.Sum(d => d.PaidByCash), days.Sum(d => d.PaidByCard), days.Sum(d => d.PaidByOther),
days.Sum(d => d.SalesCount), days.Sum(d => d.CashSalesCount), days.Sum(d => d.CardSalesCount),
days.Sum(d => d.MixedSalesCount), days.Sum(d => d.OtherSalesCount), days.Sum(d => d.UnpaidSalesCount),
days.Sum(d => d.ExpenseTotal), days.Sum(d => d.Income),
days.Sum(d => d.CostOfGoodsSold), days.Sum(d => d.GrossProfit), days.Sum(d => d.NetProfit),
days.Sum(d => d.EarlierInvoiceCollections), days.Sum(d => d.NetCash),
days[0].OpeningCashInHand, days.Sum(d => d.CashDeposited), days[^1].ClosingCashInHand);
private sealed class DayAccumulator
{
private decimal _total, _paid, _cash, _card, _other, _cost;
private int _count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount;
public decimal ExpenseTotal { get; set; }
public decimal EarlierCollections { get; set; }
public CashPositionDto? Cash { get; set; }
/// <summary>A sale counts as a cash/card/other sale when that is the only way it was paid,
/// mixed when paid more than one way, unpaid when nothing has been paid yet.</summary>
public void AddSale(Sale s)
{
_total += s.Total;
_paid += s.Paid;
_cash += s.Cash;
_card += s.Card;
_other += s.Other;
_cost += s.Cost;
_count++;
var ways = (s.Cash > 0 ? 1 : 0) + (s.Card > 0 ? 1 : 0) + (s.Other > 0 ? 1 : 0);
if (ways == 0) _unpaidCount++;
else if (ways > 1) _mixedCount++;
else if (s.Cash > 0) _cashCount++;
else if (s.Card > 0) _cardCount++;
else _otherCount++;
}
public DayEndReportDayDto ToDto(DateOnly date) => new(
date, _total, _paid, _total - _paid, _cash, _card, _other,
_count, _cashCount, _cardCount, _mixedCount, _otherCount, _unpaidCount,
ExpenseTotal, _total - ExpenseTotal,
_cost, _total - _cost, _total - _cost - ExpenseTotal,
EarlierCollections,
Cash?.NetCash ?? 0m, Cash?.Opening ?? 0m, Cash?.Deposited ?? 0m, Cash?.Closing ?? 0m);
}
}
@@ -0,0 +1,189 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Payments against a Posted expense (docs/EXPENSE_MODULE_PLAN.md §4). One submission may
/// split across several cash/bank accounts; it posts a single GL journal entry (Debit Accrued
/// Expenses / Credit each account) and records one <see cref="ExpensePayment"/> row per line,
/// all sharing that journal number. Same call-GL-before-commit pattern as
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the
/// whole payment back. Post &amp; Pay runs the accrual and a full or partial payment in one transaction.
/// </summary>
public sealed class ExpensePaymentService : IExpensePaymentService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpensePayment> _payments;
private readonly ExpenseService _expenseService;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccruedExpensesCode;
public ExpensePaymentService(
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_expenses = expenses;
_payments = payments;
_expenseService = expenseService;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
}
public async Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
{
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status != ExpenseStatus.Posted || expense.BalanceAmount <= 0)
throw new DomainException(ErrorCodes.ExpenseNotPayable,
$"Expense {expense.DocNo} must be posted with a balance outstanding before it can be paid.", 409);
var total = ValidateRequest(request);
if (total > expense.BalanceAmount)
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
$"Payment total {total} exceeds the remaining balance {expense.BalanceAmount}.", 400);
var accounts = await ResolveAccountsAsync(request, ct);
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
var recorded = await _uow.ExecuteInTransactionAsync(
token => RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token), ct);
return recorded.Select(ExpenseService.MapPayment).ToList();
}
public async Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
{
var expense = await _expenseService.LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status != ExpenseStatus.Draft)
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status}; use the payments endpoint instead.", 409);
// Full or partial: whatever is left stays as the balance, payable later like any Posted expense.
var total = ValidateRequest(request);
if (total > expense.Amount)
throw new DomainException(ErrorCodes.ExpensePaymentExceedsBalance,
$"Payment total {total} exceeds the expense amount {expense.Amount}.", 400);
var accounts = await ResolveAccountsAsync(request, ct);
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, DateTime.UtcNow);
await _uow.ExecuteInTransactionAsync(async token =>
{
await _expenseService.PostInCurrentTransactionAsync(expense, token);
return await RecordPaymentAsync(expense, request, accounts, paymentDay, paymentDate, token);
}, ct);
return ExpenseService.Map(expense);
}
public async Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default)
{
if (!await _expenses.Query().AnyAsync(x => x.ExpenseId == expenseId, ct))
throw new NotFoundException($"Expense {expenseId} was not found.");
var payments = await _payments.Query().AsNoTracking()
.Where(p => p.ExpenseId == expenseId)
.OrderByDescending(p => p.ExpensePaymentId)
.ToListAsync(ct);
return payments.Select(ExpenseService.MapPayment).ToList();
}
/// <summary>Posts the settlement journal and records the payment rows. Runs inside the caller's transaction.</summary>
private async Task<List<ExpensePayment>> RecordPaymentAsync(
Expense expense, CreateExpensePaymentRequest request, IReadOnlyDictionary<long, GlBankAccount> accounts,
DateOnly paymentDay, DateTime paymentDate, CancellationToken ct)
{
var total = request.Lines.Sum(l => l.Amount);
var narration = $"Payment of expense {expense.DocNo}";
var glLines = new List<GlJournalEntryLineRequest> { new(_glAccruedExpensesCode, total, 0m, narration) };
glLines.AddRange(request.Lines.Select(l => new GlJournalEntryLineRequest(accounts[l.GlBankAccountId].GlAccountCode, 0m, l.Amount, narration)));
var period = await _gl.GetPeriodByDateAsync(paymentDay, ct);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = paymentDay,
SourceModule = "EXPENSE_PAYMENT",
Reference = expense.DocNo,
Narration = narration,
Lines = glLines
}, ct);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var chequeNo = request.PaymentMethod == ExpensePaymentMethod.Cheque ? request.ChequeNo?.Trim() : null;
var reference = string.IsNullOrWhiteSpace(request.Reference) ? null : request.Reference.Trim();
var rows = request.Lines.Select(l => new ExpensePayment
{
ExpenseId = expense.ExpenseId,
Amount = l.Amount,
PaymentDate = paymentDate,
PaymentMethod = request.PaymentMethod,
GlBankAccountId = l.GlBankAccountId,
BankAccountName = accounts[l.GlBankAccountId].AccountName,
ChequeNo = chequeNo,
Reference = reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
}).ToList();
foreach (var row in rows) expense.Payments.Add(row);
expense.PaidAmount += total;
expense.BalanceAmount -= total;
expense.PaymentStatus = ExpenseService.DerivePaymentStatus(expense.PaidAmount, expense.BalanceAmount);
expense.UpdatedAt = now;
return rows;
}
private static decimal ValidateRequest(CreateExpensePaymentRequest request)
{
if (request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Add at least one payment line.", 400);
if (request.Lines.Any(l => l.Amount <= 0))
throw new DomainException(ErrorCodes.Validation, "Every payment line needs an amount greater than zero.", 400);
if (request.PaymentMethod == ExpensePaymentMethod.Cheque && string.IsNullOrWhiteSpace(request.ChequeNo))
throw new DomainException(ErrorCodes.ExpenseChequeNoRequired, "Enter the cheque number for a cheque payment.", 400);
return request.Lines.Sum(l => l.Amount);
}
private async Task<IReadOnlyDictionary<long, GlBankAccount>> ResolveAccountsAsync(CreateExpensePaymentRequest request, CancellationToken ct)
{
var all = await _gl.ListBankAccountsAsync(ct);
var result = new Dictionary<long, GlBankAccount>();
foreach (var id in request.Lines.Select(l => l.GlBankAccountId).Distinct())
{
result[id] = all.FirstOrDefault(a => a.AccountId == id)
?? throw new DomainException(ErrorCodes.ExpenseBankAccountNotFound, $"Bank/cash account {id} was not found.", 404);
}
return result;
}
/// <summary>Same rule as <see cref="PurchaseInvoicePaymentService"/>: omitted ⇒ today, one day of
/// slack on "future" for clients east of UTC; a back-dated payment is stamped at that day's start.</summary>
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
{
var today = DateOnly.FromDateTime(now);
var day = requested ?? today;
if (day > today.AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
}
}
+390
View File
@@ -0,0 +1,390 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Business expense bills (docs/EXPENSE_MODULE_PLAN.md §4). Draft is editable and has no GL
/// impact; Post accrues it (Debit the title's expense account / Credit Accrued Expenses) via a
/// real GL journal entry posted before commit — same atomicity approach as
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted
/// expense with no payments posts the reversing entry.
/// </summary>
public sealed class ExpenseService : IExpenseService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IRepository<Vendor> _vendors;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glAccruedExpensesCode;
public ExpenseService(
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors,
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_expenses = expenses;
_titles = titles;
_vendors = vendors;
_currentUser = currentUser;
_numbers = numbers;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
}
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
{
IQueryable<Expense> q = _expenses.Query().AsNoTracking().Include(x => x.Category).Include(x => x.Title);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%")
|| (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%"))
|| (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%")));
}
if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status);
if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus);
if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId);
if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId);
if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From);
if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To);
if (filter.OverdueOnly)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<ExpenseSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: false, ct);
return expense is null ? null : new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default)
{
var title = await ValidateTitleAsync(request, ct);
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
ValidateDates(request);
var branchId = request.BranchId > 0
? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
: await _branchConfig.ResolveDefaultBranchIdAsync(ct);
var expense = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new Expense
{
DocNo = await _numbers.NextAsync(DocumentTypes.Expense, branchId, token),
BranchId = branchId,
Status = ExpenseStatus.Draft,
PaymentStatus = ExpensePaymentStatus.Unpaid,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
Apply(entity, request, vendorName);
await _expenses.AddAsync(entity, token);
return entity;
}, ct);
expense.Category = title.Category;
expense.Title = title;
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
EnsureDraft(expense, "edited");
var title = await ValidateTitleAsync(request, ct);
var vendorName = await ValidateVendorAsync(request.VendorId, ct);
ValidateDates(request);
if (request.BranchId > 0 && request.BranchId != expense.BranchId)
expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
Apply(expense, request, vendorName);
expense.Category = title.Category;
expense.Title = title;
expense.UpdatedAt = DateTime.UtcNow;
await SaveAsync(ct);
return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
}
public async Task DeleteAsync(int expenseId, CancellationToken ct = default)
{
var expense = await _expenses.Query().FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
EnsureDraft(expense, "deleted");
_expenses.Remove(expense);
await _uow.SaveChangesAsync(ct);
}
public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
await _uow.ExecuteInTransactionAsync(token => PostInCurrentTransactionAsync(expense, token), ct);
return Map(expense);
}
/// <summary>
/// Posts the accrual entry and flips the expense to Posted. Must run inside the caller's
/// transaction (the caller commits) — shared with <see cref="ExpensePaymentService.PostAndPayAsync"/>
/// so Post &amp; Pay is one atomic unit.
/// </summary>
internal async Task<int> PostInCurrentTransactionAsync(Expense expense, CancellationToken ct)
{
EnsureDraft(expense, "posted");
var title = expense.Title ?? throw new InvalidOperationException("Expense title must be loaded before posting.");
if (title.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' is inactive — pick another title before posting.", 409);
var now = DateTime.UtcNow;
var narration = $"{expense.Category?.Name} - {title.Name} ({expense.DocNo})";
var period = await _gl.GetPeriodByDateAsync(expense.ExpenseDate, ct);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = expense.ExpenseDate,
SourceModule = "EXPENSE",
Reference = expense.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(title.GlExpenseAccountCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(_glAccruedExpensesCode, 0m, expense.Amount, narration)
]
}, ct);
expense.Status = ExpenseStatus.Posted;
expense.GlExpenseAccountCode = title.GlExpenseAccountCode;
expense.GlJournalNo = posted.JournalNo;
expense.GlPostedAt = now;
expense.UpdatedAt = now;
return 0;
}
public async Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default)
{
var expense = await LoadAsync(expenseId, tracking: true, ct)
?? throw new NotFoundException($"Expense {expenseId} was not found.");
if (expense.Status == ExpenseStatus.Cancelled)
throw new ConflictException($"Expense {expense.DocNo} is already cancelled.");
if (expense.PaidAmount > 0)
throw new DomainException(ErrorCodes.ExpenseHasPayments, $"Expense {expense.DocNo} has payments recorded and cannot be cancelled.", 409);
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// A Draft never reached GL, so there is nothing to reverse.
if (expense.Status == ExpenseStatus.Posted)
{
var today = DateOnly.FromDateTime(now);
var narration = $"Cancellation of expense {expense.DocNo}";
var period = await _gl.GetPeriodByDateAsync(today, token);
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = today,
SourceModule = "EXPENSE",
Reference = expense.DocNo,
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(_glAccruedExpensesCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(expense.GlExpenseAccountCode ?? string.Empty, 0m, expense.Amount, narration)
]
}, token);
expense.GlReversalJournalNo = reversal.JournalNo;
}
expense.Status = ExpenseStatus.Cancelled;
expense.CancelReason = request.Reason.Trim();
expense.UpdatedAt = now;
return 0;
}, ct);
return Map(expense);
}
public async Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? new DateOnly(today.Year, today.Month, 1);
var end = to ?? start.AddMonths(1).AddDays(-1);
if (end < start)
throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400);
var posted = _expenses.Query().AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
var inRange = await posted
.Where(x => x.ExpenseDate >= start && x.ExpenseDate <= end)
.Select(x => new
{
x.ExpenseCategoryId, CategoryName = x.Category!.Name, CategorySort = x.Category.SortOrder,
x.ExpenseTitleId, TitleName = x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount
})
.ToListAsync(ct);
var outstanding = await posted.Where(x => x.BalanceAmount > 0)
.Select(x => new { x.BalanceAmount, x.DueDate })
.ToListAsync(ct);
var overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList();
var draftCount = await _expenses.Query().CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
var categories = inRange
.GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort })
.OrderBy(g => g.Key.CategorySort).ThenBy(g => g.Key.CategoryName)
.Select(g => new ExpenseCategoryTotalDto(
g.Key.ExpenseCategoryId, g.Key.CategoryName, g.Sum(x => x.Amount), g.Count(),
g.GroupBy(x => new { x.ExpenseTitleId, x.TitleName })
.OrderByDescending(t => t.Sum(x => x.Amount))
.Select(t => new ExpenseTitleTotalDto(t.Key.ExpenseTitleId, t.Key.TitleName, t.Sum(x => x.Amount), t.Count()))
.ToList()))
.ToList();
return new ExpenseSummaryReportDto(
start, end,
inRange.Sum(x => x.Amount), inRange.Sum(x => x.PaidAmount), inRange.Sum(x => x.BalanceAmount), inRange.Count,
outstanding.Sum(x => x.BalanceAmount), overdue.Sum(x => x.BalanceAmount), overdue.Count, draftCount,
categories);
}
internal Task<Expense?> LoadAsync(int expenseId, bool tracking, CancellationToken ct)
{
var q = _expenses.Query()
.Include(x => x.Category)
.Include(x => x.Title)
.Include(x => x.Vendor)
.Include(x => x.Payments);
return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct);
}
private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct)
{
// Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and
// identity resolution hands back the instance the expense already loaded.
var title = await _titles.Query().Include(t => t.Category)
.FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422);
if (title.ExpenseCategoryId != request.ExpenseCategoryId)
throw new DomainException(ErrorCodes.ExpenseTitleCategoryMismatch, $"Title '{title.Name}' does not belong to the selected category.", 422);
if (title.Status != EntityStatus.Active || title.Category?.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.ExpenseTitleInactive, $"Expense title '{title.Name}' or its category is inactive.", 422);
return title;
}
private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct)
{
if (vendorId is null) return null;
return await _vendors.Query().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => v.Name).FirstOrDefaultAsync(ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
}
private static void ValidateDates(ExpenseRequestBase request)
{
// One day of slack, as with payment dates: the client sends its local date.
if (request.ExpenseDate > DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Expense date cannot be in the future.", 400);
if (request.DueDate is not null && request.DueDate < request.ExpenseDate)
throw new DomainException(ErrorCodes.Validation, "Due date cannot be before the expense date.", 400);
}
private static void Apply(Expense expense, ExpenseRequestBase request, string? vendorName)
{
expense.ExpenseDate = request.ExpenseDate;
expense.DueDate = request.DueDate;
expense.ExpenseCategoryId = request.ExpenseCategoryId;
expense.ExpenseTitleId = request.ExpenseTitleId;
expense.VendorId = request.VendorId;
// A linked vendor names the payee unless one was typed explicitly.
expense.Payee = Normalize(request.Payee) ?? vendorName;
expense.BillReference = Normalize(request.BillReference);
expense.Description = Normalize(request.Description);
expense.PeriodMonth = Normalize(request.PeriodMonth);
expense.Amount = request.Amount;
expense.PaidAmount = 0m;
expense.BalanceAmount = request.Amount;
expense.PaymentStatus = ExpensePaymentStatus.Unpaid;
}
private static void EnsureDraft(Expense expense, string verb)
{
if (expense.Status != ExpenseStatus.Draft)
throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status} and cannot be {verb}.", 409);
}
private async Task SaveAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412);
}
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) =>
balanceAmount <= 0 ? ExpensePaymentStatus.Paid
: paidAmount > 0 ? ExpensePaymentStatus.PartiallyPaid
: ExpensePaymentStatus.Unpaid;
private static ExpenseSummaryDto MapSummary(Expense x) => new(
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
x.Payee, x.BillReference, x.PeriodMonth, x.Amount, x.PaidAmount, x.BalanceAmount,
x.Status, x.PaymentStatus, x.GlJournalNo, x.CreatedAt);
internal static ExpenseDto Map(Expense x) => new(
x.ExpenseId, x.DocNo, x.BranchId, x.ExpenseDate, x.DueDate,
x.ExpenseCategoryId, x.Category?.Name ?? string.Empty, x.ExpenseTitleId, x.Title?.Name ?? string.Empty,
x.Payee, x.VendorId, x.Vendor?.Name, x.BillReference, x.Description, x.PeriodMonth,
x.Amount, x.PaidAmount, x.BalanceAmount, x.Status, x.PaymentStatus,
x.GlExpenseAccountCode, x.GlJournalNo, x.GlPostedAt, x.GlReversalJournalNo, x.CancelReason,
x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.Payments.OrderByDescending(p => p.ExpensePaymentId).Select(MapPayment).ToList());
internal static ExpensePaymentDto MapPayment(ExpensePayment p) => new(
p.ExpensePaymentId, p.ExpenseId, p.Amount, p.PaymentDate, p.PaymentMethod,
p.GlBankAccountId, p.BankAccountName, p.ChequeNo, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,133 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Expense categories and titles. Categories are the fixed seeded four (no create — only their
/// active flag changes). Seeded rows (<c>IsSystem</c>) keep their names — they can be
/// deactivated, and a title's GL account code can be corrected — while user-added titles
/// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one
/// means setting it Inactive (it stops being offered on new expenses).
/// </summary>
public sealed class ExpenseSetupService : IExpenseSetupService
{
private readonly IRepository<ExpenseCategory> _categories;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IUnitOfWork _uow;
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow)
{
_categories = categories;
_titles = titles;
_uow = uow;
}
public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default)
{
var categories = await _categories.Query().AsNoTracking()
.Include(c => c.Titles)
.Where(c => includeInactive || c.Status == EntityStatus.Active)
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
.ToListAsync(ct);
return categories.Select(c => Map(c, includeInactive)).ToList();
}
public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
{
var category = await _categories.Query().Include(c => c.Titles)
.FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)
?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim();
if (name != category.Name)
{
if (category.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
if (await _categories.Query().AnyAsync(c => c.Name == name && c.ExpenseCategoryId != expenseCategoryId, ct))
throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
category.Name = name;
}
category.Status = request.Status;
category.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(category, includeInactive: true);
}
public async Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default)
{
if (!await _categories.Query().AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct))
throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim();
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct);
var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId)
.MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1;
var title = new ExpenseTitle
{
ExpenseCategoryId = expenseCategoryId,
Name = name,
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
IsSystem = false,
SortOrder = nextSort,
CreatedAt = DateTime.UtcNow
};
await _titles.AddAsync(title, ct);
await _uow.SaveChangesAsync(ct);
return Map(title);
}
public async Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default)
{
var title = await _titles.Query().FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct)
?? throw new NotFoundException($"Expense title {expenseTitleId} was not found.");
var name = request.Name.Trim();
if (name != title.Name)
{
if (title.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, ct);
title.Name = name;
}
// The GL code is editable on system rows too — the seeded codes are placeholders.
// Already-posted expenses keep the code they snapshotted at post time.
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
title.Status = request.Status;
title.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(title);
}
private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct)
{
var duplicate = await _titles.Query().AnyAsync(t =>
t.ExpenseCategoryId == expenseCategoryId && t.Name == name
&& (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409);
}
private static ExpenseCategoryDto Map(ExpenseCategory c, bool includeInactive) => new(
c.ExpenseCategoryId, c.Code, c.Name, c.IsSystem, c.Status, c.SortOrder,
c.Titles
.Where(t => includeInactive || t.Status == EntityStatus.Active)
.OrderBy(t => t.SortOrder).ThenBy(t => t.Name)
.Select(Map).ToList());
private static ExpenseTitleDto Map(ExpenseTitle t) => new(
t.ExpenseTitleId, t.ExpenseCategoryId, t.Name, t.GlExpenseAccountCode, t.IsSystem, t.Status, t.SortOrder);
}
@@ -1,15 +1,25 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5; docs/DUAL_APPROVAL_PLAN.md §5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default);
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
/// <summary>Submits an adjustment request — it posts at once only when approvals are switched off.</summary>
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
/// <summary>Gives the next approval; the last one posts the stock movement and GL.</summary>
Task<AdjustmentDto> ApproveAsync(int adjustmentId, string? comment, CancellationToken ct = default);
Task<AdjustmentDto> RejectAsync(int adjustmentId, string reason, CancellationToken ct = default);
/// <summary>The requester withdraws a request that is still pending.</summary>
Task<AdjustmentDto> CancelAsync(int adjustmentId, CancellationToken ct = default);
}
@@ -0,0 +1,48 @@
using ERPCore.Dtos.Approvals;
namespace ERPCore.Services.Interfaces;
/// <summary>Where a document stands in its approval, as the shared engine needs to see it.</summary>
/// <param name="NavCode">The screen whose Approve action an approver needs (e.g. <c>stock.adjustments</c>).</param>
/// <param name="CurrentLevel">The approval being given now: 1, or 2 after the first.</param>
public sealed record ApprovalTarget(
string DocType, int DocId, string DocNo, string NavCode, int RequestedBy, int RequiredApprovals, int CurrentLevel, int? Level1ApprovedBy);
public enum ApprovalOutcome
{
AdvancedToLevel2,
FullyApproved,
Rejected
}
/// <summary>
/// The shared maker–checker rules for stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §4):
/// the requester can't approve, the two approvers must differ, level 2 may be limited to roles.
/// The Approve permission itself is checked by the endpoint's policy. Decisions are added to the
/// caller's unit of work — the document service saves them with its own changes.
/// </summary>
public interface IApprovalService
{
Task<ApprovalConfigDto> GetConfigAsync(CancellationToken ct = default);
Task<ApprovalConfigDto> UpdateConfigAsync(UpdateApprovalConfigRequest request, CancellationToken ct = default);
/// <summary>Approvals a stock adjustment worth <paramref name="absoluteValue"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default);
/// <summary>Approvals a price change of <paramref name="largestChangePercent"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default);
/// <summary>Validates the current user may decide, and records the decision (unsaved).</summary>
Task<ApprovalOutcome> DecideAsync(ApprovalTarget target, bool approve, string? comment, CancellationToken ct = default);
/// <summary>Null when the current user may decide; otherwise the reason they can't.</summary>
Task<string?> WhyCannotDecideAsync(ApprovalTarget target, CancellationToken ct = default);
Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct = default);
/// <summary>Active users who could give <paramref name="target"/>'s current approval — for notifications.</summary>
Task<IReadOnlyList<int>> ApproverIdsAsync(ApprovalTarget target, CancellationToken ct = default);
/// <summary>Notifies whoever can give the target's next approval that it is waiting.</summary>
Task NotifyPendingAsync(ApprovalTarget target, string summary, CancellationToken ct = default);
}
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Till cash in hand per branch and business day (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.1) — the one
/// calculation both the deposit form and the Day-End Sales Report read. A branch-scoped caller
/// always gets their own branch; <c>branchId</c> null means every branch together.
/// </summary>
public interface ICashInHandService
{
/// <summary>The configured <c>BankDeposit:CashTrackingStartDate</c>, if any.</summary>
DateOnly? TrackingStartDate { get; }
/// <summary><paramref name="day"/> null ⇒ today in the business time zone.</summary>
Task<CashInHandDto> GetPositionAsync(DateOnly? day, int? branchId, CancellationToken ct = default);
/// <summary>Every day of <c>from</c>..<c>to</c> (at most 92 days), opening carried in from earlier days.</summary>
Task<CashInHandDailyDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
/// <summary>Same as <see cref="GetDailyAsync"/> without the range cap — for callers that validate their own range.</summary>
Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
/// <summary>
/// The most that can still be deposited against <paramref name="day"/>: the lowest closing balance
/// from that day to today, never below zero; zero for a future day or one before tracking starts.
/// <paramref name="branchScope"/> is used as given (the caller has already resolved it).
/// </summary>
Task<decimal> GetAvailableToDepositAsync(DateOnly day, int? branchScope, CancellationToken ct = default);
}
/// <summary>Bank deposits of till cash — record (posts GL), cancel (reverses GL), list (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2).</summary>
public interface IBankDepositService
{
Task<BankDepositListDto> ListAsync(PageQuery query, BankDepositListFilter filter, CancellationToken ct = default);
Task<BankDepositDto?> GetAsync(int bankDepositId, CancellationToken ct = default);
Task<BankDepositDto> CreateAsync(CreateBankDepositRequest request, CancellationToken ct = default);
Task<BankDepositDto> CancelAsync(int bankDepositId, CancelBankDepositRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Reports;
namespace ERPCore.Services.Interfaces;
/// <summary>Read-only day-end sales/expense/income report (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
public interface IDayEndReportService
{
/// <summary><paramref name="date"/> null ⇒ today in the report's time zone.</summary>
Task<DayEndReportDto> GetDayAsync(DateOnly? date, int? branchId, CancellationToken ct = default);
Task<DayEndDailyReportDto> GetDailyAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
}
@@ -0,0 +1,35 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
namespace ERPCore.Services.Interfaces;
/// <summary>Expense categories and their titles (docs/EXPENSE_MODULE_PLAN.md §2.1).</summary>
public interface IExpenseSetupService
{
Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default);
Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default);
Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default);
Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default);
}
/// <summary>Expense bills: draft CRUD, post (GL accrual), cancel and reporting (docs/EXPENSE_MODULE_PLAN.md §4).</summary>
public interface IExpenseService
{
Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default);
Task<ETagged<ExpenseDto>?> GetAsync(int expenseId, CancellationToken ct = default);
Task<ETagged<ExpenseDto>> CreateAsync(CreateExpenseRequest request, CancellationToken ct = default);
Task<ETagged<ExpenseDto>> UpdateAsync(int expenseId, UpdateExpenseRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task DeleteAsync(int expenseId, CancellationToken ct = default);
Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default);
Task<ExpenseDto> CancelAsync(int expenseId, CancelExpenseRequest request, CancellationToken ct = default);
Task<ExpenseSummaryReportDto> GetSummaryAsync(DateOnly? from, DateOnly? to, CancellationToken ct = default);
}
/// <summary>Payments against a Posted expense's balance, and the one-step Post &amp; Pay.</summary>
public interface IExpensePaymentService
{
Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
Task<ExpenseDto> PostAndPayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default);
}
@@ -34,5 +34,8 @@ public interface IItemService
/// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no
/// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary>
/// <summary>The item's most recent posted GRN receipts (newest first) — what it was bought at.</summary>
Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default);
Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default);
}
@@ -17,6 +17,19 @@ public interface INotificationService
int? relatedEntityId = null,
CancellationToken ct = default);
/// <summary>
/// Same as <see cref="RaiseAsync"/> but delivered only to <paramref name="userIds"/> (active ones) —
/// e.g. the approvers of a request, or its requester. Same InApp-channel gate; no-op for an empty list.
/// </summary>
Task NotifyUsersAsync(
IEnumerable<int> userIds,
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default);
Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default);
Task MarkAllReadAsync(int userId, CancellationToken ct = default);
@@ -0,0 +1,25 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
namespace ERPCore.Services.Interfaces;
/// <summary>Selling-price change requests with dual approval (docs/DUAL_APPROVAL_PLAN.md §6).</summary>
public interface IPriceAdjustmentService
{
Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default);
Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default);
/// <summary>Submits a request — applied at once only when price approvals are switched off.</summary>
Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default);
/// <summary>Gives the next approval; the last one writes the new prices to the items.</summary>
Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default);
Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default);
Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default);
Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default);
/// <summary>True when selling prices may only change through an approved price adjustment.</summary>
Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default);
}
+33 -1
View File
@@ -32,6 +32,12 @@ public sealed class ItemService : IItemService
private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<TaxRate> _taxRates;
private readonly IRepository<ApprovalConfig> _approvalConfig;
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
=> (await _approvalConfig.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
@@ -59,8 +65,10 @@ public sealed class ItemService : IItemService
IRepository<Serial> serials,
IProductConfigService config,
INotificationService notifications,
IUnitOfWork uow)
IUnitOfWork uow,
IRepository<ApprovalConfig> approvalConfig)
{
_approvalConfig = approvalConfig;
_items = items;
_categories = categories;
_subCategories = subCategories;
@@ -207,6 +215,21 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
return await _grnLines.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null)
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId)
.Take(Math.Clamp(take, 1, 50))
.Select(l => new ItemPurchasePriceDto(
l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code,
l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct))
.ToListAsync(ct);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
@@ -309,6 +332,15 @@ public sealed class ItemService : IItemService
ErrorCodes.MasterInUse,
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
// With price approvals on, selling prices change only through an approved price adjustment
// (docs/DUAL_APPROVAL_PLAN.md §6.3). Sending the current prices back unchanged is fine.
var pricesChanged = item.SellingMinPrice != request.SellingMinPrice
|| item.SellingMaxPrice != request.SellingMaxPrice
|| item.SellingFlatPrice != request.SellingFlatPrice;
if (pricesChanged && await PricesNeedApprovalAsync(ct))
throw new DomainException(ErrorCodes.PriceChangeRequiresApproval,
"Selling prices need approval — raise a price adjustment (Products → Price Adjustments) instead of editing them here.", 409);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(
@@ -39,16 +39,43 @@ public sealed class NotificationService : INotificationService
int? relatedEntityId = null,
CancellationToken ct = default)
{
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
if (!channels.Contains(NotificationChannel.InApp)) return;
var activeUserIds = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active)
.Select(u => u.UserId)
.ToListAsync(ct);
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
}
public async Task NotifyUsersAsync(
IEnumerable<int> userIds,
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default)
{
var wanted = userIds.Distinct().ToList();
if (wanted.Count == 0) return;
var activeUserIds = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && wanted.Contains(u.UserId))
.Select(u => u.UserId)
.ToListAsync(ct);
await DeliverAsync(activeUserIds, moduleKey, title, message, relatedEntityType, relatedEntityId, ct);
}
private async Task DeliverAsync(
List<int> activeUserIds, string moduleKey, string title, string message,
string? relatedEntityType, int? relatedEntityId, CancellationToken ct)
{
if (activeUserIds.Count == 0) return;
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
if (!channels.Contains(NotificationChannel.InApp)) return;
var notification = new Notification
{
ModuleKey = moduleKey,
@@ -0,0 +1,364 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
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>
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6). A request snapshots each item's
/// current prices and the prices wanted; nothing changes until it is fully approved (rules in
/// <see cref="IApprovalService"/>). Approval then writes the new prices to the items and records an
/// <see cref="ItemPriceHistory"/> row — unless an item was repriced since the request was made, in
/// which case the approval is refused as stale. One pending request per item at a time.
/// </summary>
public sealed class PriceAdjustmentService : IPriceAdjustmentService
{
public const string ApprovalDocType = "PADJ";
public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments;
private readonly IRepository<Item> _items;
private readonly IRepository<ItemPriceHistory> _history;
private readonly IRepository<User> _users;
private readonly IRepository<ApprovalConfig> _config;
private readonly IApprovalService _approvals;
private readonly INotificationService _notifications;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public PriceAdjustmentService(
IRepository<PriceAdjustment> adjustments, IRepository<Item> items, IRepository<ItemPriceHistory> history,
IRepository<User> users, IRepository<ApprovalConfig> config, IApprovalService approvals,
INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_items = items;
_history = history;
_users = users;
_config = config;
_approvals = approvals;
_notifications = notifications;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%"));
}
if (status is not null) q = q.Where(p => p.Status == status);
if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(p => p.PriceAdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(p => new PriceAdjustmentSummaryDto(
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals,
p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : p.Status == PriceAdjustmentStatus.PendingSecondApproval ? 2 : null,
p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt))
.ToListAsync(ct);
return PagedResponse<PriceAdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default)
{
var adjustment = await Load(tracking: false).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
return adjustment is null ? null : await ToDtoAsync(adjustment, ct);
}
public async Task<PriceAdjustmentDto> CreateAsync(CreatePriceAdjustmentRequest request, CancellationToken ct = default)
{
var reason = request.Reason?.Trim();
if (string.IsNullOrEmpty(reason))
throw new DomainException(ErrorCodes.Validation, "Give a reason for the price change.", 400);
if (request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Add at least one item.", 400);
if (request.Lines.Select(l => l.ItemId).Distinct().Count() != request.Lines.Count)
throw new DomainException(ErrorCodes.Validation, "Each item can appear only once in a request.", 400);
var ids = request.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
var pendingItemIds = await _adjustments.Query().AsNoTracking()
.Where(p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval)
.SelectMany(p => p.Lines.Select(l => new { l.ItemId, p.DocNo }))
.Where(x => ids.Contains(x.ItemId))
.ToListAsync(ct);
var lines = new List<PriceAdjustmentLine>();
var largestChange = 0m;
foreach (var input in request.Lines)
{
if (!items.TryGetValue(input.ItemId, out var item))
throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId);
if (clash is not null)
throw new DomainException(ErrorCodes.PriceAdjustmentItemPending,
$"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409);
ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice);
if (input.NewMinPrice == item.SellingMinPrice && input.NewMaxPrice == item.SellingMaxPrice && input.NewFlatPrice == item.SellingFlatPrice)
throw new DomainException(ErrorCodes.Validation, $"{item.Sku}: the new prices are the same as the current ones.", 400);
largestChange = Math.Max(largestChange, Math.Max(
ChangePercent(item.SellingMinPrice, input.NewMinPrice),
Math.Max(ChangePercent(item.SellingMaxPrice, input.NewMaxPrice), ChangePercent(item.SellingFlatPrice, input.NewFlatPrice))));
lines.Add(new PriceAdjustmentLine
{
ItemId = item.ItemId,
OldMinPrice = item.SellingMinPrice, OldMaxPrice = item.SellingMaxPrice, OldFlatPrice = item.SellingFlatPrice,
NewMinPrice = input.NewMinPrice, NewMaxPrice = input.NewMaxPrice, NewFlatPrice = input.NewFlatPrice
});
}
var required = await _approvals.RequiredForPriceAdjustmentAsync(largestChange, ct);
var now = DateTime.UtcNow;
var adjustment = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PriceAdjustment
{
DocNo = await _numbers.NextAsync(DocumentTypes.PriceAdjustment, token),
Reason = reason,
Status = required == 0 ? PriceAdjustmentStatus.Approved : PriceAdjustmentStatus.PendingApproval,
RequiredApprovals = required,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = lines
};
await _adjustments.AddAsync(entity, token);
if (required == 0)
await ApplyInCurrentTransactionAsync(entity, items, now, token);
return entity;
}, ct);
if (required > 0)
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> ApproveAsync(int priceAdjustmentId, string? comment, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
var target = Target(adjustment);
var outcome = await _approvals.DecideAsync(target, approve: true, comment, ct);
var now = DateTime.UtcNow;
if (target.CurrentLevel == 1)
{
adjustment.ApprovedLevel1By = _currentUser.AuditUserId;
adjustment.ApprovedLevel1At = now;
}
else
{
adjustment.ApprovedLevel2By = _currentUser.AuditUserId;
adjustment.ApprovedLevel2At = now;
}
try
{
await _uow.ExecuteInTransactionAsync(async token =>
{
if (outcome == ApprovalOutcome.AdvancedToLevel2)
{
adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval;
return 0;
}
var ids = adjustment.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token);
adjustment.Status = PriceAdjustmentStatus.Approved;
await ApplyInCurrentTransactionAsync(adjustment, items, now, token);
return 0;
}, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
if (outcome == ApprovalOutcome.AdvancedToLevel2)
await _approvals.NotifyPendingAsync(Target(adjustment), Summary(adjustment), ct);
else
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.approved",
$"Approved: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was approved — the new prices are now in effect.",
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> RejectAsync(int priceAdjustmentId, string reason, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
adjustment.Status = PriceAdjustmentStatus.Rejected;
adjustment.RejectedBy = _currentUser.AuditUserId;
adjustment.RejectedAt = DateTime.UtcNow;
adjustment.RejectReason = reason.Trim();
await SaveDecisionAsync(adjustment, ct);
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
$"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}",
ApprovalDocType, adjustment.PriceAdjustmentId, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<PriceAdjustmentDto> CancelAsync(int priceAdjustmentId, CancellationToken ct = default)
{
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
if (adjustment.CreatedBy != _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
adjustment.Status = PriceAdjustmentStatus.Cancelled;
adjustment.CancelledAt = DateTime.UtcNow;
await SaveDecisionAsync(adjustment, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
}
public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default)
{
var rows = await _history.Query().AsNoTracking()
.Where(h => h.ItemId == itemId)
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
.ToListAsync(ct);
var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType<int>().Distinct().ToList();
var names = await _users.Query().AsNoTracking().Where(u => userIds.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
var docIds = rows.Select(r => r.PriceAdjustmentId).OfType<int>().Distinct().ToList();
var docNos = await _adjustments.Query().AsNoTracking().Where(p => docIds.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct);
string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null;
return rows.Select(h => new ItemPriceHistoryDto(
h.ItemPriceHistoryId, h.ItemId, h.PriceAdjustmentId, h.PriceAdjustmentId is int d ? docNos.GetValueOrDefault(d) : null,
h.OldMinPrice, h.OldMaxPrice, h.OldFlatPrice, h.NewMinPrice, h.NewMaxPrice, h.NewFlatPrice,
h.RequestedBy, Name(h.RequestedBy), h.ApprovedLevel1By, Name(h.ApprovedLevel1By), h.ApprovedLevel2By, Name(h.ApprovedLevel2By),
h.AppliedAt)).ToList();
}
public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default)
=> (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
// --- Helpers ---------------------------------------------------------------------------------
/// <summary>Writes each line's new prices to its item and records the history. Refuses the whole
/// request if any item's prices no longer match what the request was made against.</summary>
private async Task ApplyInCurrentTransactionAsync(PriceAdjustment adjustment, IReadOnlyDictionary<int, Item> items, DateTime now, CancellationToken ct)
{
foreach (var line in adjustment.Lines)
{
var item = items[line.ItemId];
if (item.SellingMinPrice != line.OldMinPrice || item.SellingMaxPrice != line.OldMaxPrice || item.SellingFlatPrice != line.OldFlatPrice)
throw new DomainException(ErrorCodes.PriceAdjustmentStale,
$"{item.Sku}'s prices changed after {adjustment.DocNo} was requested. Reject this request and raise a new one against the current prices.", 409);
item.SellingMinPrice = line.NewMinPrice;
item.SellingMaxPrice = line.NewMaxPrice;
item.SellingFlatPrice = line.NewFlatPrice;
item.UpdatedAt = now;
await _history.AddAsync(new ItemPriceHistory
{
ItemId = item.ItemId,
PriceAdjustment = adjustment, // FK set by EF, also when the request is new
OldMinPrice = line.OldMinPrice, OldMaxPrice = line.OldMaxPrice, OldFlatPrice = line.OldFlatPrice,
NewMinPrice = line.NewMinPrice, NewMaxPrice = line.NewMaxPrice, NewFlatPrice = line.NewFlatPrice,
RequestedBy = adjustment.CreatedBy,
ApprovedLevel1By = adjustment.ApprovedLevel1By,
ApprovedLevel2By = adjustment.ApprovedLevel2By,
AppliedAt = now
}, ct);
}
adjustment.AppliedAt = now;
}
private static void ValidatePrices(string sku, decimal? min, decimal? max, decimal? flat)
{
if (min < 0 || max < 0 || flat < 0)
throw new DomainException(ErrorCodes.Validation, $"{sku}: prices cannot be negative.", 400);
if (min is not null && max is not null && min > max)
throw new DomainException(ErrorCodes.Validation, $"{sku}: the minimum price is above the maximum.", 400);
if (flat is not null && ((min is not null && flat < min) || (max is not null && flat > max)))
throw new DomainException(ErrorCodes.Validation, $"{sku}: the flat price must be between the minimum and maximum.", 400);
}
/// <summary>Size of a change in percent; setting or clearing a price counts as 100%.</summary>
public static decimal ChangePercent(decimal? oldPrice, decimal? newPrice)
{
if (oldPrice == newPrice) return 0m;
if (oldPrice is null || newPrice is null || oldPrice == 0) return 100m;
return Math.Abs(newPrice.Value - oldPrice.Value) / oldPrice.Value * 100m;
}
private IQueryable<PriceAdjustment> Load(bool tracking)
{
var q = _adjustments.Query().Include(p => p.Lines).ThenInclude(l => l.Item).Include(p => p.Creator);
return tracking ? q : q.AsNoTracking();
}
private async Task<PriceAdjustment> LoadPendingAsync(int priceAdjustmentId, CancellationToken ct)
{
var adjustment = await _adjustments.Query().Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct)
?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found.");
if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval))
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
return adjustment;
}
private async Task SaveDecisionAsync(PriceAdjustment adjustment, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} was just decided by someone else — reload it.", 409);
}
}
private static int? PendingLevel(PriceAdjustment p) => p.Status switch
{
PriceAdjustmentStatus.PendingApproval => 1,
PriceAdjustmentStatus.PendingSecondApproval => 2,
_ => null
};
private static ApprovalTarget Target(PriceAdjustment p) => new(
ApprovalDocType, p.PriceAdjustmentId, p.DocNo, ApprovalNavCode, p.CreatedBy, p.RequiredApprovals, PendingLevel(p) ?? 1, p.ApprovedLevel1By);
private static string Summary(PriceAdjustment p)
=> $"Price adjustment {p.DocNo} ({p.Lines.Count} item{(p.Lines.Count == 1 ? "" : "s")}: {p.Reason})";
private async Task<PriceAdjustmentDto> ToDtoAsync(PriceAdjustment p, CancellationToken ct)
{
var pending = PendingLevel(p);
var whyNot = pending is null ? "Not waiting for approval." : await _approvals.WhyCannotDecideAsync(Target(p), ct);
return new PriceAdjustmentDto(
p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, pending,
p.CreatedBy, p.Creator?.DisplayName, p.CreatedAt,
p.ApprovedLevel1By, p.ApprovedLevel1At, p.ApprovedLevel2By, p.ApprovedLevel2At,
p.RejectedBy, p.RejectedAt, p.RejectReason, p.CancelledAt, p.AppliedAt,
p.Lines.OrderBy(l => l.PriceAdjustmentLineId).Select(l => new PriceAdjustmentLineDto(
l.PriceAdjustmentLineId, l.ItemId, l.Item?.Sku ?? $"#{l.ItemId}", l.Item?.Name ?? string.Empty,
l.OldMinPrice, l.OldMaxPrice, l.OldFlatPrice, l.NewMinPrice, l.NewMaxPrice, l.NewFlatPrice,
l.Item?.SellingMinPrice, l.Item?.SellingMaxPrice, l.Item?.SellingFlatPrice)).ToList(),
await _approvals.HistoryAsync(ApprovalDocType, p.PriceAdjustmentId, ct),
whyNot is null, whyNot,
pending is not null && p.CreatedBy == _currentUser.AuditUserId);
}
}
@@ -91,6 +91,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
PaymentMethod = ResolveMethod(request.PaymentMethod, account),
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
@@ -151,16 +152,17 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, SalesPaymentMethod Method, decimal Amount)>();
var invIdx = 0;
var invRemaining = ordered[0].BalanceAmount;
foreach (var (line, account) in lines)
{
var lineRemaining = line.Amount;
var method = ResolveMethod(line.PaymentMethod, account);
while (lineRemaining > 0)
{
var take = Math.Min(lineRemaining, invRemaining);
allocations.Add((ordered[invIdx], account, take));
allocations.Add((ordered[invIdx], account, method, take));
lineRemaining -= take;
invRemaining -= take;
if (invRemaining == 0 && invIdx < ordered.Count - 1)
@@ -196,7 +198,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
}, token);
var entities = new List<SalesInvoicePayment>();
foreach (var (invoice, account, amount) in allocations)
foreach (var (invoice, account, method, amount) in allocations)
{
var entity = new SalesInvoicePayment
{
@@ -205,6 +207,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
PaymentMethod = method,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
@@ -238,6 +241,13 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
}
/// <summary>The caller's method, or — when omitted (older clients) — the account's kind: money into a
/// Cash account is cash, money into a Bank account is taken to be a card payment.</summary>
private static SalesPaymentMethod ResolveMethod(SalesPaymentMethod? requested, GlBankAccount account)
=> requested ?? (string.Equals(account.AccountType, "Cash", StringComparison.OrdinalIgnoreCase)
? SalesPaymentMethod.Cash
: SalesPaymentMethod.Card);
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
@@ -248,11 +258,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
.OrderByDescending(p => p.SalesInvoicePaymentId)
.Select(p => new SalesInvoicePaymentDto(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod))
.ToListAsync(ct);
}
private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt, p.PaymentMethod);
}
@@ -26,7 +26,9 @@ public sealed class SalesMappingService : ISalesMappingService
slip.TaxTotal,
slip.GrandTotal,
slip.PaidAmount,
slip.BalanceAmount);
slip.BalanceAmount,
slip.CashAmount,
slip.CardAmount);
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new(
+11 -4
View File
@@ -142,7 +142,7 @@ public sealed class SalesSlipService : ISalesSlipService
CreatedAt = DateTime.UtcNow,
Lines = lines
};
Recalculate(entity, request.PaidAmount);
Recalculate(entity, request.PaidAmount, request.CardAmount);
await _slips.AddAsync(entity, token);
return entity;
}, ct);
@@ -163,7 +163,7 @@ public sealed class SalesSlipService : ISalesSlipService
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
Recalculate(slip, request.PaidAmount);
Recalculate(slip, request.PaidAmount, request.CardAmount);
slip.UpdatedAt = DateTime.UtcNow;
try
{
@@ -249,9 +249,9 @@ public sealed class SalesSlipService : ISalesSlipService
/// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment
/// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed
/// GrandTotal, not the request's own line totals, since GrandTotal is only known after
/// lines are summed here.
/// lines are summed here. <paramref name="requestedCardAmount"/> null means all cash.
/// </summary>
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount)
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount, decimal? requestedCardAmount)
{
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
@@ -263,8 +263,15 @@ public sealed class SalesSlipService : ISalesSlipService
throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid,
$"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400);
var card = requestedCardAmount ?? 0m;
if (card < 0 || card > paid)
throw new DomainException(ErrorCodes.SalesCardAmountInvalid,
$"cardAmount must be between 0 and the paid amount {paid}.", 400);
slip.PaidAmount = paid;
slip.BalanceAmount = slip.GrandTotal - paid;
slip.CardAmount = card;
slip.CashAmount = paid - card;
}
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
@@ -28,6 +28,7 @@ public sealed class FifoCostingService : IFifoCostingService
private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts;
/// <param name="lowStock">Records each issue for the after-request low-stock check; optional so callers outside a request (tests) can omit it.</param>
public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db,
@@ -312,6 +313,7 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
}
_lowStock?.Track(itemId, warehouseId, qtyBase);
return segments;
}
@@ -0,0 +1,67 @@
using ERPCore.Domain.Entities;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Raises the <c>stock.low</c> notification when an issue takes an item's available stock at a
/// warehouse down to or below that warehouse's reorder point (<see cref="ItemReorder"/>). Only the
/// crossing raises it — stock that was already low doesn't alert again on every sale. Runs after
/// the request has committed (<see cref="Infra.Filters.LowStockAlertFilter"/>), so a rolled-back
/// document never alerts.
/// </summary>
public sealed class LowStockAlertService
{
public const string ModuleKey = "stock.low";
private readonly LowStockTracker _tracker;
private readonly IRepository<ItemReorder> _reorders;
private readonly IStockService _stock;
private readonly INotificationService _notifications;
public LowStockAlertService(
LowStockTracker tracker, IRepository<ItemReorder> reorders, IStockService stock, INotificationService notifications)
{
_tracker = tracker;
_reorders = reorders;
_stock = stock;
_notifications = notifications;
}
public async Task CheckAsync(CancellationToken ct = default)
{
var issued = _tracker.Drain();
if (issued.Count == 0) return;
var itemIds = issued.Keys.Select(k => k.ItemId).Distinct().ToList();
var rules = await _reorders.Query().AsNoTracking()
.Where(r => itemIds.Contains(r.ItemId))
.Select(r => new
{
r.ItemId, r.WarehouseId, r.ReorderPoint, r.ReorderQty,
r.Item!.Sku, ItemName = r.Item.Name, Uom = r.Item.BaseUom!.Name,
WarehouseCode = r.Warehouse!.Code, WarehouseName = r.Warehouse.Name
})
.ToListAsync(ct);
foreach (var rule in rules)
{
if (!issued.TryGetValue((rule.ItemId, rule.WarehouseId), out var qty)) continue;
var available = (await _stock.GetOnHandAsync(rule.ItemId, rule.WarehouseId, ct)).Available;
var before = available + qty;
if (available > rule.ReorderPoint || before <= rule.ReorderPoint) continue;
await _notifications.RaiseAsync(
ModuleKey,
$"Low stock: {rule.ItemName}",
$"{rule.Sku} — {rule.ItemName} is down to {Qty(available)} {rule.Uom} at {rule.WarehouseCode} ({rule.WarehouseName}), "
+ $"at or below its alert level of {Qty(rule.ReorderPoint)}. Suggested reorder: {Qty(rule.ReorderQty)} {rule.Uom}.",
nameof(Item), rule.ItemId, ct);
}
}
private static string Qty(decimal value) => value.ToString("0.####");
}
@@ -0,0 +1,27 @@
namespace ERPCore.Services.Stock;
/// <summary>
/// Collects, for one request, every item/warehouse whose stock went down and by how much.
/// <see cref="FifoCostingService.ConsumeAsync"/> — the one path every stock issue takes (sales,
/// transfers, adjustments, returns, production) — records here; <see cref="LowStockAlertService"/>
/// checks the entries against their reorder rules once the request has committed. Has no
/// dependencies on purpose, so the FIFO service can take it without a DI cycle.
/// </summary>
public sealed class LowStockTracker
{
private readonly Dictionary<(int ItemId, int WarehouseId), decimal> _issued = new();
public void Track(int itemId, int warehouseId, decimal qty)
{
if (qty <= 0) return;
_issued[(itemId, warehouseId)] = _issued.GetValueOrDefault((itemId, warehouseId)) + qty;
}
/// <summary>Hands back what was recorded and starts empty again.</summary>
public IReadOnlyDictionary<(int ItemId, int WarehouseId), decimal> Drain()
{
var copy = new Dictionary<(int ItemId, int WarehouseId), decimal>(_issued);
_issued.Clear();
return copy;
}
}