Compare commits
2 commits
6c5bf367dc
...
b283736312
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b283736312 | ||
| b0243d5001 |
10 changed files with 866 additions and 300 deletions
|
|
@ -691,7 +691,18 @@ paths:
|
|||
properties:
|
||||
device:
|
||||
type: object
|
||||
required: [id, tenant_id, device_code, name, token_hint, status, allowed_scopes, registered_at, created_by]
|
||||
required:
|
||||
[
|
||||
id,
|
||||
tenant_id,
|
||||
device_code,
|
||||
name,
|
||||
token_hint,
|
||||
status,
|
||||
allowed_scopes,
|
||||
registered_at,
|
||||
created_by,
|
||||
]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Ulid' }
|
||||
tenant_id: { $ref: '#/components/schemas/Ulid' }
|
||||
|
|
@ -803,7 +814,8 @@ paths:
|
|||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [cursor, entity_type, entity_id, operation, revision, payload, created_at]
|
||||
required:
|
||||
[cursor, entity_type, entity_id, operation, revision, payload, created_at]
|
||||
properties:
|
||||
cursor: { type: string }
|
||||
entity_type: { type: string }
|
||||
|
|
@ -837,7 +849,15 @@ paths:
|
|||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
required: [command_type, schema_version, idempotency_key, correlation_id, created_at, payload]
|
||||
required:
|
||||
[
|
||||
command_type,
|
||||
schema_version,
|
||||
idempotency_key,
|
||||
correlation_id,
|
||||
created_at,
|
||||
payload,
|
||||
]
|
||||
properties:
|
||||
command_type: { type: string }
|
||||
schema_version: { type: integer }
|
||||
|
|
@ -874,7 +894,16 @@ paths:
|
|||
conflict:
|
||||
oneOf:
|
||||
- type: object
|
||||
required: [conflict_code, entity_type, entity_id, expected_revision, actual_revision, safe_description, client_action]
|
||||
required:
|
||||
[
|
||||
conflict_code,
|
||||
entity_type,
|
||||
entity_id,
|
||||
expected_revision,
|
||||
actual_revision,
|
||||
safe_description,
|
||||
client_action,
|
||||
]
|
||||
properties:
|
||||
conflict_code: { type: string, enum: [REVISION_MISMATCH] }
|
||||
entity_type: { type: string }
|
||||
|
|
@ -885,6 +914,9 @@ paths:
|
|||
client_action: { type: string, enum: [FETCH_LATEST_AND_RETRY] }
|
||||
- { type: 'null' }
|
||||
error_code:
|
||||
description: >
|
||||
Код ошибки, если status = rejected.
|
||||
Команды `upsert_work_session` и `upsert_timesheet_entry` отклоняются с кодом COMMAND_NOT_SUPPORTED.
|
||||
oneOf:
|
||||
- { type: string }
|
||||
- { type: 'null' }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## 1. Архитектурный контекст и инварианты
|
||||
|
||||
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
|
||||
|
||||
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
|
||||
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
|
||||
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
|
||||
|
|
@ -13,9 +14,11 @@ Krona Staff является модулем платформы Krona Business Pl
|
|||
## 2. Жизненный цикл устройства и аутентификация
|
||||
|
||||
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
|
||||
|
||||
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
|
||||
|
||||
**Запрос:**
|
||||
|
||||
```json
|
||||
{
|
||||
"device_code": "TERM-MAIN-01",
|
||||
|
|
@ -26,6 +29,7 @@ Krona Staff является модулем платформы Krona Business Pl
|
|||
```
|
||||
|
||||
**Ответ (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
|
|
@ -48,11 +52,15 @@ Krona Staff является модулем платформы Krona Business Pl
|
|||
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
|
||||
|
||||
### 2.2. Аутентификация запросов устройства
|
||||
|
||||
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <device_token>
|
||||
```
|
||||
|
||||
Сервер:
|
||||
|
||||
1. Вычисляет `SHA-256(device_token)`.
|
||||
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
|
||||
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
|
||||
|
|
@ -61,9 +69,11 @@ Authorization: Bearer <device_token>
|
|||
6. Асинхронно обновляет `last_seen_at`.
|
||||
|
||||
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
|
||||
|
||||
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
|
||||
|
||||
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 403,
|
||||
|
|
@ -77,28 +87,37 @@ Authorization: Bearer <device_token>
|
|||
## 3. Монотонный непрозрачный курсор изменений
|
||||
|
||||
### 3.1. Формат курсора
|
||||
|
||||
Курсор представляет собой непрозрачную строку вида:
|
||||
|
||||
```text
|
||||
cur_<sequence>_<signature_hash>
|
||||
```
|
||||
|
||||
Например: `cur_105_7a9f8b2c1d`.
|
||||
|
||||
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
|
||||
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
|
||||
|
||||
Свойства курсора:
|
||||
|
||||
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
|
||||
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
|
||||
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
|
||||
|
||||
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
|
||||
|
||||
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
|
||||
|
||||
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
|
||||
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
|
||||
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
|
||||
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
|
||||
|
||||
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "cur_105_7a9f8b2c1d",
|
||||
|
|
@ -106,7 +125,9 @@ cur_<sequence>_<signature_hash>
|
|||
"entity_types": ["employee", "work_session"]
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"changes": [
|
||||
|
|
@ -133,12 +154,15 @@ cur_<sequence>_<signature_hash>
|
|||
```
|
||||
|
||||
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
|
||||
|
||||
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "cur_106_3c4d5e6f7a"
|
||||
}
|
||||
```
|
||||
|
||||
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
|
||||
|
||||
---
|
||||
|
|
@ -146,13 +170,16 @@ cur_<sequence>_<signature_hash>
|
|||
## 4. Пакетная отправка команд (Push) и идемпотентность
|
||||
|
||||
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
|
||||
|
||||
Клиент отправляет накопленные в офлайне команды пачками.
|
||||
|
||||
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
|
||||
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
|
||||
- Максимальный размер HTTP-запроса: 2 МБ.
|
||||
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
|
||||
|
||||
**Пример запроса:**
|
||||
|
||||
```json
|
||||
{
|
||||
"commands": [
|
||||
|
|
@ -174,15 +201,23 @@ cur_<sequence>_<signature_hash>
|
|||
```
|
||||
|
||||
### 4.2. Идемпотентность (`staff_sync_command_inbox`)
|
||||
|
||||
Таблица `staff_sync_command_inbox` содержит уникальный ключ:
|
||||
|
||||
```sql
|
||||
UNIQUE (tenant_id, device_id, idempotency_key)
|
||||
```
|
||||
|
||||
При повторной отправке команды с тем же `idempotency_key`:
|
||||
|
||||
1. Мутация повторно не исполняется.
|
||||
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
|
||||
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
|
||||
|
||||
### 4.3. Запрещённые команды
|
||||
|
||||
Прямое создание или изменение смен (`upsert_work_session`) и табелей (`upsert_timesheet_entry`) с устройств строго запрещено для предотвращения несанкционированных начислений. При получении таких команд сервер отклоняет их со статусом `rejected` и кодом ошибки `COMMAND_NOT_SUPPORTED` (без записи в базу данных).
|
||||
|
||||
---
|
||||
|
||||
## 5. Обнаружение конфликтов
|
||||
|
|
@ -192,6 +227,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
|
||||
|
||||
**Ответ с конфликтом (в составе `results`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotency_key": "cmd-term01-778900",
|
||||
|
|
@ -210,6 +246,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
```
|
||||
|
||||
Клиент, получив конфликт, обязан:
|
||||
|
||||
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
|
||||
2. Обновить локальное представление.
|
||||
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
||||
|
|
@ -220,15 +257,15 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
|
||||
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
|
||||
|
||||
| Сущность | Разрешённые поля | Запрещённые данные |
|
||||
| :--- | :--- | :--- |
|
||||
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
|
||||
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
|
||||
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
|
||||
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
|
||||
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
|
||||
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
|
||||
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
|
||||
| Сущность | Разрешённые поля | Запрещённые данные |
|
||||
| :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
|
||||
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
|
||||
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
|
||||
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
|
||||
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
|
||||
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
|
||||
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
|
||||
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
|
||||
|
||||
> [!CAUTION]
|
||||
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
|
||||
|
|
@ -247,9 +284,11 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
## 8. Политика хранения данных (Retention)
|
||||
|
||||
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
|
||||
|
||||
```sql
|
||||
SELECT * FROM staff_sync_cleanup_retention(30);
|
||||
```
|
||||
|
||||
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
|
||||
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
|
||||
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).
|
||||
|
|
|
|||
126
docs/audit/2026-09-26/ACCEPTANCE-2.md
Normal file
126
docs/audit/2026-09-26/ACCEPTANCE-2.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Приёмка №2: Grok, Codex, Antigravity — 2026-09-26
|
||||
|
||||
Проверка независимая, на живой PostgreSQL 17.10, в отдельных рабочих копиях. Ветки исполнителей не менялись. Основная ветка — `f5ac179`.
|
||||
|
||||
## Итог
|
||||
|
||||
| Исполнитель | Ветка | Вердикт |
|
||||
| ----------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Grok | `grok/e2e-harness` @ `ba59a48` | **Принять и влить первым.** Всё из TASK-GROK-2 сделано и подтверждено |
|
||||
| Codex | `codex/fix-live-database` @ `b289dd6` | **K3 и K4 исправлены** (подтверждено мной и Grok). Влить после пересборки ветки форматирования |
|
||||
| Codex | `codex/fix-format-baseline` @ `fd1ec3d` | **Переделать:** в коммит форматирования попало функциональное изменение Antigravity |
|
||||
| Antigravity | `antigravity/fix-reject-device-shifts` @ `1df2019` | **Не принимать пока:** S1 исправлен, но интеграционный тест на реальной базе по-прежнему красный |
|
||||
| Antigravity | `antigravity/fix-sync-c0-bootstrap` @ `8baad55` | **SYNC-C0 исправлен**, но тест падает в `afterAll`, и ветка конфликтует с предыдущей. См. дополнение ниже |
|
||||
| Codex | `codex/fix-format-baseline` @ `513cdff` (перезалив) | **По-прежнему переделать:** синхронизация Antigravity осталась в коммите. См. дополнение ниже |
|
||||
|
||||
## Grok — принято
|
||||
|
||||
Проверено мной:
|
||||
|
||||
- **Windows.** `node scripts/test/run-e2e.mjs` запускается на Windows 11 **без правок**: 54 сценария, 45 красных (все — известные дефекты), 9 зелёных, проверка списка известных дефектов проходит. После прогона рабочая копия чистая — прогонные файлы в git больше не попадают.
|
||||
- **Сопоставление ID.** Сделано по таблице аудита: сценарии называются двойными ID вида `K3/N2`, ключи `known-defects.json` переименованы так же.
|
||||
- **Недостающие сценарии добавлены:** K6, K7, K8, K9, S9, A4, N12-DATE. Все красные, как и должно быть. K7 уже показывает потерю денег при импорте: итог 22 200 вместо 33 300 и 37 700.
|
||||
- **Приёмка веток исполнителей подтверждена.** На ветке Codex `codex/fix-live-database` я сам прогнал стенд Grok и получил 41 красный и 13 зелёных — ровно как в его отчёте. Позеленели K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B.
|
||||
- **Зона не нарушена**, все коммиты с объяснением причины.
|
||||
|
||||
Замечания на следующий круг:
|
||||
|
||||
- Windows-раннера нет, поэтому задача `windows` в CI выключена переменной. Это честно отмечено в отчёте.
|
||||
- В приёмке веток запускались только сценарии `tests/e2e`, а интеграционные тесты самих исполнителей — нет. Из-за этого не пойман красный тест Antigravity (см. ниже).
|
||||
|
||||
## Codex
|
||||
|
||||
**`codex/fix-live-database`: K3 и K4 исправлены.**
|
||||
|
||||
- Подтверждение смены, расчёт и утверждение работают на реальной базе; создание сотрудника отвечает 201.
|
||||
- Цепочка расчёта теперь доходит до денежных дефектов, и они подтверждаются по существу:
|
||||
- K5 — одна смена начисляется дважды: 196 800 вместо 98 400;
|
||||
- K6 — после отклонения оплаченной смены нет сторно.
|
||||
|
||||
Это следующий этап задания (этап 3), а не ошибка ветки.
|
||||
|
||||
- Остаток A4: `month=2026-13` по-прежнему отвечает 500 и возвращает код PostgreSQL.
|
||||
|
||||
**`codex/fix-format-baseline`: переделать.** Grok установил, что «стилевой» коммит `fd1ec3d` содержит функциональное изменение: после нормализации форматирования `src/domain/sync/service.ts` совпадает с исправлением Antigravity, а `tests/unit/sync.test.ts` — тоже. Два нарушения AGENTS.md сразу:
|
||||
|
||||
- смешение тем — поведение спрятано в коммите форматирования;
|
||||
- правка чужой зоны: синхронизация принадлежит Antigravity.
|
||||
|
||||
Если влить обе ветки, изменение Antigravity придёт дважды, и одна из веток получит конфликт. Нужно пересобрать коммит форматирования **без** изменений в `src/domain/sync/**` и `tests/unit/sync.test.ts`. Исправление S1 должно прийти только из ветки Antigravity.
|
||||
|
||||
## Antigravity
|
||||
|
||||
**`antigravity/fix-reject-device-shifts` @ `1df2019`.**
|
||||
|
||||
- S1 исправлен: у Grok SYNC-A и SYNC-B зелёные.
|
||||
- Сообщение коммита приведено к формату: короткий заголовок, причина в теле.
|
||||
- Очистка теста переведена на роль мигратора.
|
||||
|
||||
**Но тест по-прежнему красный на реальной базе.** Теперь он падает раньше, на подготовке данных: `column "created_by" of relation "staff_employees" does not exist` (`tests/integration/sync-rejection.test.ts:81`). Оба теста файла пропускаются, файл целиком — FAIL. Значит, на настоящей базе тест снова не запускали. Пункт 1 задания не закрыт.
|
||||
|
||||
Как проверить у себя до пуша:
|
||||
|
||||
```text
|
||||
DATABASE_TEST_URL=<роль приложения> DATABASE_MIGRATION_URL=<мигратор> npx vitest run tests/integration/sync-rejection.test.ts
|
||||
```
|
||||
|
||||
Готово, когда: `Tests 2 passed`.
|
||||
|
||||
## Порядок влития
|
||||
|
||||
1. `grok/e2e-harness` — влить сейчас: только тесты и CI.
|
||||
2. Codex пересобирает `codex/fix-format-baseline` без синхронизации, затем вливаются форматирование и `codex/fix-live-database`. Grok отдельным коммитом убирает K3/N2 и K4/N1 из `known-defects.json`.
|
||||
3. Antigravity чинит тест, затем вливается его ветка. Grok убирает K13/SYNC-A и K13/SYNC-B.
|
||||
|
||||
## Новое: задача по пилоту от сессии платформы
|
||||
|
||||
Сессия «БП» по просьбе владельца и в режиме только чтения выяснила два факта.
|
||||
|
||||
1. Пилот на 192.168.1.81 пишет все данные под арендатором-заглушкой `01J00000000000000000000000`. Настоящий арендатор владельца в платформе — `01M2R6AC5652NPT3MG34DKN4ZP` («Сушигедза»).
|
||||
2. Скрипта сопоставления standalone → platform, обещанного в `docs/PLATFORM-INTEGRATION.md`, нет.
|
||||
|
||||
В репозитории я подтвердил:
|
||||
|
||||
- заглушку записывает сам `deploy/pilot/setup.sh:20`;
|
||||
- она же стоит значением по умолчанию в `compose.yaml:16` и в `.env.example:14`;
|
||||
- в `scripts/` скрипта сопоставления нет.
|
||||
|
||||
Задание — [TASK-CODEX-2.md](TASK-CODEX-2.md).
|
||||
|
||||
Договор идентичности с платформой (модуль ждёт JWT, платформа шлёт неподписанные заголовки) исправляется на стороне платформы: пункты А-5, С-6, С-7 её плана. Этим занимается сессия «БП». Модулю — не менять `PlatformIdentityAdapter` под неподписанные заголовки.
|
||||
|
||||
## Дополнение: перезаливы после первой версии приёмки
|
||||
|
||||
**`antigravity/fix-sync-c0-bootstrap` @ `8baad55`** (от `f5ac179`).
|
||||
|
||||
- Статус точки при начальной загрузке теперь вычисляется из `is_active` и `deleted_at`. На стенде Grok SYNC-C0 зелёный, итог стенда — 44 красных, 10 зелёных.
|
||||
- `tests/integration/sync-bootstrap.test.ts` на реальной базе: `Tests 2 passed`, но файл FAIL. Пул закрывается дважды: `app.close()` уже закрывает его через хук `onClose` (`src/app.ts:148`), а тест в `afterAll` (строка 96) закрывает его снова — `Called end on pool more than once`.
|
||||
- Конфликтует с `antigravity/fix-reject-device-shifts` в `src/domain/sync/service.ts`: обе ветки созданы от одной основы.
|
||||
|
||||
**`codex/fix-format-baseline` @ `513cdff`** (перезалив от `f5ac179`).
|
||||
|
||||
- Я нормализовал prettier'ом версии из основы, из ветки Codex и из ветки Antigravity. `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` у Codex **построчно совпадают** с веткой Antigravity и отличаются от основы на 255 и 139 строк. Исправление S1 по-прежнему в коммите форматирования. Пункт 1 TASK-CODEX-2 не выполнен.
|
||||
- `codex/fix-live-database` перебазирован на него (`8e955b0`), поэтому тоже несёт эту копию.
|
||||
|
||||
**Конфликты между ветками** (`git merge-tree`):
|
||||
|
||||
| Пара | Результат |
|
||||
| ------------------------ | ----------------------------------------------------- |
|
||||
| AG reject + AG c0 | конфликт |
|
||||
| Codex format + AG c0 | конфликт |
|
||||
| Codex format + AG reject | чисто — потому что в Codex лежит копия исправления AG |
|
||||
| Grok + AG c0 | чисто |
|
||||
|
||||
**Задания следующего круга:**
|
||||
|
||||
- [TASK-ANTIGRAVITY-3.md](TASK-ANTIGRAVITY-3.md) — починить оба теста, выстроить ветки в цепочку, отдельный коммит форматирования синхронизации, дальше S2–S9 и push;
|
||||
- [TASK-GROK-3.md](TASK-GROK-3.md) — приёмка с интеграционными тестами и проверкой конфликтов, проверка «форматирование без поведения», храповик после влития, сценарий переноса арендатора;
|
||||
- [TASK-CODEX-2.md](TASK-CODEX-2.md) — без изменений.
|
||||
|
||||
**Порядок влития, уточнённый:**
|
||||
|
||||
1. Grok.
|
||||
2. Цепочка Antigravity: reject → c0.
|
||||
3. Codex: форматирование без синхронизации, затем `fix-live-database`.
|
||||
|
||||
Цепочка Antigravity идёт раньше Codex, потому что после пересборки форматирования у Codex не останется пересечений с синхронизацией.
|
||||
|
|
@ -2,21 +2,24 @@
|
|||
|
||||
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через git.
|
||||
|
||||
| Файл | Что это |
|
||||
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца |
|
||||
| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity |
|
||||
| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 |
|
||||
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, **текущий круг**: синхронизация, push, оповещения, outbox |
|
||||
| [TASK-GROK-2.md](TASK-GROK-2.md) | Grok, **текущий круг**: запуск стенда под Windows, сопоставление ID, недостающие сценарии, приёмка |
|
||||
| [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Задания первого круга, для истории |
|
||||
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
|
||||
| Файл | Что это |
|
||||
| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца |
|
||||
| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity |
|
||||
| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 |
|
||||
| [ACCEPTANCE-2.md](ACCEPTANCE-2.md) | Приёмка второго круга: Grok принят, Codex и Antigravity — что доделать, порядок влития |
|
||||
| [TASK-CODEX-2.md](TASK-CODEX-2.md) | Codex, **текущий круг**: форматирование без синхронизации, пилот на настоящего арендатора, сопоставление с платформой |
|
||||
| [TASK-ANTIGRAVITY-3.md](TASK-ANTIGRAVITY-3.md) | Antigravity, **текущий круг**: починить тесты, цепочка веток, дальше S2–S9 и push |
|
||||
| [TASK-GROK-3.md](TASK-GROK-3.md) | Grok, **текущий круг**: приёмка с интеграционными тестами и конфликтами, перенос арендатора |
|
||||
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, круг 2: полный список S2–S9, push, оповещения, outbox — остаётся в силе |
|
||||
| [TASK-GROK-2.md](TASK-GROK-2.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Прошлые круги, для истории |
|
||||
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
|
||||
|
||||
## Как работать
|
||||
|
||||
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
|
||||
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
|
||||
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
|
||||
- Приёмку каждой ветки независимо проводит Grok; итог следующего круга появится здесь как `ACCEPTANCE-2.md`.
|
||||
- Приёмку каждой ветки независимо проводит Grok; итоги — в файлах `ACCEPTANCE-N.md`.
|
||||
|
||||
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
||||
|
|
|
|||
80
docs/audit/2026-09-26/TASK-ANTIGRAVITY-3.md
Normal file
80
docs/audit/2026-09-26/TASK-ANTIGRAVITY-3.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Задание для Antigravity, круг 3: довести две ветки синхронизации до влития
|
||||
|
||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Задание `TASK-ANTIGRAVITY-2.md` остаётся в силе, этот файл его дополняет и задаёт порядок.
|
||||
|
||||
## Итог приёмки твоих веток
|
||||
|
||||
| Ветка | Коммит | Что подтверждено | Что мешает влить |
|
||||
| -------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `antigravity/fix-reject-device-shifts` | `1df2019` | S1 исправлен: у Grok SYNC-A и SYNC-B зелёные | `tests/integration/sync-rejection.test.ts:81` вставляет столбец `created_by` в `staff_employees`, а такого столбца нет. Файл целиком FAIL |
|
||||
| `antigravity/fix-sync-c0-bootstrap` | `8baad55` | SYNC-C0 исправлен: на стенде Grok зелёный, оба теста `sync-bootstrap.test.ts` на реальной базе проходят | Файл всё равно FAIL: `Called end on pool more than once` в `afterAll` (строка 96). Пул закрывает `app.close()` через хук `onClose` (`src/app.ts:148`), а тест закрывает его второй раз |
|
||||
| обе вместе | — | — | **Конфликт** в `src/domain/sync/service.ts`: обе ветки созданы от `f5ac179` и правят один файл |
|
||||
|
||||
Проверка у меня: PostgreSQL 17.10, роль приложения без SUPERUSER и BYPASSRLS, миграции 0001–0011 на чистой базе.
|
||||
|
||||
## 1. Починить тесты (сегодня)
|
||||
|
||||
**1.1. `sync-rejection.test.ts`.** Подготовку данных сверить со схемой из миграций, а не писать по памяти: в `staff_employees` нет `created_by`. Смотри `migrations/*.up.sql` или `\d staff_employees`.
|
||||
|
||||
**1.2. `sync-bootstrap.test.ts`.** Закрывать пул один раз. Либо только `app.close()`, либо свою `Database`, которую приложение не закрывает, — но не обе сразу.
|
||||
|
||||
**Готово, когда** на реальной базе оба файла дают `Test Files 1 passed`, а не только `Tests N passed`: ошибка в хуке тоже делает файл красным, и CI его не пропустит.
|
||||
|
||||
```text
|
||||
DATABASE_TEST_URL=<роль приложения> DATABASE_MIGRATION_URL=<мигратор> npx vitest run tests/integration/sync-rejection.test.ts tests/integration/sync-bootstrap.test.ts
|
||||
```
|
||||
|
||||
## 2. Одна цепочка вместо двух конфликтующих веток
|
||||
|
||||
Две ветки от одной основы с правками одного файла нельзя влить обе без ручного разрешения конфликта, а разрешать его за тебя никто не будет. Сделай цепочку:
|
||||
|
||||
1. `antigravity/fix-reject-device-shifts` — исправленный тест (пункт 1.1), без новых функций.
|
||||
2. `antigravity/fix-sync-c0-bootstrap` — **перебазировать поверх** пункта 1 и разрешить конфликт у себя. Исправленный тест пункта 1.2 — здесь же.
|
||||
3. Пуш обеих веток с `--force-with-lease`.
|
||||
|
||||
Тогда вливается сначала первая ветка, потом вторая — без конфликтов.
|
||||
|
||||
**Готово, когда:**
|
||||
|
||||
- `git merge-base --is-ancestor origin/antigravity/fix-reject-device-shifts origin/antigravity/fix-sync-c0-bootstrap` выполняется успешно;
|
||||
- на вершине цепочки у Grok зелёные SYNC-A, SYNC-B и SYNC-C0, и оба интеграционных файла дают `passed`.
|
||||
|
||||
## 3. Форматирование синхронизации — твоё
|
||||
|
||||
В ветке Codex `codex/fix-format-baseline` (`513cdff`) до сих пор лежит твоё исправление S1: после prettier `service.ts` и `sync.test.ts` в ней совпадают с твоей веткой построчно. Codex получил задание убрать оттуда `src/domain/sync/**` и `tests/unit/sync.test.ts`. Поэтому:
|
||||
|
||||
- форматирование файлов синхронизации (`prettier --write src/domain/sync tests/unit/sync.test.ts tests/integration/sync-*.test.ts`) делаешь ты, в своей цепочке, **отдельным коммитом** `style(sync): …` без изменений поведения;
|
||||
- после этого `npx prettier --check src/domain/sync tests/unit/sync.test.ts` на вершине цепочки проходит.
|
||||
|
||||
## 4. Перед каждым пушем
|
||||
|
||||
В прошлых двух кругах интеграционный тест пушился без запуска на настоящей базе. Порядок перед каждым пушем:
|
||||
|
||||
1. `npm run check`.
|
||||
2. Интеграционные тесты своей зоны на реальной PostgreSQL — результат `Test Files … passed`.
|
||||
3. Стенд Grok: `node scripts/test/run-e2e.mjs` поверх своей ветки. Ни один сценарий, зелёный на основе, не должен покраснеть.
|
||||
4. В описании коммита или ветки — вывод пунктов 2 и 3 (строки `Test Files`, `Tests`, таблица по ID).
|
||||
|
||||
Если реальная база недоступна, так и напиши в отчёте. Не пиши «проверено», если не запускал.
|
||||
|
||||
## 5. Дальше — по TASK-ANTIGRAVITY-2, новыми ветками поверх цепочки
|
||||
|
||||
После пунктов 1–3 продолжай по разделам 3–6 `TASK-ANTIGRAVITY-2.md`. Одна тема — одна ветка, каждая следующая — от вершины предыдущей, пока они не влиты:
|
||||
|
||||
| Порядок | Ветка | ID | Готово, когда |
|
||||
| ------- | ------------------------------- | ------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| 1 | `antigravity/sync-device-scope` | S4, S8, S9 | SYNC-E, SYNC-F, SYNC-H зелёные; точку с устройством можно удалить или получить понятный отказ |
|
||||
| 2 | `antigravity/sync-idempotency` | S3, S2 | SYNC-G, SYNC-J зелёные |
|
||||
| 3 | `antigravity/sync-cursor` | S5, S6, S7 | SYNC-C, SYNC-D зелёные; отметка через синхронизацию пишет outbox |
|
||||
| 4 | `antigravity/push-hardening` | K12, N8, N9, U1–U7 | N3 и N9 зелёные |
|
||||
| 5 | `antigravity/outbox-publisher` | раздел 6 | по TASK-ANTIGRAVITY-2 |
|
||||
|
||||
Оповещения процесса подтверждения (раздел 5 TASK-ANTIGRAVITY-2) — только после того, как Codex вольёт этап 4.
|
||||
|
||||
Зона файлов прежняя: `src/domain/sync/**`, `src/domain/notifications/**`, push-модули, их миграции и тесты. Если нужна правка вне зоны — отдельный коммит с объяснением и пометкой в отчёте. Лучше попроси Codex.
|
||||
|
||||
## Отчёт
|
||||
|
||||
Для каждой ветки: имя, коммит вершины, основа, какие ID закрыты, вывод интеграционных тестов и стенда Grok, что осталось.
|
||||
80
docs/audit/2026-09-26/TASK-CODEX-2.md
Normal file
80
docs/audit/2026-09-26/TASK-CODEX-2.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Задание для Codex, круг 2: исправить ветку форматирования, перевести пилот на настоящего арендатора
|
||||
|
||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Основное задание `TASK-CODEX.md` остаётся в силе, этот файл его дополняет.
|
||||
|
||||
## 1. Пересобрать ветку форматирования (сегодня)
|
||||
|
||||
По данным приёмки, коммит `fd1ec3d` в ветке `codex/fix-format-baseline` содержит, кроме форматирования, функциональное изменение Antigravity. После нормализации prettier файлы `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` совпадают с его исправлением S1. Это одновременно смешение тем и правка чужой зоны (AGENTS.md). Кроме того, при влитии обеих веток изменение придёт дважды.
|
||||
|
||||
**Что сделать:** пересобрать коммит форматирования от `f5ac179` так, чтобы в нём было только `prettier --write`, без изменений в `src/domain/sync/**` и `tests/unit/sync.test.ts`. Исправление S1 должно приходить только из ветки Antigravity. Затем перебазировать `codex/fix-live-database` на новый коммит форматирования.
|
||||
|
||||
**Готово, когда:**
|
||||
|
||||
- `git diff <новый коммит>~1 <новый коммит> -w` после `prettier --check` не содержит изменений поведения;
|
||||
- стенд Grok на `codex/fix-live-database` даёт K3/N2 и K4/N1 зелёными, а K13/SYNC-A и K13/SYNC-B — красными (они придут от Antigravity).
|
||||
|
||||
## 2. Пилот — на настоящего арендатора
|
||||
|
||||
По данным сессии «БП» (запросы только на чтение к `krona-staff-pilot-postgres-1`), под заглушкой `01J00000000000000000000000` сейчас лежит:
|
||||
|
||||
| Таблица | Строк |
|
||||
| ---------------------- | ----- |
|
||||
| `staff_employees` | 51 |
|
||||
| `staff_payroll_ledger` | 3 830 |
|
||||
| `staff_work_items` | 3 353 |
|
||||
| `staff_daily_metrics` | 2 894 |
|
||||
| `staff_audit_log` | 49 |
|
||||
|
||||
Остальные таблицы — единицы строк.
|
||||
|
||||
Настоящий арендатор владельца в платформе — `01M2R6AC5652NPT3MG34DKN4ZP` («Сушигедза»).
|
||||
|
||||
### 2.1 Чтобы заглушка не вернулась
|
||||
|
||||
| Где | Что сделать |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `deploy/pilot/setup.sh:20` | Не генерировать `STANDALONE_TENANT_ID` со значением-заглушкой: требовать его явно, без значения по умолчанию, с понятной ошибкой |
|
||||
| `compose.yaml:16` | `${STANDALONE_TENANT_ID:?…}` вместо `${STANDALONE_TENANT_ID:-01J000…}` |
|
||||
| `.env.example:14` | Оставить пустым с комментарием, откуда берётся значение (идентификатор организации в платформе) |
|
||||
| `src/config.ts` | Вне режима разработки на loopback отклонять `01J00000000000000000000000` при старте как известную заглушку |
|
||||
|
||||
### 2.2 Перенос уже внесённых данных
|
||||
|
||||
Способ выбираешь сам: однократная перезапись `tenant_id` или переимпорт книги владельца. В обоих случаях обязательно:
|
||||
|
||||
1. **Сначала резервная копия** (задача K18 из основного задания) и проверка, что из неё действительно можно восстановиться. Без этого перенос не начинать.
|
||||
2. **Сначала прогон на копии** пилотной базы, затем на пилоте в согласованное окно, при остановленном приложении. Терминалы на это время копят отметки у себя.
|
||||
3. **Перезапись выполняет привилегированная роль администратора БД**, а не роль приложения. RLS с `FORCE`, составные внешние ключи `(tenant_id, id)`, неизменяемые журналы (`staff_payroll_ledger`, `staff_audit_log`) — всё это нужно учесть явно: одна транзакция, внешние ключи переустанавливаются в ней же, триггеры неизменяемости отключаются только внутри неё.
|
||||
4. **Скрипт в репозитории:** `scripts/rehome-tenant.mjs` (или аналог) с режимом `--dry-run` (только отчёт о количестве строк по таблицам) и отказом работать, если целевой арендатор уже содержит данные.
|
||||
5. **Запись о переносе** в `staff_audit_log` нового арендатора: кто, когда, откуда, куда, сколько строк.
|
||||
|
||||
**Готово, когда:**
|
||||
|
||||
- `SELECT DISTINCT tenant_id` по всем таблицам со столбцом `tenant_id` возвращает только `01M2R6AC5652NPT3MG34DKN4ZP`;
|
||||
- количество строк в каждой таблице до и после совпадает;
|
||||
- сумма `amount_minor` в реестре начислений до и после совпадает;
|
||||
- пилот под новым арендатором показывает тех же 51 сотрудника;
|
||||
- тест изоляции арендаторов зелёный.
|
||||
|
||||
Вывод `--dry-run` и итоговые запросы приложи к отчёту. Grok проверит скрипт на копии: данные под старым арендатором → перенос → сверка.
|
||||
|
||||
### 2.3 Скрипт сопоставления с платформой
|
||||
|
||||
`docs/PLATFORM-INTEGRATION.md` обещает сопоставление standalone ULID с записями Core, но скрипта нет. После пункта 2.2 перенос арендатора уже решён, поэтому скрипт сводится к заполнению `core_user_id` у сотрудников:
|
||||
|
||||
- вход — выгрузка пользователей платформы (JSON или CSV: идентификатор пользователя Core и признак сопоставления — табельный номер, e-mail или телефон);
|
||||
- режим `--dry-run` с отчётом: сопоставлены, не найдены, найдены неоднозначно;
|
||||
- идемпотентность: повторный запуск ничего не меняет, существующие `core_user_id` не перезаписываются без флага;
|
||||
- запись в журнал аудита.
|
||||
|
||||
**Готово, когда** скрипт есть, покрыт тестом на реальной PostgreSQL и описан в `docs/PLATFORM-INTEGRATION.md`.
|
||||
|
||||
## 3. Не делать
|
||||
|
||||
**Не менять `PlatformIdentityAdapter` под нынешние неподписанные заголовки `x-tenant-id`**, которые платформа передаёт модулю. Договор будет с подписью: пункты А-5, С-6, С-7 плана платформы. Им занимается сессия «БП» в репозитории `business-platform`. Режим `platform` остаётся недоступным, пока платформа этого не сделает.
|
||||
|
||||
## Порядок
|
||||
|
||||
Пункт 1 → пункт 2.1 → резервная копия (K18) → пункт 2.2 на копии → 2.2 на пилоте → пункт 2.3. Параллельно продолжается основное задание: этап 2 — доступ и пилот, этап 3 — деньги (K5 и K6 теперь воспроизводятся по существу).
|
||||
88
docs/audit/2026-09-26/TASK-GROK-3.md
Normal file
88
docs/audit/2026-09-26/TASK-GROK-3.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Задание для Grok, круг 3: приёмка с интеграционными тестами, конфликты веток, сценарий переноса арендатора
|
||||
|
||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Ветка `grok/e2e-harness` (`ba59a48`) принята и вливается первой.
|
||||
|
||||
## Итог прошлого круга
|
||||
|
||||
Принято без замечаний по существу: стенд работает на Windows без правок, ID сопоставлены с аудитом, недостающие сценарии добавлены, приёмка ветки Codex совпала с моей до цифры (41 красный / 13 зелёных). Твоя находка про поведение в «стилевом» коммите Codex подтверждена.
|
||||
|
||||
Одно упущение: в приёмке запускались только `tests/e2e`, а интеграционные тесты самих исполнителей — нет. Из-за этого два красных файла Antigravity прошли бы как «принято».
|
||||
|
||||
## Правила — как раньше
|
||||
|
||||
Только твоя зона: `tests/e2e/**`, `scripts/test/**`, `known-defects.json`, CI. В код исполнителей не коммитишь. Ветки исполнителей не меняешь — проверяешь в отдельных рабочих копиях.
|
||||
|
||||
## 1. Приёмка = стенд + интеграционные тесты + конфликты
|
||||
|
||||
Добавь в `scripts/test/` режим приёмки ветки, например `node scripts/test/accept-branch.mjs <ветка> [<ветка>…]`. Для каждой ветки он:
|
||||
|
||||
1. Создаёт отдельную рабочую копию (`git worktree`) и накладывает текущий стенд.
|
||||
2. Запускает `npm run check`.
|
||||
3. Запускает **все** `tests/integration/*.test.ts` на реальной PostgreSQL. Считает файл красным по итоговой строке `Test Files`, а не `Tests`: ошибка в `beforeAll`/`afterAll` делает файл красным даже при зелёных тестах.
|
||||
4. Запускает e2e-стенд и сравнивает с основой: что позеленело, что покраснело.
|
||||
5. Проверяет попарно слияние всех переданных веток между собой и с основой (`git merge-tree --write-tree`). Печатает таблицу «чисто / конфликт, файлы».
|
||||
6. Печатает итог в markdown, чтобы его можно было вставить в отчёт.
|
||||
|
||||
**Роли базы.** Стенд пересоздаёт роли со случайными паролями на общем кластере — это ломает параллельную работу других. Сделай так, чтобы роли и пароли можно было передать переменными (`E2E_MIGRATOR_URL`, `E2E_APP_URL`) и стенд их тогда не трогал. По умолчанию — как сейчас.
|
||||
|
||||
**Готово, когда** запуск на `antigravity/fix-reject-device-shifts antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline` сам показывает то, что я нашёл вручную:
|
||||
|
||||
| Что | Ожидаемый результат на сегодня |
|
||||
| ------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `sync-rejection.test.ts` на `1df2019` | FAIL: `column "created_by" … does not exist` |
|
||||
| `sync-bootstrap.test.ts` на `8baad55` | FAIL: `Called end on pool more than once` при `Tests 2 passed` |
|
||||
| SYNC-C0 на `8baad55` | зелёный |
|
||||
| AG reject + AG c0 | конфликт, `src/domain/sync/service.ts` |
|
||||
| Codex format + AG c0 | конфликт |
|
||||
| Codex format + AG reject | чисто (потому что в форматировании Codex лежит копия исправления S1) |
|
||||
|
||||
## 2. Проверка «форматирование без поведения»
|
||||
|
||||
Для ветки `codex/fix-format-baseline` (сейчас `513cdff`) добавь в режим приёмки проверку: для каждого изменённого файла взять версию из основы, прогнать через prettier и сравнить с версией из ветки. Любое отличие после нормализации — это поведение, а не стиль. Печатать список таких файлов.
|
||||
|
||||
На сегодня ожидается: `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` отличаются. После нормализации они совпадают с веткой Antigravity построчно. Я проверил это на `513cdff`: Codex перезалил ветку, но синхронизацию не убрал.
|
||||
|
||||
**Готово, когда** проверка ловит `513cdff` и пропускает ветку, где остался только prettier.
|
||||
|
||||
## 3. Храповик после каждого влития
|
||||
|
||||
Когда владелец вливает ветку, отдельным коммитом убери из `known-defects.json` то, что стало зелёным на основе:
|
||||
|
||||
| После влития | Убрать |
|
||||
| --------------------------------- | ------------------------------- |
|
||||
| `codex/fix-live-database` | K3/N2, K4/N1 |
|
||||
| цепочка Antigravity (reject → c0) | K13/SYNC-A, K13/SYNC-B, SYNC-C0 |
|
||||
|
||||
Коммит — только `known-defects.json`, в теле — вывод стенда на основе после влития.
|
||||
|
||||
## 4. Сценарий переноса арендатора (для TASK-CODEX-2, п. 2.2)
|
||||
|
||||
Codex пишет `scripts/rehome-tenant.mjs`: перенос всех данных пилота из заглушки `01J00000000000000000000000` в арендатора `01M2R6AC5652NPT3MG34DKN4ZP`. Подготовь проверку заранее, чтобы принять скрипт в день пуша:
|
||||
|
||||
1. Наполнить базу под заглушкой: сотрудники, точки, смены, начисления в реестре, строки аудита, устройства синхронизации, push-подписки — по несколько строк в каждой таблице со столбцом `tenant_id`. Плюс второй, посторонний арендатор с собственными данными.
|
||||
2. Снять контрольные цифры: число строк по каждой таблице и арендатору, `SUM(amount_minor)` реестра.
|
||||
3. `--dry-run` — ничего не меняет, печатает те же числа.
|
||||
4. Перенос ролью администратора БД.
|
||||
5. Сверка:
|
||||
- `DISTINCT tenant_id` — только новый арендатор и посторонний;
|
||||
- число строк и суммы совпадают;
|
||||
- данные постороннего арендатора не тронуты;
|
||||
- в `staff_audit_log` нового арендатора есть запись о переносе;
|
||||
- приложение ролью `krona_staff_app` под новым арендатором видит тех же сотрудников, а под старым — ничего;
|
||||
- журналы остались неизменяемыми: после переноса `UPDATE` в `staff_payroll_ledger` отклоняется.
|
||||
6. Повторный запуск отказывает: целевой арендатор уже содержит данные.
|
||||
|
||||
Пока скрипта нет, сценарий красный с понятным сообщением и заносится в `known-defects.json` как `PILOT-REHOME`. Список таблиц со столбцом `tenant_id` бери из `information_schema.columns`, а не руками — так новая таблица без переноса сразу даст красный.
|
||||
|
||||
## Порядок
|
||||
|
||||
1 → 2 → прогнать приёмку по всем открытым веткам и выложить результат → 3 по мере влития → 4.
|
||||
|
||||
## Отчёт
|
||||
|
||||
- ветка и коммит;
|
||||
- вывод режима приёмки по всем открытым веткам (`codex/*`, `antigravity/*`) на момент отчёта;
|
||||
- что изменилось в `known-defects.json`;
|
||||
- что не запускалось и почему.
|
||||
|
|
@ -90,7 +90,11 @@ export class SyncService {
|
|||
|
||||
const tokenTenant = SyncService.parseTenantFromToken(rawToken);
|
||||
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
|
||||
throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403);
|
||||
throw new SyncAuthError(
|
||||
'Несоответствие арендатора в токене устройства',
|
||||
'TENANT_MISMATCH',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = providedTenantId ?? tokenTenant;
|
||||
|
|
@ -187,9 +191,10 @@ export class SyncService {
|
|||
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
|
||||
const tokenHash = SyncService.hashToken(rawToken);
|
||||
const tokenHint = rawToken.slice(0, 16);
|
||||
const scopes = params.allowed_scopes && params.allowed_scopes.length > 0
|
||||
? params.allowed_scopes
|
||||
: ['attendance:sync'];
|
||||
const scopes =
|
||||
params.allowed_scopes && params.allowed_scopes.length > 0
|
||||
? params.allowed_scopes
|
||||
: ['attendance:sync'];
|
||||
|
||||
const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null;
|
||||
|
||||
|
|
@ -210,7 +215,17 @@ export class SyncService {
|
|||
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
|
||||
[deviceId, tenantId, params.device_code.trim(), params.name.trim(), tokenHash, tokenHint, scopes, orgUnitId, actorId],
|
||||
[
|
||||
deviceId,
|
||||
tenantId,
|
||||
params.device_code.trim(),
|
||||
params.name.trim(),
|
||||
tokenHash,
|
||||
tokenHint,
|
||||
scopes,
|
||||
orgUnitId,
|
||||
actorId,
|
||||
],
|
||||
);
|
||||
|
||||
const row = res.rows[0]!;
|
||||
|
|
@ -326,7 +341,8 @@ export class SyncService {
|
|||
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
||||
if (options.cursor) {
|
||||
const seq = decodeCursor(tenantId, options.cursor);
|
||||
const filterTypes = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||
const filterTypes =
|
||||
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||
|
||||
const res = await client.query<{
|
||||
id: string;
|
||||
|
|
@ -360,9 +376,8 @@ export class SyncService {
|
|||
created_at: r.created_at.toISOString(),
|
||||
}));
|
||||
|
||||
const nextCursor = changes.length > 0
|
||||
? changes[changes.length - 1]!.cursor
|
||||
: options.cursor;
|
||||
const nextCursor =
|
||||
changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
|
@ -380,16 +395,22 @@ export class SyncService {
|
|||
const latestCursor = encodeCursor(tenantId, maxSeq);
|
||||
|
||||
const wanted = (type: string) =>
|
||||
!options.entity_types || options.entity_types.length === 0 || options.entity_types.includes(type);
|
||||
!options.entity_types ||
|
||||
options.entity_types.length === 0 ||
|
||||
options.entity_types.includes(type);
|
||||
|
||||
const changes: SyncChange[] = [];
|
||||
|
||||
// Org Units
|
||||
if (wanted('org_unit')) {
|
||||
const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`,
|
||||
[tenantId],
|
||||
);
|
||||
const orgRes = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
|
||||
tenantId,
|
||||
]);
|
||||
for (const row of orgRes.rows) {
|
||||
const payload: SafeOrgUnitPayload = {
|
||||
id: row.id,
|
||||
|
|
@ -411,7 +432,12 @@ export class SyncService {
|
|||
|
||||
// Professions
|
||||
if (wanted('profession')) {
|
||||
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||
const profRes = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
|
||||
[tenantId],
|
||||
);
|
||||
|
|
@ -662,7 +688,8 @@ export class SyncService {
|
|||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||
error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
||||
error_message:
|
||||
'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
||||
};
|
||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
|
||||
results.push(rejectedResult);
|
||||
|
|
@ -757,7 +784,17 @@ export class SyncService {
|
|||
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
||||
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId],
|
||||
[
|
||||
eventId,
|
||||
tenantId,
|
||||
employeeId,
|
||||
eventType,
|
||||
sourceEventId,
|
||||
occurredAt,
|
||||
device.id,
|
||||
device.id,
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
|
||||
const safePayload: SafeAttendanceEventPayload = {
|
||||
|
|
@ -771,7 +808,15 @@ export class SyncService {
|
|||
status: 'accepted',
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload);
|
||||
await this.recordChange(
|
||||
client,
|
||||
tenantId,
|
||||
'attendance_event',
|
||||
eventId,
|
||||
'upsert',
|
||||
1,
|
||||
safePayload,
|
||||
);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
|
|
@ -781,207 +826,15 @@ export class SyncService {
|
|||
};
|
||||
}
|
||||
|
||||
case 'upsert_work_session': {
|
||||
const sessionId = (payload.id as string) || newUlid();
|
||||
const employeeId = payload.employee_id as string;
|
||||
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
||||
const businessDate = payload.business_date as string;
|
||||
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||
const startedAt = payload.started_at as string | null | undefined;
|
||||
const endedAt = payload.ended_at as string | null | undefined;
|
||||
const workedMinutes = typeof payload.worked_minutes === 'number' ? payload.worked_minutes : 0;
|
||||
const sessionStatus = (payload.status as string) || 'open';
|
||||
|
||||
if (!employeeId || !orgUnitId || !businessDate) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
|
||||
error_message: 'employee_id, org_unit_id и business_date обязательны',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||
`SELECT id, revision, status FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2`,
|
||||
[tenantId, sessionId],
|
||||
);
|
||||
|
||||
if (existingRes.rows.length > 0) {
|
||||
const existing = existingRes.rows[0]!;
|
||||
if (existing.revision !== clientRevision) {
|
||||
const conflict: SyncConflict = {
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'work_session',
|
||||
entity_id: sessionId,
|
||||
expected_revision: clientRevision,
|
||||
actual_revision: existing.revision,
|
||||
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
};
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'conflict',
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
const nextRev = existing.revision + 1;
|
||||
await client.query(
|
||||
`UPDATE staff_work_sessions
|
||||
SET started_at = $1, ended_at = $2, worked_minutes = $3, status = $4, revision = $5, updated_at = now()
|
||||
WHERE tenant_id = $6 AND id = $7`,
|
||||
[startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, nextRev, tenantId, sessionId],
|
||||
);
|
||||
|
||||
const safePayload: SafeWorkSessionPayload = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: businessDate,
|
||||
started_at: startedAt ?? null,
|
||||
ended_at: endedAt ?? null,
|
||||
worked_minutes: workedMinutes,
|
||||
status: sessionStatus,
|
||||
revision: nextRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', nextRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: sessionId, revision: nextRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
// New work session insert
|
||||
const newRev = clientRevision;
|
||||
await client.query(
|
||||
`INSERT INTO staff_work_sessions
|
||||
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[sessionId, tenantId, employeeId, orgUnitId, businessDate, startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, newRev],
|
||||
);
|
||||
|
||||
const safePayload: SafeWorkSessionPayload = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: businessDate,
|
||||
started_at: startedAt ?? null,
|
||||
ended_at: endedAt ?? null,
|
||||
worked_minutes: workedMinutes,
|
||||
status: sessionStatus,
|
||||
revision: newRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', newRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: sessionId, revision: newRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
case 'upsert_work_session':
|
||||
case 'upsert_timesheet_entry': {
|
||||
const entryId = (payload.id as string) || newUlid();
|
||||
const employeeId = payload.employee_id as string;
|
||||
const workDate = payload.work_date as string;
|
||||
const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0;
|
||||
const tsStatus = (payload.status as string) || 'draft';
|
||||
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||
|
||||
if (!employeeId || !workDate) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'INVALID_TIMESHEET_PAYLOAD',
|
||||
error_message: 'employee_id и work_date обязательны',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
|
||||
[tenantId, entryId],
|
||||
);
|
||||
|
||||
if (existingRes.rows.length > 0) {
|
||||
const existing = existingRes.rows[0]!;
|
||||
if (existing.revision !== clientRevision) {
|
||||
const conflict: SyncConflict = {
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'timesheet_entry',
|
||||
entity_id: entryId,
|
||||
expected_revision: clientRevision,
|
||||
actual_revision: existing.revision,
|
||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
};
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'conflict',
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
const nextRev = existing.revision + 1;
|
||||
await client.query(
|
||||
`UPDATE staff_timesheet_entries
|
||||
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
|
||||
WHERE tenant_id = $4 AND id = $5`,
|
||||
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
|
||||
);
|
||||
|
||||
const safePayload: SafeTimesheetEntryPayload = {
|
||||
id: entryId,
|
||||
employee_id: employeeId,
|
||||
work_date: workDate,
|
||||
minutes_worked: minutesWorked,
|
||||
status: tsStatus,
|
||||
revision: nextRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: entryId, revision: nextRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
const newRev = clientRevision;
|
||||
await client.query(
|
||||
`INSERT INTO staff_timesheet_entries
|
||||
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
|
||||
);
|
||||
|
||||
const safePayload: SafeTimesheetEntryPayload = {
|
||||
id: entryId,
|
||||
employee_id: employeeId,
|
||||
work_date: workDate,
|
||||
minutes_worked: minutesWorked,
|
||||
status: tsStatus,
|
||||
revision: newRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: entryId, revision: newRev, status: 'applied' },
|
||||
status: 'rejected',
|
||||
error_code: 'COMMAND_NOT_SUPPORTED',
|
||||
error_message:
|
||||
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1052,17 +905,23 @@ export class SyncService {
|
|||
);
|
||||
}
|
||||
|
||||
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => {
|
||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||
[retentionDays],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return {
|
||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||
purged_commands: Number(row?.purged_commands ?? 0),
|
||||
};
|
||||
});
|
||||
async cleanupRetention(
|
||||
retentionDays = 30,
|
||||
): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||
return this.database.tenantTransaction(
|
||||
this.systemActorId,
|
||||
this.systemActorId,
|
||||
async (client) => {
|
||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||
[retentionDays],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return {
|
||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||
purged_commands: Number(row?.purged_commands ?? 0),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
208
tests/integration/sync-rejection.test.ts
Normal file
208
tests/integration/sync-rejection.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Database } from '../../src/infrastructure/database.js';
|
||||
import { newUlid, parseUlid } from '../../src/core/ids.js';
|
||||
import { SyncService } from '../../src/domain/sync/service.js';
|
||||
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_TEST_URL;
|
||||
const migrationConnectionString =
|
||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
||||
const describeDatabase = connectionString ? describe : describe.skip;
|
||||
|
||||
const cleanupTables = [
|
||||
'staff_sync_command_inbox',
|
||||
'staff_sync_change_log',
|
||||
'staff_sync_devices',
|
||||
'staff_work_sessions',
|
||||
'staff_timesheet_entries',
|
||||
'staff_employees',
|
||||
] as const;
|
||||
|
||||
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J00000000000000000000001');
|
||||
const deviceId = newUlid();
|
||||
const employeeId = newUlid();
|
||||
const sessionId = newUlid();
|
||||
const timesheetId = newUlid();
|
||||
const rawToken = `kstaff_dev_${tenantId}_test_rejection_token_secret`;
|
||||
const tokenHash = SyncService.hashToken(rawToken);
|
||||
|
||||
const syncService = new SyncService(database, actorId);
|
||||
|
||||
const testDevice = {
|
||||
id: deviceId,
|
||||
tenant_id: tenantId,
|
||||
device_code: 'TERM-REJECT-TEST',
|
||||
name: 'Rejection Test Terminal',
|
||||
token_hash: tokenHash,
|
||||
token_hint: 'kstaff_dev_...',
|
||||
status: 'active' as const,
|
||||
allowed_scopes: ['attendance:sync'],
|
||||
org_unit_id: null,
|
||||
registered_at: new Date().toISOString(),
|
||||
last_seen_at: null,
|
||||
revoked_at: null,
|
||||
created_by: actorId,
|
||||
};
|
||||
|
||||
async function cleanupAsMigrator() {
|
||||
if (!migrationConnectionString) {
|
||||
return;
|
||||
}
|
||||
const migrator = new pg.Client({
|
||||
connectionString: migrationConnectionString,
|
||||
application_name: 'krona-staff-test-cleanup',
|
||||
});
|
||||
await migrator.connect();
|
||||
try {
|
||||
await migrator.query('BEGIN');
|
||||
try {
|
||||
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
||||
for (const table of cleanupTables) {
|
||||
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]);
|
||||
}
|
||||
await migrator.query('COMMIT');
|
||||
} catch (error) {
|
||||
await migrator.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await migrator.end();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanupAsMigrator();
|
||||
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
||||
// Create employee
|
||||
await client.query(
|
||||
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
||||
VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active')`,
|
||||
[employeeId, tenantId],
|
||||
);
|
||||
|
||||
// Create sync device
|
||||
await client.query(
|
||||
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
deviceId,
|
||||
tenantId,
|
||||
testDevice.device_code,
|
||||
testDevice.name,
|
||||
tokenHash,
|
||||
testDevice.token_hint,
|
||||
'active',
|
||||
['attendance:sync'],
|
||||
actorId,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await cleanupAsMigrator();
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects upsert_work_session and creates NO records in staff_work_sessions or change log', async () => {
|
||||
// S1 scenario: device attempts to create a confirmed work session with 2880 minutes
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_work_session',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-live-s1-work-session',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
business_date: '2026-09-26',
|
||||
status: 'confirmed',
|
||||
worked_minutes: 2880,
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const results = await syncService.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
|
||||
// Verify 0 rows in staff_work_sessions
|
||||
const sessionRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ cnt: number }>(
|
||||
'SELECT count(*)::int as cnt FROM staff_work_sessions WHERE tenant_id = $1',
|
||||
[tenantId],
|
||||
),
|
||||
);
|
||||
expect(sessionRes.rows[0]?.cnt).toBe(0);
|
||||
|
||||
// Verify 0 rows in staff_sync_change_log for work_session
|
||||
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ cnt: number }>(
|
||||
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'work_session'",
|
||||
[tenantId],
|
||||
),
|
||||
);
|
||||
expect(changeRes.rows[0]?.cnt).toBe(0);
|
||||
|
||||
// Verify recorded in staff_sync_command_inbox as rejected
|
||||
const inboxRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ status: string; error_code: string }>(
|
||||
'SELECT status, error_code FROM staff_sync_command_inbox WHERE tenant_id = $1 AND idempotency_key = $2',
|
||||
[tenantId, command.idempotency_key],
|
||||
),
|
||||
);
|
||||
expect(inboxRes.rows[0]?.status).toBe('rejected');
|
||||
expect(inboxRes.rows[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
});
|
||||
|
||||
it('rejects upsert_timesheet_entry and creates NO records in staff_timesheet_entries or change log', async () => {
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_timesheet_entry',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-live-timesheet-entry',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: timesheetId,
|
||||
employee_id: employeeId,
|
||||
work_date: '2026-09-26',
|
||||
minutes_worked: 480,
|
||||
status: 'approved',
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const results = await syncService.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
|
||||
// Verify 0 rows in staff_timesheet_entries
|
||||
const tsRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ cnt: number }>(
|
||||
'SELECT count(*)::int as cnt FROM staff_timesheet_entries WHERE tenant_id = $1',
|
||||
[tenantId],
|
||||
),
|
||||
);
|
||||
expect(tsRes.rows[0]?.cnt).toBe(0);
|
||||
|
||||
// Verify 0 rows in staff_sync_change_log for timesheet_entry
|
||||
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ cnt: number }>(
|
||||
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'timesheet_entry'",
|
||||
[tenantId],
|
||||
),
|
||||
);
|
||||
expect(changeRes.rows[0]?.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,9 @@ import type { SyncCommand } from '../../src/domain/sync/types.js';
|
|||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
||||
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
|
||||
function createMockDb(
|
||||
queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number },
|
||||
) {
|
||||
const queries: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
queries.push({ sql, params });
|
||||
|
|
@ -105,7 +107,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
|||
expect(device.tenant_id).toBe(tenantA);
|
||||
expect(device.status).toBe('active');
|
||||
expect(
|
||||
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
|
||||
queries.some(
|
||||
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -187,7 +191,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
|||
});
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
|
||||
await expect(
|
||||
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'SCOPE_INSUFFICIENT',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
|
@ -297,25 +303,15 @@ describe('Sync Push & Idempotency', () => {
|
|||
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns structured conflict on revision mismatch for work_session', async () => {
|
||||
it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
|
||||
const sessionId = newUlid();
|
||||
|
||||
const { database } = createMockDb((sql) => {
|
||||
if (sql.includes('SELECT status, result, conflict')) {
|
||||
return { rows: [] }; // Not in inbox
|
||||
}
|
||||
if (sql.includes('SELECT id, revision, status FROM staff_work_sessions')) {
|
||||
// Server has revision 3
|
||||
return { rows: [{ id: sessionId, revision: 3, status: 'confirmed' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
const { database } = createMockDb();
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_work_session',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-session-conflict',
|
||||
idempotency_key: 'cmd-session-reject',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
|
|
@ -323,43 +319,28 @@ describe('Sync Push & Idempotency', () => {
|
|||
employee_id: employeeId,
|
||||
org_unit_id: newUlid(),
|
||||
business_date: '2026-09-26',
|
||||
revision: 2, // Client expects revision 2, but server has 3
|
||||
revision: 1,
|
||||
worked_minutes: 480,
|
||||
status: 'confirmed',
|
||||
},
|
||||
};
|
||||
|
||||
const results = await service.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('conflict');
|
||||
expect(results[0]?.conflict).toEqual({
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'work_session',
|
||||
entity_id: sessionId,
|
||||
expected_revision: 2,
|
||||
actual_revision: 3,
|
||||
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
});
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
});
|
||||
|
||||
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
|
||||
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
|
||||
const entryId = newUlid();
|
||||
|
||||
const { database } = createMockDb((sql) => {
|
||||
if (sql.includes('SELECT status, result, conflict')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT id, revision, status FROM staff_timesheet_entries')) {
|
||||
return { rows: [{ id: entryId, revision: 4, status: 'approved' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
const { database } = createMockDb();
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_timesheet_entry',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-ts-conflict',
|
||||
idempotency_key: 'cmd-ts-reject',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
|
|
@ -367,22 +348,92 @@ describe('Sync Push & Idempotency', () => {
|
|||
employee_id: employeeId,
|
||||
work_date: '2026-09-26',
|
||||
minutes_worked: 500,
|
||||
revision: 1, // Mismatch: expects 1, server has 4
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const results = await service.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('conflict');
|
||||
expect(results[0]?.conflict).toEqual({
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'timesheet_entry',
|
||||
entity_id: entryId,
|
||||
expected_revision: 1,
|
||||
actual_revision: 4,
|
||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
});
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
});
|
||||
|
||||
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
|
||||
const { database, queries } = createMockDb();
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const commands: SyncCommand[] = [
|
||||
{
|
||||
command_type: 'upsert_work_session',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-session-no-write',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: newUlid(),
|
||||
employee_id: employeeId,
|
||||
org_unit_id: newUlid(),
|
||||
business_date: '2026-09-26',
|
||||
status: 'confirmed',
|
||||
worked_minutes: 2880,
|
||||
},
|
||||
},
|
||||
{
|
||||
command_type: 'upsert_timesheet_entry',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-ts-no-write',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: newUlid(),
|
||||
employee_id: employeeId,
|
||||
work_date: '2026-09-26',
|
||||
minutes_worked: 500,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = await service.push(tenantId, testDevice, commands);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.status === 'rejected')).toBe(true);
|
||||
|
||||
// Verify nothing is written to staff_work_sessions
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_work_sessions') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify nothing is written to staff_timesheet_entries
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify nothing is written to staff_sync_change_log
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_sync_change_log') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify commands ARE saved to inbox with status 'rejected'
|
||||
const inboxInserts = queries.filter((q) =>
|
||||
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
|
||||
);
|
||||
expect(inboxInserts).toHaveLength(2);
|
||||
for (const insert of inboxInserts) {
|
||||
expect(insert.params).toContain('rejected');
|
||||
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces batch size limits and payload size limits', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue