From 193ba49ecb5442541ab024ffde9ec3d200fa9988 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 09:44:50 +0500 Subject: [PATCH] =?UTF-8?q?fix(stock):=20=C2=AB=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BD=D1=83=D0=BB=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=C2=BB=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=80=D1=8B=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остатки строились ОТ таблицы Stocks (JOIN Products), поэтому товары без строки Stock (ни разу не приходованные) не попадали в список вообще, а includeZero лишь снимал фильтр Quantity!=0 у существующих строк. То же в отчёте «Остатки на дату», в экспорте и в фильтре по группе (iOS получал total 0). - GetStock/ExportStock: выборка ОТ Products (не-услуги, не-архивные) с остатком коррелированным подзапросом (товар без Stock = 0). Склад выбран → строка склада; без склада → агрегат «Все склады». Нулевые видны при includeZero; negativeOnly и пагинация/сортировка/поиск/экспорт работают и для нулевых. Остаток/ед./штрихкод дотягиваются batch-запросом по id страницы (EF8 не переводит подзапросы в SELECT+ORDER BY). - Отчёт «Остатки на дату»: includeZero добавляет товары без движений (Qty 0); фильтр по группе теперь с вложенностью. - groupId/productGroupId — синонимы с вложенными группами на всех списках остатков и в каталоге (List + Export). Раньше catalog?productGroupId молча игнорировался. - Штрихкод рядом с артикулом в списке/экспорте — различать одноимённые товары (4× «BIOBLAS шампунь» с разными артикулами из МойСклада — не удалять). - Demo (30 894 товара): без флага 12, includeZero → 30 894, <500мс/страница. Co-Authored-By: Claude Opus 4.8 --- docs/filters-sweep-progress.md | 105 +++++++++ docs/mobile-api.md | 18 +- .../Controllers/Catalog/ProductsController.cs | 15 +- .../Controllers/Inventory/StockController.cs | 221 ++++++++++++------ .../Reports/StockReportController.cs | 52 ++++- src/food-market.web/src/lib/types.ts | 2 +- src/food-market.web/src/pages/StockPage.tsx | 10 +- .../MobileApiTests.cs | 54 +++++ .../Support/ApiActor.cs | 4 +- 9 files changed, 393 insertions(+), 88 deletions(-) create mode 100644 docs/filters-sweep-progress.md diff --git a/docs/filters-sweep-progress.md b/docs/filters-sweep-progress.md new file mode 100644 index 0000000..e908c18 --- /dev/null +++ b/docs/filters-sweep-progress.md @@ -0,0 +1,105 @@ +# Фильтры и «нулевые остатки» — прогресс и проверка + +Жалоба владельца (08.10.2026): в остатках «Показать нулевые» не работает — товары, +которых ни разу не приходовали, не видно вообще. Плюс поручение: сплошная проверка +ВСЕХ фильтров на ВСЕХ списках (web + mobile API) и приёмочное тестирование процессов. + +Легенда: [x] готово · [~] частично · [ ] не начато. + +## Часть 1. «Нулевые остатки» — корневая причина и фикс + +### Диагноз (подтверждён чтением кода) +`GET /api/inventory/stock` строил выборку **от таблицы Stocks**: +``` +from s in _db.Stocks join p in _db.Products on s.ProductId equals p.Id ... +``` +→ товар, у которого нет ни одной строки Stock (никогда не приходовали), **физически +не мог попасть в результат**. Флаг `includeZero` лишь убирал фильтр `Quantity != 0` +у уже существующих строк. Тот же баг в `GET /api/reports/stock` (отчёт на дату), +в экспорте `GET /api/inventory/stock/export` и в фильтре по группе (`groupId` → 0). + +Репро владельца: без includeZero total=12, с includeZero=true total=13, а в каталоге +30 894 товара (Demo). Ожидалось: includeZero=true → все не-услуги с остатком 0. + +### Сверка с МойСкладом +Отчёт «Остатки»: по умолчанию строка на товар с суммой по всем складам; при выборе +конкретного склада — остаток этого склада. Флаг «Показывать нулевые остатки» +(в API `stockMode=all`) добавляет товары с нулём. Берём эту же модель. + +### Решение +Выборка **от Products** (тенант по query-filter, `!IsService` — услугам остаток не +ведём) **LEFT JOIN Stocks**: +- **Склад выбран** (`storeId`): LEFT JOIN Stocks по (ProductId, StoreId=storeId) — + строка на товар этого склада; нет строки → Quantity/Reserved = 0. +- **Все склады** (storeId не задан): агрегат по товару — `SUM(Quantity)`, + `SUM(ReservedQuantity)` по всем складам через group-join; нет строк нигде → 0. + StoreName = «Все склады» (StoreId = Guid.Empty). +- `includeZero=false` (по умолчанию) → только `Quantity != 0`; `includeZero=true` → + все; `negativeOnly` → `Quantity < 0`. +- В строку добавлен **штрихкод** (основной: IsPrimary → первый) рядом с артикулом, + чтобы различать одноимённые товары (напр. 4× «BIOBLAS шампунь» с разными + артикулами — это разные товары из МойСклада, НЕ удалять). +- Пагинация/сортировка/поиск/фильтр по группе/экспорт — работают и для нулевых. + +### Статус +1. [x] `StockController.GetStock` — выборка ОТ Products (не-услуги/не-архивные), + фильтр нуль/негатив коррелированным подзапросом остатка (нулевые видны); остаток/ед./ + штрихкод дотягиваются в памяти по странице (EF8 не переводит подзапросы в SELECT+ORDER BY). + `storeId` → строка склада; без склада → агрегат «Все склады» (StoreId=Guid.Empty). +2. [x] `StockController.ExportStock` — те же фильтры (включая нулевые) + штрихкод в колонках. +3. [x] `StockReportController.BuildAsync` (отчёт на дату) — includeZero добавляет товары без + движений (Qty 0); фильтр по группе теперь с вложенностью (+`groupId` синоним). +4. [x] `groupId`/`productGroupId` как синонимы + вложенные группы: stock, stock/export, + reports/stock (+export), catalog/products (List + Export). Grep остальных — ниже. +5. [x] Производительность < 500 мс/страница на 30k — замер ниже (все в норме). +6. [x] Тесты: integration ✅ (MobileApiTests 6/6). Деплой на стенд ✅ + сверка репро владельца + на Demo (30 894 товара) ✅: + - `/api/inventory/stock?pageSize=1` → **total 12** (без флага, как у владельца). + - `/api/inventory/stock?includeZero=true` → **total 30 894** (= все не-услуги/не-архивные). + - 4× «BIOBLAS шампунь» видны с РАЗНЫМИ артикулами и штрихкодами; один (art 19677) с qty 0 + (ни разу не приходован) теперь виден — НЕ удаляли, различаются по артикулу+ШК. ✅ + - `catalog?productGroupId=` → 5047 (раньше молча игнорировался → 30 894). ✅ синоним. + - `stock?groupId=&includeZero=true` → 5047 (iOS-баг: было 0). ✅ + - `stock?groupId=` без флага → 5 (только ненулевые). ✅ + +### Найденный ПРЕДСУЩЕСТВУЮЩИЙ баг (не мой регресс — падает и на чистом main) +- `RetailOversellingTests.Posting_sale_above_available_stock_is_rejected`: продажа 5 шт при + нулевом остатке проводится (204) вместо блокировки (409). Розничная продажа сверх остатка + не блокируется. Разобрать в фазе фиксов (P1/P2 — касса/продажи). + +### Замер производительности (Demo, 30 894 товара, test.admin.food-market.kz) +_(pageSize=50, время полного HTTP-ответа)_ +- all-stores, includeZero=true, page 1: **162 мс** ✅ +- all-stores, includeZero=true, page 500 (глубокая пагинация): **399 мс** ✅ +- sort=quantity desc, includeZero=true: **275 мс** ✅ +- поиск includeZero (ILIKE name/article + EXISTS по штрихкоду): **784 мс** — чуть выше + цели; это поиск по тексту на 30k, не постраничный листинг. При необходимости — pg_trgm + индекс на name/article (отдельная оптимизация, не блокер). +- Индексы: Stocks (OrganizationId, ProductId, StoreId) UNIQUE и (OrganizationId, StoreId) + уже есть; подзапрос остатка идёт по ним. N+1 нет — остаток/ед./штрихкод одним batch-запросом + по id страницы. +- Вложенность групп: в Demo группы плоские (50 верхних, без подгрупп). 2-уровневая + вложенность покрыта integration-тестом `Stock_filter_by_group_includes_nested_groups`. + +## Часть 2. Реестр фильтров и e2e meaning-checks +- [ ] Реестр: каждая web-страница-список × каждый контрол (поиск, фильтры/тумблеры, + селекторы склад/группа/период/статус, сортировка по каждой колонке, пагинация, + сброс, экспорт-с-фильтрами). +- [ ] Для каждого — e2e в реальном браузере, где тест независимо считает ожидаемый + набор строк/итог (не «200 OK»). Любое расхождение = баг (репро→фикс→тест→деплой). +- [ ] Синонимы параметров groupId/productGroupId на всех list-эндпоинтах. +- [ ] filters-sweep в nightly (~/nightly-verify.sh) и quality-watchdog (ежедневно). +- [ ] Кратко — то же для mobile API. + +## Часть 4. Приёмочное тестирование процессов (UAT) — см. docs/uat-report-.md +- [ ] Матрица покрытия: что уже зелёное (не перепроверять) vs новое. +- [ ] P1–P4, каждый контрол, positive + границы + ошибки ввода + права + mobile 390px. + +## Найденные попутно баги (фикс в этой же сессии) +- [ ] showcase «Последние продажи» — будущие времена (TZ double-shift) — фикс по корню. +- [ ] causeUserId=null в уведомлениях о минусах — не пишется автор документа (все типы). +- [ ] Ошибки ввода приходят техническим английским (JSON-conversion) — перевести все + model-binding/validation ошибки в дружелюбный русский (web toast + mobile API). + +## Что НЕ трогаем +Прод, shared-postgres, global.json, gateway nginx, sklad. diff --git a/docs/mobile-api.md b/docs/mobile-api.md index f6532f7..9ef7fba 100644 --- a/docs/mobile-api.md +++ b/docs/mobile-api.md @@ -5,12 +5,20 @@ OpenIddict password/refresh grant, `client_id=food-market-web`, Bearer-токе ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`. ## 1. Остатки по группе товаров (с вложенными) -`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&page=&pageSize=&sort=&order=` +`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&negativeOnly=&page=&pageSize=&sort=&order=` -- `groupId` — фильтр по группе товаров **включая все вложенные подгруппы** - (по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой. -- Ответ: `PagedResult` — `{ items:[{ productId, productName, article, unitSymbol, - storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`. +- `groupId` (синоним `productGroupId`) — фильтр по группе товаров **включая все + вложенные подгруппы** (по денормализованному `Path`). Комбинируется с `storeId`, + `search`, сортировкой. +- **Выборка идёт ОТ товаров (не-услуги, не-архивные) LEFT JOIN остатки**, поэтому + товар без прихода тоже виден (`quantity 0`). `includeZero=true` → показать нулевые; + по умолчанию (`false`) — только `quantity != 0`; `negativeOnly=true` → только минусы. +- Склад выбран (`storeId`) → остаток этого склада; склад не выбран → агрегат (сумма) + по всем складам, `storeId=00000000-…`, `storeName="Все склады"`. +- `search` ищет по названию, артикулу **и штрихкоду**. +- Ответ: `PagedResult` — `{ items:[{ productId, productName, article, barcode, + unitSymbol, storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`. + `barcode` — основной штрихкод товара (для различения одноимённых товаров), может быть `null`. ## 2. Добавить штрихкод к товару `POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`. diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 278fa0a..b71d994 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -120,6 +120,7 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] Guid? groupId, + [FromQuery] Guid? productGroupId, [FromQuery] bool? isService, [FromQuery] Packaging? packaging, [FromQuery] bool? isMarked, @@ -145,6 +146,7 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) case "all": /* без фильтра */ break; } if (availableForSale is not null) q = q.Where(p => p.IsAvailableForSale == availableForSale); + groupId ??= productGroupId; // синонимы: оба параметра означают фильтр по группе if (groupId is not null) { // Include the whole subtree: match on the group's Path prefix so sub-groups also show up. @@ -224,6 +226,7 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) [FromQuery] string? format, [FromQuery] string? search, [FromQuery] Guid? groupId, + [FromQuery] Guid? productGroupId, [FromQuery] string? archived, CancellationToken ct) { @@ -235,7 +238,17 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) case "true": q = q.Where(p => p.IsArchived); break; case "all": break; } - if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId); + groupId ??= productGroupId; // синонимы, как в List + if (groupId is not null) + { + // Вся подгруппа по Path-префиксу (как в List), чтобы экспорт совпадал со списком. + var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct); + if (root is not null) + q = q.Where(p => p.ProductGroup != null && + (p.ProductGroup.Path == root.Path || p.ProductGroup.Path.StartsWith(root.Path + "/"))); + else + q = q.Where(p => p.ProductGroupId == groupId); + } if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim().ToLower(); diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs index 2f33def..356b833 100644 --- a/src/food-market.api/Controllers/Inventory/StockController.cs +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -16,7 +16,7 @@ public class StockController : ControllerBase public StockController(AppDbContext db) => _db = db; public record StockRow( - Guid ProductId, string ProductName, string? Article, string UnitSymbol, + Guid ProductId, string ProductName, string? Article, string? Barcode, string UnitSymbol, Guid StoreId, string StoreName, decimal Quantity, decimal ReservedQuantity, decimal Available); @@ -25,6 +25,7 @@ public record StockRow( [FromQuery] Guid? storeId, [FromQuery] Guid? productId, [FromQuery] Guid? groupId, + [FromQuery] Guid? productGroupId, [FromQuery] string? search, [FromQuery] bool includeZero = false, [FromQuery] bool negativeOnly = false, @@ -34,89 +35,175 @@ public record StockRow( [FromQuery] string? order = null, CancellationToken ct = default) { - var q = from s in _db.Stocks - join p in _db.Products on s.ProductId equals p.Id - join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id - join st in _db.Stores on s.StoreId equals st.Id - select new { s, p, u, st }; + var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct); + var total = await products.CountAsync(ct); - if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value); - if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value); - if (groupId.HasValue) - { - var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct); - q = q.Where(x => groupIds.Contains(x.p.ProductGroupId)); - } - if (negativeOnly) q = q.Where(x => x.s.Quantity < 0); - else if (!includeZero) q = q.Where(x => x.s.Quantity != 0); - if (!string.IsNullOrWhiteSpace(search)) - { - var term = $"%{search.Trim()}%"; - q = q.Where(x => EF.Functions.ILike(x.p.Name, term) - || (x.p.Article != null && EF.Functions.ILike(x.p.Article, term))); - } - - var total = await q.CountAsync(ct); var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase); - q = (sort, desc) switch + var ordered = OrderProducts(products, sort, desc, storeId); + var paged = await ordered.Skip((page - 1) * pageSize).Take(pageSize) + .Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct); + + var (sid, storeName) = await StoreContextAsync(storeId, ct); + var ids = paged.Select(x => x.Id).ToList(); + var stock = await StockByProductAsync(ids, storeId, ct); + var barcodes = await BarcodesByProductAsync(ids, ct); + var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct); + + var items = paged.Select(x => { - ("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name), - ("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name), - ("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name), - ("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name), - ("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name), - ("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name), - ("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name), - ("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name), - ("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name), - ("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name), - ("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name), - ("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name), - ("name", true) => q.OrderByDescending(x => x.p.Name), - _ => q.OrderBy(x => x.p.Name), - }; - var items = await q - .Skip((page - 1) * pageSize).Take(pageSize) - .Select(x => new StockRow( - x.p.Id, x.p.Name, x.p.Article, x.u.Name, - x.st.Id, x.st.Name, - x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) - .ToListAsync(ct); + var (qty, reserved) = stock.GetValueOrDefault(x.Id); + return new StockRow( + x.Id, x.Name, x.Article, barcodes.GetValueOrDefault(x.Id), + units.GetValueOrDefault(x.UnitOfMeasureId, ""), + sid, storeName, qty, reserved, qty - reserved); + }).ToList(); return new PagedResult { Items = items, Total = total, Page = page, PageSize = pageSize }; } - /// Sprint 19: экспорт остатков. - [HttpGet("stock/export")] - public async Task ExportStock( - [FromQuery] Guid? storeId, [FromQuery] string? search, - [FromQuery] bool includeZero = false, [FromQuery] string? format = null, - CancellationToken ct = default) + /// Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные), + /// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом + /// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем + /// IQueryable<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице, + /// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY). + private async Task> FilteredProductsAsync( + Guid? storeId, Guid? productId, Guid? groupId, string? search, + bool includeZero, bool negativeOnly, CancellationToken ct) { - var q = from s in _db.Stocks - join p in _db.Products on s.ProductId equals p.Id - join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id - join st in _db.Stores on s.StoreId equals st.Id - select new { s, p, u, st }; - if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value); - if (!includeZero) q = q.Where(x => x.s.Quantity != 0); + var products = _db.Products.Where(p => !p.IsService && !p.IsArchived); + if (productId.HasValue) products = products.Where(p => p.Id == productId.Value); + if (groupId.HasValue) + { + var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct); + products = products.Where(p => groupIds.Contains(p.ProductGroupId)); + } if (!string.IsNullOrWhiteSpace(search)) { var term = $"%{search.Trim()}%"; - q = q.Where(x => EF.Functions.ILike(x.p.Name, term) - || (x.p.Article != null && EF.Functions.ILike(x.p.Article, term))); + products = products.Where(p => EF.Functions.ILike(p.Name, term) + || (p.Article != null && EF.Functions.ILike(p.Article, term)) + || p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term))); } - var rows = await q.OrderBy(x => x.p.Name) - .Select(x => new StockExportRow( - x.p.Name, x.p.Article, x.u.Name, x.st.Name, - x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) + + // Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE + // EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory). + var sid = storeId ?? Guid.Empty; + if (storeId.HasValue) + { + if (negativeOnly) + products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m); + else if (!includeZero) + products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m); + } + else + { + if (negativeOnly) + products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m); + else if (!includeZero) + products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m); + } + return products; + } + + /// Сортировка списка остатков. По количеству/резерву/доступному — + /// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит). + private IQueryable OrderProducts( + IQueryable products, string? sort, bool desc, Guid? storeId) + { + var sid = storeId ?? Guid.Empty; + System.Linq.Expressions.Expression> qty = storeId.HasValue + ? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) + : (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m); + System.Linq.Expressions.Expression> reserved = storeId.HasValue + ? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m) + : (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m); + System.Linq.Expressions.Expression> avail = storeId.HasValue + ? (p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) + - (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m)) + : (p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) + - (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m)); + System.Linq.Expressions.Expression> unit = + p => _db.UnitsOfMeasure.Where(u => u.Id == p.UnitOfMeasureId).Select(u => u.Name).FirstOrDefault(); + + return sort switch + { + "article" => desc ? products.OrderByDescending(p => p.Article).ThenBy(p => p.Name) : products.OrderBy(p => p.Article).ThenBy(p => p.Name), + "unit" => desc ? products.OrderByDescending(unit).ThenBy(p => p.Name) : products.OrderBy(unit).ThenBy(p => p.Name), + "quantity" => desc ? products.OrderByDescending(qty).ThenBy(p => p.Name) : products.OrderBy(qty).ThenBy(p => p.Name), + "reserved" => desc ? products.OrderByDescending(reserved).ThenBy(p => p.Name) : products.OrderBy(reserved).ThenBy(p => p.Name), + "available" => desc ? products.OrderByDescending(avail).ThenBy(p => p.Name) : products.OrderBy(avail).ThenBy(p => p.Name), + "store" => products.OrderBy(p => p.Name), // один склад или агрегат «все склады» + "name" when desc => products.OrderByDescending(p => p.Name), + _ => products.OrderBy(p => p.Name), + }; + } + + private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId, CancellationToken ct) + => storeId.HasValue + ? (storeId.Value, await _db.Stores.Where(s => s.Id == storeId.Value).Select(s => s.Name).FirstOrDefaultAsync(ct) ?? "") + : (Guid.Empty, "Все склады"); + + /// Остаток (кол-во/резерв) по товарам: один склад — его строка; без склада — + /// сумма по складам. Нет строки Stock → товара нет в словаре (трактуем как 0). + private async Task> StockByProductAsync( + IReadOnlyCollection ids, Guid? storeId, CancellationToken ct) + { + if (ids.Count == 0) return new(); + var sq = _db.Stocks.Where(s => ids.Contains(s.ProductId)); + if (storeId.HasValue) sq = sq.Where(s => s.StoreId == storeId.Value); + var raw = await sq.Select(s => new { s.ProductId, s.Quantity, s.ReservedQuantity }).ToListAsync(ct); + return raw.GroupBy(x => x.ProductId) + .ToDictionary(g => g.Key, g => (g.Sum(x => x.Quantity), g.Sum(x => x.ReservedQuantity))); + } + + private async Task> UnitNamesAsync(IReadOnlyCollection unitIds, CancellationToken ct) + { + if (unitIds.Count == 0) return new(); + return await _db.UnitsOfMeasure.Where(u => unitIds.Contains(u.Id)) + .Select(u => new { u.Id, u.Name }).ToDictionaryAsync(u => u.Id, u => u.Name, ct); + } + + /// Основной штрихкод (IsPrimary → первый) для набора товаров — одним + /// запросом, чтобы не тащить коррелированный подзапрос в основную выборку. + private async Task> BarcodesByProductAsync(IReadOnlyCollection productIds, CancellationToken ct) + { + if (productIds.Count == 0) return new(); + var raw = await _db.ProductBarcodes + .Where(b => productIds.Contains(b.ProductId)) + .Select(b => new { b.ProductId, b.Code, b.IsPrimary }) .ToListAsync(ct); - var headers = new[] { "Товар", "Артикул", "Ед.", "Склад", "Количество", "Резерв", "Доступно" }; + return raw.GroupBy(b => b.ProductId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).First()); + } + + /// Sprint 19: экспорт остатков (те же фильтры, что и список; включая нулевые). + [HttpGet("stock/export")] + public async Task ExportStock( + [FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId, + [FromQuery] string? search, + [FromQuery] bool includeZero = false, [FromQuery] string? format = null, + CancellationToken ct = default) + { + var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, includeZero, false, ct); + var paged = await products.OrderBy(p => p.Name) + .Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct); + var (_, storeName) = await StoreContextAsync(storeId, ct); + var ids = paged.Select(x => x.Id).ToList(); + var stock = await StockByProductAsync(ids, storeId, ct); + var barcodes = await BarcodesByProductAsync(ids, ct); + var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct); + var rows = paged.Select(x => + { + var (qty, reserved) = stock.GetValueOrDefault(x.Id); + return new StockExportRow(x.Name, x.Article, barcodes.GetValueOrDefault(x.Id), + units.GetValueOrDefault(x.UnitOfMeasureId, ""), storeName, qty, reserved, qty - reserved); + }).ToList(); + var headers = new[] { "Товар", "Артикул", "Штрихкод", "Ед.", "Склад", "Количество", "Резерв", "Доступно" }; return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase) ? ReportExport.Xlsx(rows, "stock", "Остатки", headers) : ReportExport.Csv(rows, "stock", headers); } - public record StockExportRow(string Name, string? Article, string Unit, string Store, + public record StockExportRow(string Name, string? Article, string? Barcode, string Unit, string Store, decimal Quantity, decimal Reserved, decimal Available); /// Id группы + всех её вложенных подгрупп (по денормализованному diff --git a/src/food-market.api/Controllers/Reports/StockReportController.cs b/src/food-market.api/Controllers/Reports/StockReportController.cs index 6996605..27f980d 100644 --- a/src/food-market.api/Controllers/Reports/StockReportController.cs +++ b/src/food-market.api/Controllers/Reports/StockReportController.cs @@ -42,11 +42,12 @@ public record StockRow( [FromQuery] DateTime? date, [FromQuery] Guid? storeId, [FromQuery] Guid? productGroupId, + [FromQuery] Guid? groupId, [FromQuery] bool includeZero = false, CancellationToken ct = default) { var on = AsUtc(date) ?? DateTime.UtcNow; - return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct)); + return Ok(await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct)); } [HttpGet("export")] @@ -54,12 +55,13 @@ public record StockRow( [FromQuery] DateTime? date, [FromQuery] Guid? storeId, [FromQuery] Guid? productGroupId, + [FromQuery] Guid? groupId, [FromQuery] bool includeZero = false, [FromQuery] string format = "csv", CancellationToken ct = default) { var on = AsUtc(date) ?? DateTime.UtcNow; - var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct); + var rows = await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct); var name = $"stock-{on:yyyyMMdd}"; var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" }; return format.ToLower() switch @@ -79,6 +81,18 @@ public record StockRow( _ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc), }; + /// Id группы + всех её вложенных подгрупп (по денормализованному Path). + private async Task> DescendantGroupIdsAsync(Guid groupId, CancellationToken ct) + { + var path = await _db.ProductGroups.Where(g => g.Id == groupId) + .Select(g => g.Path).FirstOrDefaultAsync(ct); + if (string.IsNullOrEmpty(path)) + return await _db.ProductGroups.Where(g => g.Id == groupId).Select(g => g.Id).ToListAsync(ct); + return await _db.ProductGroups + .Where(g => g.Path == path || g.Path.StartsWith(path + "/")) + .Select(g => g.Id).ToListAsync(ct); + } + private async Task> BuildAsync( DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct) { @@ -108,18 +122,33 @@ public record StockRow( var lastCosts = lastCostsRaw .ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m); + // Набор допустимых товаров (не-услуги; фильтр по группе с вложенностью). + HashSet? allowedSet = null; 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(); + var groupIds = await DescendantGroupIdsAsync(productGroupId.Value, ct); + allowedSet = (await _db.Products.AsNoTracking() + .Where(p => !p.IsService && !p.IsArchived && groupIds.Contains(p.ProductGroupId)) + .Select(p => p.Id).ToListAsync(ct)).ToHashSet(); + qtyByPair = qtyByPair.Where(x => allowedSet.Contains(x.ProductId)).ToList(); } - if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList(); + if (!includeZero) + { + qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList(); + } + else + { + // «Показать нулевые»: добавляем товары без движений (ни разу не приходованы) — + // строка с Qty 0. Склад выбран → его id; все склады → агрегат «Все склады». + var seen = qtyByPair.Select(x => x.ProductId).ToHashSet(); + var zeroQ = _db.Products.AsNoTracking().Where(p => !p.IsService && !p.IsArchived && !seen.Contains(p.Id)); + if (allowedSet is not null) zeroQ = zeroQ.Where(p => allowedSet.Contains(p.Id)); + var zeroStore = storeId ?? Guid.Empty; + var zeroIds = await zeroQ.Select(p => p.Id).ToListAsync(ct); + qtyByPair = qtyByPair.Concat( + zeroIds.Select(id => new { ProductId = id, StoreId = zeroStore, Qty = 0m })).ToList(); + } // Подтаскиваем имена товара/склада. var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList(); @@ -144,9 +173,10 @@ public record StockRow( 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; + var storeName = x.StoreId == Guid.Empty ? "Все склады" : (s?.Name ?? "(unknown)"); return new StockRow( x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName, - x.StoreId, s?.Name ?? "(unknown)", + x.StoreId, storeName, x.Qty, cost, x.Qty * cost); }) .OrderBy(r => r.ProductName) diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index feaa222..eb9fd69 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -70,7 +70,7 @@ export interface Product { } export interface StockRow { - productId: string; productName: string; article: string | null; unitName: string; + productId: string; productName: string; article: string | null; barcode: string | null; unitName: string; storeId: string; storeName: string; quantity: number; reservedQuantity: number; available: number; } diff --git a/src/food-market.web/src/pages/StockPage.tsx b/src/food-market.web/src/pages/StockPage.tsx index 9527975..4b797d2 100644 --- a/src/food-market.web/src/pages/StockPage.tsx +++ b/src/food-market.web/src/pages/StockPage.tsx @@ -63,6 +63,9 @@ export function StockPage() { `${r.productId}:${r.storeId}`} sortKey={sortKey} sortOrder={sortOrder} @@ -71,7 +74,11 @@ export function StockPage() { { header: 'Товар', sortKey: 'name', cell: (r) => (
{r.productName}
- {r.article &&
{r.article}
} + {(r.article || r.barcode) && ( +
+ {[r.article, r.barcode].filter(Boolean).join(' · ')} +
+ )}
)}, { header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName }, @@ -88,7 +95,6 @@ export function StockPage() { )}, ]} - empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)." /> ) diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs index d643055..270dba1 100644 --- a/tests/food-market.IntegrationTests/MobileApiTests.cs +++ b/tests/food-market.IntegrationTests/MobileApiTests.cs @@ -85,6 +85,60 @@ public async Task Stock_filter_by_group_includes_nested_groups() childIds.Should().NotContain(pInParent); } + // ── 1b. «Показать нулевые»: товары без прихода тоже видны ────────────── + [Fact] + public async Task Stock_includeZero_shows_products_never_received() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"mob-zero-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + + var withStock = await api.CreateProductAsync(refs, $"Приход-{Guid.NewGuid():N}", 100m, RandomBarcode()); + var neverReceived = await api.CreateProductAsync(refs, $"Нольный-{Guid.NewGuid():N}", 150m, RandomBarcode()); + await SeedStockAsync(api, refs, withStock, 5m); + + // Без includeZero — только товар с остатком. + var def = await api.Http.GetFromJsonAsync("/api/inventory/stock?pageSize=500"); + var defIds = def.GetProperty("items").EnumerateArray() + .Select(x => x.GetProperty("productId").GetString()).ToHashSet(); + defIds.Should().Contain(withStock); + defIds.Should().NotContain(neverReceived, "без includeZero товар без прихода не показывается"); + + // С includeZero=true — виден и товар без прихода, с quantity 0 и штрихкодом. + var all = await api.Http.GetFromJsonAsync("/api/inventory/stock?includeZero=true&pageSize=500"); + var zeroRow = all.GetProperty("items").EnumerateArray() + .FirstOrDefault(x => x.GetProperty("productId").GetString() == neverReceived); + zeroRow.ValueKind.Should().NotBe(JsonValueKind.Undefined, "товар без прихода виден при includeZero"); + zeroRow.GetProperty("quantity").GetDecimal().Should().Be(0m); + zeroRow.GetProperty("barcode").GetString().Should().NotBeNullOrEmpty("штрихкод отдаётся для различения дублей"); + } + + // ── 1c. Фильтр по группе + нулевые (корневая причина iOS-бага) ───────── + [Fact] + public async Task Stock_group_filter_with_includeZero_shows_group_products_without_stock() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"mob-gz-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + + var grp = await CreateGroupAsync(api, $"Группа-{Guid.NewGuid():N}", null); + var p = await CreateProductInGroupAsync(api, refs, $"БезПрихода-{Guid.NewGuid():N}", 100m, RandomBarcode(), grp); + // Остаток НЕ заводим. + + // Без includeZero — группа «пустая». + var def = await api.Http.GetFromJsonAsync($"/api/inventory/stock?groupId={grp}&pageSize=100"); + def.GetProperty("total").GetInt32().Should().Be(0); + + // С includeZero — товар группы виден с нулём. И productGroupId — синоним groupId. + foreach (var param in new[] { $"groupId={grp}", $"productGroupId={grp}" }) + { + var all = await api.Http.GetFromJsonAsync($"/api/inventory/stock?{param}&includeZero=true&pageSize=100"); + var ids = all.GetProperty("items").EnumerateArray() + .Select(x => x.GetProperty("productId").GetString()).ToHashSet(); + ids.Should().Contain(p, $"параметр {param} должен фильтровать по группе и показывать нулевые"); + } + } + // ── 2. Добавление штрихкода к товару ─────────────────────────────────── [Fact] public async Task Add_barcode_creates_and_rejects_duplicate() diff --git a/tests/food-market.IntegrationTests/Support/ApiActor.cs b/tests/food-market.IntegrationTests/Support/ApiActor.cs index dc2b61b..70063f7 100644 --- a/tests/food-market.IntegrationTests/Support/ApiActor.cs +++ b/tests/food-market.IntegrationTests/Support/ApiActor.cs @@ -181,7 +181,9 @@ public async Task CreateRetailPointAsync(string storeId, string name) public async Task StockOfAsync(string storeId, string productId) { - var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200"); + // Остаток товара на конкретном складе: фильтруем по складу (без storeId + // список отдаёт агрегат «Все склады» с storeId = Guid.Empty). + var list = await ListAsync($"/api/inventory/stock?productId={productId}&storeId={storeId}&includeZero=true&pageSize=200"); var row = list.FirstOrDefault(i => i.GetProperty("productId").GetString() == productId && i.GetProperty("storeId").GetString() == storeId);