diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs index 9f03558..e77604c 100644 --- a/src/food-market.api/Controllers/Money/CashShiftsController.cs +++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs @@ -166,7 +166,13 @@ public async Task> 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 }); } diff --git a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs index 3396536..a9b6c97 100644 --- a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs @@ -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(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, diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 4f0f6d3..db7e878 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -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> 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 ReadVatRateAsync(string countryCode, CancellationTok o.ShowMarkedOnProduct, o.ShowMinMaxStock, o.AllowFractionalPrices, + o.AllowNegativeBalance, o.ShowReferencePriceOnProduct, o.ShowCountryOfOriginOnProduct, o.ShowDescriptionOnProduct); diff --git a/src/food-market.application/Money/IMoneyService.cs b/src/food-market.application/Money/IMoneyService.cs index 5243f54..3ce372a 100644 --- a/src/food-market.application/Money/IMoneyService.cs +++ b/src/food-market.application/Money/IMoneyService.cs @@ -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) diff --git a/src/food-market.domain/Money/MoneyTransaction.cs b/src/food-market.domain/Money/MoneyTransaction.cs index 860f90b..1d7c238 100644 --- a/src/food-market.domain/Money/MoneyTransaction.cs +++ b/src/food-market.domain/Money/MoneyTransaction.cs @@ -53,10 +53,33 @@ public enum MoneyTransactionStatus /// отмена = отдельный флаг =Draft + пересчёт /// Supply.PayedSum в сторону уменьшения, в одной транзакции /// Postgres (см. MoneyService.Unpost). +/// Происхождение/тип движения денег — для отчёта «Деньги» и для того, +/// чтобы отличать движения смены (создаются при закрытии кассовой смены) от +/// ручных платежей/ордеров. Manual — обычный платёж/ордер/перевод. +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; } + /// Тип движения. Manual — ручной платёж/ордер/перевод; остальные — + /// движения кассовой смены (материализуются при закрытии). + public MoneyTransactionCategory Category { get; set; } = MoneyTransactionCategory.Manual; + + /// Смена, породившая это движение (для движений смены). null — ручной документ. + public Guid? CashShiftId { get; set; } + /// Человекочитаемый номер, уникальный в орге. Генерим по шаблону /// Д-{year}-{seq6} (Д как «деньги»), общий счётчик на все виды /// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 3ea8b2c..3c60dcc 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -73,6 +73,11 @@ public class Organization : Entity /// дробное через API. public bool AllowFractionalPrices { get; set; } + /// Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.: + /// расход больше остатка (оплата, выплата из смены) отклоняется с 409. + /// Менять может только администратор организации. + public bool AllowNegativeBalance { get; set; } + /// Показывать ли в карточке товара поле «Эталонная цена». /// Default: true. public bool ShowReferencePriceOnProduct { get; set; } = true; diff --git a/src/food-market.infrastructure/Money/MoneyService.cs b/src/food-market.infrastructure/Money/MoneyService.cs index 153d10f..668e80a 100644 --- a/src/food-market.infrastructure/Money/MoneyService.cs +++ b/src/food-market.infrastructure/Money/MoneyService.cs @@ -58,6 +58,21 @@ public async Task 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 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 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 UnpostAsync(Guid transactionId, Guid curre await _db.SaveChangesAsync(ct); return new(MoneyServiceStatus.Ok, op.Id, null, null); } + + /// Остаток счёта = OpeningBalance + Σ приходов − Σ расходов по + /// проведённым movements (кроме исключённого документа). + private async Task 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); + } + + /// Наличные в кассе открытой смены: размен + продажи нал − возвраты + /// нал + внесения − выплаты (на текущий момент). + private async Task 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; + } + + /// Материализует финансовые движения закрытой смены в money_transactions + /// (Posted) — продажи/возвраты нал (касса), внесения/выплаты (касса), + /// излишек/недостача (касса), продажи/возвраты по карте (эквайринг). + /// Идемпотентно по CashShiftId. Размен (OpeningFloat) не материализуется — + /// эти деньги уже были в кассе до смены. + 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, "Возвраты по карте"); + } + } } diff --git a/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs index 227534a..fd3395b 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs @@ -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(e => diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261008030000_Phase34_ShiftMoneyMovements.cs b/src/food-market.infrastructure/Persistence/Migrations/20261008030000_Phase34_ShiftMoneyMovements.cs new file mode 100644 index 0000000..2118882 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261008030000_Phase34_ShiftMoneyMovements.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase34 — движения кассовой смены попадают в деньги. + /// + /// Добавляет: + /// + /// money_transactions.Category (integer, DEFAULT 0 = Manual) — + /// тип движения: розничные продажи/возвраты нал/карта, внесения/выплаты, + /// излишек/недостача смены. + /// money_transactions.CashShiftId (uuid, nullable) — + /// смена, породившая движение (материализуется при закрытии). + /// organizations.AllowNegativeBalance (boolean, DEFAULT false) — + /// разрешить ли уход счетов в минус. + /// + /// + /// Идемпотентно (IF NOT EXISTS). Данные не пересчитываем: остаток счёта + /// вычисляемый (Opening + Σ Posted money_transactions); движения прошлых + /// закрытых смен материализуются ретроспективно отдельным backfill-методом + /// в DevDataSeeder (idempotent, по CashShiftId). + [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""; + "); + } + } +}