Зачем: владелец пометил N13 как «не воспроизведено», а в коде окно гонки (SELECT ... FOR UPDATE по отсутствующей строке, затем INSERT ... ON CONFLICT DO NOTHING со своим runId) видно. Нужен повторяемый замер на живом сервере и реальной PostgreSQL 17, а не рассуждение. Что: scripts/test/n13-race.mjs — 10/25/50 параллельных первых POST /payroll/calculate, каждая попытка на новой точке, проверка статусов, числа run_id, строк в staff_payroll_runs и «фантомных» run_id. Режим as-is показывает, что сейчас всё упирается в N2 (500). Режим n2-bypass ставит DEFAULT now() только в одноразовой БД скрипта (продукт и миграции не меняются), чтобы увидеть саму гонку. Итог: 0 некорректных из 30 попыток; причина (из кода) — случайная сериализация через upsert ensureDefaultPayRules. Это записано в NOTES-FOR-EXECUTORS вместе с рекомендацией явной блокировки и замечанием об утечке текста ошибок БД в 500.
141 lines
17 KiB
Markdown
141 lines
17 KiB
Markdown
# Заметки для исполнителей (Codex / Antigravity)
|
||
|
||
Автор: Grok (стенд тестов и независимая приёмка). Я не меняю `src/`, `apps/`,
|
||
`migrations/`, `deploy/`; всё, что требует изменения продукта, описано здесь.
|
||
|
||
Как запускать:
|
||
|
||
```sh
|
||
# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают)
|
||
export E2E_PG_ADMIN_URL=postgres://<admin>:<pass>@127.0.0.1:55433/postgres
|
||
node scripts/test/run-e2e.mjs # храповик known-defects (как в CI)
|
||
node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные
|
||
E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском
|
||
./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e
|
||
```
|
||
|
||
**Правило храповика.** Исправили дефект → его сценарий позеленел →
|
||
`run-e2e.mjs` падает с «дефект исправлен, обновите known-defects.json» —
|
||
удалите ID из `tests/e2e/known-defects.json` в том же коммите. Новый красный
|
||
сценарий, которого нет в списке, — регрессия.
|
||
|
||
## 1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4
|
||
|
||
API пока нет, поэтому сценарии `tests/e2e/30-owner-decisions.e2e.ts` написаны
|
||
против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и
|
||
этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя.
|
||
Все пути относительно `/api/v1/staff`.
|
||
|
||
### C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC)
|
||
|
||
- `POST /access-grants` (только главный менеджер, `staff:employee:write`)
|
||
тело `{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": ["<ULID>"], "display_name": "..." }`
|
||
→ `201 { "id", "role", "org_unit_ids", "access_token" }`. Токен показывается один раз,
|
||
хранится как хеш (как ключи сотрудников), используется как `Authorization: Bearer`.
|
||
- `point_manager` видит/считает только свои `org_unit_ids`; чужая точка → `403`
|
||
(и для нового `POST /payroll/batches`, и для старого `POST /payroll/calculate`).
|
||
- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md).
|
||
|
||
### C2. Пакет зарплаты точки и согласование
|
||
|
||
| Метод и путь | Кто | Результат |
|
||
| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `POST /payroll/batches` `{org_unit_id, from, to}` | accountant; point_manager своей точки | `201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]}` |
|
||
| `POST /payroll/batches/:id/submit` | только accountant | `200 {status:"submitted"}`; point_manager → `403`; повтор после подписи → `409` |
|
||
| `GET /notifications` | любой пользователь | `200 {notifications:[{type:"payroll.batch.submitted", subject_id:<batchId>}]}`; каждому директору ровно одно уведомление на отправку |
|
||
| `POST /payroll/batches/:id/sign` | только director | `200 {status:"signed"}`; accountant → `403`; если смена пакета изменилась после отправки → `409`, пакет получает `status:"returned"` |
|
||
| `POST /payroll/batches/:id/return` `{comment}` | director | `200 {status:"returned", return_comment}` |
|
||
| `GET /payroll/batches/:id` | accountant, director | `200 {status, return_comment, lines:[...]}` |
|
||
|
||
Инварианты, которые проверяют тесты:
|
||
|
||
- до подписи в `staff_payroll_ledger` для сотрудников пакета нет ни `accrual`, ни `payout` (OWN-05);
|
||
- подпись атомарно пишет `accrual` и `payout`; после подписи `PATCH /work-sessions/:id`
|
||
для смен пакета → `409`, пакет не отправляется повторно (OWN-06);
|
||
- строка пакета принадлежит точке, где отработана смена (`staff_work_sessions.org_unit_id`),
|
||
а не основной точке сотрудника (OWN-09).
|
||
|
||
### C3. Согласие на биометрию
|
||
|
||
- `PUT /employees/:id/biometric-consent` `{ "status": "granted" | "revoked", "document_ref": "..." }`
|
||
(главный менеджер) → `200`.
|
||
- `GET /terminal/employees/:id/enrollment` (терминальный токен) →
|
||
`200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }`.
|
||
Без записи о согласии — `false/consent_missing` (OWN-10); после отзыва — `false/consent_revoked` (OWN-11).
|
||
- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md);
|
||
сервер хранит только факт согласия. Тот же флаг стоит отдать в `/sync/pull` (employee payload).
|
||
|
||
Сейчас все OWN красные с `404 Route ... not found` (C1–C3 не реализованы);
|
||
OWN-03..09 дополнительно упрутся в N2 (подтверждение смены падает 500).
|
||
|
||
## 2. Дефекты продукта, найденные сценариями (все красные сейчас)
|
||
|
||
Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`.
|
||
|
||
| ID | Файл | Что исправить |
|
||
| -------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||
| N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии |
|
||
| N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией |
|
||
| N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) |
|
||
| N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, `redirect:'manual'`; сейчас сервер подключается к `127.0.0.1` |
|
||
| N9 | `push-dispatcher.ts` | `ON CONFLICT ... DO UPDATE SET employee_id` → 409 для чужого endpoint |
|
||
| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 |
|
||
| N11 | импорт | валидировать `kind` и схему `items` → 400 вместо 500 (23514) |
|
||
| N12 | PUT pay-assignment | `additionalProperties:false`, верхние границы сумм |
|
||
| F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) |
|
||
| F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию |
|
||
| F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) |
|
||
| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` |
|
||
| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять |
|
||
| SYNC-A/B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) |
|
||
| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке |
|
||
| SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка |
|
||
| SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках |
|
||
| SYNC-E | `executeCommand` | проверять `status='active'` сотрудника |
|
||
| SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства |
|
||
| SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета |
|
||
| SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале |
|
||
| SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) |
|
||
| SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) |
|
||
| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` |
|
||
|
||
## 3. deploy/ (PILOT-1, PILOT-2) — Codex
|
||
|
||
- PILOT-1: все подстановки в `deploy/pilot/compose.yaml` → `${VAR:?VAR is required}`
|
||
(сейчас 14 без `:?`: пустой `STANDALONE_MANAGER_TOKEN` молча превращает pilot в F2).
|
||
- PILOT-2: `NODE_ENV: development` в `app`. Конфликт: `config.ts` запрещает
|
||
`STAFF_RUNTIME_MODE=standalone` при `NODE_ENV=production`. Нужно решение: либо
|
||
разрешить standalone в production при обязательных токенах и HTTPS, либо
|
||
отдельный режим для pilot. Тест требует только «не development».
|
||
- N6: `scripts/create-app-role.mjs` не работает мигратором без SUPERUSER
|
||
(«Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e
|
||
обходятся `scripts/test/provision-pg.mjs` (роли создаёт bootstrap-админ, как
|
||
`docker/postgres/init-app-role.sh`). В pilot мигратор = `POSTGRES_USER` =
|
||
суперпользователь; рекомендую схему как в provision-pg.mjs.
|
||
|
||
## 4. CI
|
||
|
||
- Задаче `docker-build` нужен раннер с меткой `docker-host` (Docker daemon).
|
||
Без него задача ждёт в очереди; остальные задачи не блокируются.
|
||
- `format` красная, пока не исправлен F14 (25 файлов, в основном `src/`, `docs/`, `tests/unit/`).
|
||
- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено.
|
||
|
||
## 5. N13 (гонка первого расчёта) и утечка текста ошибок БД
|
||
|
||
- `scripts/test/n13-race.mjs`, вывод `tests/e2e/evidence/n13-race.txt`: 10/25/50
|
||
параллельных первых `POST /payroll/calculate` × 5 попыток. Как есть — все 500 (N2),
|
||
гонку не видно. В одноразовой БД с обходом N2 (`DEFAULT now()` только там) — 0 из 30
|
||
некорректных попыток (без смен и с подтверждённой сменой): один `run_id`, один расчёт в БД.
|
||
- Почему не воспроизводится (вывод из кода, не проверено отдельным экспериментом):
|
||
`calculatePayroll` сначала вызывает `ensureDefaultPayRules`, а там
|
||
`INSERT ... ON CONFLICT DO UPDATE` по одним и тем же строкам `staff_pay_rules` тенанта.
|
||
Первая транзакция держит блокировки этих строк до коммита, остальные ждут — расчёты
|
||
одного тенанта выполняются по очереди случайно. Если upsert правил вынесут из расчёта
|
||
или сделают `DO NOTHING`, окно `SELECT ... FOR UPDATE` (строки нет) → `INSERT ... ON
|
||
CONFLICT DO NOTHING` со своим `runId` → строки/UPDATE по несуществующему `runId` вернётся.
|
||
Рекомендация: явный `pg_advisory_xact_lock` по (tenant, org_unit, period_start) или
|
||
`INSERT ... ON CONFLICT DO UPDATE ... RETURNING id`. Скрипт стоит перезапустить после
|
||
такого изменения.
|
||
- Ответы 500 содержат сырой текст ошибки PostgreSQL (например
|
||
`null value in column "occurred_at" of relation "staff_outbox" ...`) — клиенту видна
|
||
схема БД. Для 5xx отдавать нейтральное сообщение, подробности — только в лог.
|