food-market/src/food-market.api/Controllers/Money/MoneyReportsController.cs
nns 524f8f41b1
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(money): акт сверки с поставщиком — печать/PDF (п.7)
GET /api/money/reports/supplier-reconciliation?supplierId=&from=&to=: начальное
сальдо (до from), движения периода (приёмка +долг, возврат/оплата −долг) с бегущим
сальдо, конечное сальдо. Веб-страница /money/supplier-debts/:id/reconciliation —
печатная форма акта (window.print()/PDF) + кнопка «Акт сверки» в долгах поставщикам.
Integration-тест: приёмка как debit, конечное сальдо.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 06:39:08 +05:00

246 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Отчёты модуля «Деньги»: остатки по счетам, движение за период,
/// долги поставщикам. По образцу МойСклад <c>/report/money/plotseries</c> и
/// <c>/report/counterparty</c> (упрощённо).</summary>
[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<AccountBalanceRow> Accounts, decimal TotalByCurrencySum);
[HttpGet("balances"), RequiresPermission("MoneyView")]
public async Task<ActionResult<BalancesReport>> 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<MoneyPoint> Series);
/// <summary>Движение денежных средств за период (аналог
/// <c>/report/money/plotseries</c>). Интервал — day/month.</summary>
[HttpGet("movement"), RequiresPermission("MoneyView")]
public async Task<ActionResult<MoneyMovementReport>> 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<DateTime, DateTime> 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 ReconLine(DateTime Date, string DocType, string? DocNumber, decimal Debit, decimal Credit, decimal Balance);
public record SupplierReconciliation(
Guid SupplierId, string SupplierName, string? Bin,
DateTime From, DateTime To, decimal OpeningBalance, decimal ClosingBalance,
IReadOnlyList<ReconLine> Rows);
/// <summary>Акт сверки с поставщиком (как МойСклад): начальное сальдо, движения
/// за период (приёмки = мы должны +; возвраты и оплаты = −), конечное сальдо.
/// Баланс в разрезе «мы должны поставщику». Печать — из веба (браузерный print/PDF).</summary>
[HttpGet("supplier-reconciliation"), RequiresPermission("MoneyView")]
public async Task<ActionResult<SupplierReconciliation>> GetSupplierReconciliation(
[FromQuery] Guid supplierId, [FromQuery] DateTime? from, [FromQuery] DateTime? to, CancellationToken ct = default)
{
var sup = await _db.Counterparties.AsNoTracking().Where(c => c.Id == supplierId)
.Select(c => new { c.Name, c.Bin }).FirstOrDefaultAsync(ct);
if (sup is null) return NotFound(new { error = "Поставщик не найден." });
var fromUtc = (from ?? DateTime.UtcNow.AddMonths(-3)).ToUniversalTime();
var toUtc = (to ?? DateTime.UtcNow).ToUniversalTime();
// Все движения по поставщику (без фильтра по дате) — чтобы посчитать и
// начальное сальдо (до from), и строки периода.
var supplies = await _db.Supplies.AsNoTracking()
.Where(s => s.Status == SupplyStatus.Posted && s.SupplierId == supplierId)
.Select(s => new { s.Date, s.Number, s.Total }).ToListAsync(ct);
var returns = await _db.SupplierReturns.AsNoTracking()
.Where(r => r.Status == SupplierReturnStatus.Posted && r.SupplierId == supplierId)
.Select(r => new { r.Date, r.Number, r.Total }).ToListAsync(ct);
var payments = await _db.MoneyTransactions.AsNoTracking()
.Where(t => t.Status == MoneyTransactionStatus.Posted && t.CounterpartyId == supplierId)
.Select(t => new { t.Date, t.Number, t.Amount, t.Kind }).ToListAsync(ct);
// Движение: (дата, тип, номер, debit — увеличивает наш долг, credit — уменьшает).
var all = new List<(DateTime Date, string Type, string? Num, decimal Debit, decimal Credit)>();
foreach (var s in supplies) all.Add((s.Date, "Приёмка", s.Number, s.Total, 0m));
foreach (var r in returns) all.Add((r.Date, "Возврат поставщику", r.Number, 0m, r.Total));
foreach (var p in payments)
{
// Оплата поставщику (деньги ушли) уменьшает наш долг; приход от поставщика — наоборот.
var isOut = p.Kind is MoneyTransactionKind.CashOut or MoneyTransactionKind.PaymentOut or MoneyTransactionKind.TransferOut;
if (isOut) all.Add((p.Date, "Оплата", p.Number, 0m, p.Amount));
else all.Add((p.Date, "Поступление от поставщика", p.Number, p.Amount, 0m));
}
var opening = all.Where(x => x.Date < fromUtc).Sum(x => x.Debit - x.Credit);
var periodOrdered = all.Where(x => x.Date >= fromUtc && x.Date <= toUtc).OrderBy(x => x.Date).ToList();
var rows = new List<ReconLine>();
var bal = opening;
foreach (var x in periodOrdered)
{
bal += x.Debit - x.Credit;
rows.Add(new ReconLine(x.Date, x.Type, x.Num, x.Debit, x.Credit, bal));
}
return new SupplierReconciliation(supplierId, sup.Name, sup.Bin, fromUtc, toUtc, opening, bal, rows);
}
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<SupplierDebtRow> Rows, decimal TotalDebt);
/// <summary>Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок.
/// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально.</summary>
[HttpGet("supplier-debts"), RequiresPermission("MoneyView")]
public async Task<ActionResult<SupplierDebtsReport>> 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<SupplierDebtRow>();
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));
}
}