# Hotfix: invoice-upload 500 + фоновые 403 (owner caught on stage) **Context:** владелец продукта поймал руками на `https://test.admin.food-market.kz` две ошибки: 1. 500 при загрузке накладной (`POST /api/purchases/invoices/upload`). 2. Тост «Нет доступа» в правом углу при F5 на любой странице. Этот документ — ход работы и доказательства фикса, 5/5. --- ## 1. 500 на invoice upload — root cause **Error (из логов `food-market-stage-api-1`, 18:57):** ``` DbUpdateException: 23502 null value in column "UpdatedAt" of relation "invoice_uploads" violates not-null constraint ``` **Root cause.** В миграции `20261004170000_Phase31a_InvoiceUploads` колонка `UpdatedAt` объявлена `NOT NULL DEFAULT now()`: ```sql "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now(), ``` Но в конвенции проекта базовый `Entity.UpdatedAt` — это `DateTime?` (nullable), а стамп делает `AppDbContext.StampTimestamps()` только для `EntityState.Modified`: ```csharp // AppDbContext.cs:292 foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow; else if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow; } ``` При INSERT `UpdatedAt` остаётся `null` → EF пишет `UpdatedAt = NULL` в SQL (а не опускает колонку), и NOT NULL constraint ловит это. `DEFAULT now()` не спасает: Postgres применяет default только когда колонку опустили в INSERT. Во всех остальных миграциях (`InitialSchema`, `Phase1Catalog`, `Phase2*`, …) `UpdatedAt` — `timestamp with time zone, nullable: true`. **Fix.** Новая миграция делает `invoice_uploads.UpdatedAt` и `invoice_aliases.UpdatedAt` nullable — приводит к конвенции. См. `20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable.cs` — идемпотентный `ALTER … DROP NOT NULL/DEFAULT`, Down — no-op (назад возвращаться смысла нет). **Почему тесты s31 это не поймали.** В `tests/food-market.IntegrationTests/InvoicePipelineTests.cs` на реальной Postgres (Testcontainers) проверялись только: - `InvoiceProcessor.BuildMatcherAsync` + `matcher.Match()` для barcode-hit; - multi-tenant isolation при построении matcher'а. **HTTP-эндпоинт `/api/purchases/invoices/upload` не вызывался ни разу** — а значит `INSERT INTO invoice_uploads` не выполнялся, и NOT NULL-constraint не ловился. Дыра закрыта новым тестом `tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs`: - `Upload_single_file_returns_200_and_persists_row` — multipart POST с multipart/form-data, проверяет 200 + detail endpoint возвращает запись; - `Upload_multiple_files_in_one_request_all_persist` — 3 файла в одном запросе, 200 + 3 Id в ответе. Оба теста ходят через `WebApplicationFactory` на Testcontainers-Postgres 16-alpine → фактический INSERT в `invoice_uploads` → если `UpdatedAt NOT NULL` вернётся, тест упадёт на 23502 с ясной диагностикой. **Прогон (локально, Testcontainers, 2026-10-05):** ``` Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4 Duration: 22 s - foodmarket.IntegrationTests.dll (net8.0) ``` - `InvoicePipelineTests` — 2/2 (как и было); - `InvoiceUploadHttpTests` — 2/2 (новый, с миграцией-фиксом). --- ## 2. Фоновые 403 — root cause **Что ловил владелец.** F5 на любой странице → в правом верхнем углу всплывает тост «Нет доступа» (title=«Нет доступа»). Иногда несколько подряд. У `demo-keeper` и `demo-cashier` особенно шумно. **Источники 403 (найдено, не только два названных):** | Эндпоинт | Permission / режим | Где фетчится | Статус | |---|---|---|---| | `GET /api/user/presets` | `[RequiresAdvancedMode]` | `SavedPresets` → embedded в ProductsPage, StockReport, ProfitReport, SalesReport (все доступны и в simple-режиме) | **FIX**: `SavedPresets` рендерится и фетчит только в advanced-режиме (useMe + `enabled`) | | `GET /api/organization/employees` | `[Authorize(Roles = "SuperAdmin,Admin")]` | `useOnboardingProgress` → OnboardingPage (`/` для всех ролей, включая кассира/кладовщика) | **FIX**: `enabled: canSeeEmployees` в useQuery; для не-admin шаг «добавить команду» помечается как `done=true` | | любой `GET` с 403 | — | интерцептор `axios.response` показывал тост на любом 4xx/5xx | **FIX**: `method === 'get' && status === 403` → silent. Тост остаётся на POST/PUT/DELETE/PATCH — т.е. только на действие пользователя | Политика после фикса — **«тост только на действие пользователя»**, по правилу владельца. Фоновый 403 от GET-а теперь не отображается никому; UI либо не покажет блок (например, chips пресетов), либо покажет пустое состояние. **Файлы фикса:** - `src/food-market.web/src/lib/api.ts:94-115` — политика silent-GET-403. - `src/food-market.web/src/components/SavedPresets.tsx:14-56` — advanced-mode gate. - `src/food-market.web/src/lib/useOnboardingProgress.ts` — role-gate на employees-count. Остальные `[RequiresAdvancedMode]`-контроллеры (TelegramBinding, Promotions, Transfers, AbcReport, Demands, OrgAuditLog, Loyalty*) — пути уже обёрнуты в `` в `App.tsx`, с simple-режима на них не попасть, фоновых хитов нет. Остальные `[Authorize(Roles=…)]`-эндпоинты либо mutation-only (POST/PUT/DELETE), либо на страницах за ``. --- ## 3. Menu-reload regression **Файл:** `tests/e2e/scenarios/stage-ui-menu-walk.spec.ts` (новый). **Что делает:** для каждой демо-роли (`demo-admin`, `demo-keeper`, `demo-cashier`) логинится quick-login'ом, идёт на `/`, собирает все `href` из видимого sidebar, затем для каждого: 1. `page.goto(href)` → `networkidle` 2. assert body непустой, 0 error-тостов (`[data-toast-kind="error"]`) 3. `page.reload()` → `networkidle` (имитирует F5, тот самый сценарий) 4. assert body непустой, 0 error-тостов снова 5. в финале — assert по watchPage: 0 неожиданных 4xx/5xx (ожидаемые 404 — в белом списке), 0 `console.error` Для стабильности e2e-селектора добавлен `data-toast-kind={entry.kind}` в `Toaster.tsx:44`. **Запуск:** ```bash cd tests/e2e E2E_ADMIN_URL=https://test.admin.food-market.kz pnpm exec playwright test \ scenarios/stage-ui-menu-walk.spec.ts ``` **В quality-watchdog.sh (step 9).** Прогон тяжёлый (~5 мин), поэтому гоняется раз в 4 часа (`$(date +%H) % 4 == 0`). Падение помечает `menu_walk` red, уходит в state + dashboard, при двукратном подряд — incident-файл в очередь Server-Claude. --- ## 4. Real invoice upload e2e **Файл:** `tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts` + фикстура. **Фикстура:** `tests/fixtures/invoice-sample.jpg` — реалистичная JPEG-накладная 1800×2400 (JFIF, baseline, 215 KB), сгенерирована из HTML-шаблона в `tests/e2e/scripts/make-invoice-fixture.ts` через Playwright Chromium `page.screenshot({ type:'jpeg', quality:92 })`. В HTML — шапка (поставщик, №, дата, валюта), таблица с 5 строками (штрихкод + supplier code + название + ед + qty + price + sum), tfoot «ИТОГО: 52440.00». Регенерация: `pnpm exec tsx scripts/make-invoice-fixture.ts` из `tests/e2e/`. **Сценарий (без моков):** 1. demo-keeper, quick-login → `/`; 2. в sidebar клик по «Накладные (по фото)» → переход на `/purchases/invoices`; 3. клик `data-testid=invoice-upload-file`, `page.waitForEvent('filechooser')` → `setFiles(FIXTURE)`; 4. ждём, что карточка `[data-testid^="invoice-row-"]` появилась; 5. HTTP: `GET /api/purchases/invoices?take=5`, забираем id первой загрузки; 6. polling `/api/purchases/invoices/{id}` каждые 3 с до 180 с, ждём `needs_review` / `applied` / `failed`; 7. при остатке в `pending`/`parsing` 180 с → fail с чётким сообщением «Claude CLI на стенде не настроен — [RemoteHost не указан / /usr/local/bin/claude отсутствует]»; 8. при `failed` → fail с текстом ошибки от extractor'а; 9. при `needs_review`/`applied` → открываем `/purchases/invoices/{id}` (экран проверки), assert body непустой + 0 error-тостов; 10. финальный assert по `watchPage`: 0 console.error / 0 неожиданных 4xx-5xx. **Запуск:** ```bash cd tests/e2e E2E_ADMIN_URL=https://test.admin.food-market.kz pnpm exec playwright test \ scenarios/stage-ui-invoice-upload.spec.ts ``` --- ## 5. Stage retest + итог _(заполняется)_