Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API: - Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток, привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance). - Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена в Serializable-транзакции через MoneyService). - Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink, SuppliesController отдаёт PaymentStatus + фильтр paymentStatus. - Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts. - Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual, Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController. - Отчёт «Деньги»: balances/movement с проверкой инварианта остатков. Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
15 KiB
C#
312 lines
15 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 (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<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 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 = 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<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);
|
||
}
|
||
}
|