using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Money; using foodmarket.Domain.Money; using foodmarket.Domain.Purchases; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace foodmarket.Infrastructure.Money; /// Единая реализация . Внутри — Serializable /// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте /// Postgres падает в 40001, миддлварь (SerializationConflictMiddleware) /// преобразует в 409. Любые изменения Supply.PayedSum / SupplierReturn.PayedSum /// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно /// защищён. public sealed class MoneyService : IMoneyService { private readonly AppDbContext _db; private readonly ITenantContext _tenant; private readonly ILogger _log; public MoneyService(AppDbContext db, ITenantContext tenant, ILogger log) { _db = db; _tenant = tenant; _log = log; } public async Task PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct) { await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var mt = await _db.MoneyTransactions .Include(m => m.Account) .Include(m => m.Links) .ThenInclude(l => l.Supply) .Include(m => m.Links) .ThenInclude(l => l.SupplierReturn) .FirstOrDefaultAsync(m => m.Id == transactionId, ct); if (mt is null) return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден."); if (mt.Status == MoneyTransactionStatus.Posted) return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyPosted, "already-posted", "Документ уже проведён."); // Валидация валюты счёта и документа. if (mt.Account.CurrencyId != mt.CurrencyId) return MoneyServiceResult.Fail(MoneyServiceStatus.WrongCurrency, "wrong-currency", "Валюта документа не совпадает с валютой счёта."); if (!mt.Account.IsActive) return MoneyServiceResult.Fail(MoneyServiceStatus.AccountInactive, "account-inactive", "Счёт/касса архивированы — проводить движения по ним нельзя."); if (mt.Amount <= 0) return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive", "Сумма платежа должна быть больше нуля."); // Сумма связей не может превышать сумму документа. var linkedTotal = mt.Links.Sum(l => l.LinkedSum); if (linkedTotal > mt.Amount + 0.0001m) return MoneyServiceResult.Fail(MoneyServiceStatus.LinkedSumMismatch, "linked-sum-exceeds-amount", $"Сумма разноски ({linkedTotal:0.##}) больше суммы документа ({mt.Amount:0.##})."); // Пересчёт PayedSum на каждом целевом документе — защищён инвариантом. foreach (var link in mt.Links) { if (link.SupplyId is Guid sId) { var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct); var newPayed = supply.PayedSum + link.LinkedSum; if (newPayed > supply.Total + 0.0001m) return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid", $"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}."); supply.PayedSum = newPayed; supply.UpdatedAt = DateTime.UtcNow; } else if (link.SupplierReturnId is Guid srId) { var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct); var newPayed = sr.PayedSum + link.LinkedSum; if (newPayed > sr.Total + 0.0001m) return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid", $"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}."); sr.PayedSum = newPayed; sr.UpdatedAt = DateTime.UtcNow; } } mt.Status = MoneyTransactionStatus.Posted; mt.PostedAt = DateTime.UtcNow; mt.PostedByUserId = currentUserId; mt.UpdatedAt = DateTime.UtcNow; // Для TransferOut атомарно проводим парный TransferIn if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut) { var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct); if (pair is not null && pair.Status == MoneyTransactionStatus.Draft) { pair.Status = MoneyTransactionStatus.Posted; pair.PostedAt = mt.PostedAt; pair.PostedByUserId = currentUserId; pair.UpdatedAt = mt.UpdatedAt; } } await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _log.LogInformation("MoneyTransaction {Number} posted by {UserId}", mt.Number, currentUserId); return MoneyServiceResult.Ok(); } public async Task UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct) { await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var mt = await _db.MoneyTransactions .Include(m => m.Links) .FirstOrDefaultAsync(m => m.Id == transactionId, ct); if (mt is null) return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден."); if (mt.Status == MoneyTransactionStatus.Draft) return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyDraft, "already-draft", "Документ уже черновик."); foreach (var link in mt.Links) { if (link.SupplyId is Guid sId) { var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct); supply.PayedSum = Math.Max(0, supply.PayedSum - link.LinkedSum); supply.UpdatedAt = DateTime.UtcNow; } else if (link.SupplierReturnId is Guid srId) { var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct); sr.PayedSum = Math.Max(0, sr.PayedSum - link.LinkedSum); sr.UpdatedAt = DateTime.UtcNow; } } mt.Status = MoneyTransactionStatus.Draft; mt.PostedAt = null; mt.PostedByUserId = null; mt.UpdatedAt = DateTime.UtcNow; if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut) { var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct); if (pair is not null && pair.Status == MoneyTransactionStatus.Posted) { pair.Status = MoneyTransactionStatus.Draft; pair.PostedAt = null; pair.PostedByUserId = null; pair.UpdatedAt = mt.UpdatedAt; } } await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _log.LogInformation("MoneyTransaction {Number} unposted by {UserId}", mt.Number, currentUserId); return MoneyServiceResult.Ok(); } public async Task OpenShiftAsync(Guid retailPointId, Guid cashAccountId, Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct) { if (openingFloat < 0) return new(MoneyServiceStatus.ValidationFailed, null, null, "opening-float-negative", "Размен не может быть отрицательным."); await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var existing = await _db.CashShifts.AnyAsync( s => s.RetailPointId == retailPointId && s.Status == CashShiftStatus.Open, ct); if (existing) return new(MoneyServiceStatus.ShiftAlreadyOpen, null, null, "shift-already-open", "На этой точке уже открыта смена."); var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == cashAccountId, ct); if (cash is null || cash.Kind != MoneyAccountKind.Cash) return new(MoneyServiceStatus.ValidationFailed, null, null, "cash-account-invalid", "Счёт для смены должен быть типа «Касса»."); if (!cash.IsActive) return new(MoneyServiceStatus.AccountInactive, null, null, "account-inactive", "Касса архивирована."); if (acquiringAccountId is Guid aid) { var acq = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == aid, ct); if (acq is null || acq.Kind != MoneyAccountKind.Acquiring) return new(MoneyServiceStatus.ValidationFailed, null, null, "acquiring-account-invalid", "Счёт эквайринга должен быть типа «Эквайринг»."); if (!acq.IsActive) return new(MoneyServiceStatus.AccountInactive, null, null, "account-inactive", "Счёт эквайринга архивирован."); } var orgId = _tenant.OrganizationId ?? Guid.Empty; var now = DateTime.UtcNow; var shift = new CashShift { OrganizationId = orgId, Number = await MoneyNumbering.NextShiftNumberAsync(_db, orgId, now.Year, ct), Status = CashShiftStatus.Open, RetailPointId = retailPointId, CashAccountId = cashAccountId, AcquiringAccountId = acquiringAccountId, CashierUserId = cashierUserId, OpenedAt = now, OpeningFloat = openingFloat, }; _db.CashShifts.Add(shift); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _log.LogInformation("CashShift {Number} opened at {RetailPointId} by {CashierUserId}", shift.Number, retailPointId, cashierUserId); return new(MoneyServiceStatus.Ok, shift.Id, shift.Number, null, null); } public async Task CloseShiftAsync(Guid shiftId, decimal actualCash, Guid closedByUserId, string? notes, CancellationToken ct) { if (actualCash < 0) return new(MoneyServiceStatus.ValidationFailed, 0, 0, "actual-cash-negative", "Фактическая сумма наличных не может быть отрицательной."); await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var shift = await _db.CashShifts .Include(s => s.DrawerOperations) .FirstOrDefaultAsync(s => s.Id == shiftId, ct); if (shift is null) return new(MoneyServiceStatus.NotFound, 0, 0, "not-found", "Смена не найдена."); if (shift.Status == CashShiftStatus.Closed) return new(MoneyServiceStatus.ShiftNotOpen, shift.ExpectedCash, shift.CashDifference, "shift-already-closed", "Смена уже закрыта."); // Снапшоты продаж/возвратов в периоде смены (используем Id'шник // смены на RetailSale). var sales = await _db.RetailSales .Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted) .ToListAsync(ct); var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash); var salesCard = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCard); var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash); var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard); var drawerIn = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount); var drawerOut = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount); var expected = shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut; shift.SalesCash = salesCash; shift.SalesCard = salesCard; shift.ReturnsCash = returnsCash; shift.ReturnsCard = returnsCard; shift.DrawerIn = drawerIn; shift.DrawerOut = drawerOut; shift.ExpectedCash = expected; shift.ActualCash = actualCash; shift.CashDifference = actualCash - expected; shift.Status = CashShiftStatus.Closed; shift.ClosedAt = DateTime.UtcNow; shift.ClosedByUserId = closedByUserId; shift.Notes = notes; shift.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}", shift.Number, closedByUserId, expected, actualCash, shift.CashDifference); return new(MoneyServiceStatus.Ok, expected, shift.CashDifference, null, null); } public async Task AddDrawerOperationAsync(Guid shiftId, CashDrawerDirection direction, decimal amount, string reason, Guid byUserId, CancellationToken ct) { if (amount <= 0) return new(MoneyServiceStatus.ValidationFailed, null, "amount-must-be-positive", "Сумма должна быть больше нуля."); if (string.IsNullOrWhiteSpace(reason)) return new(MoneyServiceStatus.ValidationFailed, null, "reason-required", "Причина обязательна."); var shift = await _db.CashShifts.FirstOrDefaultAsync(s => s.Id == shiftId, ct); if (shift is null) return new(MoneyServiceStatus.NotFound, null, "not-found", "Смена не найдена."); if (shift.Status != CashShiftStatus.Open) return new(MoneyServiceStatus.ShiftNotOpen, null, "shift-not-open", "Смена закрыта — добавлять операции нельзя."); var op = new CashDrawerOperation { OrganizationId = shift.OrganizationId, CashShiftId = shift.Id, Direction = direction, Amount = amount, Reason = reason.Trim(), At = DateTime.UtcNow, ByUserId = byUserId, }; _db.CashDrawerOperations.Add(op); await _db.SaveChangesAsync(ct); return new(MoneyServiceStatus.Ok, op.Id, null, null); } }