Compare commits

..

No commits in common. "5a2ab4589c32c466a6c7c8a107d1fb79cfaea667" and "80070cbace8b7684487b1e007132d99ec41f11b4" have entirely different histories.

25 changed files with 107 additions and 1132 deletions

View file

@ -1,122 +0,0 @@
# Фильтры и «нулевые остатки» — прогресс и проверка
Жалоба владельца (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
- [x] `tests/e2e/filters-sweep.ts` + `filters-sweep.sh`: поднимает свежую temp-орг,
сеет контролируемый набор (внук-со-стоком / ребёнок-ноль / родитель-минус / поиск /
услуга в 3-уровневой группе), и для каждого контрола НЕЗАВИСИМО считает ожидаемый
набор id и сверяет. 21/21 ✓ на стенде:
- stock: includeZero (нулевые), negativeOnly, groupId/productGroupId (3 уровня),
store-specific (пусто/агрегат), поиск по арт/ШК, сортировка по кол-ву (монотонна),
пагинация (разбиение без пересечений, total), экспорт-с-фильтром (число строк).
- catalog: groupId/productGroupId (поддерево), isService.
- [x] Синонимы groupId/productGroupId проверяются и для stock, и для catalog.
- [x] filters-sweep в nightly (`~/nightly-verify.sh` step 3c) и quality-watchdog
(`~/quality-watchdog.sh` step 12, суточная каденция в 03:XX).
- [~] Mobile API: те же /api/inventory/stock проверки покрыты (iOS ходит в этот же
эндпоинт) + integration MobileApiTests. Остальные моб-списки — по мере необходимости.
- [ ] Расширить реестр на остальные web-списки (продажи/приёмки/движения/контрагенты/
сотрудники) — периоды/статусы/сорт — следующими итерациями.
## Часть 4. Приёмочное тестирование процессов (UAT) — см. docs/uat-report-<date>.md
- [ ] Матрица покрытия: что уже зелёное (не перепроверять) vs новое.
- [ ] P1–P4, каждый контрол, positive + границы + ошибки ввода + права + mobile 390px.
## Найденные попутно баги (фикс в этой же сессии)
- [x] showcase «Последние продажи» — будущие времена. Корень: дата чека бралась от
клиента без нормализации. `BusinessTime.ToInstantUtc` (Kind→UTC + clamp будущего к now)
в рознице и приёме чеков с кассы. Юнит-тесты. Коммит f3a177a.
- [x] causeUserId=null в уведомлениях о минусах. Корень: StockService писал автора
только из черновика; теперь fallback на `ITenantContext.UserId` в единой точке
ApplyMovementAsync → пишется для ВСЕХ типов документов. Тест. Коммит 9dc8f6e.
- [x] Ошибки ввода техническим английским (JSON-conversion) → `ModelErrorTranslator`:
«Неверное значение поля «Причина»», без классов/путей/англ.; сырой текст только в лог.
Действует на web (toast) и mobile API. Тест. Коммит 4aceda3.
- [x] (НЕ баг, устаревший тест) `RetailOversellingTests` ждал 409 на продажу сверх
остатка — но по решению владельца 08.10 продажа в минус РАЗРЕШЕНА (создаётся
предупреждение). Тест обновлён на актуальную политику (204 + алерт).
- [x] (попутно) `food-market.UnitTests` не компилировался (FakeServer не реализовывал
новый QuickCreateProductAsync) → все юнит-тесты не запускались. Стаб добавлен. Коммит 1324ffd.
## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad.

View file

@ -5,20 +5,12 @@ OpenIddict password/refresh grant, `client_id=food-market-web`, Bearer-токе
ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`.
## 1. Остатки по группе товаров (с вложенными)
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&negativeOnly=&page=&pageSize=&sort=&order=`
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&page=&pageSize=&sort=&order=`
- `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`.
- `groupId` — фильтр по группе товаров **включая все вложенные подгруппы**
(по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой.
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, unitSymbol,
storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
## 2. Добавить штрихкод к товару
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.

View file

@ -1,76 +0,0 @@
# Приёмочное тестирование (UAT) — 2026-10-09
Стенд: `https://test.admin.food-market.kz`. Исполнитель: автономная сессия (Opus 4.8).
Правило владельца: **не перепроверять уже проверенное**, тестировать только непокрытое;
отчёт — двумя частями.
## Часть A. Уже покрыто (регрессия, НЕ перепроверяем заново)
| Область | Чем покрыто | Где |
|---|---|---|
| Сквозная «жизнь магазина» (регистрация→справочники→сотрудники/точки→деньги→3 приёмки→оплаты→смена/продажи/возврат/Z→склад→отчёты→инварианты→откат), сверка ЧИСЕЛ | full-chain e2e (режим C temp-орг + режим B демо) | `tests/e2e/full-chain.sh`, nightly step 3b, watchdog step 11 |
| Доступность всех пунктов меню, роутинг | menu-walk e2e | watchdog step 9 (4-час каденция) |
| Касса (POS): продажа/оплата/отложенные/смена/деньги, кросс-платформенный рендер | POS `--uitest`/smoke + headless-скриншоты (24 кадра) | `src/food-market.pos`, [[pos_done]] |
| iOS «FM Склад»: остатки по группе, штрихкоды, /api/me права, весовые этикетки | integration MobileApiTests | `tests/food-market.IntegrationTests/MobileApiTests.cs` |
| Отрицательные остатки: разрешение продажи/списания в минус, алерт, recount, авто-resolve | integration NegativeStockTests | там же |
| Отчёт остатков на дату, прибыль, продажи, ABC | integration StockReport/Profit/Sales/AbcReportTests | там же |
| Деньги/смена: внесения/выемки, авто-открытие смены, Z-отчёт | integration MoneyModule + full-chain | там же |
| Мультитенантность/авторизация/роли/2FA/SSO/лояльность/промо | integration + watchdog step 7 | [[sprint27_done]] и др. |
| Простой/расширенный режим (гейтинг функций) | AdvancedMode unit+integration+e2e | [[sprint29_done]] |
## Часть B. Проверено сейчас (непокрытое ранее)
### B1. Баг владельца «показать нулевые» + фильтры (Часть 1–2) — ✅
Полная сверка — в `docs/filters-sweep-progress.md`. Кратко:
| # | Проверка | Ожидание | Факт | ✓/✗ |
|---|---|---|---|---|
| 1 | Demo (30 894), `stock` без флага | только с остатком | total 12 | ✓ |
| 2 | Demo, `stock?includeZero=true` | все не-услуги/не-архив | **30 894** | ✓ |
| 3 | 4× «BIOBLAS шампунь» | различимы, не удалены, нулевой виден | разные арт.+ШК; art 19677 qty 0 виден | ✓ |
| 4 | `catalog?productGroupId=<g>` | фильтрует (был игнор) | 5047 (как groupId) | ✓ |
| 5 | `stock?groupId&includeZero` (iOS) | товары группы с нулями | 5047 (было 0) | ✓ |
| 6 | filters-sweep (21 смысловых проверок) | все сходятся | 21/21 ✓ | ✓ |
| 7 | Производительность (30k, стр. 500) | < 500 мс | 162–399 мс | ✓ |
### B2. Ошибки ввода — дружелюбный русский (не 500 / не технический англ.) — ✅
Проба POST с кривыми телами на 6 типах (fresh temp-орг), ни одного 500 / утечки классов:
| Эндпоинт | Вход | Ответ | ✓/✗ |
|---|---|---|---|
| `inventory/losses` | enum «ХЗ» | «Неверное значение поля «Причина»» | ✓ |
| `sales/retail` | payment «нал» | «Неверное значение поля «Оплата»» | ✓ |
| `catalog/products` | packaging «штук» | «Неверное значение поля «Фасовка»» | ✓ |
| `inventory/enters` | пустое тело | «Поле StoreId обязательно.» | ✓ |
| `catalog/counterparties` | пустое имя | «Название обязательно.» (+рус. заголовок) | ✓ |
| `inventory/transfers` | в простом режиме | 403 «доступно в расширенном режиме» | ✓ |
### B3. Экраны P1 на ширине телефона (390px) — ✅
Playwright-спека `stage-ui-uat-mobile.spec.ts` (в nightly-наборе): dashboard, catalog/products,
inventory/stock, sales/retail, reports — **без горизонтального скролла**, без console-ошибок и 5xx.
Скриншоты: `tests/e2e/reports/uat-mobile/` (галерея `index.html`).
| Экран | 390px без h-scroll | console/5xx | ✓/✗ |
|---|---|---|---|
| Дашборд | да | чисто | ✓ |
| Товары | да | чисто | ✓ |
| Остатки | да | чисто | ✓ |
| Продажи | да | чисто | ✓ |
| Отчёты | да | чисто | ✓ |
Плюс на «Остатках» (390px): по умолчанию виден товар со стоком с артикулом/штрихкодом,
нулевой скрыт; тумблер «Показать нулевые» показывает нулевой — визуально подтверждено
(`stock-includeZero-390.png`).
### B4. Прочие найденные и починенные баги (детали в filters-sweep-progress.md)
- Будущие времена в «Последние продажи» — нормализация даты к UTC + clamp будущего.
- `causeUserId=null` в алертах минуса — автор пишется для всех типов документов.
- Устаревший oversell-тест (409) приведён к политике «минус разрешён».
- `food-market.UnitTests` не компилировался (FakeServer) — починен, юнит-тесты снова идут.
## Остаётся на следующие итерации (честно, не покрыто этой сессией)
- Ручной обход КАЖДОГО контрола на КАЖДОМ экране P2–P4 в браузере (периоды/статусы/
сорт на продажах/приёмках/движениях/контрагентах/сотрудниках). Частично покрыто
filters-sweep (списки) и menu-walk (достижимость); полный per-control UI-UAT — далее.
- UI-гейтинг прав для роли «кассир» в вебе (API-права покрыты /api/me; UI-скрытие — далее).
- Реальное окно POS на Windows 11 с новым лого (VM-захват) — отдельная задача [[pos_windows_capture]].

View file

@ -120,7 +120,6 @@ 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,
@ -146,7 +145,6 @@ 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.
@ -226,7 +224,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] string? format,
[FromQuery] string? search,
[FromQuery] Guid? groupId,
[FromQuery] Guid? productGroupId,
[FromQuery] string? archived,
CancellationToken ct)
{
@ -238,17 +235,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
case "true": q = q.Where(p => p.IsArchived); break;
case "all": break;
}
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 (groupId is not null) 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? Barcode, string UnitSymbol,
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
Guid StoreId, string StoreName,
decimal Quantity, decimal ReservedQuantity, decimal Available);
@ -25,7 +25,6 @@ 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,
@ -35,175 +34,89 @@ public record StockRow(
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
var total = await products.CountAsync(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 };
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
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 =>
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 (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();
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
{
("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);
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
/// <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 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()}%";
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)));
}
// Остаток товара как скалярный подзапрос с 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);
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>
/// <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] Guid? storeId, [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 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);
if (!string.IsNullOrWhiteSpace(search))
{
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[] { "Товар", "Артикул", "Штрихкод", "Ед.", "Склад", "Количество", "Резерв", "Доступно" };
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 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))
.ToListAsync(ct);
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? Barcode, string Unit, string Store,
public record StockExportRow(string Name, string? Article, string Unit, string Store,
decimal Quantity, decimal Reserved, decimal Available);
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному

