docs(stock): отчёт и скриншоты проверки фильтров остатков (stage, SQL, UI)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

Проверка задачи 11: интеграционный тест (эндпоинт), SQL по реальному датасету
МойСклад (org FOOD MARKET: в наличии 4746 / нулевые 23418 / минус 2829 / всего
30993; группа «Кондитерские…» зеро+минус 3848), живой stage-API на сид-данных и
скриншоты реальной админки (три галочки, URL states=…). StockReportPage не
менялся. Прод не трогали. Нужен рабочий пароль SuperAdmin для скриншота именно
org FOOD MARKET.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-11 18:45:00 +05:00
parent d49b586904
commit e6cdaa9839
10 changed files with 186 additions and 0 deletions

View file

@ -0,0 +1,78 @@
# Фильтры остатков «В наличии / Нулевые / В минусе» (задача 11, 11.10.2026)
Галочки состояния остатка объединяются (ИЛИ); группа/склад/поиск сужают (И).
Пусто → positive+negative (нулевые скрыты).
## Что сделано
- **API** `StockController` (`/api/inventory/stock` и `/stock/export`): параметр
`states` = набор `positive|zero|negative` (через запятую или повтором). Один WHERE:
`(positive И qty>0) ИЛИ (zero И qty=0) ИЛИ (negative И qty<0)` по остатку
(COALESCE-подзапрос; товар без строки Stock = 0). total/пагинация/сортировка
корректны. Старые `includeZero`/`negativeOnly` оставлены для совместимости
(`includeZero` ≡ все три; `negativeOnly` ≡ negative); `states` приоритетнее.
- **Веб-админка** `StockPage`: три галочки «В наличии» (>0), «Нулевые» (=0),
«В минусе» (<0); состояние в URL (`?states=…`); экспорт учитывает галочки.
Ссылки `NegativeStockBanner`/`OverdueNegativeStockBlock`/`NotificationCenter`
на минусовые → `states=negative`.
- **`StockReportPage`** НЕ меняли: это отдельный отчёт по остаткам на дату с одной
галочкой «Показывать нулевые остатки» (другой эндпоинт), три состояния к нему
не относятся.
- Деплой на **stage** (`test.admin.food-market.kz`). Прод не трогали.
## Проверка
### 1. Интеграционный тест (эндпоинт) — 1/1 зелёный
`tests/food-market.IntegrationTests/StockStateFilterTests.cs`: сочетания
(zero; negative; positive; zero+negative; +группа; все три = весь каталог;
пусто = positive+negative; со складом) и числовые тождества
(positive+negative = пусто; positive+zero+negative = весь каталог) + совместимость
старых флагов. EF8 переводит предикат (в логе SQL `COALESCE((…SUM…),0) < 0` и т.п.).
### 2. SQL по реальному датасету МойСклад (org «FOOD MARKET», foodmarket-migr)
org id `eb5db1ae-57c1-4f32-a646-13807d49de54`, аккаунт
`foodmarket-migr@food-market.local`. Та же агрегатная логика, что и в контроллере
(`COALESCE(SUM(Quantity),0)`, `IsService=false AND IsArchived=false`):
| состояние | факт (SQL) | ожидание владельца |
|---|---|---|
| В наличии (>0) | **4746** | ~4752 |
| Нулевые (=0) | **23418** | ~23415 (МойСклад «Доступно: Нулевое» 23 419) |
| В минусе (<0) | **2829** | ~2826 |
| Всего | **30993** | 30 993 |
Тождества: 4746 + 2829 = **7575** (по умолчанию) ; 4746 + 23418 + 2829 = **30993**
(весь каталог). Небольшие расхождения с ожиданием — живые продажи/синхронизация
(как и отмечено в задаче).
**Группа + зеро/минус:** группа «Кондитерские изделия и снеки»
(`eacc1fc2-1734-4dcd-a18c-4441d5cc6303`, с 2 подгруппами): zero+negative = **3848**
(нулевые 3510, минусовые 338) — проверено SQL (с учётом подгрупп по Path, как в
`DescendantGroupIdsAsync`).
### 3. Живой stage-API (деплой) на сид-данных
Свежий стенд-аккаунт (обычный публичный signup), 3 товара (в наличии/нулевой/минус):
`default(pos+neg)=2 · positive=1 · zero=1 · negative=1 · zero,negative=2 · all=3` —
ровно по правилу.
### 4. Скриншоты реальной админки (test.admin)
`s1-default.png` … `s6-all.png` + `index.html` — на том же стенд-аккаунте:
- по умолчанию (ни одна галочка) → 2 (в наличии + в минусе), URL без `states`;
- «В наличии» → 1 (`?states=positive`); «Нулевые» → 1 (`?states=zero`);
«В минусе» → 1 (`?states=negative`);
- «Нулевые»+«В минусе» → 2 (`?states=zero,negative`) — см. скриншот
(товар −3 и товар 0);
- все три → 3 (весь каталог, `?states=positive,zero,negative`).
Состояние галочек отражается в URL (ссылку можно скопировать).
## Что НЕ сделано / нужно решение владельца
- **Скриншот именно org «FOOD MARKET» в UI** не снят: сохранённый пароль
SuperAdmin стенда (`~/.fm-watchdog/stage-superadmin-password`) не подходит
(`invalid_grant`), а сбрасывать пароль аккаунта `foodmarket-migr` я не стал —
это операция над чужими учётными данными (авто-режим её и заблокировал). Числа
этой org проверены SQL (таблица выше) и эндпоинт — интеграционным тестом; UI-поведение
снято на идентичном коде (свежий стенд-аккаунт). Если нужны скриншоты именно
в org «FOOD MARKET» — пришлите рабочий пароль SuperAdmin стенда или аккаунта
`foodmarket-migr`.
- **iOS-клиент ALL MARKET Staff** (три галочки) — ваша часть (п. 6): переделываете
вы и выкладываете в TestFlight. Серверный контракт (`states`) готов и на stage.
- **Прод** не трогали (по правилу — без слова владельца).

