Owner caught on stage (2026-06-07 18:57):
1) POST /api/purchases/invoices/upload → 500: migration Phase31a создала
invoice_uploads.UpdatedAt как NOT NULL, но конвенция проекта — DateTime?
nullable + стамп только для Modified в AppDbContext.StampTimestamps.
EF на INSERT отправлял UpdatedAt=NULL → 23502. Фикс: Phase31b DROP NOT NULL
+ Phase31b для invoice_aliases.
2) Фоновые 403 → тост «Нет доступа» при F5 на любой странице:
- SavedPresets фетчил /api/user/presets (RequiresAdvancedMode) из любой
страницы с фильтрами в простом режиме → 403;
- useOnboardingProgress фетчил /api/organization/employees (Admin only)
для кладовщика/кассира на '/' → 403.
Фикс:
- api.ts: GET-403 silent, тост только на действия юзера (POST/PUT/DELETE);
- SavedPresets gated на advancedMode (useMe + enabled);
- useOnboardingProgress canSeeEmployees-gate.
3) tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs — HTTP-прогон
POST /upload через Testcontainers-Postgres (дыра: s31 pipeline tests
проверяли matcher, но ни разу не инсертили в invoice_uploads).
4) tests/e2e/scenarios/stage-ui-menu-walk.spec.ts — regress: каждая демо-роль,
каждый пункт меню, F5, assert 0 unexpected 4xx/5xx + 0 error-тостов +
0 console.error. В quality-watchdog step 9 с 4-часовой каденцией.
5) tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts — demo-keeper
загружает реальное фото накладной (tests/fixtures/invoice-sample.jpg,
сгенерирована Playwright-ом из HTML, 1800×2400 JPEG) → ждёт recognition
→ экран проверки. При не-настроенном Claude CLI тест fail'ится с ясным
сообщением (это тоже баг).
Прогон локально 4/4 (InvoicePipelineTests 2/2 + InvoiceUploadHttpTests 2/2).
docs/hotfix-invoice500-403-progress.md — ход работы 1-4/5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Hotfix: invoice-upload 500 + фоновые 403 (owner caught on stage)
Context: владелец продукта поймал руками на https://test.admin.food-market.kz две ошибки:
- 500 при загрузке накладной (
POST /api/purchases/invoices/upload). - Тост «Нет доступа» в правом углу при 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():
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now(),
Но в конвенции проекта базовый Entity.UpdatedAt — это DateTime? (nullable), а стамп делает AppDbContext.StampTimestamps() только для EntityState.Modified:
// AppDbContext.cs:292
foreach (var entry in ChangeTracker.Entries<Entity>()) {
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*) — пути уже обёрнуты в <AdvancedModeGuard> в App.tsx, с simple-режима на них не попасть, фоновых хитов нет. Остальные [Authorize(Roles=…)]-эндпоинты либо mutation-only (POST/PUT/DELETE), либо на страницах за <RoleGuard>.
3. Menu-reload regression
Файл: tests/e2e/scenarios/stage-ui-menu-walk.spec.ts (новый).
Что делает: для каждой демо-роли (demo-admin, demo-keeper, demo-cashier) логинится quick-login'ом, идёт на /, собирает все href из видимого sidebar, затем для каждого:
page.goto(href)→networkidle- assert body непустой, 0 error-тостов (
[data-toast-kind="error"]) page.reload()→networkidle(имитирует F5, тот самый сценарий)- assert body непустой, 0 error-тостов снова
- в финале — assert по watchPage: 0 неожиданных 4xx/5xx (ожидаемые 404 — в белом списке), 0
console.error
Для стабильности e2e-селектора добавлен data-toast-kind={entry.kind} в Toaster.tsx:44.
Запуск:
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/.
Сценарий (без моков):
- demo-keeper, quick-login →
/; - в sidebar клик по «Накладные (по фото)» → переход на
/purchases/invoices; - клик
data-testid=invoice-upload-file,page.waitForEvent('filechooser')→setFiles(FIXTURE); - ждём, что карточка
[data-testid^="invoice-row-"]появилась; - HTTP:
GET /api/purchases/invoices?take=5, забираем id первой загрузки; - polling
/api/purchases/invoices/{id}каждые 3 с до 180 с, ждёмneeds_review/applied/failed; - при остатке в
pending/parsing180 с → fail с чётким сообщением «Claude CLI на стенде не настроен — [RemoteHost не указан / /usr/local/bin/claude отсутствует]»; - при
failed→ fail с текстом ошибки от extractor'а; - при
needs_review/applied→ открываем/purchases/invoices/{id}(экран проверки), assert body непустой + 0 error-тостов; - финальный assert по
watchPage: 0 console.error / 0 неожиданных 4xx-5xx.
Запуск:
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 + итог
(заполняется)