View file

@ -436,14 +436,11 @@ orderby l.SortOrder
if (currencyId == Guid.Empty)
throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");
// Время чека с кассы → UTC-инстант без «будущих» времён (касса могла прислать
// локальное/спешащее время). Остальные отметки движения берут тот же инстант.
var occurredAtUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(src.OccurredAt);
var number = await GenerateNumberAsync(occurredAtUtc, ct);
var number = await GenerateNumberAsync(src.OccurredAt, ct);
var sale = new RetailSale
{
Number = number,
Date = occurredAtUtc,
Date = src.OccurredAt,
Status = RetailSaleStatus.Draft,
StoreId = storeId,
CurrencyId = currencyId,
@ -501,7 +498,7 @@ orderby l.SortOrder
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: l.UnitPrice,
OccurredAt: occurredAtUtc), ct);
OccurredAt: src.OccurredAt), ct);
}
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
// от повторного возврата той же позиции). Насыщаем по строкам продукта.

View file

@ -42,12 +42,11 @@ 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 ?? groupId, includeZero, ct));
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
}
[HttpGet("export")]
@ -55,13 +54,12 @@ 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 ?? groupId, includeZero, ct);
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
var name = $"stock-{on:yyyyMMdd}";
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
return format.ToLower() switch
@ -81,18 +79,6 @@ 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)
{
@ -122,33 +108,18 @@ private async Task<List<Guid>> DescendantGroupIdsAsync(Guid groupId, Cancellatio
var lastCosts = lastCostsRaw
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
// Набор допустимых товаров (не-услуги; фильтр по группе с вложенностью).
HashSet<Guid>? allowedSet = null;
if (productGroupId is not null)
{
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();
// Фильтр по группе — отдельным запросом, чтобы получить ProductIds.
var allowed = await _db.Products.AsNoTracking()
.Where(p => p.ProductGroupId == productGroupId)
.Select(p => p.Id)
.ToListAsync(ct);
var set = allowed.ToHashSet();
qtyByPair = qtyByPair.Where(x => set.Contains(x.ProductId)).ToList();
}
if (!includeZero)
{
qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
}
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();
}
if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
// Подтаскиваем имена товара/склада.
var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList();
@ -173,10 +144,9 @@ private async Task<List<Guid>> DescendantGroupIdsAsync(Guid groupId, Cancellatio
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, storeName,
x.StoreId, s?.Name ?? "(unknown)",
x.Qty, cost, x.Qty * cost);
})
.OrderBy(r => r.ProductName)

