From 524f8f41b147732e71f19af421971fa7fed9e45f Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 06:39:08 +0500 Subject: [PATCH] =?UTF-8?q?feat(money):=20=D0=B0=D0=BA=D1=82=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D1=81=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=BE=D0=BC=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8C/PDF=20(=D0=BF.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Money/MoneyReportsController.cs | 55 ++++++++++ src/food-market.web/src/App.tsx | 2 + .../src/pages/money/SupplierDebtsPage.tsx | 6 + .../money/SupplierReconciliationPage.tsx | 103 ++++++++++++++++++ .../SupplierReconciliationTests.cs | 37 +++++++ 5 files changed, 203 insertions(+) create mode 100644 src/food-market.web/src/pages/money/SupplierReconciliationPage.tsx create mode 100644 tests/food-market.IntegrationTests/SupplierReconciliationTests.cs diff --git a/src/food-market.api/Controllers/Money/MoneyReportsController.cs b/src/food-market.api/Controllers/Money/MoneyReportsController.cs index 54c2007..f3e2fa7 100644 --- a/src/food-market.api/Controllers/Money/MoneyReportsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyReportsController.cs @@ -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 Rows); + + /// Акт сверки с поставщиком (как МойСклад): начальное сальдо, движения + /// за период (приёмки = мы должны +; возвраты и оплаты = −), конечное сальдо. + /// Баланс в разрезе «мы должны поставщику». Печать — из веба (браузерный print/PDF). + [HttpGet("supplier-reconciliation"), RequiresPermission("MoneyView")] + public async Task> 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(); + 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, diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 1084c27..8838226 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -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() { {lz(MoneyTransactionsPage)}} /> {lz(CashShiftsPage)}} /> {lz(SupplierDebtsPage)}} /> + {lz(SupplierReconciliationPage)}} /> {lz(MoneyReportPage)}} /> } /> } /> diff --git a/src/food-market.web/src/pages/money/SupplierDebtsPage.tsx b/src/food-market.web/src/pages/money/SupplierDebtsPage.tsx index fd40fba..9c18bf2 100644 --- a/src/food-market.web/src/pages/money/SupplierDebtsPage.tsx +++ b/src/food-market.web/src/pages/money/SupplierDebtsPage.tsx @@ -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) => ( 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)} )}, + { header: '', width: '130px', cell: (r) => ( + + )}, ]} /> diff --git a/src/food-market.web/src/pages/money/SupplierReconciliationPage.tsx b/src/food-market.web/src/pages/money/SupplierReconciliationPage.tsx new file mode 100644 index 0000000..3d54361 --- /dev/null +++ b/src/food-market.web/src/pages/money/SupplierReconciliationPage.tsx @@ -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({ + queryKey: ['/api/money/reports/supplier-reconciliation', supplierId, from, to], + queryFn: async () => (await api.get( + `/api/money/reports/supplier-reconciliation?supplierId=${supplierId}${from ? `&from=${from}` : ''}${to ? `&to=${to}` : ''}`)).data, + enabled: !!supplierId, + }) + + const d = q.data + return ( +
+
+
+ + +
+ + {!d ? ( +
Загрузка…
+ ) : ( +
+

Акт сверки взаиморасчётов

+
+ Поставщик: {d.supplierName}{d.bin && <> · БИН {d.bin}} +
+
+ Период: {new Date(d.from).toLocaleDateString('ru')} — {new Date(d.to).toLocaleDateString('ru')} +
+
+ Сальдо на начало: {fmt(d.openingBalance)} («+» — мы должны) +
+ +
+ + + + + + + + + + + + {d.rows.length === 0 ? ( + + ) : d.rows.map((r, i) => ( + + + + + + + + ))} + +
ДатаДокументДолг +Оплата −Сальдо
Движений за период нет.
{new Date(r.date).toLocaleDateString('ru')}{r.docType}{r.docNumber ? ` ${r.docNumber}` : ''}{r.debit > 0 ? fmt(r.debit) : ''}{r.credit > 0 ? fmt(r.credit) : ''}{fmt(r.balance)}
+
+ +
+ Сальдо на конец: {fmt(d.closingBalance)} + + {d.closingBalance > 0 ? '— мы должны поставщику' : d.closingBalance < 0 ? '— поставщик должен нам' : '— взаиморасчёты закрыты'} + +
+ +
+
От нашей организации: _______________
+
От поставщика: _______________
+
+
+ )} +
+
+ ) +} diff --git a/tests/food-market.IntegrationTests/SupplierReconciliationTests.cs b/tests/food-market.IntegrationTests/SupplierReconciliationTests.cs new file mode 100644 index 0000000..c9b614a --- /dev/null +++ b/tests/food-market.IntegrationTests/SupplierReconciliationTests.cs @@ -0,0 +1,37 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Паритет п.7: акт сверки с поставщиком — начальное сальдо, движения +/// периода, конечное сальдо. +[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, "конечное сальдо = сумма приёмки (неоплачено)"); + } +}