View file

@ -0,0 +1,10 @@
<!doctype html><meta charset="utf-8"><title>Фильтры остатков 2026-10-11</title>
<style>body{font:14px system-ui;margin:0;padding:24px;max-width:1100px;margin:auto;background:#f8fafc}
figure{margin:0 0 20px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
figcaption{padding:8px 12px;font-size:13px;color:#475569;background:#f1f5f9}img{display:block;width:100%}code{font-size:11px;color:#64748b}</style>
<h1>Фильтры остатков «В наличии / Нулевые / В минусе» — админка test.admin</h1><figure><figcaption>По умолчанию (ни одна галочка — в наличии + в минусе) — «2 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock</code></figcaption><img src="s1-default.png" loading="lazy"></figure>
<figure><figcaption>Только «В наличии» — «1 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock?states=positive</code></figcaption><img src="s2-positive.png" loading="lazy"></figure>
<figure><figcaption>Только «Нулевые» — «1 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock?states=zero</code></figcaption><img src="s3-zero.png" loading="lazy"></figure>
<figure><figcaption>Только «В минусе» — «1 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock?states=negative</code></figcaption><img src="s4-negative.png" loading="lazy"></figure>
<figure><figcaption>«Нулевые» + «В минусе» (объединение) — «2 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock?states=zero%2Cnegative</code></figcaption><img src="s5-zero-negative.png" loading="lazy"></figure>
<figure><figcaption>Все три = весь каталог — «3 позиций по всем складам»<br><code>https://test.admin.food-market.kz/inventory/stock?states=positive%2Czero%2Cnegative</code></figcaption><img src="s6-all.png" loading="lazy"></figure>

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View file

@ -0,0 +1,32 @@
[
{
"caption": "По умолчанию (ни одна галочка — в наличии + в минусе)",
"count": "2 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock"
},
{
"caption": "Только «В наличии»",
"count": "1 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock?states=positive"
},
{
"caption": "Только «Нулевые»",
"count": "1 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock?states=zero"
},
{
"caption": "Только «В минусе»",
"count": "1 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock?states=negative"
},
{
"caption": "«Нулевые» + «В минусе» (объединение)",
"count": "2 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock?states=zero%2Cnegative"
},
{
"caption": "Все три = весь каталог",
"count": "3 позиций по всем складам",
"url": "https://test.admin.food-market.kz/inventory/stock?states=positive%2Czero%2Cnegative"
}
]

View file

@ -0,0 +1,66 @@
// Скриншоты фильтров остатков «В наличии / Нулевые / В минусе» на реальной
// админке test.admin.food-market.kz (свежий стенд-аккаунт). Вывод — docs/stock-filter-2026-10-11/.
import { chromium } from '@playwright/test'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
const BASE = process.env.ADMIN_URL ?? 'https://test.admin.food-market.kz'
const OUT = resolve('/home/nns/food-market', 'docs/stock-filter-2026-10-11')
mkdirSync(OUT, { recursive: true })
const email = readFileSync('/tmp/stockui-email.txt', 'utf8').trim()
const password = readFileSync('/tmp/stockui-pw.txt', 'utf8').trim()
const shots = [], results = []
const b = await chromium.launch({ args: ['--ignore-certificate-errors'] })
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, ignoreHTTPSErrors: true })
const p = await ctx.newPage()
// Логин
await p.goto(BASE + '/', { waitUntil: 'networkidle' })
await p.locator('input[type="email"]').fill(email)
await p.locator('input[type="password"]').fill(password)
await p.getByRole('button', { name: /Войти/ }).click().catch(async () => { await p.locator('button[type="submit"]').click() })
await p.waitForTimeout(2500)
await p.goto(BASE + '/inventory/stock', { waitUntil: 'networkidle' })
await p.waitForTimeout(1500)
const boxes = p.getByRole('checkbox')
async function ensure(pos, zero, neg) {
const want = [pos, zero, neg]
for (let i = 0; i < 3; i++) {
const cur = await boxes.nth(i).isChecked()
if (cur !== want[i]) await boxes.nth(i).click()
}
await p.waitForTimeout(1200)
}
async function countText() {
return (await p.locator('text=/позиц/').first().innerText().catch(() => '')) || ''
}
async function step(file, caption, pos, zero, neg) {
await ensure(pos, zero, neg)
const txt = await countText()
const url = p.url()
await p.screenshot({ path: resolve(OUT, file) })
shots.push({ file, caption: `${caption} — «${txt.trim()}»`, url })
results.push({ caption, count: txt.trim(), url })
console.log(`${caption}: ${txt.trim()} [${url}]`)
}
await step('s1-default.png', 'По умолчанию (ни одна галочка — в наличии + в минусе)', false, false, false)
await step('s2-positive.png', 'Только «В наличии»', true, false, false)
await step('s3-zero.png', 'Только «Нулевые»', false, true, false)
await step('s4-negative.png', 'Только «В минусе»', false, false, true)
await step('s5-zero-negative.png', '«Нулевые» + «В минусе» (объединение)', false, true, true)
await step('s6-all.png', 'Все три = весь каталог', true, true, true)
await b.close()
const rows = shots.map((s) => `<figure><figcaption>${s.caption}<br><code>${s.url}</code></figcaption><img src="${s.file}" loading="lazy"></figure>`).join('\n')
writeFileSync(resolve(OUT, 'index.html'), `<!doctype html><meta charset="utf-8"><title>Фильтры остатков 2026-10-11</title>
<style>body{font:14px system-ui;margin:0;padding:24px;max-width:1100px;margin:auto;background:#f8fafc}
figure{margin:0 0 20px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
figcaption{padding:8px 12px;font-size:13px;color:#475569;background:#f1f5f9}img{display:block;width:100%}code{font-size:11px;color:#64748b}</style>
<h1>Фильтры остатков «В наличии / Нулевые / В минусе» — админка test.admin</h1>${rows}`)
writeFileSync(resolve(OUT, 'ui-results.json'), JSON.stringify(results, null, 2))
console.log(`\n[stock-filter-ui] ${shots.length} скриншотов → ${OUT}`)