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));
}
}