View file

@ -328,10 +328,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" });
}
}
// Нормализуем дату чека к UTC и не даём «будущих» времён (иначе ленты
// «Последние продажи»/Z-отчёт показывают времена вперёд). Корень фикса.
var saleDateUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(input.Date);
var number = await GenerateNumberAsync(saleDateUtc, ct);
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
// Если возврат привязан к чеку — валидация что reference существует и проведён.
@ -358,7 +355,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
var sale = new RetailSale
{
Number = number,
Date = saleDateUtc,
Date = input.Date,
Status = RetailSaleStatus.Draft,
StoreId = input.StoreId,
RetailPointId = input.RetailPointId,

View file

@ -1,123 +0,0 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace foodmarket.Api.Infrastructure.Validation;
/// <summary>Переводит технические ошибки разбора тела запроса (model binding /
/// JSON-десериализация) в дружелюбный русский. ASP.NET для неверного типа/enum
/// отдаёт тексты вида «The JSON value could not be converted to
/// foodmarket.Api.Controllers.Inventory.LossesController+LossInput. Path: $.reason …»
/// — показывать их пользователю нельзя (имена классов, пути, англ.). Заменяем на
/// «Неверное значение поля «Причина»». Сообщения FluentValidation (уже осмысленные,
/// русские) оставляем как есть.</summary>
public static class ModelErrorTranslator
{
// Маркеры технических сообщений от System.Text.Json / model binder.
private static readonly string[] TechnicalMarkers =
{
"could not be converted",
"JSON value",
"is not a valid",
"could not be parsed",
"The input was not valid",
"The supplied value is invalid",
"is not valid for",
"Cannot deserialize",
"A non-empty request body is required",
"The dto field is required",
"could not convert",
"Could not convert",
};
// Человеческие названия частых полей (ключ — последний сегмент пути, lower).
private static readonly IReadOnlyDictionary<string, string> FieldLabels =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["reason"] = "Причина",
["quantity"] = "Количество",
["qty"] = "Количество",
["unitcost"] = "Себестоимость",
["unitprice"] = "Цена",
["price"] = "Цена",
["amount"] = "Сумма",
["sum"] = "Сумма",
["date"] = "Дата",
["storeid"] = "Склад",
["productid"] = "Товар",
["counterpartyid"] = "Контрагент",
["customerid"] = "Покупатель",
["supplierid"] = "Поставщик",
["currencyid"] = "Валюта",
["discount"] = "Скидка",
["vatpercent"] = "НДС",
["vat"] = "НДС",
["payment"] = "Оплата",
["paidcash"] = "Оплата наличными",
["paidcard"] = "Оплата картой",
["type"] = "Тип",
["status"] = "Статус",
["packaging"] = "Фасовка",
["kind"] = "Вид",
["email"] = "Email",
["phone"] = "Телефон",
["name"] = "Название",
["article"] = "Артикул",
["barcode"] = "Штрихкод",
["code"] = "Код",
["plan"] = "Тариф",
["role"] = "Роль",
["lines"] = "Позиции",
["items"] = "Позиции",
};
private static bool IsTechnical(string msg)
=> !string.IsNullOrEmpty(msg) && TechnicalMarkers.Any(m => msg.Contains(m, StringComparison.OrdinalIgnoreCase));
/// <summary>Человеческое имя поля из ключа ModelState («$.reason», «lines[0].reason»,
/// «dto.Amount» → «Причина» / «Сумма»; неизвестное — очищенный ключ в кавычках).</summary>
public static string FieldLabel(string key)
{
if (string.IsNullOrWhiteSpace(key) || key == "$") return "данные запроса";
var seg = key.TrimStart('$', '.');
// последний сегмент пути без индексов массива
var lastDot = seg.LastIndexOf('.');
if (lastDot >= 0) seg = seg[(lastDot + 1)..];
var bracket = seg.IndexOf('[');
if (bracket >= 0) seg = seg[..bracket];
seg = seg.Trim();
if (seg.Length == 0) return "данные запроса";
return FieldLabels.TryGetValue(seg, out var label) ? label : seg;
}
/// <summary>Переводит одно сообщение. Техническое → «Неверное значение поля «…»»;
/// осмысленное (FluentValidation) — без изменений.</summary>
public static string Translate(string key, string message)
=> IsTechnical(message)
? $"Неверное значение поля «{FieldLabel(key)}»"
: message;
/// <summary>Строит словарь дружелюбных ошибок по полям (ключ — очищенное имя поля)
/// из ModelState. Технические сообщения переведены, имена классов/пути убраны.</summary>
public static Dictionary<string, string[]> Humanize(ModelStateDictionary modelState)
{
var result = new Dictionary<string, string[]>();
foreach (var kv in modelState)
{
if (kv.Value is null || kv.Value.Errors.Count == 0) continue;
var field = CleanFieldKey(kv.Key);
var msgs = kv.Value.Errors
.Select(e => Translate(kv.Key, string.IsNullOrWhiteSpace(e.ErrorMessage)
? $"Неверное значение поля «{FieldLabel(kv.Key)}»" : e.ErrorMessage))
.Distinct()
.ToArray();
result[field] = msgs;
}
return result;
}
private static string CleanFieldKey(string key)
{
if (string.IsNullOrWhiteSpace(key) || key == "$") return "";
var seg = key.TrimStart('$', '.');
return seg.Length == 0 ? "" : seg;
}
}

