food-market/src/food-market.infrastructure/Money/MoneyService.cs
nns 8785fd60d1 fix(money): открытие смены на неактивной/непривязанной точке продаж → отказ
OpenShiftAsync проверяет RetailPoint.IsActive (retail-point-inactive) и привязку
кассира к точке (retail-point-not-assigned; у кого привязок нет — на любой точке).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:10:11 +05:00

339 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Единая реализация <see cref="IMoneyService"/>. Внутри — Serializable
/// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте
/// Postgres падает в 40001, миддлварь (<c>SerializationConflictMiddleware</c>)
/// преобразует в 409. Любые изменения <c>Supply.PayedSum</c> / <c>SupplierReturn.PayedSum</c>
/// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно
/// защищён.</summary>
public sealed class MoneyService : IMoneyService
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<MoneyService> _log;
public MoneyService(AppDbContext db, ITenantContext tenant, ILogger<MoneyService> log)
{
_db = db;
_tenant = tenant;
_log = log;
}
public async Task<MoneyServiceResult> 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<MoneyServiceResult> 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<CashShiftOpenResult> 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<CashShiftCloseResult> 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<CashDrawerOperationResult> 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);
}
}