fix(money): движения смены попадают в остатки счетов + запрет отрицательного остатка

Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток
кассы (касса уходила в −, эквайринг оставался 0). Теперь при закрытии смены
MoneyService.MaterializeShiftMovementsAsync создаёт Posted-движения money_transactions
по типам (Category): продажи нал/карта, возвраты нал/карта, внесение, выплата,
излишек/недостача — привязанные к смене, идемпотентно. Остаток кассы/эквайринга и
отчёт «Деньги» теперь верны автоматически (всё читает money_transactions).

Отрицательный остаток: расход (CashOut/PaymentOut/TransferOut) больше остатка счёта
и выплата из смены больше наличных смены → 409 insufficient-funds; настройка
Organization.AllowNegativeBalance (по умолч. выкл., меняет только Admin).

Миграция Phase34 (idempotent): money_transactions.Category/.CashShiftId,
organizations.AllowNegativeBalance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 03:11:09 +05:00
parent b6c4d6056d
commit 1e6d7fb2ab
9 changed files with 224 additions and 1 deletions

View file

@ -166,7 +166,13 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
var userId = CurrentUserId();
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.NotFound ? 404 : 400,
return Problem(r.Message,
statusCode: r.Status switch
{
MoneyServiceStatus.NotFound => 404,
MoneyServiceStatus.InsufficientFunds => 409,
_ => 400,
},
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.OperationId });
}

View file