View file

@ -34,16 +34,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecu
{
foreach (var err in result.Errors)
ctx.ModelState.AddModelError(err.PropertyName, err.ErrorMessage);
// Дружелюбный русский заголовок вместо дефолтного англ. «One or more
// validation errors occurred.»; сообщения полей уже русские (FluentValidation).
var friendly = ModelErrorTranslator.Humanize(ctx.ModelState);
var detail = friendly.Values.SelectMany(v => v).Distinct().ToList();
ctx.Result = new BadRequestObjectResult(new ValidationProblemDetails(friendly)
{
Status = 400,
Title = "Проверьте правильность заполнения",
Detail = string.Join("; ", detail.Take(3)) + (detail.Count > 3 ? $" и ещё {detail.Count - 3}" : ""),
});
ctx.Result = new BadRequestObjectResult(new ValidationProblemDetails(ctx.ModelState));
return;
}
}

View file

@ -337,25 +337,20 @@ static string ApplyDefaultPoolConfig(string? raw)
// «за какое поле отвалилось» без детализации в теле ответа.
o.InvalidModelStateResponseFactory = ctx =>
{
// Технический лог — с сырыми путями/классами (видит только бэкенд).
var raw = ctx.ModelState
var problems = ctx.ModelState
.Where(kv => kv.Value?.Errors.Count > 0)
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
.ToList();
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("ModelValidation")
.LogWarning("[Проверка] {Path}: {Problems}",
ctx.HttpContext.Request.Path, string.Join("; ", raw));
// Пользователю — дружелюбный русский без имён классов/путей/англ.
var friendly = foodmarket.Api.Infrastructure.Validation.ModelErrorTranslator.Humanize(ctx.ModelState);
var detailMsgs = friendly.Values.SelectMany(v => v).Distinct().ToList();
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(friendly)
ctx.HttpContext.Request.Path, string.Join("; ", problems));
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "Проверьте правильность заполнения",
Detail = string.Join("; ", detailMsgs.Take(3))
+ (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""),
Title = "Сервер не принял данные",
Detail = string.Join("; ", problems.Take(3))
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
};
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
{

View file

@ -33,22 +33,4 @@ public static DateTime StartOfMonthUtc(DateTime instantUtc)
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
public static DateTime LocalDate(DateTime instantUtc) =>
(DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
/// <summary>Нормализует дату документа от клиента к UTC-инстанту и защищает от
/// «будущих» времён. Kind=Utc — как есть; Local — в UTC; Unspecified (наивная,
/// без 'Z') — трактуем как UTC (без сдвига). Если результат оказался в будущем
/// больше допуска (клиентские часы спешат / наивная локаль дала сдвиг вперёд) —
/// заменяем на текущий момент: чек/документ не может быть в будущем, иначе ленты
/// («Последние продажи», Z-отчёт, список продаж) показывают будущие времена.</summary>
public static DateTime ToInstantUtc(DateTime d, TimeSpan? futureTolerance = null)
{
var utc = d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc),
};
var now = DateTime.UtcNow;
return utc > now + (futureTolerance ?? TimeSpan.FromMinutes(5)) ? now : utc;
}
}

View file

@ -22,10 +22,6 @@ public StockService(AppDbContext db, ITenantContext tenant, INegativeStockAlertS
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
// Автор движения: берём из черновика, иначе — текущий пользователь (JWT). Так
// автор пишется для ВСЕХ документов (касса/списание/перемещение/приёмка/…),
// даже если вызывающий его явно не указал — иначе CauseUserId в алертах был null.
var userId = d.CreatedByUserId ?? _tenant.UserId;
_db.StockMovements.Add(new StockMovement
{
@ -39,7 +35,7 @@ public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, Cancellation
DocumentId = d.DocumentId,
DocumentNumber = d.DocumentNumber,
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
CreatedByUserId = userId,
CreatedByUserId = d.CreatedByUserId,
Notes = d.Notes,
});
@ -65,7 +61,7 @@ public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, Cancellation
// Предупреждение об отрицательном остатке: единая точка детекции для всех
// документов. Остаток < 0 → открыть/обновить алерт; ≥ 0 → закрыть.
await _negAlerts.OnStockLevelAsync(d.StoreId, d.ProductId, stock.Quantity,
d.DocumentType, d.DocumentId, d.DocumentNumber, userId, d.OccurredAt, ct);
d.DocumentType, d.DocumentId, d.DocumentNumber, d.CreatedByUserId, d.OccurredAt, ct);
return stock.Quantity;
}

View file

@ -70,7 +70,7 @@ export interface Product {
}
export interface StockRow {
productId: string; productName: string; article: string | null; barcode: string | null; unitName: string;
productId: string; productName: string; article: string | null; unitName: string;
storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number;
}

View file

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

View file

@ -1,22 +0,0 @@
#!/usr/bin/env bash
# Смысловая проверка фильтров всех списков: не «200 OK», а независимо посчитанный
# ожидаемый набор строк/итог (нулевые остатки, группы с вложенностью, синонимы
# groupId/productGroupId, поиск, сортировка, пагинация, экспорт-с-фильтром).
# Поднимает свежую temp-организацию на стенде, сеет контролируемые данные, сверяет.
# Выход != 0 если есть расхождение — для nightly/quality-watchdog.
#
# tests/e2e/filters-sweep.sh
# Env: E2E_ADMIN_URL (default https://test.admin.food-market.kz)
set -uo pipefail
cd "$(dirname "$0")"
export E2E_ADMIN_URL="${E2E_ADMIN_URL:-https://test.admin.food-market.kz}"
[ -d node_modules ] || pnpm install --silent
echo "[filters-sweep] стенд=$E2E_ADMIN_URL"
pnpm exec tsx filters-sweep.ts
RC=$?
if [ "$RC" -ne 0 ]; then
echo "[filters-sweep] РЕЗУЛЬТАТ: КРАСНЫЙ (rc=$RC)"
exit 1
fi
echo "[filters-sweep] РЕЗУЛЬТАТ: ЗЕЛЁНЫЙ"
exit 0

View file

@ -1,255 +0,0 @@
/**
* Filters-sweep: смысловая проверка фильтров списков (не «200 OK», а независимо
* посчитанный ожидаемый набор строк / итог). Поднимает свежую temp-организацию,
* сеет контролируемый набор товаров (с остатком / без / в минусе / услуга /
* архивный / в 3-уровневой группе), затем для каждого контрола каждого списка
* сверяет фактический ответ API с ожидаемым, посчитанным в этом же скрипте.
*
* Запуск: pnpm exec tsx filters-sweep.ts (или tests/e2e/filters-sweep.sh)
* Env: E2E_ADMIN_URL (default https://test.admin.food-market.kz)
* Выход: 0 — всё сошлось; 1 — есть расхождения (= баги фильтров).
*/
import { makeClient, signupOrg, ean13 } from './lib/fc.js'
import { writeFileSync, mkdirSync } from 'node:fs'
import type { AxiosInstance } from 'axios'
const results: { name: string; ok: boolean; detail: string }[] = []
function check(name: string, ok: boolean, detail = '') {
results.push({ name, ok, detail })
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`)
}
function eqSet(a: string[], b: string[]) {
const sa = new Set(a), sb = new Set(b)
if (sa.size !== sb.size) return false
for (const x of sa) if (!sb.has(x)) return false
return true
}
async function firstId(api: AxiosInstance, url: string, where?: (x: any) => boolean): Promise<string> {
const r = await api.get(url)
const items = Array.isArray(r.data) ? r.data : (r.data.items ?? [])
const pick = where ? items.find(where) : items[0]
if (!pick) throw new Error(`no item at ${url}`)
return pick.id
}
async function createGroup(api: AxiosInstance, name: string, parentId: string | null): Promise<string> {
const r = await api.post('/api/catalog/product-groups', { name, parentId })
if (r.status >= 300) throw new Error(`create group ${name}: ${r.status} ${JSON.stringify(r.data)}`)
return r.data.id
}
async function createProduct(api: AxiosInstance, opts: {
name: string; article?: string; barcode?: string; groupId: string; price?: number;
unitId: string; currencyId: string; priceTypeId: string; isService?: boolean;
}): Promise<string> {
const r = await api.post('/api/catalog/products', {
name: opts.name, article: opts.article ?? null, description: null,
unitOfMeasureId: opts.unitId, vat: 12, vatEnabled: true, productGroupId: opts.groupId,
isService: opts.isService ?? false, packaging: 0, isMarked: false,
referencePrice: (opts.price ?? 100) * 0.7, purchaseCurrencyId: opts.currencyId,
prices: [{ priceTypeId: opts.priceTypeId, amount: opts.price ?? 100, currencyId: opts.currencyId }],
barcodes: opts.barcode ? [{ code: opts.barcode, type: 0, isPrimary: true }] : [],
})
if (r.status >= 300) throw new Error(`create product ${opts.name}: ${r.status} ${JSON.stringify(r.data)}`)
return r.data.id
}
async function enterStock(api: AxiosInstance, storeId: string, currencyId: string, productId: string, qty: number) {
const d = await api.post('/api/inventory/enters', {
date: new Date().toISOString(), storeId, currencyId, notes: 'sweep',
lines: [{ productId, quantity: qty, unitCost: 50 }],
})
if (d.status >= 300) throw new Error(`enter: ${d.status} ${JSON.stringify(d.data)}`)
const p = await api.post(`/api/inventory/enters/${d.data.id}/post`, {})
if (p.status >= 300) throw new Error(`enter post: ${p.status} ${JSON.stringify(p.data)}`)
}
async function lossStock(api: AxiosInstance, storeId: string, currencyId: string, productId: string, qty: number) {
const d = await api.post('/api/inventory/losses', {
date: new Date().toISOString(), storeId, currencyId, reason: 0, notes: 'sweep-neg',
lines: [{ productId, quantity: qty, unitCost: 50 }],
})
if (d.status >= 300) throw new Error(`loss: ${d.status} ${JSON.stringify(d.data)}`)
const p = await api.post(`/api/inventory/losses/${d.data.id}/post`, {})
if (p.status >= 300) throw new Error(`loss post: ${p.status} ${JSON.stringify(p.data)}`)
}
/** Собирает все страницы списка, возвращает массив productId (или id). */
async function allIds(api: AxiosInstance, url: string, idKey = 'productId'): Promise<string[]> {
const ids: string[] = []
let page = 1
for (;;) {
const sep = url.includes('?') ? '&' : '?'
const r = await api.get(`${url}${sep}page=${page}&pageSize=100`)
if (r.status >= 300) throw new Error(`${url}: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`)
const items = r.data.items ?? []
for (const it of items) ids.push(it[idKey] ?? it.id)
const total = r.data.total ?? ids.length
if (ids.length >= total || items.length === 0) break
page++
if (page > 50) break
}
return ids
}
async function main() {
const sess = await signupOrg()
const api = makeClient(sess.token)
console.log(`[filters-sweep] temp-орг ${sess.email}`)
const unitId = await firstId(api, '/api/catalog/units-of-measure?pageSize=50')
const storeId = await firstId(api, '/api/catalog/stores?pageSize=50')
const store2 = await (async () => {
const r = await api.post('/api/catalog/stores', { name: `Склад2-${Date.now()}`, code: null })
return r.status < 300 ? r.data.id : null
})()
const currencyId = await firstId(api, '/api/catalog/currencies?pageSize=50', (c: any) => c.code === 'KZT')
const priceTypeId = await firstId(api, '/api/catalog/price-types?pageSize=50')
// 3-уровневая группа: parent → child → grandchild.
const gParent = await createGroup(api, `P-${Date.now()}`, null)
const gChild = await createGroup(api, `C-${Date.now()}`, gParent)
const gGrand = await createGroup(api, `G-${Date.now()}`, gChild)
// Отдельная группа вне дерева (контроль, что фильтр не захватывает чужое).
const gOther = await createGroup(api, `O-${Date.now()}`, null)
const art = `ART${Date.now() % 100000}`
const bc = ean13(777001)
const base = { unitId, currencyId, priceTypeId }
const pGrandStock = await createProduct(api, { name: 'Товар-внук-состок', groupId: gGrand, ...base })
const pChildZero = await createProduct(api, { name: 'Товар-ребёнок-ноль', groupId: gChild, ...base })
const pParentNeg = await createProduct(api, { name: 'Товар-родитель-минус', groupId: gParent, ...base })
const pSearch = await createProduct(api, { name: 'Товар-поиск', article: art, barcode: bc, groupId: gOther, ...base })
const pService = await createProduct(api, { name: 'Услуга-доставка', groupId: gOther, isService: true, ...base })
// Остатки: внук +10 (store1), поиск +5 (store1), родитель 2 затем −3 (минус),
// ребёнок — 0 (не приходуем). Услуга — без остатка.
await enterStock(api, storeId, currencyId, pGrandStock, 10)
await enterStock(api, storeId, currencyId, pSearch, 5)
await enterStock(api, storeId, currencyId, pParentNeg, 2)
await lossStock(api, storeId, currencyId, pParentNeg, 5) // → −3
// ожидаемые множества товаров (все не-услуги):
const allProducts = [pGrandStock, pChildZero, pParentNeg, pSearch]
const withStockAllStores = [pGrandStock, pSearch, pParentNeg] // qty != 0 (внук10, поиск5, родитель−3)
const parentSubtree = [pGrandStock, pChildZero, pParentNeg] // в дереве parent
const grandOnly = [pGrandStock]
const stockAllIncl = await allIds(api, '/api/inventory/stock?includeZero=true')
// ── Проверки остатков ───────────────────────────────────────────────
check('stock: без includeZero = только ненулевые',
eqSet(await allIds(api, '/api/inventory/stock'), withStockAllStores),
`ожидали ${withStockAllStores.length}`)
check('stock: includeZero=true = все не-услуги',
eqSet(stockAllIncl, allProducts),
`получили ${stockAllIncl.length}`)
check('stock: услуга никогда не в остатках', !stockAllIncl.includes(pService))
check('stock: includeZero добавляет именно нулевой товар',
stockAllIncl.includes(pChildZero) && !(await allIds(api, '/api/inventory/stock')).includes(pChildZero))
check('stock: negativeOnly=true = только минус',
eqSet(await allIds(api, '/api/inventory/stock?negativeOnly=true'), [pParentNeg]))
check('stock: groupId=parent (+zero) = всё поддерево (3 уровня)',
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gParent}&includeZero=true`), parentSubtree))
check('stock: groupId=grandchild = только внук',
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gGrand}&includeZero=true`), grandOnly))
check('stock: productGroupId — синоним groupId',
eqSet(await allIds(api, `/api/inventory/stock?productGroupId=${gParent}&includeZero=true`), parentSubtree))
check('stock: groupId без zero = только ненулевые из группы',
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gParent}`), [pGrandStock, pParentNeg]))
{
const byArt = await allIds(api, `/api/inventory/stock?includeZero=true&search=${art}`)
check('stock: поиск по артикулу', eqSet(byArt, [pSearch]))
const byBc = await allIds(api, `/api/inventory/stock?includeZero=true&search=${bc}`)
check('stock: поиск по штрихкоду', eqSet(byBc, [pSearch]))
}
{
// store-specific: store2 без остатков → includeZero покажет все (qty 0),
// без флага — пусто.
if (store2) {
const s2zero = await allIds(api, `/api/inventory/stock?storeId=${store2}`)
check('stock: другой склад без остатков и без zero = пусто', s2zero.length === 0,
`получили ${s2zero.length}`)
const s2all = await allIds(api, `/api/inventory/stock?storeId=${store2}&includeZero=true`)
check('stock: другой склад + includeZero = все товары с 0',
eqSet(s2all, allProducts))
}
}
{
// сортировка по количеству (все склады, includeZero) — не убывает по возрастанию
const r = await api.get('/api/inventory/stock?includeZero=true&sort=quantity&order=asc&pageSize=100')
const qs = (r.data.items ?? []).map((x: any) => x.quantity)
let mono = true
for (let i = 1; i < qs.length; i++) if (qs[i] < qs[i - 1]) mono = false
check('stock: сортировка по количеству (asc) монотонна', mono, `[${qs.join(',')}]`)
}
{
// пагинация: pageSize=2 разбивает полный набор без пересечений/пропусков
const full = await allIds(api, '/api/inventory/stock?includeZero=true')
const p1 = (await api.get('/api/inventory/stock?includeZero=true&page=1&pageSize=2')).data
const p2 = (await api.get('/api/inventory/stock?includeZero=true&page=2&pageSize=2')).data
const ids1 = (p1.items ?? []).map((x: any) => x.productId)
const ids2 = (p2.items ?? []).map((x: any) => x.productId)
const overlap = ids1.some((x: string) => ids2.includes(x))
check('stock: пагинация без пересечений', !overlap && ids1.length === 2 && ids2.length === 2,
`page1=${ids1.length} page2=${ids2.length} total=${p1.total}`)
check('stock: total пагинации = полному набору', p1.total === full.length, `${p1.total} vs ${full.length}`)
}
{
// экспорт с фильтром includeZero должен отдать все строки (CSV)
const r = await api.get('/api/inventory/stock/export?includeZero=true&format=csv')
const lines = String(r.data).trim().split('\n').filter(Boolean)
const dataRows = lines.length - 1 // минус заголовок
check('stock/export: includeZero отдаёт все товары',
dataRows === allProducts.length, `строк ${dataRows}`)
}
// ── Проверки каталога ───────────────────────────────────────────────
check('catalog: groupId=parent = поддерево',
eqSet(await allIds(api, `/api/catalog/products?groupId=${gParent}`, 'id'), parentSubtree))
check('catalog: productGroupId — синоним (раньше молча игнорировался)',
eqSet(await allIds(api, `/api/catalog/products?productGroupId=${gParent}`, 'id'), parentSubtree))
check('catalog: groupId=grandchild = только внук',
eqSet(await allIds(api, `/api/catalog/products?groupId=${gGrand}`, 'id'), grandOnly))
{
const services = await allIds(api, '/api/catalog/products?isService=true', 'id')
check('catalog: isService=true = только услуги', eqSet(services, [pService]))
}
// ── отчёт ───────────────────────────────────────────────────────────
const fails = results.filter(r => !r.ok)
const ts = new Date().toISOString().replace(/[:.]/g, '-')
mkdirSync('reports', { recursive: true })
const md = [
`# Filters-sweep ${ts}`,
``,
`Стенд: ${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'} · орг: ${sess.email}`,
``,
`**Итог:** ${results.filter(r => r.ok).length} ✓ / ${fails.length} ✗`,
``,
`| | Проверка | Детали |`,
`|---|---|---|`,
...results.map(r => `| ${r.ok ? '✓' : '✗'} | ${r.name} | ${r.detail} |`),
].join('\n')
const path = `reports/filters-sweep-${ts}.md`
writeFileSync(path, md)
console.log(`\n[filters-sweep] отчёт: ${path}`)
console.log(`[filters-sweep] ${results.filter(r => r.ok).length} ✓ / ${fails.length} ✗`)
process.exit(fails.length > 0 ? 1 : 0)
}
main().catch(e => { console.error('[filters-sweep] FATAL', e?.message ?? e); process.exit(2) })

