fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода

Остатки строились ОТ таблицы 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 09:44:50 +05:00
parent 80070cbace
commit 193ba49ecb
9 changed files with 393 additions and 88 deletions

View file

@ -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=<g>` → 5047 (раньше молча игнорировался → 30 894). ✅ синоним.
- `stock?groupId=<g>&includeZero=true` → 5047 (iOS-баг: было 0). ✅
- `stock?groupId=<g>` без флага → 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-<date>.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.

View file

@ -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<StockRow>` — `{ 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<StockRow>` — `{ items:[{ productId, productName, article, barcode,
unitSymbol, storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
`barcode` — основной штрихкод товара (для различения одноимённых товаров), может быть `null`.
## 2. Добавить штрихкод к товару
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.

View file

@ -120,6 +120,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
public async Task<ActionResult<PagedResult<ProductDto>>> 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<decimal> 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<decimal> 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<decimal> 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();

View file

@ -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<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
/// <summary>Sprint 19: экспорт остатков.</summary>
[HttpGet("stock/export")]
public async Task<IActionResult> ExportStock(
[FromQuery] Guid? storeId, [FromQuery] string? search,
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
CancellationToken ct = default)
/// <summary>Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные),
/// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом
/// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем
/// IQueryable&lt;Product&gt; — остаток/ед./штрихкод дотягиваем в памяти по странице,
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
private async Task<IQueryable<Domain.Catalog.Product>> 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;
}
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
private IQueryable<Domain.Catalog.Product> OrderProducts(
IQueryable<Domain.Catalog.Product> products, string? sort, bool desc, Guid? storeId)
{
var sid = storeId ?? Guid.Empty;
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> 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<Func<Domain.Catalog.Product, decimal>> 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<Func<Domain.Catalog.Product, decimal>> 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<Func<Domain.Catalog.Product, string?>> 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, "Все склады");
/// <summary>Остаток (кол-во/резерв) по товарам: один склад — его строка; без склада —
/// сумма по складам. Нет строки Stock → товара нет в словаре (трактуем как 0).</summary>
private async Task<Dictionary<Guid, (decimal Qty, decimal Reserved)>> StockByProductAsync(
IReadOnlyCollection<Guid> 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<Dictionary<Guid, string>> UnitNamesAsync(IReadOnlyCollection<Guid> 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);
}
/// <summary>Основной штрихкод (IsPrimary → первый) для набора товаров — одним
/// запросом, чтобы не тащить коррелированный подзапрос в основную выборку.</summary>
private async Task<Dictionary<Guid, string>> BarcodesByProductAsync(IReadOnlyCollection<Guid> 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());
}
/// <summary>Sprint 19: экспорт остатков (те же фильтры, что и список; включая нулевые).</summary>
[HttpGet("stock/export")]
public async Task<IActionResult> 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);
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному

View file

@ -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),
};
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному Path).</summary>
private async Task<List<Guid>> 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<List<StockRow>> 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<Guid>? 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)

View file

@ -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;
}

View file

@ -63,6 +63,9 @@ export function StockPage() {
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
empty={!includeZero && !negativeOnly
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
: 'Нет данных'}
rowKey={(r) => `${r.productId}:${r.storeId}`}
sortKey={sortKey}
sortOrder={sortOrder}
@ -71,7 +74,11 @@ export function StockPage() {
{ header: 'Товар', sortKey: 'name', cell: (r) => (
<div>
<div className="font-medium">{r.productName}</div>
{r.article && <div className="text-xs text-slate-500 dark:text-slate-400 font-mono">{r.article}</div>}
{(r.article || r.barcode) && (
<div className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{[r.article, r.barcode].filter(Boolean).join(' · ')}
</div>
)}
</div>
)},
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
@ -88,7 +95,6 @@ export function StockPage() {
</span>
)},
]}
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
/>
</ListPageShell>
)

View file

@ -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<JsonElement>("/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<JsonElement>("/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<JsonElement>($"/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<JsonElement>($"/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()

View file

@ -181,7 +181,9 @@ public async Task<string> CreateRetailPointAsync(string storeId, string name)
public async Task<decimal> 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);