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
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>
151 lines
10 KiB
Markdown
151 lines
10 KiB
Markdown
# 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<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`.
|
||
|
||
**Запуск:**
|
||
|
||
```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 + итог
|
||
|
||
_(заполняется)_
|