View file

@ -130,10 +130,9 @@ async function maybeFailShot(ctx: Ctx, step: Step) {
;(step.screenshots ??= []).push({ rel: sh.rel, caption: `Состояние на падении ${step.id}` })
}
// helper: inventory stock for product (store-scoped). Фильтруем по складу: без
// storeId список отдаёт агрегат «Все склады» (storeId = Guid.Empty), а не строку склада.
// helper: inventory stock for product (store-scoped)
async function stockOf(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
const r = await api.get(`/api/inventory/stock?productId=${productId}&storeId=${storeId}&includeZero=true&pageSize=200`)
const r = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
if (r.status !== 200) return 0
const items = (r.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[]
const row = items.find((i) => i.storeId === storeId && i.productId === productId)

View file

@ -1,86 +0,0 @@
/**
* UAT: ключевые P1-экраны на ширине телефона (390px) — рендер без горизонтального
* скролла, без console-ошибок и 5xx, видны заголовки и пустые состояния. Плюс
* проверка исправленной страницы «Остатки»: тумблер «Показать нулевые» меняет набор,
* у товара виден артикул/штрихкод. Скриншоты — в reports/uat-mobile/.
*/
import { test, expect } from '@playwright/test'
import { apiSignup, attachSession, watchPage } from '../lib/ui.js'
import { request as apiRequest } from '@playwright/test'
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
const DIR = 'reports/uat-mobile'
test.describe('UAT mobile 390px', () => {
test('P1-экраны на 390px + остатки «показать нулевые»', async ({ page }) => {
test.setTimeout(180_000)
await page.setViewportSize({ width: 390, height: 844 })
const errs = watchPage(page)
const sess = await apiSignup('uatm')
// Сид через API: группа, товар со стоком и товар без стока (нулевой).
const ctx = await apiRequest.newContext({
baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
const first = async (url: string, pred?: (x: any) => boolean) => {
const r = await ctx.get(url); const j = await r.json()
const items = Array.isArray(j) ? j : (j.items ?? [])
return (pred ? items.find(pred) : items[0]).id as string
}
const unitId = await first('/api/catalog/units-of-measure?pageSize=50')
const storeId = await first('/api/catalog/stores?pageSize=50')
const currencyId = await first('/api/catalog/currencies?pageSize=50', (c: any) => c.code === 'KZT')
const priceTypeId = await first('/api/catalog/price-types?pageSize=50')
const groupId = await first('/api/catalog/product-groups?pageSize=50')
const mk = async (name: string, article: string, barcode: string) => {
const r = await ctx.post('/api/catalog/products', { data: {
name, article, unitOfMeasureId: unitId, vat: 12, vatEnabled: true, productGroupId: groupId,
isService: false, packaging: 0, isMarked: false, referencePrice: 70, purchaseCurrencyId: currencyId,
prices: [{ priceTypeId, amount: 100, currencyId }],
barcodes: [{ code: barcode, type: 0, isPrimary: true }],
} })
return (await r.json()).id as string
}
const withStock = await mk('UAT со стоком', 'UATART1', '4600000000017')
await mk('UAT нулевой', 'UATART2', '4600000000024')
const enter = await ctx.post('/api/inventory/enters', { data: {
date: new Date().toISOString(), storeId, currencyId, notes: 'uat',
lines: [{ productId: withStock, quantity: 7, unitCost: 50 }],
} })
const eid = (await enter.json()).id
await ctx.post(`/api/inventory/enters/${eid}/post`, { data: {} })
await ctx.dispose()
const pages: [string, string, RegExp][] = [
['/dashboard', 'dashboard', /./],
['/catalog/products', 'catalog-products', /Товары|товар/i],
['/inventory/stock', 'stock', /Остатки/i],
['/sales/retail', 'sales-retail', /Продажи|чек/i],
['/reports', 'reports', /Отчёт|отчёт/i],
]
for (const [path, shot, _re] of pages) {
await attachSession(page, sess, path)
await page.waitForTimeout(1500)
// нет горизонтального скролла на 390px
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
const clientW = await page.evaluate(() => document.documentElement.clientWidth)
expect(scrollW, `${path}: без горизонтального скролла`).toBeLessThanOrEqual(clientW + 2)
await page.screenshot({ path: `${DIR}/${shot}-390.png`, fullPage: true })
}
// Остатки: по умолчанию виден товар со стоком (артикул показан).
await attachSession(page, sess, '/inventory/stock')
await expect(page.getByText('UAT со стоком')).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('UATART1').first()).toBeVisible()
// Нулевой товар скрыт без флага.
await expect(page.getByText('UAT нулевой')).toHaveCount(0)
// Включаем «Показать нулевые» → нулевой появляется.
await page.getByText('Показать нулевые').click()
await expect(page.getByText('UAT нулевой')).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: `${DIR}/stock-includeZero-390.png`, fullPage: true })
expect(errs.console, `console-ошибки: ${errs.console.join(' | ')}`).toEqual([])
expect(errs.network, `сетевые ошибки: ${errs.network.join(' | ')}`).toEqual([])
})
})

View file

@ -85,90 +85,6 @@ 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} должен фильтровать по группе и показывать нулевые");
}
}
// ── 1d. Ошибки ввода — дружелюбный русский без имён классов/путей ──────
[Fact]
public async Task Invalid_enum_value_returns_friendly_russian_without_class_names()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-err-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var pid = await api.CreateProductAsync(refs, $"Списываемый-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Списание с несуществующим значением enum «reason» → ранее приходил
// технический англ. текст с именем класса LossInput. Теперь — русский.
var json = System.Text.Json.JsonSerializer.Serialize(new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
reason = "НЕвернаяПричина", notes = "x",
lines = new[] { new { productId = pid, quantity = 1m, unitCost = 10m } },
});
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var resp = await api.Http.PostAsync("/api/inventory/losses", content);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await resp.Content.ReadAsStringAsync();
body.Should().NotContain("foodmarket.", "в ответе не должно быть имён классов");
body.Should().NotContain("JSON value", "технический англ. текст недопустим");
body.Should().NotContain("could not be converted");
body.Should().NotContain("$.", "пути JSON не показываем пользователю");
body.Should().Contain("Причина", "поле названо по-русски");
body.Should().MatchRegex("[Нн]еверное значение", "дружелюбная формулировка");
}
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
[Fact]
public async Task Add_barcode_creates_and_rejects_duplicate()

