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? }`.
|
ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`.
|
||||||
|
|
||||||
## 1. Остатки по группе товаров (с вложенными)
|
## 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` — фильтр по группе товаров **включая все вложенные подгруппы**
|
- `groupId` (синоним `productGroupId`) — фильтр по группе товаров **включая все
|
||||||
(по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой.
|
вложенные подгруппы** (по денормализованному `Path`). Комбинируется с `storeId`,
|
||||||
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, unitSymbol,
|
`search`, сортировкой.
|
||||||
storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
|
- **Выборка идёт ОТ товаров (не-услуги, не-архивные) 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. Добавить штрихкод к товару
|
## 2. Добавить штрихкод к товару
|
||||||
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.
|
`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(
|
public async Task<ActionResult<PagedResult<ProductDto>>> List(
|
||||||
[FromQuery] PagedRequest req,
|
[FromQuery] PagedRequest req,
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
|
[FromQuery] Guid? productGroupId,
|
||||||
[FromQuery] bool? isService,
|
[FromQuery] bool? isService,
|
||||||
[FromQuery] Packaging? packaging,
|
[FromQuery] Packaging? packaging,
|
||||||
[FromQuery] bool? isMarked,
|
[FromQuery] bool? isMarked,
|
||||||
|
|
@ -145,6 +146,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
case "all": /* без фильтра */ break;
|
case "all": /* без фильтра */ break;
|
||||||
}
|
}
|
||||||
if (availableForSale is not null) q = q.Where(p => p.IsAvailableForSale == availableForSale);
|
if (availableForSale is not null) q = q.Where(p => p.IsAvailableForSale == availableForSale);
|
||||||
|
groupId ??= productGroupId; // синонимы: оба параметра означают фильтр по группе
|
||||||
if (groupId is not null)
|
if (groupId is not null)
|
||||||
{
|
{
|
||||||
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
|
// 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? format,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
|
[FromQuery] Guid? productGroupId,
|
||||||
[FromQuery] string? archived,
|
[FromQuery] string? archived,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -235,7 +238,17 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
case "true": q = q.Where(p => p.IsArchived); break;
|
case "true": q = q.Where(p => p.IsArchived); break;
|
||||||
case "all": 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))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
var s = search.Trim().ToLower();
|
var s = search.Trim().ToLower();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ public class StockController : ControllerBase
|
||||||
public StockController(AppDbContext db) => _db = db;
|
public StockController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public record StockRow(
|
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,
|
Guid StoreId, string StoreName,
|
||||||
decimal Quantity, decimal ReservedQuantity, decimal Available);
|
decimal Quantity, decimal ReservedQuantity, decimal Available);
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ public record StockRow(
|
||||||
[FromQuery] Guid? storeId,
|
[FromQuery] Guid? storeId,
|
||||||
[FromQuery] Guid? productId,
|
[FromQuery] Guid? productId,
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
|
[FromQuery] Guid? productGroupId,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
[FromQuery] bool negativeOnly = false,
|
[FromQuery] bool negativeOnly = false,
|
||||||
|
|
@ -34,89 +35,175 @@ public record StockRow(
|
||||||
[FromQuery] string? order = null,
|
[FromQuery] string? order = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = from s in _db.Stocks
|
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
|
||||||
join p in _db.Products on s.ProductId equals p.Id
|
var total = await products.CountAsync(ct);
|
||||||
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 (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);
|
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),
|
var (qty, reserved) = stock.GetValueOrDefault(x.Id);
|
||||||
("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name),
|
return new StockRow(
|
||||||
("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name),
|
x.Id, x.Name, x.Article, barcodes.GetValueOrDefault(x.Id),
|
||||||
("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name),
|
units.GetValueOrDefault(x.UnitOfMeasureId, ""),
|
||||||
("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name),
|
sid, storeName, qty, reserved, qty - reserved);
|
||||||
("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name),
|
}).ToList();
|
||||||
("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);
|
|
||||||
|
|
||||||
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sprint 19: экспорт остатков.</summary>
|
/// <summary>Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные),
|
||||||
[HttpGet("stock/export")]
|
/// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом
|
||||||
public async Task<IActionResult> ExportStock(
|
/// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем
|
||||||
[FromQuery] Guid? storeId, [FromQuery] string? search,
|
/// IQueryable<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице,
|
||||||
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
|
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
|
||||||
CancellationToken ct = default)
|
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
|
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
|
||||||
join p in _db.Products on s.ProductId equals p.Id
|
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
|
||||||
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
if (groupId.HasValue)
|
||||||
join st in _db.Stores on s.StoreId equals st.Id
|
{
|
||||||
select new { s, p, u, st };
|
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
|
||||||
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
products = products.Where(p => groupIds.Contains(p.ProductGroupId));
|
||||||
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
var term = $"%{search.Trim()}%";
|
var term = $"%{search.Trim()}%";
|
||||||
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|
products = products.Where(p => EF.Functions.ILike(p.Name, term)
|
||||||
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, 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(
|
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
|
||||||
x.p.Name, x.p.Article, x.u.Name, x.st.Name,
|
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
|
||||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
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);
|
.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)
|
return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase)
|
||||||
? ReportExport.Xlsx(rows, "stock", "Остатки", headers)
|
? ReportExport.Xlsx(rows, "stock", "Остатки", headers)
|
||||||
: ReportExport.Csv(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);
|
decimal Quantity, decimal Reserved, decimal Available);
|
||||||
|
|
||||||
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному
|
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,12 @@ public record StockRow(
|
||||||
[FromQuery] DateTime? date,
|
[FromQuery] DateTime? date,
|
||||||
[FromQuery] Guid? storeId,
|
[FromQuery] Guid? storeId,
|
||||||
[FromQuery] Guid? productGroupId,
|
[FromQuery] Guid? productGroupId,
|
||||||
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var on = AsUtc(date) ?? DateTime.UtcNow;
|
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")]
|
[HttpGet("export")]
|
||||||
|
|
@ -54,12 +55,13 @@ public record StockRow(
|
||||||
[FromQuery] DateTime? date,
|
[FromQuery] DateTime? date,
|
||||||
[FromQuery] Guid? storeId,
|
[FromQuery] Guid? storeId,
|
||||||
[FromQuery] Guid? productGroupId,
|
[FromQuery] Guid? productGroupId,
|
||||||
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
[FromQuery] string format = "csv",
|
[FromQuery] string format = "csv",
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var on = AsUtc(date) ?? DateTime.UtcNow;
|
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 name = $"stock-{on:yyyyMMdd}";
|
||||||
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
|
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
|
||||||
return format.ToLower() switch
|
return format.ToLower() switch
|
||||||
|
|
@ -79,6 +81,18 @@ public record StockRow(
|
||||||
_ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc),
|
_ => 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(
|
private async Task<List<StockRow>> BuildAsync(
|
||||||
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
|
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -108,18 +122,33 @@ public record StockRow(
|
||||||
var lastCosts = lastCostsRaw
|
var lastCosts = lastCostsRaw
|
||||||
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
|
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
|
||||||
|
|
||||||
|
// Набор допустимых товаров (не-услуги; фильтр по группе с вложенностью).
|
||||||
|
HashSet<Guid>? allowedSet = null;
|
||||||
if (productGroupId is not null)
|
if (productGroupId is not null)
|
||||||
{
|
{
|
||||||
// Фильтр по группе — отдельным запросом, чтобы получить ProductIds.
|
var groupIds = await DescendantGroupIdsAsync(productGroupId.Value, ct);
|
||||||
var allowed = await _db.Products.AsNoTracking()
|
allowedSet = (await _db.Products.AsNoTracking()
|
||||||
.Where(p => p.ProductGroupId == productGroupId)
|
.Where(p => !p.IsService && !p.IsArchived && groupIds.Contains(p.ProductGroupId))
|
||||||
.Select(p => p.Id)
|
.Select(p => p.Id).ToListAsync(ct)).ToHashSet();
|
||||||
.ToListAsync(ct);
|
qtyByPair = qtyByPair.Where(x => allowedSet.Contains(x.ProductId)).ToList();
|
||||||
var set = allowed.ToHashSet();
|
|
||||||
qtyByPair = qtyByPair.Where(x => set.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();
|
var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList();
|
||||||
|
|
@ -144,9 +173,10 @@ public record StockRow(
|
||||||
sMap.TryGetValue(x.StoreId, out var s);
|
sMap.TryGetValue(x.StoreId, out var s);
|
||||||
lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost);
|
lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost);
|
||||||
if (cost == 0m && p is not null) cost = p.Cost;
|
if (cost == 0m && p is not null) cost = p.Cost;
|
||||||
|
var storeName = x.StoreId == Guid.Empty ? "Все склады" : (s?.Name ?? "(unknown)");
|
||||||
return new StockRow(
|
return new StockRow(
|
||||||
x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName,
|
x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName,
|
||||||
x.StoreId, s?.Name ?? "(unknown)",
|
x.StoreId, storeName,
|
||||||
x.Qty, cost, x.Qty * cost);
|
x.Qty, cost, x.Qty * cost);
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.ProductName)
|
.OrderBy(r => r.ProductName)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export interface Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockRow {
|
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;
|
storeId: string; storeName: string;
|
||||||
quantity: number; reservedQuantity: number; available: number;
|
quantity: number; reservedQuantity: number; available: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ export function StockPage() {
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
empty={!includeZero && !negativeOnly
|
||||||
|
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
|
||||||
|
: 'Нет данных'}
|
||||||
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
|
|
@ -71,7 +74,11 @@ export function StockPage() {
|
||||||
{ header: 'Товар', sortKey: 'name', cell: (r) => (
|
{ header: 'Товар', sortKey: 'name', cell: (r) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{r.productName}</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>
|
</div>
|
||||||
)},
|
)},
|
||||||
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
|
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
|
||||||
|
|
@ -88,7 +95,6 @@ export function StockPage() {
|
||||||
</span>
|
</span>
|
||||||
)},
|
)},
|
||||||
]}
|
]}
|
||||||
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
|
|
||||||
/>
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,60 @@ public async Task Stock_filter_by_group_includes_nested_groups()
|
||||||
childIds.Should().NotContain(pInParent);
|
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. Добавление штрихкода к товару ───────────────────────────────────
|
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_barcode_creates_and_rejects_duplicate()
|
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)
|
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 =>
|
var row = list.FirstOrDefault(i =>
|
||||||
i.GetProperty("productId").GetString() == productId &&
|
i.GetProperty("productId").GetString() == productId &&
|
||||||
i.GetProperty("storeId").GetString() == storeId);
|
i.GetProperty("storeId").GetString() == storeId);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue