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
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:
parent
18752497a8
commit
524f8f41b1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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, "конечное сальдо = сумма приёмки (неоплачено)");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue