OpenShiftAsync проверяет RetailPoint.IsActive (retail-point-inactive) и привязку кассира к точке (retail-point-not-assigned; у кого привязок нет — на любой точке). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
17 KiB
C#
339 lines
17 KiB
C#
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);
|
||
}
|
||
}
|