using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Money; using foodmarket.Domain.Money; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Money; /// Отчёты модуля «Деньги»: остатки по счетам, движение за период, /// долги поставщикам. По образцу МойСклад /report/money/plotseries и /// /report/counterparty (упрощённо). [ApiController] [Authorize] [Route("api/money/reports")] public class MoneyReportsController : ControllerBase { private readonly AppDbContext _db; private readonly ITenantContext _tenant; public MoneyReportsController(AppDbContext db, ITenantContext tenant) { _db = db; _tenant = tenant; } public record AccountBalanceRow(Guid AccountId, string Name, MoneyAccountKind Kind, string CurrencyCode, decimal OpeningBalance, decimal CurrentBalance, bool IsActive); public record BalancesReport(IReadOnlyList Accounts, decimal TotalByCurrencySum); [HttpGet("balances"), RequiresPermission("MoneyView")] public async Task> Balances(CancellationToken ct) { var accounts = await _db.MoneyAccounts.AsNoTracking() .OrderBy(a => a.Kind).ThenBy(a => a.Name) .ToListAsync(ct); var ids = accounts.Select(a => a.Id).ToList(); var movements = await _db.MoneyTransactions.AsNoTracking() .Where(m => ids.Contains(m.AccountId) && m.Status == MoneyTransactionStatus.Posted) .GroupBy(m => new { m.AccountId, m.Kind }) .Select(g => new { g.Key.AccountId, g.Key.Kind, Sum = g.Sum(m => m.Amount) }) .ToListAsync(ct); var currencies = await _db.Currencies.AsNoTracking().Where(c => ids.Any()) .ToDictionaryAsync(c => c.Id, c => c.Code, ct); var rows = accounts.Select(a => { var inSum = movements.Where(m => m.AccountId == a.Id && (m.Kind == MoneyTransactionKind.PaymentIn || m.Kind == MoneyTransactionKind.CashIn || m.Kind == MoneyTransactionKind.TransferIn)).Sum(m => m.Sum); var outSum = movements.Where(m => m.AccountId == a.Id && (m.Kind == MoneyTransactionKind.PaymentOut || m.Kind == MoneyTransactionKind.CashOut || m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum); return new AccountBalanceRow(a.Id, a.Name, a.Kind, currencies.TryGetValue(a.CurrencyId, out var code) ? code : "", a.OpeningBalance, MoneyMath.AccountBalance(a.OpeningBalance, inSum, outSum), a.IsActive); }).ToList(); return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance)); } public record MoneyPoint(DateTime Date, decimal Credit, decimal Debit, decimal Balance); public record MoneyMovementReport(DateTime From, DateTime To, decimal TotalCredit, decimal TotalDebit, IReadOnlyList Series); /// Движение денежных средств за период (аналог /// /report/money/plotseries). Интервал — day/month. [HttpGet("movement"), RequiresPermission("MoneyView")] public async Task> Movement( [FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] string interval = "day", [FromQuery] Guid? accountId = null, CancellationToken ct = default) { if (to <= from) return BadRequest(new { code = "bad-range", message = "to должен быть позже from" }); var fromUtc = DateTime.SpecifyKind(from, DateTimeKind.Utc); var toUtc = DateTime.SpecifyKind(to, DateTimeKind.Utc); var q = _db.MoneyTransactions.AsNoTracking() .Where(m => m.Status == MoneyTransactionStatus.Posted && m.Date >= fromUtc && m.Date < toUtc); if (accountId is not null) q = q.Where(m => m.AccountId == accountId); var raw = await q.Select(m => new { m.Date, m.Kind, m.Amount }).ToListAsync(ct); // Переводы не считаем ни «приходом», ни «расходом» орги — они // внутри орги, не меняют совокупных денег. Фильтруем. raw = raw.Where(r => r.Kind != MoneyTransactionKind.TransferIn && r.Kind != MoneyTransactionKind.TransferOut).ToList(); Func bucket = interval switch { "month" => d => new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc), _ => d => d.Date, }; var groups = raw.GroupBy(r => bucket(r.Date)) .Select(g => new { Date = g.Key, Credit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentIn || x.Kind == MoneyTransactionKind.CashIn).Sum(x => x.Amount), Debit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentOut || x.Kind == MoneyTransactionKind.CashOut).Sum(x => x.Amount), }) .OrderBy(g => g.Date) .ToList(); var series = groups.Select(g => new MoneyPoint(g.Date, g.Credit, g.Debit, g.Credit - g.Debit)).ToList(); return new MoneyMovementReport(fromUtc, toUtc, series.Sum(s => s.Credit), series.Sum(s => s.Debit), series); } public record SupplierDebtRow( Guid SupplierId, string SupplierName, string? Bin, string? Phone, decimal SuppliesTotal, decimal ReturnsTotal, decimal PaymentsIn, decimal PaymentsOut, decimal AdjustmentDebt, decimal Debt, DateTime? OldestUnpaidDate, string? OldestUnpaidNumber); public record SupplierDebtsReport(IReadOnlyList Rows, decimal TotalDebt); /// Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок. /// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально. [HttpGet("supplier-debts"), RequiresPermission("MoneyView")] public async Task> SupplierDebts( [FromQuery] bool withDebtOnly = true, CancellationToken ct = default) { var supplies = await _db.Supplies.AsNoTracking() .Where(s => s.Status == SupplyStatus.Posted) .GroupBy(s => s.SupplierId) .Select(g => new { SupplierId = g.Key, Total = g.Sum(s => s.Total), Paid = g.Sum(s => s.PayedSum), OldestUnpaid = g.Where(s => s.PayedSum < s.Total).OrderBy(s => s.Date).Select(s => new { s.Date, s.Number }).FirstOrDefault(), }) .ToListAsync(ct); var returns = await _db.SupplierReturns.AsNoTracking() .Where(r => r.Status == SupplierReturnStatus.Posted) .GroupBy(r => r.SupplierId) .Select(g => new { SupplierId = g.Key, Total = g.Sum(r => r.Total), Paid = g.Sum(r => r.PayedSum) }) .ToListAsync(ct); var adjustments = await _db.CounterpartyAdjustments.AsNoTracking() .Where(a => a.Status == MoneyTransactionStatus.Posted) .GroupBy(a => a.CounterpartyId) .Select(g => new { CounterpartyId = g.Key, WeOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.WeOwe).Sum(a => a.Amount), TheyOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.TheyOwe).Sum(a => a.Amount), }) .ToListAsync(ct); var supplierIds = supplies.Select(s => s.SupplierId) .Union(returns.Select(r => r.SupplierId)) .Union(adjustments.Select(a => a.CounterpartyId)) .Distinct().ToList(); var suppliers = await _db.Counterparties.AsNoTracking() .Where(c => supplierIds.Contains(c.Id)) .Select(c => new { c.Id, c.Name, c.Bin, c.Phone }) .ToListAsync(ct); var rows = new List(); foreach (var sup in suppliers) { var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id); var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id); var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id); // Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны). var debt = MoneyMath.SupplierDebt( s?.Total ?? 0, s?.Paid ?? 0, r?.Total ?? 0, r?.Paid ?? 0, a?.WeOwe ?? 0, a?.TheyOwe ?? 0); if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue; rows.Add(new SupplierDebtRow( sup.Id, sup.Name, sup.Bin, sup.Phone, s?.Total ?? 0, r?.Total ?? 0, r?.Paid ?? 0, s?.Paid ?? 0, (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0), debt, s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number)); } rows = rows.OrderByDescending(x => x.Debt).ToList(); return new SupplierDebtsReport(rows, rows.Sum(x => x.Debt)); } }