@ -44,6 +44,7 @@ public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? Supplie
public record MoneyTxRow(
Guid Id, string Number, DateTime Date,
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
MoneyTransactionCategory Category,
Guid AccountId, string AccountName,
Guid? CounterpartyId, string? CounterpartyName,
Guid? ExpenseItemId, string? ExpenseItemName,
@ -100,6 +101,7 @@ public record MoneyTxInput(
.Select(x => new
{
x.Id, x.Number, x.Date, x.Kind, x.Status,
x.Category,
x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "",
x.CounterpartyId, CounterpartyName = x.CounterpartyId == null ? null : _db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault(),
x.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(),
@ -124,6 +126,7 @@ public record MoneyTxInput(
var items = rows.Select(r => new MoneyTxRow(
r.Id, r.Number, r.Date, r.Kind, r.Status,
r.Category,
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
r.ExpenseItemId, r.ExpenseItemName,
r.CurrencyId, r.CurrencyCode,
@ -340,6 +343,7 @@ private Guid CurrentUserId()
MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.InsufficientFunds => Conflict(new { code = r.ErrorCode, message = r.Message }),
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
};
@ -360,6 +364,7 @@ private Guid CurrentUserId()
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
return new MoneyTxRow(
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
mt.Category,
mt.AccountId, account, mt.CounterpartyId, counterparty,
mt.ExpenseItemId, exp,
mt.CurrencyId, currency,

View file

@ -35,6 +35,7 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
@ -49,6 +50,7 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
@ -87,6 +89,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.AllowNegativeBalance = input.AllowNegativeBalance;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
@ -118,6 +121,7 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct,
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.AllowNegativeBalance,
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct);

View file

@ -50,6 +50,7 @@ public enum MoneyServiceStatus
LinkedSumMismatch = 9,
WrongCurrency = 10,
AccountInactive = 11,
InsufficientFunds = 12,
}
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)

View file

@ -53,10 +53,33 @@ public enum MoneyTransactionStatus
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
/// <summary>Происхождение/тип движения денег — для отчёта «Деньги» и для того,
/// чтобы отличать движения смены (создаются при закрытии кассовой смены) от
/// ручных платежей/ордеров. Manual — обычный платёж/ордер/перевод.</summary>
public enum MoneyTransactionCategory
{
Manual = 0,
RetailSalesCash = 1, // розничные продажи наличными (приход в кассу смены)
RetailReturnsCash = 2, // возвраты покупателям наличными (расход из кассы смены)
DrawerIn = 3, // внесение в кассу смены
DrawerOut = 4, // выплата из кассы смены
ShiftSurplus = 5, // излишек при закрытии смены (факт > должно быть)
ShiftShortage = 6, // недостача при закрытии смены (факт < должно быть)
RetailSalesCard = 7, // продажи картой (приход на эквайринг смены)
RetailReturnsCard = 8, // возвраты по карте (расход с эквайринга смены)
}
public class MoneyTransaction : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
/// <summary>Тип движения. Manual — ручной платёж/ордер/перевод; остальные —
/// движения кассовой смены (материализуются при закрытии).</summary>
public MoneyTransactionCategory Category { get; set; } = MoneyTransactionCategory.Manual;
/// <summary>Смена, породившая это движение (для движений смены). null — ручной документ.</summary>
public Guid? CashShiftId { get; set; }
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли

View file

@ -73,6 +73,11 @@ public class Organization : Entity
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
/// Менять может только администратор организации.</summary>
public bool AllowNegativeBalance { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true;

View file

@ -58,6 +58,21 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
"Сумма платежа должна быть больше нуля.");
// Отрицательный остаток: расход больше остатка счёта → отказ,
// если орг не разрешила уходить в минус.
if (mt.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut)
{
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == mt.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
if (!allowNeg)
{
var balance = await AccountBalanceAsync(mt.AccountId, mt.Id, ct);
if (balance < mt.Amount - MoneyMath.Eps)
return MoneyServiceResult.Fail(MoneyServiceStatus.InsufficientFunds, "insufficient-funds",
$"На счёте «{mt.Account.Name}» недостаточно денег: остаток {balance:0.##} ₸, нужно {mt.Amount:0.##} ₸.");
}
}
// Сумма связей не может превышать сумму документа.
var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
if (linkedTotal > mt.Amount + 0.0001m)
@ -296,6 +311,11 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
shift.Notes = notes;
shift.UpdatedAt = DateTime.UtcNow;
// Материализуем движения смены в money_transactions → остаток счетов и
// отчёт «Деньги» теперь учитывают продажи/возвраты/внесения/выплаты/расхождение.
await MaterializeShiftMovementsAsync(shift, salesCash, salesCard, returnsCash, returnsCard,
drawerIn, drawerOut, shift.CashDifference, ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
@ -321,6 +341,20 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
return new(MoneyServiceStatus.ShiftNotOpen, null,
"shift-not-open", "Смена закрыта — добавлять операции нельзя.");
// Выплата больше наличных в кассе смены → отказ (если орг не разрешает минус).
if (direction == CashDrawerDirection.Out)
{
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == shift.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
if (!allowNeg)
{
var cashOnHand = await ShiftCashOnHandAsync(shift, ct);
if (cashOnHand < amount - MoneyMath.Eps)
return new(MoneyServiceStatus.InsufficientFunds, null, "insufficient-funds",
$"В кассе смены недостаточно наличных: есть {cashOnHand:0.##} ₸, нужно {amount:0.##} ₸.");
}
}
var op = new CashDrawerOperation
{
OrganizationId = shift.OrganizationId,
@ -335,4 +369,91 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
await _db.SaveChangesAsync(ct);
return new(MoneyServiceStatus.Ok, op.Id, null, null);
}
/// <summary>Остаток счёта = OpeningBalance + Σ приходов − Σ расходов по
/// проведённым movements (кроме исключённого документа).</summary>
private async Task<decimal> AccountBalanceAsync(Guid accountId, Guid? excludeTxId, CancellationToken ct)
{
var opening = await _db.MoneyAccounts.Where(a => a.Id == accountId)
.Select(a => a.OpeningBalance).FirstAsync(ct);
var q = _db.MoneyTransactions
.Where(m => m.AccountId == accountId && m.Status == MoneyTransactionStatus.Posted);
if (excludeTxId is Guid ex) q = q.Where(m => m.Id != ex);
var rows = await q.Select(m => new { m.Kind, m.Amount }).ToListAsync(ct);
var inSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentIn or MoneyTransactionKind.CashIn or MoneyTransactionKind.TransferIn).Sum(r => r.Amount);
var outSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut).Sum(r => r.Amount);
return MoneyMath.AccountBalance(opening, inSum, outSum);
}
/// <summary>Наличные в кассе открытой смены: размен + продажи нал − возвраты
/// нал + внесения − выплаты (на текущий момент).</summary>
private async Task<decimal> ShiftCashOnHandAsync(CashShift shift, CancellationToken ct)
{
var sales = await _db.RetailSales
.Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted)
.Select(s => new { s.IsReturn, s.PaidCash }).ToListAsync(ct);
var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash);
var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash);
var ops = await _db.CashDrawerOperations.Where(o => o.CashShiftId == shift.Id)
.Select(o => new { o.Direction, o.Amount }).ToListAsync(ct);
var drawerIn = ops.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount);
var drawerOut = ops.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount);
return shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut;
}
/// <summary>Материализует финансовые движения закрытой смены в money_transactions
/// (Posted) — продажи/возвраты нал (касса), внесения/выплаты (касса),
/// излишек/недостача (касса), продажи/возвраты по карте (эквайринг).
/// Идемпотентно по CashShiftId. Размен (OpeningFloat) не материализуется —
/// эти деньги уже были в кассе до смены.</summary>
private async Task MaterializeShiftMovementsAsync(CashShift shift,
decimal salesCash, decimal salesCard, decimal returnsCash, decimal returnsCard,
decimal drawerIn, decimal drawerOut, decimal difference, CancellationToken ct)
{
var existing = await _db.MoneyTransactions.Where(m => m.CashShiftId == shift.Id).ToListAsync(ct);
if (existing.Count > 0) _db.MoneyTransactions.RemoveRange(existing);
var year = (shift.ClosedAt ?? DateTime.UtcNow).Year;
var baseNum = await MoneyNumbering.NextMoneyTxNumberAsync(_db, shift.OrganizationId, year, ct);
var seq = int.Parse(baseNum.Split('-')[2]);
var cashCur = await _db.MoneyAccounts.Where(a => a.Id == shift.CashAccountId).Select(a => a.CurrencyId).FirstAsync(ct);
Guid? acqId = shift.AcquiringAccountId;
var acqCur = acqId is Guid a ? await _db.MoneyAccounts.Where(x => x.Id == a).Select(x => x.CurrencyId).FirstAsync(ct) : Guid.Empty;
void Add(Guid accountId, Guid currencyId, MoneyTransactionKind kind, MoneyTransactionCategory cat, decimal amount, string purpose)
{
if (amount <= MoneyMath.Eps) return;
_db.MoneyTransactions.Add(new MoneyTransaction
{
OrganizationId = shift.OrganizationId,
Number = $"Д-{year}-{seq++:D6}",
Date = shift.ClosedAt ?? DateTime.UtcNow,
Kind = kind,
Category = cat,
Status = MoneyTransactionStatus.Posted,
AccountId = accountId,
CurrencyId = currencyId,
Amount = amount,
CashShiftId = shift.Id,
Purpose = $"{purpose} (смена {shift.Number})",
PostedAt = DateTime.UtcNow,
PostedByUserId = shift.ClosedByUserId,
});
}
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.RetailSalesCash, salesCash, "Розничные продажи (нал)");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.RetailReturnsCash, returnsCash, "Возвраты покупателям (нал)");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.DrawerIn, drawerIn, "Внесение в кассу");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.DrawerOut, drawerOut, "Выплата из кассы");
if (difference > MoneyMath.Eps)
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.ShiftSurplus, difference, "Излишек смены");
else if (difference < -MoneyMath.Eps)
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.ShiftShortage, -difference, "Недостача смены");
if (acqId is Guid aid)
{
Add(aid, acqCur, MoneyTransactionKind.PaymentIn, MoneyTransactionCategory.RetailSalesCard, salesCard, "Продажи картой");
Add(aid, acqCur, MoneyTransactionKind.PaymentOut, MoneyTransactionCategory.RetailReturnsCard, returnsCard, "Возвраты по карте");
}
}
}

View file

@ -72,6 +72,9 @@ public static void ConfigureMoney(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
.HasFilter("\"CounterpartyId\" IS NOT NULL");
// Движения смены (материализуются при закрытии) ищем по смене —
// идемпотентность и отчёт по смене.
e.HasIndex(x => x.CashShiftId).HasFilter("\"CashShiftId\" IS NOT NULL");
});
b.Entity<MoneyTransactionLink>(e =>

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase34 — движения кассовой смены попадают в деньги.
///
/// Добавляет:
/// <list type="bullet">
/// <item>money_transactions.<c>Category</c> (integer, DEFAULT 0 = Manual) —
/// тип движения: розничные продажи/возвраты нал/карта, внесения/выплаты,
/// излишек/недостача смены.</item>
/// <item>money_transactions.<c>CashShiftId</c> (uuid, nullable) —
/// смена, породившая движение (материализуется при закрытии).</item>
/// <item>organizations.<c>AllowNegativeBalance</c> (boolean, DEFAULT false) —
/// разрешить ли уход счетов в минус.</item>
/// </list>
///
/// Идемпотентно (IF NOT EXISTS). Данные не пересчитываем: остаток счёта
/// вычисляемый (Opening + Σ Posted money_transactions); движения прошлых
/// закрытых смен материализуются ретроспективно отдельным backfill-методом
/// в DevDataSeeder (idempotent, по CashShiftId).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261008030000_Phase34_ShiftMoneyMovements")]
public partial class Phase34_ShiftMoneyMovements : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.money_transactions
ADD COLUMN IF NOT EXISTS ""Category"" integer NOT NULL DEFAULT 0;
ALTER TABLE public.money_transactions
ADD COLUMN IF NOT EXISTS ""CashShiftId"" uuid;
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_CashShiftId""
ON public.money_transactions (""CashShiftId"")
WHERE ""CashShiftId"" IS NOT NULL;
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""AllowNegativeBalance"" boolean NOT NULL DEFAULT false;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP INDEX IF EXISTS public.""IX_money_transactions_CashShiftId"";
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""CashShiftId"";
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""Category"";
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""AllowNegativeBalance"";
");
}
}
}