From d380e186a86aa809c0f592a8782e6432118cd02d Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 10:16:47 +0500 Subject: [PATCH] =?UTF-8?q?feat(uat):=20=D0=B4=D1=80=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=D0=BB=D1=8E=D0=B1=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20+=20mobile-UAT=20390px=20+=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ValidationFilter (FluentValidation-путь): русский заголовок «Проверьте правильность заполнения» через ModelErrorTranslator вместо дефолтного англ. «One or more validation errors occurred» (сообщения полей уже были русскими). - stage-ui-uat-mobile.spec.ts: P1-экраны на 390px без h-scroll/console/5xx + проверка «Остатки» (артикул/штрихкод, тумблер «показать нулевые»). - docs/uat-report-2026-10-09.md: матрица покрытия (уже-зелёное vs проверено сейчас) по требованию владельца; галерея скринов reports/uat-mobile/. Co-Authored-By: Claude Opus 4.8 --- docs/uat-report-2026-10-09.md | 76 ++++++++++++++++ .../Validation/ValidationFilter.cs | 11 ++- .../e2e/scenarios/stage-ui-uat-mobile.spec.ts | 86 +++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 docs/uat-report-2026-10-09.md create mode 100644 tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts diff --git a/docs/uat-report-2026-10-09.md b/docs/uat-report-2026-10-09.md new file mode 100644 index 0000000..2cbe69c --- /dev/null +++ b/docs/uat-report-2026-10-09.md @@ -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=` | фильтрует (был игнор) | 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]]. diff --git a/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs b/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs index aacf89a..c090c7e 100644 --- a/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs +++ b/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs @@ -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; } } diff --git a/tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts b/tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts new file mode 100644 index 0000000..da0cb2a --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-uat-mobile.spec.ts @@ -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([]) + }) +})