feat(money): акт сверки с поставщиком — печать/PDF (п.7)
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

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>
This commit is contained in:
nns 2026-10-09 06:39:08 +05:00
parent 18752497a8
commit 524f8f41b1
5 changed files with 203 additions and 0 deletions

View file

@ -110,6 +110,61 @@ public record MoneyMovementReport(DateTime From, DateTime To, decimal TotalCredi
return new MoneyMovementReport(fromUtc, toUtc, series.Sum(s => s.Credit), series.Sum(s => s.Debit), series); 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( public record SupplierDebtRow(
Guid SupplierId, string SupplierName, string? Bin, string? Phone, Guid SupplierId, string SupplierName, string? Bin, string? Phone,
decimal SuppliesTotal, decimal ReturnsTotal, decimal PaymentsIn, decimal PaymentsOut, decimal AdjustmentDebt, decimal SuppliesTotal, decimal ReturnsTotal, decimal PaymentsIn, decimal PaymentsOut, decimal AdjustmentDebt,

View file

@ -84,6 +84,7 @@ const ExpenseItemsPage = lazy(() => import('@/pages/money/ExpenseItemsPage').the
const MoneyTransactionsPage = lazy(() => import('@/pages/money/MoneyTransactionsPage').then(m => ({ default: m.MoneyTransactionsPage }))) const MoneyTransactionsPage = lazy(() => import('@/pages/money/MoneyTransactionsPage').then(m => ({ default: m.MoneyTransactionsPage })))
const CashShiftsPage = lazy(() => import('@/pages/money/CashShiftsPage').then(m => ({ default: m.CashShiftsPage }))) const CashShiftsPage = lazy(() => import('@/pages/money/CashShiftsPage').then(m => ({ default: m.CashShiftsPage })))
const SupplierDebtsPage = lazy(() => import('@/pages/money/SupplierDebtsPage').then(m => ({ default: m.SupplierDebtsPage }))) const SupplierDebtsPage = lazy(() => import('@/pages/money/SupplierDebtsPage').then(m => ({ default: m.SupplierDebtsPage })))
const SupplierReconciliationPage = lazy(() => import('@/pages/money/SupplierReconciliationPage').then(m => ({ default: m.SupplierReconciliationPage })))
const MoneyReportPage = lazy(() => import('@/pages/money/MoneyReportPage').then(m => ({ default: m.MoneyReportPage }))) const MoneyReportPage = lazy(() => import('@/pages/money/MoneyReportPage').then(m => ({ default: m.MoneyReportPage })))
// SuperAdmin консоль — почти всегда редко открываемая (доступна только // SuperAdmin консоль — почти всегда редко открываемая (доступна только
@ -210,6 +211,7 @@ export default function App() {
<Route path="/money/transactions" element={<RoleGuard roles={['Admin']}>{lz(MoneyTransactionsPage)}</RoleGuard>} /> <Route path="/money/transactions" element={<RoleGuard roles={['Admin']}>{lz(MoneyTransactionsPage)}</RoleGuard>} />
<Route path="/money/shifts" element={<RoleGuard roles={['Admin', 'Cashier']}>{lz(CashShiftsPage)}</RoleGuard>} /> <Route path="/money/shifts" element={<RoleGuard roles={['Admin', 'Cashier']}>{lz(CashShiftsPage)}</RoleGuard>} />
<Route path="/money/supplier-debts" element={<RoleGuard roles={['Admin']}>{lz(SupplierDebtsPage)}</RoleGuard>} /> <Route path="/money/supplier-debts" element={<RoleGuard roles={['Admin']}>{lz(SupplierDebtsPage)}</RoleGuard>} />
<Route path="/money/supplier-debts/:supplierId/reconciliation" element={<RoleGuard roles={['Admin']}>{lz(SupplierReconciliationPage)}</RoleGuard>} />
<Route path="/money/report" element={<RoleGuard roles={['Admin']}>{lz(MoneyReportPage)}</RoleGuard>} /> <Route path="/money/report" element={<RoleGuard roles={['Admin']}>{lz(MoneyReportPage)}</RoleGuard>} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />

View file

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Scale, Plus } from 'lucide-react' import { Scale, Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
@ -15,6 +16,7 @@ import { AdjustmentDirection, type SupplierDebtsReport } from '@/lib/money'
function todayIso() { return new Date().toISOString().slice(0, 10) } function todayIso() { return new Date().toISOString().slice(0, 10) }
export function SupplierDebtsPage() { export function SupplierDebtsPage() {
const navigate = useNavigate()
const fmt = useFormatCurrency() const fmt = useFormatCurrency()
const qc = useQueryClient() const qc = useQueryClient()
const counterparties = useCounterparties() const counterparties = useCounterparties()
@ -89,6 +91,10 @@ export function SupplierDebtsPage() {
{ header: 'Долг', width: '150px', className: 'text-right font-mono', cell: (r) => ( { header: 'Долг', width: '150px', className: 'text-right font-mono', cell: (r) => (
<span className={r.debt > 0.0001 ? 'text-red-700 dark:text-red-400 font-medium' : r.debt < -0.0001 ? 'text-green-700 dark:text-green-400' : ''}>{fmt(r.debt)}</span> <span className={r.debt > 0.0001 ? 'text-red-700 dark:text-red-400 font-medium' : r.debt < -0.0001 ? 'text-green-700 dark:text-green-400' : ''}>{fmt(r.debt)}</span>
)}, )},
{ header: '', width: '130px', cell: (r) => (
<button type="button" onClick={() => navigate(`/money/supplier-debts/${r.supplierId}/reconciliation`)}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Акт сверки</button>
)},
]} ]}
/> />
</ListPageShell> </ListPageShell>

View file

@ -0,0 +1,103 @@
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Printer, ArrowLeft } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
interface ReconLine { date: string; docType: string; docNumber: string | null; debit: number; credit: number; balance: number }
interface Reconciliation {
supplierId: string; supplierName: string; bin: string | null
from: string; to: string; openingBalance: number; closingBalance: number; rows: ReconLine[]
}
/**
* Акт сверки с поставщиком (п.7 паритета). Начальное сальдо → движения за период
* (приёмки увеличивают наш долг, возвраты/оплаты уменьшают) → конечное сальдо.
* Печать — браузерная (window.print() / Сохранить как PDF).
*/
export function SupplierReconciliationPage() {
const { supplierId } = useParams<{ supplierId: string }>()
const [sp] = useSearchParams()
const navigate = useNavigate()
const fmt = useFormatCurrency()
const from = sp.get('from') ?? ''
const to = sp.get('to') ?? ''
const q = useQuery<Reconciliation>({
queryKey: ['/api/money/reports/supplier-reconciliation', supplierId, from, to],
queryFn: async () => (await api.get<Reconciliation>(
`/api/money/reports/supplier-reconciliation?supplierId=${supplierId}${from ? `&from=${from}` : ''}${to ? `&to=${to}` : ''}`)).data,
enabled: !!supplierId,
})
const d = q.data
return (
<div className="h-full overflow-auto">
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-4 print:hidden">
<Button variant="secondary" size="sm" onClick={() => navigate('/money/supplier-debts')}>
<ArrowLeft className="w-4 h-4" /> К долгам
</Button>
<Button size="sm" onClick={() => window.print()}><Printer className="w-4 h-4" /> Печать / PDF</Button>
</div>
{!d ? (
<div className="text-sm text-slate-500 py-8 text-center">Загрузка…</div>
) : (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6 print:border-0">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Акт сверки взаиморасчётов</h1>
<div className="text-sm text-slate-600 dark:text-slate-400 mt-1">
Поставщик: <b>{d.supplierName}</b>{d.bin && <> · БИН {d.bin}</>}
</div>
<div className="text-sm text-slate-600 dark:text-slate-400">
Период: {new Date(d.from).toLocaleDateString('ru')} — {new Date(d.to).toLocaleDateString('ru')}
</div>
<div className="text-sm text-slate-700 dark:text-slate-300 mt-3">
Сальдо на начало: <b>{fmt(d.openingBalance)}</b> <span className="text-xs text-slate-400">(«+» — мы должны)</span>
</div>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-left text-xs uppercase text-slate-500">
<th className="py-2 pr-2">Дата</th>
<th className="py-2 pr-2">Документ</th>
<th className="py-2 pr-2 text-right">Долг +</th>
<th className="py-2 pr-2 text-right">Оплата −</th>
<th className="py-2 text-right">Сальдо</th>
</tr>
</thead>
<tbody>
{d.rows.length === 0 ? (
<tr><td colSpan={5} className="py-4 text-center text-slate-400">Движений за период нет.</td></tr>
) : d.rows.map((r, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-1.5 pr-2 whitespace-nowrap">{new Date(r.date).toLocaleDateString('ru')}</td>
<td className="py-1.5 pr-2">{r.docType}{r.docNumber ? ` ${r.docNumber}` : ''}</td>
<td className="py-1.5 pr-2 text-right font-mono">{r.debit > 0 ? fmt(r.debit) : ''}</td>
<td className="py-1.5 pr-2 text-right font-mono">{r.credit > 0 ? fmt(r.credit) : ''}</td>
<td className="py-1.5 text-right font-mono font-semibold">{fmt(r.balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-sm text-slate-900 dark:text-slate-100 mt-4 font-semibold">
Сальдо на конец: {fmt(d.closingBalance)}
<span className="font-normal text-slate-500 ml-2">
{d.closingBalance > 0 ? '— мы должны поставщику' : d.closingBalance < 0 ? '— поставщик должен нам' : '— взаиморасчёты закрыты'}
</span>
</div>
<div className="grid grid-cols-2 gap-8 mt-10 text-sm text-slate-600 dark:text-slate-400">
<div>От нашей организации: _______________</div>
<div>От поставщика: _______________</div>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Паритет п.7: акт сверки с поставщиком — начальное сальдо, движения
/// периода, конечное сальдо.</summary>
[Collection(ApiCollection.Name)]
public class SupplierReconciliationTests
{
private readonly ApiFactory _factory;
public SupplierReconciliationTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Reconciliation_includes_supply_as_debit_and_closing_balance()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"recon-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
// Приёмка 10×100 = 1000 → мы должны 1000.
var (_, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m);
var recon = await api.GetJsonAsync($"/api/money/reports/supplier-reconciliation?supplierId={supplier}");
recon.GetProperty("supplierId").GetString().Should().Be(supplier);
var rows = recon.GetProperty("rows").EnumerateArray().ToList();
rows.Should().Contain(r => r.GetProperty("docType").GetString() == "Приёмка" && r.GetProperty("debit").GetDecimal() == total);
recon.GetProperty("closingBalance").GetDecimal().Should().Be(total, "конечное сальдо = сумма приёмки (неоплачено)");
}
}