View file

@ -64,12 +64,6 @@ public async Task Loss_into_negative_is_allowed_creates_alert_recount_and_resolv
alerts[0].GetProperty("quantity").GetDecimal().Should().Be(-3m);
alerts[0].GetProperty("productId").GetString().Should().Be(p);
alerts[0].GetProperty("causeDocType").GetString().Should().Be("loss");
// Автор документа записан (раньше CauseUserId был null). Сверяем с /api/me.
var me = await api.GetJsonAsync("/api/me");
var myId = me.GetProperty("sub").GetString();
alerts[0].TryGetProperty("causeUserId", out var cu).Should().BeTrue();
cu.GetString().Should().NotBeNullOrEmpty("автор минусового документа должен быть записан");
cu.GetString().Should().Be(myId, "автор — текущий пользователь");
// «Пересчитать эти товары» → черновик инвентаризации с этим товаром.
var rc = await api.Http.PostAsJsonAsync("/api/negative-stock/recount", new { storeId = refs.StoreId });

View file

@ -15,11 +15,8 @@ public class RetailOversellingTests
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
// Решение владельца 08.10.2026: продажа в минус РАЗРЕШЕНА (как у МойСклада по
// умолчанию) — вместо блокировки создаётся предупреждение об отрицательном
// остатке. Этот тест раньше ждал 409 (старая политика) и был устаревшим.
[Fact]
public async Task Posting_sale_above_available_stock_is_allowed_and_creates_negative_alert()
public async Task Posting_sale_above_available_stock_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"oversell-{Guid.NewGuid():N}");
@ -45,13 +42,7 @@ public async Task Posting_sale_above_available_stock_is_allowed_and_creates_nega
var saleId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue(
$"продажа в минус разрешена: {(int)post.StatusCode} {await post.Content.ReadAsStringAsync()}");
// Остаток ушёл в −5, и создано предупреждение об отрицательном остатке.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(-5m);
var cnt = await api.GetJsonAsync("/api/negative-stock/count");
cnt.GetProperty("total").GetInt32().Should().BeGreaterThan(0, "минус создаёт предупреждение");
((int)post.StatusCode).Should().Be(409, "продажа сверх остатка должна блокироваться");
}
[Fact]

