food-market/docs/hotfix-invoice500-403-progress.md
nns f434e87d9b
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
fix(hotfix): invoice500 + фоновые 403 на reload + regression e2e
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>
2026-10-05 00:21:41 +05:00

10 KiB
Raw Blame History

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():

"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, затем для каждого:

  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.

Запуск:

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.

Запуск:

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 + итог

(заполняется)