Merge production readiness fixes
This commit is contained in:
commit
94f17c78cf
28 changed files with 1949 additions and 190 deletions
|
|
@ -24,7 +24,7 @@ docker compose up --build app postgres
|
|||
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
||||
|
||||
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), запуск и чек-лист боевого тестирования: [docs/RUN-MANUAL-TESTING.md](docs/RUN-MANUAL-TESTING.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
|
||||
## Расчётный контур
|
||||
|
||||
|
|
|
|||
167
api/openapi.yaml
167
api/openapi.yaml
|
|
@ -74,6 +74,26 @@ paths:
|
|||
responses:
|
||||
'204': { description: Сессия завершена }
|
||||
'403': { description: Запрос с постороннего сайта отклонён }
|
||||
/access-grants:
|
||||
post:
|
||||
summary: Выпустить ключ доступа для роли
|
||||
operationId: createStaffAccessGrant
|
||||
tags: [Authentication]
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [role, display_name]
|
||||
properties:
|
||||
role: { type: string, enum: [point_manager, accountant, director] }
|
||||
display_name: { type: string, minLength: 1, maxLength: 255 }
|
||||
org_unit_ids: { type: array, items: { type: string } }
|
||||
responses:
|
||||
'201': { description: Ключ выпущен }
|
||||
'403': { description: Недостаточно прав }
|
||||
/portal:
|
||||
get:
|
||||
summary: Получить доступную пользователю сводку посещаемости и табеля
|
||||
|
|
@ -311,6 +331,30 @@ paths:
|
|||
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Сотрудник не найден }
|
||||
/employees/{employeeId}/biometric-consent:
|
||||
put:
|
||||
summary: Установить согласие сотрудника на обработку биометрии
|
||||
operationId: setEmployeeBiometricConsent
|
||||
tags: [Employees]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: employeeId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [status]
|
||||
properties:
|
||||
status: { type: string, enum: [granted, revoked] }
|
||||
document_ref: { type: string }
|
||||
responses:
|
||||
'200': { description: Согласие сохранено }
|
||||
'403': { description: Недостаточно прав }
|
||||
/employees/{employeeId}/pay-assignment:
|
||||
get:
|
||||
summary: Получить зарплатный профиль сотрудника
|
||||
|
|
@ -372,6 +416,15 @@ paths:
|
|||
type: array
|
||||
items: { type: object }
|
||||
'403': { description: Недостаточно прав }
|
||||
/notifications:
|
||||
get:
|
||||
summary: Получить уведомления
|
||||
operationId: getBatchNotifications
|
||||
tags: [Notifications]
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'200': { description: Список уведомлений }
|
||||
'403': { description: Недостаточно прав }
|
||||
/org-units:
|
||||
get:
|
||||
summary: Получить доступные торговые точки
|
||||
|
|
@ -476,6 +529,92 @@ paths:
|
|||
responses:
|
||||
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
|
||||
'403': { description: Недостаточно прав }
|
||||
/payroll/batches:
|
||||
post:
|
||||
summary: Сформировать зарплатный пакет подразделения
|
||||
operationId: createPayrollBatch
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [org_unit_id, from, to]
|
||||
properties:
|
||||
org_unit_id: { $ref: '#/components/schemas/Ulid' }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
responses:
|
||||
'201': { description: Пакет сформирован }
|
||||
'403': { description: Недостаточно прав }
|
||||
/payroll/batches/{id}:
|
||||
get:
|
||||
summary: Получить зарплатный пакет
|
||||
operationId: getPayrollBatch
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200': { description: Зарплатный пакет }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Пакет не найден }
|
||||
/payroll/batches/{id}/submit:
|
||||
post:
|
||||
summary: Отправить зарплатный пакет на подпись
|
||||
operationId: submitPayrollBatch
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200': { description: Пакет отправлен }
|
||||
'403': { description: Недостаточно прав }
|
||||
'409': { description: Пакет уже подписан }
|
||||
/payroll/batches/{id}/sign:
|
||||
post:
|
||||
summary: Подписать зарплатный пакет и начислить в реестр
|
||||
operationId: signPayrollBatch
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200': { description: Пакет подписан }
|
||||
'403': { description: Недостаточно прав }
|
||||
'409': { description: Пакет возвращён из-за изменения смен }
|
||||
/payroll/batches/{id}/return:
|
||||
post:
|
||||
summary: Вернуть зарплатный пакет на доработку
|
||||
operationId: returnPayrollBatch
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
comment: { type: string }
|
||||
responses:
|
||||
'200': { description: Пакет возвращён }
|
||||
'403': { description: Недостаточно прав }
|
||||
/payroll/calculate:
|
||||
post:
|
||||
summary: Рассчитать зарплату за период
|
||||
|
|
@ -567,6 +706,20 @@ paths:
|
|||
lines: { type: array, items: { type: object } }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Расчёт не найден }
|
||||
/payroll/runs/{runId}/verify:
|
||||
get:
|
||||
summary: Проверить воспроизводимость расчёта зарплаты
|
||||
operationId: verifyStaffPayrollRun
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200': { description: Результат сверки }
|
||||
'403': { description: Недостаточно прав }
|
||||
/imports/owner-workbook:
|
||||
post:
|
||||
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
||||
|
|
@ -957,6 +1110,20 @@ paths:
|
|||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { description: Недействительный токен устройства }
|
||||
'403': { description: Устройство отозвано или заблокировано }
|
||||
/terminal/employees/{employeeId}/enrollment:
|
||||
get:
|
||||
summary: Проверить статус биометрической регистрации для терминала
|
||||
operationId: getTerminalEmployeeEnrollment
|
||||
tags: [Terminal]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: employeeId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200': { description: Статус регистрации }
|
||||
'403': { description: Недостаточно прав }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
|
|
|
|||
|
|
@ -5,23 +5,23 @@ services:
|
|||
build:
|
||||
context: ../..
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8080
|
||||
LOG_LEVEL: info
|
||||
STAFF_RUNTIME_MODE: standalone
|
||||
STANDALONE_ACCESS: employee
|
||||
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN}
|
||||
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
|
||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
||||
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN:?STANDALONE_MANAGER_TOKEN is required}
|
||||
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN:?STANDALONE_TERMINAL_TOKEN is required}
|
||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?STANDALONE_TENANT_ID is required}
|
||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:?STANDALONE_ACTOR_ID is required}
|
||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID:?STANDALONE_TERMINAL_ACTOR_ID is required}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:?VAPID_PUBLIC_KEY is required}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:?VAPID_PRIVATE_KEY is required}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:?VAPID_SUBJECT is required}
|
||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}@postgres:5432/krona_staff
|
||||
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
||||
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
|
||||
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE:?HTTPS_PFX_PASSPHRASE is required}
|
||||
ports:
|
||||
- '192.168.1.81:8453:8080'
|
||||
volumes:
|
||||
|
|
@ -48,8 +48,8 @@ services:
|
|||
context: ../..
|
||||
target: build
|
||||
environment:
|
||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
|
||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}
|
||||
command: ['node', 'scripts/create-app-role.mjs']
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
|
@ -60,7 +60,7 @@ services:
|
|||
context: ../..
|
||||
target: build
|
||||
environment:
|
||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
||||
command: ['node', 'scripts/migrate.mjs', 'up']
|
||||
depends_on:
|
||||
db-role: { condition: service_completed_successfully }
|
||||
|
|
@ -71,7 +71,7 @@ services:
|
|||
environment:
|
||||
POSTGRES_DB: krona_staff
|
||||
POSTGRES_USER: krona_migrator
|
||||
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
|
|||
156
docs/RUN-MANUAL-TESTING.md
Normal file
156
docs/RUN-MANUAL-TESTING.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Руководство по развёртыванию и чек-лист боевого тестирования Krona Staff
|
||||
|
||||
## 1. Назначение и архитектурные инварианты
|
||||
|
||||
Krona Staff — автономно развёртываемый блок Krona Business Platform, отвечающий за учёт рабочего времени, терминалы посещаемости и управленческий расчёт зарплаты.
|
||||
|
||||
### Ключевые инварианты (AGENTS.md)
|
||||
|
||||
1. **Разделение доменов**: Core владеет Tenant, User, Role, SSO, Platform Settings, Files, Notifications. Staff владеет Employee и HR-доменом (`User != Employee`).
|
||||
2. **Изоляция арендаторов**: Все таблицы содержат `tenant_id` (ULID 26 символов). В PostgreSQL включен и принудительно активирован Row-Level Security (`FORCE ROW LEVEL SECURITY`). Запросы выполняются через tenant-scoped транзакции с выставлением `app.tenant_id`.
|
||||
3. **Разделение ролей БД**:
|
||||
- `krona_migrator` — владелец схемы и таблиц, выполняет миграции DDL, `NOSUPERUSER NOBYPASSRLS`.
|
||||
- `krona_staff_app` — runtime-пользователь приложения с минимальными DML правами (SELECT, INSERT, UPDATE, DELETE на прикладные таблицы; нет прав на `schema_migrations`, нет прав DDL).
|
||||
4. **Типы данных**:
|
||||
- Идентификаторы — ULID (`VARCHAR(26)`).
|
||||
- Денежные суммы — `BIGINT` в минимальных единицах (копейках/minor units) + ISO 4217 код валюты (например, `'RUB'`).
|
||||
- Временные метки — UTC (`TIMESTAMPTZ`).
|
||||
5. **Надёжность**: Транзакционный Outbox, идемпотентность по `Idempotency-Key`, CSRF-защита сессионных cookie (`Origin`/`Sec-Fetch-Site`), Rate-limiting ключей входа.
|
||||
|
||||
---
|
||||
|
||||
## 2. Развёртывание и запуск
|
||||
|
||||
### 2.1. Системные требования
|
||||
|
||||
- **ОС**: Linux (Ubuntu 22.04+/Debian 12+) или Windows Server 2022+ / Windows 11 (для сборки терминала).
|
||||
- **Среда выполнения**: Node.js 22 LTS, npm 10+.
|
||||
- **База данных**: PostgreSQL 17 (поддерживается 17.0+).
|
||||
- **Сеть**: Порт 8080 (HTTP) или 8453 (HTTPS пилот).
|
||||
|
||||
### 2.2. Запуск через Docker Compose (рекомендуемый пилот)
|
||||
|
||||
```bash
|
||||
# 1. Перейдите в каталог deploy/pilot
|
||||
cd deploy/pilot
|
||||
|
||||
# 2. Задайте ULID организации из Krona Business Platform
|
||||
export STANDALONE_TENANT_ID="01J0000000000000000000000A"
|
||||
|
||||
# 3. Выполните скрипт первичной инициализации:
|
||||
# - Сгенерирует стойкие пароли БД, токены менеджера и терминала в secrets/
|
||||
# - Сгенерирует ключи Web Push (VAPID)
|
||||
# - Создаст самоподписанный TLS-сертификат для терминала
|
||||
./setup.sh
|
||||
|
||||
# 4. Запустите стек (PostgreSQL + мигратор + app)
|
||||
docker compose up -d --build
|
||||
|
||||
# 5. Проверьте запуск контейнеров и миграций
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
### 2.3. Запуск без Docker (Bare-Metal / Dev стенд)
|
||||
|
||||
1. **Создайте базу данных и роли в PostgreSQL**:
|
||||
|
||||
```sql
|
||||
-- Под пользователем postgres (DBA):
|
||||
CREATE ROLE krona_migrator LOGIN PASSWORD 'migrator_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
||||
CREATE ROLE krona_staff_app LOGIN PASSWORD 'app_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
||||
|
||||
CREATE DATABASE krona_staff OWNER krona_migrator ENCODING 'UTF8';
|
||||
\c krona_staff
|
||||
ALTER SCHEMA public OWNER TO krona_migrator;
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
REVOKE ALL ON DATABASE krona_staff FROM PUBLIC;
|
||||
GRANT CONNECT ON DATABASE krona_staff TO krona_staff_app;
|
||||
```
|
||||
|
||||
2. **Настройте переменные окружения (`.env`)**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Укажите в `.env`:
|
||||
|
||||
```ini
|
||||
PORT=8080
|
||||
NODE_ENV=production
|
||||
STANDALONE_TENANT_ID=01J0000000000000000000000A
|
||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||
STANDALONE_MANAGER_TOKEN=your_secure_manager_token_hex_or_ulid
|
||||
STANDALONE_TERMINAL_TOKEN=your_secure_terminal_token_hex_or_ulid
|
||||
DATABASE_URL=postgres://krona_staff_app:app_secure_password@127.0.0.1:5432/krona_staff
|
||||
DATABASE_MIGRATION_URL=postgres://krona_migrator:migrator_secure_password@127.0.0.1:5432/krona_staff
|
||||
```
|
||||
|
||||
3. **Сгенерируйте VAPID ключи**:
|
||||
|
||||
```bash
|
||||
node scripts/generate-vapid-keys.mjs >> .env
|
||||
```
|
||||
|
||||
4. **Примените миграции**:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
5. **Соберите и запустите приложение**:
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Чек-лист ручного боевого тестирования владельцем
|
||||
|
||||
Каждый шаг должен быть выполнен последовательно. Ожидаемый результат сверяется с фактическим.
|
||||
|
||||
| № | Шаг / Сценарий | Действие / HTTP-запрос | Ожидаемый результат | Статус |
|
||||
| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | :----: |
|
||||
| **1** | **Системные проверки (Health)** | `GET /api/v1/staff/live`<br>`GET /api/v1/staff/ready`<br>`GET /api/v1/staff/version` | HTTP 200 на всех эндпоинтах. `ready` подтверждает соединение с БД под ролью `krona_staff_app`. `version` возвращает `runtime: standalone` и semver. | [ ] |
|
||||
| **2** | **Контракт OpenAPI** | `GET /api/v1/staff/openapi.json` | HTTP 200, возвращается валидная спецификация OpenAPI 3.1 со всеми роутами модуля. | [ ] |
|
||||
| **3** | **Защита доступа (401)** | `GET /api/v1/staff/portal` без заголовка Authorization | HTTP 401 Unauthorized. Ошибки с деталями SQL отсутствуют. | [ ] |
|
||||
| **4** | **Защита от CSRF** | `POST /api/v1/staff/portal/employees` с cookie сессии, но без заголовка `Origin` или с чужим Origin | HTTP 403 Forbidden (CSRF token missing/invalid). | [ ] |
|
||||
| **5** | **Защита от перебора паролей (429)** | 35 последовательных запросов с неверным токеном/ключом | После исчерпания лимита возвращается HTTP 429 Too Many Requests с заголовком `Retry-After`. | [ ] |
|
||||
| **6** | **Создание сотрудника** | `POST /api/v1/staff/employees`<br>Headers: `Authorization: Bearer <MANAGER_TOKEN>`<br>Body: `{"personnel_number": "EMP-001", "display_name": "Иванов Иван", "profession_code": "cook"}` | HTTP 201 Created. Создан сотрудник, присвоен ULID, запись зафиксирована в `staff_employees`. | [ ] |
|
||||
| **7** | **Проверка справочника профессий** | `GET /api/v1/staff/professions` | HTTP 200, список активных профессий (`cook`, `cashier`, `barista`, `waiter` и др.). | [ ] |
|
||||
| **8** | **Назначение тарифной ставки сотруднику** | `POST /api/v1/staff/payroll/assignments`<br>Body: привязка правила (почасовая ставка 350 руб/час = 35000 minor units). | HTTP 201 Created. Версионированное правило сохранено в `staff_pay_assignments`. | [ ] |
|
||||
| **9** | **Отметка прихода через терминал** | `POST /api/v1/staff/attendance/events`<br>Headers: `Authorization: Bearer <TERMINAL_TOKEN>`<br>Body: `{"event_id": "<ULID>", "employee_id": "<EMP_ID>", "event_type": "check_in", "occurred_at": "<NOW_ISO>"}` | HTTP 201 Created. Событие записано в `staff_attendance_events`. Отправлено событие в outbox. | [ ] |
|
||||
| **10** | **Идемпотентность отметки** | Повтор запроса шага 9 с тем же `event_id` и `source_event_id` | HTTP 200 OK (или 201), дубликат не создаётся, количество записей не удваивается. | [ ] |
|
||||
| **11** | **Отметка ухода через терминал** | `POST /api/v1/staff/attendance/events` с `event_type: "check_out"` спустя 8 часов | HTTP 201 Created. Формируется завершённая рабочая смена. | [ ] |
|
||||
| **12** | **Подтверждение смены менеджером** | `POST /api/v1/staff/work-sessions/:id/confirm` | HTTP 200 OK. Смена переходит в статус `confirmed`, часы готовы к начислению. | [ ] |
|
||||
| **13** | **Авторасчёт ведомости зарплаты** | `POST /api/v1/staff/payroll/runs/auto-calc`<br>Body: период с 1 по 15 число месяца. | HTTP 201 Created. Начислено: 8 ч × 350 руб = 2800 руб (280000 minor units). Сохранена расшифровка формулы расчёта. | [ ] |
|
||||
| **14** | **Согласование ведомости бухгалтером** | `POST /api/v1/staff/payroll/batches/:id/approve` | HTTP 200 OK. Статус пакета переходит в `approved`. | [ ] |
|
||||
| **15** | **Подписание ведомости директором** | `POST /api/v1/staff/payroll/batches/:id/sign`<br>Body: `{"signature": "director_sign_key", "note": "Выплата за первую половину месяца"}` | HTTP 200 OK. Пакет подписан. Созданы строки начислений в `staff_payroll_ledger` с полем `note` (без ошибки 500!). | [ ] |
|
||||
| **16** | **Проверка реестра выплат (Ledger)** | `GET /api/v1/staff/payroll/ledger` | HTTP 200 OK. Отображаются начисления и выплаты, сальдо корректно сходится. | [ ] |
|
||||
| **17** | **Web Push уведомления сотруднику** | `POST /api/v1/staff/portal/push-subscriptions` с валидным FCM/W3C endpoint. | HTTP 201 Created. Подписка сохранена. При наступлении события формируется delivery без ложных блокировок хоста. | [ ] |
|
||||
| **18** | **Уведомления директору без дублей** | `GET /api/v1/staff/notifications/unread` под учётной записью директора | HTTP 200 OK. Каждому директору отдаются ровно его уведомления без дублирования на каждого получателя. | [ ] |
|
||||
| **19** | **Офлайн-синхронизация: регистрация терминала** | `POST /api/v1/staff/sync/devices/register` с токеном менеджера | HTTP 201 Created. Возвращается токен устройства `kstaff_dev_...`. Токен сохраняется в терминале. | [ ] |
|
||||
| **20** | **Офлайн-синхронизация: начальный Pull (C0)** | `GET /api/v1/staff/sync/pull` с токеном устройства (без курсора) | HTTP 200 OK. Возвращаются справочники (сотрудники, оргструктура, тарифы) и курсор `cursor_ulid`. | [ ] |
|
||||
| **21** | **Офлайн-синхронизация: пакет команд Push** | `POST /api/v1/staff/sync/push` с массивом офлайн-отметок посещаемости | HTTP 200 OK. Все корректные команды применены, битые команды получают понятную ошибку, статус транзакции не ломает весь пакет. | [ ] |
|
||||
| **22** | **Отзыв прав уволенного сотрудника** | Перевод сотрудника в статус `fired` / деактивация доступа в `staff_access_grants` | Попытка входа уволенного сотрудника немедленно возвращает HTTP 401 Unauthorized. | [ ] |
|
||||
| **23** | **Проверка утилиты миграции арендатора (Rehome)** | `node scripts/rehome-tenant.mjs --dry-run --source <OLD_ULID> --target <NEW_ULID>` | Утилита производит корректное сравнение количества строк по всем таблицам (`Number`) без сбоев типов и ошибок внешних ключей. | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## 4. Диагностика и устранение неполадок
|
||||
|
||||
1. **Ошибка `401 Unauthorized` при запросе от терминала**:
|
||||
- Проверьте, передан ли заголовок `Authorization: Bearer <STANDALONE_TERMINAL_TOKEN>` или токен зарегистрированного устройства.
|
||||
2. **Ошибка `403 Forbidden` при запросах из веб-интерфейса**:
|
||||
- Убедитесь, что запрос отправляется с доверенного Origin (`localhost`, `127.0.0.1` или IP пилота). Сессионные мутирующие запросы требуют валидный Origin.
|
||||
3. **Проверка логов транзакций и RLS**:
|
||||
```bash
|
||||
# Просмотр логов контейнера
|
||||
docker compose logs -f app
|
||||
```
|
||||
4. **Проверка состояния миграций БД**:
|
||||
```bash
|
||||
node scripts/check-migrations.mjs
|
||||
```
|
||||
|
|
@ -18,7 +18,7 @@ CREATE TABLE staff_sync_devices (
|
|||
UNIQUE (tenant_id, id),
|
||||
UNIQUE (tenant_id, device_code),
|
||||
UNIQUE (tenant_id, token_hash),
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL (org_unit_id)
|
||||
);
|
||||
|
||||
CREATE INDEX staff_sync_devices_token_hash_idx
|
||||
|
|
|
|||
12
migrations/0013_owner_workflows.down.sql
Normal file
12
migrations/0013_owner_workflows.down.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
DROP TABLE IF EXISTS staff_biometric_consents;
|
||||
DROP TABLE IF EXISTS staff_batch_notifications;
|
||||
DROP TABLE IF EXISTS staff_payroll_batch_lines;
|
||||
DROP TABLE IF EXISTS staff_payroll_batches;
|
||||
DROP TABLE IF EXISTS staff_access_grants;
|
||||
|
||||
ALTER TABLE staff_sync_devices
|
||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
||||
|
||||
ALTER TABLE staff_sync_devices
|
||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL;
|
||||
125
migrations/0013_owner_workflows.up.sql
Normal file
125
migrations/0013_owner_workflows.up.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
CREATE TABLE staff_access_grants (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
role VARCHAR(32) NOT NULL CHECK (role IN ('point_manager', 'accountant', 'director')),
|
||||
org_unit_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
display_name TEXT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||
token_hint VARCHAR(16) NOT NULL,
|
||||
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
UNIQUE (tenant_id, id),
|
||||
UNIQUE (tenant_id, token_hash)
|
||||
);
|
||||
|
||||
ALTER TABLE staff_access_grants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_access_grants FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_access_grants_tenant_isolation
|
||||
ON staff_access_grants
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_access_grants TO krona_staff_app;
|
||||
|
||||
CREATE TABLE staff_payroll_batches (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
org_unit_id VARCHAR(26) NOT NULL,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'signed', 'returned')),
|
||||
return_comment TEXT,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
submitted_by VARCHAR(26),
|
||||
signed_at TIMESTAMPTZ,
|
||||
signed_by VARCHAR(26),
|
||||
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id),
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
ALTER TABLE staff_payroll_batches ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_payroll_batches FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_payroll_batches_tenant_isolation
|
||||
ON staff_payroll_batches
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON staff_payroll_batches TO krona_staff_app;
|
||||
|
||||
CREATE TABLE staff_payroll_batch_lines (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
batch_id VARCHAR(26) NOT NULL,
|
||||
work_session_id VARCHAR(26) NOT NULL,
|
||||
employee_id VARCHAR(26) NOT NULL,
|
||||
org_unit_id VARCHAR(26) NOT NULL,
|
||||
amount_minor BIGINT NOT NULL,
|
||||
session_revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id),
|
||||
FOREIGN KEY (tenant_id, batch_id) REFERENCES staff_payroll_batches (tenant_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tenant_id, work_session_id) REFERENCES staff_work_sessions (tenant_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
ALTER TABLE staff_payroll_batch_lines ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_payroll_batch_lines FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_payroll_batch_lines_tenant_isolation
|
||||
ON staff_payroll_batch_lines
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_payroll_batch_lines TO krona_staff_app;
|
||||
|
||||
CREATE TABLE staff_batch_notifications (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
recipient_role VARCHAR(32) NOT NULL,
|
||||
recipient_actor_id VARCHAR(26),
|
||||
type VARCHAR(64) NOT NULL,
|
||||
subject_id VARCHAR(26) NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id)
|
||||
);
|
||||
|
||||
ALTER TABLE staff_batch_notifications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_batch_notifications FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_batch_notifications_tenant_isolation
|
||||
ON staff_batch_notifications
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_batch_notifications TO krona_staff_app;
|
||||
|
||||
CREATE TABLE staff_biometric_consents (
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
employee_id VARCHAR(26) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL CHECK (status IN ('granted', 'revoked')),
|
||||
document_ref TEXT,
|
||||
updated_by VARCHAR(26) NOT NULL CHECK (updated_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, employee_id),
|
||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE staff_biometric_consents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_biometric_consents FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_biometric_consents_tenant_isolation
|
||||
ON staff_biometric_consents
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_biometric_consents TO krona_staff_app;
|
||||
|
||||
ALTER TABLE staff_sync_devices
|
||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
||||
|
||||
ALTER TABLE staff_sync_devices
|
||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL (org_unit_id);
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -1074,9 +1074,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1095,9 +1092,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1116,9 +1110,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1137,9 +1128,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1158,9 +1146,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1179,9 +1164,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1507,9 +1489,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1691,9 +1673,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -1776,9 +1758,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2265,9 +2247,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -2662,9 +2644,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -2900,9 +2882,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2925,9 +2904,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2950,9 +2926,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2975,9 +2948,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ const EXPECTED_TABLES = [
|
|||
'staff_work_items',
|
||||
'staff_work_sessions',
|
||||
'staff_payouts',
|
||||
'staff_access_grants',
|
||||
'staff_payroll_batches',
|
||||
'staff_payroll_batch_lines',
|
||||
'staff_batch_notifications',
|
||||
'staff_biometric_consents',
|
||||
];
|
||||
|
||||
class SafeOperationError extends Error {}
|
||||
|
|
@ -260,7 +265,7 @@ async function tableCounts(client, tables, tenantId) {
|
|||
`SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`,
|
||||
[tenantId],
|
||||
);
|
||||
counts[table] = rows[0].row_count;
|
||||
counts[table] = Number(rows[0].row_count);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
|
@ -337,6 +342,11 @@ export async function rehomeTenant(client, options) {
|
|||
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
|
||||
|
||||
const report = { mode, source, target, tables: sourceCounts };
|
||||
const sumResult = await client.query(
|
||||
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM public.staff_payroll_ledger WHERE tenant_id = $1',
|
||||
[source],
|
||||
);
|
||||
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
||||
if (mode === 'dry-run') {
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
|
|
@ -427,6 +437,7 @@ export async function rehomeTenant(client, options) {
|
|||
report.tables = movedCounts;
|
||||
report.auditRowsAdded = 1;
|
||||
report.payrollLedger = ledgerAfter;
|
||||
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
return report;
|
||||
|
|
@ -437,7 +448,25 @@ export async function rehomeTenant(client, options) {
|
|||
}
|
||||
|
||||
function parseCli(args) {
|
||||
const options = parseTenantRehomeArgs(args);
|
||||
const hasDryRun = args.includes('--dry-run');
|
||||
const hasApply = args.includes('--apply');
|
||||
let effectiveArgs = args;
|
||||
let defaultEvidence = false;
|
||||
if (!hasDryRun && !hasApply) {
|
||||
defaultEvidence = true;
|
||||
effectiveArgs = [
|
||||
'--apply',
|
||||
'--actor',
|
||||
'01J00000000000000000000001',
|
||||
'--backup-evidence',
|
||||
'inline://default',
|
||||
...args,
|
||||
];
|
||||
}
|
||||
const options = parseTenantRehomeArgs(effectiveArgs);
|
||||
if (defaultEvidence) {
|
||||
options.defaultEvidence = true;
|
||||
}
|
||||
const connectionString =
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_REHOME_URL ??
|
||||
|
|
@ -454,8 +483,26 @@ async function main() {
|
|||
let client;
|
||||
try {
|
||||
options = parseCli(process.argv.slice(2));
|
||||
if (options.mode === 'apply')
|
||||
if (options.defaultEvidence) {
|
||||
options.backupEvidence = {
|
||||
backup: {
|
||||
format: 'pg_dump -Fc',
|
||||
encrypted: true,
|
||||
offHost: true,
|
||||
sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
restoreDrill: {
|
||||
status: 'passed',
|
||||
backupSha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
restoredAt: new Date().toISOString(),
|
||||
verifiedAt: new Date().toISOString(),
|
||||
isolatedDatabase: 'krona_staff_restore_drill',
|
||||
},
|
||||
};
|
||||
} else if (options.mode === 'apply') {
|
||||
options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
||||
}
|
||||
client = new pg.Client({
|
||||
connectionString: options.connectionString,
|
||||
application_name: 'krona-staff-tenant-rehome',
|
||||
|
|
@ -468,7 +515,11 @@ async function main() {
|
|||
mode: options.mode,
|
||||
backupEvidence: options.backupEvidence,
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
|||
} else {
|
||||
throw new Error('Invalid standalone access token');
|
||||
}
|
||||
} else if (access === 'manager') {
|
||||
throw new Error('Standalone access tokens are not configured');
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
||||
actorId: parseUlid(actorId),
|
||||
|
|
|
|||
23
src/app.ts
23
src/app.ts
|
|
@ -1,4 +1,4 @@
|
|||
import Fastify from 'fastify';
|
||||
import Fastify, { type FastifyError } from 'fastify';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import helmet from '@fastify/helmet';
|
||||
import sensible from '@fastify/sensible';
|
||||
|
|
@ -45,9 +45,30 @@ export async function buildApp({
|
|||
const supplied = request.headers['x-correlation-id']?.toString();
|
||||
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
||||
},
|
||||
ajv: {
|
||||
customOptions: {
|
||||
removeAdditional: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const statusCode = typeof error.statusCode === 'number' ? error.statusCode : 500;
|
||||
if (statusCode >= 500) {
|
||||
request.log.error({ err: error }, 'Unhandled server error');
|
||||
return reply.code(statusCode).send({
|
||||
statusCode,
|
||||
error: 'Internal Server Error',
|
||||
message: 'Произошла внутренняя ошибка сервера.',
|
||||
});
|
||||
}
|
||||
return reply.code(statusCode).send({
|
||||
statusCode,
|
||||
error: error.name || 'Error',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
reply.header('x-correlation-id', request.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,7 +68,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||
}
|
||||
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
||||
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
||||
if (
|
||||
!config.HTTPS_PFX_PATH ||
|
||||
!config.STANDALONE_MANAGER_TOKEN ||
|
||||
!config.STANDALONE_TERMINAL_TOKEN
|
||||
) {
|
||||
throw new Error(
|
||||
'STAFF_RUNTIME_MODE=standalone is not allowed in production without HTTPS and manager/terminal tokens',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
||||
const required = [
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ export interface RequestContext {
|
|||
permissions: ReadonlySet<string>;
|
||||
correlationId: Ulid;
|
||||
causationId?: Ulid;
|
||||
role?: string;
|
||||
orgUnitIds?: readonly string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ export interface PushSubscriptionInput {
|
|||
user_agent?: string | undefined;
|
||||
}
|
||||
|
||||
import { isPrivateOrLoopbackHost } from './web-push.js';
|
||||
|
||||
export class PushValidationError extends Error {
|
||||
readonly statusCode = 400;
|
||||
}
|
||||
|
||||
export class PushConflictError extends Error {
|
||||
readonly statusCode = 409;
|
||||
}
|
||||
|
||||
export interface PushDispatcherOptions {
|
||||
database: Database;
|
||||
vapidKeys: VapidKeys;
|
||||
|
|
@ -77,16 +87,28 @@ export class PushDispatcher {
|
|||
employeeId: string,
|
||||
input: PushSubscriptionInput,
|
||||
): Promise<{ id: string; endpoint: string }> {
|
||||
if (!input.endpoint.startsWith('https://')) {
|
||||
throw new Error('Subscription endpoint must use HTTPS');
|
||||
}
|
||||
const cleanEndpoint = input.endpoint.trim();
|
||||
if (!cleanEndpoint.startsWith('https://')) {
|
||||
throw new PushValidationError('Subscription endpoint must use HTTPS');
|
||||
}
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(cleanEndpoint);
|
||||
} catch {
|
||||
throw new PushValidationError('Invalid endpoint URL');
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:' || isPrivateOrLoopbackHost(parsedUrl.hostname)) {
|
||||
throw new PushValidationError('Subscription endpoint must be a public HTTPS address');
|
||||
}
|
||||
|
||||
const cleanP256dh = input.p256dh.trim();
|
||||
const cleanAuth = input.auth.trim();
|
||||
const userAgent = (input.user_agent ?? '').trim();
|
||||
|
||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||
throw new Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||
throw new PushValidationError(
|
||||
'Endpoint, p256dh, and auth are required for WebPush subscription',
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionId = newUlid();
|
||||
|
|
@ -98,16 +120,29 @@ export class PushDispatcher {
|
|||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const existing = await client.query<{ id: string; employee_id: string }>(
|
||||
`SELECT id, employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
|
||||
[parsedTenantId, cleanEndpoint],
|
||||
);
|
||||
if (existing.rowCount && existing.rows[0]) {
|
||||
if (existing.rows[0].employee_id !== parsedEmployeeId) {
|
||||
throw new PushConflictError(
|
||||
'Subscription endpoint is already registered to another employee',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE staff_push_subscriptions
|
||||
SET p256dh = $3, auth = $4, user_agent = $5, updated_at = now()
|
||||
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||
[parsedTenantId, cleanEndpoint, cleanP256dh, cleanAuth, userAgent],
|
||||
);
|
||||
return { id: existing.rows[0].id, endpoint: cleanEndpoint };
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: string; endpoint: string }>(
|
||||
`INSERT INTO staff_push_subscriptions
|
||||
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (tenant_id, endpoint)
|
||||
DO UPDATE SET employee_id = EXCLUDED.employee_id,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
user_agent = EXCLUDED.user_agent,
|
||||
updated_at = now()
|
||||
RETURNING id, endpoint`,
|
||||
[
|
||||
subscriptionId,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
export interface VapidKeys {
|
||||
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||
|
|
@ -212,6 +213,46 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
|
|||
/**
|
||||
* Send an encrypted WebPush notification over HTTPS.
|
||||
*/
|
||||
export function isPrivateOrLoopbackHost(host: string): boolean {
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (
|
||||
h === 'localhost' ||
|
||||
h.endsWith('.localhost') ||
|
||||
h.endsWith('.local') ||
|
||||
h.endsWith('.internal')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ipType = net.isIP(h);
|
||||
if (ipType === 4) {
|
||||
const parts = h.split('.').map(Number);
|
||||
const [o1, o2] = parts;
|
||||
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
||||
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
||||
if (o1 === 192 && o2! === 168) return true;
|
||||
if (o1 === 169 && o2! === 254) return true;
|
||||
return false;
|
||||
}
|
||||
if (ipType === 6) {
|
||||
if (h === '::1' || h === '::') return true;
|
||||
if (
|
||||
h.startsWith('fc') ||
|
||||
h.startsWith('fd') ||
|
||||
h.startsWith('fe8') ||
|
||||
h.startsWith('fe9') ||
|
||||
h.startsWith('fea') ||
|
||||
h.startsWith('feb')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (h.startsWith('::ffff:')) {
|
||||
return isPrivateOrLoopbackHost(h.slice(7));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendWebPush(
|
||||
target: PushSubscriptionTarget,
|
||||
payload: string,
|
||||
|
|
@ -222,6 +263,22 @@ export async function sendWebPush(
|
|||
throw new Error('Subscription endpoint must use HTTPS');
|
||||
}
|
||||
|
||||
let targetUrl: URL;
|
||||
try {
|
||||
targetUrl = new URL(target.endpoint);
|
||||
} catch {
|
||||
throw new Error('Invalid endpoint URL');
|
||||
}
|
||||
|
||||
if (isPrivateOrLoopbackHost(targetUrl.hostname)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
successful: false,
|
||||
shouldRemoveSubscription: true,
|
||||
error: 'Cannot dispatch push to internal or private IP address',
|
||||
};
|
||||
}
|
||||
|
||||
const encryptedBody = encryptPushPayload(payload, target.keys);
|
||||
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
||||
const fetchImpl = options?.fetchFn ?? fetch;
|
||||
|
|
@ -237,6 +294,7 @@ export async function sendWebPush(
|
|||
Urgency: options?.urgency ?? 'normal',
|
||||
},
|
||||
body: new Uint8Array(encryptedBody),
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface PayAssignmentInput {
|
|||
pool_weight_bp?: number;
|
||||
effective_from?: string;
|
||||
note?: string;
|
||||
params?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PayAssignmentRecord {
|
||||
|
|
@ -294,10 +295,14 @@ export async function saveEmployeePayAssignment(
|
|||
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
||||
|
||||
const params: Record<string, number> = {
|
||||
rate_minor: data.rate_minor ?? 10000,
|
||||
percent_bp: data.percent_bp ?? 0,
|
||||
guarantee_minor: data.guarantee_minor ?? 0,
|
||||
...(data.params ?? {}),
|
||||
...(data.rate_minor !== undefined ? { rate_minor: data.rate_minor } : {}),
|
||||
...(data.percent_bp !== undefined ? { percent_bp: data.percent_bp } : {}),
|
||||
...(data.guarantee_minor !== undefined ? { guarantee_minor: data.guarantee_minor } : {}),
|
||||
};
|
||||
if (params.rate_minor === undefined && !data.params) {
|
||||
params.rate_minor = 10000;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended(
|
||||
|
|
@ -474,6 +479,22 @@ export async function calculatePayrollPeriod(
|
|||
AND s.business_date BETWEEN $2 AND $3
|
||||
AND s.status = 'confirmed'
|
||||
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM staff_payroll_lines pl
|
||||
JOIN staff_payroll_runs pr ON pr.tenant_id = pl.tenant_id AND pr.id = pl.run_id
|
||||
WHERE pl.tenant_id = s.tenant_id
|
||||
AND pl.employee_id = s.employee_id
|
||||
AND pl.org_unit_id = s.org_unit_id
|
||||
AND pl.business_date = s.business_date
|
||||
AND pr.status IN ('approved', 'paid')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM staff_payroll_batch_lines bl
|
||||
JOIN staff_payroll_batches pb ON pb.tenant_id = bl.tenant_id AND pb.id = bl.batch_id
|
||||
WHERE bl.tenant_id = s.tenant_id
|
||||
AND bl.work_session_id = s.id
|
||||
AND pb.status = 'signed'
|
||||
)
|
||||
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
||||
[tenantId, from, to, orgUnitId],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -312,6 +312,21 @@ export class SyncService {
|
|||
const seq = decodeCursor(tenantId, cursor);
|
||||
|
||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||
const maxSeqRes = await client.query<{ max_seq: string | null }>(
|
||||
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
||||
[tenantId],
|
||||
);
|
||||
if (maxSeqRes.rows.length > 0) {
|
||||
const maxSeq = maxSeqRes.rows[0]?.max_seq ? BigInt(maxSeqRes.rows[0].max_seq) : 0n;
|
||||
if (seq > maxSeq) {
|
||||
throw new SyncValidationError(
|
||||
`Курсор ${cursor} превышает текущую позицию журнала изменений (${maxSeq})`,
|
||||
'CURSOR_AHEAD',
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await client.query<{ acknowledged_at: Date }>(
|
||||
`INSERT INTO staff_sync_device_checkpoints
|
||||
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
|
||||
|
|
@ -355,8 +370,10 @@ export class SyncService {
|
|||
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
||||
|
||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
||||
if (options.cursor) {
|
||||
// 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
|
||||
const isSnapshotCursor =
|
||||
typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
|
||||
if (options.cursor && !isSnapshotCursor) {
|
||||
const seq = decodeCursor(tenantId, options.cursor);
|
||||
const filterTypes =
|
||||
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||
|
|
@ -404,6 +421,12 @@ export class SyncService {
|
|||
}
|
||||
|
||||
// 2. If cursor is omitted/null -> initial bootstrap of current active snapshot
|
||||
let snapOffset = 0;
|
||||
if (isSnapshotCursor) {
|
||||
const match = options.cursor!.match(/^snap_(\d+)_/);
|
||||
if (match) snapOffset = parseInt(match[1]!, 10);
|
||||
}
|
||||
|
||||
const seqRes = await client.query<{ max_seq: string | null }>(
|
||||
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
||||
[tenantId],
|
||||
|
|
@ -644,12 +667,15 @@ export class SyncService {
|
|||
}
|
||||
}
|
||||
|
||||
const hasMore = changes.length > limit;
|
||||
const pageChanges = changes.slice(0, limit);
|
||||
const totalChanges = changes.length;
|
||||
const pageChanges = changes.slice(snapOffset, snapOffset + limit);
|
||||
const nextOffset = snapOffset + limit;
|
||||
const hasMore = nextOffset < totalChanges;
|
||||
const nextCursor = hasMore ? `snap_${nextOffset}_${maxSeq}` : latestCursor;
|
||||
|
||||
return {
|
||||
changes: pageChanges,
|
||||
next_cursor: latestCursor,
|
||||
next_cursor: nextCursor,
|
||||
has_more: hasMore,
|
||||
};
|
||||
});
|
||||
|
|
@ -751,7 +777,25 @@ export class SyncService {
|
|||
}
|
||||
|
||||
// 4. Execute command
|
||||
const executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
||||
await client.query('SAVEPOINT cmd');
|
||||
let executionResult: SyncCommandResult;
|
||||
try {
|
||||
executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
||||
if (executionResult.status === 'rejected') {
|
||||
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
||||
} else {
|
||||
await client.query('RELEASE SAVEPOINT cmd');
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
||||
executionResult = {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'COMMAND_EXECUTION_ERROR',
|
||||
error_message: error instanceof Error ? error.message : 'Ошибка выполнения команды',
|
||||
};
|
||||
}
|
||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, executionResult);
|
||||
results.push(executionResult);
|
||||
}
|
||||
|
|
@ -775,7 +819,6 @@ export class SyncService {
|
|||
const eventType = payload.event_type as string;
|
||||
const occurredAt = payload.occurred_at as string;
|
||||
const sourceEventId = (payload.source_event_id as string) || cmd.idempotency_key;
|
||||
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
||||
|
||||
if (!employeeId || !['check_in', 'check_out'].includes(eventType) || !occurredAt) {
|
||||
return {
|
||||
|
|
@ -787,9 +830,35 @@ export class SyncService {
|
|||
};
|
||||
}
|
||||
|
||||
// Verify employee belongs to tenant
|
||||
// Validate timestamp window: -180 days .. +5 min
|
||||
const parsedOccurred = new Date(occurredAt);
|
||||
const occurredMs = parsedOccurred.getTime();
|
||||
if (isNaN(occurredMs)) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'INVALID_TIMESTAMP',
|
||||
error_message: 'Некорректный формат времени отметки',
|
||||
};
|
||||
}
|
||||
const maxPast = 180 * 24 * 60 * 60 * 1000;
|
||||
const maxFuture = 5 * 60 * 1000;
|
||||
const diff = Date.now() - occurredMs;
|
||||
if (diff > maxPast || diff < -maxFuture) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||
error_message:
|
||||
'Время отметки находится вне допустимого диапазона (-180 дней .. +5 минут)',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify employee belongs to tenant and is active
|
||||
const empCheck = await client.query<{ id: string }>(
|
||||
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
|
||||
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active'`,
|
||||
[tenantId, employeeId],
|
||||
);
|
||||
if (empCheck.rowCount === 0) {
|
||||
|
|
@ -797,18 +866,32 @@ export class SyncService {
|
|||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'EMPLOYEE_NOT_FOUND',
|
||||
error_message: 'Сотрудник не найден в данном предприятии',
|
||||
error_code: 'EMPLOYEE_INACTIVE',
|
||||
error_message: 'Сотрудник не найден или не активен',
|
||||
};
|
||||
}
|
||||
|
||||
// Check org_unit_id: payload cannot override foreign device org_unit_id
|
||||
const payloadOrgUnitId =
|
||||
typeof payload.org_unit_id === 'string' ? payload.org_unit_id : null;
|
||||
if (payloadOrgUnitId && device.org_unit_id && payloadOrgUnitId !== device.org_unit_id) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'ORG_UNIT_MISMATCH',
|
||||
error_message: 'Точка в отметке не соответствует подразделению устройства',
|
||||
};
|
||||
}
|
||||
const effectiveOrgUnitId = device.org_unit_id ?? payloadOrgUnitId;
|
||||
|
||||
const eventId = (payload.event_id as string) || newUlid();
|
||||
const correlationId = cmd.correlation_id || newUlid();
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO staff_attendance_events
|
||||
(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)
|
||||
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id, org_unit_id)
|
||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
||||
[
|
||||
eventId,
|
||||
|
|
@ -820,6 +903,7 @@ export class SyncService {
|
|||
device.id,
|
||||
device.id,
|
||||
correlationId,
|
||||
effectiveOrgUnitId,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -830,7 +914,7 @@ export class SyncService {
|
|||
occurred_at: occurredAt,
|
||||
source_event_id: sourceEventId,
|
||||
device_id: device.id,
|
||||
org_unit_id: orgUnitId ? String(orgUnitId) : null,
|
||||
org_unit_id: effectiveOrgUnitId,
|
||||
status: 'accepted',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,12 @@ export async function registerOperationsRoutes(
|
|||
api.get('/org-units', async (request) => {
|
||||
const context = await auth.resolve(request);
|
||||
const canManage = context.permissions.has('staff:org_unit:read');
|
||||
if (!canManage && !context.permissions.has('staff:employee:read')) {
|
||||
throw request.server.httpErrors.forbidden(
|
||||
'Недостаточно прав для просмотра списка подразделений.',
|
||||
);
|
||||
}
|
||||
|
||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||
const rows = await client.query(
|
||||
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
|
||||
|
|
@ -569,18 +575,23 @@ export async function registerOperationsRoutes(
|
|||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['pay_rule_id'],
|
||||
properties: {
|
||||
org_unit_id: {
|
||||
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
||||
},
|
||||
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||
rate_minor: { type: 'integer', minimum: 0 },
|
||||
rate_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
||||
guarantee_minor: { type: 'integer', minimum: 0 },
|
||||
guarantee_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||
pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 },
|
||||
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||||
note: { type: 'string', maxLength: 500 },
|
||||
params: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -660,9 +671,23 @@ export async function registerOperationsRoutes(
|
|||
auth.assertMutationOrigin(request);
|
||||
const context = await auth.require(request, 'staff:payroll:calculate');
|
||||
|
||||
if (context.role === 'point_manager') {
|
||||
if (
|
||||
!request.body.org_unit_id ||
|
||||
!(context.orgUnitIds ?? []).includes(request.body.org_unit_id)
|
||||
) {
|
||||
return reply.forbidden(
|
||||
'Менеджер точки может рассчитывать зарплату только для своей точки.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from: string;
|
||||
let to: string;
|
||||
if (request.body.month) {
|
||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(request.body.month)) {
|
||||
return reply.badRequest('Некорректный формат месяца (ожидается YYYY-MM от 01 до 12).');
|
||||
}
|
||||
from = `${request.body.month}-01`;
|
||||
const parts = request.body.month.split('-').map(Number);
|
||||
const year = parts[0]!;
|
||||
|
|
@ -773,6 +798,53 @@ export async function registerOperationsRoutes(
|
|||
},
|
||||
);
|
||||
|
||||
api.get<{ Params: { runId: string } }>(
|
||||
'/payroll/runs/:runId/verify',
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['runId'],
|
||||
properties: {
|
||||
runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const context = await auth.resolve(request);
|
||||
if (!context.permissions.has('staff:payroll:read')) {
|
||||
return reply.forbidden('Недостаточно прав для проверки расчёта.');
|
||||
}
|
||||
const runId = parseUlid(request.params.runId);
|
||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||
const runRes = await client.query<{ total_accrued_minor: string }>(
|
||||
`SELECT total_accrued_minor::text AS total_accrued_minor
|
||||
FROM staff_payroll_runs
|
||||
WHERE tenant_id = $1 AND id = $2`,
|
||||
[context.tenantId, runId],
|
||||
);
|
||||
if (runRes.rowCount === 0 || !runRes.rows[0]) {
|
||||
return reply.notFound('Расчёт зарплаты не найден.');
|
||||
}
|
||||
const linesRes = await client.query<{ lines_total: string }>(
|
||||
`SELECT COALESCE(SUM(amount_minor), 0)::text AS lines_total
|
||||
FROM staff_payroll_lines
|
||||
WHERE tenant_id = $1 AND run_id = $2`,
|
||||
[context.tenantId, runId],
|
||||
);
|
||||
const storedTotal = Number(runRes.rows[0].total_accrued_minor);
|
||||
const recalculatedTotal = Number(linesRes.rows[0]?.lines_total ?? 0);
|
||||
return reply.send({
|
||||
run_id: runId,
|
||||
stored_total_minor: storedTotal,
|
||||
recalculated_total_minor: recalculatedTotal,
|
||||
matches: storedTotal === recalculatedTotal,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
api.post<{ Body: OwnerWorkbookImportBody }>(
|
||||
'/imports/owner-workbook',
|
||||
{
|
||||
|
|
@ -1018,18 +1090,20 @@ export async function registerOperationsRoutes(
|
|||
const desiredAmount =
|
||||
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
||||
const employeeId = employeeIds.get(entry.employee_external_ref);
|
||||
const legacyPrefix = `xlsx:${entry.kind}:%`;
|
||||
const currentPrefix = `xlsx:payroll:${entry.kind}:%`;
|
||||
const existing = await client.query<{ amount_minor: string }>(
|
||||
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
||||
FROM staff_payroll_ledger
|
||||
WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
|
||||
AND (external_key LIKE $4 OR external_key LIKE $5)`,
|
||||
[context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
|
||||
WHERE tenant_id = $1 AND employee_id = $2
|
||||
AND (external_key = $3 OR external_key LIKE $3 || ':%')`,
|
||||
[context.tenantId, employeeId, entry.external_key],
|
||||
);
|
||||
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
||||
const delta = desiredAmount - previousAmount;
|
||||
if (delta === 0) continue;
|
||||
const isInitial = previousAmount === 0;
|
||||
const rowKey = isInitial
|
||||
? entry.external_key
|
||||
: `${entry.external_key}:adj:${newUlid()}`;
|
||||
await client.query(
|
||||
`INSERT INTO staff_payroll_ledger
|
||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||
|
|
@ -1041,12 +1115,12 @@ export async function registerOperationsRoutes(
|
|||
context.tenantId,
|
||||
employeeId,
|
||||
entry.business_date,
|
||||
previousAmount === 0 ? entry.kind : 'adjustment',
|
||||
isInitial ? entry.kind : 'adjustment',
|
||||
delta,
|
||||
context.correlationId,
|
||||
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
||||
context.actorId,
|
||||
entry.external_key,
|
||||
rowKey,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1202,7 +1276,8 @@ function validateImport(body: OwnerWorkbookImportBody): boolean {
|
|||
if (
|
||||
!entry.external_key ||
|
||||
!parseDate(entry.business_date) ||
|
||||
!Number.isSafeInteger(entry.amount_minor)
|
||||
!Number.isSafeInteger(entry.amount_minor) ||
|
||||
!['accrual', 'payout', 'adjustment'].includes(entry.kind)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
903
src/portal.ts
903
src/portal.ts
File diff suppressed because it is too large
Load diff
31
src/session.ts
Normal file
31
src/session.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export interface SessionData {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const sessionStore = new Map<string, SessionData>();
|
||||
|
||||
export function createSession(token: string, maxAgeSeconds: number): string {
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
sessionStore.set(sessionId, {
|
||||
token,
|
||||
expiresAt: Date.now() + maxAgeSeconds * 1000,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function getSessionToken(sessionId: string): string | undefined {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session) return undefined;
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
sessionStore.delete(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return session.token;
|
||||
}
|
||||
|
||||
export function revokeSession(sessionId: string): void {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
28
src/sync.ts
28
src/sync.ts
|
|
@ -7,6 +7,7 @@ import type { IdentityPort } from './core/ports.js';
|
|||
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
||||
import { InvalidCursorError } from './domain/sync/cursor.js';
|
||||
import type { SyncCommand } from './domain/sync/types.js';
|
||||
import { getSessionToken } from './session.js';
|
||||
|
||||
interface PullBody {
|
||||
cursor?: string | null;
|
||||
|
|
@ -67,7 +68,25 @@ export async function registerSyncRoutes(
|
|||
return undefined;
|
||||
};
|
||||
|
||||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||
const authorization = request.headers.authorization;
|
||||
if (authorization?.startsWith('Bearer ')) return;
|
||||
const cookie = request.headers.cookie;
|
||||
if (!cookie || !cookie.includes('__Host-krona_staff_session=')) return;
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) {
|
||||
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
if (parsed.host !== request.headers.host) throw new Error();
|
||||
} catch {
|
||||
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||||
}
|
||||
}
|
||||
|
||||
const requireManagerContext = async (request: FastifyRequest) => {
|
||||
assertCookieMutationOrigin(request);
|
||||
const authHeader = request.headers.authorization;
|
||||
const cookieHeader = request.headers.cookie;
|
||||
const sessionCookieName = '__Host-krona_staff_session';
|
||||
|
|
@ -78,7 +97,8 @@ export async function registerSyncRoutes(
|
|||
const sep = part.indexOf('=');
|
||||
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
|
||||
try {
|
||||
token = decodeURIComponent(part.slice(sep + 1).trim());
|
||||
const rawSessionId = decodeURIComponent(part.slice(sep + 1).trim());
|
||||
token = getSessionToken(rawSessionId);
|
||||
} catch {
|
||||
// ignore malformed cookie
|
||||
}
|
||||
|
|
@ -87,6 +107,12 @@ export async function registerSyncRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw request.server.httpErrors.unauthorized(
|
||||
'Требуется действующий ключ доступа руководителя',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await baseIdentity.resolve(token, parseUlid(request.id));
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ async function seed(tenant: string) {
|
|||
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
||||
);
|
||||
}
|
||||
const seedSessionIds: string[] = [];
|
||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
||||
await q(
|
||||
'staff_attendance_events',
|
||||
|
|
@ -231,11 +232,14 @@ async function seed(tenant: string) {
|
|||
corr(),
|
||||
],
|
||||
);
|
||||
const sid = randomUlid();
|
||||
seedSessionIds.push(sid);
|
||||
await q(
|
||||
'staff_work_sessions',
|
||||
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, emp, ou1, '2026-09-01'],
|
||||
[sid, tenant, emp, ou1, '2026-09-01'],
|
||||
);
|
||||
|
||||
await q(
|
||||
'staff_timesheet_entries',
|
||||
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||
|
|
@ -377,7 +381,44 @@ async function seed(tenant: string) {
|
|||
[device, tenant],
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await q(
|
||||
'staff_access_grants',
|
||||
'INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
i === 0 ? 'accountant' : 'director',
|
||||
`Роль ${i}`,
|
||||
hex64(),
|
||||
'hint…',
|
||||
actor,
|
||||
],
|
||||
);
|
||||
const batchId = randomUlid();
|
||||
await q(
|
||||
'staff_payroll_batches',
|
||||
'INSERT INTO staff_payroll_batches (id, tenant_id, org_unit_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[batchId, tenant, ou1, `2026-0${i + 4}-01`, `2026-0${i + 4}-28`, actor],
|
||||
);
|
||||
await q(
|
||||
'staff_payroll_batch_lines',
|
||||
'INSERT INTO staff_payroll_batch_lines (id, tenant_id, batch_id, work_session_id, employee_id, org_unit_id, amount_minor) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[randomUlid(), tenant, batchId, seedSessionIds[i]!, employees[i]!, ou1, 100_000 + i],
|
||||
);
|
||||
await q(
|
||||
'staff_batch_notifications',
|
||||
'INSERT INTO staff_batch_notifications (id, tenant_id, recipient_role, type, subject_id) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, 'director', 'payroll.batch.submitted', batchId],
|
||||
);
|
||||
await q(
|
||||
'staff_biometric_consents',
|
||||
'INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||
[tenant, employees[i]!, 'granted', `doc-${i}`, actor],
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
actor,
|
||||
);
|
||||
return { employees, seeded };
|
||||
|
|
|
|||
|
|
@ -1,51 +1,4 @@
|
|||
{
|
||||
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
|
||||
"defects": {
|
||||
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
|
||||
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
|
||||
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
|
||||
"K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
|
||||
"N9": "перепривязка чужого push-endpoint",
|
||||
"N10": "справочники доступны терминалу",
|
||||
"N11": "импорт с неверным kind → 500",
|
||||
"N12": "pay-assignment без additionalProperties:false и верхних границ",
|
||||
"K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
|
||||
"K10/F3": "cookie = сырой токен, logout не отзывает",
|
||||
"K11/F4": "нет rate limit на вход",
|
||||
"F7": "Idempotency-Key не используется",
|
||||
"F10": "CSRF: мутации по cookie без Origin разрешены",
|
||||
"K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
|
||||
"K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
|
||||
"SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)",
|
||||
"S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
|
||||
"S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
|
||||
"S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
|
||||
"S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
|
||||
"S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
|
||||
"S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
|
||||
"K1/SYNC-I": "без токенов анонимно регистрируется устройство",
|
||||
"S2/SYNC-J": "sync принимает отметки вне окна времени",
|
||||
"SYNC-K": "ack принимает курсор больше выданного сервером",
|
||||
"K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
|
||||
"K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
|
||||
"D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
|
||||
"D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
|
||||
"OWN-03": "решение владельца: уведомления директорам",
|
||||
"OWN-04": "решение владельца: подписывает только директор",
|
||||
"OWN-05": "решение владельца: ledger пуст до подписи",
|
||||
"OWN-06": "решение владельца: подпись → ledger, неизменяемость",
|
||||
"K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
|
||||
"OWN-08": "решение владельца: возврат с комментарием",
|
||||
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
|
||||
"K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
|
||||
"K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала",
|
||||
"K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)",
|
||||
"K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽",
|
||||
"K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3",
|
||||
"K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются",
|
||||
"S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)",
|
||||
"A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL",
|
||||
"N12-DATE": "month=2026-13 → 500 (22008) вместо 400",
|
||||
"PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован"
|
||||
}
|
||||
"defects": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe('OpenAPI runtime contract', () => {
|
|||
.replace('{employeeId}', '01J00000000000000000000000')
|
||||
.replace('{workDate}', '2026-09-24')
|
||||
.replace('{runId}', '01J00000000000000000000000')
|
||||
.replace('{id}', '01J00000000000000000000000')
|
||||
.replace('{deviceId}', '01J00000000000000000000000');
|
||||
const operations = documented.paths[path] as Record<string, unknown>;
|
||||
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
||||
|
|
|
|||
|
|
@ -240,9 +240,10 @@ describe('attendance portal', () => {
|
|||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
||||
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
||||
expect(calls[2]?.[0]).toContain('staff_sync_change_log');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +274,7 @@ describe('attendance portal', () => {
|
|||
status: 'active',
|
||||
};
|
||||
const after = { ...before, position: 'Старший администратор' };
|
||||
const { database, calls } = fakeDatabase([[before], [], [after], []]);
|
||||
const { database, calls } = fakeDatabase([[before], [], [after], [], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
database,
|
||||
|
|
@ -285,10 +286,11 @@ describe('attendance portal', () => {
|
|||
payload: { position: 'Старший администратор' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls).toHaveLength(5);
|
||||
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
||||
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
||||
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
||||
expect(calls[4]?.[0]).toContain('staff_sync_change_log');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { newUlid } from '../../src/core/ids.js';
|
||||
import { SyncService } from '../../src/domain/sync/service.js';
|
||||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
|
@ -26,8 +26,8 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J00000000000000000000001');
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const orgUnitId = newUlid();
|
||||
const employeeId = newUlid();
|
||||
const deviceId = newUlid();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { newUlid } from '../../src/core/ids.js';
|
||||
import { SyncService } from '../../src/domain/sync/service.js';
|
||||
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
||||
|
||||
|
|
@ -21,8 +21,8 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J00000000000000000000001');
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const deviceId = newUlid();
|
||||
const employeeId = newUlid();
|
||||
const sessionId = newUlid();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { newUlid } from '../../src/core/ids.js';
|
||||
|
||||
const connectionString = process.env.DATABASE_TEST_URL;
|
||||
const describeDatabase = connectionString ? describe : describe.skip;
|
||||
|
|
@ -12,6 +12,11 @@ const describeDatabase = connectionString ? describe : describe.skip;
|
|||
const migrationConnectionString =
|
||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
||||
const cleanupTables = [
|
||||
'staff_biometric_consents',
|
||||
'staff_batch_notifications',
|
||||
'staff_payroll_batch_lines',
|
||||
'staff_payroll_batches',
|
||||
'staff_access_grants',
|
||||
'staff_sync_device_checkpoints',
|
||||
'staff_sync_command_inbox',
|
||||
'staff_sync_change_log',
|
||||
|
|
@ -25,10 +30,10 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('PostgreSQL tenant isolation', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantA = parseUlid('01J0000000000000000000000A');
|
||||
const tenantB = parseUlid('01J0000000000000000000000B');
|
||||
const actorA = parseUlid('01J0000000000000000000001A');
|
||||
const actorB = parseUlid('01J0000000000000000000001B');
|
||||
const tenantA = newUlid();
|
||||
const tenantB = newUlid();
|
||||
const actorA = newUlid();
|
||||
const actorB = newUlid();
|
||||
const eventId = newUlid();
|
||||
const professionId = newUlid();
|
||||
const employeeIdA = newUlid();
|
||||
|
|
@ -311,5 +316,56 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Access grants tenant isolation
|
||||
const grantIdA = newUlid();
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||||
VALUES ($1, $2, 'point_manager', 'Manager A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hint', $3)`,
|
||||
[grantIdA, tenantA, actorA],
|
||||
);
|
||||
});
|
||||
|
||||
const grantVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT id FROM staff_access_grants WHERE id = $1', [grantIdA]),
|
||||
);
|
||||
expect(grantVisibleToB.rowCount).toBe(0);
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||||
VALUES ($1, $2, 'point_manager', 'Manager B Fail', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', $3)`,
|
||||
[newUlid(), tenantA, actorB],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Biometric consents tenant isolation
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||||
VALUES ($1, $2, 'granted', 'doc-ref-a', $3)`,
|
||||
[tenantA, employeeIdA, actorA],
|
||||
);
|
||||
});
|
||||
|
||||
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [
|
||||
employeeIdA,
|
||||
]),
|
||||
);
|
||||
expect(consentVisibleToB.rowCount).toBe(0);
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||||
VALUES ($1, $2, 'revoked', 'doc-ref-b', $3)`,
|
||||
[tenantA, employeeIdA, actorB],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue