From 63c58ef6c1ac6d6ac93f12a4c86dde3bbcd79aca Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 11:14:24 +0500 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=C2=AB=D0=9E=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=C2=BB=20=D1=81=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20(P1-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/reports/stock?date=… — восстанавливает остатки (Product, Store) агрегацией журнала StockMovement до указанной даты. На «сейчас» совпадает с материализованным Stock (инвариант учёта); на прошлую дату — реальная реконструкция через Σ движений. Edge-cases: • дата в будущем → текущий остаток (движений из будущего нет); • дата раньше первой операции → пусто (пара не существовала); • операция с future-датой исключена снимком на «сегодня». Стоимость: последний UnitCost движения до даты на пару (Product, Store); fallback на Product.Cost если в журнале не было ни одной партии. Это приближённая оценка — точный FIFO требует партий. Параметры: storeId, productGroupId, includeZero (вернуть и нулевые позиции). Export CSV/XLSX через тот же ReportExport. Web: /reports/stock — дата, фильтры, экспорт, итоговая стоимость. Тесты: 5 интеграционных (today=current, before-first-mov→empty, future=current, date-before-op-excludes-it, tenant-изоляция). Co-Authored-By: Claude Opus 4.7 --- .../Reports/StockReportController.cs | 146 +++++++++++++++++ src/food-market.web/src/App.tsx | 2 + .../src/components/AppLayout.tsx | 1 + .../src/pages/StockReportPage.tsx | 154 ++++++++++++++++++ .../StockReportTests.cs | 138 ++++++++++++++++ 5 files changed, 441 insertions(+) create mode 100644 src/food-market.api/Controllers/Reports/StockReportController.cs create mode 100644 src/food-market.web/src/pages/StockReportPage.tsx create mode 100644 tests/food-market.IntegrationTests/StockReportTests.cs diff --git a/src/food-market.api/Controllers/Reports/StockReportController.cs b/src/food-market.api/Controllers/Reports/StockReportController.cs new file mode 100644 index 0000000..dd44796 --- /dev/null +++ b/src/food-market.api/Controllers/Reports/StockReportController.cs @@ -0,0 +1,146 @@ +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Reports; + +/// Отчёт «Остатки на дату». +/// +/// Восстанавливает остаток (Product, Store) на произвольный момент, +/// агрегируя журнал до этой даты. +/// Текущий является результатом +/// Σ движений (инвариант учёта), поэтому подсчёт на «сейчас» совпадает с +/// материализованным остатком; подсчёт на прошлую дату — реконструкция. +/// +/// Edge-cases: +/// • Дата в будущем — возвращаем текущий остаток (Σ до сейчас): движений +/// из будущего нет, фильтр на это пропускает. +/// • Дата раньше первой операции — возвращаем нули (или вообще не отдаём +/// строку, потому что и таких пар (Product, Store) не было). +/// +/// Стоимость остатка считаем по последнему известному `UnitCost` движения +/// до этой даты ИЛИ снимаем с `Product.Cost` если в движениях не было ни +/// одной партии (например, оприходование без UnitCost). Это компромисс +/// для приближённой оценки — точная FIFO/LIFO потребует партий, которые +/// в текущем учёте не ведутся. +[ApiController] +[Authorize] +[Route("api/reports/stock")] +public class StockReportController : ControllerBase +{ + private readonly AppDbContext _db; + public StockReportController(AppDbContext db) => _db = db; + + public record StockRow( + Guid ProductId, string ProductName, string? ProductArticle, string? UnitName, + Guid StoreId, string StoreName, + decimal Quantity, decimal Cost, decimal Value); + + [HttpGet] + public async Task>> Get( + [FromQuery] DateTime? date, + [FromQuery] Guid? storeId, + [FromQuery] Guid? productGroupId, + [FromQuery] bool includeZero = false, + CancellationToken ct = default) + { + var on = date ?? DateTime.UtcNow; + return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct)); + } + + [HttpGet("export")] + public async Task Export( + [FromQuery] DateTime? date, + [FromQuery] Guid? storeId, + [FromQuery] Guid? productGroupId, + [FromQuery] bool includeZero = false, + [FromQuery] string format = "csv", + CancellationToken ct = default) + { + var on = date ?? DateTime.UtcNow; + var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct); + var name = $"stock-{on:yyyyMMdd}"; + var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" }; + return format.ToLower() switch + { + "xlsx" => ReportExport.Xlsx(rows, name, "Stock", headers), + _ => ReportExport.Csv(rows, name, headers), + }; + } + + private async Task> BuildAsync( + DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct) + { + // Σ Quantity и последний UnitCost (через max OccurredAt) на (Product, Store) + // — два отдельных запроса; объединяем в памяти. Альтернатива через window + // functions требует sql-raw; на типовых объёмах (десятки тыс. движений) + // двойной round-trip с агрегатом дешевле и читабельнее. + var movQ = _db.StockMovements.AsNoTracking().Where(m => m.OccurredAt <= on); + if (storeId is not null) movQ = movQ.Where(m => m.StoreId == storeId); + + var qtyByPair = await movQ + .GroupBy(m => new { m.ProductId, m.StoreId }) + .Select(g => new { g.Key.ProductId, g.Key.StoreId, Qty = g.Sum(m => m.Quantity) }) + .ToListAsync(ct); + + // Последний UnitCost (max OccurredAt с не-null UnitCost) на пару. + var lastCostsRaw = await movQ + .Where(m => m.UnitCost != null) + .GroupBy(m => new { m.ProductId, m.StoreId }) + .Select(g => new + { + g.Key.ProductId, + g.Key.StoreId, + LastCost = g.OrderByDescending(m => m.OccurredAt).Select(m => m.UnitCost).First(), + }) + .ToListAsync(ct); + var lastCosts = lastCostsRaw + .ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m); + + if (productGroupId is not null) + { + // Фильтр по группе — отдельным запросом, чтобы получить ProductIds. + var allowed = await _db.Products.AsNoTracking() + .Where(p => p.ProductGroupId == productGroupId) + .Select(p => p.Id) + .ToListAsync(ct); + var set = allowed.ToHashSet(); + qtyByPair = qtyByPair.Where(x => set.Contains(x.ProductId)).ToList(); + } + + if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList(); + + // Подтаскиваем имена товара/склада. + var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList(); + var storeIds = qtyByPair.Select(x => x.StoreId).Distinct().ToList(); + var products = await _db.Products.AsNoTracking() + .Where(p => productIds.Contains(p.Id)) + .Join(_db.UnitsOfMeasure.AsNoTracking(), p => p.UnitOfMeasureId, u => u.Id, + (p, u) => new { p.Id, p.Name, p.Article, p.Cost, UnitName = u.Name }) + .ToListAsync(ct); + var stores = await _db.Stores.AsNoTracking() + .Where(s => storeIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Name }) + .ToListAsync(ct); + + var pMap = products.ToDictionary(p => p.Id); + var sMap = stores.ToDictionary(s => s.Id); + + return qtyByPair + .Select(x => + { + pMap.TryGetValue(x.ProductId, out var p); + sMap.TryGetValue(x.StoreId, out var s); + lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost); + if (cost == 0m && p is not null) cost = p.Cost; + return new StockRow( + x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName, + x.StoreId, s?.Name ?? "(unknown)", + x.Qty, cost, x.Qty * cost); + }) + .OrderBy(r => r.ProductName) + .ThenBy(r => r.StoreName) + .ToList(); + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 0675cd0..3ba2680 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -39,6 +39,7 @@ import { InventoryEditPage } from '@/pages/InventoryEditPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SalesReportPage } from '@/pages/SalesReportPage' +import { StockReportPage } from '@/pages/StockReportPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -127,6 +128,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 7d629c4..e724d5e 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -112,6 +112,7 @@ function buildNav(roles: string[]): NavSection[] { if (isAdmin || isStorekeeper) { sections.push({ group: 'Отчёты', items: [ { to: '/reports/sales', icon: BarChart3, label: 'Продажи' }, + { to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' }, ]}) } diff --git a/src/food-market.web/src/pages/StockReportPage.tsx b/src/food-market.web/src/pages/StockReportPage.tsx new file mode 100644 index 0000000..0142426 --- /dev/null +++ b/src/food-market.web/src/pages/StockReportPage.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Download } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, Select, Checkbox } from '@/components/Field' +import { DateField } from '@/components/DateField' +import { useStores, useProductGroups } from '@/lib/useLookups' +import { useOrgSettings } from '@/lib/useOrgSettings' +import { type StockReportRow } from '@/lib/types' + +/** Отчёт «Остатки на дату». Реконструкция через журнал StockMovement. */ +export function StockReportPage() { + const today = new Date().toISOString().slice(0, 10) + const [date, setDate] = useState(today) + const [storeId, setStoreId] = useState('') + const [productGroupId, setProductGroupId] = useState('') + const [includeZero, setIncludeZero] = useState(false) + + const stores = useStores() + const groups = useProductGroups() + const org = useOrgSettings() + const fractional = org.data?.allowFractionalPrices ?? false + const moneyFmt = fractional + ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 } + + const params = (extra: Record = {}) => { + const p = new URLSearchParams({ + date: new Date(`${date}T23:59:59`).toISOString(), + ...extra, + }) + if (storeId) p.set('storeId', storeId) + if (productGroupId) p.set('productGroupId', productGroupId) + if (includeZero) p.set('includeZero', 'true') + return p + } + + const rep = useQuery({ + queryKey: ['stock-report', date, storeId, productGroupId, includeZero], + queryFn: async () => (await api.get(`/api/reports/stock?${params()}`)).data, + }) + + const exportFile = async (format: 'csv' | 'xlsx') => { + const resp = await api.get(`/api/reports/stock/export?${params({ format })}`, { responseType: 'blob' }) + const blob = new Blob([resp.data]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const cd = resp.headers['content-disposition'] as string | undefined + const match = cd?.match(/filename="?([^";]+)"?/i) + a.download = match?.[1] ?? `stock-report.${format}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + const total = rep.data?.reduce((s, r) => s + r.value, 0) ?? 0 + const items = rep.data?.length ?? 0 + + return ( +
+
+

Отчёт «Остатки на дату»

+

+ Реконструкция через журнал движений. Стоимость — последний UnitCost + движения; если в журнале нет — Product.Cost (приближённая оценка). +

+
+
+
+
+
+ + setDate(v ?? today)} /> + + + + + + + + + + +
+
+
+ + Позиций: {items.toLocaleString('ru')} + + + Стоимость: {total.toLocaleString('ru', moneyFmt)} + +
+
+ + +
+
+
+ +
+ {rep.isLoading &&
Загружаю…
} + {!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( +
На эту дату нет остатков.
+ )} + {!rep.isLoading && rep.data && rep.data.length > 0 && ( +
+ + + + + + + + + + + + + + {rep.data.map((r) => ( + + + + + + + + + + ))} + +
ТоварАртикулЕд.СкладКол-воЦенаСтоимость
{r.productName}{r.productArticle ?? '—'}{r.unitName ?? '—'}{r.storeName}{r.quantity.toLocaleString('ru')}{r.cost.toLocaleString('ru', moneyFmt)}{r.value.toLocaleString('ru', moneyFmt)}
+
+ )} +
+
+
+
+ ) +} diff --git a/tests/food-market.IntegrationTests/StockReportTests.cs b/tests/food-market.IntegrationTests/StockReportTests.cs new file mode 100644 index 0000000..fa651a3 --- /dev/null +++ b/tests/food-market.IntegrationTests/StockReportTests.cs @@ -0,0 +1,138 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class StockReportTests +{ + private readonly ApiFactory _factory; + public StockReportTests(ApiFactory factory) => _factory = factory; + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + /// Реконструкция остатков на «сейчас» совпадает с текущим Stock. + [Fact] + public async Task Today_matches_current_stock() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"strpt-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = p1, quantity = 7m, unitCost = 30m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var rep = await api.GetJsonAsync("/api/reports/stock"); + var rows = rep.EnumerateArray().ToList(); + var r = rows.First(x => x.GetProperty("productId").GetString() == p1); + r.GetProperty("quantity").GetDecimal().Should().Be(7m); + r.GetProperty("cost").GetDecimal().Should().Be(30m); + r.GetProperty("value").GetDecimal().Should().Be(210m); + } + + /// Запрос на дату до первой операции — пусто. + [Fact] + public async Task Date_before_first_movement_returns_empty() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"strpt-pre-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Принять товар прямо сейчас. + var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = p1, quantity = 3m, unitCost = 10m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + // Запрос на год назад — ничего. + var pastDate = DateTime.UtcNow.AddYears(-1).ToString("o"); + var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(pastDate)}"); + var rows = rep.EnumerateArray().ToList(); + rows.Should().NotContain(x => x.GetProperty("productId").GetString() == p1); + } + + /// Дата в будущем — отчёт = текущий остаток. + [Fact] + public async Task Future_date_equals_current() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"strpt-fut-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 20m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var future = DateTime.UtcNow.AddYears(1).ToString("o"); + var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(future)}"); + var r = rep.EnumerateArray().First(x => x.GetProperty("productId").GetString() == p1); + r.GetProperty("quantity").GetDecimal().Should().Be(5m); + } + + /// Снимок «вчера» — без учёта сегодняшней операции. + [Fact] + public async Task Date_before_operation_excludes_it() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"strpt-bef-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Принять с future-датой (так контроллер использует входящую дату). + var date = DateTime.UtcNow.AddDays(2); + var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { + date, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "future-supply", lines = new[] { new { productId = p1, quantity = 4m, unitCost = 10m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + // На сейчас (раньше OccurredAt = date) — товара ещё нет. + var now = DateTime.UtcNow.ToString("o"); + var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(now)}"); + var rows = rep.EnumerateArray().ToList(); + rows.Should().NotContain(x => x.GetProperty("productId").GetString() == p1); + + // На дату ПОСЛЕ операции — товар появился. + var after = date.AddDays(1).ToString("o"); + var rep2 = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(after)}"); + var r = rep2.EnumerateArray().First(x => x.GetProperty("productId").GetString() == p1); + r.GetProperty("quantity").GetDecimal().Should().Be(4m); + } + + [Fact] + public async Task Tenant_isolation_stock_report() + { + var a = new ApiActor(_factory.CreateClient()); + var b = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"strpt-iso-a-{Guid.NewGuid():N}"); + await b.SignupAndLoginAsync($"strpt-iso-b-{Guid.NewGuid():N}"); + var refsA = await a.LoadRefsAsync(); + var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var e = await a.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, + notes = "seed", lines = new[] { new { productId = pA, quantity = 1m, unitCost = 5m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await a.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var repB = await b.GetJsonAsync("/api/reports/stock"); + repB.EnumerateArray().Should().NotContain(x => x.GetProperty("productId").GetString() == pA); + } +}