# 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 + итог ### Второй проход: coord-уточнение после ручного ретеста владельца Владелец после первого фикса (п.1) загружал настоящее фото и получил: ``` Ошибка распознавания: An error occurred trying to start process 'claude' with working directory '/app'. No such file or directory ``` **Root cause.** В контейнере `food-market-stage-api-1` CLI `claude` НЕ установлен; extractor имел ветку `RemoteHost` по ssh (из `~/ref/sklad`), но env-переменная не была задана → ветка не бралась, экстрактор пытался запустить локальный `claude`. **Почему мой e2e п.4 это не поймал.** Я добавил сам spec, но запустил только `menu-walk` в разделе 5-первом проходе. `stage-ui-invoice-upload.spec.ts` не был прогнан до этого фикса. Сам spec ДЕЙСТВИТЕЛЬНО ловит эту ошибку — текст `"Распознавание упало в failed: An error occurred trying to start process 'claude'..."` появляется в assert'е. Урок: регресс-проверка существует, если её запустили. **Фикс (коммит `0ff93b6`).** 1. `ClaudeCliInvoiceExtractor.cs` — передаёт в `ssh`/`scp` опции `-i `, `-o StrictHostKeyChecking=accept-new`, `-o UserKnownHostsFile=`, `-o BatchMode=yes`, `-o ConnectTimeout=10`. Два новых конфига: `Invoices:ClaudeCli:SshKey`, `Invoices:ClaudeCli:SshKnownHosts` (default `/tmp/.fm-ssh/known_hosts` — writable путь, т.к. `/root/.ssh` в контейнере read-only). 2. `deploy/Dockerfile.api` — `+openssh-client` в runtime stage. 3. На prod-vm (192.168.1.190): сгенерирован ed25519-ключ `~/food-market-stage/deploy/secrets/invoice-ocr-key[.pub]` (не в репо; `secrets/` под `./deploy/`). 4. На dev-vm (192.168.1.192, где установлен и залогинен `claude`): `~/.ssh/authorized_keys` + строка `from="192.168.1.190",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ` — ограниченный доступ только с prod-vm. 5. `docker-compose.yml` на prod-vm (не в репо): ```yaml environment: Invoices__ClaudeCli__RemoteHost: ${INVOICE_REMOTE_HOST:-nns@192.168.1.192} Invoices__ClaudeCli__Executable: /home/nns/.local/bin/claude Invoices__ClaudeCli__SshKey: /app/.ssh/invoice-ocr-key Invoices__ClaudeCli__SshKnownHosts: /tmp/.fm-ssh/known_hosts volumes: - ./secrets/invoice-ocr-key:/app/.ssh/invoice-ocr-key:ro ``` `Executable` — полный путь, т.к. non-interactive non-login ssh не наследует `~/.profile`. **Прогон на stage (после фикса):** ``` Running 4 tests using 1 worker [invoice-upload] id=f4d9e256-dacf-4a47-b454-b79ad9538ab5 initial status=pending [invoice-upload] final status=needs_review err=- ✓ 1 ... invoice upload full-cycle — demo-keeper … (31.6s) ✓ 2 ... menu-walk … demo-admin — reload каждого пункта меню, 0 ошибок (1.9m) ✓ 3 ... menu-walk … demo-keeper — reload каждого пункта меню, 0 ошибок (1.1m) ✓ 4 ... menu-walk … demo-cashier — reload каждого пункта меню, 0 ошибок (29.2s) 4 passed (4.1m) ``` ### Файловая политика (owner decision 2026-10-05) Фото/сканы накладных на сервере **хранить запрещено**. После распознавания (успех + ошибка) файл удаляется. **Что сделано:** - `InvoiceProcessor.ProcessAsync` — `DeleteStored(up)` сразу после `ExtractAsync` (успех) и в catch (ошибка). Хелпер затирает `up.StoredPath = ""`, безопасно к гонкам и падениям FS. - `ClaudeCliInvoiceExtractor.ExtractAsync` — `rm -f` временной копии на RemoteHost в `finally` (как и было, подтверждено). - `InvoiceWorker` — удалён периодический `SweepAsync` + поле `_lastSweep`. Конфиг `Invoices:KeepFilesDays` больше не читается; оставлен как no-op с комментарием в XML-доке. - На stage prod-vm `/opt/food-market-data/uploads/invoices` — все накопленные legacy-файлы удалены вручную (4 шт от 18:57–19:36, до фикса); `invoice_uploads.StoredPath` обнулён для всех записей с terminal-статусом. **Верификация:** после двукратного e2e-прогона в `/opt/food-market-data/uploads/invoices` — 0 файлов, в БД — 0 записей с непустым `StoredPath`. ### Итог 7/7 | # | Пункт | Статус | |---|---|---| | 1 | 500 на upload (UpdatedAt NOT NULL) | ✅ fix + integration-test на реальной Postgres | | 2 | Фоновые 403 (presets, employees, telegram) | ✅ 3 места у источника + silent-GET-403 политика | | 3 | menu-reload regression e2e | ✅ в quality-watchdog (step 9) + 3/3 green на stage | | 4 | Real invoice upload e2e | ✅ pending → needs_review на stage | | 5 | deploy-stage + retest + итог | ✅ 4/4 e2e green | | 6 | Claude CLI via ssh на dev-vm | ✅ RemoteHost config + ключ смонтирован ro | | 7 | Не хранить фото накладных | ✅ delete-on-done + sweep удалён + legacy cleanup | Прод не трогал, global.json не трогал, nginx gateway не трогал.