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 (!MoneyMath.CanApplyPayment(supply.PayedSum, link.LinkedSum, supply.Total))
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 (!MoneyMath.CanApplyPayment(sr.PayedSum, link.LinkedSum, sr.Total))
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 rp = await _db.RetailPoints.FirstOrDefaultAsync(r => r.Id == retailPointId, ct);
if (rp is null)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"retail-point-not-found", "Точка продаж не найдена.");
if (!rp.IsActive)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"retail-point-inactive", "Точка продаж деактивирована — открыть смену нельзя.");
// Привязка кассира к точкам: если у сотрудника настроены привязки к точкам
// продаж, открывать смену он может ТОЛЬКО на закреплённой точке. У кого
// привязок нет (администратор/управляющий) — на любой.
var emp = await _db.Employees.FirstOrDefaultAsync(e => e.UserId == cashierUserId, ct);
if (emp is not null)
{
var hasAnyAssignment = await _db.EmployeeRetailPointAssignments.AnyAsync(a => a.EmployeeId == emp.Id, ct);
if (hasAnyAssignment)
{
var assigned = await _db.EmployeeRetailPointAssignments
.AnyAsync(a => a.EmployeeId == emp.Id && a.RetailPointId == retailPointId, ct);
if (!assigned)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"retail-point-not-assigned", "Вы не закреплены за этой точкой продаж — открыть смену нельзя.");
}
}
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 = MoneyMath.ExpectedCash(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 = MoneyMath.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);
}
}