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