View file

@ -181,9 +181,7 @@ public async Task<string> CreateRetailPointAsync(string storeId, string name)
public async Task<decimal> StockOfAsync(string storeId, string productId)
{
// Остаток товара на конкретном складе: фильтруем по складу (без storeId
// список отдаёт агрегат «Все склады» с storeId = Guid.Empty).
var list = await ListAsync($"/api/inventory/stock?productId={productId}&storeId={storeId}&includeZero=true&pageSize=200");
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");
var row = list.FirstOrDefault(i =>
i.GetProperty("productId").GetString() == productId &&
i.GetProperty("storeId").GetString() == storeId);

View file

@ -1,52 +0,0 @@
using FluentAssertions;
using foodmarket.Application.Common;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Нормализация даты документа к UTC и защита от «будущих» времён
/// (корень бага «Последние продажи» показывали время вперёд).</summary>
public class BusinessTimeTests
{
[Fact]
public void Utc_date_in_past_is_unchanged()
{
var past = DateTime.UtcNow.AddHours(-3);
BusinessTime.ToInstantUtc(past).Should().BeCloseTo(past, TimeSpan.FromSeconds(1));
}
[Fact]
public void Future_date_is_clamped_to_now()
{
var future = DateTime.UtcNow.AddHours(3);
var result = BusinessTime.ToInstantUtc(future);
result.Should().BeOnOrBefore(DateTime.UtcNow.AddSeconds(1));
result.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1), "будущее заменяется на now");
}
[Fact]
public void Small_clock_skew_within_tolerance_is_kept()
{
var slightlyAhead = DateTime.UtcNow.AddMinutes(2);
BusinessTime.ToInstantUtc(slightlyAhead).Should().BeCloseTo(slightlyAhead, TimeSpan.FromSeconds(1),
"небольшой разбег часов (до 5 мин) допускаем");
}
[Fact]
public void Local_kind_is_converted_to_utc()
{
var local = DateTime.SpecifyKind(DateTime.Now.AddHours(-1), DateTimeKind.Local);
var result = BusinessTime.ToInstantUtc(local);
result.Kind.Should().Be(DateTimeKind.Utc);
result.Should().BeCloseTo(local.ToUniversalTime(), TimeSpan.FromSeconds(1));
}
[Fact]
public void Unspecified_kind_is_treated_as_utc_no_shift()
{
var naive = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(-2), DateTimeKind.Unspecified);
var result = BusinessTime.ToInstantUtc(naive);
result.Kind.Should().Be(DateTimeKind.Utc);
result.Should().BeCloseTo(DateTime.SpecifyKind(naive, DateTimeKind.Utc), TimeSpan.FromSeconds(1));
}
}

View file

@ -300,7 +300,6 @@ public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, Cancel
});
}
public Task<QuickCreatedProductDto> QuickCreateProductAsync(QuickCreateProductBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();