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:
parent
b6c4d6056d
commit
1e6d7fb2ab
|
|
@ -166,7 +166,13 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
|
||||||
var userId = CurrentUserId();
|
var userId = CurrentUserId();
|
||||||
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
|
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
|
||||||
if (r.Status != MoneyServiceStatus.Ok)
|
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}");
|
type: $"https://food-market.kz/errors/{r.ErrorCode}");
|
||||||
return Ok(new { id = r.OperationId });
|
return Ok(new { id = r.OperationId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? Supplie
|
||||||
public record MoneyTxRow(
|
public record MoneyTxRow(
|
||||||
Guid Id, string Number, DateTime Date,
|
Guid Id, string Number, DateTime Date,
|
||||||
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
|
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
|
||||||
|
MoneyTransactionCategory Category,
|
||||||
Guid AccountId, string AccountName,
|
Guid AccountId, string AccountName,
|
||||||
Guid? CounterpartyId, string? CounterpartyName,
|
Guid? CounterpartyId, string? CounterpartyName,
|
||||||
Guid? ExpenseItemId, string? ExpenseItemName,
|
Guid? ExpenseItemId, string? ExpenseItemName,
|
||||||
|
|
@ -100,6 +101,7 @@ public record MoneyTxInput(
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id, x.Number, x.Date, x.Kind, x.Status,
|
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.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.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(),
|
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(
|
var items = rows.Select(r => new MoneyTxRow(
|
||||||
r.Id, r.Number, r.Date, r.Kind, r.Status,
|
r.Id, r.Number, r.Date, r.Kind, r.Status,
|
||||||
|
r.Category,
|
||||||
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
|
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
|
||||||
r.ExpenseItemId, r.ExpenseItemName,
|
r.ExpenseItemId, r.ExpenseItemName,
|
||||||
r.CurrencyId, r.CurrencyCode,
|
r.CurrencyId, r.CurrencyCode,
|
||||||
|
|
@ -340,6 +343,7 @@ private Guid CurrentUserId()
|
||||||
MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }),
|
MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }),
|
||||||
MoneyServiceStatus.WrongCurrency => 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.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 }),
|
_ => 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);
|
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
|
||||||
return new MoneyTxRow(
|
return new MoneyTxRow(
|
||||||
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
|
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
|
||||||
|
mt.Category,
|
||||||
mt.AccountId, account, mt.CounterpartyId, counterparty,
|
mt.AccountId, account, mt.CounterpartyId, counterparty,
|
||||||
mt.ExpenseItemId, exp,
|
mt.ExpenseItemId, exp,
|
||||||
mt.CurrencyId, currency,
|
mt.CurrencyId, currency,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ public record OrgSettingsDto(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
|
bool AllowNegativeBalance,
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
@ -49,6 +50,7 @@ public record OrgSettingsInput(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
|
bool AllowNegativeBalance,
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
@ -87,6 +89,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
||||||
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||||
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||||
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
||||||
|
o.AllowNegativeBalance = input.AllowNegativeBalance;
|
||||||
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||||
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
||||||
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
||||||
|
|
@ -118,6 +121,7 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.ShowMarkedOnProduct,
|
o.ShowMarkedOnProduct,
|
||||||
o.ShowMinMaxStock,
|
o.ShowMinMaxStock,
|
||||||
o.AllowFractionalPrices,
|
o.AllowFractionalPrices,
|
||||||
|
o.AllowNegativeBalance,
|
||||||
o.ShowReferencePriceOnProduct,
|
o.ShowReferencePriceOnProduct,
|
||||||
o.ShowCountryOfOriginOnProduct,
|
o.ShowCountryOfOriginOnProduct,
|
||||||
o.ShowDescriptionOnProduct);
|
o.ShowDescriptionOnProduct);
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ public enum MoneyServiceStatus
|
||||||
LinkedSumMismatch = 9,
|
LinkedSumMismatch = 9,
|
||||||
WrongCurrency = 10,
|
WrongCurrency = 10,
|
||||||
AccountInactive = 11,
|
AccountInactive = 11,
|
||||||
|
InsufficientFunds = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)
|
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,33 @@ public enum MoneyTransactionStatus
|
||||||
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
|
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
|
||||||
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
|
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
|
||||||
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
|
/// 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 class MoneyTransaction : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
public uint Xmin { get; set; }
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Тип движения. Manual — ручной платёж/ордер/перевод; остальные —
|
||||||
|
/// движения кассовой смены (материализуются при закрытии).</summary>
|
||||||
|
public MoneyTransactionCategory Category { get; set; } = MoneyTransactionCategory.Manual;
|
||||||
|
|
||||||
|
/// <summary>Смена, породившая это движение (для движений смены). null — ручной документ.</summary>
|
||||||
|
public Guid? CashShiftId { get; set; }
|
||||||
|
|
||||||
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
|
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
|
||||||
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
|
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
|
||||||
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
|
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ public class Organization : Entity
|
||||||
/// дробное через API.</summary>
|
/// дробное через API.</summary>
|
||||||
public bool AllowFractionalPrices { get; set; }
|
public bool AllowFractionalPrices { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
|
||||||
|
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
|
||||||
|
/// Менять может только администратор организации.</summary>
|
||||||
|
public bool AllowNegativeBalance { get; set; }
|
||||||
|
|
||||||
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
||||||
/// Default: true.</summary>
|
/// Default: true.</summary>
|
||||||
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
|
||||||
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
|
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);
|
var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
|
||||||
if (linkedTotal > mt.Amount + 0.0001m)
|
if (linkedTotal > mt.Amount + 0.0001m)
|
||||||
|
|
@ -296,6 +311,11 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
shift.Notes = notes;
|
shift.Notes = notes;
|
||||||
shift.UpdatedAt = DateTime.UtcNow;
|
shift.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Материализуем движения смены в money_transactions → остаток счетов и
|
||||||
|
// отчёт «Деньги» теперь учитывают продажи/возвраты/внесения/выплаты/расхождение.
|
||||||
|
await MaterializeShiftMovementsAsync(shift, salesCash, salesCard, returnsCash, returnsCard,
|
||||||
|
drawerIn, drawerOut, shift.CashDifference, ct);
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
await tx.CommitAsync(ct);
|
await tx.CommitAsync(ct);
|
||||||
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
|
_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,
|
return new(MoneyServiceStatus.ShiftNotOpen, null,
|
||||||
"shift-not-open", "Смена закрыта — добавлять операции нельзя.");
|
"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
|
var op = new CashDrawerOperation
|
||||||
{
|
{
|
||||||
OrganizationId = shift.OrganizationId,
|
OrganizationId = shift.OrganizationId,
|
||||||
|
|
@ -335,4 +369,91 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return new(MoneyServiceStatus.Ok, op.Id, null, null);
|
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, "Возвраты по карте");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.AccountId, x.Date });
|
||||||
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
|
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
|
||||||
.HasFilter("\"CounterpartyId\" IS NOT NULL");
|
.HasFilter("\"CounterpartyId\" IS NOT NULL");
|
||||||
|
// Движения смены (материализуются при закрытии) ищем по смене —
|
||||||
|
// идемпотентность и отчёт по смене.
|
||||||
|
e.HasIndex(x => x.CashShiftId).HasFilter("\"CashShiftId\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Entity<MoneyTransactionLink>(e =>
|
b.Entity<MoneyTransactionLink>(e =>
|
||||||
|
|
|
||||||
|
|
@ -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"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue