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

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 CashShiftsPage = lazy(() => import('@/pages/money/CashShiftsPage').then(m => ({ default: m.CashShiftsPage })))
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 })))
// SuperAdmin консоль — почти всегда редко открываемая (доступна только
@ -210,6 +211,7 @@ export default function App() {
<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/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="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Scale, Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
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) }
export function SupplierDebtsPage() {
const navigate = useNavigate()
const fmt = useFormatCurrency()
const qc = useQueryClient()
const counterparties = useCounterparties()
@ -89,6 +91,10 @@ export function SupplierDebtsPage() {
{ 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>
)},
{ 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>

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, "конечное сальдо = сумма приёмки (неоплачено)");
}
}