Compare commits
9 commits
80070cbace
...
5a2ab4589c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2ab4589c | ||
|
|
d380e186a8 | ||
|
|
1cc3c0a9dc | ||
|
|
d7db011331 | ||
|
|
f3a177a121 | ||
|
|
1324ffd04f | ||
|
|
9dc8f6e044 | ||
|
|
4aceda3e41 | ||
|
|
193ba49ecb |
122
docs/filters-sweep-progress.md
Normal file
122
docs/filters-sweep-progress.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Фильтры и «нулевые остатки» — прогресс и проверка
|
||||
|
||||
Жалоба владельца (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.
|
||||
|
|
@ -5,12 +5,20 @@ OpenIddict password/refresh grant, `client_id=food-market-web`, Bearer-токе
|
|||
ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`.
|
||||
|
||||
## 1. Остатки по группе товаров (с вложенными)
|
||||
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&page=&pageSize=&sort=&order=`
|
||||
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&negativeOnly=&page=&pageSize=&sort=&order=`
|
||||
|
||||
- `groupId` — фильтр по группе товаров **включая все вложенные подгруппы**
|
||||
(по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой.
|
||||
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, unitSymbol,
|
||||
storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
|
||||
- `groupId` (синоним `productGroupId`) — фильтр по группе товаров **включая все
|
||||
вложенные подгруппы** (по денормализованному `Path`). Комбинируется с `storeId`,
|
||||
`search`, сортировкой.
|
||||
- **Выборка идёт ОТ товаров (не-услуги, не-архивные) LEFT JOIN остатки**, поэтому
|
||||
товар без прихода тоже виден (`quantity 0`). `includeZero=true` → показать нулевые;
|
||||
по умолчанию (`false`) — только `quantity != 0`; `negativeOnly=true` → только минусы.
|
||||
- Склад выбран (`storeId`) → остаток этого склада; склад не выбран → агрегат (сумма)
|
||||
по всем складам, `storeId=00000000-…`, `storeName="Все склады"`.
|
||||
- `search` ищет по названию, артикулу **и штрихкоду**.
|
||||
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, barcode,
|
||||
unitSymbol, storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
|
||||
`barcode` — основной штрихкод товара (для различения одноимённых товаров), может быть `null`.
|
||||
|
||||
## 2. Добавить штрихкод к товару
|
||||
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.
|
||||
|
|
|
|||
76
docs/uat-report-2026-10-09.md
Normal file
76
docs/uat-report-2026-10-09.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Приёмочное тестирование (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]].
|
||||
|
|
@ -120,6 +120,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
public async Task<ActionResult<PagedResult<ProductDto>>> List(
|
||||
[FromQuery] PagedRequest req,
|
||||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] bool? isService,
|
||||
[FromQuery] Packaging? packaging,
|
||||
[FromQuery] bool? isMarked,
|
||||
|
|
@ -145,6 +146,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
case "all": /* без фильтра */ break;
|
||||
}
|
||||
if (availableForSale is not null) q = q.Where(p => p.IsAvailableForSale == availableForSale);
|
||||
groupId ??= productGroupId; // синонимы: оба параметра означают фильтр по группе
|
||||
if (groupId is not null)
|
||||
{
|
||||
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
|
||||
|
|
@ -224,6 +226,7 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
[FromQuery] string? format,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] string? archived,
|
||||
CancellationToken ct)
|
||||
{
|
||||
|
|
@ -235,7 +238,17 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
case "true": q = q.Where(p => p.IsArchived); break;
|
||||
case "all": break;
|
||||
}
|
||||
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
|
||||
groupId ??= productGroupId; // синонимы, как в List
|
||||
if (groupId is not null)
|
||||
{
|
||||
// Вся подгруппа по Path-префиксу (как в List), чтобы экспорт совпадал со списком.
|
||||
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
|
||||
if (root is not null)
|
||||
q = q.Where(p => p.ProductGroup != null &&
|
||||
(p.ProductGroup.Path == root.Path || p.ProductGroup.Path.StartsWith(root.Path + "/")));
|
||||
else
|
||||
q = q.Where(p => p.ProductGroupId == groupId);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim().ToLower();
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public class StockController : ControllerBase
|
|||
public StockController(AppDbContext db) => _db = db;
|
||||
|
||||
public record StockRow(
|
||||
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
|
||||
Guid ProductId, string ProductName, string? Article, string? Barcode, string UnitSymbol,
|
||||
Guid StoreId, string StoreName,
|
||||
decimal Quantity, decimal ReservedQuantity, decimal Available);
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ public record StockRow(
|
|||
[FromQuery] Guid? storeId,
|
||||
[FromQuery] Guid? productId,
|
||||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool includeZero = false,
|
||||
[FromQuery] bool negativeOnly = false,
|
||||
|
|
@ -34,89 +35,175 @@ public record StockRow(
|
|||
[FromQuery] string? order = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = from s in _db.Stocks
|
||||
join p in _db.Products on s.ProductId equals p.Id
|
||||
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||
join st in _db.Stores on s.StoreId equals st.Id
|
||||
select new { s, p, u, st };
|
||||
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
|
||||
var total = await products.CountAsync(ct);
|
||||
|
||||
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
||||
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
|
||||
if (groupId.HasValue)
|
||||
{
|
||||
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
|
||||
q = q.Where(x => groupIds.Contains(x.p.ProductGroupId));
|
||||
}
|
||||
if (negativeOnly) q = q.Where(x => x.s.Quantity < 0);
|
||||
else if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = $"%{search.Trim()}%";
|
||||
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|
||||
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
q = (sort, desc) switch
|
||||
var ordered = OrderProducts(products, sort, desc, storeId);
|
||||
var paged = await ordered.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
|
||||
|
||||
var (sid, storeName) = await StoreContextAsync(storeId, ct);
|
||||
var ids = paged.Select(x => x.Id).ToList();
|
||||
var stock = await StockByProductAsync(ids, storeId, ct);
|
||||
var barcodes = await BarcodesByProductAsync(ids, ct);
|
||||
var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct);
|
||||
|
||||
var items = paged.Select(x =>
|
||||
{
|
||||
("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name),
|
||||
("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name),
|
||||
("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name),
|
||||
("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name),
|
||||
("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name),
|
||||
("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name),
|
||||
("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name),
|
||||
("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name),
|
||||
("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||
("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||
("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||
("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||
("name", true) => q.OrderByDescending(x => x.p.Name),
|
||||
_ => q.OrderBy(x => x.p.Name),
|
||||
};
|
||||
var items = await q
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new StockRow(
|
||||
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
|
||||
x.st.Id, x.st.Name,
|
||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||
.ToListAsync(ct);
|
||||
var (qty, reserved) = stock.GetValueOrDefault(x.Id);
|
||||
return new StockRow(
|
||||
x.Id, x.Name, x.Article, barcodes.GetValueOrDefault(x.Id),
|
||||
units.GetValueOrDefault(x.UnitOfMeasureId, ""),
|
||||
sid, storeName, qty, reserved, qty - reserved);
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт остатков.</summary>
|
||||
[HttpGet("stock/export")]
|
||||
public async Task<IActionResult> ExportStock(
|
||||
[FromQuery] Guid? storeId, [FromQuery] string? search,
|
||||
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
|
||||
CancellationToken ct = default)
|
||||
/// <summary>Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные),
|
||||
/// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом
|
||||
/// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем
|
||||
/// IQueryable<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице,
|
||||
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
|
||||
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
|
||||
Guid? storeId, Guid? productId, Guid? groupId, string? search,
|
||||
bool includeZero, bool negativeOnly, CancellationToken ct)
|
||||
{
|
||||
var q = from s in _db.Stocks
|
||||
join p in _db.Products on s.ProductId equals p.Id
|
||||
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||
join st in _db.Stores on s.StoreId equals st.Id
|
||||
select new { s, p, u, st };
|
||||
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
||||
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
|
||||
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
|
||||
if (groupId.HasValue)
|
||||
{
|
||||
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
|
||||
products = products.Where(p => groupIds.Contains(p.ProductGroupId));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = $"%{search.Trim()}%";
|
||||
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|
||||
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
|
||||
products = products.Where(p => EF.Functions.ILike(p.Name, term)
|
||||
|| (p.Article != null && EF.Functions.ILike(p.Article, term))
|
||||
|| p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term)));
|
||||
}
|
||||
var rows = await q.OrderBy(x => x.p.Name)
|
||||
.Select(x => new StockExportRow(
|
||||
x.p.Name, x.p.Article, x.u.Name, x.st.Name,
|
||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||
|
||||
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
|
||||
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
|
||||
var sid = storeId ?? Guid.Empty;
|
||||
if (storeId.HasValue)
|
||||
{
|
||||
if (negativeOnly)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m);
|
||||
else if (!includeZero)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (negativeOnly)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m);
|
||||
else if (!includeZero)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m);
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
|
||||
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
|
||||
private IQueryable<Domain.Catalog.Product> OrderProducts(
|
||||
IQueryable<Domain.Catalog.Product> products, string? sort, bool desc, Guid? storeId)
|
||||
{
|
||||
var sid = storeId ?? Guid.Empty;
|
||||
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> qty = storeId.HasValue
|
||||
? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m)
|
||||
: (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m);
|
||||
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> reserved = storeId.HasValue
|
||||
? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m)
|
||||
: (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m);
|
||||
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> avail = storeId.HasValue
|
||||
? (p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m)
|
||||
- (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m))
|
||||
: (p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m)
|
||||
- (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m));
|
||||
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, string?>> unit =
|
||||
p => _db.UnitsOfMeasure.Where(u => u.Id == p.UnitOfMeasureId).Select(u => u.Name).FirstOrDefault();
|
||||
|
||||
return sort switch
|
||||
{
|
||||
"article" => desc ? products.OrderByDescending(p => p.Article).ThenBy(p => p.Name) : products.OrderBy(p => p.Article).ThenBy(p => p.Name),
|
||||
"unit" => desc ? products.OrderByDescending(unit).ThenBy(p => p.Name) : products.OrderBy(unit).ThenBy(p => p.Name),
|
||||
"quantity" => desc ? products.OrderByDescending(qty).ThenBy(p => p.Name) : products.OrderBy(qty).ThenBy(p => p.Name),
|
||||
"reserved" => desc ? products.OrderByDescending(reserved).ThenBy(p => p.Name) : products.OrderBy(reserved).ThenBy(p => p.Name),
|
||||
"available" => desc ? products.OrderByDescending(avail).ThenBy(p => p.Name) : products.OrderBy(avail).ThenBy(p => p.Name),
|
||||
"store" => products.OrderBy(p => p.Name), // один склад или агрегат «все склады»
|
||||
"name" when desc => products.OrderByDescending(p => p.Name),
|
||||
_ => products.OrderBy(p => p.Name),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId, CancellationToken ct)
|
||||
=> storeId.HasValue
|
||||
? (storeId.Value, await _db.Stores.Where(s => s.Id == storeId.Value).Select(s => s.Name).FirstOrDefaultAsync(ct) ?? "")
|
||||
: (Guid.Empty, "Все склады");
|
||||
|
||||
/// <summary>Остаток (кол-во/резерв) по товарам: один склад — его строка; без склада —
|
||||
/// сумма по складам. Нет строки Stock → товара нет в словаре (трактуем как 0).</summary>
|
||||
private async Task<Dictionary<Guid, (decimal Qty, decimal Reserved)>> StockByProductAsync(
|
||||
IReadOnlyCollection<Guid> ids, Guid? storeId, CancellationToken ct)
|
||||
{
|
||||
if (ids.Count == 0) return new();
|
||||
var sq = _db.Stocks.Where(s => ids.Contains(s.ProductId));
|
||||
if (storeId.HasValue) sq = sq.Where(s => s.StoreId == storeId.Value);
|
||||
var raw = await sq.Select(s => new { s.ProductId, s.Quantity, s.ReservedQuantity }).ToListAsync(ct);
|
||||
return raw.GroupBy(x => x.ProductId)
|
||||
.ToDictionary(g => g.Key, g => (g.Sum(x => x.Quantity), g.Sum(x => x.ReservedQuantity)));
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> UnitNamesAsync(IReadOnlyCollection<Guid> unitIds, CancellationToken ct)
|
||||
{
|
||||
if (unitIds.Count == 0) return new();
|
||||
return await _db.UnitsOfMeasure.Where(u => unitIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.Name }).ToDictionaryAsync(u => u.Id, u => u.Name, ct);
|
||||
}
|
||||
|
||||
/// <summary>Основной штрихкод (IsPrimary → первый) для набора товаров — одним
|
||||
/// запросом, чтобы не тащить коррелированный подзапрос в основную выборку.</summary>
|
||||
private async Task<Dictionary<Guid, string>> BarcodesByProductAsync(IReadOnlyCollection<Guid> productIds, CancellationToken ct)
|
||||
{
|
||||
if (productIds.Count == 0) return new();
|
||||
var raw = await _db.ProductBarcodes
|
||||
.Where(b => productIds.Contains(b.ProductId))
|
||||
.Select(b => new { b.ProductId, b.Code, b.IsPrimary })
|
||||
.ToListAsync(ct);
|
||||
var headers = new[] { "Товар", "Артикул", "Ед.", "Склад", "Количество", "Резерв", "Доступно" };
|
||||
return raw.GroupBy(b => b.ProductId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).First());
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт остатков (те же фильтры, что и список; включая нулевые).</summary>
|
||||
[HttpGet("stock/export")]
|
||||
public async Task<IActionResult> ExportStock(
|
||||
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, includeZero, false, ct);
|
||||
var paged = await products.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
|
||||
var (_, storeName) = await StoreContextAsync(storeId, ct);
|
||||
var ids = paged.Select(x => x.Id).ToList();
|
||||
var stock = await StockByProductAsync(ids, storeId, ct);
|
||||
var barcodes = await BarcodesByProductAsync(ids, ct);
|
||||
var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct);
|
||||
var rows = paged.Select(x =>
|
||||
{
|
||||
var (qty, reserved) = stock.GetValueOrDefault(x.Id);
|
||||
return new StockExportRow(x.Name, x.Article, barcodes.GetValueOrDefault(x.Id),
|
||||
units.GetValueOrDefault(x.UnitOfMeasureId, ""), storeName, qty, reserved, qty - reserved);
|
||||
}).ToList();
|
||||
var headers = new[] { "Товар", "Артикул", "Штрихкод", "Ед.", "Склад", "Количество", "Резерв", "Доступно" };
|
||||
return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase)
|
||||
? ReportExport.Xlsx(rows, "stock", "Остатки", headers)
|
||||
: ReportExport.Csv(rows, "stock", headers);
|
||||
}
|
||||
public record StockExportRow(string Name, string? Article, string Unit, string Store,
|
||||
public record StockExportRow(string Name, string? Article, string? Barcode, string Unit, string Store,
|
||||
decimal Quantity, decimal Reserved, decimal Available);
|
||||
|
||||
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному
|
||||
|
|
|
|||
|
|
@ -436,11 +436,14 @@ orderby l.SortOrder
|
|||
if (currencyId == Guid.Empty)
|
||||
throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");
|
||||
|
||||
var number = await GenerateNumberAsync(src.OccurredAt, ct);
|
||||
// Время чека с кассы → UTC-инстант без «будущих» времён (касса могла прислать
|
||||
// локальное/спешащее время). Остальные отметки движения берут тот же инстант.
|
||||
var occurredAtUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(src.OccurredAt);
|
||||
var number = await GenerateNumberAsync(occurredAtUtc, ct);
|
||||
var sale = new RetailSale
|
||||
{
|
||||
Number = number,
|
||||
Date = src.OccurredAt,
|
||||
Date = occurredAtUtc,
|
||||
Status = RetailSaleStatus.Draft,
|
||||
StoreId = storeId,
|
||||
CurrencyId = currencyId,
|
||||
|
|
@ -498,7 +501,7 @@ orderby l.SortOrder
|
|||
DocumentId: sale.Id,
|
||||
DocumentNumber: sale.Number,
|
||||
UnitCost: l.UnitPrice,
|
||||
OccurredAt: src.OccurredAt), ct);
|
||||
OccurredAt: occurredAtUtc), ct);
|
||||
}
|
||||
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
|
||||
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
|
||||
|
|
|
|||
|
|
@ -42,11 +42,12 @@ public record StockRow(
|
|||
[FromQuery] DateTime? date,
|
||||
[FromQuery] Guid? storeId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] bool includeZero = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var on = AsUtc(date) ?? DateTime.UtcNow;
|
||||
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
|
||||
return Ok(await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct));
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
|
|
@ -54,12 +55,13 @@ public record StockRow(
|
|||
[FromQuery] DateTime? date,
|
||||
[FromQuery] Guid? storeId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] bool includeZero = false,
|
||||
[FromQuery] string format = "csv",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var on = AsUtc(date) ?? DateTime.UtcNow;
|
||||
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
|
||||
var rows = await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct);
|
||||
var name = $"stock-{on:yyyyMMdd}";
|
||||
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
|
||||
return format.ToLower() switch
|
||||
|
|
@ -79,6 +81,18 @@ public record StockRow(
|
|||
_ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному Path).</summary>
|
||||
private async Task<List<Guid>> DescendantGroupIdsAsync(Guid groupId, CancellationToken ct)
|
||||
{
|
||||
var path = await _db.ProductGroups.Where(g => g.Id == groupId)
|
||||
.Select(g => g.Path).FirstOrDefaultAsync(ct);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return await _db.ProductGroups.Where(g => g.Id == groupId).Select(g => g.Id).ToListAsync(ct);
|
||||
return await _db.ProductGroups
|
||||
.Where(g => g.Path == path || g.Path.StartsWith(path + "/"))
|
||||
.Select(g => g.Id).ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<List<StockRow>> BuildAsync(
|
||||
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -108,18 +122,33 @@ public record StockRow(
|
|||
var lastCosts = lastCostsRaw
|
||||
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
|
||||
|
||||
// Набор допустимых товаров (не-услуги; фильтр по группе с вложенностью).
|
||||
HashSet<Guid>? allowedSet = null;
|
||||
if (productGroupId is not null)
|
||||
{
|
||||
// Фильтр по группе — отдельным запросом, чтобы получить ProductIds.
|
||||
var allowed = await _db.Products.AsNoTracking()
|
||||
.Where(p => p.ProductGroupId == productGroupId)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(ct);
|
||||
var set = allowed.ToHashSet();
|
||||
qtyByPair = qtyByPair.Where(x => set.Contains(x.ProductId)).ToList();
|
||||
var groupIds = await DescendantGroupIdsAsync(productGroupId.Value, ct);
|
||||
allowedSet = (await _db.Products.AsNoTracking()
|
||||
.Where(p => !p.IsService && !p.IsArchived && groupIds.Contains(p.ProductGroupId))
|
||||
.Select(p => p.Id).ToListAsync(ct)).ToHashSet();
|
||||
qtyByPair = qtyByPair.Where(x => allowedSet.Contains(x.ProductId)).ToList();
|
||||
}
|
||||
|
||||
if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
|
||||
if (!includeZero)
|
||||
{
|
||||
qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
// «Показать нулевые»: добавляем товары без движений (ни разу не приходованы) —
|
||||
// строка с Qty 0. Склад выбран → его id; все склады → агрегат «Все склады».
|
||||
var seen = qtyByPair.Select(x => x.ProductId).ToHashSet();
|
||||
var zeroQ = _db.Products.AsNoTracking().Where(p => !p.IsService && !p.IsArchived && !seen.Contains(p.Id));
|
||||
if (allowedSet is not null) zeroQ = zeroQ.Where(p => allowedSet.Contains(p.Id));
|
||||
var zeroStore = storeId ?? Guid.Empty;
|
||||
var zeroIds = await zeroQ.Select(p => p.Id).ToListAsync(ct);
|
||||
qtyByPair = qtyByPair.Concat(
|
||||
zeroIds.Select(id => new { ProductId = id, StoreId = zeroStore, Qty = 0m })).ToList();
|
||||
}
|
||||
|
||||
// Подтаскиваем имена товара/склада.
|
||||
var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList();
|
||||
|
|
@ -144,9 +173,10 @@ public record StockRow(
|
|||
sMap.TryGetValue(x.StoreId, out var s);
|
||||
lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost);
|
||||
if (cost == 0m && p is not null) cost = p.Cost;
|
||||
var storeName = x.StoreId == Guid.Empty ? "Все склады" : (s?.Name ?? "(unknown)");
|
||||
return new StockRow(
|
||||
x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName,
|
||||
x.StoreId, s?.Name ?? "(unknown)",
|
||||
x.StoreId, storeName,
|
||||
x.Qty, cost, x.Qty * cost);
|
||||
})
|
||||
.OrderBy(r => r.ProductName)
|
||||
|
|
|
|||
|
|
@ -328,7 +328,10 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
|||
return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" });
|
||||
}
|
||||
}
|
||||
var number = await GenerateNumberAsync(input.Date, ct);
|
||||
// Нормализуем дату чека к UTC и не даём «будущих» времён (иначе ленты
|
||||
// «Последние продажи»/Z-отчёт показывают времена вперёд). Корень фикса.
|
||||
var saleDateUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(input.Date);
|
||||
var number = await GenerateNumberAsync(saleDateUtc, 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 существует и проведён.
|
||||
|
|
@ -355,7 +358,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
|||
var sale = new RetailSale
|
||||
{
|
||||
Number = number,
|
||||
Date = input.Date,
|
||||
Date = saleDateUtc,
|
||||
Status = RetailSaleStatus.Draft,
|
||||
StoreId = input.StoreId,
|
||||
RetailPointId = input.RetailPointId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,16 @@ public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecu
|
|||
{
|
||||
foreach (var err in result.Errors)
|
||||
ctx.ModelState.AddModelError(err.PropertyName, err.ErrorMessage);
|
||||
ctx.Result = new BadRequestObjectResult(new ValidationProblemDetails(ctx.ModelState));
|
||||
// Дружелюбный русский заголовок вместо дефолтного англ. «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}" : ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,20 +337,25 @@ static string ApplyDefaultPoolConfig(string? raw)
|
|||
// «за какое поле отвалилось» без детализации в теле ответа.
|
||||
o.InvalidModelStateResponseFactory = ctx =>
|
||||
{
|
||||
var problems = ctx.ModelState
|
||||
// Технический лог — с сырыми путями/классами (видит только бэкенд).
|
||||
var raw = 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("; ", problems));
|
||||
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
|
||||
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)
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Сервер не принял данные",
|
||||
Detail = string.Join("; ", problems.Take(3))
|
||||
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
|
||||
Title = "Проверьте правильность заполнения",
|
||||
Detail = string.Join("; ", detailMsgs.Take(3))
|
||||
+ (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""),
|
||||
};
|
||||
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -33,4 +33,22 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ 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
|
||||
{
|
||||
|
|
@ -35,7 +39,7 @@ public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, Cancellation
|
|||
DocumentId = d.DocumentId,
|
||||
DocumentNumber = d.DocumentNumber,
|
||||
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
|
||||
CreatedByUserId = d.CreatedByUserId,
|
||||
CreatedByUserId = userId,
|
||||
Notes = d.Notes,
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +65,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, d.CreatedByUserId, d.OccurredAt, ct);
|
||||
d.DocumentType, d.DocumentId, d.DocumentNumber, userId, d.OccurredAt, ct);
|
||||
|
||||
return stock.Quantity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export interface Product {
|
|||
}
|
||||
|
||||
export interface StockRow {
|
||||
productId: string; productName: string; article: string | null; unitName: string;
|
||||
productId: string; productName: string; article: string | null; barcode: string | null; unitName: string;
|
||||
storeId: string; storeName: string;
|
||||
quantity: number; reservedQuantity: number; available: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ export function StockPage() {
|
|||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
empty={!includeZero && !negativeOnly
|
||||
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
|
||||
: 'Нет данных'}
|
||||
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
|
|
@ -71,7 +74,11 @@ export function StockPage() {
|
|||
{ header: 'Товар', sortKey: 'name', cell: (r) => (
|
||||
<div>
|
||||
<div className="font-medium">{r.productName}</div>
|
||||
{r.article && <div className="text-xs text-slate-500 dark:text-slate-400 font-mono">{r.article}</div>}
|
||||
{(r.article || r.barcode) && (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{[r.article, r.barcode].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
|
||||
|
|
@ -88,7 +95,6 @@ export function StockPage() {
|
|||
</span>
|
||||
)},
|
||||
]}
|
||||
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
|
||||
/>
|
||||
</ListPageShell>
|
||||
)
|
||||
|
|
|
|||
22
tests/e2e/filters-sweep.sh
Executable file
22
tests/e2e/filters-sweep.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/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
|
||||
255
tests/e2e/filters-sweep.ts
Normal file
255
tests/e2e/filters-sweep.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* 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) })
|
||||
|
|
@ -130,9 +130,10 @@ async function maybeFailShot(ctx: Ctx, step: Step) {
|
|||
;(step.screenshots ??= []).push({ rel: sh.rel, caption: `Состояние на падении ${step.id}` })
|
||||
}
|
||||
|
||||
// helper: inventory stock for product (store-scoped)
|
||||
// helper: inventory stock for product (store-scoped). Фильтруем по складу: без
|
||||
// storeId список отдаёт агрегат «Все склады» (storeId = Guid.Empty), а не строку склада.
|
||||
async function stockOf(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
|
||||
const r = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
|
||||
const r = await api.get(`/api/inventory/stock?productId=${productId}&storeId=${storeId}&includeZero=true&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)
|
||||
|
|
|
|||
86
tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts
Normal file
86
tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -85,6 +85,90 @@ 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()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ 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 });
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ 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_rejected()
|
||||
public async Task Posting_sale_above_available_stock_is_allowed_and_creates_negative_alert()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"oversell-{Guid.NewGuid():N}");
|
||||
|
|
@ -42,7 +45,13 @@ public async Task Posting_sale_above_available_stock_is_rejected()
|
|||
var saleId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
|
||||
using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
|
||||
((int)post.StatusCode).Should().Be(409, "продажа сверх остатка должна блокироваться");
|
||||
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, "минус создаёт предупреждение");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -181,7 +181,9 @@ public async Task<string> CreateRetailPointAsync(string storeId, string name)
|
|||
|
||||
public async Task<decimal> StockOfAsync(string storeId, string productId)
|
||||
{
|
||||
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");
|
||||
// Остаток товара на конкретном складе: фильтруем по складу (без storeId
|
||||
// список отдаёт агрегат «Все склады» с storeId = Guid.Empty).
|
||||
var list = await ListAsync($"/api/inventory/stock?productId={productId}&storeId={storeId}&includeZero=true&pageSize=200");
|
||||
var row = list.FirstOrDefault(i =>
|
||||
i.GetProperty("productId").GetString() == productId &&
|
||||
i.GetProperty("storeId").GetString() == storeId);
|
||||
|
|
|
|||
52
tests/food-market.UnitTests/BusinessTimeTests.cs
Normal file
52
tests/food-market.UnitTests/BusinessTimeTests.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -300,6 +300,7 @@ 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue