food-market/src/food-market.api/Controllers/Money/MoneyReportsController.cs
nns deef65d819
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
test(money): unit (MoneyMath 25) + integration (7 на Postgres) + MoneyMath refactor
- MoneyMath: вынесены чистые формулы (статус оплаты, ожидаемая наличность,
  разница смены, остаток счёта, долг поставщика, защита от переплаты);
  сервис/SuppliesController/MoneyReportsController теперь зовут их.
- UnitTests/MoneyMathTests: 25 кейсов на формулы + money-флаги RolePermissions.
- IntegrationTests/MoneyModuleTests (реальный Postgres, 7 [Fact]):
  мультитенантность (Б не видит счета А), инвариант остатка, частичная→полная
  оплата приёмки, переплата→409, долг поставщика, продажа без смены→409 +
  после open→ok, расчёт expected/difference при закрытии смены.
- ApiActor: helpers CreateRetailPoint/CreateMoneyAccount/CreatePostedSupply.
- tests/e2e/scrape-moysklad.ts — скрейпер для docs/money-moysklad-reference.md.

Прогон: UnitTests 25/25 ✓, IntegrationTests money 7/7 ✓ (33s, Testcontainers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 22:11:19 +05:00

191 lines
9.5 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 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));
}
}