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:
parent
80070cbace
commit
193ba49ecb
105
docs/filters-sweep-progress.md
Normal file
105
docs/filters-sweep-progress.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице,
|
||||
/// чтобы не тащить подзапросы в 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 группы + всех её вложенных подгрупп (по денормализованному
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue