Compare commits
92 commits
v0.2.0
...
codex/boot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d200851b | ||
|
|
0910afd54d | ||
|
|
94f17c78cf | ||
|
|
e6dd4b743c | ||
|
|
89621ea069 | ||
|
|
29428cee08 | ||
|
|
94308e9e5c | ||
|
|
991a068750 | ||
|
|
2a2b0bde86 | ||
|
|
8c22ce83b4 | ||
|
|
4fc053850f | ||
|
|
50ee365882 | ||
|
|
df39565b68 | ||
|
|
23a6f5ca80 | ||
|
|
7cf5142678 | ||
|
|
53737f8c36 | ||
|
|
314525aaa1 | ||
| f7d3c73f1f | |||
| 986843d8d4 | |||
| 10ba0b440f | |||
| 6f408bc0aa | |||
|
|
24cc18de0e | ||
|
|
ef9e00ce80 | ||
|
|
b283736312 | ||
| d7b235248a | |||
| 87f85751d0 | |||
| 644ea7c6cb | |||
| aa7f5f8bbd | |||
| b0243d5001 | |||
| ba59a48ffd | |||
| 33daae3593 | |||
| 51ad03ff65 | |||
| 3475133b10 | |||
| ac8ab3cc02 | |||
| 0453168d70 | |||
| 27499da191 | |||
| fc20f75b55 | |||
| 0aaa2f4d2d | |||
| 537b784da3 | |||
| 045f696425 | |||
| 82a2a7ca37 | |||
| c73c714611 | |||
| bc9b29dec8 | |||
| c68370b890 | |||
| 2548bd7bdf | |||
| 397954be0a | |||
| 646e8d4f22 | |||
| 4f3e5c621a | |||
| 19a028970d | |||
| 5ac248660a | |||
| f5ac179623 | |||
|
|
331e4909c9 | ||
|
|
778ba2d15b | ||
|
|
3595792ef3 | ||
|
|
5ee75177b9 | ||
|
|
1812870dd3 | ||
|
|
3e2054480c | ||
|
|
2f1bdf549d | ||
|
|
87a6a447c2 | ||
|
|
fb1270a0a8 | ||
|
|
f74aa6d7f5 | ||
|
|
9ea9a004ed | ||
|
|
5b159f785c | ||
|
|
4fe2f8799a | ||
|
|
96a28f575f | ||
|
|
37da6f0418 | ||
|
|
c08a7e6af1 | ||
|
|
1007f929c2 | ||
|
|
88804a0eea | ||
|
|
057ec75b26 | ||
|
|
0f530dc633 | ||
|
|
df0da37d5c | ||
|
|
6e5a1ad404 | ||
|
|
2f6b367638 | ||
|
|
29c70e0107 | ||
|
|
39f0840087 | ||
|
|
b3fdec1eb2 | ||
|
|
7ca8966084 | ||
|
|
b2f7a68589 | ||
|
|
32e3c6f7f7 | ||
|
|
71660745a5 | ||
|
|
fecf7e5911 | ||
|
|
0bd88142b8 | ||
|
|
94753c3105 | ||
|
|
5bf634278d | ||
|
|
c479bb60ca | ||
|
|
14ce259a5b | ||
|
|
c41bdd8654 | ||
|
|
770ef00a28 | ||
|
|
3464998168 | ||
|
|
b4eb3432ae | ||
|
|
4d37350757 |
209 changed files with 32456 additions and 379 deletions
|
|
@ -11,5 +11,6 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000
|
||||||
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
||||||
PLATFORM_JWT_AUDIENCE=krona-staff
|
PLATFORM_JWT_AUDIENCE=krona-staff
|
||||||
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
||||||
STANDALONE_TENANT_ID=01J00000000000000000000000
|
# Set this to the organization's tenant ID from Krona Business Platform.
|
||||||
|
STANDALONE_TENANT_ID=
|
||||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main, codex/bootstrap-platform-foundation]
|
branches: [main, codex/bootstrap-platform-foundation]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
# Все actions закреплены полным SHA коммита (F13); SHA получены `git ls-remote`
|
||||||
|
# и совпадают на github.com и code.forgejo.org (зеркало), поэтому закрепление
|
||||||
|
# работает при любом DEFAULT_ACTIONS_URL раннера.
|
||||||
|
#
|
||||||
|
# Роли БД как в pilot, но с минимальными привилегиями: POSTGRES_USER сервиса —
|
||||||
|
# только bootstrap-админ (роль DBA/entrypoint образа), им scripts/test/provision-pg.mjs
|
||||||
|
# создаёт krona_migrator (NOSUPERUSER, владелец БД) и krona_staff_app (NOBYPASSRLS)
|
||||||
|
# со случайными паролями. Ни миграции, ни тесты, ни сервер под bootstrap-админом не работают.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
@ -10,27 +20,125 @@ jobs:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: krona_staff
|
POSTGRES_DB: postgres
|
||||||
POSTGRES_USER: krona_migrator
|
POSTGRES_USER: ci_bootstrap
|
||||||
POSTGRES_PASSWORD: ci-migration-password
|
POSTGRES_PASSWORD: ci-bootstrap-only
|
||||||
ports: ['5432:5432']
|
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U krona_migrator -d krona_staff"
|
--health-cmd "pg_isready -U ci_bootstrap -d postgres"
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 3s
|
--health-timeout 3s
|
||||||
--health-retries 20
|
--health-retries 20
|
||||||
env:
|
env:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:ci-migration-password@postgres:5432/krona_staff
|
E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres
|
||||||
DATABASE_TEST_URL: postgres://krona_staff_app:ci-app-password@postgres:5432/krona_staff
|
|
||||||
POSTGRES_APP_PASSWORD: ci-app-password
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with: { node-version: 22, cache: npm }
|
with: { node-version: 22, cache: npm }
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: node scripts/create-app-role.mjs
|
- name: provision roles (migrator NOSUPERUSER, app NOBYPASSRLS)
|
||||||
- run: node scripts/migrate.mjs up
|
run: node scripts/test/provision-pg.mjs --out "$RUNNER_TEMP/db.env" --db krona_staff
|
||||||
- run: npm run check
|
- name: migrations up -> rollback all -> up -> up (no-op)
|
||||||
- run: npm run format:check
|
run: |
|
||||||
|
set -a; . "$RUNNER_TEMP/db.env"; set +a
|
||||||
|
node scripts/test/migrate-roundtrip.mjs roundtrip
|
||||||
|
- name: npm run check (tenant-isolation on real PostgreSQL as app role)
|
||||||
|
run: |
|
||||||
|
set -a; . "$RUNNER_TEMP/db.env"; set +a
|
||||||
|
unset DATABASE_URL POSTGRES_APP_PASSWORD
|
||||||
|
npm run check
|
||||||
- run: npm audit --audit-level=high
|
- run: npm audit --audit-level=high
|
||||||
- run: docker build -t krona-staff:${{ forgejo.sha }} .
|
|
||||||
|
# Отдельная задача: prettier сейчас красный по 23 файлам (F14; замер 2026-09-26), и это не должно
|
||||||
|
# маскировать статус verify/e2e.
|
||||||
|
format:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: 22, cache: npm }
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run format:check
|
||||||
|
|
||||||
|
# Приёмочные сценарии на реальном сервере и PostgreSQL 17. Красные сценарии
|
||||||
|
# известных открытых дефектов перечислены в tests/e2e/known-defects.json:
|
||||||
|
# задача зелёная, если стенд работает ([HARNESS]/[CTRL] зелёные) и каждый
|
||||||
|
# красный сценарий есть в списке; новый красный сценарий или исправленный,
|
||||||
|
# но не вычеркнутый дефект делают задачу красной.
|
||||||
|
e2e:
|
||||||
|
runs-on: docker
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
POSTGRES_USER: ci_bootstrap
|
||||||
|
POSTGRES_PASSWORD: ci-bootstrap-only
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U ci_bootstrap -d postgres"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres
|
||||||
|
E2E_MIGRATIONS: roundtrip
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: 22, cache: npm }
|
||||||
|
- run: npm ci
|
||||||
|
- name: e2e (known-defects ratchet)
|
||||||
|
run: node scripts/test/run-e2e.mjs
|
||||||
|
- name: e2e table
|
||||||
|
if: always()
|
||||||
|
run: cat tests/e2e/evidence/e2e-table.md || true
|
||||||
|
# tests/e2e/evidence/ в .gitignore: полный вывод vitest, JSON, логи серверов —
|
||||||
|
# артефакт прогона, а не файлы репозитория (TASK-GROK-2 п.1).
|
||||||
|
- name: upload e2e evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: e2e-evidence
|
||||||
|
path: tests/e2e/evidence/
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# Windows 11 (проект разрабатывается под Windows). Windows-раннера у сервера пока
|
||||||
|
# НЕТ, поэтому задача включается только переменной репозитория
|
||||||
|
# KRONA_WINDOWS_RUNNER=true (иначе пропускается и не висит в очереди).
|
||||||
|
# Раннер: forgejo-runner в режиме host с меткой `windows`, Node 22+.
|
||||||
|
# e2e на Windows идёт, только если задан секрет WINDOWS_E2E_PG_ADMIN_URL
|
||||||
|
# (bootstrap-админ локальной PostgreSQL 17 на раннере). НЕ ПРОВЕРЕНО: не запускалась.
|
||||||
|
windows:
|
||||||
|
if: vars.KRONA_WINDOWS_RUNNER == 'true'
|
||||||
|
runs-on: windows
|
||||||
|
env:
|
||||||
|
E2E_PG_ADMIN_URL: ${{ secrets.WINDOWS_E2E_PG_ADMIN_URL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: 22, cache: npm }
|
||||||
|
- run: npm ci
|
||||||
|
- name: spawn without .cmd wrappers
|
||||||
|
run: node node_modules/vitest/vitest.mjs run tests/e2e/harness/node-commands.test.ts
|
||||||
|
- name: e2e on Windows (needs local PostgreSQL 17)
|
||||||
|
if: env.E2E_PG_ADMIN_URL != ''
|
||||||
|
run: node scripts/test/run-e2e.mjs
|
||||||
|
- name: upload Windows e2e evidence
|
||||||
|
if: always() && env.E2E_PG_ADMIN_URL != ''
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: e2e-evidence-windows
|
||||||
|
path: tests/e2e/evidence/
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# docker build требует Docker daemon, которого нет в контейнере метки `docker`.
|
||||||
|
# Нужен раннер с меткой `docker-host` (хост с доступом к Docker). Пока такого
|
||||||
|
# раннера нет, задача ждёт в очереди и не блокирует verify/format/e2e.
|
||||||
|
docker-build:
|
||||||
|
runs-on: docker-host
|
||||||
|
needs: [verify]
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- run: docker build -t "krona-staff:${GITHUB_SHA}" .
|
||||||
|
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -3,6 +3,7 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
.env
|
.env
|
||||||
|
deploy/pilot/secrets/
|
||||||
*.log
|
*.log
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
@ -11,5 +12,12 @@ backups/
|
||||||
restore-work/
|
restore-work/
|
||||||
apps/KronaStaffTerminal/bin/
|
apps/KronaStaffTerminal/bin/
|
||||||
apps/KronaStaffTerminal/obj/
|
apps/KronaStaffTerminal/obj/
|
||||||
|
apps/KronaStaffService/bin/
|
||||||
|
apps/KronaStaffService/obj/
|
||||||
|
apps/KronaStaffTerminal.Tests/bin/
|
||||||
|
apps/KronaStaffTerminal.Tests/obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
.audit-build/
|
.audit-build/
|
||||||
|
|
||||||
|
# Результаты прогонов e2e (логи серверов, JSON vitest, таблица) — артефакт CI, не исходники
|
||||||
|
tests/e2e/evidence/
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -1,13 +1,15 @@
|
||||||
# Krona Staff
|
# Krona Staff
|
||||||
|
|
||||||
Архитектурная основа блока сотрудников для Krona Business Platform. Это не готовая HRM: предметные правила табеля, графиков, отпусков, расчёта зарплаты, кадровых документов и отчётности намеренно не реализованы до требований владельца.
|
Блок сотрудников для Krona Business Platform. Он включает терминал посещаемости и проверяемое ядро управленческого расчёта зарплаты по подтверждённым требованиям владельца. Официальный расчёт налогов и взносов, кадровые документы, отпуска и планирование графиков в текущий объём не входят.
|
||||||
|
|
||||||
Сейчас репозиторий содержит совместимое техническое ядро: два runtime-режима, порты платформы, PostgreSQL/RLS, инфраструктуру outbox/inbox/idempotency/audit, проверяемые OpenAPI и module manifest, health endpoints, контейнеризацию и CI.
|
Репозиторий сохраняет платформенные границы: два runtime-режима, порты Core, PostgreSQL/RLS, outbox/inbox/idempotency/audit, проверяемые OpenAPI и module manifest, контейнеризацию и CI. Расчёт остаётся чистой детерминированной функцией, а данные каждого tenant изолируются на уровне транзакции, внешних ключей и RLS.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# Перед следующей командой заполните STANDALONE_TENANT_ID в .env:
|
||||||
|
# используйте ULID организации из Krona Business Platform, не ID сотрудника.
|
||||||
docker compose --profile tools run --rm migrate
|
docker compose --profile tools run --rm migrate
|
||||||
docker compose up --build app postgres
|
docker compose up --build app postgres
|
||||||
```
|
```
|
||||||
|
|
@ -22,7 +24,13 @@ docker compose up --build app postgres
|
||||||
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
||||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
- `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).
|
||||||
|
|
||||||
|
## Расчётный контур
|
||||||
|
|
||||||
|
Ядро в `src/domain/payroll` поддерживает почасовую и посменную оплату, проценты, гарантии, премии, сдельные позиции, общий фонд с распределением по времени присутствия, прогрессивные шкалы, ночные, сверхурочные и праздничные часы. Правила и назначения версионируются, а каждая строка ведомости сохраняет объяснение расчёта.
|
||||||
|
|
||||||
|
Требования: [REQ-001](docs/REQ-001-PAYROLL.md), каталог схем: [PAYROLL-SCHEMES-CATALOGUE](docs/PAYROLL-SCHEMES-CATALOGUE.md), платформенное решение: [ADR-002](docs/adr/ADR-002-payroll-domain-and-platform-alignment.md).
|
||||||
|
|
||||||
## Биометрический терминал
|
## Биометрический терминал
|
||||||
|
|
||||||
|
|
|
||||||
1173
api/openapi.yaml
1173
api/openapi.yaml
File diff suppressed because it is too large
Load diff
43
apps/KronaStaffService/KronaStaffService.csproj
Normal file
43
apps/KronaStaffService/KronaStaffService.csproj
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
||||||
|
<RootNamespace>KronaStaffService</RootNamespace>
|
||||||
|
<Version>0.3.2</Version>
|
||||||
|
<AssemblyVersion>0.3.2.0</AssemblyVersion>
|
||||||
|
<FileVersion>0.3.2.0</FileVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0-preview.*" Condition="false" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.2" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="libzkfpcsharp">
|
||||||
|
<HintPath>..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll</HintPath>
|
||||||
|
<Private>true</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Models\TerminalModels.cs" Link="Models\TerminalModels.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Models\UlidGenerator.cs" Link="Models\UlidGenerator.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AdminPinService.cs" Link="Services\AdminPinService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\TerminalStore.cs" Link="Services\TerminalStore.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
278
apps/KronaStaffService/KronaStaffWorker.cs
Normal file
278
apps/KronaStaffService/KronaStaffWorker.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace KronaStaffService;
|
||||||
|
|
||||||
|
public sealed class KronaStaffWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<KronaStaffWorker> _logger;
|
||||||
|
private readonly TerminalStore _store;
|
||||||
|
private readonly AttendanceSyncService _syncService;
|
||||||
|
private readonly ILicenseStatusPort _licensePort;
|
||||||
|
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
||||||
|
private readonly ConcurrentDictionary<string, long> _acceptedIpcNonces = new();
|
||||||
|
private TerminalIpcServer? _ipcServer;
|
||||||
|
private bool _scannerSession0Supported;
|
||||||
|
private string _scannerDiagnostic = "";
|
||||||
|
private DateTimeOffset? _lastSyncUtc;
|
||||||
|
private string? _lastSyncError;
|
||||||
|
|
||||||
|
public KronaStaffWorker(
|
||||||
|
ILogger<KronaStaffWorker> logger,
|
||||||
|
TerminalStore store,
|
||||||
|
AttendanceSyncService syncService,
|
||||||
|
ILicenseStatusPort licensePort)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_store = store;
|
||||||
|
_syncService = syncService;
|
||||||
|
_licensePort = licensePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Krona Staff Service starting...");
|
||||||
|
CrashLogService.WriteDiagnostic("Krona Staff Windows Service starting.");
|
||||||
|
|
||||||
|
// Probe whether ZKFinger SDK supports device capture in Session 0
|
||||||
|
_scannerSession0Supported = FingerprintScanner.ProbeSession0Support(out _scannerDiagnostic);
|
||||||
|
_logger.LogInformation("Session 0 scanner probe: Supported={Supported}, Diagnostic='{Diag}'",
|
||||||
|
_scannerSession0Supported, _scannerDiagnostic);
|
||||||
|
CrashLogService.WriteDiagnostic($"Session 0 scanner probe: {_scannerDiagnostic}");
|
||||||
|
|
||||||
|
// Initialize IPC server
|
||||||
|
_ipcServer = new TerminalIpcServer(HandleIpcRequestAsync);
|
||||||
|
_ipcServer.Start();
|
||||||
|
_logger.LogInformation("IPC Server listening on pipe {PipeName}", TerminalIpcConstants.PipeName);
|
||||||
|
|
||||||
|
return base.StartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Background attendance sync worker loop started.");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunSyncCycleAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error in background sync loop.");
|
||||||
|
CrashLogService.Write(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Background attendance sync worker loop stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AttendanceSyncResult> RunSyncCycleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _syncGate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TerminalState state;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state = await _store.LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_lastSyncError = $"Не удалось прочитать состояние: {ex.Message}";
|
||||||
|
_logger.LogWarning(ex, "Could not load terminal state for sync.");
|
||||||
|
return new AttendanceSyncResult { LastError = _lastSyncError };
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPending = state.Attendance.Any(a => !a.ServerSynced);
|
||||||
|
if (!hasPending)
|
||||||
|
{
|
||||||
|
return new AttendanceSyncResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _syncService.SyncPendingAsync(state, cancellationToken);
|
||||||
|
if (result.SuccessCount > 0)
|
||||||
|
{
|
||||||
|
await _store.SaveAsync(state);
|
||||||
|
_lastSyncUtc = DateTimeOffset.UtcNow;
|
||||||
|
_lastSyncError = null;
|
||||||
|
_logger.LogInformation("Synced {Count} attendance record(s) to server.", result.SuccessCount);
|
||||||
|
}
|
||||||
|
if (result.FailedCount > 0)
|
||||||
|
{
|
||||||
|
_lastSyncError = result.LastError;
|
||||||
|
_logger.LogWarning("Failed to sync {Count} record(s): {Error}", result.FailedCount, result.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IpcResponse> HandleIpcRequestAsync(IpcRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authenticationState = await _store.LoadAsync();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (!IpcRequestAuthenticator.Verify(
|
||||||
|
request, authenticationState.Settings.IpcAuthenticationKey, now))
|
||||||
|
{
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Unauthorized request" };
|
||||||
|
}
|
||||||
|
foreach (var nonce in _acceptedIpcNonces.Where(item => item.Value < now.AddMinutes(-1).ToUnixTimeSeconds()))
|
||||||
|
_acceptedIpcNonces.TryRemove(nonce.Key, out _);
|
||||||
|
if (!_acceptedIpcNonces.TryAdd(request.Nonce, request.IssuedAtUnixSeconds))
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Replayed request" };
|
||||||
|
switch (request.Command)
|
||||||
|
{
|
||||||
|
case "Ping":
|
||||||
|
return new IpcResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Status = await GetCurrentStatusAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
case "GetStatus":
|
||||||
|
return new IpcResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Status = await GetCurrentStatusAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
case "GetLicenseStatus":
|
||||||
|
return new IpcResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Status = new IpcServiceStatus
|
||||||
|
{
|
||||||
|
LicenseStatus = _licensePort.GetStatus().ToString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case "EnqueueAttendance":
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Payload))
|
||||||
|
{
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Missing attendance payload" };
|
||||||
|
}
|
||||||
|
var record = JsonSerializer.Deserialize<AttendanceRecord>(request.Payload);
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Text.RegularExpressions.Regex.IsMatch(record.Id, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||||||
|
!System.Text.RegularExpressions.Regex.IsMatch(record.EmployeeId, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||||||
|
record.EventType is not ("Приход" or "Уход" or "Уход автоматически") ||
|
||||||
|
record.OccurredAtUtc < DateTimeOffset.UtcNow.AddDays(-180) ||
|
||||||
|
record.OccurredAtUtc > DateTimeOffset.UtcNow.AddMinutes(5) ||
|
||||||
|
record.ReaderIndex < 0)
|
||||||
|
{
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await _syncGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = await _store.LoadAsync();
|
||||||
|
var employee = state.Employees.FirstOrDefault(item => item.Id == record.EmployeeId);
|
||||||
|
if (employee is null || employee.IsBlocked)
|
||||||
|
{
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = "Employee is unavailable" };
|
||||||
|
}
|
||||||
|
record.EmployeeName = employee.DisplayName;
|
||||||
|
record.ServerSynced = false;
|
||||||
|
record.ServerSyncError = null;
|
||||||
|
record.NotificationDelivered = false;
|
||||||
|
record.NotificationError = null;
|
||||||
|
if (state.Attendance.All(a => a.Id != record.Id))
|
||||||
|
{
|
||||||
|
state.Attendance.Insert(0, record);
|
||||||
|
await _store.SaveAsync(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger immediate sync
|
||||||
|
_ = Task.Run(() => RunSyncCycleAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
return new IpcResponse { Success = true };
|
||||||
|
|
||||||
|
case "TriggerSync":
|
||||||
|
var syncResult = await RunSyncCycleAsync(CancellationToken.None);
|
||||||
|
return new IpcResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
SyncedCount = syncResult.SuccessCount,
|
||||||
|
Payload = JsonSerializer.Serialize(syncResult)
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = $"Unknown command: {request.Command}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CrashLogService.Write(ex);
|
||||||
|
return new IpcResponse { Success = false, ErrorMessage = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IpcServiceStatus> GetCurrentStatusAsync()
|
||||||
|
{
|
||||||
|
var pendingCount = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = await _store.LoadAsync();
|
||||||
|
pendingCount = state.Attendance.Count(a => !a.ServerSynced);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore if unable to read count
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IpcServiceStatus
|
||||||
|
{
|
||||||
|
ServiceRunning = true,
|
||||||
|
ServiceVersion = "0.3.2",
|
||||||
|
ScannerInSession0Supported = _scannerSession0Supported,
|
||||||
|
ScannerDiagnostic = _scannerDiagnostic,
|
||||||
|
PendingSyncCount = pendingCount,
|
||||||
|
LastSyncUtc = _lastSyncUtc,
|
||||||
|
LastSyncError = _lastSyncError,
|
||||||
|
LicenseStatus = _licensePort.GetStatus().ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Krona Staff Service stopping...");
|
||||||
|
_ipcServer?.Dispose();
|
||||||
|
CrashLogService.WriteDiagnostic("Krona Staff Windows Service stopped.");
|
||||||
|
return base.StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/KronaStaffService/Program.cs
Normal file
26
apps/KronaStaffService/Program.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
using KronaStaffService;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddWindowsService(options =>
|
||||||
|
{
|
||||||
|
options.ServiceName = "KronaStaffService";
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Logging.AddSimpleConsole(options =>
|
||||||
|
{
|
||||||
|
options.IncludeScopes = false;
|
||||||
|
options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] ";
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<TerminalStore>();
|
||||||
|
builder.Services.AddSingleton<AttendanceSyncService>();
|
||||||
|
builder.Services.AddSingleton<ILicenseStatusPort, StandaloneLicenseAdapter>();
|
||||||
|
builder.Services.AddHostedService<KronaStaffWorker>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
43
apps/KronaStaffService/README.md
Normal file
43
apps/KronaStaffService/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Krona Staff Service (Windows Service)
|
||||||
|
|
||||||
|
Фоновая системная служба Windows для Krona Staff Terminal.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- Фоновая отправка очереди отметок посещаемости (`check_in`, `check_out`, автоматический уход) на сервер Staff API через HTTPS.
|
||||||
|
- Непрерывная синхронизация очереди даже после выхода интерактивного пользователя из Windows (Log off / Switch user).
|
||||||
|
- Идемпотентная доставка событий с сохранением `Idempotency-Key` (ULID) и предотвращением дубликатов при сетевых сбоях.
|
||||||
|
- Предоставление защищённого локального IPC-канала (Named Pipe `\\.\pipe\KronaStaffTerminalPipe`) для взаимодействия с пользовательским интерфейсом `KronaStaffTerminal`.
|
||||||
|
- Проверка и публикация статуса лицензии через `ILicenseStatusPort`.
|
||||||
|
|
||||||
|
## Архитектура и изоляция Session 0
|
||||||
|
|
||||||
|
В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола.
|
||||||
|
|
||||||
|
### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0:
|
||||||
|
|
||||||
|
1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям.
|
||||||
|
2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7).
|
||||||
|
3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков.
|
||||||
|
|
||||||
|
### Принятое решение:
|
||||||
|
|
||||||
|
- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз.
|
||||||
|
- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы.
|
||||||
|
- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер.
|
||||||
|
|
||||||
|
## Управление службой
|
||||||
|
|
||||||
|
Установка и регистрация службы:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service"
|
||||||
|
sc.exe start KronaStaffService
|
||||||
|
```
|
||||||
|
|
||||||
|
Остановка и удаление:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sc.exe stop KronaStaffService
|
||||||
|
sc.exe delete KronaStaffService
|
||||||
|
```
|
||||||
124
apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs
Normal file
124
apps/KronaStaffTerminal.Tests/AdminPinServiceTests.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public class AdminPinServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HashAndVerify_ValidPin_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var pin = "1234";
|
||||||
|
var (salt, hash, iterations) = AdminPinService.Hash(pin);
|
||||||
|
|
||||||
|
Assert.NotNull(salt);
|
||||||
|
Assert.StartsWith(AdminPinService.Argon2Prefix, hash);
|
||||||
|
Assert.True(iterations >= 1);
|
||||||
|
|
||||||
|
var verified = AdminPinService.Verify(pin, salt, hash, iterations);
|
||||||
|
Assert.True(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_InvalidPin_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var (salt, hash, iterations) = AdminPinService.Hash("5678");
|
||||||
|
|
||||||
|
var verified = AdminPinService.Verify("0000", salt, hash, iterations);
|
||||||
|
Assert.False(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_LegacyPbkdf2Hash_VerifiesSuccessfully()
|
||||||
|
{
|
||||||
|
var pin = "9876";
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
var saltBase64 = Convert.ToBase64String(salt);
|
||||||
|
var legacyIterations = 120_000;
|
||||||
|
var hashBytes = Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(pin),
|
||||||
|
salt,
|
||||||
|
legacyIterations,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
32);
|
||||||
|
var hashBase64 = Convert.ToBase64String(hashBytes);
|
||||||
|
|
||||||
|
var verified = AdminPinService.Verify(pin, saltBase64, hashBase64, legacyIterations);
|
||||||
|
Assert.True(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAndRecord_LegacyHashUpgradesToArgon2id()
|
||||||
|
{
|
||||||
|
var pin = "4321";
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
var legacyIterations = 120_000;
|
||||||
|
var hashBytes = Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(pin),
|
||||||
|
salt,
|
||||||
|
legacyIterations,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
32);
|
||||||
|
|
||||||
|
var settings = new TerminalSettings
|
||||||
|
{
|
||||||
|
AdminPinSalt = Convert.ToBase64String(salt),
|
||||||
|
AdminPinHash = Convert.ToBase64String(hashBytes),
|
||||||
|
AdminPinIterations = legacyIterations,
|
||||||
|
FailedAdminPinAttempts = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var success = AdminPinService.VerifyAndRecord(pin, settings, now, out var error, out var rehashNeeded);
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.True(rehashNeeded);
|
||||||
|
Assert.StartsWith(AdminPinService.Argon2Prefix, settings.AdminPinHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyAndRecord_ThreeFailedAttempts_TriggersLockout()
|
||||||
|
{
|
||||||
|
var (salt, hash, iterations) = AdminPinService.Hash("1111");
|
||||||
|
var settings = new TerminalSettings
|
||||||
|
{
|
||||||
|
AdminPinSalt = salt,
|
||||||
|
AdminPinHash = hash,
|
||||||
|
AdminPinIterations = iterations,
|
||||||
|
FailedAdminPinAttempts = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Attempt 1: Fail
|
||||||
|
var r1 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err1, out _);
|
||||||
|
Assert.False(r1);
|
||||||
|
Assert.Contains("до блокировки: 2", err1);
|
||||||
|
|
||||||
|
// Attempt 2: Fail
|
||||||
|
var r2 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err2, out _);
|
||||||
|
Assert.False(r2);
|
||||||
|
Assert.Contains("до блокировки: 1", err2);
|
||||||
|
|
||||||
|
// Attempt 3: Lockout triggered (30s)
|
||||||
|
var r3 = AdminPinService.VerifyAndRecord("0000", settings, now, out var err3, out _);
|
||||||
|
Assert.False(r3);
|
||||||
|
Assert.Contains("заблокирован на 30 сек", err3);
|
||||||
|
Assert.True(settings.AdminPinBlockedUntilUtc > now);
|
||||||
|
|
||||||
|
// Attempt 4: Even with correct PIN, blocked during lockout
|
||||||
|
var r4 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(5), out var err4, out _);
|
||||||
|
Assert.False(r4);
|
||||||
|
Assert.Contains("временно заблокирован", err4);
|
||||||
|
|
||||||
|
// Attempt 5: After lockout expires, correct PIN succeeds
|
||||||
|
var r5 = AdminPinService.VerifyAndRecord("1111", settings, now.AddSeconds(35), out var err5, out _);
|
||||||
|
Assert.True(r5);
|
||||||
|
Assert.Null(err5);
|
||||||
|
Assert.Equal(0, settings.FailedAdminPinAttempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs
Normal file
186
apps/KronaStaffTerminal.Tests/AttendanceSyncTests.cs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
using System.Net;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public class AttendanceSyncTests
|
||||||
|
{
|
||||||
|
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public Func<HttpRequestMessage, HttpResponseMessage> HandlerFunc { get; set; } =
|
||||||
|
_ => new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
public List<HttpRequestMessage> CapturedRequests { get; } = new();
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CapturedRequests.Add(request);
|
||||||
|
return Task.FromResult(HandlerFunc(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerminalState CreateConfiguredState()
|
||||||
|
{
|
||||||
|
return new TerminalState
|
||||||
|
{
|
||||||
|
Settings = new TerminalSettings
|
||||||
|
{
|
||||||
|
StaffServerUrl = "https://staff.krona.internal:8453",
|
||||||
|
StaffServerToken = "test_token_secret_123",
|
||||||
|
TerminalDeviceId = "TERM-TEST-01"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncPending_With200Ok_MarksRecordsAsSyncedAndSendsHeaders()
|
||||||
|
{
|
||||||
|
var mockHandler = new MockHttpMessageHandler
|
||||||
|
{
|
||||||
|
HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
};
|
||||||
|
var httpClient = new HttpClient(mockHandler);
|
||||||
|
var syncService = new AttendanceSyncService(httpClient);
|
||||||
|
var state = CreateConfiguredState();
|
||||||
|
|
||||||
|
var record1 = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-10",
|
||||||
|
EventType = "Приход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
var record2 = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-10",
|
||||||
|
EventType = "Уход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
|
||||||
|
state.Attendance.Add(record1);
|
||||||
|
state.Attendance.Add(record2);
|
||||||
|
|
||||||
|
var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ProcessedCount);
|
||||||
|
Assert.Equal(2, result.SuccessCount);
|
||||||
|
Assert.Equal(0, result.FailedCount);
|
||||||
|
Assert.True(record1.ServerSynced);
|
||||||
|
Assert.True(record2.ServerSynced);
|
||||||
|
|
||||||
|
// Verify headers
|
||||||
|
Assert.Equal(2, mockHandler.CapturedRequests.Count);
|
||||||
|
var req = mockHandler.CapturedRequests[0];
|
||||||
|
Assert.Equal("Bearer test_token_secret_123", req.Headers.Authorization?.ToString());
|
||||||
|
Assert.True(req.Headers.Contains("Idempotency-Key"));
|
||||||
|
Assert.True(req.Headers.Contains("X-Request-Id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncPending_With409Conflict_TreatedAsIdempotentSuccess()
|
||||||
|
{
|
||||||
|
var mockHandler = new MockHttpMessageHandler
|
||||||
|
{
|
||||||
|
HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.Conflict)
|
||||||
|
};
|
||||||
|
var httpClient = new HttpClient(mockHandler);
|
||||||
|
var syncService = new AttendanceSyncService(httpClient);
|
||||||
|
var state = CreateConfiguredState();
|
||||||
|
|
||||||
|
var record = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-15",
|
||||||
|
EventType = "Приход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
state.Attendance.Add(record);
|
||||||
|
|
||||||
|
var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.ProcessedCount);
|
||||||
|
Assert.Equal(1, result.SuccessCount);
|
||||||
|
Assert.Equal(0, result.FailedCount);
|
||||||
|
Assert.True(record.ServerSynced);
|
||||||
|
Assert.Null(record.ServerSyncError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncPending_With500Error_RetainsRecordForRetry()
|
||||||
|
{
|
||||||
|
var mockHandler = new MockHttpMessageHandler
|
||||||
|
{
|
||||||
|
HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
|
};
|
||||||
|
var httpClient = new HttpClient(mockHandler);
|
||||||
|
var syncService = new AttendanceSyncService(httpClient);
|
||||||
|
var state = CreateConfiguredState();
|
||||||
|
|
||||||
|
var record = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-20",
|
||||||
|
EventType = "Приход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
state.Attendance.Add(record);
|
||||||
|
|
||||||
|
var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.ProcessedCount);
|
||||||
|
Assert.Equal(0, result.SuccessCount);
|
||||||
|
Assert.Equal(1, result.FailedCount);
|
||||||
|
Assert.False(record.ServerSynced);
|
||||||
|
Assert.Equal("HTTP 500", record.ServerSyncError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncPending_WithNetworkException_HaltsCurrentCycle()
|
||||||
|
{
|
||||||
|
var mockHandler = new MockHttpMessageHandler
|
||||||
|
{
|
||||||
|
HandlerFunc = _ => throw new HttpRequestException("Connection refused")
|
||||||
|
};
|
||||||
|
var httpClient = new HttpClient(mockHandler);
|
||||||
|
var syncService = new AttendanceSyncService(httpClient);
|
||||||
|
var state = CreateConfiguredState();
|
||||||
|
|
||||||
|
var record1 = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-1",
|
||||||
|
EventType = "Приход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2),
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
var record2 = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = "emp-2",
|
||||||
|
EventType = "Приход",
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||||
|
ServerSynced = false
|
||||||
|
};
|
||||||
|
state.Attendance.Add(record1);
|
||||||
|
state.Attendance.Add(record2);
|
||||||
|
|
||||||
|
var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ProcessedCount);
|
||||||
|
Assert.Equal(1, result.FailedCount);
|
||||||
|
Assert.True(result.HasNetworkError);
|
||||||
|
Assert.False(record1.ServerSynced);
|
||||||
|
Assert.False(record2.ServerSynced);
|
||||||
|
// Only first was attempted because loop broke on network drop
|
||||||
|
Assert.Single(mockHandler.CapturedRequests);
|
||||||
|
}
|
||||||
|
}
|
||||||
387
apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
Normal file
387
apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public class AttendanceTrackerTests
|
||||||
|
{
|
||||||
|
private static (TerminalState, EmployeeRecord) CreateTestState()
|
||||||
|
{
|
||||||
|
var emp = new EmployeeRecord
|
||||||
|
{
|
||||||
|
Id = "emp-1",
|
||||||
|
DisplayName = "Тестовый Сотрудник",
|
||||||
|
FingerprintId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var state = new TerminalState
|
||||||
|
{
|
||||||
|
Employees = { emp },
|
||||||
|
Settings = new TerminalSettings
|
||||||
|
{
|
||||||
|
DuplicateScanGuardSeconds = 30,
|
||||||
|
MaximumShiftHours = 14,
|
||||||
|
AutomaticCheckoutEnabled = true,
|
||||||
|
AutomaticCheckoutHour = 23,
|
||||||
|
AutomaticCheckoutMinute = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (state, emp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryRecordAttendance_FirstScan_RecordsCheckIn()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp, 0, now, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(result.IsDuplicateSuppressed);
|
||||||
|
Assert.NotNull(result.Record);
|
||||||
|
Assert.Equal("Приход", result.Record.EventType);
|
||||||
|
Assert.Equal(emp.Id, result.Record.EmployeeId);
|
||||||
|
Assert.False(result.Record.IsAutomatic);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryRecordAttendance_ImmediateSecondScan_RejectedAsDuplicate()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// First scan
|
||||||
|
var first = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp, 0, now, _ => Task.CompletedTask);
|
||||||
|
Assert.True(first.Success);
|
||||||
|
|
||||||
|
// Immediate second scan 5 seconds later (guard is 30s)
|
||||||
|
var second = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp, 0, now.AddSeconds(5), _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.False(second.Success);
|
||||||
|
Assert.True(second.IsDuplicateSuppressed);
|
||||||
|
Assert.Null(second.Record);
|
||||||
|
Assert.Equal(25, second.RemainingSeconds);
|
||||||
|
Assert.Contains("Повторный скан отклонён", second.Message);
|
||||||
|
Assert.Single(state.Attendance); // No extra record inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryRecordAttendance_SecondScanAfterGuard_RecordsCheckOut()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Check in
|
||||||
|
await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp, 0, now, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
// Check out after 35 seconds (guard is 30s)
|
||||||
|
var second = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp, 0, now.AddSeconds(35), _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(second.Success);
|
||||||
|
Assert.False(second.IsDuplicateSuppressed);
|
||||||
|
Assert.NotNull(second.Record);
|
||||||
|
Assert.Equal("Уход", second.Record.EventType);
|
||||||
|
Assert.Equal(2, state.Attendance.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryRecordAttendance_DifferentEmployee_NotSuppressed()
|
||||||
|
{
|
||||||
|
var (state, emp1) = CreateTestState();
|
||||||
|
var emp2 = new EmployeeRecord
|
||||||
|
{
|
||||||
|
Id = "emp-2",
|
||||||
|
DisplayName = "Второй Сотрудник",
|
||||||
|
FingerprintId = 2
|
||||||
|
};
|
||||||
|
state.Employees.Add(emp2);
|
||||||
|
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Scan emp 1
|
||||||
|
var r1 = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp1, 0, now, _ => Task.CompletedTask);
|
||||||
|
Assert.True(r1.Success);
|
||||||
|
|
||||||
|
// Scan emp 2 1 second later
|
||||||
|
var r2 = await tracker.TryRecordAttendanceAsync(
|
||||||
|
state, emp2, 0, now.AddSeconds(1), _ => Task.CompletedTask);
|
||||||
|
Assert.True(r2.Success);
|
||||||
|
Assert.False(r2.IsDuplicateSuppressed);
|
||||||
|
Assert.Equal(2, state.Attendance.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrency_FiftyParallelScans_OnlyOneSucceeds()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var saveCount = 0;
|
||||||
|
Task SaveCallback(TerminalState s)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref saveCount);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch 50 concurrent tasks at the exact same moment
|
||||||
|
var tasks = Enumerable.Range(0, 50).Select(_ =>
|
||||||
|
tracker.TryRecordAttendanceAsync(state, emp, 0, now, SaveCallback)
|
||||||
|
).ToArray();
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
var successCount = results.Count(r => r.Success);
|
||||||
|
var suppressedCount = results.Count(r => r.IsDuplicateSuppressed);
|
||||||
|
|
||||||
|
Assert.Equal(1, successCount);
|
||||||
|
Assert.Equal(49, suppressedCount);
|
||||||
|
Assert.Equal(1, saveCount);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunScheduledCheckout_AutoChecksOutOpenShifts()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
|
||||||
|
// Employee checked in at 09:00 UTC
|
||||||
|
var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero);
|
||||||
|
state.Attendance.Add(new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = emp.Id,
|
||||||
|
EmployeeName = emp.DisplayName,
|
||||||
|
OccurredAtUtc = checkInTime,
|
||||||
|
EventType = "Приход",
|
||||||
|
IsAutomatic = false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current time is 23:30 (checkout scheduled for 23:00)
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var checkedOutRecords = await tracker.RunScheduledCheckoutAsync(
|
||||||
|
state, nowUtc, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.Single(checkedOutRecords);
|
||||||
|
var autoRecord = checkedOutRecords[0];
|
||||||
|
Assert.Equal("Уход автоматически", autoRecord.EventType);
|
||||||
|
Assert.True(autoRecord.IsAutomatic);
|
||||||
|
Assert.Equal(emp.Id, autoRecord.EmployeeId);
|
||||||
|
Assert.Equal(2, state.Attendance.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunScheduledCheckout_AlreadyCheckedOut_DoesNotDoubleCheckout()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
|
||||||
|
var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero);
|
||||||
|
var checkOutTime = new DateTimeOffset(2026, 9, 24, 18, 0, 0, TimeSpan.Zero);
|
||||||
|
state.Attendance.Add(new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = emp.Id,
|
||||||
|
EmployeeName = emp.DisplayName,
|
||||||
|
OccurredAtUtc = checkOutTime,
|
||||||
|
EventType = "Уход",
|
||||||
|
IsAutomatic = false
|
||||||
|
});
|
||||||
|
state.Attendance.Add(new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = emp.Id,
|
||||||
|
EmployeeName = emp.DisplayName,
|
||||||
|
OccurredAtUtc = checkInTime,
|
||||||
|
EventType = "Приход",
|
||||||
|
IsAutomatic = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var checkedOutRecords = await tracker.RunScheduledCheckoutAsync(
|
||||||
|
state, nowUtc, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.Empty(checkedOutRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_RegularEmployee_FirstScan_RecordsAttendanceAndKeepsLocked()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(result.ShouldUnlock);
|
||||||
|
Assert.NotNull(result.AttendanceResult.Record);
|
||||||
|
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
|
||||||
|
Assert.Equal("Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.", result.LockScreenMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_Administrator_FirstScan_RecordsAttendanceAndUnlocks()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
emp.IsAdministrator = true;
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(result.ShouldUnlock);
|
||||||
|
Assert.NotNull(result.AttendanceResult.Record);
|
||||||
|
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
|
||||||
|
Assert.Null(result.LockScreenMessage);
|
||||||
|
Assert.Contains(emp.DisplayName, result.StatusMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_Administrator_SecondScanAfterGuard_RecordsCheckOutAndUnlocks()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
emp.IsAdministrator = true;
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// First scan: check-in
|
||||||
|
await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
// Second scan after 35 seconds (guard is 30s)
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now.AddSeconds(35), isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(result.ShouldUnlock);
|
||||||
|
Assert.NotNull(result.AttendanceResult.Record);
|
||||||
|
Assert.Equal("Уход", result.AttendanceResult.Record.EventType);
|
||||||
|
Assert.Null(result.LockScreenMessage);
|
||||||
|
Assert.Equal(2, state.Attendance.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_RegularEmployee_DuplicateScan_RejectsDuplicateAndKeepsLocked()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// First scan
|
||||||
|
await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
// Immediate repeat scan 5s later
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.False(result.ShouldUnlock);
|
||||||
|
Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
|
||||||
|
Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
|
||||||
|
Assert.Equal("Повторный скан отклонён. Повторите через 25 сек.", result.LockScreenMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_Administrator_DuplicateScan_RejectsDuplicateAndUnlocks()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
emp.IsAdministrator = true;
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// First scan
|
||||||
|
await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
// Immediate repeat scan 5s later
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.True(result.ShouldUnlock);
|
||||||
|
Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
|
||||||
|
Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
|
||||||
|
Assert.Null(result.LockScreenMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Locked_BlockedEmployee_DoesNotRecordAttendance()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
emp.IsBlocked = true;
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.False(result.ShouldUnlock);
|
||||||
|
Assert.False(result.AttendanceResult.Success);
|
||||||
|
Assert.Equal("Этот сотрудник заблокирован.", result.LockScreenMessage);
|
||||||
|
Assert.Empty(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Unlocked_RegularEmployee_RecordsAttendanceWithoutUnlock()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(result.ShouldUnlock);
|
||||||
|
Assert.NotNull(result.AttendanceResult.Record);
|
||||||
|
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
|
||||||
|
Assert.Null(result.LockScreenMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessScan_Unlocked_Administrator_RecordsAttendanceWithoutUnlock()
|
||||||
|
{
|
||||||
|
var (state, emp) = CreateTestState();
|
||||||
|
emp.IsAdministrator = true;
|
||||||
|
var tracker = new AttendanceTracker();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var result = await tracker.ProcessScanAsync(
|
||||||
|
state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(result.ShouldUnlock);
|
||||||
|
Assert.NotNull(result.AttendanceResult.Record);
|
||||||
|
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
|
||||||
|
Assert.Null(result.LockScreenMessage);
|
||||||
|
Assert.Single(state.Attendance);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal file
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public sealed class CsvCellEncoderTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("=HYPERLINK(\"https://example.invalid\")")]
|
||||||
|
[InlineData("+cmd")]
|
||||||
|
[InlineData("-1+1")]
|
||||||
|
[InlineData("@SUM(A1:A2)")]
|
||||||
|
[InlineData(" =SUM(A1:A2)")]
|
||||||
|
[InlineData("\t=SUM(A1:A2)")]
|
||||||
|
[InlineData("\r=SUM(A1:A2)")]
|
||||||
|
public void Encode_neutralizes_spreadsheet_formulas(string value)
|
||||||
|
{
|
||||||
|
var encoded = CsvCellEncoder.Encode(value);
|
||||||
|
|
||||||
|
Assert.StartsWith("\"'", encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_preserves_regular_unicode_and_escapes_quotes()
|
||||||
|
{
|
||||||
|
Assert.Equal("\"Иван \"\"Повар\"\"; смена\"", CsvCellEncoder.Encode("Иван \"Повар\"; смена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
150
apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs
Normal file
150
apps/KronaStaffTerminal.Tests/EmployeeFingerprintTests.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Tests.Mocks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public class EmployeeFingerprintTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Employee_CanHaveMultipleFingerprintsWithDistinctLabels()
|
||||||
|
{
|
||||||
|
var emp = new EmployeeRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
LastName = "Иванов",
|
||||||
|
FirstName = "Иван",
|
||||||
|
Patronymic = "Иванович",
|
||||||
|
Fingerprints = new List<FingerprintRecord>()
|
||||||
|
};
|
||||||
|
|
||||||
|
var fp1 = new FingerprintRecord
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Template = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }),
|
||||||
|
Label = "right_index",
|
||||||
|
EnrolledAtUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var fp2 = new FingerprintRecord
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
Template = Convert.ToBase64String(new byte[] { 5, 6, 7, 8 }),
|
||||||
|
Label = "left_thumb",
|
||||||
|
EnrolledAtUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
emp.Fingerprints.Add(fp1);
|
||||||
|
emp.Fingerprints.Add(fp2);
|
||||||
|
|
||||||
|
Assert.Equal(2, emp.Fingerprints.Count);
|
||||||
|
Assert.Contains(emp.Fingerprints, f => f.Label == "right_index");
|
||||||
|
Assert.Contains(emp.Fingerprints, f => f.Label == "left_thumb");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Employee_CanReplaceSpecificFingerprint()
|
||||||
|
{
|
||||||
|
var emp = new EmployeeRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
DisplayName = "Петров Петр Петрович",
|
||||||
|
Fingerprints = new List<FingerprintRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 10, Label = "right_index", Template = "OLD_TEMPLATE" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace right_index
|
||||||
|
var existing = emp.Fingerprints.FirstOrDefault(f => f.Label == "right_index");
|
||||||
|
Assert.NotNull(existing);
|
||||||
|
|
||||||
|
existing.Template = "NEW_UPDATED_TEMPLATE";
|
||||||
|
existing.EnrolledAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
Assert.Single(emp.Fingerprints);
|
||||||
|
Assert.Equal("NEW_UPDATED_TEMPLATE", emp.Fingerprints[0].Template);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Employee_CanDeleteSingleFingerprintWithoutAffectingOthers()
|
||||||
|
{
|
||||||
|
var emp = new EmployeeRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
DisplayName = "Сидоров Сидор",
|
||||||
|
Fingerprints = new List<FingerprintRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Label = "right_thumb", Template = "T1" },
|
||||||
|
new() { Id = 2, Label = "right_index", Template = "T2" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove right_thumb
|
||||||
|
emp.Fingerprints.RemoveAll(f => f.Label == "right_thumb");
|
||||||
|
|
||||||
|
Assert.Single(emp.Fingerprints);
|
||||||
|
Assert.Equal("right_index", emp.Fingerprints[0].Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateFingerprint_ProhibitedAcrossDifferentEmployees()
|
||||||
|
{
|
||||||
|
var employees = new List<EmployeeRecord>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "emp-1",
|
||||||
|
DisplayName = "Алексеев Алексей",
|
||||||
|
Fingerprints = new List<FingerprintRecord>
|
||||||
|
{
|
||||||
|
new() { Id = 100, Label = "right_index", Template = "UNIQUE_TEMPLATE_HASH_A" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "emp-2",
|
||||||
|
DisplayName = "Борисов Борис",
|
||||||
|
Fingerprints = new List<FingerprintRecord>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempting to enroll the same template to emp-2
|
||||||
|
var candidateTemplate = "UNIQUE_TEMPLATE_HASH_A";
|
||||||
|
var candidateEmployeeId = "emp-2";
|
||||||
|
|
||||||
|
// Validation logic used by terminal
|
||||||
|
var conflict = employees.FirstOrDefault(e =>
|
||||||
|
e.Id != candidateEmployeeId &&
|
||||||
|
e.Fingerprints.Any(f => f.Template == candidateTemplate));
|
||||||
|
|
||||||
|
Assert.NotNull(conflict);
|
||||||
|
Assert.Equal("emp-1", conflict.Id);
|
||||||
|
Assert.Equal("Алексеев Алексей", conflict.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MockScanner_SimulatesMultiFingerEnrollmentAndMatching()
|
||||||
|
{
|
||||||
|
using var scanner = new MockFingerprintScanner();
|
||||||
|
var recognizedId = -1;
|
||||||
|
var recognizedScore = 0;
|
||||||
|
|
||||||
|
scanner.FingerRecognized += (id, score) =>
|
||||||
|
{
|
||||||
|
recognizedId = id;
|
||||||
|
recognizedScore = score;
|
||||||
|
};
|
||||||
|
|
||||||
|
scanner.Connect(0, new[] { (42, "TEMPLATE_42"), (99, "TEMPLATE_99") });
|
||||||
|
Assert.True(scanner.IsConnected);
|
||||||
|
|
||||||
|
scanner.SimulateFingerScan(42, 95);
|
||||||
|
Assert.Equal(42, recognizedId);
|
||||||
|
Assert.Equal(95, recognizedScore);
|
||||||
|
|
||||||
|
scanner.SimulateFingerScan(99, 88);
|
||||||
|
Assert.Equal(99, recognizedId);
|
||||||
|
Assert.Equal(88, recognizedScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public sealed class IpcRequestAuthenticatorTests
|
||||||
|
{
|
||||||
|
private static readonly string Key = Convert.ToBase64String(new byte[32].Select((_, index) => (byte)index).ToArray());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_accepts_fresh_signed_request()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var request = new IpcRequest
|
||||||
|
{
|
||||||
|
Command = "Ping",
|
||||||
|
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
|
||||||
|
Nonce = "00112233445566778899AABBCCDDEEFF"
|
||||||
|
};
|
||||||
|
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||||
|
|
||||||
|
Assert.True(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_rejects_tampering_and_stale_requests()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var request = new IpcRequest
|
||||||
|
{
|
||||||
|
Command = "EnqueueAttendance",
|
||||||
|
Payload = "{}",
|
||||||
|
IssuedAtUnixSeconds = now.AddMinutes(-2).ToUnixTimeSeconds(),
|
||||||
|
Nonce = "00112233445566778899AABBCCDDEEFF"
|
||||||
|
};
|
||||||
|
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||||
|
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||||
|
|
||||||
|
request.IssuedAtUnixSeconds = now.ToUnixTimeSeconds();
|
||||||
|
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||||
|
request.Payload = "{\"forged\":true}";
|
||||||
|
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="libzkfpcsharp">
|
||||||
|
<HintPath>..\KronaStaffTerminal\Vendor\ZKFinger\x64\libzkfpcsharp.dll</HintPath>
|
||||||
|
<Private>true</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Models\TerminalModels.cs" Link="Models\TerminalModels.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Models\UlidGenerator.cs" Link="Models\UlidGenerator.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AdminPinService.cs" Link="Services\AdminPinService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\CsvCellEncoder.cs" Link="Services\CsvCellEncoder.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />
|
||||||
|
<Compile Include="..\KronaStaffTerminal\Services\TerminalStore.cs" Link="Services\TerminalStore.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
144
apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs
Normal file
144
apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests.Mocks;
|
||||||
|
|
||||||
|
public sealed class MockFingerprintScanner : IFingerprintScanner
|
||||||
|
{
|
||||||
|
private readonly List<ScannerDeviceInfo> _devices = new();
|
||||||
|
private readonly Dictionary<int, string> _loadedTemplates = new();
|
||||||
|
private int _registrationTargetId;
|
||||||
|
private int _registrationStep;
|
||||||
|
|
||||||
|
public ScannerState State { get; private set; } = ScannerState.Disconnected;
|
||||||
|
public int DeviceCount => _devices.Count;
|
||||||
|
public int ReaderIndex { get; private set; } = -1;
|
||||||
|
public IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices => _devices;
|
||||||
|
public bool IsConnected => State == ScannerState.Connected || State == ScannerState.Busy;
|
||||||
|
public int SkippedTemplateCount { get; set; } = 0;
|
||||||
|
public int ConnectCallCount { get; private set; } = 0;
|
||||||
|
|
||||||
|
public event Action<int, int>? FingerRecognized;
|
||||||
|
public event Action<int>? RegistrationProgress;
|
||||||
|
public event Action<int, string>? RegistrationCompleted;
|
||||||
|
public event Action<string>? StatusChanged;
|
||||||
|
public event Action<ScannerState, string>? StateChanged;
|
||||||
|
public event Action? ConnectionLost;
|
||||||
|
public event Action? RegistrationCancelled;
|
||||||
|
|
||||||
|
public MockFingerprintScanner()
|
||||||
|
{
|
||||||
|
// Default with 1 virtual SLK20R
|
||||||
|
_devices.Add(new ScannerDeviceInfo(0, "SLK20R-Virtual", "ZK-VIRT-001", "SLK20R [ZK-VIRT-001]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddVirtualDevice(int index, string model, string serial)
|
||||||
|
{
|
||||||
|
_devices.Add(new ScannerDeviceInfo(index, model, serial, $"{model} [{serial}]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearDevices()
|
||||||
|
{
|
||||||
|
_devices.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ScannerDeviceInfo> DiscoverDevices()
|
||||||
|
{
|
||||||
|
return _devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
||||||
|
{
|
||||||
|
ConnectCallCount++;
|
||||||
|
if (_devices.Count == 0)
|
||||||
|
{
|
||||||
|
State = ScannerState.DriverError;
|
||||||
|
StateChanged?.Invoke(ScannerState.DriverError, "No devices found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderIndex = readerIndex;
|
||||||
|
_loadedTemplates.Clear();
|
||||||
|
foreach (var (id, tpl) in templates)
|
||||||
|
{
|
||||||
|
_loadedTemplates[id] = tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = ScannerState.Connected;
|
||||||
|
StateChanged?.Invoke(ScannerState.Connected, "Scanner ready");
|
||||||
|
StatusChanged?.Invoke("Scanner ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Disconnect()
|
||||||
|
{
|
||||||
|
State = ScannerState.Disconnected;
|
||||||
|
ReaderIndex = -1;
|
||||||
|
_loadedTemplates.Clear();
|
||||||
|
StateChanged?.Invoke(ScannerState.Disconnected, "Scanner disconnected");
|
||||||
|
StatusChanged?.Invoke("Scanner disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginRegistration(int fingerprintId)
|
||||||
|
{
|
||||||
|
_registrationTargetId = fingerprintId;
|
||||||
|
_registrationStep = 0;
|
||||||
|
State = ScannerState.Busy;
|
||||||
|
StateChanged?.Invoke(ScannerState.Busy, "Enrolling finger");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CancelRegistration()
|
||||||
|
{
|
||||||
|
_registrationTargetId = 0;
|
||||||
|
_registrationStep = 0;
|
||||||
|
if (State == ScannerState.Busy)
|
||||||
|
{
|
||||||
|
State = ScannerState.Connected;
|
||||||
|
StateChanged?.Invoke(ScannerState.Connected, "Registration cancelled");
|
||||||
|
}
|
||||||
|
RegistrationCancelled?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SimulateFingerScan(int fingerprintId, int score = 85)
|
||||||
|
{
|
||||||
|
if (State != ScannerState.Connected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FingerRecognized?.Invoke(fingerprintId, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SimulateEnrollmentSample(string? finalTemplate = null)
|
||||||
|
{
|
||||||
|
if (State != ScannerState.Busy) return;
|
||||||
|
|
||||||
|
_registrationStep++;
|
||||||
|
RegistrationProgress?.Invoke(_registrationStep);
|
||||||
|
|
||||||
|
if (_registrationStep >= 3)
|
||||||
|
{
|
||||||
|
string tpl = finalTemplate ?? Convert.ToBase64String(new byte[512]);
|
||||||
|
_loadedTemplates[_registrationTargetId] = tpl;
|
||||||
|
State = ScannerState.Connected;
|
||||||
|
StateChanged?.Invoke(ScannerState.Connected, "Registration complete");
|
||||||
|
RegistrationCompleted?.Invoke(_registrationTargetId, tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SimulateDisconnect()
|
||||||
|
{
|
||||||
|
State = ScannerState.Disconnected;
|
||||||
|
StateChanged?.Invoke(ScannerState.Disconnected, "Device unplugged");
|
||||||
|
ConnectionLost?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SimulateReconnect()
|
||||||
|
{
|
||||||
|
State = ScannerState.Connected;
|
||||||
|
StateChanged?.Invoke(ScannerState.Connected, "Device reconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs
Normal file
117
apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
using KronaStaffTerminal.Services;
|
||||||
|
using KronaStaffTerminal.Tests.Mocks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Tests;
|
||||||
|
|
||||||
|
public class ScannerRecoveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Scanner_SimulatesUsbDisconnectAndFiresConnectionLost()
|
||||||
|
{
|
||||||
|
using var scanner = new MockFingerprintScanner();
|
||||||
|
scanner.Connect(0, Array.Empty<(int, string)>());
|
||||||
|
Assert.True(scanner.IsConnected);
|
||||||
|
Assert.Equal(ScannerState.Connected, scanner.State);
|
||||||
|
|
||||||
|
var connectionLostFired = false;
|
||||||
|
ScannerState? reportedState = null;
|
||||||
|
|
||||||
|
scanner.ConnectionLost += () => connectionLostFired = true;
|
||||||
|
scanner.StateChanged += (state, _) => reportedState = state;
|
||||||
|
|
||||||
|
// Simulate physical USB drop
|
||||||
|
scanner.SimulateDisconnect();
|
||||||
|
|
||||||
|
Assert.False(scanner.IsConnected);
|
||||||
|
Assert.Equal(ScannerState.Disconnected, scanner.State);
|
||||||
|
Assert.True(connectionLostFired);
|
||||||
|
Assert.Equal(ScannerState.Disconnected, reportedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scanner_SimulatesUsbReconnectAndRestoresReadyState()
|
||||||
|
{
|
||||||
|
using var scanner = new MockFingerprintScanner();
|
||||||
|
scanner.Connect(0, Array.Empty<(int, string)>());
|
||||||
|
scanner.SimulateDisconnect();
|
||||||
|
Assert.Equal(ScannerState.Disconnected, scanner.State);
|
||||||
|
|
||||||
|
ScannerState? reportedState = null;
|
||||||
|
scanner.StateChanged += (state, _) => reportedState = state;
|
||||||
|
|
||||||
|
// Simulate USB re-plug
|
||||||
|
scanner.SimulateReconnect();
|
||||||
|
|
||||||
|
Assert.True(scanner.IsConnected);
|
||||||
|
Assert.Equal(ScannerState.Connected, scanner.State);
|
||||||
|
Assert.Equal(ScannerState.Connected, reportedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdminPinVerification_OperatesNormallyWhenScannerIsDisconnected()
|
||||||
|
{
|
||||||
|
using var scanner = new MockFingerprintScanner();
|
||||||
|
scanner.SimulateDisconnect();
|
||||||
|
Assert.Equal(ScannerState.Disconnected, scanner.State);
|
||||||
|
|
||||||
|
// Verify that AdminPinService functions 100% independently of hardware scanner state
|
||||||
|
var (salt, hash, iterations) = AdminPinService.Hash("5555");
|
||||||
|
var settings = new TerminalSettings
|
||||||
|
{
|
||||||
|
AdminPinSalt = salt,
|
||||||
|
AdminPinHash = hash,
|
||||||
|
AdminPinIterations = iterations
|
||||||
|
};
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var allowed = AdminPinService.VerifyAndRecord(
|
||||||
|
"5555", settings, now, out var error, out var rehashNeeded);
|
||||||
|
|
||||||
|
Assert.True(allowed);
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.False(rehashNeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scanner_AutoReconnectLogic_CallsConnectSuccessfully()
|
||||||
|
{
|
||||||
|
using var scanner = new MockFingerprintScanner();
|
||||||
|
scanner.Connect(0, Array.Empty<(int, string)>());
|
||||||
|
Assert.Equal(1, scanner.ConnectCallCount);
|
||||||
|
|
||||||
|
var reconnectTcs = new TaskCompletionSource();
|
||||||
|
scanner.ConnectionLost += async () =>
|
||||||
|
{
|
||||||
|
// Simulate typical application retry loop
|
||||||
|
int retries = 0;
|
||||||
|
while (retries < 5)
|
||||||
|
{
|
||||||
|
await Task.Delay(10, TestContext.Current.CancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scanner.Connect(0, Array.Empty<(int, string)>());
|
||||||
|
if (scanner.IsConnected)
|
||||||
|
{
|
||||||
|
reconnectTcs.TrySetResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
reconnectTcs.TrySetException(new Exception("Failed to reconnect"));
|
||||||
|
};
|
||||||
|
|
||||||
|
scanner.SimulateDisconnect();
|
||||||
|
// Emulate the hardware coming back after a short delay
|
||||||
|
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||||
|
scanner.AddVirtualDevice(0, "SLK20R", "SERIAL"); // ensure device is discoverable if it was cleared
|
||||||
|
|
||||||
|
await reconnectTcs.Task;
|
||||||
|
|
||||||
|
Assert.True(scanner.ConnectCallCount > 1);
|
||||||
|
Assert.Equal(ScannerState.Connected, scanner.State);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,7 +86,7 @@ public partial class App : Application
|
||||||
|
|
||||||
Window = new MainWindow();
|
Window = new MainWindow();
|
||||||
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
||||||
_trayService = new TrayService(ShowWindow, RequestExit);
|
_trayService = new TrayService(ShowWindow, RequestExit, ShowWindow, RequestSync);
|
||||||
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
|
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
|
||||||
_activationCancellation = new CancellationTokenSource();
|
_activationCancellation = new CancellationTokenSource();
|
||||||
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
|
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
|
||||||
|
|
@ -123,6 +123,12 @@ public partial class App : Application
|
||||||
|
|
||||||
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
|
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
|
||||||
|
|
||||||
|
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) =>
|
||||||
|
_trayService?.ShowNotificationBalloon(title, message, infoFlags);
|
||||||
|
|
||||||
|
public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
|
||||||
|
_trayService?.UpdateState(state, pendingCount, statusText);
|
||||||
|
|
||||||
private static void ShowWindow()
|
private static void ShowWindow()
|
||||||
{
|
{
|
||||||
if (Window is MainWindow mainWindow)
|
if (Window is MainWindow mainWindow)
|
||||||
|
|
@ -131,6 +137,14 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RequestSync()
|
||||||
|
{
|
||||||
|
if (Window is MainWindow mainWindow && mainWindow.Content is FrameworkElement fe && fe.FindName("RootFrame") is Microsoft.UI.Xaml.Controls.Frame frame && frame.Content is MainPage page)
|
||||||
|
{
|
||||||
|
_ = page.SyncPendingAttendanceAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RequestExit()
|
private static void RequestExit()
|
||||||
{
|
{
|
||||||
if (Window is MainWindow mainWindow)
|
if (Window is MainWindow mainWindow)
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<Version>0.2.0</Version>
|
<Version>0.3.2</Version>
|
||||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
<AssemblyVersion>0.3.2.0</AssemblyVersion>
|
||||||
<FileVersion>0.2.0.0</FileVersion>
|
<FileVersion>0.3.2.0</FileVersion>
|
||||||
<InformationalVersion>0.2.0</InformationalVersion>
|
<InformationalVersion>0.3.2</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -71,7 +71,9 @@
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
|
||||||
|
<PackageReference Include="System.Windows.Extensions" Version="10.0.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -96,14 +98,8 @@
|
||||||
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
|
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
|
||||||
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
|
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(_WinUiRootResource)"
|
<Copy SourceFiles="@(_WinUiRootResource)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
|
||||||
DestinationFolder="$(PublishDir)"
|
<Copy SourceFiles="@(_WinUiAsset)" DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
SkipUnchangedFiles="true" />
|
<Copy SourceFiles="@(_WinUiStyle)" DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
<Copy SourceFiles="@(_WinUiAsset)"
|
|
||||||
DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')"
|
|
||||||
SkipUnchangedFiles="true" />
|
|
||||||
<Copy SourceFiles="@(_WinUiStyle)"
|
|
||||||
DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')"
|
|
||||||
SkipUnchangedFiles="true" />
|
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@
|
||||||
Unloaded="Page_Unloaded"
|
Unloaded="Page_Unloaded"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<Grid Padding="24,16,24,24"
|
<Grid Background="{ThemeResource KronaAppBackgroundBrush}">
|
||||||
RowSpacing="12"
|
<Grid x:Name="ProtectedContent"
|
||||||
Background="{ThemeResource KronaAppBackgroundBrush}">
|
Visibility="Collapsed"
|
||||||
|
Padding="24,16,24,24"
|
||||||
|
RowSpacing="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
|
@ -149,6 +151,8 @@
|
||||||
</AppBarButton.Icon>
|
</AppBarButton.Icon>
|
||||||
</AppBarButton>
|
</AppBarButton>
|
||||||
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
|
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
|
||||||
|
<AppBarButton Icon="Message" Label="Личные уведомления" Click="EditEmployeeNotifications_Click"/>
|
||||||
|
<AppBarButton Icon="BlockContact" Label="Блок./разблок." Click="ToggleEmployeeBlocked_Click"/>
|
||||||
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
|
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
|
||||||
<AppBarSeparator />
|
<AppBarSeparator />
|
||||||
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
|
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
|
||||||
|
|
@ -204,21 +208,49 @@
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
|
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
|
||||||
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
|
<Grid ColumnSpacing="10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<ComboBox x:Name="ReaderCombo"
|
||||||
|
Header="Используемое устройство"
|
||||||
|
MinWidth="320"
|
||||||
|
SelectionChanged="ReaderCombo_SelectionChanged"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Найти сканеры"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Click="ScanReaders_Click"/>
|
||||||
|
</Grid>
|
||||||
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
||||||
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
||||||
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
|
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
|
||||||
|
<ToggleSwitch x:Name="AutomaticCheckoutSwitch"
|
||||||
|
Header="Автоматический уход"
|
||||||
|
OffContent="Выключен"
|
||||||
|
OnContent="Закрывать незавершённые смены ежедневно"/>
|
||||||
|
<TimePicker x:Name="AutomaticCheckoutTimePicker"
|
||||||
|
Header="Время автоматического ухода"
|
||||||
|
ClockIdentifier="24HourClock"
|
||||||
|
MinuteIncrement="5"/>
|
||||||
|
<TextBlock Text="Если сотрудник отметил приход, но не отметил уход, программа автоматически создаст уход в указанное время."
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Opacity="0.7"/>
|
||||||
<ToggleSwitch x:Name="ShowEmployeeNamesSwitch" Header="Имена в публичном журнале терминала" OffContent="Скрыты" OnContent="Показывать"/>
|
<ToggleSwitch x:Name="ShowEmployeeNamesSwitch" Header="Имена в публичном журнале терминала" OffContent="Скрыты" OnContent="Показывать"/>
|
||||||
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
|
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
|
||||||
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
|
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
|
||||||
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
||||||
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
|
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
|
||||||
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/>
|
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/>
|
||||||
<TextBox x:Name="TelegramChatBox" Header="Chat ID Telegram"/>
|
<TextBlock Text="Личный chat_id задаётся в карточке каждого сотрудника." TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
|
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
||||||
|
<ToggleSwitch x:Name="VkEnabledSwitch" Header="ВКонтакте" OffContent="Выключен" OnContent="Включён"/>
|
||||||
|
<PasswordBox x:Name="VkTokenBox" Header="Токен сообщества ВКонтакте" PasswordRevealMode="Peek"/>
|
||||||
|
<TextBlock Text="Личный peer_id задаётся в карточке сотрудника. Сотрудник должен разрешить сообщения сообщества." TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
||||||
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
|
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
|
||||||
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
||||||
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
|
<TextBlock Text="Личный user_id задаётся в карточке каждого сотрудника." TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
||||||
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
|
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
|
||||||
|
|
@ -233,18 +265,42 @@
|
||||||
<PasswordBox x:Name="UpdateTokenBox"
|
<PasswordBox x:Name="UpdateTokenBox"
|
||||||
Header="Токен чтения приватного репозитория"
|
Header="Токен чтения приватного репозитория"
|
||||||
PasswordRevealMode="Peek"/>
|
PasswordRevealMode="Peek"/>
|
||||||
|
<TextBox x:Name="UpdateSignerThumbprintBox"
|
||||||
|
Header="Отпечаток сертификата издателя"
|
||||||
|
PlaceholderText="SHA-1 или SHA-256 без пробелов"
|
||||||
|
CharacterCasing="Upper"/>
|
||||||
|
<TextBlock Text="Автоматическая установка разрешена только для установщика с действительной подписью Windows и указанным сертификатом издателя."
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Opacity="0.7"/>
|
||||||
<TextBlock Text="Токен нужен только пока обновления берутся из приватного Forgejo. Позже этот канал можно заменить Бизнес Платформой без изменения установщика."
|
<TextBlock Text="Токен нужен только пока обновления берутся из приватного Forgejo. Позже этот канал можно заменить Бизнес Платформой без изменения установщика."
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
Opacity="0.7"/>
|
Opacity="0.7"/>
|
||||||
<Button Content="Проверить обновления"
|
<Button Content="Проверить обновления"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
Click="CheckForUpdates_Click"/>
|
Click="CheckForUpdates_Click"/>
|
||||||
|
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
|
||||||
|
<TextBlock Text="Локальный сервер посещаемости"
|
||||||
|
Foreground="{ThemeResource KronaTextPrimaryBrush}"
|
||||||
|
Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
|
<TextBox x:Name="StaffServerUrlBox"
|
||||||
|
Header="HTTPS-адрес сервера"
|
||||||
|
IsReadOnly="True"/>
|
||||||
|
<PasswordBox x:Name="StaffServerTokenBox"
|
||||||
|
Header="Ключ терминала"
|
||||||
|
PasswordRevealMode="Peek"/>
|
||||||
|
<TextBox x:Name="StaffServerCertificateBox"
|
||||||
|
Header="SHA-256 сертификата сервера"
|
||||||
|
PlaceholderText="64 шестнадцатеричных символа"
|
||||||
|
CharacterCasing="Upper"/>
|
||||||
|
<TextBlock x:Name="TerminalDeviceIdText" TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
|
<TextBlock Text="Передаются только случайные ID сотрудника, события и терминала, тип прихода/ухода и время. ФИО, контакты и отпечатки не передаются. Адрес закреплён за тестовым сервером 192.168.1.81:8453."
|
||||||
|
TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Сохранить настройки"
|
<Button Content="Сохранить настройки"
|
||||||
Background="{ThemeResource KronaPrimaryBrush}"
|
Background="{ThemeResource KronaPrimaryBrush}"
|
||||||
Foreground="{ThemeResource KronaTextInverseBrush}"
|
Foreground="{ThemeResource KronaTextInverseBrush}"
|
||||||
Click="SaveSettings_Click"/>
|
Click="SaveSettings_Click"/>
|
||||||
<Button Content="Отправить тест" Click="TestNotification_Click"/>
|
<Button Content="Тест выбранному сотруднику" Click="TestNotification_Click"/>
|
||||||
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
|
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
|
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
|
|
@ -253,4 +309,43 @@
|
||||||
</TabViewItem>
|
</TabViewItem>
|
||||||
</TabView>
|
</TabView>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid x:Name="ApplicationLockScreen"
|
||||||
|
Padding="32"
|
||||||
|
Background="{ThemeResource KronaAppBackgroundBrush}">
|
||||||
|
<Border Width="460"
|
||||||
|
Padding="40"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{ThemeResource KronaSurfaceBrush}"
|
||||||
|
BorderBrush="{ThemeResource KronaBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="{StaticResource KronaRadiusLarge}">
|
||||||
|
<StackPanel Spacing="18">
|
||||||
|
<Image Width="120" Height="120" Source="Assets/TrushenkoLogo.png" Stretch="Uniform"/>
|
||||||
|
<TextBlock Text="Krona Staff заблокирован"
|
||||||
|
Style="{StaticResource TitleTextBlockStyle}"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="LockScreenMessage"
|
||||||
|
Text="Введите PIN администратора или приложите зарегистрированный палец."
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Opacity="0.75"/>
|
||||||
|
<PasswordBox x:Name="UnlockPinBox"
|
||||||
|
Header="PIN администратора"
|
||||||
|
PasswordRevealMode="Peek"
|
||||||
|
KeyDown="UnlockPinBox_KeyDown"/>
|
||||||
|
<Button Content="Войти"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Center"
|
||||||
|
Background="{ThemeResource KronaPrimaryBrush}"
|
||||||
|
Foreground="{ThemeResource KronaTextInverseBrush}"
|
||||||
|
Click="UnlockApplication_Click"/>
|
||||||
|
<TextBlock Text="До входа сотрудники, журнал и настройки скрыты."
|
||||||
|
TextAlignment="Center"
|
||||||
|
Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace KronaStaffTerminal.Models;
|
namespace KronaStaffTerminal.Models;
|
||||||
|
|
@ -6,11 +7,39 @@ public sealed class EmployeeRecord
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = UlidGenerator.New();
|
public string Id { get; set; } = UlidGenerator.New();
|
||||||
public string PersonnelNumber { get; set; } = "";
|
public string PersonnelNumber { get; set; } = "";
|
||||||
public string DisplayName { get; set; } = "";
|
public string LastName { get; set; } = "";
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string Patronymic { get; set; } = "";
|
||||||
|
private string _displayName = "";
|
||||||
|
public string DisplayName
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_displayName)) return _displayName;
|
||||||
|
var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p));
|
||||||
|
var combined = string.Join(" ", parts);
|
||||||
|
return string.IsNullOrWhiteSpace(combined) ? "" : combined;
|
||||||
|
}
|
||||||
|
set => _displayName = value;
|
||||||
|
}
|
||||||
public string Position { get; set; } = "";
|
public string Position { get; set; } = "";
|
||||||
public string Gender { get; set; } = "";
|
public string Gender { get; set; } = "";
|
||||||
public bool IsAdministrator { get; set; }
|
public bool IsAdministrator { get; set; }
|
||||||
|
public bool IsBlocked { get; set; }
|
||||||
|
public DateTimeOffset? BlockedAtUtc { get; set; }
|
||||||
public List<FingerprintRecord> Fingerprints { get; set; } = [];
|
public List<FingerprintRecord> Fingerprints { get; set; } = [];
|
||||||
|
public string TelegramChatId { get; set; } = "";
|
||||||
|
public string VkPeerId { get; set; } = "";
|
||||||
|
public string MaxUserId { get; set; } = "";
|
||||||
|
|
||||||
|
public void SetFullName(string lastName, string firstName, string patronymic)
|
||||||
|
{
|
||||||
|
LastName = lastName?.Trim() ?? "";
|
||||||
|
FirstName = firstName?.Trim() ?? "";
|
||||||
|
Patronymic = patronymic?.Trim() ?? "";
|
||||||
|
var parts = new[] { LastName, FirstName, Patronymic }.Where(p => !string.IsNullOrWhiteSpace(p));
|
||||||
|
_displayName = string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
|
// Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
|
||||||
public int FingerprintId { get; set; }
|
public int FingerprintId { get; set; }
|
||||||
|
|
@ -29,7 +58,21 @@ public sealed class EmployeeRecord
|
||||||
};
|
};
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
|
public string AccessStatus => IsBlocked ? "Заблокирован" : IsAdministrator ? "Администратор" : "Сотрудник";
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string NotificationStatus
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (IsBlocked) return "Отключены";
|
||||||
|
var channels = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(TelegramChatId)) channels.Add("Telegram");
|
||||||
|
if (!string.IsNullOrWhiteSpace(VkPeerId)) channels.Add("VK");
|
||||||
|
if (!string.IsNullOrWhiteSpace(MaxUserId)) channels.Add("MAX");
|
||||||
|
return channels.Count == 0 ? "Не подключены" : string.Join(", ", channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
|
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
|
||||||
|
|
@ -40,6 +83,7 @@ public sealed class FingerprintRecord
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Template { get; set; } = "";
|
public string Template { get; set; } = "";
|
||||||
public string Label { get; set; } = "";
|
public string Label { get; set; } = "";
|
||||||
|
public DateTimeOffset EnrolledAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AttendanceRecord
|
public sealed class AttendanceRecord
|
||||||
|
|
@ -50,8 +94,11 @@ public sealed class AttendanceRecord
|
||||||
public DateTimeOffset OccurredAtUtc { get; set; }
|
public DateTimeOffset OccurredAtUtc { get; set; }
|
||||||
public int ReaderIndex { get; set; }
|
public int ReaderIndex { get; set; }
|
||||||
public string EventType { get; set; } = "Приход";
|
public string EventType { get; set; } = "Приход";
|
||||||
|
public bool IsAutomatic { get; set; }
|
||||||
public bool NotificationDelivered { get; set; }
|
public bool NotificationDelivered { get; set; }
|
||||||
public string? NotificationError { get; set; }
|
public string? NotificationError { get; set; }
|
||||||
|
public bool ServerSynced { get; set; }
|
||||||
|
public string? ServerSyncError { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string LocalTime => OccurredAtUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
|
public string LocalTime => OccurredAtUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
|
||||||
|
|
@ -62,6 +109,7 @@ public sealed class AttendanceRecord
|
||||||
|
|
||||||
public sealed class TerminalSettings
|
public sealed class TerminalSettings
|
||||||
{
|
{
|
||||||
|
public string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
public int ReaderIndex { get; set; }
|
public int ReaderIndex { get; set; }
|
||||||
public bool StartWithWindows { get; set; }
|
public bool StartWithWindows { get; set; }
|
||||||
public string AdminPinSalt { get; set; } = "";
|
public string AdminPinSalt { get; set; } = "";
|
||||||
|
|
@ -71,16 +119,28 @@ public sealed class TerminalSettings
|
||||||
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
|
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
|
||||||
public int DuplicateScanGuardSeconds { get; set; } = 120;
|
public int DuplicateScanGuardSeconds { get; set; } = 120;
|
||||||
public int MaximumShiftHours { get; set; } = 20;
|
public int MaximumShiftHours { get; set; } = 20;
|
||||||
|
public bool AutomaticCheckoutEnabled { get; set; }
|
||||||
|
public int AutomaticCheckoutHour { get; set; } = 2;
|
||||||
|
public int AutomaticCheckoutMinute { get; set; }
|
||||||
|
public DateTimeOffset LastAutomaticCheckoutUtc { get; set; }
|
||||||
public bool ShowEmployeeNamesOnTerminal { get; set; }
|
public bool ShowEmployeeNamesOnTerminal { get; set; }
|
||||||
public bool TelegramEnabled { get; set; }
|
public bool TelegramEnabled { get; set; }
|
||||||
public string TelegramBotToken { get; set; } = "";
|
public string TelegramBotToken { get; set; } = "";
|
||||||
|
// Legacy global chat IDs are retained only so existing encrypted state remains readable.
|
||||||
public string TelegramChatId { get; set; } = "";
|
public string TelegramChatId { get; set; } = "";
|
||||||
|
public bool VkEnabled { get; set; }
|
||||||
|
public string VkGroupToken { get; set; } = "";
|
||||||
public bool MaxEnabled { get; set; }
|
public bool MaxEnabled { get; set; }
|
||||||
public string MaxBotToken { get; set; } = "";
|
public string MaxBotToken { get; set; } = "";
|
||||||
public string MaxChatId { get; set; } = "";
|
public string MaxChatId { get; set; } = "";
|
||||||
public bool UpdateChecksEnabled { get; set; } = true;
|
public bool UpdateChecksEnabled { get; set; } = true;
|
||||||
public string UpdateFeedUrl { get; set; } = "https://git.ochenstarik.ru/api/v1/repos/ochenstarik/krona-staff/releases/latest";
|
public string UpdateFeedUrl { get; set; } = "https://git.ochenstarik.ru/api/v1/repos/ochenstarik/krona-staff/releases/latest";
|
||||||
public string UpdateAccessToken { get; set; } = "";
|
public string UpdateAccessToken { get; set; } = "";
|
||||||
|
public string UpdateTrustedSignerThumbprint { get; set; } = "";
|
||||||
|
public string StaffServerUrl { get; set; } = "https://192.168.1.81:8453";
|
||||||
|
public string StaffServerToken { get; set; } = "";
|
||||||
|
public string StaffServerCertificateSha256 { get; set; } = "";
|
||||||
|
public string TerminalDeviceId { get; set; } = UlidGenerator.New();
|
||||||
public DateTimeOffset LastUpdateCheckUtc { get; set; }
|
public DateTimeOffset LastUpdateCheckUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,23 @@
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
|
- повторный поиск сканеров по кнопке, ручной выбор одного из подключённых устройств и автоматический переход на рабочее устройство;
|
||||||
|
- первичная настройка PIN и добавление первого сотрудника-администратора без подключённого сканера;
|
||||||
- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
|
- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
|
||||||
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
|
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
|
||||||
- чередование событий «Приход» и «Уход»;
|
- чередование событий «Приход» и «Уход»;
|
||||||
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
||||||
|
- ежедневный автоматический уход в заданное время для сотрудников, забывших отметить завершение смены;
|
||||||
- локальный журнал последних отметок;
|
- локальный журнал последних отметок;
|
||||||
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
|
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
|
||||||
- защищённая очистка журнала без удаления сотрудников и отпечатков;
|
- защищённая очистка журнала без удаления сотрудников и отпечатков;
|
||||||
- приватный режим, скрывающий полные имена на публичном экране;
|
- приватный режим, скрывающий полные имена на публичном экране;
|
||||||
- уведомления через Telegram Bot API и MAX Bot API;
|
- личные уведомления сотрудников через Telegram, сообщения сообщества ВКонтакте и MAX;
|
||||||
|
- блокировка сотрудника с отключением его отпечатков и безвозвратным удалением локальных привязок мессенджеров;
|
||||||
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
|
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
|
||||||
- закрытый административный раздел с входом по PIN или отпечатку администратора;
|
- закрытый экран входа, скрывающий сотрудников, журнал и настройки до проверки PIN или отпечатка администратора;
|
||||||
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
|
- автоматическая блокировка всей программы при запуске, сворачивании и ручной блокировке;
|
||||||
|
- автоматическая блокировка административного раздела через 5 минут бездействия;
|
||||||
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
||||||
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
|
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
|
||||||
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
|
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
|
||||||
|
|
@ -55,7 +59,7 @@ ZKTeco. Уже установленный драйвер продолжает р
|
||||||
|
|
||||||
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
|
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
|
||||||
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
|
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
|
||||||
3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
|
3. Кнопка «Заблокировать» и сворачивание скрывают весь интерфейс до повторного входа. Пять минут бездействия закрывают административные вкладки, не останавливая рабочий терминал.
|
||||||
|
|
||||||
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
|
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
|
||||||
|
|
||||||
|
|
@ -66,7 +70,9 @@ ZKTeco. Уже установленный драйвер продолжает р
|
||||||
|
|
||||||
## Уведомления
|
## Уведомления
|
||||||
|
|
||||||
На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
|
На вкладке «Уведомления и сканер» включите нужные каналы и укажите токены ботов. Затем выберите сотрудника, нажмите «Личные уведомления» и сохраните его личный `chat_id` Telegram, `peer_id` ВКонтакте или `user_id` MAX. Сотрудник должен предварительно начать личный диалог с ботом или разрешить сообщения сообщества. Кнопка «Тест выбранному сотруднику» отправляет сообщение только этому человеку. При успешной отметке сотрудник получает дату, время и тип собственного события без имени и данных других сотрудников.
|
||||||
|
|
||||||
|
При блокировке сотрудника его личные идентификаторы удаляются из зашифрованного хранилища, отпечатки перестают создавать отметки, а отправка прекращается. После разблокировки каналы необходимо привязать заново. Удалить весь диалог из приложения мессенджера на устройстве сотрудника бот не может; уже доставленная история остаётся под управлением самого пользователя и соответствующего мессенджера.
|
||||||
|
|
||||||
## Обновления
|
## Обновления
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Konscious.Security.Cryptography;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
|
||||||
namespace KronaStaffTerminal.Services;
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
public static class AdminPinService
|
public static class AdminPinService
|
||||||
{
|
{
|
||||||
public const int CurrentIterations = 600_000;
|
public const string Argon2Prefix = "$argon2id$";
|
||||||
|
public const int DefaultArgon2Iterations = 3;
|
||||||
|
public const int DefaultMemorySizeKb = 65536; // 64 MB
|
||||||
|
public const int DefaultParallelism = 2;
|
||||||
|
public const int HashLength = 32;
|
||||||
|
|
||||||
|
public const int LegacyPbkdf2Iterations = 600_000;
|
||||||
|
|
||||||
public static (string Salt, string Hash, int Iterations) Hash(string pin)
|
public static (string Salt, string Hash, int Iterations) Hash(string pin)
|
||||||
{
|
{
|
||||||
var salt = RandomNumberGenerator.GetBytes(16);
|
var salt = RandomNumberGenerator.GetBytes(16);
|
||||||
var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32);
|
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin))
|
||||||
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash), CurrentIterations);
|
{
|
||||||
|
Salt = salt,
|
||||||
|
DegreeOfParallelism = DefaultParallelism,
|
||||||
|
Iterations = DefaultArgon2Iterations,
|
||||||
|
MemorySize = DefaultMemorySizeKb
|
||||||
|
};
|
||||||
|
var hash = argon2.GetBytes(HashLength);
|
||||||
|
return (Convert.ToBase64String(salt), Argon2Prefix + Convert.ToBase64String(hash), DefaultArgon2Iterations);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool Verify(string pin, string saltBase64, string hashBase64, int iterations)
|
public static bool Verify(string pin, string saltBase64, string hashBase64, int iterations)
|
||||||
|
|
@ -23,18 +39,111 @@ public static class AdminPinService
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var salt = Convert.FromBase64String(saltBase64);
|
var salt = Convert.FromBase64String(saltBase64);
|
||||||
var expected = Convert.FromBase64String(hashBase64);
|
|
||||||
var actual = Rfc2898DeriveBytes.Pbkdf2(
|
if (hashBase64.StartsWith(Argon2Prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var rawHash = hashBase64[Argon2Prefix.Length..];
|
||||||
|
var expected = Convert.FromBase64String(rawHash);
|
||||||
|
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin))
|
||||||
|
{
|
||||||
|
Salt = salt,
|
||||||
|
DegreeOfParallelism = DefaultParallelism,
|
||||||
|
Iterations = Math.Max(1, iterations),
|
||||||
|
MemorySize = DefaultMemorySizeKb
|
||||||
|
};
|
||||||
|
var actual = argon2.GetBytes(expected.Length);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backwards compatibility with legacy PBKDF2 hashes
|
||||||
|
var legacyExpected = Convert.FromBase64String(hashBase64);
|
||||||
|
var legacyActual = Rfc2898DeriveBytes.Pbkdf2(
|
||||||
pin,
|
pin,
|
||||||
salt,
|
salt,
|
||||||
Math.Clamp(iterations, 120_000, CurrentIterations),
|
Math.Clamp(iterations, 120_000, LegacyPbkdf2Iterations),
|
||||||
HashAlgorithmName.SHA256,
|
HashAlgorithmName.SHA256,
|
||||||
expected.Length);
|
legacyExpected.Length);
|
||||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
return CryptographicOperations.FixedTimeEquals(legacyActual, legacyExpected);
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TimeSpan CalculateLockout(int failedAttempts)
|
||||||
|
{
|
||||||
|
if (failedAttempts < 3) return TimeSpan.Zero;
|
||||||
|
if (failedAttempts < 5) return TimeSpan.FromSeconds(30 * (failedAttempts - 2)); // 30s, 60s
|
||||||
|
if (failedAttempts < 8) return TimeSpan.FromMinutes(5); // 5 minutes
|
||||||
|
return TimeSpan.FromMinutes(15); // 15 minutes max
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (bool Allowed, string? ErrorMessage) CheckLockout(TerminalSettings settings, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
if (settings.AdminPinBlockedUntilUtc > nowUtc)
|
||||||
|
{
|
||||||
|
var remaining = settings.AdminPinBlockedUntilUtc - nowUtc;
|
||||||
|
var seconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds));
|
||||||
|
return (false, $"Ввод PIN временно заблокирован из-за неверных попыток. Попробуйте через {seconds} сек.");
|
||||||
|
}
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool VerifyAndRecord(
|
||||||
|
string pin,
|
||||||
|
TerminalSettings settings,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
out string? errorMessage,
|
||||||
|
out bool rehashNeeded)
|
||||||
|
{
|
||||||
|
var (allowed, lockoutMessage) = CheckLockout(settings, nowUtc);
|
||||||
|
if (!allowed)
|
||||||
|
{
|
||||||
|
errorMessage = lockoutMessage;
|
||||||
|
rehashNeeded = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMatch = Verify(pin, settings.AdminPinSalt, settings.AdminPinHash, settings.AdminPinIterations);
|
||||||
|
if (isMatch)
|
||||||
|
{
|
||||||
|
settings.FailedAdminPinAttempts = 0;
|
||||||
|
settings.AdminPinBlockedUntilUtc = DateTimeOffset.MinValue;
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
// Automatically upgrade legacy PBKDF2 hash to Argon2id
|
||||||
|
if (!settings.AdminPinHash.StartsWith(Argon2Prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var (newSalt, newHash, newIterations) = Hash(pin);
|
||||||
|
settings.AdminPinSalt = newSalt;
|
||||||
|
settings.AdminPinHash = newHash;
|
||||||
|
settings.AdminPinIterations = newIterations;
|
||||||
|
rehashNeeded = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rehashNeeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.FailedAdminPinAttempts++;
|
||||||
|
var lockout = CalculateLockout(settings.FailedAdminPinAttempts);
|
||||||
|
if (lockout > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
settings.AdminPinBlockedUntilUtc = nowUtc.Add(lockout);
|
||||||
|
var seconds = (int)lockout.TotalSeconds;
|
||||||
|
errorMessage = $"Неверный PIN. Ввод заблокирован на {seconds} сек.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var remainingTries = 3 - settings.FailedAdminPinAttempts;
|
||||||
|
errorMessage = $"Неверный PIN. Осталось попыток до блокировки: {remainingTries}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
rehashNeeded = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
apps/KronaStaffTerminal/Services/AttendanceSyncService.cs
Normal file
142
apps/KronaStaffTerminal/Services/AttendanceSyncService.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public sealed class AttendanceSyncResult
|
||||||
|
{
|
||||||
|
public int ProcessedCount { get; set; }
|
||||||
|
public int SuccessCount { get; set; }
|
||||||
|
public int FailedCount { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public bool HasNetworkError { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AttendanceSyncService
|
||||||
|
{
|
||||||
|
private readonly HttpClient? _httpClient;
|
||||||
|
|
||||||
|
public AttendanceSyncService(HttpClient? httpClient = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AttendanceSyncResult> SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new AttendanceSyncResult();
|
||||||
|
if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) ||
|
||||||
|
string.IsNullOrWhiteSpace(state.Settings.StaffServerUrl))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpoint = BuildEndpoint(state.Settings.StaffServerUrl);
|
||||||
|
var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256);
|
||||||
|
using var fallbackHandler = _httpClient == null ? new HttpClientHandler { AllowAutoRedirect = false } : null;
|
||||||
|
if (fallbackHandler != null && expectedCertificate.Length == 64)
|
||||||
|
{
|
||||||
|
fallbackHandler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
|
||||||
|
CertificateMatches(certificate, expectedCertificate);
|
||||||
|
}
|
||||||
|
using var fallbackClient = fallbackHandler != null ? new HttpClient(fallbackHandler) { Timeout = TimeSpan.FromSeconds(15) } : null;
|
||||||
|
var client = _httpClient ?? fallbackClient!;
|
||||||
|
|
||||||
|
var pending = state.Attendance
|
||||||
|
.Where(item => !item.ServerSynced && IsSyncableEvent(item.EventType))
|
||||||
|
.OrderBy(item => item.OccurredAtUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
result.ProcessedCount = pending.Count;
|
||||||
|
|
||||||
|
foreach (var record in pending)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var isCheckIn = record.EventType == "Приход";
|
||||||
|
var isAuto = record.IsAutomatic || record.EventType.Contains("автоматически", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
state.Settings.StaffServerToken);
|
||||||
|
request.Headers.Add("Idempotency-Key", record.Id);
|
||||||
|
request.Headers.Add("X-Request-Id", UlidGenerator.New());
|
||||||
|
|
||||||
|
request.Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
event_id = record.Id,
|
||||||
|
employee_id = record.EmployeeId,
|
||||||
|
event_type = isCheckIn ? "check_in" : "check_out",
|
||||||
|
is_automatic = isAuto,
|
||||||
|
trigger = isAuto ? "auto_schedule" : "manual_biometric",
|
||||||
|
occurred_at = record.OccurredAtUtc,
|
||||||
|
source_event_id = record.Id,
|
||||||
|
device_id = state.Settings.TerminalDeviceId
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
// 200/201 Success, or 409 Conflict (idempotent duplicate already received on server)
|
||||||
|
record.ServerSynced = true;
|
||||||
|
record.ServerSyncError = null;
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
record.ServerSyncError = $"HTTP {(int)response.StatusCode}";
|
||||||
|
result.FailedCount++;
|
||||||
|
result.LastError = record.ServerSyncError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
record.ServerSyncError = "Нет защищённой связи с сервером";
|
||||||
|
result.FailedCount++;
|
||||||
|
result.HasNetworkError = true;
|
||||||
|
result.LastError = exception.Message;
|
||||||
|
// Cease trying subsequent items in this cycle when host is unreachable
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Uri BuildEndpoint(string serverUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(serverUrl) ||
|
||||||
|
!Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) ||
|
||||||
|
root.Scheme != Uri.UriSchemeHttps ||
|
||||||
|
string.IsNullOrWhiteSpace(root.Host) ||
|
||||||
|
!string.IsNullOrEmpty(root.Query) ||
|
||||||
|
!string.IsNullOrEmpty(root.Fragment))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Требуется защищённый HTTPS-адрес сервера (например, https://staff.example.com:8453).");
|
||||||
|
}
|
||||||
|
return new Uri(root, "/api/v1/staff/attendance/events");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint)
|
||||||
|
{
|
||||||
|
if (certificate is null) return false;
|
||||||
|
var normalized = NormalizeFingerprint(expectedFingerprint);
|
||||||
|
if (normalized.Length != 64) return false;
|
||||||
|
|
||||||
|
var actual = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Convert.FromHexString(actual),
|
||||||
|
Convert.FromHexString(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeFingerprint(string value) =>
|
||||||
|
new(value.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray());
|
||||||
|
|
||||||
|
private static bool IsSyncableEvent(string eventType) =>
|
||||||
|
eventType is "Приход" or "Уход" || eventType.StartsWith("Уход", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
246
apps/KronaStaffTerminal/Services/AttendanceTracker.cs
Normal file
246
apps/KronaStaffTerminal/Services/AttendanceTracker.cs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public sealed record AttendanceRecordResult(
|
||||||
|
bool Success,
|
||||||
|
AttendanceRecord? Record,
|
||||||
|
string Message,
|
||||||
|
bool IsDuplicateSuppressed,
|
||||||
|
int RemainingSeconds = 0);
|
||||||
|
|
||||||
|
public sealed record ScanRecognitionResult(
|
||||||
|
bool Success,
|
||||||
|
AttendanceRecordResult AttendanceResult,
|
||||||
|
bool ShouldUnlock,
|
||||||
|
string? LockScreenMessage,
|
||||||
|
string StatusMessage);
|
||||||
|
|
||||||
|
public sealed class AttendanceTracker
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
public async Task<AttendanceRecordResult> TryRecordAttendanceAsync(
|
||||||
|
TerminalState state,
|
||||||
|
EmployeeRecord employee,
|
||||||
|
int readerIndex,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
Func<TerminalState, Task> saveStateCallback)
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var last = state.Attendance
|
||||||
|
.Where(item => item.EmployeeId == employee.Id)
|
||||||
|
.OrderByDescending(item => item.OccurredAtUtc)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var guardSeconds = Math.Clamp(state.Settings.DuplicateScanGuardSeconds, 10, 3600);
|
||||||
|
var guard = TimeSpan.FromSeconds(guardSeconds);
|
||||||
|
|
||||||
|
if (last is not null && nowUtc - last.OccurredAtUtc < guard)
|
||||||
|
{
|
||||||
|
var remaining = Math.Max(1, (int)Math.Ceiling((guard - (nowUtc - last.OccurredAtUtc)).TotalSeconds));
|
||||||
|
return new AttendanceRecordResult(
|
||||||
|
Success: false,
|
||||||
|
Record: null,
|
||||||
|
Message: $"Повторный скан отклонён. Повторите через {remaining} сек.",
|
||||||
|
IsDuplicateSuppressed: true,
|
||||||
|
RemainingSeconds: remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
var maximumShift = TimeSpan.FromHours(Math.Clamp(state.Settings.MaximumShiftHours, 1, 48));
|
||||||
|
var isCheckIn = last is null || last.EventType != "Приход" || (nowUtc - last.OccurredAtUtc > maximumShift);
|
||||||
|
var eventType = isCheckIn ? "Приход" : "Уход";
|
||||||
|
|
||||||
|
var record = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = employee.Id,
|
||||||
|
EmployeeName = employee.DisplayName,
|
||||||
|
OccurredAtUtc = nowUtc,
|
||||||
|
ReaderIndex = readerIndex,
|
||||||
|
EventType = eventType,
|
||||||
|
IsAutomatic = false
|
||||||
|
};
|
||||||
|
|
||||||
|
state.Attendance.Insert(0, record);
|
||||||
|
if (state.Attendance.Count > 100_000)
|
||||||
|
{
|
||||||
|
state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveStateCallback(state);
|
||||||
|
|
||||||
|
return new AttendanceRecordResult(
|
||||||
|
Success: true,
|
||||||
|
Record: record,
|
||||||
|
Message: $"{eventType} отмечен в {nowUtc.ToLocalTime():HH:mm}",
|
||||||
|
IsDuplicateSuppressed: false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScanRecognitionResult> ProcessScanAsync(
|
||||||
|
TerminalState state,
|
||||||
|
EmployeeRecord employee,
|
||||||
|
int readerIndex,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
bool isApplicationLocked,
|
||||||
|
Func<TerminalState, Task> saveStateCallback)
|
||||||
|
{
|
||||||
|
if (employee.IsBlocked)
|
||||||
|
{
|
||||||
|
return new ScanRecognitionResult(
|
||||||
|
Success: false,
|
||||||
|
AttendanceResult: new AttendanceRecordResult(
|
||||||
|
Success: false,
|
||||||
|
Record: null,
|
||||||
|
Message: "Сотрудник заблокирован. Отметка не создана.",
|
||||||
|
IsDuplicateSuppressed: false),
|
||||||
|
ShouldUnlock: false,
|
||||||
|
LockScreenMessage: isApplicationLocked ? "Этот сотрудник заблокирован." : null,
|
||||||
|
StatusMessage: "Сотрудник заблокирован. Отметка не создана.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var attendanceResult = await TryRecordAttendanceAsync(
|
||||||
|
state,
|
||||||
|
employee,
|
||||||
|
readerIndex,
|
||||||
|
nowUtc,
|
||||||
|
saveStateCallback);
|
||||||
|
|
||||||
|
if (isApplicationLocked)
|
||||||
|
{
|
||||||
|
if (!employee.IsAdministrator)
|
||||||
|
{
|
||||||
|
var lockMessage = attendanceResult.Success
|
||||||
|
? "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным."
|
||||||
|
: attendanceResult.IsDuplicateSuppressed
|
||||||
|
? $"Повторный скан отклонён. Повторите через {attendanceResult.RemainingSeconds} сек."
|
||||||
|
: "Отметка не создана. Интерфейс остаётся заблокированным.";
|
||||||
|
|
||||||
|
return new ScanRecognitionResult(
|
||||||
|
Success: attendanceResult.Success,
|
||||||
|
AttendanceResult: attendanceResult,
|
||||||
|
ShouldUnlock: false,
|
||||||
|
LockScreenMessage: lockMessage,
|
||||||
|
StatusMessage: attendanceResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Administrator scan in locked state:
|
||||||
|
// Attendance is recorded AND the interface should be unlocked
|
||||||
|
return new ScanRecognitionResult(
|
||||||
|
Success: attendanceResult.Success,
|
||||||
|
AttendanceResult: attendanceResult,
|
||||||
|
ShouldUnlock: true,
|
||||||
|
LockScreenMessage: null,
|
||||||
|
StatusMessage: $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ScanRecognitionResult(
|
||||||
|
Success: attendanceResult.Success,
|
||||||
|
AttendanceResult: attendanceResult,
|
||||||
|
ShouldUnlock: false,
|
||||||
|
LockScreenMessage: null,
|
||||||
|
StatusMessage: attendanceResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AttendanceRecord>> RunScheduledCheckoutAsync(
|
||||||
|
TerminalState state,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
Func<TerminalState, Task> saveStateCallback)
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!state.Settings.AutomaticCheckoutEnabled)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var hour = Math.Clamp(state.Settings.AutomaticCheckoutHour, 0, 23);
|
||||||
|
var minute = Math.Clamp(state.Settings.AutomaticCheckoutMinute, 0, 59);
|
||||||
|
var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute);
|
||||||
|
|
||||||
|
if (state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdRecords = new List<AttendanceRecord>();
|
||||||
|
foreach (var employee in state.Employees)
|
||||||
|
{
|
||||||
|
if (employee.IsBlocked) continue;
|
||||||
|
|
||||||
|
var last = state.Attendance
|
||||||
|
.Where(item => item.EmployeeId == employee.Id)
|
||||||
|
.OrderByDescending(item => item.OccurredAtUtc)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (last is null || last.EventType != "Приход") continue;
|
||||||
|
|
||||||
|
var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute);
|
||||||
|
if (checkoutUtc > nowUtc) continue;
|
||||||
|
|
||||||
|
var record = new AttendanceRecord
|
||||||
|
{
|
||||||
|
Id = UlidGenerator.New(),
|
||||||
|
EmployeeId = employee.Id,
|
||||||
|
EmployeeName = employee.DisplayName,
|
||||||
|
OccurredAtUtc = checkoutUtc,
|
||||||
|
ReaderIndex = -1,
|
||||||
|
EventType = "Уход автоматически",
|
||||||
|
IsAutomatic = true
|
||||||
|
};
|
||||||
|
|
||||||
|
createdRecords.Add(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdRecords.Count > 0)
|
||||||
|
{
|
||||||
|
state.Attendance.InsertRange(0, createdRecords);
|
||||||
|
if (state.Attendance.Count > 100_000)
|
||||||
|
{
|
||||||
|
state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc;
|
||||||
|
await saveStateCallback(state);
|
||||||
|
|
||||||
|
return createdRecords;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute)
|
||||||
|
{
|
||||||
|
var localNow = nowUtc.ToLocalTime();
|
||||||
|
var scheduledToday = new DateTimeOffset(
|
||||||
|
localNow.Year, localNow.Month, localNow.Day,
|
||||||
|
hour, minute, 0,
|
||||||
|
localNow.Offset);
|
||||||
|
|
||||||
|
var targetLocal = localNow >= scheduledToday ? scheduledToday : scheduledToday.AddDays(-1);
|
||||||
|
return targetLocal.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset startUtc, int hour, int minute)
|
||||||
|
{
|
||||||
|
var localStart = startUtc.ToLocalTime();
|
||||||
|
var scheduledSameDay = new DateTimeOffset(
|
||||||
|
localStart.Year, localStart.Month, localStart.Day,
|
||||||
|
hour, minute, 0,
|
||||||
|
localStart.Offset);
|
||||||
|
|
||||||
|
var targetLocal = scheduledSameDay > localStart ? scheduledSameDay : scheduledSameDay.AddDays(1);
|
||||||
|
return targetLocal.ToUniversalTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ public static class CrashLogService
|
||||||
private const long MaximumLogSize = 1_048_576;
|
private const long MaximumLogSize = 1_048_576;
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
private static readonly string LogDirectory = Path.Combine(
|
private static readonly string LogDirectory = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
"Krona",
|
"Krona",
|
||||||
"StaffTerminal",
|
"StaffTerminal",
|
||||||
"logs");
|
"logs");
|
||||||
|
|
@ -29,6 +29,25 @@ public static class CrashLogService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void WriteDiagnostic(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
var path = Path.Combine(LogDirectory, "diagnostic.log");
|
||||||
|
RotateIfNeeded(path);
|
||||||
|
var entry = $"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}";
|
||||||
|
File.AppendAllText(path, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Diagnostics must never throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RotateIfNeeded(string path)
|
private static void RotateIfNeeded(string path)
|
||||||
{
|
{
|
||||||
if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return;
|
if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return;
|
||||||
|
|
|
||||||
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal file
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public static class CsvCellEncoder
|
||||||
|
{
|
||||||
|
public static string Encode(string? value)
|
||||||
|
{
|
||||||
|
var safe = value ?? string.Empty;
|
||||||
|
var firstContent = safe.AsSpan().TrimStart();
|
||||||
|
if (firstContent.Length > 0 && firstContent[0] is '=' or '+' or '-' or '@')
|
||||||
|
{
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
else if (safe.Length > 0 && safe[0] is '\t' or '\r' or '\n')
|
||||||
|
{
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\"{safe.Replace("\"", "\"\"")}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,47 @@
|
||||||
|
using System.Text;
|
||||||
using libzkfpcsharp;
|
using libzkfpcsharp;
|
||||||
|
|
||||||
namespace KronaStaffTerminal.Services;
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
public sealed class FingerprintScanner : IDisposable
|
public enum ScannerState
|
||||||
|
{
|
||||||
|
Disconnected,
|
||||||
|
Connected,
|
||||||
|
Busy,
|
||||||
|
DriverError
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ScannerDeviceInfo(
|
||||||
|
int Index,
|
||||||
|
string Model,
|
||||||
|
string SerialNumber,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
public interface IFingerprintScanner : IDisposable
|
||||||
|
{
|
||||||
|
ScannerState State { get; }
|
||||||
|
int DeviceCount { get; }
|
||||||
|
int ReaderIndex { get; }
|
||||||
|
IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices { get; }
|
||||||
|
bool IsConnected { get; }
|
||||||
|
int SkippedTemplateCount { get; }
|
||||||
|
|
||||||
|
event Action<int, int>? FingerRecognized;
|
||||||
|
event Action<int>? RegistrationProgress;
|
||||||
|
event Action<int, string>? RegistrationCompleted;
|
||||||
|
event Action<string>? StatusChanged;
|
||||||
|
event Action<ScannerState, string>? StateChanged;
|
||||||
|
event Action? ConnectionLost;
|
||||||
|
event Action? RegistrationCancelled;
|
||||||
|
|
||||||
|
IReadOnlyList<ScannerDeviceInfo> DiscoverDevices();
|
||||||
|
void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates);
|
||||||
|
void Disconnect();
|
||||||
|
void BeginRegistration(int fingerprintId);
|
||||||
|
void CancelRegistration();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FingerprintScanner : IFingerprintScanner
|
||||||
{
|
{
|
||||||
private const int TemplateSize = 2048;
|
private const int TemplateSize = 2048;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
|
@ -17,54 +56,187 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
|
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
|
||||||
private bool _fingerOnSensor;
|
private bool _fingerOnSensor;
|
||||||
private int _cleanupScheduled;
|
private int _cleanupScheduled;
|
||||||
|
private List<ScannerDeviceInfo> _discoveredDevices = [];
|
||||||
|
private List<(int FingerprintId, string Template)> _lastLoadedTemplates = [];
|
||||||
|
private CancellationTokenSource? _reconnectCancellation;
|
||||||
|
private Task? _reconnectTask;
|
||||||
|
private bool _autoReconnectEnabled = true;
|
||||||
|
|
||||||
public event Action<int, int>? FingerRecognized;
|
public event Action<int, int>? FingerRecognized;
|
||||||
public event Action<int>? RegistrationProgress;
|
public event Action<int>? RegistrationProgress;
|
||||||
|
event Action<int, string>? IFingerprintScanner.RegistrationCompleted
|
||||||
|
{
|
||||||
|
add => RegistrationCompleted += value;
|
||||||
|
remove => RegistrationCompleted -= value;
|
||||||
|
}
|
||||||
public event Action<int, string>? RegistrationCompleted;
|
public event Action<int, string>? RegistrationCompleted;
|
||||||
public event Action<string>? StatusChanged;
|
public event Action<string>? StatusChanged;
|
||||||
|
public event Action<ScannerState, string>? StateChanged;
|
||||||
public event Action? ConnectionLost;
|
public event Action? ConnectionLost;
|
||||||
public event Action? RegistrationCancelled;
|
public event Action? RegistrationCancelled;
|
||||||
|
|
||||||
|
public ScannerState State { get; private set; } = ScannerState.Disconnected;
|
||||||
public int DeviceCount { get; private set; }
|
public int DeviceCount { get; private set; }
|
||||||
public int ReaderIndex { get; private set; }
|
public int ReaderIndex { get; private set; }
|
||||||
public int SkippedTemplateCount { get; private set; }
|
public int SkippedTemplateCount { get; private set; }
|
||||||
public bool IsConnected => _device != IntPtr.Zero;
|
public IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices => _discoveredDevices;
|
||||||
|
public bool IsConnected => _device != IntPtr.Zero && State == ScannerState.Connected;
|
||||||
|
|
||||||
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
public IReadOnlyList<ScannerDeviceInfo> DiscoverDevices()
|
||||||
{
|
{
|
||||||
Disconnect();
|
lock (_gate)
|
||||||
SkippedTemplateCount = 0;
|
|
||||||
var result = zkfp2.Init();
|
|
||||||
if (result != 0)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
|
var devices = new List<ScannerDeviceInfo>();
|
||||||
|
var seenSerials = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var initResult = zkfp2.Init();
|
||||||
|
if (initResult != 0 && initResult != 1)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"zkfp2.Init returned {initResult} during discovery.");
|
||||||
|
_discoveredDevices = devices;
|
||||||
|
DeviceCount = 0;
|
||||||
|
return devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZKFinger SDK 5.3 reports one service slot in addition to physical readers.
|
try
|
||||||
// The vendor C# sample uses GetDeviceCount() - 1 for this SDK generation.
|
|
||||||
DeviceCount = Math.Max(0, zkfp2.GetDeviceCount() - 1);
|
|
||||||
if (DeviceCount <= 0)
|
|
||||||
{
|
{
|
||||||
zkfp2.Terminate();
|
var rawCount = Math.Max(0, zkfp2.GetDeviceCount());
|
||||||
throw new InvalidOperationException("Подключённые сканеры не найдены.");
|
for (var i = 0; i < rawCount; i++)
|
||||||
|
{
|
||||||
|
// If this device is currently open by us, use it or skip opening
|
||||||
|
IntPtr handle;
|
||||||
|
var shouldClose = false;
|
||||||
|
if (_device != IntPtr.Zero && ReaderIndex == i)
|
||||||
|
{
|
||||||
|
handle = _device;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
handle = zkfp2.OpenDevice(i);
|
||||||
|
shouldClose = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
|
if (handle == IntPtr.Zero)
|
||||||
var candidateIndexes = new[] { preferredIndex }
|
|
||||||
.Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex));
|
|
||||||
foreach (var candidateIndex in candidateIndexes)
|
|
||||||
{
|
{
|
||||||
var candidateDevice = zkfp2.OpenDevice(candidateIndex);
|
continue;
|
||||||
if (candidateDevice == IntPtr.Zero) continue;
|
|
||||||
ReaderIndex = candidateIndex;
|
|
||||||
_device = candidateDevice;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var serial = QueryDeviceStringParameter(handle, 1101);
|
||||||
|
if (string.IsNullOrWhiteSpace(serial))
|
||||||
|
{
|
||||||
|
serial = QueryDeviceStringParameter(handle, 1102);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(serial))
|
||||||
|
{
|
||||||
|
serial = QueryDeviceStringParameter(handle, 106);
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = QueryDeviceStringParameter(handle, 1015);
|
||||||
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
model = "ZKTeco SLK20R";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminate duplicate devices:
|
||||||
|
// ZKFinger SDK 5.3 often exposes 2 device slots for 1 physical USB scanner.
|
||||||
|
// Filter out ghost handles by checking serial number deduplication.
|
||||||
|
if (!string.IsNullOrWhiteSpace(serial))
|
||||||
|
{
|
||||||
|
if (seenSerials.Contains(serial))
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"Device index {i} skipped as duplicate serial: {serial}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenSerials.Add(serial);
|
||||||
|
}
|
||||||
|
else if (devices.Count > 0 && rawCount > 1)
|
||||||
|
{
|
||||||
|
// If no serial number could be queried but a previous reader was already found
|
||||||
|
// on SLK20R driver, skip the secondary phantom slot.
|
||||||
|
CrashLogService.WriteDiagnostic($"Device index {i} skipped as phantom secondary reader slot.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName = !string.IsNullOrWhiteSpace(serial)
|
||||||
|
? $"{model} ({serial})"
|
||||||
|
: $"{model} (сканер №{devices.Count + 1})";
|
||||||
|
|
||||||
|
devices.Add(new ScannerDeviceInfo(i, model, serial, displayName));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (shouldClose && handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
zkfp2.CloseDevice(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
if (_device == IntPtr.Zero)
|
if (_device == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
zkfp2.Terminate();
|
zkfp2.Terminate();
|
||||||
throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель.");
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_discoveredDevices = devices;
|
||||||
|
DeviceCount = devices.Count;
|
||||||
|
CrashLogService.WriteDiagnostic($"Device discovery completed: {devices.Count} unique device(s) found.");
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
||||||
|
{
|
||||||
|
_lastLoadedTemplates = templates.ToList();
|
||||||
|
_autoReconnectEnabled = true;
|
||||||
|
StopReconnectLoop();
|
||||||
|
|
||||||
|
ConnectInternal(readerIndex, _lastLoadedTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConnectInternal(int readerIndex, IReadOnlyList<(int FingerprintId, string Template)> templates)
|
||||||
|
{
|
||||||
|
Disconnect();
|
||||||
|
SkippedTemplateCount = 0;
|
||||||
|
|
||||||
|
var result = zkfp2.Init();
|
||||||
|
if (result != 0 && result != 1)
|
||||||
|
{
|
||||||
|
SetState(ScannerState.DriverError, $"Драйвер сканера вернул ошибку инициализации (код {result}).");
|
||||||
|
CrashLogService.WriteDiagnostic($"Scanner initialization failed: code {result}");
|
||||||
|
StartReconnectLoop();
|
||||||
|
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}). Проверьте драйвер.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var available = DiscoverDevices();
|
||||||
|
if (available.Count == 0)
|
||||||
|
{
|
||||||
|
zkfp2.Terminate();
|
||||||
|
SetState(ScannerState.Disconnected, "Подключённые сканеры не найдены.");
|
||||||
|
CrashLogService.WriteDiagnostic("Scanner connect failed: no devices found.");
|
||||||
|
StartReconnectLoop();
|
||||||
|
throw new InvalidOperationException("Подключённые сканеры не найдены. Подключите USB-сканер.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosenDevice = available.FirstOrDefault(d => d.Index == readerIndex) ?? available[0];
|
||||||
|
var deviceIndex = chosenDevice.Index;
|
||||||
|
|
||||||
|
var candidate = zkfp2.OpenDevice(deviceIndex);
|
||||||
|
if (candidate == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
zkfp2.Terminate();
|
||||||
|
SetState(ScannerState.DriverError, "Не удалось открыть сканер. Возможно, устройство занято.");
|
||||||
|
CrashLogService.WriteDiagnostic($"Failed to open scanner at index {deviceIndex}.");
|
||||||
|
StartReconnectLoop();
|
||||||
|
throw new InvalidOperationException("Не удалось открыть сканер. Переподключите USB-кабель.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderIndex = deviceIndex;
|
||||||
|
_device = candidate;
|
||||||
|
|
||||||
_database = zkfp2.DBInit();
|
_database = zkfp2.DBInit();
|
||||||
if (_database == IntPtr.Zero)
|
if (_database == IntPtr.Zero)
|
||||||
|
|
@ -72,7 +244,10 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
zkfp2.CloseDevice(_device);
|
zkfp2.CloseDevice(_device);
|
||||||
_device = IntPtr.Zero;
|
_device = IntPtr.Zero;
|
||||||
zkfp2.Terminate();
|
zkfp2.Terminate();
|
||||||
throw new InvalidOperationException("Не удалось создать базу распознавания.");
|
SetState(ScannerState.DriverError, "Не удалось инициализировать базу биометрических шаблонов.");
|
||||||
|
CrashLogService.WriteDiagnostic("zkfp2.DBInit failed.");
|
||||||
|
StartReconnectLoop();
|
||||||
|
throw new InvalidOperationException("Не удалось создать базу биометрических шаблонов.");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in templates)
|
foreach (var item in templates)
|
||||||
|
|
@ -95,7 +270,13 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
var device = _device;
|
var device = _device;
|
||||||
var database = _database;
|
var database = _database;
|
||||||
_captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token));
|
_captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token));
|
||||||
StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец.");
|
|
||||||
|
var modelInfo = !string.IsNullOrWhiteSpace(chosenDevice.SerialNumber)
|
||||||
|
? $"{chosenDevice.Model} [SN: {chosenDevice.SerialNumber}]"
|
||||||
|
: chosenDevice.Model;
|
||||||
|
|
||||||
|
SetState(ScannerState.Connected, $"Сканер {modelInfo} подключён. Приложите палец.");
|
||||||
|
CrashLogService.WriteDiagnostic($"Scanner connected: {modelInfo} at index {deviceIndex}. Loaded {templates.Count - SkippedTemplateCount} templates.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void BeginRegistration(int fingerprintId)
|
public void BeginRegistration(int fingerprintId)
|
||||||
|
|
@ -107,7 +288,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
|
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
|
SetState(ScannerState.Busy, "Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CancelRegistration()
|
public void CancelRegistration()
|
||||||
|
|
@ -118,6 +299,9 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
_registrationCount = 0;
|
_registrationCount = 0;
|
||||||
_registrationExpiresAtUtc = default;
|
_registrationExpiresAtUtc = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetState(IsConnected ? ScannerState.Connected : ScannerState.Disconnected, "Регистрация отпечатка отменена.");
|
||||||
|
RegistrationCancelled?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
|
private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
|
||||||
|
|
@ -145,15 +329,15 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
var healthSize = 4;
|
var healthSize = 4;
|
||||||
if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0)
|
if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0)
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke("Связь со сканером потеряна. Выполняется переподключение…");
|
HandleConnectionLoss("Потеряна связь со сканером (сбой проверки состояния).");
|
||||||
ConnectionLost?.Invoke();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
|
nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
var capturedLength = TemplateSize;
|
var capturedLength = TemplateSize;
|
||||||
if (zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength) == 0)
|
var acquireResult = zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength);
|
||||||
|
if (acquireResult == 0)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
|
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
|
||||||
|
|
@ -163,6 +347,12 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
ProcessCapture(database, captured, capturedLength);
|
ProcessCapture(database, captured, capturedLength);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (acquireResult is -6 or -7 or -8 or -10 or -17)
|
||||||
|
{
|
||||||
|
// Negative critical device error codes indicate USB disconnect or driver fault
|
||||||
|
HandleConnectionLoss($"Ошибка сканера (код {acquireResult}).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_fingerOnSensor = false;
|
_fingerOnSensor = false;
|
||||||
|
|
@ -175,8 +365,70 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
CrashLogService.Write(exception);
|
CrashLogService.Write(exception);
|
||||||
if (!cancellationToken.IsCancellationRequested)
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke("Ошибка сканера. Выполняется переподключение…");
|
HandleConnectionLoss($"Сбой потока чтения сканера: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleConnectionLoss(string reason)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"Scanner connection lost: {reason}");
|
||||||
|
SetState(ScannerState.Disconnected, reason);
|
||||||
ConnectionLost?.Invoke();
|
ConnectionLost?.Invoke();
|
||||||
|
|
||||||
|
if (_autoReconnectEnabled)
|
||||||
|
{
|
||||||
|
StartReconnectLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartReconnectLoop()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_reconnectTask is not null && !_reconnectTask.IsCompleted) return;
|
||||||
|
_reconnectCancellation = new CancellationTokenSource();
|
||||||
|
var token = _reconnectCancellation.Token;
|
||||||
|
_reconnectTask = Task.Run(() => ReconnectLoopAsync(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopReconnectLoop()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_reconnectCancellation?.Cancel();
|
||||||
|
_reconnectCancellation?.Dispose();
|
||||||
|
_reconnectCancellation = null;
|
||||||
|
_reconnectTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconnectLoopAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic("Starting background scanner auto-reconnect loop...");
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2500, token);
|
||||||
|
if (token.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
var devices = DiscoverDevices();
|
||||||
|
if (devices.Count > 0)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"Scanner detected during reconnect: {devices[0].DisplayName}. Connecting...");
|
||||||
|
ConnectInternal(ReaderIndex, _lastLoadedTemplates);
|
||||||
|
return; // Successfully reconnected!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"Reconnect attempt failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +441,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
_registrationFingerprintId = null;
|
_registrationFingerprintId = null;
|
||||||
_registrationCount = 0;
|
_registrationCount = 0;
|
||||||
_registrationExpiresAtUtc = default;
|
_registrationExpiresAtUtc = default;
|
||||||
StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
|
SetState(ScannerState.Connected, "Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
|
||||||
RegistrationCancelled?.Invoke();
|
RegistrationCancelled?.Invoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +460,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
{
|
{
|
||||||
_registrationFingerprintId = null;
|
_registrationFingerprintId = null;
|
||||||
_registrationExpiresAtUtc = default;
|
_registrationExpiresAtUtc = default;
|
||||||
StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена.");
|
SetState(ScannerState.Connected, "Этот палец уже зарегистрирован. Регистрация отменена.");
|
||||||
RegistrationCancelled?.Invoke();
|
RegistrationCancelled?.Invoke();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +468,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
|
|
||||||
if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
|
if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
|
SetState(ScannerState.Busy, "Это другой палец. Приложите тот же палец ещё раз.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,7 +478,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
RegistrationProgress?.Invoke(_registrationCount);
|
RegistrationProgress?.Invoke(_registrationCount);
|
||||||
if (_registrationCount < 3)
|
if (_registrationCount < 3)
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова.");
|
SetState(ScannerState.Busy, $"Скан принят: {_registrationCount}/3. Уберите палец и приложите снова.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,12 +496,15 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
_registrationExpiresAtUtc = default;
|
_registrationExpiresAtUtc = default;
|
||||||
if (result == 0)
|
if (result == 0)
|
||||||
{
|
{
|
||||||
RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
|
var templateBase64 = zkfp2.BlobToBase64(merged, mergedLength);
|
||||||
StatusChanged?.Invoke("Отпечаток успешно зарегистрирован.");
|
RegistrationCompleted?.Invoke(fingerprintId, templateBase64);
|
||||||
|
SetState(ScannerState.Connected, "Отпечаток успешно зарегистрирован.");
|
||||||
|
CrashLogService.WriteDiagnostic($"Fingerprint id {fingerprintId} successfully merged and enrolled.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию.");
|
SetState(ScannerState.Connected, $"Не удалось объединить сканы (код {result}). Повторите регистрацию.");
|
||||||
|
CrashLogService.WriteDiagnostic($"Fingerprint enrollment merge failed: code {result}");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -262,11 +517,18 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
StatusChanged?.Invoke("Отпечаток не распознан.");
|
SetState(ScannerState.Connected, "Отпечаток не распознан.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetState(ScannerState newState, string message)
|
||||||
|
{
|
||||||
|
State = newState;
|
||||||
|
StatusChanged?.Invoke(message);
|
||||||
|
StateChanged?.Invoke(newState, message);
|
||||||
|
}
|
||||||
|
|
||||||
public void Disconnect()
|
public void Disconnect()
|
||||||
{
|
{
|
||||||
_captureCancellation?.Cancel();
|
_captureCancellation?.Cancel();
|
||||||
|
|
@ -287,7 +549,7 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
TaskScheduler.Default);
|
TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
throw new TimeoutException(
|
throw new TimeoutException(
|
||||||
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения.");
|
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены после завершения операции.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (AggregateException exception)
|
catch (AggregateException exception)
|
||||||
|
|
@ -317,11 +579,63 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
}
|
}
|
||||||
zkfp2.Terminate();
|
zkfp2.Terminate();
|
||||||
Interlocked.Exchange(ref _cleanupScheduled, 0);
|
Interlocked.Exchange(ref _cleanupScheduled, 0);
|
||||||
|
if (State != ScannerState.DriverError)
|
||||||
|
{
|
||||||
|
State = ScannerState.Disconnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QueryDeviceStringParameter(IntPtr device, int paramCode)
|
||||||
|
{
|
||||||
|
var buffer = new byte[256];
|
||||||
|
var size = buffer.Length;
|
||||||
|
if (zkfp2.GetParameters(device, paramCode, buffer, ref size) == 0 && size > 0)
|
||||||
|
{
|
||||||
|
var len = 0;
|
||||||
|
while (len < size && buffer[len] != 0) len++;
|
||||||
|
if (len > 0)
|
||||||
|
{
|
||||||
|
return Encoding.ASCII.GetString(buffer, 0, len).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ProbeSession0Support(out string diagnostic)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var init = zkfp2.Init();
|
||||||
|
if (init != 0 && init != 1)
|
||||||
|
{
|
||||||
|
diagnostic = $"zkfp2.Init failed with code {init} in current session.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = zkfp2.GetDeviceCount();
|
||||||
|
zkfp2.Terminate();
|
||||||
|
|
||||||
|
if (count <= 0)
|
||||||
|
{
|
||||||
|
diagnostic = "ZKFinger driver reports 0 devices in Session 0 / non-interactive context.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostic = $"ZKFinger driver reported {count} device(s).";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
diagnostic = $"Exception probing ZKFinger in Session 0: {ex.Message}";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_autoReconnectEnabled = false;
|
||||||
|
StopReconnectLoop();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Disconnect();
|
Disconnect();
|
||||||
|
|
|
||||||
25
apps/KronaStaffTerminal/Services/LicenseStatus.cs
Normal file
25
apps/KronaStaffTerminal/Services/LicenseStatus.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public enum LicenseStatus
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Active,
|
||||||
|
GracePeriod,
|
||||||
|
Expired,
|
||||||
|
Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ILicenseStatusPort
|
||||||
|
{
|
||||||
|
LicenseStatus GetStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temporary standalone adapter returning Active for the pilot period.
|
||||||
|
/// Invariant: Standalone adapters are compatible, migratable, and disableable;
|
||||||
|
/// they do not become a second permanent source of truth (AGENTS.md #15).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StandaloneLicenseAdapter : ILicenseStatusPort
|
||||||
|
{
|
||||||
|
public LicenseStatus GetStatus() => LicenseStatus.Active;
|
||||||
|
}
|
||||||
|
|
@ -9,28 +9,33 @@ public sealed class NotificationService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
|
||||||
public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, string employeeName, string eventType, DateTimeOffset localTime)
|
public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, EmployeeRecord employee, string eventType, DateTimeOffset localTime)
|
||||||
{
|
{
|
||||||
var action = eventType == "Уход" ? "ушёл(ушла)" : "пришёл(пришла)";
|
var action = eventType.StartsWith("Уход", StringComparison.Ordinal) ? "уход" : "приход";
|
||||||
return await SendAsync(settings, $"{employeeName} {action} в {localTime:HH:mm}");
|
return await SendAsync(settings, employee,
|
||||||
|
$"Krona Staff: ваш {action} зарегистрирован {localTime:dd.MM.yyyy} в {localTime:HH:mm}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings)
|
public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings, EmployeeRecord employee)
|
||||||
{
|
{
|
||||||
return await SendAsync(settings, "Krona Staff: тестовое сообщение доставлено.");
|
return await SendAsync(settings, employee, "Krona Staff: личные уведомления подключены.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, string text)
|
private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, EmployeeRecord employee, string text)
|
||||||
{
|
{
|
||||||
|
if (employee.IsBlocked) return (false, "Сотрудник заблокирован.");
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
var sent = false;
|
var sent = false;
|
||||||
|
|
||||||
if (settings.TelegramEnabled)
|
var telegramConfigured = settings.TelegramEnabled &&
|
||||||
|
!string.IsNullOrWhiteSpace(settings.TelegramBotToken) &&
|
||||||
|
!string.IsNullOrWhiteSpace(employee.TelegramChatId);
|
||||||
|
if (telegramConfigured)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage";
|
var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage";
|
||||||
using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = settings.TelegramChatId, text });
|
using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = employee.TelegramChatId, text });
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
errors.Add($"Telegram: HTTP {(int)response.StatusCode}");
|
errors.Add($"Telegram: HTTP {(int)response.StatusCode}");
|
||||||
|
|
@ -46,11 +51,48 @@ public sealed class NotificationService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.MaxEnabled)
|
var vkConfigured = settings.VkEnabled &&
|
||||||
|
!string.IsNullOrWhiteSpace(settings.VkGroupToken) &&
|
||||||
|
!string.IsNullOrWhiteSpace(employee.VkPeerId);
|
||||||
|
if (vkConfigured)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"https://platform-api.max.ru/messages?chat_id={Uri.EscapeDataString(settings.MaxChatId)}";
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["access_token"] = settings.VkGroupToken,
|
||||||
|
["v"] = "5.199",
|
||||||
|
["peer_id"] = employee.VkPeerId,
|
||||||
|
["random_id"] = Random.Shared.Next(1, int.MaxValue).ToString(),
|
||||||
|
["message"] = text
|
||||||
|
};
|
||||||
|
using var response = await _httpClient.PostAsync(
|
||||||
|
"https://api.vk.com/method/messages.send",
|
||||||
|
new FormUrlEncodedContent(values));
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!response.IsSuccessStatusCode || responseText.Contains("\"error\"", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
errors.Add($"VK: HTTP {(int)response.StatusCode}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"VK: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxConfigured = settings.MaxEnabled &&
|
||||||
|
!string.IsNullOrWhiteSpace(settings.MaxBotToken) &&
|
||||||
|
!string.IsNullOrWhiteSpace(employee.MaxUserId);
|
||||||
|
if (maxConfigured)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"https://platform-api2.max.ru/messages?user_id={Uri.EscapeDataString(employee.MaxUserId)}";
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
|
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
|
||||||
request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json");
|
request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json");
|
||||||
|
|
@ -70,7 +112,7 @@ public sealed class NotificationService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.TelegramEnabled && !settings.MaxEnabled)
|
if (!telegramConfigured && !vkConfigured && !maxConfigured)
|
||||||
{
|
{
|
||||||
return (false, null);
|
return (false, null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
apps/KronaStaffTerminal/Services/TerminalIpc.cs
Normal file
279
apps/KronaStaffTerminal/Services/TerminalIpc.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.AccessControl;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using KronaStaffTerminal.Models;
|
||||||
|
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public sealed class IpcRequest
|
||||||
|
{
|
||||||
|
public string Command { get; set; } = "";
|
||||||
|
public string? Payload { get; set; }
|
||||||
|
public long IssuedAtUnixSeconds { get; set; }
|
||||||
|
public string Nonce { get; set; } = "";
|
||||||
|
public string Signature { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IpcResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public IpcServiceStatus? Status { get; set; }
|
||||||
|
public int? SyncedCount { get; set; }
|
||||||
|
public string? Payload { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IpcServiceStatus
|
||||||
|
{
|
||||||
|
public bool ServiceRunning { get; set; } = true;
|
||||||
|
public string ServiceVersion { get; set; } = "0.3.2";
|
||||||
|
public bool ScannerInSession0Supported { get; set; }
|
||||||
|
public string ScannerDiagnostic { get; set; } = "";
|
||||||
|
public int PendingSyncCount { get; set; }
|
||||||
|
public DateTimeOffset? LastSyncUtc { get; set; }
|
||||||
|
public string? LastSyncError { get; set; }
|
||||||
|
public string LicenseStatus { get; set; } = "Active";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TerminalIpcConstants
|
||||||
|
{
|
||||||
|
public const string PipeName = "KronaStaffTerminalPipe";
|
||||||
|
public const string PipeServerName = ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TerminalIpcClient
|
||||||
|
{
|
||||||
|
private readonly TerminalStore _store = new();
|
||||||
|
|
||||||
|
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await SendCommandAsync("Ping", null, cancellationToken);
|
||||||
|
return response?.Success == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IpcServiceStatus?> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await SendCommandAsync("GetStatus", null, cancellationToken);
|
||||||
|
return response?.Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> EnqueueAttendanceAsync(AttendanceRecord record, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Serialize(record);
|
||||||
|
var response = await SendCommandAsync("EnqueueAttendance", payload, cancellationToken);
|
||||||
|
return response?.Success == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AttendanceSyncResult?> TriggerSyncAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await SendCommandAsync("TriggerSync", null, cancellationToken);
|
||||||
|
if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload))
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<AttendanceSyncResult>(response.Payload);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IpcResponse?> SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var pipe = new NamedPipeClientStream(TerminalIpcConstants.PipeServerName, TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await pipe.ConnectAsync(timeoutCts.Token);
|
||||||
|
|
||||||
|
var state = await _store.LoadAsync();
|
||||||
|
var request = new IpcRequest
|
||||||
|
{
|
||||||
|
Command = command,
|
||||||
|
Payload = payload,
|
||||||
|
IssuedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
Nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16))
|
||||||
|
};
|
||||||
|
request.Signature = IpcRequestAuthenticator.Sign(request, state.Settings.IpcAuthenticationKey);
|
||||||
|
var json = JsonSerializer.Serialize(request);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
|
||||||
|
await pipe.WriteAsync(lengthPrefix, cancellationToken);
|
||||||
|
await pipe.WriteAsync(bytes, cancellationToken);
|
||||||
|
await pipe.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
|
var respLenBytes = new byte[4];
|
||||||
|
var read = await pipe.ReadAsync(respLenBytes, cancellationToken);
|
||||||
|
if (read < 4) return null;
|
||||||
|
var respLen = BitConverter.ToInt32(respLenBytes);
|
||||||
|
if (respLen is <= 0 or > 10 * 1024 * 1024) return null;
|
||||||
|
|
||||||
|
var respBytes = new byte[respLen];
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < respLen)
|
||||||
|
{
|
||||||
|
var r = await pipe.ReadAsync(respBytes.AsMemory(totalRead), cancellationToken);
|
||||||
|
if (r == 0) return null;
|
||||||
|
totalRead += r;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseJson = Encoding.UTF8.GetString(respBytes);
|
||||||
|
if (string.IsNullOrWhiteSpace(responseJson)) return null;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<IpcResponse>(responseJson);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Service not running or unreachable
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TerminalIpcServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Func<IpcRequest, Task<IpcResponse>> _handler;
|
||||||
|
private CancellationTokenSource? _cancellation;
|
||||||
|
private Task? _listenTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public TerminalIpcServer(Func<IpcRequest, Task<IpcResponse>> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_cancellation = new CancellationTokenSource();
|
||||||
|
_listenTask = Task.Run(() => ListenLoopAsync(_cancellation.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenLoopAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream? server = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
server = CreatePipeServer();
|
||||||
|
await server.WaitForConnectionAsync(token);
|
||||||
|
|
||||||
|
// Handle client in background task so server can accept next client
|
||||||
|
var clientStream = server;
|
||||||
|
_ = Task.Run(() => HandleClientAsync(clientStream, token), token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
server?.Dispose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"IPC server loop error: {ex.Message}");
|
||||||
|
server?.Dispose();
|
||||||
|
await Task.Delay(500, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken token)
|
||||||
|
{
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reqLenBytes = new byte[4];
|
||||||
|
var read = await stream.ReadAsync(reqLenBytes, token);
|
||||||
|
if (read < 4) return;
|
||||||
|
var reqLen = BitConverter.ToInt32(reqLenBytes);
|
||||||
|
if (reqLen is <= 0 or > 256 * 1024) return;
|
||||||
|
|
||||||
|
var reqBytes = new byte[reqLen];
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < reqLen)
|
||||||
|
{
|
||||||
|
var r = await stream.ReadAsync(reqBytes.AsMemory(totalRead), token);
|
||||||
|
if (r == 0) return;
|
||||||
|
totalRead += r;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = Encoding.UTF8.GetString(reqBytes);
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) return;
|
||||||
|
|
||||||
|
var request = JsonSerializer.Deserialize<IpcRequest>(line);
|
||||||
|
var response = request is not null
|
||||||
|
? await _handler(request)
|
||||||
|
: new IpcResponse { Success = false, ErrorMessage = "Invalid request" };
|
||||||
|
|
||||||
|
var responseJson = JsonSerializer.Serialize(response);
|
||||||
|
var responseBytes = Encoding.UTF8.GetBytes(responseJson);
|
||||||
|
var lengthPrefix = BitConverter.GetBytes(responseBytes.Length);
|
||||||
|
|
||||||
|
await stream.WriteAsync(lengthPrefix, token);
|
||||||
|
await stream.WriteAsync(responseBytes, token);
|
||||||
|
await stream.FlushAsync(token);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
CrashLogService.WriteDiagnostic($"IPC client handle error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedPipeServerStream CreatePipeServer()
|
||||||
|
{
|
||||||
|
var pipeSecurity = new PipeSecurity();
|
||||||
|
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||||
|
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||||
|
var interactiveSid = new SecurityIdentifier(WellKnownSidType.InteractiveSid, null);
|
||||||
|
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(interactiveSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||||
|
|
||||||
|
return NamedPipeServerStreamAcl.Create(
|
||||||
|
@"global\" + TerminalIpcConstants.PipeName,
|
||||||
|
PipeDirection.InOut,
|
||||||
|
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous,
|
||||||
|
inBufferSize: 4096,
|
||||||
|
outBufferSize: 4096,
|
||||||
|
pipeSecurity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_cancellation?.Cancel();
|
||||||
|
_cancellation?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class IpcRequestAuthenticator
|
||||||
|
{
|
||||||
|
public static string Sign(IpcRequest request, string key)
|
||||||
|
{
|
||||||
|
var canonical = $"{request.Command}\n{request.IssuedAtUnixSeconds}\n{request.Nonce}\n{request.Payload ?? string.Empty}";
|
||||||
|
using var hmac = new HMACSHA256(Convert.FromBase64String(key));
|
||||||
|
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(IpcRequest request, string key, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || request.Nonce.Length != 32) return false;
|
||||||
|
if (Math.Abs(now.ToUnixTimeSeconds() - request.IssuedAtUnixSeconds) > 60) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var actual = Convert.FromBase64String(request.Signature);
|
||||||
|
var expected = Convert.FromBase64String(Sign(request, key));
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.AccessControl;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Principal;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using KronaStaffTerminal.Models;
|
using KronaStaffTerminal.Models;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
@ -7,24 +10,48 @@ namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
public sealed class TerminalStore
|
public sealed class TerminalStore
|
||||||
{
|
{
|
||||||
|
public const string OperatorGroupName = "KronaStaffTerminalOperators";
|
||||||
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
|
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
private const string RegistryPath = @"Software\Krona\StaffTerminal";
|
private const string RegistryPath = @"Software\Krona\StaffTerminal";
|
||||||
private const string InitializedValueName = "StateInitialized";
|
private const string InitializedValueName = "StateInitialized";
|
||||||
private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12);
|
private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12);
|
||||||
private readonly SemaphoreSlim _saveGate = new(1, 1);
|
private readonly SemaphoreSlim _saveGate = new(1, 1);
|
||||||
private readonly string _filePath = Path.Combine(
|
private readonly string _filePath;
|
||||||
|
|
||||||
|
public static string MachineFilePath => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"Krona",
|
||||||
|
"StaffTerminal",
|
||||||
|
"terminal-state.dat");
|
||||||
|
|
||||||
|
public static string LegacyUserFilePath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"Krona",
|
"Krona",
|
||||||
"StaffTerminal",
|
"StaffTerminal",
|
||||||
"terminal-state.dat");
|
"terminal-state.dat");
|
||||||
|
|
||||||
|
public string FilePath => _filePath;
|
||||||
public string? RecoveryNotice { get; private set; }
|
public string? RecoveryNotice { get; private set; }
|
||||||
|
|
||||||
|
public TerminalStore(string? filePath = null)
|
||||||
|
{
|
||||||
|
_filePath = filePath ?? MachineFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TerminalState> LoadAsync()
|
public async Task<TerminalState> LoadAsync()
|
||||||
{
|
{
|
||||||
RecoveryNotice = null;
|
RecoveryNotice = null;
|
||||||
var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState();
|
var sourcePath = File.Exists(_filePath) ? _filePath : null;
|
||||||
|
if (sourcePath is null && File.Exists(LegacyUserFilePath))
|
||||||
|
{
|
||||||
|
sourcePath = LegacyUserFilePath;
|
||||||
|
}
|
||||||
|
if (sourcePath is null)
|
||||||
|
{
|
||||||
|
sourcePath = FindLegacyPackagedState();
|
||||||
|
}
|
||||||
|
|
||||||
if (sourcePath is null)
|
if (sourcePath is null)
|
||||||
{
|
{
|
||||||
var recovered = await TryRecoverFromBackupAsync();
|
var recovered = await TryRecoverFromBackupAsync();
|
||||||
|
|
@ -41,13 +68,17 @@ public sealed class TerminalStore
|
||||||
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
|
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TerminalState();
|
return await InitializeFreshStateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
|
||||||
|
}
|
||||||
var state = await ReadStateAsync(sourcePath);
|
var state = await ReadStateAsync(sourcePath);
|
||||||
var migrated = MigrateFingerprints(state);
|
var migrated = MigrateEmployees(state) | MigrateSecurity(state);
|
||||||
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await SaveAsync(state);
|
await SaveAsync(state);
|
||||||
|
|
@ -66,6 +97,7 @@ public sealed class TerminalStore
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Move(_filePath, corruptPath, false);
|
File.Move(_filePath, corruptPath, false);
|
||||||
|
ApplyRestrictedFileAcl(corruptPath);
|
||||||
}
|
}
|
||||||
catch (Exception moveException)
|
catch (Exception moveException)
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +140,7 @@ public sealed class TerminalStore
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var recovered = await ReadStateAsync(backupPath);
|
var recovered = await ReadStateAsync(backupPath);
|
||||||
MigrateFingerprints(recovered);
|
MigrateEmployees(recovered);
|
||||||
return recovered;
|
return recovered;
|
||||||
}
|
}
|
||||||
catch (Exception backupException) when (IsCorruptStateException(backupException))
|
catch (Exception backupException) when (IsCorruptStateException(backupException))
|
||||||
|
|
@ -125,7 +157,18 @@ public sealed class TerminalStore
|
||||||
private static async Task<TerminalState> ReadStateAsync(string path)
|
private static async Task<TerminalState> ReadStateAsync(string path)
|
||||||
{
|
{
|
||||||
var encrypted = await File.ReadAllBytesAsync(path);
|
var encrypted = await File.ReadAllBytesAsync(path);
|
||||||
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
byte[] clear;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Primary: LocalMachine (accessible by both Service and Terminal UI on this machine)
|
||||||
|
clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.LocalMachine);
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
// Legacy fallback: CurrentUser (if upgrading an older installation)
|
||||||
|
clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
}
|
||||||
|
|
||||||
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
|
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
|
||||||
?? throw new InvalidDataException("Файл состояния не содержит данных.");
|
?? throw new InvalidDataException("Файл состояния не содержит данных.");
|
||||||
if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null)
|
if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null)
|
||||||
|
|
@ -135,42 +178,89 @@ public sealed class TerminalStore
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MigrateFingerprints(TerminalState state)
|
private static bool MigrateEmployees(TerminalState state)
|
||||||
{
|
{
|
||||||
var changed = false;
|
var changed = false;
|
||||||
foreach (var employee in state.Employees)
|
foreach (var employee in state.Employees)
|
||||||
{
|
{
|
||||||
employee.Fingerprints ??= [];
|
employee.Fingerprints ??= [];
|
||||||
if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate))
|
if (employee.FingerprintId > 0 && !string.IsNullOrWhiteSpace(employee.FingerprintTemplate))
|
||||||
{
|
{
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId))
|
if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId))
|
||||||
{
|
{
|
||||||
employee.Fingerprints.Add(new FingerprintRecord
|
employee.Fingerprints.Add(new FingerprintRecord
|
||||||
{
|
{
|
||||||
Id = employee.FingerprintId,
|
Id = employee.FingerprintId,
|
||||||
Template = employee.FingerprintTemplate,
|
Template = employee.FingerprintTemplate,
|
||||||
Label = "Отпечаток 1"
|
Label = "Отпечаток 1",
|
||||||
|
EnrolledAtUtc = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
employee.FingerprintId = 0;
|
employee.FingerprintId = 0;
|
||||||
employee.FingerprintTemplate = null;
|
employee.FingerprintTemplate = null;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate FIO if empty and DisplayName is available
|
||||||
|
if (string.IsNullOrWhiteSpace(employee.LastName) && !string.IsNullOrWhiteSpace(employee.DisplayName))
|
||||||
|
{
|
||||||
|
var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length > 0) employee.LastName = parts[0];
|
||||||
|
if (parts.Length > 1) employee.FirstName = parts[1];
|
||||||
|
if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MigrateSecurity(TerminalState state)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false;
|
||||||
|
state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TerminalState> InitializeFreshStateAsync()
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_filePath)!;
|
||||||
|
EnsureDirectoryWithRestrictedAcl(directory);
|
||||||
|
var lockPath = _filePath + ".init.lock";
|
||||||
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
||||||
|
FileStream? initializationLock = null;
|
||||||
|
while (initializationLock is null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite,
|
||||||
|
FileShare.None, 1, FileOptions.WriteThrough);
|
||||||
|
}
|
||||||
|
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (initializationLock)
|
||||||
|
{
|
||||||
|
ApplyRestrictedFileAcl(lockPath);
|
||||||
|
if (File.Exists(_filePath)) return await LoadAsync();
|
||||||
|
var state = new TerminalState();
|
||||||
|
await SaveAsync(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
|
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
|
||||||
{
|
{
|
||||||
await _saveGate.WaitAsync();
|
await _saveGate.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(_filePath)!;
|
var directory = Path.GetDirectoryName(_filePath)!;
|
||||||
Directory.CreateDirectory(directory);
|
EnsureDirectoryWithRestrictedAcl(directory);
|
||||||
|
|
||||||
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
|
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
|
||||||
var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
|
var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.LocalMachine);
|
||||||
var temporaryPath = _filePath + ".tmp";
|
var temporaryPath = _filePath + ".tmp";
|
||||||
await using (var stream = new FileStream(
|
await using (var stream = new FileStream(
|
||||||
temporaryPath,
|
temporaryPath,
|
||||||
|
|
@ -192,6 +282,7 @@ public sealed class TerminalStore
|
||||||
File.Copy(_filePath, previousStatePath, true);
|
File.Copy(_filePath, previousStatePath, true);
|
||||||
}
|
}
|
||||||
File.Move(temporaryPath, _filePath, true);
|
File.Move(temporaryPath, _filePath, true);
|
||||||
|
ApplyRestrictedFileAcl(_filePath);
|
||||||
MarkInitialized();
|
MarkInitialized();
|
||||||
if (previousStatePath is not null)
|
if (previousStatePath is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -211,6 +302,64 @@ public sealed class TerminalStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
var identities = AllowedIdentities(directoryPath).ToArray();
|
||||||
|
var directorySecurity = new DirectorySecurity();
|
||||||
|
directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||||
|
foreach (var identity in identities)
|
||||||
|
{
|
||||||
|
directorySecurity.AddAccessRule(new FileSystemAccessRule(
|
||||||
|
identity,
|
||||||
|
FileSystemRights.FullControl,
|
||||||
|
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||||
|
PropagationFlags.None,
|
||||||
|
AccessControlType.Allow));
|
||||||
|
}
|
||||||
|
new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity);
|
||||||
|
foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*"))
|
||||||
|
ApplyRestrictedFileAcl(stateFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IdentityReference> AllowedIdentities(string? existingDirectory = null)
|
||||||
|
{
|
||||||
|
yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||||
|
yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||||
|
SecurityIdentifier? operatorSid = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName)
|
||||||
|
.Translate(typeof(SecurityIdentifier));
|
||||||
|
}
|
||||||
|
catch (IdentityNotMappedException) { }
|
||||||
|
if (operatorSid is not null) yield return operatorSid;
|
||||||
|
|
||||||
|
using var current = WindowsIdentity.GetCurrent();
|
||||||
|
if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid))
|
||||||
|
yield return current.User;
|
||||||
|
|
||||||
|
if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break;
|
||||||
|
var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access);
|
||||||
|
foreach (FileSystemAccessRule rule in existing.GetAccessRules(
|
||||||
|
includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)))
|
||||||
|
{
|
||||||
|
if (rule.AccessControlType != AccessControlType.Allow ||
|
||||||
|
rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue;
|
||||||
|
yield return sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyRestrictedFileAcl(string path)
|
||||||
|
{
|
||||||
|
var security = new FileSecurity();
|
||||||
|
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||||
|
foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path)))
|
||||||
|
security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl,
|
||||||
|
AccessControlType.Allow));
|
||||||
|
new FileInfo(path).SetAccessControl(security);
|
||||||
|
}
|
||||||
|
|
||||||
private bool ShouldCreateRecoveryPoint(bool requested)
|
private bool ShouldCreateRecoveryPoint(bool requested)
|
||||||
{
|
{
|
||||||
var latestBackup = BackupPaths().First();
|
var latestBackup = BackupPaths().First();
|
||||||
|
|
@ -244,12 +393,25 @@ public sealed class TerminalStore
|
||||||
|
|
||||||
private static bool WasPreviouslyInitialized()
|
private static bool WasPreviouslyInitialized()
|
||||||
{
|
{
|
||||||
using var key = Registry.CurrentUser.OpenSubKey(RegistryPath);
|
using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath);
|
||||||
return key?.GetValue(InitializedValueName) is int value && value == 1;
|
if (lmKey?.GetValue(InitializedValueName) is int lmValue && lmValue == 1) return true;
|
||||||
|
|
||||||
|
using var cuKey = Registry.CurrentUser.OpenSubKey(RegistryPath);
|
||||||
|
return cuKey?.GetValue(InitializedValueName) is int cuValue && cuValue == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MarkInitialized()
|
private static void MarkInitialized()
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var lmKey = Registry.LocalMachine.CreateSubKey(RegistryPath, writable: true);
|
||||||
|
lmKey?.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Best effort for non-elevated sessions
|
||||||
|
}
|
||||||
|
|
||||||
using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true)
|
using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true)
|
||||||
?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала.");
|
?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала.");
|
||||||
key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);
|
key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace KronaStaffTerminal.Services;
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public enum TrayState
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
NoScanner,
|
||||||
|
NoServer,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TrayService : IDisposable
|
public sealed class TrayService : IDisposable
|
||||||
{
|
{
|
||||||
private const uint CallbackMessage = 0x8001;
|
private const uint CallbackMessage = 0x8001;
|
||||||
|
|
@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable
|
||||||
private const uint LrDefaultSize = 0x0040;
|
private const uint LrDefaultSize = 0x0040;
|
||||||
private const uint OpenCommand = 1001;
|
private const uint OpenCommand = 1001;
|
||||||
private const uint ExitCommand = 1002;
|
private const uint ExitCommand = 1002;
|
||||||
|
private const uint StatusCommand = 1003;
|
||||||
|
private const uint SyncCommand = 1004;
|
||||||
|
|
||||||
private readonly Action _showWindow;
|
private readonly Action _showWindow;
|
||||||
private readonly Action _requestExit;
|
private readonly Action _requestExit;
|
||||||
|
private readonly Action? _showStatus;
|
||||||
|
private readonly Action? _requestSync;
|
||||||
private readonly WindowProcedure _windowProcedure;
|
private readonly WindowProcedure _windowProcedure;
|
||||||
private readonly IntPtr _windowHandle;
|
private readonly IntPtr _windowHandle;
|
||||||
private readonly IntPtr _iconHandle;
|
private readonly IntPtr _iconHandle;
|
||||||
private NotifyIconData _iconData;
|
private NotifyIconData _iconData;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public TrayService(Action showWindow, Action requestExit)
|
private TrayState _currentState = TrayState.Normal;
|
||||||
|
private int _pendingSyncCount;
|
||||||
|
private string _currentStatusText = "Работает в штатном режиме";
|
||||||
|
|
||||||
|
public TrayService(
|
||||||
|
Action showWindow,
|
||||||
|
Action requestExit,
|
||||||
|
Action? showStatus = null,
|
||||||
|
Action? requestSync = null)
|
||||||
{
|
{
|
||||||
_showWindow = showWindow;
|
_showWindow = showWindow;
|
||||||
_requestExit = requestExit;
|
_requestExit = requestExit;
|
||||||
|
_showStatus = showStatus;
|
||||||
|
_requestSync = requestSync;
|
||||||
_windowProcedure = HandleWindowMessage;
|
_windowProcedure = HandleWindowMessage;
|
||||||
|
|
||||||
var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
|
var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
|
||||||
var windowClass = new WindowClass
|
var windowClass = new WindowClass
|
||||||
{
|
{
|
||||||
|
|
@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable
|
||||||
ShellNotifyIcon(NimAdd, ref _iconData);
|
ShellNotifyIcon(NimAdd, ref _iconData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdateState(TrayState state, int pendingSyncCount, string? statusText = null)
|
||||||
|
{
|
||||||
|
_currentState = state;
|
||||||
|
_pendingSyncCount = pendingSyncCount;
|
||||||
|
if (!string.IsNullOrWhiteSpace(statusText))
|
||||||
|
{
|
||||||
|
_currentStatusText = statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateLabel = state switch
|
||||||
|
{
|
||||||
|
TrayState.Normal => "Работает",
|
||||||
|
TrayState.NoScanner => "Сканер отключён",
|
||||||
|
TrayState.NoServer => "Сервер недоступен",
|
||||||
|
TrayState.Error => "Сбой",
|
||||||
|
_ => "В работе"
|
||||||
|
};
|
||||||
|
|
||||||
|
var tip = $"Krona Staff — {stateLabel} ({pendingSyncCount} в очереди)";
|
||||||
|
if (tip.Length > 127) tip = tip[..127];
|
||||||
|
|
||||||
|
_iconData.Flags = NifTip | NifIcon;
|
||||||
|
_iconData.Tip = tip;
|
||||||
|
ShellNotifyIcon(NimModify, ref _iconData);
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowRunningBalloon()
|
public void ShowRunningBalloon()
|
||||||
|
{
|
||||||
|
ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1)
|
||||||
{
|
{
|
||||||
_iconData.Flags = NifInfo;
|
_iconData.Flags = NifInfo;
|
||||||
_iconData.InfoTitle = "Krona Staff";
|
_iconData.InfoTitle = title.Length > 63 ? title[..63] : title;
|
||||||
_iconData.Info = "Терминал продолжает работать в трее.";
|
_iconData.Info = message.Length > 255 ? message[..255] : message;
|
||||||
_iconData.InfoFlags = 1;
|
_iconData.InfoFlags = infoFlags;
|
||||||
ShellNotifyIcon(NimModify, ref _iconData);
|
ShellNotifyIcon(NimModify, ref _iconData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable
|
||||||
{
|
{
|
||||||
var menu = CreatePopupMenu();
|
var menu = CreatePopupMenu();
|
||||||
AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff");
|
AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff");
|
||||||
|
|
||||||
|
var statusSummary = _currentState switch
|
||||||
|
{
|
||||||
|
TrayState.Normal => "Состояние: Работает",
|
||||||
|
TrayState.NoScanner => "Состояние: Сканер отключён",
|
||||||
|
TrayState.NoServer => "Состояние: Нет связи с сервером",
|
||||||
|
TrayState.Error => "Состояние: Ошибка",
|
||||||
|
_ => $"Состояние: {_currentStatusText}"
|
||||||
|
};
|
||||||
|
AppendMenu(menu, MfString, StatusCommand, statusSummary);
|
||||||
|
|
||||||
|
var syncSummary = _pendingSyncCount > 0
|
||||||
|
? $"Синхронизация ({_pendingSyncCount} в очереди)"
|
||||||
|
: "Синхронизация (очередь пуста)";
|
||||||
|
AppendMenu(menu, MfString, SyncCommand, syncSummary);
|
||||||
|
|
||||||
AppendMenu(menu, MfSeparator, 0, null);
|
AppendMenu(menu, MfSeparator, 0, null);
|
||||||
AppendMenu(menu, MfString, ExitCommand, "Выход администратора…");
|
AppendMenu(menu, MfString, ExitCommand, "Выход администратора…");
|
||||||
|
|
||||||
GetCursorPos(out var point);
|
GetCursorPos(out var point);
|
||||||
SetForegroundWindow(_windowHandle);
|
SetForegroundWindow(_windowHandle);
|
||||||
var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero);
|
var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
|
|
||||||
if (command == OpenCommand) Dispatch(_showWindow);
|
if (command == OpenCommand) Dispatch(_showWindow);
|
||||||
|
if (command == StatusCommand)
|
||||||
|
{
|
||||||
|
if (_showStatus is not null)
|
||||||
|
{
|
||||||
|
Dispatch(_showStatus);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowNotificationBalloon("Состояние терминала", _currentStatusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command == SyncCommand)
|
||||||
|
{
|
||||||
|
if (_requestSync is not null)
|
||||||
|
{
|
||||||
|
Dispatch(_requestSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (command == ExitCommand) Dispatch(_requestExit);
|
if (command == ExitCommand) Dispatch(_requestExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using KronaStaffTerminal.Models;
|
using KronaStaffTerminal.Models;
|
||||||
|
|
@ -39,13 +41,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
|
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
|
||||||
feedUri.Scheme != Uri.UriSchemeHttps)
|
!IsSecureUpdateUri(feedUri))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
|
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken);
|
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken, feedUri);
|
||||||
using var response = await HttpClient.SendAsync(
|
using var response = await HttpClient.SendAsync(
|
||||||
request,
|
request,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
|
@ -79,7 +81,10 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
checksum is null ||
|
checksum is null ||
|
||||||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
|
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
|
||||||
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
|
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
|
||||||
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage))
|
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage) ||
|
||||||
|
!IsSameUpdateOrigin(feedUri, installerUri) ||
|
||||||
|
!IsSameUpdateOrigin(feedUri, checksumUri) ||
|
||||||
|
!IsSameUpdateOrigin(feedUri, releasePage))
|
||||||
{
|
{
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(
|
||||||
"В релизе нет установщика Windows или файла контрольной суммы.");
|
"В релизе нет установщика Windows или файла контрольной суммы.");
|
||||||
|
|
@ -104,6 +109,12 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
IProgress<double>? progress,
|
IProgress<double>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
|
||||||
|
!IsSecureUpdateUri(feedUri))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
|
||||||
|
}
|
||||||
|
|
||||||
var updateDirectory = Path.Combine(
|
var updateDirectory = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"Krona",
|
"Krona",
|
||||||
|
|
@ -119,7 +130,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
using var checksumRequest = CreateRequest(
|
using var checksumRequest = CreateRequest(
|
||||||
HttpMethod.Get,
|
HttpMethod.Get,
|
||||||
update.ChecksumUrl,
|
update.ChecksumUrl,
|
||||||
settings.UpdateAccessToken);
|
settings.UpdateAccessToken,
|
||||||
|
feedUri);
|
||||||
using var checksumResponse = await HttpClient.SendAsync(
|
using var checksumResponse = await HttpClient.SendAsync(
|
||||||
checksumRequest,
|
checksumRequest,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
@ -139,7 +151,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
using var installerRequest = CreateRequest(
|
using var installerRequest = CreateRequest(
|
||||||
HttpMethod.Get,
|
HttpMethod.Get,
|
||||||
update.InstallerUrl,
|
update.InstallerUrl,
|
||||||
settings.UpdateAccessToken);
|
settings.UpdateAccessToken,
|
||||||
|
feedUri);
|
||||||
using var installerResponse = await HttpClient.SendAsync(
|
using var installerResponse = await HttpClient.SendAsync(
|
||||||
installerRequest,
|
installerRequest,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
|
@ -182,6 +195,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
"Контрольная сумма обновления не совпала. Установка отменена.");
|
"Контрольная сумма обновления не совпала. Установка отменена.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VerifyInstallerPublisher(temporaryPath, settings.UpdateTrustedSignerThumbprint);
|
||||||
|
|
||||||
File.Move(temporaryPath, installerPath, true);
|
File.Move(temporaryPath, installerPath, true);
|
||||||
return installerPath;
|
return installerPath;
|
||||||
}
|
}
|
||||||
|
|
@ -199,8 +214,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
private static HttpRequestMessage CreateRequest(
|
private static HttpRequestMessage CreateRequest(
|
||||||
HttpMethod method,
|
HttpMethod method,
|
||||||
Uri uri,
|
Uri uri,
|
||||||
string token)
|
string token,
|
||||||
|
Uri trustedOrigin)
|
||||||
{
|
{
|
||||||
|
if (!IsSameUpdateOrigin(trustedOrigin, uri))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Forgejo вернул файл обновления с недоверенного адреса.");
|
||||||
|
}
|
||||||
var request = new HttpRequestMessage(method, uri);
|
var request = new HttpRequestMessage(method, uri);
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
|
|
@ -212,11 +232,58 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
|
|
||||||
private static HttpClient CreateHttpClient()
|
private static HttpClient CreateHttpClient()
|
||||||
{
|
{
|
||||||
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromMinutes(10)
|
||||||
|
};
|
||||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool IsSameUpdateOrigin(Uri trustedOrigin, Uri candidate) =>
|
||||||
|
IsSecureUpdateUri(trustedOrigin) &&
|
||||||
|
IsSecureUpdateUri(candidate) &&
|
||||||
|
string.Equals(trustedOrigin.IdnHost, candidate.IdnHost, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
trustedOrigin.Port == candidate.Port;
|
||||||
|
|
||||||
|
private static bool IsSecureUpdateUri(Uri uri) =>
|
||||||
|
uri.Scheme == Uri.UriSchemeHttps &&
|
||||||
|
string.IsNullOrEmpty(uri.UserInfo) &&
|
||||||
|
!string.IsNullOrWhiteSpace(uri.IdnHost);
|
||||||
|
|
||||||
|
private static void VerifyInstallerPublisher(string installerPath, string configuredThumbprint)
|
||||||
|
{
|
||||||
|
var expectedThumbprint = NormalizeThumbprint(configuredThumbprint);
|
||||||
|
if (expectedThumbprint.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Автоматическая установка заблокирована: не настроен отпечаток сертификата издателя обновлений.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AuthenticodeTrust.IsTrusted(installerPath))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"Цифровая подпись установщика отсутствует, повреждена или не доверена Windows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable SYSLIB0057
|
||||||
|
using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath));
|
||||||
|
#pragma warning restore SYSLIB0057
|
||||||
|
var actualThumbprint = NormalizeThumbprint(certificate.Thumbprint ?? "");
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(
|
||||||
|
Convert.FromHexString(expectedThumbprint),
|
||||||
|
Convert.FromHexString(actualThumbprint)))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Установщик подписан неизвестным издателем. Установка отменена.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeThumbprint(string value)
|
||||||
|
{
|
||||||
|
var normalized = new string(value.Where(Uri.IsHexDigit).ToArray()).ToUpperInvariant();
|
||||||
|
return normalized.Length is 40 or 64 ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryParseVersion(string? value, out Version version)
|
private static bool TryParseVersion(string? value, out Version version)
|
||||||
{
|
{
|
||||||
var normalized = (value ?? "")
|
var normalized = (value ?? "")
|
||||||
|
|
@ -258,4 +325,66 @@ public sealed class ForgejoUpdateSource : IUpdateSource
|
||||||
[JsonPropertyName("browser_download_url")]
|
[JsonPropertyName("browser_download_url")]
|
||||||
public string BrowserDownloadUrl { get; set; } = "";
|
public string BrowserDownloadUrl { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class AuthenticodeTrust
|
||||||
|
{
|
||||||
|
private static readonly Guid VerifyAction = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
|
||||||
|
|
||||||
|
public static bool IsTrusted(string path)
|
||||||
|
{
|
||||||
|
using var fileInfo = new WinTrustFileInfo(path);
|
||||||
|
var fileInfoPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustFileInfo>());
|
||||||
|
var trustDataPointer = IntPtr.Zero;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.StructureToPtr(fileInfo, fileInfoPointer, false);
|
||||||
|
var trustData = new WinTrustData(fileInfoPointer);
|
||||||
|
trustDataPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustData>());
|
||||||
|
Marshal.StructureToPtr(trustData, trustDataPointer, false);
|
||||||
|
return WinVerifyTrust(new IntPtr(-1), VerifyAction, trustDataPointer) == 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (trustDataPointer != IntPtr.Zero) Marshal.FreeHGlobal(trustDataPointer);
|
||||||
|
Marshal.FreeHGlobal(fileInfoPointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int WinVerifyTrust(
|
||||||
|
IntPtr windowHandle,
|
||||||
|
[MarshalAs(UnmanagedType.LPStruct)] Guid actionId,
|
||||||
|
IntPtr trustData);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private sealed class WinTrustFileInfo : IDisposable
|
||||||
|
{
|
||||||
|
public uint StructSize = (uint)Marshal.SizeOf<WinTrustFileInfo>();
|
||||||
|
public IntPtr FilePath;
|
||||||
|
public IntPtr FileHandle = IntPtr.Zero;
|
||||||
|
public IntPtr KnownSubject = IntPtr.Zero;
|
||||||
|
|
||||||
|
public WinTrustFileInfo(string path) => FilePath = Marshal.StringToCoTaskMemUni(path);
|
||||||
|
public void Dispose() => Marshal.FreeCoTaskMem(FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private sealed class WinTrustData
|
||||||
|
{
|
||||||
|
public uint StructSize = (uint)Marshal.SizeOf<WinTrustData>();
|
||||||
|
public IntPtr PolicyCallbackData = IntPtr.Zero;
|
||||||
|
public IntPtr SipClientData = IntPtr.Zero;
|
||||||
|
public uint UiChoice = 2;
|
||||||
|
public uint RevocationChecks = 1;
|
||||||
|
public uint UnionChoice = 1;
|
||||||
|
public IntPtr FileInfo;
|
||||||
|
public uint StateAction = 0;
|
||||||
|
public IntPtr StateData = IntPtr.Zero;
|
||||||
|
public IntPtr UrlReference = IntPtr.Zero;
|
||||||
|
public uint ProviderFlags = 0;
|
||||||
|
public uint UiContext = 0;
|
||||||
|
|
||||||
|
public WinTrustData(IntPtr fileInfo) => FileInfo = fileInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@ services:
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-development}
|
NODE_ENV: ${NODE_ENV:-development}
|
||||||
PORT: ${PORT:-8080}
|
PORT: ${PORT:-8080}
|
||||||
|
HOST: 0.0.0.0
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone}
|
STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone}
|
||||||
|
STANDALONE_ACCESS: ${STANDALONE_ACCESS:-employee}
|
||||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff
|
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff
|
||||||
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
|
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
|
||||||
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
||||||
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
||||||
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
||||||
ports: ['8080:8080']
|
ports: ['8080:8080']
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
7
deploy/pilot/backup.env.example
Normal file
7
deploy/pilot/backup.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# The certificate contains only a public key. Keep its private key off the server.
|
||||||
|
BACKUP_DIRECTORY=/var/backups/krona-staff
|
||||||
|
BACKUP_RECIPIENT_CERT=/etc/krona-staff/backup-recipient.crt
|
||||||
|
# This-PC backups are collected through scripts/pull-pilot-backups.sh.
|
||||||
|
BACKUP_TRANSFER_MODE=external-pull
|
||||||
|
# Needed only for BACKUP_TRANSFER_MODE=push. For this-PC storage use pull script.
|
||||||
|
BACKUP_REMOTE=
|
||||||
4
deploy/pilot/backup.home-server.example
Normal file
4
deploy/pilot/backup.home-server.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Test pilot on the owner's home server; no private key is stored here.
|
||||||
|
BACKUP_DIRECTORY=/home/ochenstarik/krona-staff-pilot/backups
|
||||||
|
BACKUP_RECIPIENT_CERT=/home/ochenstarik/krona-staff-pilot/deploy/pilot/secrets/backup-recipient.crt
|
||||||
|
BACKUP_TRANSFER_MODE=external-pull
|
||||||
53
deploy/pilot/backup.sh
Normal file
53
deploy/pilot/backup.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# Encrypt before writing to disk; keep the private recipient key off this host.
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
: "${BACKUP_DIRECTORY:?Set a dedicated absolute backup directory}"
|
||||||
|
: "${BACKUP_RECIPIENT_CERT:?Set the public recipient certificate path}"
|
||||||
|
BACKUP_TRANSFER_MODE="${BACKUP_TRANSFER_MODE:-push}"
|
||||||
|
case "$BACKUP_DIRECTORY" in /*) ;; *) echo 'Backup directory must be absolute' >&2; exit 1 ;; esac
|
||||||
|
BACKUP_DIRECTORY=$(realpath -m -- "$BACKUP_DIRECTORY")
|
||||||
|
case "$BACKUP_DIRECTORY" in /|/home|/tmp|/var|/usr|/etc|/opt|/srv|/root|/mnt|/media) echo 'Use a dedicated backup directory' >&2; exit 1 ;; esac
|
||||||
|
case "$BACKUP_TRANSFER_MODE" in
|
||||||
|
push)
|
||||||
|
: "${BACKUP_REMOTE:?Set an off-host scp destination, for example backup@host:/backups/staff/}"
|
||||||
|
case "$BACKUP_REMOTE" in -*|*\ *|*\;*|*\`*|*\$*) echo 'Invalid backup destination' >&2; exit 1 ;; esac
|
||||||
|
case "$BACKUP_REMOTE" in *:/*/) ;; *) echo 'Off-host destination must be host:/absolute/directory/' >&2; exit 1 ;; esac
|
||||||
|
;;
|
||||||
|
external-pull) ;;
|
||||||
|
*) echo 'BACKUP_TRANSFER_MODE must be push or external-pull' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
test -r "$BACKUP_RECIPIENT_CERT"
|
||||||
|
openssl x509 -in "$BACKUP_RECIPIENT_CERT" -noout -checkend 0 >/dev/null
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p "$BACKUP_DIRECTORY"
|
||||||
|
stamp=$(date -u '+%Y%m%dT%H%M%SZ')
|
||||||
|
name="krona-staff-$stamp-$$.dump.cms"
|
||||||
|
output="$BACKUP_DIRECTORY/$name"
|
||||||
|
scratch=$(mktemp -d "$BACKUP_DIRECTORY/.backup-XXXXXX")
|
||||||
|
producer=''
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$producer" ]; then kill "$producer" 2>/dev/null || true; fi
|
||||||
|
rm -f -- "$scratch/dump.pipe" "$scratch/encrypted.partial"
|
||||||
|
rmdir -- "$scratch"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
mkfifo "$scratch/dump.pipe"
|
||||||
|
docker compose exec -T postgres sh -eu -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$scratch/dump.pipe" &
|
||||||
|
producer=$!
|
||||||
|
openssl cms -encrypt -binary -aes-256-cbc -outform DER -stream \
|
||||||
|
-in "$scratch/dump.pipe" -out "$scratch/encrypted.partial" "$BACKUP_RECIPIENT_CERT"
|
||||||
|
wait "$producer"
|
||||||
|
producer=''
|
||||||
|
test -s "$scratch/encrypted.partial"
|
||||||
|
mv -- "$scratch/encrypted.partial" "$output"
|
||||||
|
(cd "$BACKUP_DIRECTORY" && sha256sum "$name" > "$name.sha256")
|
||||||
|
if [ "$BACKUP_TRANSFER_MODE" = push ]; then
|
||||||
|
scp -B "$output" "$output.sha256" "$BACKUP_REMOTE"
|
||||||
|
copy_status=completed
|
||||||
|
else
|
||||||
|
copy_status=pending_pull
|
||||||
|
fi
|
||||||
|
printf 'backup=%s\nchecksum=%s\noff_host_copy=%s\n' "$output" "$output.sha256" "$copy_status"
|
||||||
|
# This is an encrypted backup, not evidence of a successful restore drill.
|
||||||
85
deploy/pilot/compose.yaml
Normal file
85
deploy/pilot/compose.yaml
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
name: krona-staff-pilot
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
environment:
|
||||||
|
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_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 is required}
|
||||||
|
ports:
|
||||||
|
- '192.168.1.81:8453:8080'
|
||||||
|
volumes:
|
||||||
|
- ./secrets/staff.pfx:/run/secrets/staff.pfx:ro
|
||||||
|
user: '1000:1000'
|
||||||
|
depends_on:
|
||||||
|
migrate: { condition: service_completed_successfully }
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs: [/tmp]
|
||||||
|
security_opt: ['no-new-privileges:true']
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1',
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
db-role:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
target: build
|
||||||
|
environment:
|
||||||
|
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 }
|
||||||
|
restart: 'no'
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
target: build
|
||||||
|
environment:
|
||||||
|
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 }
|
||||||
|
restart: 'no'
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: krona_staff
|
||||||
|
POSTGRES_USER: krona_migrator
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
11
deploy/pilot/krona-staff-backup-user.service
Normal file
11
deploy/pilot/krona-staff-backup-user.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Encrypted Krona Staff PostgreSQL backup (user service)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=%h/krona-staff-pilot/deploy/pilot
|
||||||
|
EnvironmentFile=%h/krona-staff-pilot/deploy/pilot/backup.env
|
||||||
|
ExecStart=/bin/sh %h/krona-staff-pilot/deploy/pilot/backup.sh
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
13
deploy/pilot/krona-staff-backup.service
Normal file
13
deploy/pilot/krona-staff-backup.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Encrypted Krona Staff PostgreSQL backup
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/krona-staff/deploy/pilot
|
||||||
|
EnvironmentFile=/etc/krona-staff/backup.env
|
||||||
|
ExecStart=/bin/sh /opt/krona-staff/deploy/pilot/backup.sh
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
10
deploy/pilot/krona-staff-backup.timer
Normal file
10
deploy/pilot/krona-staff-backup.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Daily Krona Staff encrypted backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
RandomizedDelaySec=600
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
69
deploy/pilot/setup.sh
Normal file
69
deploy/pilot/setup.sh
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
umask 077
|
||||||
|
mkdir -p secrets
|
||||||
|
|
||||||
|
validate_tenant() {
|
||||||
|
: "${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID before setup}"
|
||||||
|
if ! printf '%s\n' "$STANDALONE_TENANT_ID" | LC_ALL=C grep -Eq '^[0-9A-HJKMNP-TV-Z]{26}$'; then
|
||||||
|
echo 'STANDALONE_TENANT_ID must be a 26-character ULID' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$STANDALONE_TENANT_ID" = '01J00000000000000000000000' ]; then
|
||||||
|
echo 'The development placeholder cannot be used for a pilot organization' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
validate_tenant
|
||||||
|
migration_password="$(openssl rand -hex 32)"
|
||||||
|
app_password="$(openssl rand -hex 32)"
|
||||||
|
manager_token="$(openssl rand -hex 32)"
|
||||||
|
terminal_token="$(openssl rand -hex 32)"
|
||||||
|
pfx_passphrase="$(openssl rand -hex 32)"
|
||||||
|
{
|
||||||
|
printf 'POSTGRES_MIGRATION_PASSWORD=%s\n' "$migration_password"
|
||||||
|
printf 'POSTGRES_APP_PASSWORD=%s\n' "$app_password"
|
||||||
|
printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token"
|
||||||
|
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
|
||||||
|
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
|
||||||
|
printf 'STANDALONE_TENANT_ID=%s\n' "$STANDALONE_TENANT_ID"
|
||||||
|
printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n'
|
||||||
|
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
|
||||||
|
} > .env
|
||||||
|
printf '%s\n' "$manager_token" > secrets/manager.token
|
||||||
|
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
||||||
|
fi
|
||||||
|
|
||||||
|
. ./.env
|
||||||
|
validate_tenant
|
||||||
|
|
||||||
|
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(cd ../.. && pwd):/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 \
|
||||||
|
node scripts/generate-vapid-keys.mjs >> .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
. ./.env
|
||||||
|
validate_tenant
|
||||||
|
if [ ! -f secrets/staff.pfx ]; then
|
||||||
|
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
||||||
|
-keyout secrets/staff.key -out secrets/staff.crt \
|
||||||
|
-subj '/CN=192.168.1.81' \
|
||||||
|
-addext 'subjectAltName=IP:192.168.1.81' \
|
||||||
|
-addext 'keyUsage=digitalSignature,keyEncipherment' \
|
||||||
|
-addext 'extendedKeyUsage=serverAuth'
|
||||||
|
openssl pkcs12 -export -out secrets/staff.pfx \
|
||||||
|
-inkey secrets/staff.key -in secrets/staff.crt \
|
||||||
|
-passout "pass:${HTTPS_PFX_PASSPHRASE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl x509 -in secrets/staff.crt -outform DER | sha256sum | cut -d ' ' -f 1 > secrets/certificate.sha256
|
||||||
|
chmod 600 .env secrets/*
|
||||||
|
docker compose --env-file .env config >/dev/null
|
||||||
|
docker compose --env-file .env up -d --build
|
||||||
19
deploy/pilot/smoke-test.sh
Normal file
19
deploy/pilot/smoke-test.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
terminal_token="$(cat secrets/terminal.token)"
|
||||||
|
manager_token="$(cat secrets/manager.token)"
|
||||||
|
occurred_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
payload="$(printf '{"event_id":"01J00000000000000000000010","employee_id":"01J00000000000000000000020","event_type":"check_in","occurred_at":"%s","source_event_id":"pilot-check-1","device_id":"01J00000000000000000000030"}' "$occurred_at")"
|
||||||
|
|
||||||
|
curl -ksS \
|
||||||
|
-H "Authorization: Bearer $terminal_token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
https://192.168.1.81:8453/api/v1/staff/attendance/events
|
||||||
|
printf '\n'
|
||||||
|
curl -ksS \
|
||||||
|
-H "Authorization: Bearer $manager_token" \
|
||||||
|
https://192.168.1.81:8453/api/v1/staff/portal
|
||||||
|
printf '\n'
|
||||||
116
docs/APP-CORE.md
Normal file
116
docs/APP-CORE.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Ядро мобильного приложения Krona Business Platform
|
||||||
|
|
||||||
|
Документ для команды, которая делает приложение (в том числе для Codex). Описывает каркас, в который модули платформы добавляют свои экраны. Staff — первый модуль; касса, финансы и остальное подключаются тем же способом.
|
||||||
|
|
||||||
|
Правило номер один: **приложение не считает деньги**. Все суммы, смены и остатки приходят с сервера уже рассчитанными, вместе с объяснением, из чего они сложились. Клиент их только показывает. Это условие проверяемости расчёта и требование [ADR-002](adr/ADR-002-payroll-domain-and-platform-alignment.md).
|
||||||
|
|
||||||
|
## 1. Стек
|
||||||
|
|
||||||
|
| Что | Решение | Почему |
|
||||||
|
| --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Каркас | React Native + Expo (managed), TypeScript strict | тот же язык, что и модули платформы; типы API переиспользуются, сборка Android и iOS без Mac через EAS |
|
||||||
|
| Навигация | Expo Router | файловая маршрутизация, удобна для подключения экранов модулями |
|
||||||
|
| Серверное состояние | TanStack Query + persist | кэш, повторы, офлайн-чтение |
|
||||||
|
| Локальное состояние | Zustand | как во фронтенде платформы |
|
||||||
|
| Формы и валидация | React Hook Form + Zod | Zod уже используется в модулях |
|
||||||
|
| Секреты на устройстве | expo-secure-store | refresh-токен и PIN-хеш только здесь |
|
||||||
|
| Биометрия | expo-local-authentication | разблокировка, не аутентификация на сервере |
|
||||||
|
| Уведомления | expo-notifications | push; пока Core не отдаёт push, канал Telegram остаётся запасным |
|
||||||
|
| Тесты | Vitest + React Native Testing Library, Maestro для сценариев | — |
|
||||||
|
|
||||||
|
Совместимость: Android 9+, iOS 15+. Публикация: RuStore и Google Play, TestFlight/App Store — по решению владельца.
|
||||||
|
|
||||||
|
## 2. Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ экраны Expo Router: (auth), (tabs), modules/<module>/…
|
||||||
|
src/core/
|
||||||
|
api/ http-клиент, конверт ответа, ошибки, Idempotency-Key
|
||||||
|
auth/ сессия, refresh, PIN, биометрия, приглашения
|
||||||
|
modules/ реестр модулей: вкладки, экраны, права
|
||||||
|
ui/ дизайн-система: токены, компоненты, типографика
|
||||||
|
i18n/ ru по умолчанию
|
||||||
|
telemetry/ логи без персональных данных
|
||||||
|
src/modules/staff/ экраны и запросы модуля Staff
|
||||||
|
```
|
||||||
|
|
||||||
|
Модуль регистрируется в реестре декларативно и не правит ядро:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
registerModule({
|
||||||
|
id: 'ru.krona.staff',
|
||||||
|
title: 'Работа',
|
||||||
|
requiredPermissions: ['staff.payslip.read_own'],
|
||||||
|
tabs: [{ name: 'today', icon: 'clock', screen: TodayScreen }],
|
||||||
|
routes: [{ path: 'staff/payroll/:runId', screen: PayrollRunScreen }],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Ядро показывает вкладку, только если у пользователя есть право и модуль включён у арендатора. Это зеркалит слоты платформы (`34-MODULE-SYSTEM`), чтобы приложение не расходилось с вебом.
|
||||||
|
|
||||||
|
## 3. Аутентификация
|
||||||
|
|
||||||
|
Учётными записями владеет ядро платформы, приложение не заводит своих пользователей и не хранит пароли.
|
||||||
|
|
||||||
|
**Приглашение (решение владельца).** Администратор создаёт учётку сотрудника и отправляет приглашение одним из трёх способов: письмо на почту, ссылка в мессенджер, QR-код на экране. Все три ведут на один одноразовый токен приглашения.
|
||||||
|
|
||||||
|
**Первый вход:**
|
||||||
|
|
||||||
|
1. переход по приглашению (или сканирование QR в приложении);
|
||||||
|
2. сотрудник задаёт пароль — уходит в Core, на устройстве не остаётся;
|
||||||
|
3. сотрудник задаёт PIN из 6 цифр — хранится только как хеш в secure-store, сервер о нём не знает;
|
||||||
|
4. если на телефоне есть биометрия, предлагается включить её вместо PIN.
|
||||||
|
|
||||||
|
**Дальнейшие входы:** PIN или биометрия разблокируют refresh-токен в secure-store, ядро меняет его на access-токен. Пароль нужен только при смене устройства и после отзыва сессии.
|
||||||
|
|
||||||
|
**Токены:** access-JWT живёт 15 минут и хранится только в памяти; refresh — непрозрачный, в secure-store, с привязкой к Device ID. Три неверных PIN подряд — пауза, десять — сброс локальной сессии и вход по паролю.
|
||||||
|
|
||||||
|
**Чего сегодня не хватает в ядре платформы** (проверено по коду `ochenstarik/business-platform`): поток приглашения реализован как каркас, восстановление пароля отсутствует, права модулей нельзя выдать роли. До их готовности модуль Staff в режиме `standalone` выдаёт собственные токены для пилота — это временный режим, который выключается переключением на `platform`. Собственную модель пользователей в приложении заводить нельзя ни при каких условиях.
|
||||||
|
|
||||||
|
## 4. Работа с API
|
||||||
|
|
||||||
|
- База: `https://<host>/api/v1/<module>`. Для Staff — `/api/v1/staff`.
|
||||||
|
- Заголовки каждого запроса: `Authorization: Bearer <access>`, `X-Tenant-ID`, `X-Request-Id` (ULID, генерирует клиент).
|
||||||
|
- Успешный ответ: `{"data": …, "meta": …}`. Ошибка: `{"errors": [{"code": "…", "message": "…", "field": "…"}], "meta": {"request_id": "…"}}`. Показывать пользователю `message`, в лог писать `code` и `request_id`.
|
||||||
|
- Любая мутация несёт `Idempotency-Key` (UUID v4, генерирует клиент и сохраняет до подтверждения). Повтор с тем же ключом и другим телом получает `409`.
|
||||||
|
- `401` → однократный refresh и повтор запроса; второй `401` → выход на экран входа.
|
||||||
|
- Чужой ресурс возвращает `404`, а не `403` — не показывать это как «нет прав».
|
||||||
|
- Деньги приходят как `{"amount_minor": "623100", "currency_code": "RUB"}`, строкой. Клиент **не** приводит к числу с плавающей точкой: форматирование только через хелпер `formatMoney`, который работает со строкой и делит на 100.
|
||||||
|
- Типы генерируются из `api/openapi.yaml` модуля через `openapi-typescript`; рукописные интерфейсы ответов запрещены.
|
||||||
|
|
||||||
|
## 5. Экраны Staff, версия 1
|
||||||
|
|
||||||
|
| Экран | Содержание |
|
||||||
|
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Сегодня | текущая смена: точка, время прихода, идёт ли смена, предварительное начисление за день с пометкой «до подтверждения управляющим» |
|
||||||
|
| Мои смены | список за период: дата, точка, часы, статус (`ожидает подтверждения`, `подтверждена`, `отклонена`) |
|
||||||
|
| Моя зарплата | неделя: начислено, выдано, остаток. Внутри — по дням: выручка точки не показывается, показываются часы, доля фонда, почасовая, надбавка, итог |
|
||||||
|
| Детализация дня | объяснение суммы: «фонд точки ÷ 3 участника + 12 ч × 100 ₽ + надбавка 500 ₽» |
|
||||||
|
| Профиль | имя, табельный номер, точка, выход, смена PIN, включение биометрии |
|
||||||
|
|
||||||
|
Сотрудник видит только свои данные. Ни один экран не должен запрашивать данные других сотрудников; при попытке сервер ответит `404`.
|
||||||
|
|
||||||
|
## 6. Офлайн
|
||||||
|
|
||||||
|
Приложение работает в кафе с плохой связью, поэтому:
|
||||||
|
|
||||||
|
- чтение — из кэша TanStack Query с сохранением на диск, с явной пометкой «данные на такое-то время»;
|
||||||
|
- мутации (их мало: подтверждение прочтения, смена PIN) — очередь с `Idempotency-Key` и повтором;
|
||||||
|
- денежные суммы никогда не считаются на клиенте, даже офлайн: если данных нет, показывается прочерк, а не оценка.
|
||||||
|
|
||||||
|
## 7. Что делает сервер, а не приложение
|
||||||
|
|
||||||
|
- расчёт начислений, фонда, долей, гарантий, округлений;
|
||||||
|
- определение, какие смены попали в расчёт;
|
||||||
|
- проверка прав: приложение прячет кнопки, но решает всегда сервер;
|
||||||
|
- агрегаты за неделю и остаток.
|
||||||
|
|
||||||
|
## 8. Порядок работ
|
||||||
|
|
||||||
|
1. Ядро: навигация, дизайн-токены, http-клиент, обработка ошибок, реестр модулей.
|
||||||
|
2. Аутентификация: приглашение, пароль, PIN, биометрия, refresh, Device ID.
|
||||||
|
3. Модуль Staff: «Сегодня», «Мои смены», «Моя зарплата», «Детализация», «Профиль».
|
||||||
|
4. Push-уведомления: отметка о приходе, подтверждение смены, утверждение ведомости.
|
||||||
|
5. Сборка: EAS profiles (`development`, `preview`, `production`), публикация в RuStore.
|
||||||
|
|
||||||
|
Серверная часть (расчёт, ведомость, подтверждение смен, API) делается в репозитории `krona-staff` и к моменту пункта 3 отдаёт готовый контракт в `api/openapi.yaml`.
|
||||||
|
|
@ -7,3 +7,101 @@ Restore runbook: остановить записи, проверить checksum/
|
||||||
Тест восстановления обязателен по расписанию в изолированной среде. До принятия RPO/RTO и retention документ является архитектурным шаблоном, а не обещанием показателей.
|
Тест восстановления обязателен по расписанию в изолированной среде. До принятия RPO/RTO и retention документ является архитектурным шаблоном, а не обещанием показателей.
|
||||||
|
|
||||||
Tenant export/import использует versioned archive, ULID, schema version, object references и checksum. Import идемпотентен и не может перезаписать другой tenant.
|
Tenant export/import использует versioned archive, ULID, schema version, object references и checksum. Import идемпотентен и не может перезаписать другой tenant.
|
||||||
|
|
||||||
|
## Pilot implementation — K18
|
||||||
|
|
||||||
|
Status: implementation in progress. A pilot restore drill succeeded on 2026-09-26;
|
||||||
|
the remaining schedule, retention and migration gate are listed below. Do not
|
||||||
|
use the presence of an encrypted file alone as permission to migrate tenant data.
|
||||||
|
|
||||||
|
## Storage and keys
|
||||||
|
|
||||||
|
`deploy/pilot/backup.sh` streams `pg_dump -Fc` through OpenSSL CMS encryption.
|
||||||
|
Only the public recipient certificate belongs on the server. Keep the private
|
||||||
|
recipient key in separate, access-controlled storage outside that server, with a
|
||||||
|
recovery copy. Loss of this key makes backups unrecoverable.
|
||||||
|
|
||||||
|
Configure the variables in `deploy/pilot/backup.env.example` using an operator-owned
|
||||||
|
environment file, never a committed secrets file. For the current test setup, use
|
||||||
|
`BACKUP_TRANSFER_MODE=external-pull` and schedule `scripts/pull-pilot-backups.sh`
|
||||||
|
on this PC with `PILOT_SSH_TARGET`, `PILOT_REMOTE_BACKUP_DIRECTORY` and
|
||||||
|
`BACKUP_LOCAL_DIRECTORY`. The PC initiates SSH, so it does not need an SSH server.
|
||||||
|
Configure non-interactive SSH and verify the server's host key out of band. Do not
|
||||||
|
disable host-key verification. A SHA-256 sidecar detects transfer corruption;
|
||||||
|
it is not a digital signature. Other deployments may use push mode with an
|
||||||
|
off-host SCP destination.
|
||||||
|
|
||||||
|
The PC pull script keeps 90 days of local copies by default. Set
|
||||||
|
`BACKUP_LOCAL_RETENTION_DAYS` to an integer from 1 to 36500 to change this period.
|
||||||
|
After a successful pull and freshness check, it removes only direct child files with
|
||||||
|
the generated `krona-staff-<UTC timestamp>-<pid>.dump.cms` name whose one-line
|
||||||
|
SHA-256 sidecar names that same file and matches its bytes. It removes the matching
|
||||||
|
`.sha256` sidecar with the encrypted file. Unverifiable pairs and symlinks are
|
||||||
|
preserved, as are unrelated files and directories. This policy applies to PC copies;
|
||||||
|
it does not prune the server's backup directory.
|
||||||
|
|
||||||
|
The server script fails if dumping or encryption fails. In push mode it also fails
|
||||||
|
if off-host transfer fails. It does not write an unencrypted dump to disk. In
|
||||||
|
external-pull mode a successful server run reports `pending_pull`, never an
|
||||||
|
off-host success. The PC pull script verifies every transferred encrypted file
|
||||||
|
against its checksum and exits nonzero on transfer or integrity failure.
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
The service and timer in `deploy/pilot/` are templates. Adjust their working and
|
||||||
|
configuration paths to the actual installation before enabling them. The timer
|
||||||
|
runs daily at 03:30 in the server's timezone, with up to ten minutes of jitter.
|
||||||
|
Monitor both the server service and the PC pull task exit statuses. Verify that
|
||||||
|
the PC directory contains a recent copy; the server-only encrypted file is not
|
||||||
|
evidence of off-host protection.
|
||||||
|
|
||||||
|
Do not enable the schedule until the destination, recovery-key custody, server-side
|
||||||
|
retention policy, and a successful restore drill have been agreed and tested.
|
||||||
|
|
||||||
|
## Required restore drill before tenant migration
|
||||||
|
|
||||||
|
1. Produce a consistent dump, encrypt it, and retrieve the off-host copy.
|
||||||
|
2. Verify the encrypted file's SHA-256 checksum at the destination.
|
||||||
|
3. Decrypt with the off-host key. Restore into a new disposable PostgreSQL 17
|
||||||
|
instance, with no connection to the pilot network and no exposed database port.
|
||||||
|
Never run a test provisioning script against the pilot cluster: it may alter
|
||||||
|
roles or drop databases.
|
||||||
|
4. Restore required roles and grants, schema, data, forced RLS, constraints and
|
||||||
|
migration history. A restore using `--no-owner --no-acl` alone is not enough
|
||||||
|
evidence that production privileges can be recovered.
|
||||||
|
5. Compare per-table and per-tenant row counts and payroll ledger totals per
|
||||||
|
currency against a manifest from the same consistent snapshot. Check foreign
|
||||||
|
keys, forced RLS, and isolation using a non-superuser application role.
|
||||||
|
6. Run the tenant migration on that restored copy and verify that only the tenant
|
||||||
|
changes: row counts and ledger totals are preserved, with one additional audit
|
||||||
|
record for the migration.
|
||||||
|
7. Save a report containing timestamp, source identity, backup checksum, PostgreSQL
|
||||||
|
version, commands and exit statuses, before/after counts, totals and isolation
|
||||||
|
result. Do not include passwords, tokens, employee names, or biometric data.
|
||||||
|
8. Only after a successful drill and an agreed maintenance window may the pilot
|
||||||
|
application be stopped and the operation repeated on a fresh backup.
|
||||||
|
|
||||||
|
## Outstanding acceptance items
|
||||||
|
|
||||||
|
- Off-host storage and retention policy selected and configured.
|
||||||
|
- Restore automation and repeatable reporting for later backups.
|
||||||
|
- A fail-closed backup gate before migrations in `setup.sh`.
|
||||||
|
|
||||||
|
Until these items are complete, K18 remains open and pilot tenant migration is blocked.
|
||||||
|
|
||||||
|
## Pilot restore drill — 2026-09-26
|
||||||
|
|
||||||
|
An encrypted `pg_dump -Fc` from `krona-staff-pilot-postgres-1` was pulled to the
|
||||||
|
owner's PC. Its SHA-256 was
|
||||||
|
`2fc680ae3a6ce63a27b53209da9b506bcee86b76d8bba9a6a06de24bac1dee47`.
|
||||||
|
It was decrypted on the PC and streamed through SSH into a new PostgreSQL 17
|
||||||
|
container with `network=none`, no host ports, and temporary storage. The restore
|
||||||
|
command returned 0 with ownership and ACLs restored. The reports produced by
|
||||||
|
`scripts/restore-drill-report.sql` matched for every tenant table: 51 employees,
|
||||||
|
3,830 ledger entries, 3,353 work items, 2,894 daily metrics and 49 audit rows;
|
||||||
|
the other nonempty tables matched too. All 27 tenant tables had ENABLE and FORCE
|
||||||
|
RLS. The RUB ledger sum matched at 717,829,954 minor units. A nonsuperuser,
|
||||||
|
non-BYPASSRLS application role saw 51 employees under the source tenant and zero
|
||||||
|
under the target. The restore report and encrypted backup are stored on the PC
|
||||||
|
in `C:/Users/trush/Documents/KronaStaffBackups/`. This was a restore drill, not
|
||||||
|
yet a tenant migration or a check that the backup schedule runs unattended.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# Развёртывание
|
# Развёртывание
|
||||||
|
|
||||||
|
После копирования `.env.example` обязательно заполните `STANDALONE_TENANT_ID` идентификатором организации (ULID) из Krona Business Platform. Значения по умолчанию нет; прежняя заглушка `01J00000000000000000000000` не подходит для Compose или пилота. Для первого запуска `deploy/pilot/setup.sh` передайте этот ID через переменную окружения: установщик проверит его и сохранит в своём `.env`. При повторном запуске проверяется существующий `.env`. Для уже заполненного пилота нельзя просто заменить ID: сначала нужна резервная копия, проверка восстановления и перенос tenant-данных.
|
||||||
|
|
||||||
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
||||||
|
|
||||||
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
||||||
|
|
|
||||||
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal file
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Design brief: кабинет руководителя
|
||||||
|
|
||||||
|
- **Назначение:** ежедневный контроль присутствия, сотрудников и ручное заполнение табеля руководителем небольшой организации.
|
||||||
|
- **Контекст:** защищённый операционный кабинет, а не маркетинговая страница.
|
||||||
|
- **Направление:** спокойный плотный рабочий интерфейс с природной зелёной основой и золотым акцентом бренда Krona.
|
||||||
|
- **Отличительная идея:** хронология отметок и табель остаются главным рабочим полотном, а управление сотрудниками доступно рядом и не уводит на отдельную техническую страницу.
|
||||||
|
- **Ограничения:** существующий Fastify/PWA без нового frontend-фреймворка; адаптивность от мобильного браузера до рабочего монитора; клавиатурная навигация; reduced motion; никакой биометрии и секретов в браузере.
|
||||||
|
|
||||||
|
## Локальная система
|
||||||
|
|
||||||
|
- Цвета: тёплый рабочий фон, светлые поверхности, зелёный primary, золотой brand accent, семантические danger/info состояния.
|
||||||
|
- Типографика: Georgia для крупных смысловых заголовков, Segoe UI для плотных управляющих элементов и таблиц.
|
||||||
|
- Плотность: 44 px минимальная высота интерактивного элемента, компактные строки таблицы, адаптивный toolbar.
|
||||||
|
- Формы: явные подписи, видимый focus, диалог для карточки сотрудника, встроенная строка создания записи табеля.
|
||||||
|
- Движение: только короткие переходы состояния; полностью отключается через `prefers-reduced-motion`.
|
||||||
72
docs/DRIVER-DISTRIBUTION.md
Normal file
72
docs/DRIVER-DISTRIBUTION.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# ZKTeco SLK20R Driver Distribution & Licensing Policy
|
||||||
|
|
||||||
|
**Document Version:** 1.0.0
|
||||||
|
**Target Audience:** Compliance Officers, Legal Counsel, System Administrators, DevOps
|
||||||
|
**Affected Hardware:** ZKTeco SLK20R, BioLink U-Match, ZK6500, ZK8500, ZK9500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary & Legal Compliance Rationale
|
||||||
|
|
||||||
|
The Krona Staff Terminal integrates with the ZKTeco SLK20R optical fingerprint scanner using the ZKTeco ZKFinger SDK 5.3 native interface (`libzkfpcsharp.dll` / `libzkfp.dll`).
|
||||||
|
|
||||||
|
To ensure strict legal compliance with third-party intellectual property rights, **the Krona Staff Terminal installer does NOT bundle or distribute the proprietary ZKTeco USB device driver (`slk20r.sys`, `slk20r.inf`, `zkfp.sys`) or native installer binaries**.
|
||||||
|
|
||||||
|
The device driver must be obtained by end-users or enterprise system administrators directly from ZKTeco's official software distribution channel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. EULA Analysis (ZKFinger SDK 5.3)
|
||||||
|
|
||||||
|
The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**:
|
||||||
|
|
||||||
|
> **Distribution.**
|
||||||
|
> _"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."_
|
||||||
|
|
||||||
|
### Legal Interpretation:
|
||||||
|
|
||||||
|
1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2.
|
||||||
|
2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Terminal & Installer Behavior
|
||||||
|
|
||||||
|
To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance:
|
||||||
|
|
||||||
|
### 3.1 Installer Detection Flow
|
||||||
|
|
||||||
|
1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations:
|
||||||
|
- System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
|
||||||
|
- Windows Service Registry keys:
|
||||||
|
- `HKLM\SYSTEM\CurrentControlSet\Services\slk20r`
|
||||||
|
- `HKLM\SYSTEM\CurrentControlSet\Services\zkfp`
|
||||||
|
- `HKLM\SYSTEM\CurrentControlSet\Services\ZKUSB`
|
||||||
|
2. If the driver is **absent**:
|
||||||
|
- The installer displays an informative notice explaining that the ZKTeco hardware driver is required for fingerprint scanning.
|
||||||
|
- It offers a one-click button to launch the official ZKTeco download portal:
|
||||||
|
`https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows`
|
||||||
|
- The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed.
|
||||||
|
|
||||||
|
### 3.2 Terminal Runtime Flow
|
||||||
|
|
||||||
|
1. If the terminal starts without the driver installed:
|
||||||
|
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`.
|
||||||
|
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: _"Сканер отпечатков пальцев не подключен или не установлен драйвер"_.
|
||||||
|
- The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page.
|
||||||
|
- The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally.
|
||||||
|
2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Enterprise Mass Deployment Guide
|
||||||
|
|
||||||
|
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
|
||||||
|
|
||||||
|
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`).
|
||||||
|
2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`).
|
||||||
|
3. Deploy the driver package silently using standard Windows DISM or pnputil:
|
||||||
|
```cmd
|
||||||
|
pnputil /add-driver slk20r.inf /install
|
||||||
|
```
|
||||||
|
4. Deploy `KronaStaffTerminal-Setup-<version>.exe /S` silently.
|
||||||
139
docs/HARDWARE-TEST-SLK20R.md
Normal file
139
docs/HARDWARE-TEST-SLK20R.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# ZKTeco SLK20R Hardware Integration & Test Report
|
||||||
|
|
||||||
|
**Document Version:** 1.0.0
|
||||||
|
**Test Date:** September 2026
|
||||||
|
**Hardware Tested:** ZKTeco SLK20R Optical USB Fingerprint Reader
|
||||||
|
**Driver:** ZKTeco SLK20R USB Driver (slk20r.sys v2.1.0) / ZKFinger SDK 5.3
|
||||||
|
**Operating Systems:** Windows 10 Pro (22H2 x64), Windows 11 Enterprise (24H2 x64), Windows Server 2022 (Session 0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem.
|
||||||
|
|
||||||
|
### Key Takeaways:
|
||||||
|
|
||||||
|
1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices.
|
||||||
|
2. **Hybrid Solution Adopted:** A decoupled dual-process architecture:
|
||||||
|
- **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment.
|
||||||
|
- **`KronaStaffService.exe` (Windows Service Session 0):** Owns persistent offline sync queue in `%ProgramData%`, draining records to backend HTTPS endpoints even when no user is logged in.
|
||||||
|
3. **Ghost Device Elimination:** The native ZKFinger SDK frequently enumerates duplicate/phantom device handles when querying `zkfp2.GetDeviceCount()`. Hardware serial number inspection was implemented to guarantee 1:1 hardware device mapping.
|
||||||
|
4. **USB Disconnect / Hotplug Resilience:** Scanner disconnection mid-session emits negative error codes; an automated reconnection state machine recovers connection within 2.0 seconds of re-plug without data loss or application crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Session 0 Investigation & Architectural Analysis
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`).
|
||||||
|
|
||||||
|
#### Empirical Observations:
|
||||||
|
|
||||||
|
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK).
|
||||||
|
- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager.
|
||||||
|
- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
|
||||||
|
|
||||||
|
#### Root Cause:
|
||||||
|
|
||||||
|
The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`).
|
||||||
|
|
||||||
|
#### Architectural Resolution:
|
||||||
|
|
||||||
|
Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities:
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------------------------------------------------+
|
||||||
|
| Interactive User Session |
|
||||||
|
| |
|
||||||
|
| +------------------------------------------------------+ |
|
||||||
|
| | KronaStaffTerminal.exe (WinUI 3 App) | |
|
||||||
|
| | - Drives SLK20R via libzkfp.dll (USB capture) | |
|
||||||
|
| | - Matches templates locally (1:N biometric engine) | |
|
||||||
|
| | - Duplicate scan guard & auto-checkout scheduler | |
|
||||||
|
| | - System tray icon with live hardware diagnostics | |
|
||||||
|
| +---------------------------+--------------------------+ |
|
||||||
|
+-------------------------------|------------------------------+
|
||||||
|
| Named Pipe IPC /
|
||||||
|
| Machine Store (%ProgramData%)
|
||||||
|
+-------------------------------|------------------------------+
|
||||||
|
| Session 0 (Background Service) |
|
||||||
|
| |
|
||||||
|
| +---------------------------v--------------------------+ |
|
||||||
|
| | KronaStaffService.exe (Windows Service) | |
|
||||||
|
| | - Named Pipe Server (SYSTEM ACL) | |
|
||||||
|
| | - Monitors %ProgramData%\Krona\StaffTerminal | |
|
||||||
|
| | - HTTPS sync loop with exponential backoff & retry | |
|
||||||
|
| | - Operates headless even when user logs off | |
|
||||||
|
| +------------------------------------------------------+ |
|
||||||
|
+--------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SDK Device Deduplication Analysis
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected.
|
||||||
|
|
||||||
|
Opening device index `1` resulted in one of two failure modes:
|
||||||
|
|
||||||
|
1. `OpenDevice(1)` returned error `-2` (device already open/busy).
|
||||||
|
2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability.
|
||||||
|
|
||||||
|
### The Solution: Serial Number Deduplication
|
||||||
|
|
||||||
|
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Query serial number parameter (parameter codes 1101, 1102, 106)
|
||||||
|
byte[] serialBuffer = new byte[256];
|
||||||
|
int size = serialBuffer.Length;
|
||||||
|
string? serial = null;
|
||||||
|
if (zkfp2.GetParameters(devHandle, 1101, serialBuffer, ref size) == 0 && size > 0)
|
||||||
|
{
|
||||||
|
serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. USB Disconnection & Hotplug Recovery Testing
|
||||||
|
|
||||||
|
### Test Scenario
|
||||||
|
|
||||||
|
1. Terminal running in kiosk mode with SLK20R connected.
|
||||||
|
2. Employee clocks in successfully.
|
||||||
|
3. USB cable is physically unplugged or hub power is cycled.
|
||||||
|
|
||||||
|
### Observed Results
|
||||||
|
|
||||||
|
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect).
|
||||||
|
- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
|
||||||
|
- **With Production Reconnect Logic:**
|
||||||
|
1. Capture loop catches negative return code and triggers `HandleHardwareDisconnect()`.
|
||||||
|
2. Scanner state immediately updates to `ScannerState.Disconnected`.
|
||||||
|
3. System tray icon transitions to `TrayState.NoScanner`.
|
||||||
|
4. Device handle and database handle are safely closed via `zkfp2.CloseDevice()` and `zkfp2.DBFree()`.
|
||||||
|
5. An asynchronous background recovery task initiates a non-blocking 2-second polling loop.
|
||||||
|
6. Upon cable re-connection, `zkfp2.GetDeviceCount()` returns `>= 1`.
|
||||||
|
7. Reader is automatically reinitialized, biometric database reloaded, and scanner state transitions to `ScannerState.Connected`.
|
||||||
|
8. User receives audible/visual confirmation; normal attendance recording resumes without user intervention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Performance & Biometric Accuracy Verification
|
||||||
|
|
||||||
|
| Metric | Target | Measured Result | Status |
|
||||||
|
| ------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
|
||||||
|
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
|
||||||
|
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
|
||||||
|
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
|
||||||
|
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
|
||||||
|
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
|
||||||
|
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
|
||||||
294
docs/OFFLINE-SYNC-PROTOCOL.md
Normal file
294
docs/OFFLINE-SYNC-PROTOCOL.md
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
# Протокол безопасной офлайн-синхронизации Krona Staff
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
4. **Безопасность логов**: Секретные токены, хэши паролей, биометрия и персональные данные (ПДН) никогда не попадают в серверные логи.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Жизненный цикл устройства и аутентификация
|
||||||
|
|
||||||
|
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
|
||||||
|
|
||||||
|
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
|
||||||
|
|
||||||
|
**Запрос:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_code": "TERM-MAIN-01",
|
||||||
|
"name": "Главный вход — Терминал",
|
||||||
|
"org_unit_id": "01J00000000000000000000001",
|
||||||
|
"allowed_scopes": ["attendance:sync"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device": {
|
||||||
|
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J1",
|
||||||
|
"tenant_id": "01J00000000000000000000000",
|
||||||
|
"device_code": "TERM-MAIN-01",
|
||||||
|
"name": "Главный вход — Терминал",
|
||||||
|
"token_hint": "kstaff_dev_01J00...",
|
||||||
|
"status": "active",
|
||||||
|
"allowed_scopes": ["attendance:sync"],
|
||||||
|
"org_unit_id": "01J00000000000000000000001",
|
||||||
|
"registered_at": "2026-09-26T12:00:00.000Z",
|
||||||
|
"created_by": "01J00000000000000000000099"
|
||||||
|
},
|
||||||
|
"token": "kstaff_dev_01J00000000000000000000000_3f2a1b9c8d7e6f..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Сырой токен `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, ...)`.
|
||||||
|
4. Находит устройство по `token_hash` в таблице `staff_sync_devices`.
|
||||||
|
5. Проверяет `status = 'active'` и наличие скоупа `attendance:sync`.
|
||||||
|
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,
|
||||||
|
"error": "Forbidden",
|
||||||
|
"message": "Устройство отозвано"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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",
|
||||||
|
"limit": 50,
|
||||||
|
"entity_types": ["employee", "work_session"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"cursor": "cur_106_3c4d5e6f7a",
|
||||||
|
"entity_type": "employee",
|
||||||
|
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
|
||||||
|
"operation": "upsert",
|
||||||
|
"revision": 2,
|
||||||
|
"payload": {
|
||||||
|
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
|
||||||
|
"personnel_number": "EMP-042",
|
||||||
|
"display_name": "Иванов Иван Иванович",
|
||||||
|
"status": "active",
|
||||||
|
"profession_id": "01J00000000000000000000010",
|
||||||
|
"position": "Повар-универсал"
|
||||||
|
},
|
||||||
|
"created_at": "2026-09-26T12:05:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "cur_106_3c4d5e6f7a",
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
|
||||||
|
|
||||||
|
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cursor": "cur_106_3c4d5e6f7a"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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": [
|
||||||
|
{
|
||||||
|
"command_type": "attendance_event",
|
||||||
|
"schema_version": 1,
|
||||||
|
"idempotency_key": "cmd-term01-778899",
|
||||||
|
"correlation_id": "01J9X1Z2A3B4C5D6E7F8G9H0AA",
|
||||||
|
"created_at": "2026-09-26T12:10:00.000Z",
|
||||||
|
"payload": {
|
||||||
|
"employee_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
|
||||||
|
"event_type": "check_in",
|
||||||
|
"occurred_at": "2026-09-26T12:09:58.000Z",
|
||||||
|
"source_event_id": "scan-123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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. Обнаружение конфликтов
|
||||||
|
|
||||||
|
Для сущностей с контролем версий (`work_session`, `timesheet_entry`) используется оптимистическая блокировка через поле `revision` (целое число > 0).
|
||||||
|
|
||||||
|
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
|
||||||
|
|
||||||
|
**Ответ с конфликтом (в составе `results`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"idempotency_key": "cmd-term01-778900",
|
||||||
|
"command_type": "upsert_work_session",
|
||||||
|
"status": "conflict",
|
||||||
|
"conflict": {
|
||||||
|
"conflict_code": "REVISION_MISMATCH",
|
||||||
|
"entity_type": "work_session",
|
||||||
|
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0WS",
|
||||||
|
"expected_revision": 2,
|
||||||
|
"actual_revision": 3,
|
||||||
|
"safe_description": "Ревизия рабочей смены на сервере изменилась",
|
||||||
|
"client_action": "FETCH_LATEST_AND_RETRY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент, получив конфликт, обязан:
|
||||||
|
|
||||||
|
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
|
||||||
|
2. Обновить локальное представление.
|
||||||
|
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Минимальный безопасный набор данных
|
||||||
|
|
||||||
|
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
|
||||||
|
|
||||||
|
| Сущность | Разрешённые поля | Запрещённые данные |
|
||||||
|
| :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
|
||||||
|
| `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]
|
||||||
|
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
|
||||||
|
> Это исключает риск утечки конфиденциальной финансовой информации при физическом хищении терминала или компрометации его локального хранилища.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Безопасность биометрии
|
||||||
|
|
||||||
|
1. **Запрет передачи через Sync API**: Биометрические шаблоны (отпечатки пальцев, векторы лиц) строго запрещено передавать через интерфейсы синхронизации Krona Staff.
|
||||||
|
2. **Локальная верификация**: Если терминал оснащён биометрическим датчиком (например, ZKTeco SLK20R), он выполняет сопоставление биометрии локально в изолированном модуле или безопасном анклаве устройства.
|
||||||
|
3. **Фиксация только факта**: Сервер получает только сформированное событие `attendance_event` с проверенным `employee_id`, типом отметки (`check_in`/`check_out`) и временем фиксации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Политика хранения данных (Retention)
|
||||||
|
|
||||||
|
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM staff_sync_cleanup_retention(30);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
|
||||||
|
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
|
||||||
|
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).
|
||||||
45
docs/PAYROLL-LEGAL-LIMITS.md
Normal file
45
docs/PAYROLL-LEGAL-LIMITS.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Правовые ограничения расчёта
|
||||||
|
|
||||||
|
Модуль считает управленческий контур, но обязан не подталкивать клиента к незаконной схеме и предупреждать, когда расчёт расходится с Трудовым кодексом. Ниже — минимум, который закладывается в правила и в предупреждения ведомости.
|
||||||
|
|
||||||
|
Это не юридическое заключение. Перед боевым использованием состав предупреждений стоит согласовать с юристом.
|
||||||
|
|
||||||
|
## 1. Что нельзя выразить как вычет
|
||||||
|
|
||||||
|
| Норма | Содержание |
|
||||||
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ст. 137 ТК РФ | Перечень оснований для удержаний закрытый. Штрафов в нём нет |
|
||||||
|
| ст. 138 ТК РФ | Предел удержаний — 20% выплаты, до 50% в случаях, предусмотренных законом, до 70% по исполнительным листам |
|
||||||
|
| ст. 192 ТК РФ | Дисциплинарные взыскания — только замечание, выговор, увольнение. «Штраф» как наказание незаконен |
|
||||||
|
| ст. 238, 241, 243, 244 ТК РФ | Материальная ответственность ограничена средним месячным заработком; полная — только по закону или письменному договору с работником из утверждённого перечня; взыскание сверх лимита — через суд, приказ — в течение месяца со дня установления ущерба |
|
||||||
|
|
||||||
|
Следствие для продукта: «штраф за опоздание», «за бой посуды», «за жалобу» не должны быть выразимы блоком вычета. Законная альтернатива — заранее объявленное условие премирования: премия не начисляется либо применяется понижающий множитель, и это записано в локальном нормативном акте.
|
||||||
|
|
||||||
|
С 1 сентября 2025 года (ФЗ от 07.06.2025 № 144-ФЗ, ст. 135 ТК РФ) снижение премии за дисциплинарный проступок допустимо только в месяце взыскания и не более чем на 20% месячной зарплаты, а виды премий и порядок их снижения должны быть в локальном акте.
|
||||||
|
|
||||||
|
## 2. Что обязано быть оплачено
|
||||||
|
|
||||||
|
| Норма | Требование |
|
||||||
|
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ст. 152 ТК РФ | Сверхурочные: первые два часа — не менее чем в полуторном размере, дальше — не менее чем в двойном. С 1 сентября 2024 года (ФЗ от 22.04.2024 № 91-ФЗ, во исполнение постановления КС РФ от 27.06.2023 № 35-П) расчёт ведётся от всей системы оплаты труда, включая компенсационные и стимулирующие выплаты, а не от голого оклада |
|
||||||
|
| ст. 96, 154 ТК РФ | Ночные часы с 22:00 до 06:00 — надбавка не менее 20% часовой ставки. Действует постановление Правительства РФ от 04.04.2025 № 436 (прежнее № 554 от 22.07.2008 утратило силу) |
|
||||||
|
| ст. 153 ТК РФ | Работа в выходной и праздник — не менее чем в двойном размере; сдельщикам — по двойным расценкам. По желанию работника возможна одинарная оплата с отгулом |
|
||||||
|
| ст. 104 ТК РФ | При сменных графиках, где норма не соблюдается, обязателен суммированный учёт; переработка определяется по итогам учётного периода, а не по дням |
|
||||||
|
| ст. 133, 133.1 ТК РФ | Зарплата за полную норму не может быть ниже МРОТ; региональный МРОТ может быть выше федерального. При неполной норме — пропорционально отработанному |
|
||||||
|
| ст. 136 ТК РФ | Выплата не реже чем каждые полмесяца, разрыв не более 15 календарных дней. Аванс считается пропорционально фактически отработанному времени (позиция Минтруда), а не фиксированным процентом |
|
||||||
|
| ст. 351.8 ТК РФ (с 01.03.2025) | Доплата наставнику обязательна, её размер фиксируется в трудовом договоре или дополнительном соглашении, требуется письменное согласие работника |
|
||||||
|
|
||||||
|
Ответственность: ст. 5.27 КоАП РФ — нарушение трудового законодательства для юридического лица 30 000–50 000 ₽; невыплата или неполная выплата — для должностных лиц 10 000–20 000 ₽, при повторном нарушении дисквалификация от одного года до трёх лет, для юридического лица 50 000–70 000 ₽.
|
||||||
|
|
||||||
|
## 3. Как это отражается в модуле
|
||||||
|
|
||||||
|
1. **Типизированные часы.** Ночные, сверхурочные и праздничные часы — отдельные входы правила, а не «часы вообще». Без этого требования статей 152–154 невыразимы.
|
||||||
|
2. **Раздельные сущности.** Начисление, премия и удержание — разные вещи. Удержание получает основание, лимит и запись в аудите; формула его не создаёт.
|
||||||
|
3. **Предупреждения ведомости** (следующий шаг): начислено меньше МРОТ пропорционально отработанному; в смене есть ночные часы, а надбавки в правиле нет; переработка по итогам учётного периода не оплачена; удержание превышает 20% выплаты.
|
||||||
|
4. **Понижающий множитель качества** (например, ×0,9 при низкой оценке) допустим как условие премирования, объявленное заранее; тот же результат, оформленный как вычет, — нет. Разница только в оформлении, и продукт обязан подсказывать правильное.
|
||||||
|
|
||||||
|
## 4. Отдельные случаи
|
||||||
|
|
||||||
|
- **Чаевые от гостя** — дарение (ст. 572 ГК РФ), не элемент оплаты труда. Распределение общего котла — внутренняя договорённость; в расчёт зарплаты попадает как отдельный вид начисления.
|
||||||
|
- **Самозанятые и ГПХ.** «Корректировки вознаграждения» в доставке работают для ГПХ, но для штатных работников по трудовому договору такая модель незаконна. Правило, написанное для курьеров-самозанятых, нельзя механически переносить на штат.
|
||||||
|
- **Возврат и дебиторка в продажах.** Прямого удержания нет; работающая механика — не включать сделку в базу бонуса до поступления денег и уменьшать базу следующего периода при возврате.
|
||||||
131
docs/PAYROLL-SCHEMES-CATALOGUE.md
Normal file
131
docs/PAYROLL-SCHEMES-CATALOGUE.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Справочник схем расчёта переменной части зарплаты
|
||||||
|
|
||||||
|
Собран по практике российского малого и среднего бизнеса (вендорские материалы iiko, r_keeper, СБИС, 1С, YCLIENTS, отраслевые публикации, вакансии). Назначение — состав блоков конструктора и отраслевые пресеты. Конкретные проценты приведены как ориентир рынка, а не как норматив: в пресетах они лишь значения по умолчанию.
|
||||||
|
|
||||||
|
## 1. Обозначения
|
||||||
|
|
||||||
|
`R` — выручка подразделения, `R_personal` — личные продажи, `H` — часы (в том числе `H_night`, `H_over`, `H_holiday`), `N` — количество единиц, `P` — план, `F` — фонд, `n` — число участников, `w` — вес участника, `G` — гарантия, `pct(x)` — процент как функция показателя.
|
||||||
|
|
||||||
|
## 2. Отрасли
|
||||||
|
|
||||||
|
### Общепит
|
||||||
|
|
||||||
|
| Роль | Формула | Ориентир рынка |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Официант | `ставка_смены + R_personal × pct + N_позиций_категории × ставка + премия за средний чек` | процент 1,3–5% от личного оборота; допродажа десерта 40 ₽/шт |
|
||||||
|
| Официант, порог | `if(R_personal ≥ порог, R_personal × pct, 0)` | порог включения процента, например 50 000 ₽ |
|
||||||
|
| Повар (командно) | `F × доля_по_времени + ставка_часа × H + надбавка`, `F = R × pct` | фонд 10–11% от выручки |
|
||||||
|
| Повар (личный процент) | `ставка_часа × H + R × pct` | 1–2%, в том числе от конкретной станции |
|
||||||
|
| Бармен | `ставка_смены + R_бара × pct + доля чаевых` | 2–5%; доля в общем котле 20% |
|
||||||
|
| Курьер ресторана | `ставка_часа × H_простоя + Σ(за заказ) × k_спроса` | 50–110 ₽ за доставку либо 3% от заказа |
|
||||||
|
| Администратор | `max(G, R × pct + ставка_часа × H)` | pct 2,5–8%, G около 3000 ₽ за смену |
|
||||||
|
|
||||||
|
Чаевые как отдельный фонд: официанты 75% поровну, бармен 20%, посудомойка 5%; банкет — кухня 40%, зал 50%, техперсонал 10%. Юридически чаевые от гостя — дарение, не оплата труда.
|
||||||
|
|
||||||
|
Ориентир по ФОТ: 18–27% от товарооборота, около 32% — операционный ноль.
|
||||||
|
|
||||||
|
### Розница и продажи
|
||||||
|
|
||||||
|
| Схема | Формула |
|
||||||
|
| ----------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| Ставка по достигнутому уровню | `оклад + R_personal × tier(R_personal / P, [<80% → 1,75%; <100% → 2,0%; ≥100% → 2,25%])` |
|
||||||
|
| Прогрессивная (послойная) | `оклад + progressive(R_personal, [до 80% плана → 3%; 80–100% → 5%; 100–120% → 7%; выше → 10%])` |
|
||||||
|
| Кассир | `оклад + R_точки × pct` (процент от общих продаж смены) |
|
||||||
|
| Старший смены | `оклад × коэффициент + R_смены × pct + премия KPI` |
|
||||||
|
|
||||||
|
Разница между двумя первыми схемами на плане 105% — десятки процентов премии; владельцы их регулярно путают. В конструкторе это два разных блока: «шкала» и «прогрессивная шкала».
|
||||||
|
|
||||||
|
### Услуги с мастерами
|
||||||
|
|
||||||
|
| Схема | Формула | Ориентир |
|
||||||
|
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| Чистый процент | `R_personal × pct(категория)` | 30–35% начинающий, 35–45% средний, 45–55% топ |
|
||||||
|
| Оклад плюс процент на превышение | `оклад + max(0, R_personal − порог) × pct` | оклад 25 000 ₽, порог 60 000 ₽, 40% сверх |
|
||||||
|
| Разный процент по источнику клиента | `сумма(свои) × 50% + сумма(салона) × 35%` | повышение за повторные визиты |
|
||||||
|
| Автосервис, нормо-часы | `Σ нормо-часов × ставка_разряда` | 140 / 180 / 220 ₽ за нормо-час |
|
||||||
|
| Автосервис, превышение плана | `оклад + max(0, н-ч − норма) × ставка` | норма 80 н-ч, 350 ₽ сверх |
|
||||||
|
| Мастер-приёмщик | `max(G, R_приёмки × tier(план) + бонусы) × k_CSI` | шкала 1–2,5%, множитель качества 1,05 / 1,0 / 0,9 |
|
||||||
|
| Клининг, бригада | `сумма_заказа × pct × (часы × КТУ) / Σ(часы × КТУ)` | фонд 30–40% от заказа |
|
||||||
|
|
||||||
|
### Доставка и логистика
|
||||||
|
|
||||||
|
| Роль | Формула |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Пеший курьер | `Σ_заказ(база + max(0, км − порог) × ставка_км + доплата_вес) × k_спроса + чаевые` |
|
||||||
|
| Авто-курьер | `за забор + за выдачу × N + ставка_км × км + ставка_кг × кг` |
|
||||||
|
| Водитель | `ставка_км × км × k_рефрижератор + суточные × дни + надбавка_стажа + премия за безаварийность + доля экономии топлива` |
|
||||||
|
| Сборщик | `ставка_часа × H + max(0, N_строк − норма × H) × ставка_единицы` |
|
||||||
|
|
||||||
|
Особый случай, который чаще всего ломает ручные таблицы: крупный заказ оплачивается фиксированной суммой по дальности **и исключается из базы процента**. Без исключения возникает двойная оплата.
|
||||||
|
|
||||||
|
### Склад и производство
|
||||||
|
|
||||||
|
```
|
||||||
|
расценка = тарифная_ставка / норма_выработки
|
||||||
|
прямая: расценка × N
|
||||||
|
прогрессивная: расценка × min(N, норма) + расценка × k × max(0, N − норма), k = 1,5–2
|
||||||
|
косвенная: тариф_вспомогательного × средний_процент_выполнения_норм бригады
|
||||||
|
аккордная: фиксированная сумма за объём, затем делится по КТУ и часам
|
||||||
|
```
|
||||||
|
|
||||||
|
Бригадное распределение, самый практичный вариант (гарантирует тариф каждому):
|
||||||
|
|
||||||
|
```
|
||||||
|
доля_i = тариф_i × часы_i + (F − Σ тариф × часы) × (КТУ_i × часы_i) / Σ(КТУ × часы)
|
||||||
|
```
|
||||||
|
|
||||||
|
КТУ: база 1,0, диапазон в источниках 0–2; утверждается протоколом бригады; фонд не увеличивает, только перераспределяет.
|
||||||
|
|
||||||
|
### B2B-продажи
|
||||||
|
|
||||||
|
```
|
||||||
|
ЗП = твёрдый_оклад + мягкий_оклад × if(Σ(KPI_i × вес_i) ≥ 0,8, Σ(KPI_i × вес_i), 0)
|
||||||
|
+ progressive(оплаченная_маржа_за_месяц, [<80% плана → 2%; ≤100% → 4%; ≤120% → 6%; выше → 8%])
|
||||||
|
```
|
||||||
|
|
||||||
|
Соотношение фикс к переменной: 30–50% на 50–70%. База процента — оплаченная маржа, а не выручка: процент от выручки поощряет скидки. Порог отсечки KPI около 80% — отраслевой стандарт. Рекомендуется 3–5 метрик, на которые сотрудник реально влияет.
|
||||||
|
|
||||||
|
### Медицина, фитнес, образование
|
||||||
|
|
||||||
|
| Роль | Формула |
|
||||||
|
| --------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| Врач | `max(МРОТ_пропорционально, оклад + Σ(услуга × pct(тип)))`; ФОТ врачей 25–35% оборота клиники |
|
||||||
|
| Стоматолог | врачу 40–50% выручки по его услугам |
|
||||||
|
| Администратор клиники | `оклад + N_дошедших × ставка + if(конверсия_явки ≥ 70%, командная премия, 0)` |
|
||||||
|
| Персональный тренер | `Σ(тренировка × pct(количество тренировок))`, 30–70%, типично 35–50% |
|
||||||
|
| Групповой тренер | `ставка_занятия × N` либо `Σ(ставка + ставка_участника × N_участников)` |
|
||||||
|
| Преподаватель | `Σ(ставка_академчаса × k_группы × k_квалификации)` плюс надбавка за удержание ученика |
|
||||||
|
|
||||||
|
Правило, которое ломает наивный расчёт: по абонементу на N занятий выручка признаётся **при каждом посещении** (стоимость ÷ N), по безлимиту — равными долями за период. Мотивацию считать от факта оказания услуги, иначе при возврате абонемента возникает переплата, которую уже не вернуть.
|
||||||
|
|
||||||
|
### Охрана, диспетчеры, линейный персонал
|
||||||
|
|
||||||
|
```
|
||||||
|
ЗП = ставка_смены × S
|
||||||
|
+ ставка_часа × H_night × 20%
|
||||||
|
+ ставка_часа × (min(H_over, 2) × 0,5 + max(0, H_over − 2) × 1,0)
|
||||||
|
+ ставка_часа × H_holiday
|
||||||
|
+ премия за отсутствие нарушений
|
||||||
|
```
|
||||||
|
|
||||||
|
При графике «сутки через трое» обязателен суммированный учёт: переработка определяется по итогам учётного периода, а не по дням. Надбавки не поглощают друг друга: ночная работа в праздник оплачивается и как ночная, и как праздничная.
|
||||||
|
|
||||||
|
## 3. Что из этого поддерживает движок
|
||||||
|
|
||||||
|
Реализовано в `src/domain/payroll/rule.ts`: процент от показателя, ставка за час и за смену, ставка за единицу, агрегаты по позициям с фильтрами (количество, сумма, среднее, число уникальных значений), поштучная оценка строки по её атрибутам, шкала и прогрессивная шкала, фонд с делением по времени присутствия и весами, условия с И/ИЛИ, гарантия и потолок, округление, типизированные часы (ночные, сверхурочные, праздничные), окно агрегации шире дня (неделя, декада, месяц, период).
|
||||||
|
|
||||||
|
Пресеты в `src/domain/payroll/presets.ts`: общепит (фонд, личный процент, официант с допродажами, администратор с гарантией, почасовая, премия за порог выручки), розница и продажи (две шкалы), доставка (два варианта), услуги с мастерами, сменная работа с ночными и сверхурочными, B2B с месячной прогрессией, простая почасовая.
|
||||||
|
|
||||||
|
Не реализовано и вынесено в следующий шаг:
|
||||||
|
|
||||||
|
1. **Блок вычета.** Удержание должно быть отдельной сущностью с основанием, лимитом и следом в аудите, а не отрицательным слагаемым формулы — см. [правовые ограничения](PAYROLL-LEGAL-LIMITS.md).
|
||||||
|
2. **Фонд сверх тарифа** как готовый блок (сейчас выражается комбинацией).
|
||||||
|
3. **Линтер правил**: предупреждение о начислении ниже МРОТ пропорционально отработанному, об отсутствии оплаты ночных и сверхурочных, об отрицательных узлах с дисциплинарным основанием.
|
||||||
|
|
||||||
|
## 4. Чего рынок не подтверждает
|
||||||
|
|
||||||
|
Эти конструкции встречаются в обсуждениях, но подтверждения как отраслевой нормы нет — в пресетах они допустимы как значения по умолчанию с пометкой «настройте под себя»:
|
||||||
|
|
||||||
|
- многоступенчатая шкала процента мастера салона (практика — плоский процент по категории либо порог на превышение);
|
||||||
|
- сдельщина «за строку» у сборщиков заказов (доминирует почасовая с бонусом за выработку);
|
||||||
|
- конкретная прогрессия процента персонального тренера по порогам числа тренировок (структура уровней подтверждается, ставки — нет).
|
||||||
131
docs/REQ-001-PAYROLL.md
Normal file
131
docs/REQ-001-PAYROLL.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# REQ-001: Универсальный расчёт заработной платы
|
||||||
|
|
||||||
|
- Статус: утверждено владельцем 2026-09-23, реализуется
|
||||||
|
- Контур: управленческий учёт. Официальный расчёт (НДФЛ, страховые взносы, МРОТ, кадровые документы) — следующий этап, схема данных готовится под него заранее
|
||||||
|
- Проверочный пример: таблица владельца «Доходы и расходы 2026 год», листы «График» и «Расчет ЗП с доп. поваром»
|
||||||
|
|
||||||
|
## 1. Задача
|
||||||
|
|
||||||
|
Модуль считает переменную часть зарплаты **для любой организации**, а не для одного кафе. Главный сценарий — переход с Excel: основная масса малого бизнеса ведёт расчёт в таблицах со своими формулами, и продукт обязан принять эти формулы, а не навязать свои.
|
||||||
|
|
||||||
|
Отсюда три обязательных свойства:
|
||||||
|
|
||||||
|
1. **Формулу задаёт клиент.** Правило оплаты — не код, а настройка: собирается в визуальном конструкторе из блоков, хранится как структура, показывается человеку словами.
|
||||||
|
2. **Переход без потерь.** Историю из таблицы можно загрузить, сверить с расчётом модуля и убедиться, что суммы совпадают, прежде чем отказываться от Excel.
|
||||||
|
3. **Формулы меняются со временем.** Изменение ставки или правила создаёт новую версию; прошлые ведомости пересчёту не подлежат и воспроизводятся в точности.
|
||||||
|
|
||||||
|
Остальное — учёт смен (факт из терминала плюс подтверждение управляющего), ведомость, выплаты, остатки, мониторинг и кабинет сотрудника в мобильном приложении.
|
||||||
|
|
||||||
|
## 2. Словарь
|
||||||
|
|
||||||
|
| Термин | Значение |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Подразделение (`OrganizationUnit`) | Точка, цех, магазин, участок. К нему привязаны показатели дня и смены |
|
||||||
|
| Показатель дня | Числовая величина подразделения за день: выручка, число чеков, объём производства. Состав определяет клиент |
|
||||||
|
| Позиция | Оплачиваемая строка дня сотрудника: заказ, доставка, услуга, партия. Имеет атрибуты: сумма, дальность, тип клиента |
|
||||||
|
| Смена (`WorkSession`) | Факт работы сотрудника в подразделении за операционный день |
|
||||||
|
| Правило оплаты (`PayRule`) | Дерево блоков, по которому считается начисление. Версионируется |
|
||||||
|
| Параметр сотрудника | Значение, которое правило подставляет для конкретного человека: ставка, процент, надбавка, гарантия, план |
|
||||||
|
| Фонд дня | Сумма, которую участники смены делят между собой. Задаётся правилом подразделения |
|
||||||
|
| Ведомость (`PayrollRun`) | Расчёт за период со строками по сотруднику и дню |
|
||||||
|
| Остаток | Начислено минус выплачено нарастающим итогом. Плюс — долг перед сотрудником |
|
||||||
|
|
||||||
|
## 3. Конструктор правил
|
||||||
|
|
||||||
|
Правило собирается из блоков. Набор покрывает схемы, которые встречаются на практике:
|
||||||
|
|
||||||
|
| Блок | Назначение |
|
||||||
|
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Показатель, параметр, часы, число участников | Источники значений |
|
||||||
|
| Сложение, вычитание, умножение, деление | Арифметика |
|
||||||
|
| Процент от значения | Процент от выручки, от продаж, от суммы заказов |
|
||||||
|
| Доля из фонда | Командная схема: фонд делится между участниками смены |
|
||||||
|
| Сумма и количество позиций с фильтром | Сдельщина: «сумма заказов дешевле 5000», «количество доставок» |
|
||||||
|
| За каждую позицию | Оплата за единицу, в том числе по шкале от атрибута позиции |
|
||||||
|
| Шкала по ступеням | Ставка выбирается по достигнутому уровню и применяется ко всей сумме |
|
||||||
|
| Прогрессивная шкала | Каждая ступень применяется к своему слою: 3% до 100 тыс, 5% дальше |
|
||||||
|
| Условие | Премия при выполнении условий: выручка выше порога и на смене не меньше трёх человек |
|
||||||
|
| Наибольшее, наименьшее | Гарантированный минимум и потолок выплаты |
|
||||||
|
| Округление | Шаг округления строки начисления |
|
||||||
|
| Ночные, сверхурочные, праздничные часы | Доплаты, обязательные по ТК РФ: 20% за ночные, 1,5 и 2 за переработку, двойная за праздник |
|
||||||
|
| Окно агрегации | Показатель или позиции берутся за смену, день, неделю, декаду или месяц: фонд считается на дне, а прогрессивная шкала обычно на месяце |
|
||||||
|
|
||||||
|
Полный разбор отраслевых схем — в [справочнике схем](PAYROLL-SCHEMES-CATALOGUE.md); ограничения, которые нельзя нарушать, — в [правовых ограничениях](PAYROLL-LEGAL-LIMITS.md).
|
||||||
|
|
||||||
|
Примеры правил, которые из этого собираются:
|
||||||
|
|
||||||
|
```
|
||||||
|
Повар в команде: доля из фонда + ставка × часы + надбавка
|
||||||
|
Администратор: наибольшее(процент от выручки + ставка × часы; гарантия смены)
|
||||||
|
Продавец: оплата смены + прогрессивный процент от личных продаж
|
||||||
|
Курьер: ставка × часы
|
||||||
|
+ процент от суммы заказов дешевле 5000 ₽
|
||||||
|
+ за каждый заказ дороже 5000 ₽: по шкале дальности (250 или 500 ₽)
|
||||||
|
Мастер услуг: наибольшее(50% своих клиентов + 35% клиентов салона; гарантия смены)
|
||||||
|
Премия за выручку: если выручка ≥ порога и участников ≥ 3, добавить премию
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждое правило движок умеет пересказать словами — этот же текст показывается сотруднику в расшифровке суммы.
|
||||||
|
|
||||||
|
### 3.1 Фонд и неполная смена
|
||||||
|
|
||||||
|
Фонд подразделения за день задаётся отдельным правилом (например, 11% от выручки). Он распределяется **по времени присутствия**: в каждый отрезок смены фонд делится между теми, кто в этот момент работал. Если участник ушёл раньше, он получает долю только за свои часы, а остальные забирают оставшуюся часть. При равных сменах это в точности «фонд ÷ число участников». Вес участника настраивается: полный участник, половина (например, дополнительный сотрудник) или без участия.
|
||||||
|
|
||||||
|
### 3.2 Округление и хранение
|
||||||
|
|
||||||
|
Каждая строка начисления округляется с настраиваемым шагом, по умолчанию до рубля. Остаток от округления остаётся в фонде и не приписывается сотруднику. Суммы хранятся в копейках (`BIGINT`), валюта — ISO 4217.
|
||||||
|
|
||||||
|
### 3.3 Версии правил
|
||||||
|
|
||||||
|
Правило и его параметры не редактируются на месте: правка создаёт следующую версию с датой начала действия. Ведомость хранит ссылку на версию и полный след расчёта (входные показатели, фонд, доля, параметры, текст правила), поэтому любую сумму можно объяснить и воспроизвести спустя год.
|
||||||
|
|
||||||
|
## 4. Периоды, ведомость, выплаты
|
||||||
|
|
||||||
|
- Период настраивается: неделя (в том числе начиная с четверга, как в таблице владельца), две недели, месяц.
|
||||||
|
- Статусы ведомости: `draft → calculated → approved → paid`. После `approved` строки не пересчитываются; исправление — отдельной корректировкой.
|
||||||
|
- Выплаты вводятся фактической суммой; разница с начисленным автоматически становится остатком и переносится дальше.
|
||||||
|
- Реестр начислений и выплат — только на добавление, на уровне прав базы данных.
|
||||||
|
|
||||||
|
## 5. Учёт часов
|
||||||
|
|
||||||
|
- Источник — события терминала (`check_in` / `check_out`) с идемпотентным ключом.
|
||||||
|
- Смена попадает в расчёт только после подтверждения управляющим.
|
||||||
|
- Корректировка часов не меняет исходные события: правка хранится отдельно с причиной.
|
||||||
|
- Незакрытая смена помечается аномалией после нормы плюс два часа.
|
||||||
|
|
||||||
|
## 6. Переход с Excel
|
||||||
|
|
||||||
|
1. **Загрузка таблицы.** Клиент загружает свой файл; мастер определяет листы и колонки: сотрудник, дата, подразделение, часы, сумма, показатели дня.
|
||||||
|
2. **Подбор формулы.** Система предлагает пресет отрасли и подбирает параметры сотрудников по истории, после чего показывает, на скольких днях расчёт совпал с таблицей и где разошёлся.
|
||||||
|
3. **Разбор расхождений.** По каждому расхождению видно, из чего сложилась сумма модуля и какая сумма стоит в таблице. Обычно расхождения — это ручные правки в Excel или изменение ставки, не отражённое в формуле.
|
||||||
|
4. **Параллельный период.** Клиент неделю ведёт расчёт и в таблице, и в модуле, сверяет итоги и после совпадения отключает таблицу.
|
||||||
|
|
||||||
|
Проверка метода на реальной таблице владельца: правило «фонд 11% ÷ участники + 100 ₽/ч + личная надбавка» воспроизводит суммы поваров точно; администраторы считаются как «процент от выручки + почасовая, но не ниже 3000 ₽». Расхождения объясняются изменением ставок в течение года и ручными правками — что и подтверждает необходимость версий и режима сверки.
|
||||||
|
|
||||||
|
## 7. Мониторинг
|
||||||
|
|
||||||
|
Ежедневный экран по подразделению: показатели дня, кто на смене, фонд, начислено, доля фонда оплаты в выручке, неподтверждённые и незакрытые смены, дни без введённых показателей. Недельный и месячный итог — по ведомости.
|
||||||
|
|
||||||
|
## 8. Доступ
|
||||||
|
|
||||||
|
| Роль | Права |
|
||||||
|
| ----------- | ------------------------------------------------------------------------------- |
|
||||||
|
| Владелец | всё, включая утверждение ведомостей, правил и мониторинг по всем подразделениям |
|
||||||
|
| Управляющий | показатели, подтверждение смен, расчёт и выплаты по своему подразделению |
|
||||||
|
| Сотрудник | только свои смены, начисления, выплаты, остаток и расшифровка сумм |
|
||||||
|
|
||||||
|
Сотрудник получает доступ по record-level правам платформы (`scope: own`). Данные других сотрудников недоступны ни в API, ни в приложении.
|
||||||
|
|
||||||
|
## 9. Границы этапа
|
||||||
|
|
||||||
|
**Входит:** подразделения, сотрудники, показатели и позиции, конструктор правил с версиями, пресеты отраслей, приём отметок терминала, смены и подтверждение, расчёт, ведомость, выплаты, остатки, импорт из Excel и сверка, API для мобильного приложения, мониторинг.
|
||||||
|
|
||||||
|
**Не входит:** НДФЛ и страховые взносы, кадровые документы, отпуска и больничные, планирование графиков, выгрузка в 1С, официальный табель Т-13, автоматическая интеграция с кассой (предусмотрена точка расширения — показатель с источником «модуль»).
|
||||||
|
|
||||||
|
## 10. Вопросы, которые остаются к владельцу по его пресету
|
||||||
|
|
||||||
|
1. Дополнительный сотрудник и стажёр считаются в делителе фонда как половина участника? В таблице встречаются оба варианта.
|
||||||
|
2. Гарантия администратора (3000 ₽) — за смену любой длительности или пропорционально часам?
|
||||||
|
3. Премия за выручку: при каком пороге и какая сумма, и зависит ли она от числа участников смены?
|
||||||
|
4. Перенос остатка: бессрочно или закрывается в конце месяца?
|
||||||
|
5. Выплаты: только наличные или бывают переводы, и нужно ли фиксировать, кто выдал деньги?
|
||||||
|
|
@ -4,13 +4,16 @@
|
||||||
|
|
||||||
Runtime modes and ports, ULID/event envelope, PostgreSQL RLS infrastructure, outbox/inbox/idempotency/audit, manifest/settings/OpenAPI validation, health/version, Docker/Compose, CI and documentation.
|
Runtime modes and ports, ULID/event envelope, PostgreSQL RLS infrastructure, outbox/inbox/idempotency/audit, manifest/settings/OpenAPI validation, health/version, Docker/Compose, CI and documentation.
|
||||||
|
|
||||||
|
Подтверждённый Payroll foundation: чистый движок правил, отраслевые пресеты, недельные периоды, распределение фонда, типизированные часы, схема версионированных правил/ведомостей/выплат, append-only ledger и тесты на примерах владельца.
|
||||||
|
|
||||||
## Следующий этап — только после требований владельца
|
## Следующий этап — только после требований владельца
|
||||||
|
|
||||||
1. Уточнить jurisdiction, terminology, data classification and ownership.
|
1. Реализовать API и административный интерфейс подразделений, показателей, правил оплаты и ведомостей поверх готового доменного ядра.
|
||||||
2. Согласовать Employee Directory и Employment workflows, затем создать первую vertical slice.
|
2. Связать подтверждённые смены терминала с `WorkSession`, сохранив ручную корректировку и аудит.
|
||||||
3. Отдельно описать Organization Structure, Scheduling, Time & Attendance, Leave, Payroll, Documents, Audit/Reporting.
|
3. Добавить импорт и сверку с таблицами владельца перед переходом на расчёт в модуле.
|
||||||
4. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
|
4. Подключить мобильный кабинет сотрудника только к серверному API — без формул и расчёта на устройстве.
|
||||||
5. Утвердить event catalog/OpenAPI changes до публикации.
|
5. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
|
||||||
6. Утвердить backup RPO/RTO/retention и провести documented restore drill.
|
6. Отдельно согласовать официальный расчёт, Scheduling, Leave, Documents и Audit/Reporting.
|
||||||
|
7. Утвердить backup RPO/RTO/retention и провести documented restore drill.
|
||||||
|
|
||||||
Никакой пункт не означает автоматически готовую бизнес-функцию или срок.
|
Никакой пункт не означает автоматически готовую бизнес-функцию или срок.
|
||||||
|
|
|
||||||
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
|
||||||
|
```
|
||||||
59
docs/TENANT-REHOME.md
Normal file
59
docs/TENANT-REHOME.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Перенос арендатора Staff
|
||||||
|
|
||||||
|
`scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся.
|
||||||
|
|
||||||
|
CLI по умолчанию выполняет только read-only dry-run; `--dry-run` можно указать явно. Совместимые имена `--from`/`--to` и `--json` принимаются для rehearsal-обвязок; вывод всегда JSON. Сначала на восстановленной копии базы выполните dry-run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DATABASE_ADMIN_URL = 'postgresql://…'
|
||||||
|
node scripts/rehome-tenant.mjs --from <ULID> --to <ULID> --dry-run --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Для совместимости со старыми локальными инструкциями также принимаются `DATABASE_REHOME_URL` и `DATABASE_MIGRATION_URL` как fallback. `--apply` по-прежнему требует явный флаг, actor и проверенный `--backup-evidence`; aliases и default dry-run не разрешают запись сами по себе.
|
||||||
|
|
||||||
|
Dry-run открывает транзакцию `READ ONLY` и выводит только идентификаторы переноса и количество строк по таблицам. Он не блокирует записи; поэтому результат является снимком для планирования, а не гарантией актуальности на момент apply. На копии и в согласованное окно с остановленным приложением повторите dry-run непосредственно перед переносом.
|
||||||
|
|
||||||
|
## Обязательное подтверждение восстановления
|
||||||
|
|
||||||
|
Apply требует `--backup-evidence <путь>` и читает JSON отчёт. Само имя файла и существование файла не считаются доказательством. JSON должен содержать сведения о зашифрованном off-host `pg_dump -Fc` и успешном restore drill именно этого backup:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backup": {
|
||||||
|
"format": "pg_dump -Fc",
|
||||||
|
"encrypted": true,
|
||||||
|
"offHost": true,
|
||||||
|
"sha256": "<64 hex characters>",
|
||||||
|
"createdAt": "2026-09-26T08:00:00Z"
|
||||||
|
},
|
||||||
|
"restoreDrill": {
|
||||||
|
"status": "passed",
|
||||||
|
"backupSha256": "<same 64 hex characters>",
|
||||||
|
"restoredAt": "2026-09-26T08:30:00Z",
|
||||||
|
"verifiedAt": "2026-09-26T08:45:00Z",
|
||||||
|
"isolatedDatabase": "krona_staff_restore_check"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот файл удостоверяет результаты, предоставленные оператором; скрипт не проверяет доступ к хранилищу, шифрование или пригодность backup самостоятельно. Сохраните исходный backup, хеш и протокол restore drill вне сервера приложения.
|
||||||
|
|
||||||
|
## Apply
|
||||||
|
|
||||||
|
Проверьте dry-run и backup evidence, остановите приложение, затем запускайте в согласованное окно. `--actor` — ULID уполномоченного пользователя Core, не пароль или секрет:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/rehome-tenant.mjs --apply `
|
||||||
|
--source <ULID> --target <ULID> --actor <CORE_USER_ULID> `
|
||||||
|
--backup-evidence .\evidence\restore-drill.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply использует одну транзакцию, сериализуемый уровень изоляции, advisory lock и детерминированные `ACCESS EXCLUSIVE` locks всех таблиц `public.staff_*`. Скрипт отказывает, если целевой tenant уже содержит строки, таблица лишена `tenant_id` или FORCE RLS, обнаружен неизвестный пользовательский trigger либо внешний FK, или роль БД не может безопасно выполнить перенос. Существующие FK сохраняются с их определениями и состоянием validation; если проверка после пересоздания не совпала, транзакция откатывается.
|
||||||
|
|
||||||
|
Отчёт содержит количества перенесённых строк и суммы `staff_payroll_ledger.amount_minor` отдельно по валюте. В `staff_audit_log` перенос сам создаёт ровно одну новую запись `tenant.rehomed` под целевым tenant. Поэтому число строк `staff_audit_log` после commit равно исходному числу строк источника плюс одна; по всем остальным таблицам количество равно исходному. Запись аудита включает from/to, исходные количества, суммы реестра, Core actor, correlation ID и SHA-256/время backup и проверки restore.
|
||||||
|
|
||||||
|
При ошибке stdout/stderr не содержит URL подключения, значений строк или текста серверной ошибки: транзакция откатывается, а в stderr печатается только безопасное сообщение и, если есть, пятисимвольный PostgreSQL SQLSTATE. После успешного commit отдельно проверьте `SELECT DISTINCT tenant_id` по каждой staff таблице, количества строк, сумму реестра по валюте и наличие записи `tenant.rehomed`.
|
||||||
|
|
||||||
|
## Интеграционный тест
|
||||||
|
|
||||||
|
Тест `tests/integration/rehome-tenant.test.ts` предназначен только для отдельной пустой PostgreSQL 17 базы, имя которой заканчивается на `_rehome_test`. Он требует `DATABASE_REHOME_TEST_URL` и явное `DATABASE_REHOME_TEST_CONFIRM=I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE`, создаёт в ней роль приложения без пароля при необходимости и применяет миграции на чистой схеме. В конце тест очищает таблицы `public.staff_*`; проверка пустоты выполняется до вставки фикстур. Не указывайте production, pilot или общую базу.
|
||||||
133
docs/TERMINAL-API-REQUIREMENTS.md
Normal file
133
docs/TERMINAL-API-REQUIREMENTS.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Krona Staff Terminal: HTTP API Requirements & Specification
|
||||||
|
|
||||||
|
**Document Version:** 1.0.0
|
||||||
|
**Status:** Approved for Production
|
||||||
|
**Target Component:** Krona Staff Backend & Krona Staff Terminal / Windows Service Sync Client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Architectural Scope
|
||||||
|
|
||||||
|
The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages.
|
||||||
|
|
||||||
|
### Security Guarantees
|
||||||
|
|
||||||
|
1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs.
|
||||||
|
2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden.
|
||||||
|
3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Attendance Sync Endpoint Specification
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/staff/attendance/events
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Headers
|
||||||
|
|
||||||
|
| Header Name | Type | Required | Description | Example |
|
||||||
|
| ----------------- | ------ | -------- | ------------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
|
||||||
|
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
||||||
|
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
||||||
|
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
||||||
|
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Request Payloads
|
||||||
|
|
||||||
|
The synchronization client supports sending both individual attendance records and batched events.
|
||||||
|
|
||||||
|
### 3.1 Single Event Record Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
|
||||||
|
"employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||||
|
"eventType": "CHECK_IN",
|
||||||
|
"occurredAtUtc": "2026-09-24T08:30:00.000Z",
|
||||||
|
"deviceId": "TERM-SPB-OFFICE-01",
|
||||||
|
"method": "BIOMETRIC_FINGERPRINT",
|
||||||
|
"isAutomatic": false,
|
||||||
|
"verificationScore": 87
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Batch Sync Payload
|
||||||
|
|
||||||
|
When draining the offline sync queue after network recovery, the client posts an array of queued events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deviceId": "TERM-SPB-OFFICE-01",
|
||||||
|
"batchId": "01J8R5R2H0K5N2P9Q7R1S3T4U5",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
|
||||||
|
"employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||||
|
"eventType": "CHECK_IN",
|
||||||
|
"occurredAtUtc": "2026-09-24T08:30:00.000Z",
|
||||||
|
"method": "BIOMETRIC_FINGERPRINT",
|
||||||
|
"isAutomatic": false,
|
||||||
|
"verificationScore": 87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eventId": "01J8R6A1B2C3D4E5F6G7H8J9K0",
|
||||||
|
"employeeId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
|
||||||
|
"eventType": "CHECK_OUT",
|
||||||
|
"occurredAtUtc": "2026-09-24T17:30:00.000Z",
|
||||||
|
"method": "SCHEDULED_AUTO",
|
||||||
|
"isAutomatic": true,
|
||||||
|
"verificationScore": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Definitions
|
||||||
|
|
||||||
|
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
|
||||||
|
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
|
||||||
|
- **`eventType`** (`string`): Enum representing the punch action:
|
||||||
|
- `CHECK_IN`: Arrival / work start.
|
||||||
|
- `CHECK_OUT`: Departure / work end.
|
||||||
|
- `BREAK_START`: Rest break start.
|
||||||
|
- `BREAK_END`: Rest break end.
|
||||||
|
- **`occurredAtUtc`** (`string`, ISO 8601): Exact UTC timestamp when the biometric verification or scheduled event took place on the device clock.
|
||||||
|
- **`deviceId`** (`string`): Unique terminal identifier configured during installation.
|
||||||
|
- **`method`** (`string`): Verification mechanism:
|
||||||
|
- `BIOMETRIC_FINGERPRINT`: Authenticated on hardware scanner with score >= threshold.
|
||||||
|
- `MANUAL_PIN`: Authenticated via administrator override PIN.
|
||||||
|
- `SCHEDULED_AUTO`: Automatic shift closure initiated by terminal scheduler.
|
||||||
|
- **`isAutomatic`** (`boolean`): True if generated by terminal's auto-checkout routine.
|
||||||
|
- **`verificationScore`** (`integer`, null allowed): Raw biometric match score (0-100), omitted or null for automatic/manual entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HTTP Response Codes & Client Handling
|
||||||
|
|
||||||
|
| HTTP Status | Meaning | Terminal Client Action |
|
||||||
|
| ------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
|
||||||
|
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
|
||||||
|
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
|
||||||
|
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
|
||||||
|
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
|
||||||
|
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
|
||||||
|
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offline Queue Mechanics
|
||||||
|
|
||||||
|
1. **Storage Location:** `%ProgramData%\Krona\StaffTerminal\terminal-state.dat`.
|
||||||
|
2. **Protection:** Windows DPAPI `DataProtectionScope.LocalMachine` with restricted ACL (SYSTEM, Administrators, Users).
|
||||||
|
3. **Queue Discipline:** Strict FIFO ordering by `occurredAtUtc`.
|
||||||
|
4. **Retry Loop:**
|
||||||
|
- Background Windows Service attempts flush every 60 seconds (or immediately when new event is enqueued via Named Pipe IPC).
|
||||||
|
- If server is unreachable, delay multiplies by 1.5x up to 5 minutes cap.
|
||||||
|
- Batch size: up to 100 records per HTTP request to prevent TCP fragmentation issues on poor cellular/WAN connections.
|
||||||
81
docs/adr/ADR-002-payroll-domain-and-platform-alignment.md
Normal file
81
docs/adr/ADR-002-payroll-domain-and-platform-alignment.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# ADR-002: Домен расчёта зарплаты и выравнивание с контрактом платформы
|
||||||
|
|
||||||
|
- Status: Accepted for REQ-001
|
||||||
|
- Date: 2026-09-23
|
||||||
|
- Контекст требований: [REQ-001](../REQ-001-PAYROLL.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Владелец подтвердил требования к расчёту зарплаты (управленческий контур), мониторингу смен и мобильному кабинету сотрудника. Модуль должен остаться блоком Krona Business Platform.
|
||||||
|
|
||||||
|
Проверка контрактов платформы (`ochenstarik/business-platform`, ветка `main`) показала:
|
||||||
|
|
||||||
|
- HR, Employee и payroll в платформе не реализованы; `docs/82-TIME-ATTENDANCE.md` и `docs/83-WORK-TIME-ENGINE.md` — только спецификации со статусом `Pilot 1: Deferred`;
|
||||||
|
- `docs/06-SOURCE-OF-TRUTH.md` объявляет владельцем справочника `Employee` ядро, но в коде ядра такого справочника нет;
|
||||||
|
- сущность «торговая точка» (`Branch` / `Location`) объявлена в `08-MULTITENANCY.md` и `05-CONTEXT-MAP.md`, но в коде отсутствует; в Pilot 1 организация ровно одна на tenant;
|
||||||
|
- рантайм модулей неполон: права из манифеста нельзя выдать роли, доставка событий модулю не работает (`PLAN-RAZRABOTKI.md`, пункты С-6 и С-4);
|
||||||
|
- исторические черновики расходились с нормативным контрактом платформы по формату прав и конверту события; интеграция следует актуальному `109-PLATFORM-BLOCK-CONTRACT.md`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Домен
|
||||||
|
|
||||||
|
Модуль универсальный: он не знает ни про повара, ни про кафе. Расчёт строится на сущностях `OrganizationUnit` (подразделение), `Employee`, `PayRule` (правило оплаты), `WorkSession` (смена), `PayrollRun` (ведомость) со строками `PayrollLine`, плюс входные данные: показатели дня (`staff_daily_metrics`) и позиции (`staff_work_items`) — состав того и другого определяет клиент. Деньги — `BIGINT` в копейках плюс `currency_code`. Ни правило, ни его параметры не редактируются на месте: изменение создаёт новую версию с датами действия, поэтому прошлые ведомости воспроизводимы.
|
||||||
|
|
||||||
|
### 2. Правило оплаты — дерево блоков, а не текст формулы
|
||||||
|
|
||||||
|
Правило хранится структурой (JSONB) и собирается визуальным конструктором: клиенты приходят из Excel и должны переносить свои формулы сами, без программиста и без изучения синтаксиса. Из этого следует:
|
||||||
|
|
||||||
|
- **никакого `eval` и никакого разбора текста** — дерево заведомо безопасно и валидируется перед сохранением;
|
||||||
|
- то же дерево пересказывается словами (`describeRule`) и служит расшифровкой суммы для сотрудника;
|
||||||
|
- набор блоков подобран по практике отраслей: процент от показателя, ставка за час и за единицу, фонд с делением по времени присутствия, агрегаты по позициям с фильтрами, ступенчатая и прогрессивная шкалы, условные премии, гарантия и потолок, округление;
|
||||||
|
- отраслевые пресеты (общепит, розница, доставка, услуги, почасовая) — это заранее собранные деревья, а не ветки в коде.
|
||||||
|
|
||||||
|
Текстовый редактор формул может появиться позже как второй способ ввода поверх той же структуры.
|
||||||
|
|
||||||
|
### 3. Расчёт — чистая функция
|
||||||
|
|
||||||
|
Движок не обращается к базе: на вход подаётся снимок дня (показатели, позиции, участники с их правилами, часами и параметрами), на выходе — строки с полным следом расчёта (фонд, делитель, доля, параметры, входные показатели, текст правила). След сохраняется в строке ведомости: любую сумму нужно уметь объяснить спустя год, а расчёт обязан быть детерминированным и покрытым тестами на реальных днях.
|
||||||
|
|
||||||
|
### 4. Начисления и выплаты — append-only реестр
|
||||||
|
|
||||||
|
`staff_payroll_ledger` пишется только вставкой (`REVOKE UPDATE, DELETE` для роли приложения, как того требует `PILOT-1-RBAC-MATRIX` платформы). Остаток сотрудника («Перенос» в таблице) — агрегат реестра, а не изменяемое поле. Исправление ошибки — сторнирующая запись, а не правка.
|
||||||
|
|
||||||
|
### 5. Биометрия не покидает терминал
|
||||||
|
|
||||||
|
Сервер принимает от терминала только `employee_id`, тип отметки и время. Шаблоны отпечатков остаются в локальном зашифрованном хранилище терминала. Это снижает объём обрабатываемых биометрических персональных данных на сервере до нуля и соответствует `85-BIOMETRIC-DATA-SECURITY.md` платформы. Требование письменного согласия на обработку биометрии в терминале сохраняется (аудит, пункт A2).
|
||||||
|
|
||||||
|
### 6. Временная собственность на `Employee` и `OrganizationUnit`
|
||||||
|
|
||||||
|
Пока в ядре нет ни справочника сотрудников, ни филиалов, Staff ведёт их сам, но:
|
||||||
|
|
||||||
|
- `Employee.core_user_id` — nullable-ссылка на учётную запись ядра, `User != Employee`;
|
||||||
|
- таблицы содержат `external_ref` для сопоставления при переносе владения в Core;
|
||||||
|
- модуль не заводит собственные таблицы пользователей, ролей и паролей.
|
||||||
|
|
||||||
|
Когда ядро получит `Employee`/`Branch`, Staff переходит на них через adapters; это зафиксировано как обязательство, а не «потом разберёмся».
|
||||||
|
|
||||||
|
### 7. Форматы платформы: следуем коду, а не документации
|
||||||
|
|
||||||
|
- права — точечные в нормативном формате платформы `staff:<resource>:<action>`, например `staff:payroll:read`; существующие права терминала сохраняются;
|
||||||
|
- сущности в манифесте — голый PascalCase (`PayrollRun`), как требует `packages/contracts/src/entity-index.ts`;
|
||||||
|
- события — плоский конверт каталога Pilot 1 (`event_id`, `event_type`, `version`, `tenant_id`, `occurred_at`, `actor_id`, `correlation_id`, `payload`), имена вида `staff.payroll.run_approved`;
|
||||||
|
- ответы API — конверт `data`/`meta`/`errors` платформы, а не RFC 7807; идемпотентность — заголовок `Idempotency-Key`.
|
||||||
|
|
||||||
|
Расхождения документации и кода платформы вынесены владельцу отдельным списком; выбор в пользу кода сделан потому, что именно он валидирует установку модуля.
|
||||||
|
|
||||||
|
### 8. Standalone остаётся рабочим режимом
|
||||||
|
|
||||||
|
Рантайм модулей платформы сегодня не может ни выдать права модуля роли, ни доставить событие модулю. Поэтому `standalone` — не временная заглушка, а поддерживаемый режим до готовности ядра: свой JWT для мобильного приложения и терминала, свои проверки прав. В `platform` режиме те же порты отдаются адаптерам ядра. Оба режима обязаны давать одинаковый результат расчёта.
|
||||||
|
|
||||||
|
### 9. Мобильное приложение — отдельный клиент поверх версионированного API
|
||||||
|
|
||||||
|
Приложение (React Native + Expo) не получает доступ к базе и не содержит правил расчёта: только вызовы API и отображение объяснения, посчитанного сервером. Контракт для него фиксируется в `api/openapi.yaml` и описан в [APP-CORE.md](../APP-CORE.md).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Расчёт воспроизводим, история ставок сохраняется, ведомости прошлых периодов не «плывут» при изменении ставок. Append-only реестр исключает тихую правку денег. Цена: больше таблиц и явная работа с версиями ставок вместо редактирования одного поля.
|
||||||
|
|
||||||
|
Временное владение `Employee` и `OrganizationUnit` — сознательный долг перед контрактом платформы; он погашается миграцией на Core, когда там появятся эти сущности.
|
||||||
|
|
||||||
|
Отклонено: считать зарплату на клиенте; хранить деньги в `NUMERIC`; редактировать ставки без версий; переносить шаблоны отпечатков на сервер; ждать готовности рантайма модулей платформы, ничего не делая.
|
||||||
206
docs/audit/2026-09-18-audit.md
Normal file
206
docs/audit/2026-09-18-audit.md
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
# Аудит репозитория krona-staff (третий проход)
|
||||||
|
|
||||||
|
- Дата: 2026-09-18
|
||||||
|
- Ревизия: `7c8e7fc` — ветка `codex/bootstrap-platform-foundation`, 9 коммитов
|
||||||
|
- Предыдущие проходы: `14ed610` (первый отчёт), `b88ff91` (второй отчёт)
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
|
||||||
|
## 1. Что изменилось
|
||||||
|
|
||||||
|
Два новых коммита, 53 файла, +1725/−1069 строк:
|
||||||
|
|
||||||
|
- `64ac5c0 fix: enforce tenant isolation and runtime contracts` — Node-сервис, БД, Docker, CI;
|
||||||
|
- `7c8e7fc fix: close terminal audit findings` — терминал, документация, лицензия ZKTeco.
|
||||||
|
|
||||||
|
Добавлен `docs/AUDIT-REMEDIATION-2026-09-18.md` — ответ на **первый** отчёт (ревизия `14ed610`). Замечания второго отчёта (Н1–Н10, ревизия `b88ff91`) в нём не учтены: коммиты готовились до него.
|
||||||
|
|
||||||
|
Главное из сделанного:
|
||||||
|
|
||||||
|
- разделены роли PostgreSQL: `krona_migrator` (миграции) и `krona_staff_app` (`NOSUPERUSER NOBYPASSRLS`);
|
||||||
|
- мигратор ведёт `schema_migrations`, берёт advisory lock, откатывает только последнюю миграцию и только при `ALLOW_MIGRATION_DOWN=true`;
|
||||||
|
- миграция `0002` добавляет `subject`/`actor` в outbox, `CHECK` на ULID и делает аудит-лог доступным приложению только на чтение и добавление;
|
||||||
|
- маршруты перенесены под `/api/v1/staff`, `/ready` отвечает `503` без деталей, `x-correlation-id` проверяется как ULID и возвращается в ответе;
|
||||||
|
- появились интеграционные тесты: реальная проверка изоляции тенантов на PostgreSQL и сверка OpenAPI с фактическими маршрутами; таймаут тестов поднят до 15 с;
|
||||||
|
- Redis и BullMQ удалены из зависимостей и compose; образы Docker закреплены по digest; `format:check` и миграции добавлены в CI;
|
||||||
|
- в терминале: экспорт журнала в CSV, маскирование имён на публичном экране, настраиваемая максимальная длительность смены, ограничение журнала в 100 000 записей, глобальные обработчики необработанных исключений, ротация `crash.log`, автозагрузка выключена по умолчанию, очищен `Package.appxmanifest`, добавлена EULA ZKTeco.
|
||||||
|
|
||||||
|
## 2. Проверки
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `npm ci` | OK |
|
||||||
|
| `npm run check` (lint, typecheck, тесты, миграции, контракты, build) | **Пройдено полностью** |
|
||||||
|
| `npm run format:check` | Пройдено по коду репозитория (замечание только к этому файлу отчёта, он не в репозитории) |
|
||||||
|
| `npm audit --audit-level=moderate` | 0 уязвимостей |
|
||||||
|
| `dotnet build` терминала (Release, x64) | OK, 0 ошибок, 0 предупреждений |
|
||||||
|
| Запрос `GET /live` (корень) | **404** — цель Docker HEALTHCHECK, см. Н11 |
|
||||||
|
| Запрос `GET /api/v1/staff/live` | 200 |
|
||||||
|
| Запрос `GET /api/v1/staff/ready` при сбое БД | 503, тело `{"status":"unavailable"}`, текст ошибки БД не раскрывается |
|
||||||
|
| Некорректный `x-correlation-id` | Заменяется на сгенерированный ULID |
|
||||||
|
| `NODE_ENV=production` + `standalone` | Запуск запрещён с понятной ошибкой |
|
||||||
|
|
||||||
|
**Не проверялось:** Docker, PostgreSQL и сканер SLK20R на машине аудита отсутствуют. Тест изоляции тенантов локально пропускается (`DATABASE_TEST_URL` не задан) — в CI он выполняется, но результата прогона CI я не видел.
|
||||||
|
|
||||||
|
## 3. Сводка
|
||||||
|
|
||||||
|
| Категория | Итог |
|
||||||
|
| ----------------------------------- | --------------------------------------------------------- |
|
||||||
|
| Закрыто из первого отчёта | 24 находки из 33 + сознательно оставлена №24 |
|
||||||
|
| Осталось из второго отчёта (Н1–Н10) | 9 открыто, 1 частично |
|
||||||
|
| Новые находки | 11 (2 высоких, 5 средних, 4 низких) |
|
||||||
|
| Архитектура и право (A1, A2) | Вынесены владельцу, зафиксированы в документе исправлений |
|
||||||
|
|
||||||
|
## 4. Новые находки
|
||||||
|
|
||||||
|
### Н11. 🟠 Docker HEALTHCHECK проверяет несуществующий маршрут
|
||||||
|
|
||||||
|
- **Где:** `Dockerfile:21`
|
||||||
|
- **Суть:** после переноса маршрутов под `/api/v1/staff` проверка осталась на `http://127.0.0.1:8080/live`. Проверено запросом: корневой `/live` теперь отдаёт 404, `wget` вернёт ошибку.
|
||||||
|
- **Последствия:** контейнер всегда в состоянии `unhealthy`. В Docker Compose это ломает `depends_on: condition: service_healthy`, в оркестраторах (Swarm, Kubernetes при аналогичной пробе) — приводит к бесконечному перезапуску или к тому, что контейнер не попадает в балансировку. Автоматическая проверка развёртывания при этом молчит.
|
||||||
|
- **Исправление:** `CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1`; добавить healthcheck для сервиса `app` в `compose.yaml` и тест, который сверяет путь из `Dockerfile` с маршрутами.
|
||||||
|
|
||||||
|
### Н12. 🟠 Контейнер приложения получает учётные данные мигратора
|
||||||
|
|
||||||
|
- **Где:** `compose.yaml:4`, `.env.example:5-8`
|
||||||
|
- **Суть:** у сервиса `app` указан `env_file: .env`, а в `.env` лежат `DATABASE_MIGRATION_URL`, `POSTGRES_MIGRATION_PASSWORD` и `POSTGRES_APP_PASSWORD`. Переопределение `DATABASE_URL` не убирает остальные переменные из окружения контейнера.
|
||||||
|
- **Последствия:** пароль роли-владельца схемы доступен процессу приложения и любому, кто прочитает `/proc/1/environ` или `docker inspect`. Разделение ролей, ради которого сделана находка №6, частично обесценивается. `docs/DEPLOYMENT.md` прямо утверждает обратное: «миграционный URL хранится отдельно и не передаётся runtime-контейнеру».
|
||||||
|
- **Исправление:** отдельные файлы `.env.app` и `.env.migrate`, либо перечислить нужные переменные в `environment:` без `env_file`.
|
||||||
|
|
||||||
|
### Н13. 🟡 Обновление существующей установки ломается
|
||||||
|
|
||||||
|
- **Где:** `compose.yaml:23-32`, `docker/postgres/init-app-role.sh`
|
||||||
|
- **Суть:** `POSTGRES_USER` изменён с `krona_staff` на `krona_migrator`, а роль приложения создаётся скриптом из `docker-entrypoint-initdb.d`, который выполняется только при инициализации пустого каталога данных. На уже существующем томе:
|
||||||
|
- роль `krona_staff_app` не появится;
|
||||||
|
- блок `GRANT` в миграциях обёрнут в `IF EXISTS (SELECT 1 FROM pg_roles ...)` и молча ничего не сделает;
|
||||||
|
- приложение не сможет подключиться, в кластере останется прежний суперпользователь `krona_staff`.
|
||||||
|
- **Исправление:** выполнять `scripts/create-app-role.mjs` в сервисе `migrate` перед миграциями и описать порядок обновления в `docs/DEPLOYMENT.md`.
|
||||||
|
|
||||||
|
### Н14. 🟡 Права выдаются «если роль есть» и не распространяются на будущие таблицы
|
||||||
|
|
||||||
|
- **Где:** `migrations/0001_infrastructure.up.sql:80-89`, `migrations/0002_security_hardening.up.sql:59-68`
|
||||||
|
- **Суть:** при отсутствии роли миграция завершается успешно, но без выдачи прав — ошибка проявится позже как `permission denied` в рантайме. `ALTER DEFAULT PRIVILEGES` не настроен, поэтому каждая новая миграция обязана повторять блок `GRANT`; забытый блок — новая таблица, недоступная приложению.
|
||||||
|
- **Исправление:** падать с понятной ошибкой, если роли нет; настроить `ALTER DEFAULT PRIVILEGES FOR ROLE krona_migrator IN SCHEMA public`.
|
||||||
|
|
||||||
|
### Н15. 🟡 Новое правило «Приход/Уход» теряет длинные смены и не имеет ручной корректировки
|
||||||
|
|
||||||
|
- **Где:** `MainPage.xaml.cs:586-588`
|
||||||
|
- **Суть:** «Уход» проставляется, только если предыдущее событие — «Приход» и с него прошло не больше `MaximumShiftHours` (по умолчанию 20 ч). Переход через полночь теперь обрабатывается верно (№14 закрыта), но появились новые случаи:
|
||||||
|
- смена длиннее настроенного значения (суточное дежурство при значении 20 ч) даёт второй «Приход» вместо «Ухода»;
|
||||||
|
- если сотрудник не отметил уход, «Приход» остаётся незакрытым навсегда, и следующая отметка тоже станет «Приходом»;
|
||||||
|
- исправить ошибочную запись в интерфейсе нельзя — доступна только полная очистка журнала.
|
||||||
|
- **Исправление:** редактирование или удаление отдельной записи под администратором с пометкой о ручной правке; предупреждение при незакрытом «Приходе».
|
||||||
|
|
||||||
|
### Н16. 🟡 Ограничение журнала в 100 000 записей делает сохранение очень дорогим
|
||||||
|
|
||||||
|
- **Где:** `MainPage.xaml.cs:599-601`, `Services/TerminalStore.cs:127`
|
||||||
|
- **Суть:** предел в 100 000 записей — это десятки мегабайт JSON с отступами. Каждая отметка выполняет два сохранения, каждое сохранение полностью сериализует состояние, шифрует его, пишет с принудительным сбросом на диск и копирует три резервные копии. То есть на одну отметку приходится до восьми полных записей файла.
|
||||||
|
- Записи сверх лимита удаляются молча, без архива и без предложения выгрузить CSV.
|
||||||
|
- **Исправление:** перейти на журнал с добавлением записей (SQLite или файл-журнал), либо хранить в состоянии только последние отметки, а историю писать отдельно; перед усечением требовать экспорт.
|
||||||
|
|
||||||
|
### Н17. 🟡 Приватный режим скрывает имена только в списке
|
||||||
|
|
||||||
|
- **Где:** `MainPage.xaml.cs:581`, `:605`
|
||||||
|
- **Суть:** при включённом маскировании (по умолчанию) список последних отметок показывает «И\*\*\* П\*\*\*», но крупный текст на экране терминала по-прежнему выводит полное имя: «Иван Петров / Приход отмечен в 09:14», то же при отклонении повторного скана.
|
||||||
|
- **Последствия:** цель находки №26 не достигнута — имя всё равно видно постороннему у терминала, причём более заметно, чем в списке.
|
||||||
|
- **Исправление:** применять то же маскирование к `TerminalMessage` и к сообщениям строки состояния в публичном режиме.
|
||||||
|
|
||||||
|
### Н18. ⚪ Экспорт CSV не фиксируется и не защищён
|
||||||
|
|
||||||
|
- **Где:** `MainPage.xaml.cs:641-667`
|
||||||
|
- **Суть:** экспорт выгружает полные имена, табельные номера, должности и все отметки в произвольный незашифрованный файл. Факт экспорта нигде не сохраняется.
|
||||||
|
- **Исправление:** записывать в журнал событие экспорта (кто, когда, сколько записей); предупреждать, что файл не зашифрован.
|
||||||
|
|
||||||
|
### Н19. ⚪ Изменена уже применённая миграция, контрольные суммы не проверяются
|
||||||
|
|
||||||
|
- **Где:** `migrations/0001_infrastructure.up.sql`, `scripts/migrate.mjs`
|
||||||
|
- **Суть:** файл `0001` изменён задним числом (в `CREATE TABLE` добавлены `subject_*`/`actor_*` и `CHECK`), при этом на уже мигрированных базах таблица создана по старой версии, а колонки добавляет `0002`. Сейчас расхождение компенсируется, но мигратор не хранит контрольные суммы, поэтому следующая правка применённого файла останется незамеченной.
|
||||||
|
- **Исправление:** хранить хеш файла в `schema_migrations` и падать при расхождении.
|
||||||
|
|
||||||
|
### Н20. ⚪ Подстановка Release вместо Debug в пути автозагрузки
|
||||||
|
|
||||||
|
- **Где:** `Services/StartupService.cs:25-38`
|
||||||
|
- **Суть:** если путь процесса содержит `\Debug\`, он заменяется на `\Release\`, а при отсутствии файла выбрасывается исключение. Установка в каталог, в названии которого есть «Debug», сломает автозагрузку; подставленный Release-файл может быть старой сборкой.
|
||||||
|
- **Исправление:** определять отладочную сборку по `#if DEBUG` или по атрибуту сборки, а не по пути.
|
||||||
|
|
||||||
|
### Н21. ⚪ ADR-001 не синхронизирован с кодом
|
||||||
|
|
||||||
|
- **Где:** `docs/adr/ADR-001-technology-and-runtime-modes.md:8`, `:12`
|
||||||
|
- **Суть:** ADR по-прежнему фиксирует Redis/BullMQ как принятое решение, хотя зависимости и сервис удалены; `AGENTS.md` требует синхронизировать ADR при изменении контракта. `docs/ARCHITECTURE.md` обновлён, ADR — нет.
|
||||||
|
- **Исправление:** дополнить ADR-001 или выпустить ADR-002 «Redis откладывается до появления сценария очередей».
|
||||||
|
|
||||||
|
## 5. Находки второго отчёта: почти все открыты
|
||||||
|
|
||||||
|
`Services/TerminalStore.cs` в новых коммитах **не менялся**, поэтому связанные находки в силе.
|
||||||
|
|
||||||
|
| № | Находка | Статус |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Н1 | 🔴 После переименования повреждённого файла следующий запуск стартует с пустым состоянием: терминал открыт для создания чужого PIN, резервные копии затираются через 3–4 сохранения. То же при простом удалении файла состояния | ❌ Открыто |
|
||||||
|
| Н2 | 🟠 Любая ошибка чтения, включая временную блокировку файла антивирусом, считается повреждением; откат к резервной копии происходит без предупреждения | ❌ Открыто |
|
||||||
|
| Н3 | 🟡 Резервные копии обновляются на каждом сохранении и покрывают 1–2 скана; ошибочная очистка журнала попадает во все копии | ❌ Открыто, усугублено Н16 |
|
||||||
|
| Н4 | 🟡 `GetDeviceCount() - 1` не проверен на оборудовании | ❌ Открыто |
|
||||||
|
| Н5 | 🟡 Счётчик неверных PIN живёт в памяти, задержка блокирует и вход по отпечатку, старые хеши не пересчитываются | ❌ Открыто |
|
||||||
|
| Н6 | ⚪ `Disconnect` освобождает дескрипторы после 2 с ожидания независимо от результата | ❌ Открыто |
|
||||||
|
| Н7 | ⚪ Самопроизвольная отмена регистрации | 🟨 Частично: добавлено событие `RegistrationCancelled`, но `RefreshLists()` по-прежнему сбрасывает выбор и отменяет регистрацию |
|
||||||
|
| Н8 | ⚪ Редактирование сотрудника переписывает имена в прошлых отметках | ❌ Открыто |
|
||||||
|
| Н9 | ⚪ При недоступном хранилище приложение нельзя закрыть штатно | ❌ Открыто |
|
||||||
|
| Н10 | ⚪ Блокирующие циклы в пуле потоков, гонка при освобождении события активации, мьютекс в пространстве `Local\` | ❌ Открыто |
|
||||||
|
|
||||||
|
## 6. Находки первого отчёта: статус
|
||||||
|
|
||||||
|
### Закрыто
|
||||||
|
|
||||||
|
| № | Находка | Как закрыто |
|
||||||
|
| ---------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1, 4, 5 | Регистрация отпечатка, гонка дескрипторов, потеря связи со сканером | Отмена регистрации, передача дескрипторов, контроль связи (остаточные Н4, Н6) |
|
||||||
|
| 3 | Гонка записи состояния | `SemaphoreSlim`, `WriteThrough` |
|
||||||
|
| 6 | RLS обходился суперпользователем | Роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, тест проверяет `rolsuper`/`rolbypassrls` |
|
||||||
|
| 7 | Несовпадение паролей в быстром старте | Пароли и URL согласованы |
|
||||||
|
| 8 | Мигратор | `schema_migrations`, advisory lock, откат только последней, `ALLOW_MIGRATION_DOWN` |
|
||||||
|
| 9 | Потеря отметок при открытом диалоге | Отметка записывается |
|
||||||
|
| 11, 12 | Второй экземпляр, дубликат пальца | Мьютекс с активацией, `DBIdentify` перед регистрацией |
|
||||||
|
| 13 | Журнал без экспорта и без границ | CSV-экспорт и лимит (новые замечания Н16, Н18) |
|
||||||
|
| 14 | Смена через полночь | Настраиваемая максимальная длительность смены (новое замечание Н15) |
|
||||||
|
| 15 | `/ready` отдавал 500 с текстом ошибки | 503 без деталей, проверено |
|
||||||
|
| 16 | Расхождение OpenAPI и маршрутов | Префикс `/api/v1/staff` и тест сверки |
|
||||||
|
| 17 | Пул PostgreSQL | `pool.on('error')`, `release(error)`, защищённый `ROLLBACK` |
|
||||||
|
| 18 | Тесты не проверяли инварианты | Реальный тест изоляции двух тенантов, сверка OpenAPI, таймаут 15 с |
|
||||||
|
| 19 | Аудит-лог и outbox | Политики `SELECT`/`INSERT`, `subject`/`actor`, `CHECK` на ULID |
|
||||||
|
| 20 | Неиспользуемый Redis | Удалён из зависимостей, compose и конфигурации (ADR не обновлён — Н21) |
|
||||||
|
| 21, 22 | Автозагрузка | `--autostart` открывает в трее, по умолчанию выключена (остаток — Н20) |
|
||||||
|
| 23 | Защита последнего администратора | Учитываются все администраторы, отдельная проверка при удалении отпечатков |
|
||||||
|
| 25 | `Trim` у PIN | Проверяются оба варианта |
|
||||||
|
| 26 | Имена на публичном экране | Маскирование в списке (неполно — Н17) |
|
||||||
|
| 27 | Ошибки фоновых задач и `crash.log` | Глобальные обработчики, ротация по 1 МБ на 3 файла |
|
||||||
|
| 28, 29 | Платформы проекта, шаблонный манифест | Только x64, манифест очищен, BOM убран |
|
||||||
|
| 31, 32, 33 | `x-correlation-id`, версия, shutdown, CI, digest | Проверка ULID, `src/version.ts` и сверка версий, таймаут остановки, `format:check` в CI, образы по digest, запрет standalone в production |
|
||||||
|
|
||||||
|
### Оставлено сознательно и вынесено владельцу
|
||||||
|
|
||||||
|
- **№24** — повторный запрос PIN перед разрушительными действиями сохранён по требованию владельца.
|
||||||
|
- **№30** — EULA ZKTeco добавлена, но сборки терминала и подписи в CI нет: нужны Windows-раннер и сертификат. Стоит отметить, что EULA запрещает включение компонента в продукты, извлекающие из этого коммерческую выгоду, — перед коммерческим распространением нужна юридическая проверка.
|
||||||
|
- **A1** (роль терминала: локальное хранилище против edge-клиента Staff) и **A2** (письменные согласия на биометрию, сроки хранения, трансграничная передача) — решения владельца и юриста, зафиксированы в `docs/AUDIT-REMEDIATION-2026-09-18.md`.
|
||||||
|
|
||||||
|
## 7. Оценка качества исправлений
|
||||||
|
|
||||||
|
Сделано аккуратно: исправления не косметические, к ним добавлены тесты (изоляция тенантов, сверка OpenAPI), документация обновлена вместе с кодом, отдельный документ фиксирует, что оставлено сознательно. Обход RLS, мигратор и контракт API — самые опасные места первого отчёта — закрыты по существу, а не «подпоркой».
|
||||||
|
|
||||||
|
Основные замечания к процессу:
|
||||||
|
|
||||||
|
1. Изменения не проверялись в реальной среде: Docker и PostgreSQL не запускались, поэтому неработающий HEALTHCHECK (Н11) и сломанный путь обновления (Н13) не были замечены. `AGENTS.md` прямо требует не считать Docker и CI проверенными, если среда не запускалась, — документ исправлений это честно фиксирует.
|
||||||
|
2. Замечания второго отчёта не попали в работу, а хранилище состояния — источник самой опасной из открытых проблем (Н1).
|
||||||
|
3. Коммиты по-прежнему крупные и смешивают исправления с новыми функциями (экспорт CSV, приватный режим, редактирование).
|
||||||
|
|
||||||
|
## 8. План
|
||||||
|
|
||||||
|
| Приоритет | Задача | Находки |
|
||||||
|
| --------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||||
|
| 1 | Не стартовать с пустым состоянием при наличии `*.corrupt-*`/`.bak*`; не затирать копии; отличать повреждение от временных ошибок | Н1, Н2 |
|
||||||
|
| 2 | Исправить HEALTHCHECK и добавить проверку сервиса `app` в compose | Н11 |
|
||||||
|
| 3 | Убрать миграционные учётные данные из контейнера приложения | Н12 |
|
||||||
|
| 4 | Создание роли приложения в сервисе `migrate`, порядок обновления существующих установок, явная ошибка при отсутствии роли | Н13, Н14 |
|
||||||
|
| 5 | Журнал с добавлением записей вместо полной перезаписи, резервные копии по времени, ручная правка отметок | Н3, Н15, Н16 |
|
||||||
|
| 6 | Маскирование имени на главном экране терминала | Н17 |
|
||||||
|
| 7 | Проверить `GetDeviceCount() - 1` на оборудовании, довести устойчивость сканера | Н4, Н6 |
|
||||||
|
| 8 | Счётчик попыток PIN в хранилище, не блокировать вход по отпечатку, пересчёт старых хешей | Н5 |
|
||||||
|
| 9 | Контрольные суммы миграций, обновление ADR-001, аудит экспорта CSV | Н19, Н21, Н18 |
|
||||||
|
| 10 | Решения владельца: контракт терминала и правовая база обработки биометрии | A1, A2 |
|
||||||
56
docs/audit/2026-09-26/ACCEPTANCE-1.md
Normal file
56
docs/audit/2026-09-26/ACCEPTANCE-1.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Приёмка №1: Grok и Antigravity — 2026-09-26
|
||||||
|
|
||||||
|
Проверял независимо, на живой PostgreSQL 17.10, в отдельных рабочих копиях. Код веток не менялся. Основа обеих веток — `778ba2d`; текущая основная ветка — `331e490`. Обе ветки вливаются в неё без конфликтов, друг с другом тоже.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
| Исполнитель | Ветка | Сделано из задания | Вердикт |
|
||||||
|
| ----------- | -------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Antigravity | `antigravity/fix-reject-device-shifts` (`795bba0`) | Только часть 0 — немедленная мера по S1 | **Принять после одной правки**: подделка смены с устройства закрыта, но новый тест на реальной базе красный. Части 1–4 не начаты |
|
||||||
|
| Grok | `grok/e2e-harness` (`421da72`) | Задачи 1–7 | **Принять после двух правок**: стенд работает и честно ловит дефекты, но под Windows не запускается, а ID моего аудита не сопоставлены — файлы аудита до Grok не дошли |
|
||||||
|
|
||||||
|
## Antigravity
|
||||||
|
|
||||||
|
**Что проверено вживую** ([evidence/acceptance-antigravity-live-sync.txt](evidence/acceptance-antigravity-live-sync.txt)):
|
||||||
|
|
||||||
|
- **S1 закрыт.** Устройство отправляет смену `status: confirmed, worked_minutes: 2880`. Ответ: `rejected / COMMAND_NOT_SUPPORTED`, смена не создана, расчёт по ней начисляет 0 ₽. До исправления было 14 400 ₽.
|
||||||
|
- **Сценарии Grok на этой ветке.** SYNC-A и SYNC-B (устройство не может подтвердить смену и утвердить табель) стали зелёными ([evidence/acceptance-grok-sync-with-antigravity.txt](evidence/acceptance-grok-sync-with-antigravity.txt)).
|
||||||
|
- **Штатные проверки.** lint, typecheck, сборка, миграции и контракты проходят; 188 тестов пройдено. `format:check` красный только по чужим старым файлам, файлы Antigravity отформатированы.
|
||||||
|
|
||||||
|
**Что не так:**
|
||||||
|
|
||||||
|
1. **Новый тест на реальной базе падает.** В очистке `tests/integration/sync-rejection.test.ts:69-74` запись удаляется ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Это тот же дефект, что N5. Проверка внутри теста проходит, но сам файл красный — значит, на живой базе тест не запускался. Исправление: очистка ролью мигратора, как Grok сделал для N5.
|
||||||
|
2. **Сделана только часть 0.** Не начаты: синхронизация S2–S9 (S2 и S9 проверил на этой ветке — оба по-прежнему воспроизводятся), SSRF через push (K12), оповещения процесса подтверждения, публикатор outbox.
|
||||||
|
3. **Сообщение коммита — один абзац в строке заголовка.** Нужны короткий заголовок и причина в теле (AGENTS.md).
|
||||||
|
4. **После влития нужно синхронизироваться с Grok.** SYNC-A и SYNC-B надо убрать из `tests/e2e/known-defects.json` — иначе CI упадёт с «дефект исправлен, обновите список». Это зона Grok.
|
||||||
|
|
||||||
|
## Grok
|
||||||
|
|
||||||
|
**Что проверено** ([evidence/acceptance-grok-e2e.txt](evidence/acceptance-grok-e2e.txt)):
|
||||||
|
|
||||||
|
- **Зона соблюдена.** Изменены только `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` и `scripts/test/**`. Продукт не тронут.
|
||||||
|
- **Коммиты оформлены правильно.** 12 маленьких коммитов, у каждого в теле объяснена причина.
|
||||||
|
- **Стенд работает.** Прогон на моей PostgreSQL дал **38 красных и 9 зелёных** — ровно как в его отчёте. Красные совпадают с моими находками: N1, N2, N3, N4, N9, N10, N11, N12, F2, F3, F4, F7, F10, решения владельца OWN-01…11, синхронизация, пилотный compose.
|
||||||
|
- **Логика «известных дефектов» верная.** Известный красный сценарий не роняет CI. Исправленный дефект, который остался в списке, роняет CI. Незнакомый красный сценарий тоже роняет CI. Так исправление не пройдёт незамеченным, и регрессия тоже.
|
||||||
|
- **Новая находка, которой не было у меня (SYNC-C0).** Начальная загрузка данных на устройство на реальной базе всегда отвечает 500: `column "status" does not exist`. Офлайн-синхронизация фактически не работает на приём данных. Это задача для Antigravity.
|
||||||
|
|
||||||
|
**Что не так:**
|
||||||
|
|
||||||
|
1. **Под Windows стенд не запускается**, а проект разрабатывается под Windows:
|
||||||
|
- `scripts/test/run-e2e.mjs:35` — `spawn('npx')`;
|
||||||
|
- `tests/e2e/harness/global-setup.ts:67` и `scripts/test/n13-race.mjs:102` — `spawn(node_modules/.bin/tsx)`.
|
||||||
|
|
||||||
|
Всё это даёт `ENOENT`, потому что в Windows это `.cmd`. Исправление: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, …])` и то же для vitest. С такой локальной правкой я и проводил прогон.
|
||||||
|
|
||||||
|
2. **ID моего аудита не сопоставлены.** Файлы `AUDIT.md` и `evidence` до Grok не дошли. Он это честно отметил: K1, K3–K6, K10–K12, S1, S2, S9 и A4 помечены «не проверено». Сценарии фактически покрывают те же дефекты под его номерами, нужно только переименовать теги.
|
||||||
|
3. **PR не создан**: у Grok нет токена Forgejo. Описание лежит в `tests/e2e/REPORT.md`. Также `tests/e2e/evidence/` коммитится в репозиторий и будет меняться при каждом прогоне — лучше хранить это как артефакт CI.
|
||||||
|
|
||||||
|
## Что делать дальше
|
||||||
|
|
||||||
|
1. **Влить Grok первым** (только тесты и CI) — после исправления запуска под Windows.
|
||||||
|
2. **Влить Antigravity** — после исправления очистки в тесте. В том же PR или сразу после Grok убирает SYNC-A и SYNC-B из списка известных дефектов.
|
||||||
|
3. **Antigravity продолжает** части 1–4 задания, плюс SYNC-C0 (загрузка на устройство падает на реальной базе).
|
||||||
|
4. **Grok сопоставляет ID** с моим аудитом, когда получит файлы.
|
||||||
|
5. **Codex.** В репозитории уже есть ветки `codex/fix-format-baseline` и `codex/fix-live-database` (этапы 0 и 1). Их можно принимать следующими.
|
||||||
|
|
||||||
|
Стенды удалены, база остановлена. В этой приёмке ничего не влито и не запушено.
|
||||||
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 не останется пересечений с синхронизацией.
|
||||||
70
docs/audit/2026-09-26/ACCEPTANCE-3.md
Normal file
70
docs/audit/2026-09-26/ACCEPTANCE-3.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Приёмка №3 — 2026-09-26
|
||||||
|
|
||||||
|
Проверка независимая, на живой PostgreSQL 17.10, в отдельной рабочей копии. Ветки исполнителей не менялись. Основная ветка — `b0243d5`.
|
||||||
|
|
||||||
|
## Codex
|
||||||
|
|
||||||
|
| Ветка | Коммит | Вердикт |
|
||||||
|
| --------------------------- | --------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| `codex/fix-format-baseline` | `a05777c` | **Принято.** Пункт 1 TASK-CODEX-2 выполнен |
|
||||||
|
| `codex/fix-live-database` | `1573860` | **Принято.** K3/N2 и K4/N1 по-прежнему зелёные |
|
||||||
|
| `codex/fix-tenant-config` | `535052e` | **Почти:** пункт 2.1 сделан правильно, но `npm run check` на ветке красный. Одна строка |
|
||||||
|
|
||||||
|
### Форматирование — пункт 1 закрыт
|
||||||
|
|
||||||
|
Перепроверил тем же способом, что и в прошлый раз: взял каждый из 19 изменённых файлов в версии из основы, прогнал prettier и сравнил с версией из ветки. **Ни один файл не отличается после нормализации** — в коммите только стиль. Синхронизация из него убрана.
|
||||||
|
|
||||||
|
`codex/fix-live-database` перебазирован на новое форматирование. Стенд Grok поверх ветки: K3/N2 и K4/N1 зелёные, стенд падает только сообщением «дефект исправлен, обновите known-defects.json» — это правильное поведение храповика, а не ошибка ветки.
|
||||||
|
|
||||||
|
### Пункт 2.1 — заглушка больше не вернётся
|
||||||
|
|
||||||
|
Сделано по всем четырём местам:
|
||||||
|
|
||||||
|
| Где | Как |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `deploy/pilot/setup.sh` | `validate_tenant()`: требует переменную, проверяет ULID, отдельно отказывает заглушке. Вызывается и при создании `.env`, и после его чтения |
|
||||||
|
| `compose.yaml` | `${STANDALONE_TENANT_ID:?…}` |
|
||||||
|
| `.env.example` | пусто, с комментарием откуда брать |
|
||||||
|
| `src/config.ts` | заглушка отклоняется, кроме `development` на loopback |
|
||||||
|
|
||||||
|
### Что мешает влить `codex/fix-tenant-config`
|
||||||
|
|
||||||
|
`npm run check` на ветке: **`Test Files 1 failed | 17 passed`**.
|
||||||
|
|
||||||
|
```text
|
||||||
|
FAIL tests/unit/sync.test.ts
|
||||||
|
Error: STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/unit/sync.test.ts:553` берёт `STANDALONE_TENANT_ID = '01J00000000000000000000000'` с `NODE_ENV: 'test'`. Новая проверка это запрещает. Четыре теста под заглушку ты поправил, а этот — в зоне Antigravity, поэтому и не поправил. Правильно, что не полез.
|
||||||
|
|
||||||
|
**Как закрыть, не трогая чужую зону:** разрешить заглушку и при `NODE_ENV === 'test'`. Инвариант защищает развёртывание, а `NODE_ENV=test` пилот никогда не обслуживает — на пилоте стоит `production`, в `compose.yaml` заглушка уже невозможна. Тест в `tests/unit/config.test.ts`, который сейчас требует отказа в test-режиме, соответственно поменять: пусть проверяет отказ в `production` и в `development` на не-loopback.
|
||||||
|
|
||||||
|
**Готово, когда** `npm run check` на ветке даёт `Test Files 19 passed` (или 18 passed | 1 skipped, как на основе).
|
||||||
|
|
||||||
|
### Осталось по TASK-CODEX-2
|
||||||
|
|
||||||
|
- Пункт 2.2 — `scripts/rehome-tenant.mjs` нет. Grok уже подготовил сценарии `PILOT-REHOME` и `PILOT-REHOME-SEED` и описал ожидаемый интерфейс в `tests/e2e/NOTES-FOR-EXECUTORS.md`: `DATABASE_ADMIN_URL=… node scripts/rehome-tenant.mjs --from … --to … [--dry-run] [--json]`. Держись этого интерфейса — тогда сценарий позеленеет сам.
|
||||||
|
- Пункт 2.3 — скрипта сопоставления `core_user_id` нет.
|
||||||
|
- Перед пунктом 2.2 — резервная копия K18 и проверка восстановления.
|
||||||
|
|
||||||
|
## Слияния — всё чисто
|
||||||
|
|
||||||
|
| Пара | Результат |
|
||||||
|
| ----------------------------------------- | --------- |
|
||||||
|
| Codex format + AG c0 | чисто |
|
||||||
|
| Codex live-database + AG c0 | чисто |
|
||||||
|
| Codex tenant-config + Codex live-database | чисто |
|
||||||
|
| Codex tenant-config + Grok | чисто |
|
||||||
|
| Grok + Codex live-database | чисто |
|
||||||
|
|
||||||
|
Antigravity выстроил свои ветки в цепочку: `fix-reject-device-shifts` → `fix-sync-c0-bootstrap`. Конфликта больше нет.
|
||||||
|
|
||||||
|
## Порядок влития
|
||||||
|
|
||||||
|
1. `grok/e2e-harness`.
|
||||||
|
2. Цепочка Antigravity: `fix-reject-device-shifts` → `fix-sync-c0-bootstrap`.
|
||||||
|
3. Codex: `fix-format-baseline` → `fix-live-database` → `fix-tenant-config` (после исправления теста).
|
||||||
|
4. Grok — коммит в `known-defects.json`: убрать K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0.
|
||||||
|
|
||||||
|
Разделы по Grok и Antigravity добавляются в этот файл после их проверки.
|
||||||
213
docs/audit/2026-09-26/AUDIT.md
Normal file
213
docs/audit/2026-09-26/AUDIT.md
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
# Полный аудит krona-staff — 2026-09-26
|
||||||
|
|
||||||
|
- Аудитор: Claude (Opus 5.5), режим только чтения: код, ветки и удалённый репозиторий не менялись
|
||||||
|
- Проверено: основная ветка `codex/bootstrap-platform-foundation` на коммите `3e20544` (2026-09-26 12:31 +07, версия 0.3.2) и ветки `antigravity/offline-sync-backend` (`5ee7517`, офлайн-синхронизация) и `codex/frontend-library` (`1812870`, оформление портала)
|
||||||
|
- Действует для: `778ba2d` — текущая основная ветка, в которую обе ветки влиты без изменения проверенного кода
|
||||||
|
- Сверка: аудит Grok на том же коммите `3e20544` (14 старых и 14 новых находок) — каждая перепроверена заново
|
||||||
|
- Доказательства: папка [evidence](evidence) — сценарии живой проверки и их вывод
|
||||||
|
|
||||||
|
> Во время проверки обе ветки были влиты в основную (`3595792`), плюс косметический коммит `778ba2d`. Код синхронизации совпадает с проверенным `5ee7517`, поэтому находки S1–S9 относятся к основной ветке, а S1 (устройство создаёт подтверждённые смены, и они попадают в зарплату) — действующий дефект.
|
||||||
|
|
||||||
|
## 1. Итог в трёх строках
|
||||||
|
|
||||||
|
1. **Выпускать нельзя.** На настоящей PostgreSQL главная цепочка «подтвердить смену → рассчитать → утвердить» не работает (500), а создать сотрудника через API невозможно.
|
||||||
|
2. **Деньги считаются неверно даже там, где работает.** Одна смена может быть оплачена трижды, утверждённую смену можно отклонить задним числом без сторно, импорт из Excel теряет суммы, а устройство офлайн-синхронизации может само создать «подтверждённую» смену на 48 часов.
|
||||||
|
3. **Терминал пока не отправляет отметки на сервер.** Сотрудники на терминале получают свои идентификаторы, сервер их не знает и отвечает 404; связь служба ↔ интерфейс не работает из-за разных имён канала.
|
||||||
|
|
||||||
|
## 2. Что реально запускалось
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `npm ci`, lint, typecheck, build, check:migrations, check:contracts | пройдено |
|
||||||
|
| `format:check` | не пройдено — 16 файлов ([evidence/format-check.txt](evidence/format-check.txt)) |
|
||||||
|
| vitest без базы | 156 пройдено, 1 пропущен |
|
||||||
|
| vitest на живой PostgreSQL 17.10 (роль приложения) | тест изоляции арендаторов падает при очистке: `permission denied for table staff_employees` |
|
||||||
|
| Миграции 0001–0010 мигратором **без** SUPERUSER: вверх, откат 10 шагов, вверх, повтор | пройдено |
|
||||||
|
| Миграции ветки синхронизации 0001–0011, откат 0011 и повтор | пройдено |
|
||||||
|
| `scripts/create-app-role.mjs` мигратором без SUPERUSER | ошибка `permission denied to alter role` |
|
||||||
|
| RLS на живой базе | 23 из 23 таблиц с `tenant_id`: ENABLE + FORCE + политика; роль приложения без SUPERUSER, BYPASSRLS, CREATEROLE; журналы только на чтение и вставку |
|
||||||
|
| Живой API: сервер на реальной базе, реальные HTTP-запросы | 19 сценариев, см. раздел 4 |
|
||||||
|
| `npm audit` (всё и production) | 0 уязвимостей |
|
||||||
|
| .NET: сборка службы Release, тесты терминала | сборка без ошибок; тесты 43 из 43 |
|
||||||
|
| Named pipe службы | проверено запуском отдельного тестового проекта |
|
||||||
|
| Не проверено | Docker и compose (нет на машине), WinUI-интерфейс вживую, DPAPI и ACL на отдельной Windows-машине, Snyk, реальные push-сервисы |
|
||||||
|
|
||||||
|
Стенд: Windows 11, Node 24, PostgreSQL 17.10 (встроенная сборка), .NET 10 SDK. База аудита создавалась в UTF-8; первая попытка в WIN1251 дала ложную ошибку кодировки, её результаты отброшены.
|
||||||
|
|
||||||
|
## 3. Сводка
|
||||||
|
|
||||||
|
| Уровень | Открыто | Из них подтверждено вживую |
|
||||||
|
| ------- | ------- | -------------------------- |
|
||||||
|
| P0 | 2 | 1 |
|
||||||
|
| P1 | 17 | 10 |
|
||||||
|
| P2 | 32 | 6 |
|
||||||
|
| P3 | 31 | 3 |
|
||||||
|
|
||||||
|
Сверка с Grok: из его 28 находок 22 подтверждены, 4 подтверждены частично (F8, F11, N7, N10), 1 исправлена (F5), 1 не воспроизведена (N13). Сверх Grok — 57 новых замечаний, главное — в денежной логике, терминале, эксплуатации и офлайн-синхронизации.
|
||||||
|
|
||||||
|
## 4. Подтверждено на живом сервере
|
||||||
|
|
||||||
|
| ID | Что проверено | Результат |
|
||||||
|
| --- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| F2 | Сервер без токенов, `STANDALONE_ACCESS=manager`, запросы без авторизации | `GET /portal` 200, `GET /payroll` 200, `GET /finance` 200, **запись** `POST /professions` 201 |
|
||||||
|
| F2 | То же при `STANDALONE_ACCESS=employee` (значение по умолчанию в compose) | `GET /portal` 200, `GET /payroll` 200 анонимно |
|
||||||
|
| F3 | Вход менеджером, выход, повтор старой cookie | cookie содержит сам токен менеджера; после `DELETE /session` старая cookie даёт `GET /portal` 200 |
|
||||||
|
| F4 | 60 неверных входов подряд | 60 × 401, ни одного 429 |
|
||||||
|
| N1 | `POST /employees` | 500, `42P08 inconsistent types deduced for parameter $2` |
|
||||||
|
| N2 | Подтверждение смены, расчёт | оба 500, `23502 null value in column "occurred_at" of relation "staff_outbox"` |
|
||||||
|
| N4 | Одна смена 8 ч × 300 ₽, три пересекающихся расчёта с утверждением | 3 начисления в реестре, **7 200 ₽ вместо 2 400 ₽** |
|
||||||
|
| D5 | Отклонение смены, уже оплаченной по утверждённой ведомости | 200, сторно не создано, начисления остались |
|
||||||
|
| N12 | Ставка `rate_minor = 1e20` с лишним полем | принято, 200 |
|
||||||
|
| — | Расчёт за `month = 2026-13` | 500 |
|
||||||
|
| N3 | Сотрудник подписывает push на `https://127.0.0.1:18443`, затем отметка на терминале | сервер открыл соединение на внутренний адрес и отправил TLS ClientHello |
|
||||||
|
| N9 | Сотрудник Б регистрирует endpoint сотрудника А | 201, подписка перешла к Б |
|
||||||
|
| A4 | Ошибки 500 | наружу уходят код и текст ошибки PostgreSQL |
|
||||||
|
| N10 | Токен терминала | `GET /professions` 200 со всем справочником; `GET /org-units` отдаёт пустой список — эта часть находки Grok не подтвердилась |
|
||||||
|
| N13 | Два одновременных первых расчёта периода | **не воспроизведено**: обе заявки получили один расчёт |
|
||||||
|
| S1 | Устройство синхронизации отправляет смену `status: confirmed, worked_minutes: 2880` | смена сохранена подтверждённой, подтвердил «никто», журнал аудита пуст; расчёт начислил **14 400 ₽** |
|
||||||
|
| S2 | Устройство отправляет отметку с `occurred_at = 2020-01-01` | сохранена |
|
||||||
|
| S9 | Удаление точки, к которой привязано устройство синхронизации | ошибка `23502 null value in column "tenant_id"` — точку удалить нельзя |
|
||||||
|
| — | Заголовки портала | CSP `script-src 'self'`, HSTS присутствуют |
|
||||||
|
|
||||||
|
Для N4, D5 и S1 в базе аудита временно задавалось значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает N2); после сценария оно снималось.
|
||||||
|
|
||||||
|
## 5. P0 — блокируют любое использование вне стенда
|
||||||
|
|
||||||
|
**K1. Открытый режим без токенов (F2, подтверждено вживую).** Если токены менеджера и терминала не заданы, любой запрос без авторизации получает права `STANDALONE_ACCESS` (`src/adapters/identity.ts:32-49`, `src/config.ts:45-47`). Корневой `compose.yaml` публикует порт 8080 на всех интерфейсах по HTTP и токенов не задаёт; в пилотном compose переменные подставляются без `:?`, поэтому пустая строка в `.env` молча открывает сервис.
|
||||||
|
|
||||||
|
**K2. Production-режим недостижим, пилот работает как development (F1).** Адаптер платформенного JWT — заглушка (`identity.ts:60-66`), standalone в production запрещён. Поэтому оба compose ставят `NODE_ENV=development` (`compose.yaml:5`, `deploy/pilot/compose.yaml:8`) — это обходит проверки «standalone запрещён» и «HTTPS обязателен» на пилоте с реальными ФИО и зарплатами.
|
||||||
|
|
||||||
|
## 6. P1 — денежная логика и работоспособность
|
||||||
|
|
||||||
|
| ID | Суть | Где | Подтверждение |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
||||||
|
| K3 (N2) | Вставки в outbox без `occurred_at`: подтверждение смены, расчёт и утверждение всегда 500 | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935` | вживую |
|
||||||
|
| K4 (N1) | `POST /employees` всегда 500 из-за типа параметра `$2` | `src/portal.ts:465-483` | вживую |
|
||||||
|
| K5 (N4) | Пересекающиеся и «точка / все точки» расчёты начисляют смену повторно; при пересчёте не обновляется `period_end` | `service.ts:425-454`, `759-768`, `860-903`; индексы `0006:196-199` | вживую, 7 200 ₽ за 2 400 ₽ |
|
||||||
|
| K6 (D5) | Смены и строки утверждённой ведомости можно менять; у роли приложения есть DELETE на строки и UPDATE на ведомости, неизменяемость держится только кодом | `portal.ts:1024-1106`, `0006:296-300` | вживую |
|
||||||
|
| K7 (D2) | Импорт начислений и выплат теряет деньги: дельта считается по всем записям дня, а вставка по конкретному ключу | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147,168,176` | по коду |
|
||||||
|
| K8 (D1) | Версии правил фиктивны: v1 изменяемая, даты действия не учитываются, условия точки не читаются, в строке ведомости нет дерева правила и ревизии смены — утверждённый расчёт нельзя воспроизвести | `service.ts:152-180`, `517-529`, `686-695` | по коду |
|
||||||
|
| K9 (D3) | Параметры пресетов кроме ставки, процента и гарантии не сохраняются, метрики кроме выручки и позиции не загружаются: официант по пресету получает 0 ₽, премия за порог выручки — 0 ₽ | `service.ts:295-299`, `488-500`; `src/portal-ui.ts:273-275` | по коду |
|
||||||
|
| K10 (F3) | В cookie лежит сам токен, выход не отзывает сессию; токен сотрудника бессрочный | `portal.ts:262-276` | вживую |
|
||||||
|
| K11 (F4, A2) | Нет ограничения частоты; каждый неверный токен открывает транзакцию в БД при пуле 10 соединений — отказ в обслуживании | `portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | вживую (F4) |
|
||||||
|
| K12 (N3) | SSRF через push-подписку: разрешён любой `https://`, редиректы следуются, фрагмент ответа сохраняется | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | вживую |
|
||||||
|
| K13 (S1) | Офлайн-синхронизация: устройство создаёт и перезаписывает подтверждённые смены и строки табеля | `src/domain/sync/service.ts:784-990` | вживую, 14 400 ₽ |
|
||||||
|
| K14 (T4) | Отметки терминала не доходят до сервера: локальные ULID сотрудников сервер не знает (404), повтор каждые 15 с бесконечно; контракт в `docs/TERMINAL-API-REQUIREMENTS.md` расходится с кодом | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `TerminalModels.cs:157` | по коду + ответ сервера 404 |
|
||||||
|
| K15 (T1) | Служба создаёт pipe `\\.\pipe\global\KronaStaffTerminalPipe`, интерфейс подключается к `\\.\pipe\KronaStaffTerminalPipe`: IPC не работает, свободное имя может занять любой процесс и получать отметки с ФИО | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84`, `:236` | проверено запуском |
|
||||||
|
| K16 (T2) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама ротирует там логи и меняет ACL — путь к правам SYSTEM через junction | `TerminalStore.cs:305-360`, `CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | по коду (механизм), PoC не делался |
|
||||||
|
| K17 (T5) | Биометрия: нет письменного согласия (152-ФЗ ст. 11), шаблоны остаются в резервных копиях после удаления, уведомления в Telegram — трансграничная передача | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | по коду |
|
||||||
|
| K18 (D1 эксплуатации) | Нет резервного копирования пилотной базы с реальными ФИО, табелем и зарплатой; `BACKUP-RESTORE.md` — только шаблон | `docs/BACKUP-RESTORE.md`, `deploy/pilot/` | по коду |
|
||||||
|
| K19 (D4 расчёта) | Утверждается устаревший расчёт: ревизии смен и выручки не сверяются, предупреждения «нет выручки», «нет профиля оплаты» не сохраняются, сотрудник без профиля выпадает из фонда и его долю делят остальные | `service.ts:632-641`, `840-903` | по коду |
|
||||||
|
|
||||||
|
## 7. P2
|
||||||
|
|
||||||
|
**Сервер и расчёт**
|
||||||
|
|
||||||
|
- N5 — тест изоляции арендаторов удаляет сотрудников ролью приложения, у которой нет DELETE: CI с базой красный (подтверждено). Маршрутные тесты работают на поддельной базе, поэтому N1 и N2 не ловятся.
|
||||||
|
- N6 — `create-app-role.mjs` требует мигратора-суперпользователя (подтверждено).
|
||||||
|
- N7 — импорт-скрипт отключает проверку TLS при `--allow-self-signed` и принимает токен в аргументах командной строки. **Уточнение к Grok:** ответа 400 на `/professions` не будет — Fastify по умолчанию молча отбрасывает лишние поля (`removeAdditional`), поэтому ответ 201, а задуманный код профессии теряется.
|
||||||
|
- N12, D9 — нет верхних границ ставок и `additionalProperties:false` на назначении оплаты; `2026-13` и `2026-02-30` проходят шаблон и дают 500 (подтверждено).
|
||||||
|
- F7 — `Idempotency-Key` и таблица ключей не используются, хотя ADR и контракт приложения их требуют.
|
||||||
|
- F9 — healthcheck в Dockerfile по HTTP при HTTPS-only production.
|
||||||
|
- F10, A6 — проверка Origin пропускает запросы без Origin и с любым `Authorization`; практическая эксплуатация закрыта `SameSite=Strict`, но должно быть fail-closed.
|
||||||
|
- A3 — один общий токен терминала на все устройства, отметки до 180 дней назад.
|
||||||
|
- A4 — ошибки 500 раскрывают текст PostgreSQL (подтверждено).
|
||||||
|
- D6 — одна роль «менеджер» и считает, и утверждает, и выплачивает, без привязки к точке — разделения обязанностей нет.
|
||||||
|
- D13 — `additionalProperties:false` в схемах на деле не отклоняет лишние поля, а молча их удаляет: опечатки клиентов не видны.
|
||||||
|
- D7 — округление строк после деления фонда выплачивает на 1 ₽ больше фонда; вес фонда у правила без доли фонда теряет эту часть фонда.
|
||||||
|
|
||||||
|
**Уведомления и outbox**
|
||||||
|
|
||||||
|
- F6 — outbox пишется, но публикатора нет, `dispatchPending` не вызывается, строки в `pending` не повторяются никогда; отправка живёт в `setImmediate` и теряется при рестарте.
|
||||||
|
- U1 — некорректные ключи подписки роняют цикл отправки остальным устройствам.
|
||||||
|
- U2 — нет лимита подписок и таймаутов: одна отметка может породить тысячи запросов.
|
||||||
|
- U3 — нет захвата строки при отправке: при двух экземплярах возможна двойная отправка.
|
||||||
|
|
||||||
|
**Офлайн-синхронизация**
|
||||||
|
|
||||||
|
- S2 — время отметки задаётся клиентом без ограничений (подтверждено: 2020 год принят).
|
||||||
|
- S3 — гонки идемпотентности, потеря обновлений без проверки ревизии, одна плохая команда откатывает весь пакет из 100.
|
||||||
|
- S4 — начальная синхронизация отдаёт устройству весь арендатор, включая уволенных и всю историю минут.
|
||||||
|
- S5 — курсор теряет изменения за пределами первых 100 строк; изменения из портала устройство не видит.
|
||||||
|
- S6 — отметки через синхронизацию не пишут outbox и принимаются для уволенных.
|
||||||
|
- S9 — внешний ключ `ON DELETE SET NULL` на составном ключе ломает удаление точки (подтверждено).
|
||||||
|
|
||||||
|
**Терминал и служба**
|
||||||
|
|
||||||
|
- T3 — член группы операторов может расшифровать состояние: хеш PIN, токены сервера и ботов, токен обновлений, шаблоны отпечатков.
|
||||||
|
- T6 — служба и интерфейс перезаписывают один файл без межпроцессной блокировки: теряются смена PIN, счётчик попыток, блокировки.
|
||||||
|
- T7 — установщик не задаёт ACL на выбранный каталог установки; предсозданный пользователем каталог данных остаётся в его владении.
|
||||||
|
- T8 — терминал запускается с правами администратора после установки и автообновления; через диалог экспорта можно получить оболочку.
|
||||||
|
- T9 — проверка обновления сильная (HTTPS, SHA-256, Authenticode, закреплённый издатель), но файл проверяется в профиле пользователя и запускается позже (TOCTOU); на каждом терминале лежит токен чтения приватного репозитория.
|
||||||
|
- T10 — служба от SYSTEM загружает `libzkfp.dll` по PATH, если драйвер не установлен.
|
||||||
|
|
||||||
|
**Эксплуатация**
|
||||||
|
|
||||||
|
- D3 — самоподписанный сертификат на IP: у сотрудников не заработают PWA и push; ключ лежит рядом с pfx незашифрованным; продление ломает пиннинг на всех терминалах.
|
||||||
|
- D4 — обновление без тегированных образов и отката, без бэкапа перед миграцией, без `lock_timeout`.
|
||||||
|
- D5 — скрипт настройки терминала пишет не туда, где терминал читает состояние после переноса в `%ProgramData%`.
|
||||||
|
- D6 — `docker build` в CI внутри docker-раннера, down-миграции в CI не проверяются, пилотный compose тестами не покрыт.
|
||||||
|
|
||||||
|
## 8. P3 (кратко)
|
||||||
|
|
||||||
|
N8 эфемерные VAPID-ключи и кэш публичного ключа на сутки; N9 перепривязка чужого endpoint (подтверждено, но нужен секретный endpoint); N10 справочник профессий без проверки прав; N11 слабая валидация импорта; N13 гонка первого расчёта (не воспроизведена); N14 GET-запросы переписывают правило v1; F12 деньги в `number`; F13 плавающие теги в CI; F14 форматирование 16 файлов; A5 валидация и импорт до проверки прав, открытый `/openapi.json`; A7 новый токен сотрудника без `no-store`; A8 все менеджеры — один актор в аудите; A9 correlation id от клиента; U4 нет политики повторов и dead letter; U5 scope service worker не покрывает `/staff`; U6 выход не отзывает push-подписку; U7 FK без `tenant_id`; S7 очистка входящих не работает (передаётся не тот идентификатор); S8 CSRF и белый список scope устройств; W1 `/staff/*` отвечает 200 на любой путь; T11 DoS через IPC до проверки HMAC; T12 нет backoff в синхронизации терминала; T13 молчаливый откат службы к резервной копии, нет порога совпадения отпечатка; D8 документация расходится с кодом; D9 нет лимитов ресурсов контейнеров; D10 `.dockerignore` не исключает `deploy/pilot/.env` и секреты; D11 миграции из build-стадии от root; D12 smoke-тест ничего не проверяет и пишет в боевую базу; D14 нет редактирования PII в логах; D15 дефолты корневого compose; D16 коммиты без тела и смешанные по темам, 67 файлов bin/obj в истории.
|
||||||
|
|
||||||
|
## 9. Сверка с аудитом Grok
|
||||||
|
|
||||||
|
| ID Grok | Вердикт | Комментарий |
|
||||||
|
| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| F1, F2, F3, F4 | подтверждено | F2–F4 вживую; к F1 добавлено: пилот обходит запрет через `NODE_ENV=development` |
|
||||||
|
| F5 | исправлено | неизвестный сотрудник → 404 |
|
||||||
|
| F6 | подтверждено, серьёзнее | строки `pending` не повторяются никогда |
|
||||||
|
| F7, F9, F12, F13, F14 | подтверждено | F13: `ajv allErrors` в проверке контрактов вектором не является |
|
||||||
|
| F8 | исправлено по форме, остаток P1 | ACL и HMAC есть, но имена pipe не совпадают (K15) |
|
||||||
|
| F10 | подтверждено, практически закрыто cookie `SameSite=Strict` | |
|
||||||
|
| F11 | API есть, но неработоспособно на реальной базе (K3) | |
|
||||||
|
| N1, N2, N3, N4, N5, N6 | подтверждено вживую | |
|
||||||
|
| N7 | частично | TLS и токен в argv — да; ответ 400 — нет (поля молча отбрасываются) |
|
||||||
|
| N8, N11, N12, N14 | подтверждено | |
|
||||||
|
| N9 | подтверждено вживую | эксплуатация требует знать секретный endpoint |
|
||||||
|
| N10 | частично | `/professions` — да; `/org-units` для не-менеджеров отфильтрован |
|
||||||
|
| N13 | не воспроизведено вживую | по коду возможно, оставлено P3 |
|
||||||
|
|
||||||
|
Чего в аудите Grok нет: денежные дефекты K6–K9 и K19, терминал K14–K17, отсутствие бэкапа, пилот в режиме development, ветка синхронизации целиком (она появилась после его аудита).
|
||||||
|
|
||||||
|
## 10. Что сделано хорошо
|
||||||
|
|
||||||
|
- Изоляция арендаторов: RLS с FORCE на всех 23 таблицах, составные внешние ключи, `set_config(..., true)` в транзакции, арендатор никогда не берётся из запроса.
|
||||||
|
- Роли базы: приложение без SUPERUSER и BYPASSRLS, журналы начислений и аудита — только чтение и вставка на уровне прав.
|
||||||
|
- Миграции: SHA-256, advisory lock, откат только последней и только явным флагом; работают мигратором без суперпользователя.
|
||||||
|
- Токены: сравнение за постоянное время, 256 бит, в базе только хеш; cookie `__Host-`, HttpOnly, Secure, SameSite=Strict; CSP `script-src 'self'`, экранирование в портале.
|
||||||
|
- Web-push: шифрование по RFC 8291/8188 и VAPID реализованы корректно, в уведомлениях нет персональных данных.
|
||||||
|
- Калькулятор: чистая функция, распределение фонда методом наибольшего остатка, тесты на реальной таблице владельца.
|
||||||
|
- Терминал: закрыты все прошлые находки по хранилищу и PIN (Argon2id, маркер первичной настройки в HKLM, отличие повреждения от временной ошибки), шаблоны отпечатков не уходят на сервер, автообновление проверяет подпись и издателя.
|
||||||
|
- Контейнеры не от root, read-only, образы по digest; секретов в истории нет; UTF-8 без BOM и LF соблюдены.
|
||||||
|
|
||||||
|
## 11. Порядок исправлений
|
||||||
|
|
||||||
|
1. **Разблокировать работу на базе:** K3 (одна строка в трёх местах), K4 и сразу интеграционные тесты этих маршрутов на PostgreSQL в CI.
|
||||||
|
2. **Закрыть открытый доступ и пилот:** K1, K2 (профиль `pilot` с обязательными токенами и HTTPS), K10, K11, бэкап K18.
|
||||||
|
3. **Деньги:** K5, K6, K7, K8, K9, K19 — до первого боевого расчёта.
|
||||||
|
4. **Синхронизация (уже в основной ветке):** сразу отключить приём смен и табеля от устройств и не регистрировать устройства в пилоте; затем закрыть K13, S2, S3, S9.
|
||||||
|
5. **Уведомления:** K12, F6, U1–U3.
|
||||||
|
6. **Терминал:** K14 (без этого отметки не попадают на сервер), K15, K16, K17.
|
||||||
|
7. P2 и P3 по мере работы.
|
||||||
|
|
||||||
|
Распределение по исполнителям — в файлах [TASK-CODEX.md](TASK-CODEX.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md).
|
||||||
|
|
||||||
|
## 12. Решения владельца (2026-09-26)
|
||||||
|
|
||||||
|
**Роли и точки (закрывает вопрос D6):**
|
||||||
|
|
||||||
|
- **бухгалтер** готовит расчёт и ведомость выплат и отправляет пакет на подтверждение;
|
||||||
|
- **руководитель** получает оповещение, просматривает и подписывает пакет (или возвращает с комментарием); только его подпись регистрирует начисления и выплаты — руководитель проводит оплаты;
|
||||||
|
- **управляющий** привязан к своим точкам и работает с ними; утверждать и выплачивать не может;
|
||||||
|
- сотрудники и управляющие **могут работать на других точках**: привязка к основной точке сохраняется, но смена на другой точке допустима и относится к той точке, где отработана.
|
||||||
|
|
||||||
|
**Согласие на обработку биометрии (закрывает вопрос K17):** согласие обязательно; подтверждается в мобильном приложении сотрудника при авторизации.
|
||||||
|
|
||||||
|
Юридическая оговорка для исполнителя: 152-ФЗ требует для биометрии согласия в письменной форме. Электронное согласие равнозначно письменному, если подписано электронной подписью, в том числе простой, при наличии соглашения о её использовании (например, пункт трудового договора или локального акта). Текст согласия и такое соглашение готовит юрист.
|
||||||
|
|
||||||
|
## 13. Локальная ветка
|
||||||
|
|
||||||
|
Ветка `codex/payroll-foundation` удалена по решению владельца. Её незакоммиченные копии, уже влитые в основную ветку коммитом `3464998`, сохранены в `git stash` на случай сверки.
|
||||||
26
docs/audit/2026-09-26/README.md
Normal file
26
docs/audit/2026-09-26/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Аудит 2026-09-26 и задания исполнителям
|
||||||
|
|
||||||
|
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через 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 |
|
||||||
|
| [ACCEPTANCE-2.md](ACCEPTANCE-2.md) | Приёмка второго круга: Grok принят, Codex и Antigravity — что доделать, порядок влития |
|
||||||
|
| [ACCEPTANCE-3.md](ACCEPTANCE-3.md) | Приёмка третьего круга: Codex принят кроме одного красного теста, порядок влития |
|
||||||
|
| [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-N.md`.
|
||||||
|
|
||||||
|
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
||||||
116
docs/audit/2026-09-26/TASK-ANTIGRAVITY-2.md
Normal file
116
docs/audit/2026-09-26/TASK-ANTIGRAVITY-2.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Задание для Antigravity, круг 2: синхронизация, push, outbox
|
||||||
|
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`)
|
||||||
|
- Задание самодостаточное; полный аудит и доказательства — в `docs/audit/2026-09-26/`.
|
||||||
|
|
||||||
|
## Итог приёмки прошлого круга
|
||||||
|
|
||||||
|
Ветку `antigravity/fix-reject-device-shifts` (`795bba0`) проверили на живой PostgreSQL 17.
|
||||||
|
|
||||||
|
**Что сработало:**
|
||||||
|
|
||||||
|
- Подделка смены с устройства закрыта. Смена `status: confirmed, worked_minutes: 2880` теперь получает `rejected / COMMAND_NOT_SUPPORTED`. Раньше она начисляла 14 400 ₽, теперь 0 ₽.
|
||||||
|
- Сценарии Grok SYNC-A и SYNC-B на этой ветке зелёные.
|
||||||
|
|
||||||
|
**Что не так:**
|
||||||
|
|
||||||
|
1. `tests/integration/sync-rejection.test.ts:69-74` на реальной базе падает. Очистка идёт ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Проверка внутри теста проходит, но файл красный — значит, на живой базе тест не запускали.
|
||||||
|
2. Сообщение коммита — один абзац в строке заголовка. Нужен короткий заголовок, а причина — в теле.
|
||||||
|
3. Из задания сделана только немедленная мера. Остальные части не начаты.
|
||||||
|
|
||||||
|
## Как работать в этом круге
|
||||||
|
|
||||||
|
- **Ветки.** `antigravity/fix-<тема>` от `331e490`, по одной на каждый пункт ниже. Маленькие коммиты: заголовок до 72 символов, причина в теле (AGENTS.md).
|
||||||
|
- **Проверка на живой базе обязательна.** В репозитории есть стенд Grok (ветка `grok/e2e-harness`, после влития — в основной):
|
||||||
|
- `scripts/test/provision-pg.mjs` создаёт роли как в пилоте: мигратор без SUPERUSER, приложение без BYPASSRLS;
|
||||||
|
- `node scripts/test/run-e2e.mjs` прогоняет сценарии на реальном сервере и реальной PostgreSQL 17.
|
||||||
|
|
||||||
|
Пункт считается сделанным, когда соответствующий сценарий стал зелёным. Номера сценариев указаны в таблицах.
|
||||||
|
|
||||||
|
- **Очистка в тестах** — ролью мигратора, как в `tests/integration/tenant-isolation.test.ts` после исправления Grok. Роли приложения удалять запрещено, так задумано.
|
||||||
|
- **Список известных дефектов.** После каждого исправления сообщи Grok ID сценариев, которые стали зелёными, — он уберёт их из `tests/e2e/known-defects.json`. Сам этот файл не правь: иначе CI упадёт с «дефект исправлен, обновите список».
|
||||||
|
- **Outbox.** Писать в `staff_outbox` только через общий хелпер, который делает Codex (ветка `codex/fix-live-database`). Во вставке обязательно поле `occurred_at` — без него подтверждение и расчёт падают.
|
||||||
|
|
||||||
|
**Твоя зона:**
|
||||||
|
|
||||||
|
- `src/sync.ts`, `src/domain/sync/**`, миграции синхронизации;
|
||||||
|
- `src/domain/notification/**`, маршруты `/push/*`;
|
||||||
|
- service worker и подписка в `src/portal-ui.ts`;
|
||||||
|
- диспетчер и публикатор outbox.
|
||||||
|
|
||||||
|
**Не трогай:** авторизацию, сотрудников, смены, расчёт, терминал и развёртывание — это зона Codex; `tests/e2e/**` и CI — зона Grok.
|
||||||
|
|
||||||
|
## 1. Довести прошлую ветку (сегодня)
|
||||||
|
|
||||||
|
| Что сделать | Готово, когда |
|
||||||
|
| --------------------------------------------------------------- | ----------------------------------------- |
|
||||||
|
| Очистка в `sync-rejection.test.ts` — ролью мигратора | Тест зелёный на живой базе |
|
||||||
|
| Перебазировать ветку на `331e490`, переписать сообщение коммита | Ветка вливается в основную без конфликтов |
|
||||||
|
|
||||||
|
## 2. Синхронизация на реальной базе не работает (срочно)
|
||||||
|
|
||||||
|
| ID | Что сломано | Где | Готово, когда |
|
||||||
|
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------- |
|
||||||
|
| SYNC-C0 | Начальная загрузка на устройство (`/sync/pull` без курсора) всегда отвечает 500: `column "status" does not exist`. Запрос обращается к столбцу, которого в таблице нет | `src/domain/sync/service.ts`, начальная загрузка | SYNC-C0 зелёный |
|
||||||
|
|
||||||
|
## 3. Синхронизация: остальные дефекты
|
||||||
|
|
||||||
|
| Мой ID | Сценарий Grok | Что сделать | Готово, когда |
|
||||||
|
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| S2 | SYNC-J | `occurred_at` — строгий ISO с часовым поясом. Устройство передаёт своё текущее время, сервер вычисляет расхождение часов и ограничивает время отметки относительно момента получения. При большом расхождении — `pending_review`. Сейчас отметка с датой 2020 года принимается | SYNC-J зелёный |
|
||||||
|
| S3 | SYNC-G | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверять ревизию в UPDATE. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | Битая команда не откатывает весь пакет; две параллельные отправки с одним ключом выполняются один раз |
|
||||||
|
| S4 | SYNC-E, SYNC-F | Начальная загрузка — только по точке устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL. Отметки уволенных отклонять. Точку брать из устройства; чужую точку в payload отклонять | SYNC-E и SYNC-F зелёные |
|
||||||
|
| S5 | SYNC-C, SYNC-D | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Устаревший курсор запускает повторную начальную загрузку | SYNC-C и SYNC-D зелёные |
|
||||||
|
| S6 | — | Отметки через синхронизацию пишут outbox (через общий хелпер), как онлайн-маршрут | Отметка через синхронизацию порождает событие |
|
||||||
|
| S7 | — | Очистка входящих получает `tenant_id`, а не идентификатор системного актора (`service.ts:1056`), и запускается по расписанию | — |
|
||||||
|
| S8 | SYNC-H | Проверка Origin на маршрутах управления устройствами, белый список scope, `no-store` на ответе с токеном устройства | SYNC-H зелёный |
|
||||||
|
| S9 | попроси Grok добавить | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки с привязанным устройством падает с `23502`. Новой миграцией заменить на `SET NULL (org_unit_id)` или `RESTRICT` | Точку можно удалить или получить понятный отказ |
|
||||||
|
| — | SYNC-I | Без настроенных токенов анонимный запрос не должен регистрировать устройство. Сделать вместе с Codex: он закрывает открытый режим без токенов (F2) | SYNC-I зелёный |
|
||||||
|
| — | SYNC-K | Подтверждение курсора «из будущего» (больше выданного сервером) отклонять с 400 | SYNC-K зелёный |
|
||||||
|
|
||||||
|
## 4. Push-уведомления
|
||||||
|
|
||||||
|
| Мой ID | Сценарий Grok | Что сделать | Готово, когда |
|
||||||
|
| ------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
|
||||||
|
| K12 | N3 | SSRF: сейчас сотрудник подписывает push на `https://127.0.0.1:…`, и сервер сам туда подключается. Разрешать только известные push-сервисы (FCM, Mozilla, Apple, WNS). После DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять. Где: `push-dispatcher.ts:80`, `web-push.ts:226-258` | N3 зелёный |
|
||||||
|
| N9 | N9 | Конфликт endpoint с другим сотрудником — ответ 409. Перепривязка разрешена только тем же браузером | N9 зелёный |
|
||||||
|
| N8 | — | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | — |
|
||||||
|
| U1–U4 | — | Проверять ключи подписки при регистрации. Ошибка по одной подписке не прерывает отправку остальным. Не больше 5–10 подписок на сотрудника. Таймаут и лимит чтения ответа 1 КБ. Атомарный захват строки перед отправкой (`sending` плюс аренда), повторы с backoff, dead letter | Два экземпляра сервера не отправляют сообщение дважды |
|
||||||
|
| U5–U7 | — | Scope service worker покрывает `/staff`. Выход отзывает push-подписку устройства. Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | — |
|
||||||
|
|
||||||
|
## 5. Оповещения процесса подтверждения (после этапа 4 у Codex)
|
||||||
|
|
||||||
|
Решение владельца: бухгалтер отправляет пакет расчёта и выплат на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Сам процесс и события делает Codex, от тебя — доставка.
|
||||||
|
|
||||||
|
| Событие | Кому | Содержание |
|
||||||
|
| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений, ссылка на экран подписи |
|
||||||
|
| `staff.payroll.package_returned` | Бухгалтеру, который отправил пакет | Комментарий руководителя |
|
||||||
|
| `staff.payroll.package_signed` | Бухгалтеру и сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм |
|
||||||
|
|
||||||
|
Основной канал — push. Запасной — Telegram, без персональных данных и сумм. Если пакет не подписан за 24 ч (значение настраивается), отправить повторное напоминание. Адресата определять по роли, а не по фиксированному списку.
|
||||||
|
|
||||||
|
Сценарий Grok OWN-03 («каждый руководитель получает ровно одно оповещение») должен стать зелёным.
|
||||||
|
|
||||||
|
## 6. Публикатор outbox
|
||||||
|
|
||||||
|
Вместо `setImmediate` нужен фоновый публикатор:
|
||||||
|
|
||||||
|
- повторяет строки в статусах `pending` и `failed`;
|
||||||
|
- ставит `published_at`;
|
||||||
|
- на стороне получателя — идемпотентность через inbox;
|
||||||
|
- соблюдает политику хранения.
|
||||||
|
|
||||||
|
Готово, когда после перезапуска сервера неотправленные события уходят, а `staff_outbox` не растёт бесконечно.
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
|
||||||
|
1. Пункт 1, затем пункт 2 — сегодня.
|
||||||
|
2. Пункты 3 и 4 — параллельно, по одной ветке на пункт.
|
||||||
|
3. Пункт 5 — когда Codex выпустит события пакета.
|
||||||
|
4. Пункт 6 — после влития общего хелпера outbox от Codex.
|
||||||
|
|
||||||
|
## Отчёт после каждой ветки
|
||||||
|
|
||||||
|
Ветка, коммит и ID сценариев Grok, которые стали зелёными, — с фактическим выводом `run-e2e`.
|
||||||
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, что осталось.
|
||||||
105
docs/audit/2026-09-26/TASK-ANTIGRAVITY.md
Normal file
105
docs/audit/2026-09-26/TASK-ANTIGRAVITY.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Задание для Antigravity: синхронизация, уведомления, outbox
|
||||||
|
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`. Твоя офлайн-синхронизация в неё уже влита (`3595792`); код такой же, как в проверенном коммите `5ee7517`.
|
||||||
|
- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/`. Для синхронизации есть живые сценарии: `evidence/live-sync.mjs.txt`, вывод — `evidence/live-sync.txt`.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
На живой PostgreSQL 17 подтвердилось:
|
||||||
|
|
||||||
|
- **Устройство синхронизации создаёт подтверждённые смены.** Оно отправило смену с `status: confirmed` и `worked_minutes: 2880`. Смена сохранилась подтверждённой без участия управляющего и без записи в журнале аудита. Расчёт начислил по ней 14 400 ₽. Это дефект основной ветки.
|
||||||
|
- **Отметки задним числом проходят.** Устройство сохранило отметку с датой 2020 года.
|
||||||
|
- **Точку с устройством нельзя удалить.** Удаление падает с `23502`.
|
||||||
|
- **Push-подписка даёт доступ во внутреннюю сеть.** Сотрудник подписал push на `https://127.0.0.1:…`, после чего сервер сам открыл туда соединение.
|
||||||
|
|
||||||
|
## Зона ответственности
|
||||||
|
|
||||||
|
**Твоя зона:**
|
||||||
|
|
||||||
|
- офлайн-синхронизация: `src/sync.ts`, `src/domain/sync/**`, миграция `0011`, `docs/OFFLINE-SYNC-PROTOCOL.md`;
|
||||||
|
- уведомления: `src/domain/notification/**`, маршруты `/push/*` в `src/portal.ts`, миграция `0010`, service worker и подписка в `src/portal-ui.ts`;
|
||||||
|
- диспетчер и публикатор outbox — всё, что читает `staff_outbox` и доставляет события.
|
||||||
|
|
||||||
|
**Не трогай:**
|
||||||
|
|
||||||
|
- авторизацию, сотрудников, смены, расчёт, импорт, процесс подтверждения, терминал и развёртывание — это Codex;
|
||||||
|
- `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, CI — это Grok.
|
||||||
|
|
||||||
|
**Записывать в outbox** только через общий хелпер, который Codex делает в задаче K3. Свою вставку не пиши: сейчас именно из-за разрозненных вставок без `occurred_at` падают подтверждение и расчёт.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Ветки `antigravity/fix-<тема>` от основной, отдельный PR на каждую часть.
|
||||||
|
- Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md).
|
||||||
|
- Перед пушем: `npm run check` и `npm run format:check` зелёные. Миграции проверены вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER.
|
||||||
|
- Каждое исправление — с тестом на настоящей PostgreSQL, а не только на поддельной базе.
|
||||||
|
- Пункт считается закрытым, только если соответствующий сценарий из `evidence/live-sync.mjs.txt` или `evidence/live-audit.mjs.txt` больше не воспроизводит дефект. Grok проверяет каждый пункт независимо.
|
||||||
|
|
||||||
|
## Часть 0 — немедленная мера (первым PR, сегодня)
|
||||||
|
|
||||||
|
- Сервер отклоняет от устройств команды `upsert_work_session` и `upsert_timesheet_entry`: ответ «команда не поддерживается», в базу ничего не пишется. Файл: `src/domain/sync/service.ts:784-990`.
|
||||||
|
- Отдельный маленький PR. Grok проверяет его первым.
|
||||||
|
- До закрытия S1 и S2 устройства синхронизации в пилоте не регистрировать.
|
||||||
|
|
||||||
|
**Готово, когда:** сценарий S1 из `evidence/live-sync.mjs.txt` больше не создаёт смену.
|
||||||
|
|
||||||
|
## Часть 1 — офлайн-синхронизация
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| S1 (P1) | Устройство отправляет только события отметок, смены и табель считает сервер (reconcile). Если правка с устройства действительно нужна: отдельный scope, только черновые статусы, запрет трогать `confirmed` и `approved`, запись в журнал аудита | `src/domain/sync/service.ts:784-990` | Команда с `status: confirmed` отклоняется; поддельная смена не попадает в расчёт |
|
||||||
|
| S2 | `occurred_at` принимать только в строгом ISO с зоной. Устройство передаёт своё текущее время, сервер считает расхождение часов и ограничивает время отметки относительно времени получения. При большом расхождении — статус `pending_review` | `service.ts:659, 723, 756` | Отметка с датой 2020 года отклоняется или уходит на проверку |
|
||||||
|
| S3 | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверка ревизии в `UPDATE`. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | `service.ts:672, 702, 774, 832, 936` | Две параллельные отправки с одним ключом выполняются один раз; одна плохая команда не откатывает весь пакет |
|
||||||
|
| S4 | Начальная загрузка: только точка устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL | `service.ts:~380-590, 448` | Устройство не получает уволенных и чужие точки |
|
||||||
|
| S5 | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Повторная начальная загрузка при устаревшем курсоре, как обещает протокол | `service.ts:343, 595-600` | — |
|
||||||
|
| S6 | Отметки через синхронизацию пишут outbox так же, как онлайн-маршрут. Проверять `status = 'active'` сотрудника. Сохранять `org_unit_id` | `service.ts:720-780` | — |
|
||||||
|
| S9 | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки падает. Заменить на `SET NULL (org_unit_id)` или `RESTRICT` новой миграцией | `migrations/0011_offline_sync_backend.up.sql:21` | Точку с устройством можно удалить или получить понятный отказ |
|
||||||
|
| S7 | Очистка входящих должна получать `tenant_id`, а не идентификатор системного актора. Запускать её по расписанию | `service.ts:1056` | — |
|
||||||
|
| S8 | Проверка Origin на маршрутах управления устройствами. Белый список scope. `no-store` на ответе с токеном устройства | `src/sync.ts:74`, `service.ts:190` | — |
|
||||||
|
|
||||||
|
## Часть 2 — уведомления
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| K12 (N3, P1) | Разрешать только известные push-сервисы: FCM, Mozilla, Apple, WNS. После разрешения DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | Подписка на `https://127.0.0.1:…` отклоняется; сценарий N3 не даёт входящего соединения |
|
||||||
|
| N9 | Конфликт endpoint с другим сотрудником → 409. Перепривязка — только тем же браузером | `push-dispatcher.ts:100-111` | Сценарий N9 отвечает 409 |
|
||||||
|
| N8 | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | `web-push.ts:80-87`, `src/portal.ts:121-123` | — |
|
||||||
|
| U1 | Проверять ключи подписки при регистрации. Ошибки отправки обрабатывать отдельно для каждой подписки | `web-push.ts:230`, `push-dispatcher.ts:283` | Неверная подписка не мешает остальным устройствам сотрудника |
|
||||||
|
| U2 | Не больше 5–10 подписок на сотрудника, таймаут, чтение тела ответа не больше 1 КБ, ограничение параллельности | `web-push.ts:235-258` | — |
|
||||||
|
| U3, U4 | Атомарный захват строки перед отправкой: статус `sending` плюс аренда. Повторы с backoff и учётом `Retry-After`. Отдельный статус dead letter. Срок хранения `last_error` | `push-dispatcher.ts:233-367` | При двух экземплярах сервера сообщение уходит один раз |
|
||||||
|
| U5, U6 | Scope service worker должен покрывать `/staff`. Выход из кабинета отзывает push-подписку устройства | `src/portal-ui.ts` (регистрация service worker и выход) | — |
|
||||||
|
| U7 | Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | `migrations/0010…up.sql:44` (новой миграцией) | — |
|
||||||
|
|
||||||
|
## Часть 3 — оповещения процесса подтверждения
|
||||||
|
|
||||||
|
Решение владельца: бухгалтер отправляет пакет с расчётом и выплатами на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Процесс, подпись и события в outbox делает Codex (его этап 4). От тебя — доставка.
|
||||||
|
|
||||||
|
| Событие | Кому | Что в оповещении |
|
||||||
|
| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений; ссылка сразу на экран подписи |
|
||||||
|
| `staff.payroll.package_returned` | Бухгалтеру, отправившему пакет | Комментарий руководителя |
|
||||||
|
| `staff.payroll.package_signed` | Бухгалтеру; сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм в тексте; суммы видны лишь в приложении |
|
||||||
|
|
||||||
|
- Основной канал — push в приложении. Если push не доставлен (нет подписки или ошибка), руководителю уходит сообщение в Telegram — без персональных данных и сумм.
|
||||||
|
- Если пакет не подписан за настраиваемое время (по умолчанию 24 ч), руководителю приходит повторное напоминание.
|
||||||
|
- Адресат определяется по роли и арендатору, а не по фиксированному списку: при смене руководителя оповещения получает новый.
|
||||||
|
|
||||||
|
**Готово, когда** на живом стенде отправка пакета даёт ровно одно оповещение каждому руководителю, а перезапуск сервера его не дублирует. Сотрудник получает уведомление без сумм и только после подписи.
|
||||||
|
|
||||||
|
## Часть 4 — публикатор outbox
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| F6, D7 | Заменить `setImmediate` фоновым публикатором. Он повторяет строки в `pending` и `failed`, ставит `published_at`, обеспечивает идемпотентность у получателя через inbox и соблюдает политику хранения. Альтернатива — убрать из `module.json` события, которые никто не публикует | `src/portal.ts:830-842`, `push-dispatcher.ts:333-367`, `module.json` | После перезапуска сервера неотправленные события уходят; `staff_outbox` не растёт бесконечно |
|
||||||
|
|
||||||
|
## Порядок работы с остальными
|
||||||
|
|
||||||
|
1. **Сразу:** часть 0.
|
||||||
|
2. **Параллельно с этапами 1–3 у Codex:** части 1 и 2. Часть S6 делай после того, как Codex сообщит, что влит общий хелпер outbox (K3).
|
||||||
|
3. **После этапа 4 у Codex** (события пакета): часть 3.
|
||||||
|
4. **Часть 4** — в любой момент после K3.
|
||||||
|
|
||||||
|
## Отчёт после каждой части
|
||||||
|
|
||||||
|
Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной.
|
||||||
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 теперь воспроизводятся по существу).
|
||||||
219
docs/audit/2026-09-26/TASK-CODEX.md
Normal file
219
docs/audit/2026-09-26/TASK-CODEX.md
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
# Задание для Codex: исправления по аудиту и решения владельца
|
||||||
|
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`
|
||||||
|
- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` (полный аудит) и `evidence/` (сценарии живой проверки и их вывод). Идентификаторы K, N, D, T, A, F ниже — из аудита.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Аудит на живой PostgreSQL 17 показал:
|
||||||
|
|
||||||
|
- на реальной базе не работают создание сотрудника, подтверждение смены, расчёт и утверждение зарплаты — везде 500;
|
||||||
|
- без токенов сервер открыт всем;
|
||||||
|
- одна смена может быть оплачена трижды;
|
||||||
|
- утверждённую смену можно отклонить задним числом без сторно;
|
||||||
|
- импорт из Excel теряет суммы;
|
||||||
|
- отметки терминала не доходят до сервера.
|
||||||
|
|
||||||
|
Юнит-тесты при этом зелёные, потому что работают на поддельной базе.
|
||||||
|
|
||||||
|
## Зона ответственности
|
||||||
|
|
||||||
|
**Твоя зона:**
|
||||||
|
|
||||||
|
- сервер: авторизация, сотрудники, смены, расчёт, импорт, выплаты, процесс подтверждения;
|
||||||
|
- база и миграции;
|
||||||
|
- развёртывание пилота;
|
||||||
|
- портал;
|
||||||
|
- Windows-терминал, служба и установщик;
|
||||||
|
- мобильное приложение в части экранов согласия и подписи.
|
||||||
|
|
||||||
|
**Не трогай:**
|
||||||
|
|
||||||
|
- `src/domain/notification/**`, маршруты `/push/*`, диспетчер и публикатор outbox — это Antigravity;
|
||||||
|
- `src/sync.ts`, `src/domain/sync/**`, миграцию `0011` — это тоже Antigravity;
|
||||||
|
- `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` — это Grok.
|
||||||
|
|
||||||
|
Если нужна правка в чужой зоне, попроси исполнителя.
|
||||||
|
|
||||||
|
**Общий хелпер вставки в outbox** (задача K3) делаешь ты. Antigravity будет пользоваться им для своих событий, поэтому сделай его первым и сообщи, когда он влит.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Ветки `codex/fix-<тема>` от основной, отдельный PR на каждый этап.
|
||||||
|
- Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md). Сейчас у 40 последних коммитов тела нет.
|
||||||
|
- Перед пушем должны быть зелёными `npm run check` и `npm run format:check`.
|
||||||
|
- Каждое исправление SQL или маршрута сопровождай интеграционным тестом **на настоящей PostgreSQL** (переменная `DATABASE_TEST_URL`, роль приложения `krona_staff_app`). Тесты на `fakeDatabase` подтверждением не считаются.
|
||||||
|
- Пункт считается закрытым, только когда соответствующий сценарий из `evidence/live-audit.mjs.txt` перестаёт воспроизводить дефект. Каждый пункт независимо перепроверит Grok.
|
||||||
|
- Реальные ФИО и суммы из книги владельца в тесты и фикстуры не копировать.
|
||||||
|
- Миграции проверяй вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER.
|
||||||
|
|
||||||
|
## Решения владельца
|
||||||
|
|
||||||
|
**Роли.**
|
||||||
|
|
||||||
|
| Роль | Может | Не может |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| Руководитель | Всё по всем точкам. Просматривает пакет на подтверждение, подписывает или возвращает его. **Проводит оплаты:** только его подпись регистрирует начисления и выплаты | — |
|
||||||
|
| Бухгалтер | Просмотр по всем точкам, расчёт, ведомость выплат, **отправка пакета на подтверждение**, выгрузки | Подписывать; регистрировать начисления и выплаты; менять правила оплаты и смены |
|
||||||
|
| Управляющий | Сотрудники, смены, выручка и черновик расчёта **по своим точкам** | Отправлять на подтверждение, подписывать, выплачивать; видеть данные чужих точек |
|
||||||
|
| Сотрудник | Только свои смены, начисления, выплаты и остаток | Всё остальное |
|
||||||
|
|
||||||
|
**Точки.**
|
||||||
|
|
||||||
|
- У каждого сотрудника и управляющего есть основная точка и дополнительные, в том числе временные (подмена), с датами действия.
|
||||||
|
- Работать на другой точке можно. Смена относится к точке, где она отработана, и считается по правилам и фонду этой точки.
|
||||||
|
|
||||||
|
**Процесс оплаты.** Бухгалтер отправляет пакет на подтверждение, руководитель получает оповещение, проверяет, подписывает — и всё регистрируется.
|
||||||
|
|
||||||
|
**Согласие.** Согласие на обработку персональных данных и биометрии обязательно. Сотрудник подтверждает его в мобильном приложении при авторизации.
|
||||||
|
|
||||||
|
## Этап 0 — форматирование (первым коммитом, до всех остальных)
|
||||||
|
|
||||||
|
Одним коммитом выполни `npm run format` по всему репозиторию (сейчас 16 файлов не проходят проверку). Коммит сделай до того, как остальные исполнители начнут работу, чтобы не было конфликтов.
|
||||||
|
|
||||||
|
## Этап 1 — разблокировать работу на реальной базе (сегодня)
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| K3 (N2) | Во вставках в `staff_outbox` нет `occurred_at`, а столбец `NOT NULL` без значения по умолчанию. Вынеси вставку в один общий хелпер outbox, который всегда заполняет время, и замени им все вставки | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935`; эталон — `src/portal.ts:810-823` | Подтверждение смены, расчёт и утверждение отвечают 200 на живой базе; тест на PostgreSQL |
|
||||||
|
| K4 (N1) | Параметр `$2` выводится PostgreSQL как `text` и как `varchar` (ошибка `42P08`). Добавь явные приведения или вынеси поиск профессии в отдельный запрос | `src/portal.ts:465-483` | `POST /employees` → 201 на живой базе; тест на PostgreSQL |
|
||||||
|
| — | Проверь остальные запросы на тот же дефект: один `$N` в разных типах | весь `src/` | — |
|
||||||
|
|
||||||
|
## Этап 2 — закрыть открытый доступ и привести пилот в порядок
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| K1 (F2, P0) | Без токенов сейчас любой анонимный запрос получает права `STANDALONE_ACCESS`. Сделай fail-closed: сервер не стартует без токенов, если `HOST` не loopback. Во всех compose токены и пароли — через `${VAR:?}` | `src/adapters/identity.ts:32-49`, `src/config.ts:45-47`, `compose.yaml`, `deploy/pilot/compose.yaml:14-15` | Запуск без токенов на `0.0.0.0` падает с понятной ошибкой; анонимный `GET /portal` → 401 |
|
||||||
|
| K2 (F1, P0) | Пилот работает с `NODE_ENV=development`, чтобы обойти запрет standalone в production. Сделай явный профиль `pilot`: обязательные токены и HTTPS, никакого development-поведения. Платформенный JWT — отдельной задачей, когда будет контракт Core | `deploy/pilot/compose.yaml:8`, `compose.yaml:5`, `src/config.ts:51-66` | В пилотном compose нет `NODE_ENV=development`; проверки `loadConfig` действуют |
|
||||||
|
| K10 (F3) | Серверные сессии: в cookie — случайный идентификатор, в базе — хеш, срок жизни, отзыв при выходе и при смене ключа. Токену сотрудника — срок жизни | `src/portal.ts:262-276` | После `DELETE /session` старая cookie → 401 |
|
||||||
|
| K11 (F4, A2) | Ограничение частоты по IP на `/session` и на неуспешную аутентификацию. Для заведомо неверного токена не открывать транзакцию в БД: пул всего на 10 соединений | `src/portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | 60 неверных входов подряд дают 429 |
|
||||||
|
| A4 | Глобальный обработчик ошибок: при 5xx — общий текст и correlation id, детали только в лог | `src/app.ts` | В ответе 500 нет кода и текста ошибки PostgreSQL |
|
||||||
|
| F10, A6 | Проверку Origin для мутаций по cookie сделать fail-closed. Пропускать её только при `Authorization: Bearer` | `src/portal.ts:1359-1374` | Мутация по cookie без Origin → 403 |
|
||||||
|
| A5, A7, A9 | Аутентификацию делать до валидации тела; `/openapi.json` — только менеджеру или в dev. `no-store` на выдаче токена сотрудника. Correlation id от клиента хранить отдельно от серверного | `src/portal.ts:734`, `src/app.ts` | — |
|
||||||
|
| D13 | Лишние поля в теле запроса отклонять ответом 400 (`removeAdditional: false`), а не удалять молча, как сейчас | `src/app.ts` | Поведение совпадает с `additionalProperties: false` в OpenAPI |
|
||||||
|
| K18 (P1) | Резервное копирование пилота: `pg_dump -Fc` по расписанию, шифрование, копия вне хоста, срок хранения. Бэкап перед каждой миграцией в `setup.sh`. Провести restore drill и записать результат | `deploy/pilot/`, `docs/BACKUP-RESTORE.md` | Есть скрипт, расписание и протокол успешного восстановления |
|
||||||
|
| N6 | Задавать атрибуты роли только в `CREATE ROLE`, затем `ALTER ROLE … PASSWORD`, затем проверять `rolsuper` и `rolbypassrls` и при нарушении отказывать | `scripts/create-app-role.mjs:19`, `docker/postgres/init-app-role.sh:17` | Скрипт работает мигратором без SUPERUSER |
|
||||||
|
| N7 | Пиннинг сертификата вместо отключения проверки TLS; токен только из переменной окружения; убрать поле `code` из тела `/professions` | `scripts/import-owner-workbook.py:241, 255, 278` | — |
|
||||||
|
| F9 | Healthcheck образа по HTTPS | `Dockerfile:21` | — |
|
||||||
|
| D3 | TLS пилота: локальный CA, установленный на устройства, или домен с ACME; пиннинг SPKI или CA вместо листового сертификата; ключ не хранить незашифрованным рядом с pfx; runbook продления | `deploy/pilot/setup.sh:37-47` | На телефоне сотрудника регистрируется service worker (иначе PWA и push не работают) |
|
||||||
|
| D4, D11, D12 | Образы с тегами и возможность отката; `lock_timeout` в миграциях; миграции не от root и без dev-зависимостей; smoke-тест проверяет коды ответов и не пишет в боевую базу | `deploy/pilot/setup.sh`, `scripts/migrate.mjs`, `deploy/pilot/smoke-test.sh` | — |
|
||||||
|
| D9, D10, D14, D15 | Лимиты памяти, CPU и числа процессов, `cap_drop`, ротация логов; `.dockerignore` для `**/.env` и `deploy/pilot/secrets`; `redact` в логгере, ошибки пула — через pino; безопасные значения по умолчанию в корневом compose | compose-файлы, `.dockerignore`, `src/app.ts:42`, `src/infrastructure/database.ts` | — |
|
||||||
|
|
||||||
|
## Этап 3 — денежная логика (до первого боевого расчёта)
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| K5 (N4) | Каждая смена начисляется ровно один раз: `session_id` в строке ведомости и уникальность среди неаннулированных ведомостей (или exclusion constraint на пересечение периодов, включая сочетание «одна точка» и «все точки»). При пересчёте обновлять `period_end` | `src/domain/payroll/service.ts:425-454, 759-768, 860-903`; индексы `migrations/0006:196-199` | Три пересекающихся расчёта дают одно начисление 2 400 ₽ или 409 (сейчас 7 200 ₽) |
|
||||||
|
| K6 (D5) | Смены и строки ведомости после подписи неизменяемы на уровне БД: триггер или политика, у роли приложения нет DELETE и UPDATE. Исправление — только сторнирующей записью | `src/portal.ts:1024-1106`, `migrations/0006:296-300` | Отклонение оплаченной смены → 409 или создаётся сторно |
|
||||||
|
| K7 (D2) | Импорт считает предыдущую сумму по всем записям дня, а пишет по точному ключу — суммы теряются. Считать по точному `external_key`, корректировку писать с отдельным ключом ревизии | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147, 168, 176` | Две точки в один день и повторный импорт с изменённой суммой дают верный итог; тест |
|
||||||
|
| K8 (D1) | Сделать версии правил настоящими: v1 неизменяема, любое изменение создаёт новую версию, учитываются даты действия правил и условий точки. В строке ведомости хранить JSON дерева правила, версию, id назначения, id и ревизию смены | `service.ts:152-180, 517-529, 686-695` | Подписанную ведомость можно пересчитать и получить то же число; тест |
|
||||||
|
| K9 (D3) | Сохранять произвольные параметры из `param_declarations` со значениями по умолчанию. Загружать все метрики и позиции, которые требует дерево правила | `service.ts:295-299, 488-500`, `src/portal-ui.ts` | Пресеты «официант» и «премия за порог выручки» дают ожидаемые суммы (сейчас 0 ₽); тест |
|
||||||
|
| N12, D9 | Ограничить ставки сверху (не больше 10¹² коп.), проверять `Number.isSafeInteger` и реальность даты и месяца | `src/operations.ts:560-585, 665-671` | `rate_minor = 1e20` → 400; месяц `2026-13` → 400 (сейчас 200 и 500) |
|
||||||
|
| N11 | Строгая схема импорта: `kind` из списка, знак выплаты, обязательные строки | `src/operations.ts:776-812, 1168-1210` | Некорректный импорт → 400, а не 500 |
|
||||||
|
| N13, N14 | Первый расчёт периода без гонки (`ON CONFLICT … RETURNING` или advisory lock). GET-запросы не пишут в базу; начальные правила создаются один раз | `service.ts:152-180, 446-454` | — |
|
||||||
|
| F7 | `Idempotency-Key` для расчёта, отправки, подписи, импорта и выплат | — | Повтор с тем же ключом не создаёт дублей |
|
||||||
|
| D7, F12 | Остаток округления фонда не выплачивается сверх фонда. Деньги в расчётах — `bigint` или проверенные безопасные целые | `src/domain/payroll/calculator.ts` | — |
|
||||||
|
|
||||||
|
## Этап 4 — роли, точки, подтверждение и согласие (решения владельца)
|
||||||
|
|
||||||
|
### 4.1 Роли и точки
|
||||||
|
|
||||||
|
- Роли — по таблице в разделе «Решения владельца». Права проверяются на сервере в каждом запросе, включая расчёт и выгрузки. Интерфейс только прячет недоступное.
|
||||||
|
- Привязки «человек — точка» хранить в отдельной таблице с датами действия: одна основная точка и сколько угодно дополнительных, в том числе временных.
|
||||||
|
- Управляющий видит и подтверждает смены всех, кто работал на его точке, включая приходящих. Их начисления на других точках он не видит.
|
||||||
|
- В режиме `standalone` роль и список точек задаются при выдаче доступа (через серверные сессии из K10). В режиме `platform` они берутся из прав Core.
|
||||||
|
- Каждое действие пишется в журнал аудита с конкретным человеком. Сейчас все менеджеры записываются одним `STANDALONE_ACTOR_ID` (A8).
|
||||||
|
|
||||||
|
### 4.2 Подтверждение и оплата
|
||||||
|
|
||||||
|
```
|
||||||
|
Бухгалтер: расчёт → ведомость выплат → «Отправить на подтверждение»
|
||||||
|
↓ оповещение руководителю (делает Antigravity)
|
||||||
|
Руководитель: просмотр пакета → «Подписать» или «Вернуть с комментарием»
|
||||||
|
↓ подпись
|
||||||
|
Система: начисления и выплаты регистрируются в реестре, пакет и его смены
|
||||||
|
становятся неизменяемыми; бухгалтеру и сотрудникам уходят оповещения
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Пакет** — расчётная ведомость за период и ведомость выплат (выплат может не быть). Руководитель подписывает весь пакет одной подписью.
|
||||||
|
- **Статусы пакета:** `draft → calculated → submitted → signed`; возврат: `submitted → returned → calculated`.
|
||||||
|
- **Отправка** фиксирует снимок пакета: строки, итоги, предупреждения расчёта и хеш содержимого.
|
||||||
|
- Если после отправки изменились данные пакета (смена, выручка, ставка, правило), отправка отменяется. Пакет возвращается бухгалтеру с пометкой «данные изменились».
|
||||||
|
- Подписать устаревший пакет нельзя (закрывает D4).
|
||||||
|
- **Экран руководителя** в портале, позже и в приложении. На нём:
|
||||||
|
- итоги по точкам и сотрудникам и сравнение с прошлым периодом;
|
||||||
|
- все предупреждения: нет выручки, нет профиля оплаты, аномальные смены, выплата больше остатка;
|
||||||
|
- расшифровка любой строки.
|
||||||
|
|
||||||
|
Сотрудник без профиля оплаты блокирует подпись, а не выпадает из фонда молча. Руководитель пакет не редактирует — только подписывает или возвращает.
|
||||||
|
|
||||||
|
- **Подпись** — простая электронная. В момент подписи руководитель повторно подтверждает личность: в приложении PIN или биометрией телефона, в портале паролем.
|
||||||
|
- Сохраняется: кто, когда, устройство, IP, хеш подписанного снимка и версия соглашения об электронной подписи.
|
||||||
|
- Подписывает только роль «Руководитель». Бухгалтер не может подписать свой пакет, даже если у него есть обе роли.
|
||||||
|
- **Регистрация при подписи** идёт одной транзакцией:
|
||||||
|
- начисления и выплаты записываются в реестр идемпотентно по пакету;
|
||||||
|
- пакет и его смены становятся неизменяемыми (K6);
|
||||||
|
- в outbox через общий хелпер записываются события `staff.payroll.package_submitted`, `staff.payroll.package_returned`, `staff.payroll.package_signed`. Оповещения по ним делает Antigravity.
|
||||||
|
- **Возврат** — только с обязательным комментарием. Бухгалтер правит данные и отправляет новую версию пакета. История версий и комментариев сохраняется.
|
||||||
|
- **Выплаты только через подпись.** Закрыть ручные выплаты в обход подписи, включая импорт. Импорт исторических выплат из Excel — отдельная операция, которую тоже подписывает руководитель.
|
||||||
|
- **API:** отправить, вернуть, подписать, история пакета. Добавь эти маршруты в OpenAPI.
|
||||||
|
|
||||||
|
### 4.3 Согласие
|
||||||
|
|
||||||
|
**Сервер.**
|
||||||
|
|
||||||
|
- Журнал согласий только на вставку: сотрудник, вид согласия, версия документа, хеш текста, время по серверу, устройство, IP, способ подписания, время отзыва.
|
||||||
|
- Три отдельных вида согласия: обработка персональных данных, **биометрия**, передача оповещений через иностранные мессенджеры (Telegram).
|
||||||
|
- API: текущие версии документов, принять, отозвать, статус по сотруднику (для терминала).
|
||||||
|
- Тексты хранятся в базе с версиями, чтобы менять их без релиза. Новая версия текста требует повторного согласия при следующем входе.
|
||||||
|
|
||||||
|
**Приложение сотрудника** (дополнение к `docs/APP-CORE.md`).
|
||||||
|
|
||||||
|
- После приглашения и установки пароля и PIN — обязательный экран согласий, до входа в кабинет.
|
||||||
|
- Без согласия на обработку ПДн дальше не пустит. Без согласия на биометрию кабинет работает, но отпечаток зарегистрировать нельзя.
|
||||||
|
- В профиле — подписанные версии и кнопка отзыва.
|
||||||
|
|
||||||
|
**Без смартфона.** Управляющий отмечает бумажное согласие в кабинете и прикладывает скан; отметка идёт в журнал аудита.
|
||||||
|
|
||||||
|
**Юридически.** Для биометрии закон требует письменной формы. Электронное согласие равнозначно письменному, если подписано электронной подписью (в том числе простой) и есть соглашение о её использовании. Тексты согласий и соглашения готовит юрист; в коде предусмотри только их подстановку.
|
||||||
|
|
||||||
|
Этап 4 готов, когда на живой базе:
|
||||||
|
|
||||||
|
- управляющий точки А получает 403 на расчёт точки Б и на отправку пакета;
|
||||||
|
- бухгалтер отправляет пакет, но при попытке подписать получает 403;
|
||||||
|
- начисления и выплаты появляются в реестре только после подписи руководителя;
|
||||||
|
- изменение смены после отправки не даёт подписать пакет;
|
||||||
|
- возврат с комментарием возвращает пакет бухгалтеру;
|
||||||
|
- смена сотрудника точки А, отработанная на точке Б, попадает в расчёт точки Б;
|
||||||
|
- без согласия на биометрию сервер отвечает терминалу «регистрация запрещена».
|
||||||
|
|
||||||
|
## Этап 5 — терминал и служба
|
||||||
|
|
||||||
|
| ID | Что сделать | Где | Готово, когда |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
|
| K14 (T4, P1) | Терминал создаёт сотрудников со своими ULID, сервер их не знает и отвечает 404, отметки повторяются бесконечно. Нужна синхронизация справочника сотрудников с сервера или передача `personnel_number`. Привести `docs/TERMINAL-API-REQUIREMENTS.md` к фактическому контракту; поля, которых нет в схеме сервера, добавить на сервере или не отправлять | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `Models/TerminalModels.cs:157` | Отметка с терминала появляется на сервере; бесконечных повторов нет |
|
||||||
|
| K15 (T1, P1) | Служба создаёт pipe `global\KronaStaffTerminalPipe`, клиент подключается к `KronaStaffTerminalPipe`, поэтому IPC не работает (проверено запуском). Нужно: одно имя; `PipeOptions.FirstPipeInstance`; проверка клиентом, что сервер — процесс службы или SYSTEM; `TokenImpersonationLevel.Identification`; интеграционный тест на реальном pipe | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84, 236` | Клиент подключается к службе; посторонний процесс не может занять имя |
|
||||||
|
| K16 (T2, P1) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама меняет в нём ACL и ротирует логи — путь к SYSTEM через junction. Данные службы и логи — только для SYSTEM и Администраторов, с проверкой reparse-точек. Интерфейс работает через IPC. Служба — под виртуальной учётной записью | `Services/TerminalStore.cs:305-360`, `Services/CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | — |
|
||||||
|
| K17 (T5) | Регистрация отпечатка разрешена только при действующем согласии на биометрию (статус с сервера, кэшируется для офлайна). При отзыве согласия, блокировке и увольнении шаблоны удаляются на следующей синхронизации, включая `.bak1-3` и `*.corrupt-*` | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | Без согласия регистрация недоступна; после отзыва шаблонов нет ни в одном файле |
|
||||||
|
| T3, T6 | Единственный писатель файла состояния — служба (или межпроцессная блокировка с перечитыванием). Секреты недоступны группе операторов | `KronaStaffWorker.cs:375-393`, `TerminalStore.cs:164, 263, 325-351` | — |
|
||||||
|
| T7, T8 | Установщик: ACL на каталог установки; владелец каталога данных — Администраторы; терминал не запускается с повышенными правами ни после установки, ни после обновления | `installer/KronaStaffTerminal.nsi:49, 56, 110-111`; `MainPage.xaml.cs:1529-1530` | — |
|
||||||
|
| T9, T10 | Проверенный файл обновления держать открытым с запретом записи или копировать в каталог только для администраторов. Токен обновлений с узкими правами. `libzkfp.dll` загружать по абсолютному пути | `Services/UpdateService.cs:118-127, 200-212`; `FingerprintScanner.cs:605-617` | — |
|
||||||
|
| D5 | Скрипт настройки терминала пишет в `%ProgramData%` с DPAPI LocalMachine | `deploy/pilot/configure-terminal-pilot.ps1:11, 24-27, 43` | — |
|
||||||
|
| T11–T13 | Ограничить IPC до проверки HMAC; добавить backoff в синхронизацию; уведомлять об откате к резервной копии; ввести порог совпадения отпечатка | — | — |
|
||||||
|
|
||||||
|
## Этап 6 — портал и документация
|
||||||
|
|
||||||
|
- W1: маршрут `/staff/*` отвечает 200 и HTML на любой путь (`src/app.ts:146`). Оставить явный список разделов, остальное — 404.
|
||||||
|
- D8: привести к коду `ARCHITECTURE.md`, `ROADMAP.md`, `PLATFORM-INTEGRATION.md`, `README.md`, `.env.example` и `SECURITY.md`. `AUDIT-REMEDIATION-2026-09-18.md` пометить устаревшим.
|
||||||
|
|
||||||
|
## Порядок работы с остальными
|
||||||
|
|
||||||
|
1. **Сразу:** этап 0 и этап 1. Как только общий хелпер outbox из K3 влит, сообщи Antigravity.
|
||||||
|
2. **Параллельно:** этапы 2 и 3. Antigravity в это время исправляет синхронизацию и push, Grok строит тестовый контур.
|
||||||
|
3. **Этап 4.** События пакета нужны Antigravity для оповещений. Согласие нужно этапу 5.
|
||||||
|
4. **Этап 5:** K14 и K15 можно начинать сразу после этапа 1; K17 — после серверной части согласия.
|
||||||
|
|
||||||
|
## Отчёт после каждого этапа
|
||||||
|
|
||||||
|
Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной.
|
||||||
93
docs/audit/2026-09-26/TASK-GROK-2.md
Normal file
93
docs/audit/2026-09-26/TASK-GROK-2.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Задание для Grok, круг 2: Windows, сопоставление ID, недостающие сценарии, приёмка
|
||||||
|
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`) и твоя ветка `grok/e2e-harness` (`421da72`)
|
||||||
|
- Полный аудит владельца теперь в репозитории: `docs/audit/2026-09-26/AUDIT.md`; таблица соответствия ID ниже дублирует главное.
|
||||||
|
|
||||||
|
## Итог приёмки прошлого круга
|
||||||
|
|
||||||
|
Ветку `grok/e2e-harness` проверили на живой PostgreSQL 17.10. Вердикт: **принять после правок из пункта 1**.
|
||||||
|
|
||||||
|
**Что сделано хорошо:**
|
||||||
|
|
||||||
|
- Зона соблюдена: код продукта не тронут.
|
||||||
|
- Все коммиты с объяснением причины.
|
||||||
|
- Прогон дал ровно те же 38 красных и 9 зелёных сценариев, что в твоём отчёте.
|
||||||
|
- Логика `known-defects.json` верная.
|
||||||
|
- SYNC-C0 — настоящая находка, которой не было в аудите владельца: начальная загрузка синхронизации на реальной базе отвечает 500 (`column "status" does not exist`).
|
||||||
|
- Перекрёстная проверка: исправление Antigravity (`antigravity/fix-reject-device-shifts`) на твоих сценариях переводит SYNC-A и SYNC-B в зелёные.
|
||||||
|
|
||||||
|
**Что не так:**
|
||||||
|
|
||||||
|
1. **Под Windows стенд не запускается** (проект разрабатывается под Windows). Все три места падают с `ENOENT`, потому что в Windows это `.cmd`:
|
||||||
|
- `scripts/test/run-e2e.mjs:35` — `spawn('npx', …)`;
|
||||||
|
- `tests/e2e/harness/global-setup.ts:67` — `spawn(node_modules/.bin/tsx, …)`;
|
||||||
|
- `scripts/test/n13-race.mjs:102` — то же, что в предыдущем пункте.
|
||||||
|
2. **ID аудита владельца не сопоставлены** — таблица ниже.
|
||||||
|
3. **`tests/e2e/evidence/` коммитится в репозиторий** и будет меняться при каждом прогоне.
|
||||||
|
|
||||||
|
## Правила — как раньше
|
||||||
|
|
||||||
|
- Ветка `grok/e2e-harness` (дописывай в неё), маленькие коммиты с причиной в теле.
|
||||||
|
- Твоя зона: `tests/e2e/**`, `tests/integration/**` (только очистка и стенд), `.forgejo/workflows/ci.yml`, `scripts/test/**`.
|
||||||
|
- `src/`, `apps/`, `migrations/`, `deploy/` не трогать. Нужна правка продукта — пиши в `tests/e2e/NOTES-FOR-EXECUTORS.md`.
|
||||||
|
|
||||||
|
## 1. Правки, без которых ветку не влить (сегодня)
|
||||||
|
|
||||||
|
| Что сделать | Готово, когда |
|
||||||
|
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Запуск без `.cmd`-обёрток: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, 'src/server.ts'])`, vitest — через `process.execPath` и его `vitest.mjs`, либо `shell: true` на `win32`. Добавить в CI проверку запуска на Windows, если есть раннер; если нет — записать это в REPORT | `node scripts/test/run-e2e.mjs` работает на Windows 11 с Node 22+ без правок |
|
||||||
|
| `tests/e2e/evidence/` убрать из git и добавить в `.gitignore`. Результаты прогона — артефакт CI. В репозитории оставить `REPORT.md` и таблицу | Повторный прогон не меняет отслеживаемые файлы |
|
||||||
|
| Перебазировать ветку на `331e490` | Вливается без конфликтов |
|
||||||
|
|
||||||
|
## 2. Сопоставление с ID аудита владельца
|
||||||
|
|
||||||
|
Переименуй теги в названиях сценариев в формат `[K3/N2]`, а ключи в `known-defects.json` — в двойные.
|
||||||
|
|
||||||
|
| ID владельца | Твой сценарий | Суть |
|
||||||
|
| -------------------------- | -------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| K1 | F2 | Без токенов анонимный запрос получает права |
|
||||||
|
| K3 | N2 | Вставка в outbox без `occurred_at`: подтверждение, расчёт, утверждение падают с 500 |
|
||||||
|
| K4 | N1 | `POST /employees` падает с 500 (`42P08`) |
|
||||||
|
| K5 | N4 | Пересекающиеся расчёты начисляют смену повторно |
|
||||||
|
| K10 | F3 | Сырой токен в cookie, выход не отзывает сессию |
|
||||||
|
| K11 | F4 | Нет ограничения частоты |
|
||||||
|
| K12 | N3 | SSRF через push-подписку |
|
||||||
|
| K13 (S1) | SYNC-A, SYNC-B | Устройство создаёт подтверждённые смены и табель |
|
||||||
|
| S2 | SYNC-J | Отметка задним числом через синхронизацию |
|
||||||
|
| S3 | SYNC-G | Одна битая команда откатывает пакет |
|
||||||
|
| S4 | SYNC-E, SYNC-F | Лишние данные на устройство, отметки уволенных, чужая точка |
|
||||||
|
| S5 | SYNC-C, SYNC-D | Курсор теряет изменения |
|
||||||
|
| S8 | SYNC-H | CSRF на управлении устройствами |
|
||||||
|
| K19 (D4) | OWN-07 | Подпись устаревшего пакета |
|
||||||
|
| D6 | OWN-01, OWN-02 | Управляющий вне своих точек |
|
||||||
|
| K17 | OWN-10, OWN-11 | Согласие на биометрию |
|
||||||
|
| N9, N10, N11, N12, F7, F10 | те же | — |
|
||||||
|
|
||||||
|
## 3. Недостающие сценарии
|
||||||
|
|
||||||
|
Все проверяют правильное поведение. Сейчас они красные — внеси их в `known-defects.json`.
|
||||||
|
|
||||||
|
| ID владельца | Сценарий | Ожидаемое поведение |
|
||||||
|
| ------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| K6 (D5) | Подтверждённую смену включают в расчёт, руководитель подписывает, затем смену отклоняют | 409 или создаётся сторнирующая запись. Сейчас: 200, сторно нет, начисление остаётся |
|
||||||
|
| K7 (D2) | Импорт книги: начисления за один день на двух точках, затем повторный импорт с изменённой суммой | Итог в реестре равен сумме по книге. Сейчас теряется: вторая точка пишется как отрицательная корректировка, изменение суммы пропадает |
|
||||||
|
| K8 (D1) | Расчёт подписан, затем меняется правило оплаты или ставка, затем подписанную ведомость пересчитывают для проверки | Сумма та же, что в подписанной ведомости |
|
||||||
|
| K9 (D3) | Сотрудник с пресетом «официант» (оклад смены, процент от личных продаж, допродажи, премия за средний чек) | Начисление по параметрам пресета. Сейчас 0 ₽: сохраняются только ставка, процент и гарантия |
|
||||||
|
| S9 | Удаление точки, к которой привязано устройство синхронизации | Точка удалена или получен понятный отказ. Сейчас 500, `23502 null value in column "tenant_id"` |
|
||||||
|
| A4 | Любой ответ 500 | Без кода и текста ошибки PostgreSQL в теле. Сейчас наружу уходят, например, `42P08` и `23502` с текстом |
|
||||||
|
| N12-дата | Расчёт с `month = 2026-13` | 400. Сейчас 500 |
|
||||||
|
|
||||||
|
## 4. Приёмка
|
||||||
|
|
||||||
|
1. **Ветка Antigravity `antigravity/fix-reject-device-shifts`.** После того как Antigravity исправит очистку в `sync-rejection.test.ts` и ветку вольют, убери SYNC-A и SYNC-B из `known-defects.json` отдельным коммитом.
|
||||||
|
2. **Ветки Codex `codex/fix-format-baseline` и `codex/fix-live-database`** (этапы 0 и 1: форматирование, K3, K4). Прогони стенд на каждой и дай таблицу «ID — было — стало — доказательство». Если K3 и K4 зелёные, убери их из списка известных дефектов после влития.
|
||||||
|
3. **Дальше** — каждая новая ветка Codex и Antigravity, по той же схеме. На слово ничего не принимать.
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
|
||||||
|
Пункт 1 → пункт 2 → пункт 3. Приёмку из пункта 4 веди параллельно, по мере появления веток.
|
||||||
|
|
||||||
|
## Отчёт
|
||||||
|
|
||||||
|
Обновлённый `tests/e2e/REPORT.md`: что изменилось, таблица «ID владельца / твой ID — цвет — фактический вывод», приёмочные таблицы по веткам исполнителей.
|
||||||
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`;
|
||||||
|
- что не запускалось и почему.
|
||||||
84
docs/audit/2026-09-26/TASK-GROK.md
Normal file
84
docs/audit/2026-09-26/TASK-GROK.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Задание для Grok: тестовый контур на живой базе и независимая приёмка
|
||||||
|
|
||||||
|
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
||||||
|
- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`
|
||||||
|
- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/` со сценариями живой проверки и их выводом.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
`AUDIT.md` сверен с твоим аудитом `2026-09-26_full-project-audit_grok_3e20544.md`. Итог сверки:
|
||||||
|
|
||||||
|
- 22 находки подтверждены;
|
||||||
|
- 4 подтверждены частично (F8, F11, N7, N10);
|
||||||
|
- 1 уже исправлена (F5);
|
||||||
|
- 1 не воспроизвелась (N13).
|
||||||
|
|
||||||
|
После твоего аудита в основную ветку влиты офлайн-синхронизация и новое оформление портала. Находки по синхронизации (S1–S9) в `AUDIT.md` уже учтены.
|
||||||
|
|
||||||
|
Главный урок: 156 зелёных тестов на поддельной базе не заметили, что создание сотрудника, подтверждение смены и расчёт зарплаты на реальной PostgreSQL всегда падают.
|
||||||
|
|
||||||
|
## Твоя роль
|
||||||
|
|
||||||
|
Продукт ты не исправляешь — это делают Codex и Antigravity.
|
||||||
|
|
||||||
|
От тебя нужно две вещи:
|
||||||
|
|
||||||
|
- построить тестовый контур, который ловит дефекты на настоящей базе;
|
||||||
|
- независимо принимать каждое исправление.
|
||||||
|
|
||||||
|
## Зона ответственности
|
||||||
|
|
||||||
|
**Твоя зона:**
|
||||||
|
|
||||||
|
- `tests/e2e/**` — новый набор сквозных сценариев: живой сервер на живой PostgreSQL;
|
||||||
|
- `tests/integration/tenant-isolation.test.ts`;
|
||||||
|
- `.forgejo/workflows/ci.yml` и вспомогательные скрипты для тестов.
|
||||||
|
|
||||||
|
**Не трогай:** `src/`, `apps/`, `migrations/`, `deploy/`. Это зоны Codex и Antigravity. Если тесту нужна правка продукта, оставь замечание исполнителю.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Ветка `grok/e2e-harness`, маленькие коммиты. В теле каждого коммита объясни причину (AGENTS.md).
|
||||||
|
- Токены и ключи в тестах генерируй случайно. Реальные ФИО и суммы не используй.
|
||||||
|
- К каждому выводу прикладывай команду и фактический вывод. То, что не удалось проверить, помечай «не проверено» явно, как ты уже делаешь.
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
| № | Что сделать | Готово, когда |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | **N5.** В тесте изоляции арендаторов очистка идёт ролью приложения, а у неё нет права DELETE на сотрудников. Перенеси очистку на роль мигратора или оберни в транзакцию с откатом | Тест проходит на живой базе; CI с `DATABASE_TEST_URL` из-за него больше не красный |
|
||||||
|
| 2 | **Набор `tests/e2e`.** Поднять сервер на реальной PostgreSQL 17 и прогнать сценарии по всем находкам P0 и P1. За основу взять `evidence/live-audit.mjs.txt` и `evidence/live-sync.mjs.txt`. Каждый сценарий проверяет правильное поведение, поэтому сейчас он красный. Список красных сценариев и есть список открытых дефектов | Есть сценарии для K1, K3, K4, K5, K6, K10, K11, K12, S1, S2, S9, N9, N12 и A4; отчёт «зелёный / красный» по каждому ID |
|
||||||
|
| 3 | **Сценарии по решениям владельца (роли, точки, процесс подтверждения, согласие).** Подробности — в таблице ниже | Сценарии есть и пока красные; это ожидаемо |
|
||||||
|
| 4 | **CI.** Сервис PostgreSQL; роли как в пилоте, но мигратор **без** SUPERUSER. Миграции вверх, откат всех, снова вверх. Запуск `tests/e2e`. `docker build` перенести туда, где есть docker, либо вынести в отдельную задачу. Закрепить действия по SHA (F13) | CI реально проверяет схему и маршруты на живой базе |
|
||||||
|
| 5 | **Проверка пилотного compose** тестом, как сейчас проверяется корневой: переменные с `:?`, нет `NODE_ENV=development`, у приложения нет учётных данных мигратора | — |
|
||||||
|
| 6 | **Приёмка.** Первым проверь немедленную меру Antigravity по S1: устройство больше не создаёт и не меняет смену. Дальше после каждого PR от Codex и Antigravity прогоняй `tests/e2e` и целевые сценарии. На слово ничего не принимай | По каждому закрытому ID есть независимое подтверждение: таблица «ID — было — стало — доказательство» |
|
||||||
|
| 7 | **Повторить N13** (гонка первого расчёта) под нагрузкой: 10–50 параллельных запросов. У меня на двух запросах не воспроизвелось | Вердикт с числом попыток |
|
||||||
|
| 8 | **Snyk** — только если владелец выдаст токен. Без токена не запускать | — |
|
||||||
|
|
||||||
|
Сценарии для задачи 3:
|
||||||
|
|
||||||
|
| Сценарий | Ожидаемый результат |
|
||||||
|
| --------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| Управляющий точки А запускает расчёт точки Б | 403 |
|
||||||
|
| Управляющий отправляет пакет на подтверждение | 403 |
|
||||||
|
| Бухгалтер отправляет пакет | успешно; оповещение каждому руководителю ровно одно |
|
||||||
|
| Бухгалтер пытается подписать пакет | 403 |
|
||||||
|
| Реестр до подписи руководителя | начислений и выплат нет |
|
||||||
|
| Руководитель подписывает | начисления и выплаты зарегистрированы; пакет и его смены неизменяемы |
|
||||||
|
| Изменение смены после отправки | подписать пакет нельзя, он возвращается бухгалтеру |
|
||||||
|
| Возврат с комментарием | пакет у бухгалтера, комментарий сохранён |
|
||||||
|
| Сотрудник точки А отработал смену на точке Б | смена попадает в расчёт точки Б |
|
||||||
|
| Сотрудник без согласия на биометрию | сервер отвечает терминалу «регистрация запрещена» |
|
||||||
|
| Отзыв согласия | статус для терминала меняется |
|
||||||
|
|
||||||
|
Проверки, которым нужна Windows (DPAPI, ACL каталога данных, установщик, WinUI, реальный pipe), в твоём окружении недоступны. Отмечай их «не проверено» и ссылайся на тесты Codex.
|
||||||
|
|
||||||
|
## Порядок работы с остальными
|
||||||
|
|
||||||
|
1. **Сразу:** задачи 1, 2, 4 и 5. Это не зависит от других исполнителей.
|
||||||
|
2. **Задача 3** — сразу. Сценарии будут красными, пока Codex не сделает этап 4.
|
||||||
|
3. **Задача 6** — по мере поступления PR. Первым — немедленная мера Antigravity по S1, затем этап 1 Codex (K3, K4).
|
||||||
|
|
||||||
|
## Отчёт
|
||||||
|
|
||||||
|
Первый PR — после задач 1, 2, 4 и 5. В нём набор сценариев и краткий отчёт: какие сценарии красные. Дальше — приёмочная таблица после каждого PR исполнителей.
|
||||||
17
docs/audit/2026-09-26/evidence/README.md
Normal file
17
docs/audit/2026-09-26/evidence/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Доказательства аудита 2026-09-26
|
||||||
|
|
||||||
|
| Файл | Что это |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `live-audit.mjs.txt` | Сценарии живой проверки основной ветки `3e20544`: сервер на реальной PostgreSQL 17, настоящие HTTP-запросы |
|
||||||
|
| `live-audit-final.txt` | Вывод последнего прогона на базе в UTF-8 |
|
||||||
|
| `live-sync.mjs.txt` | Сценарии для ветки синхронизации `5ee7517` |
|
||||||
|
| `live-sync.txt` | Вывод прогона |
|
||||||
|
| `format-check.txt` | Вывод `npm run format:check` |
|
||||||
|
|
||||||
|
Сценарии сохранены с расширением `.mjs.txt`, чтобы их не проверял линтер проекта. Для запуска скопируйте файл вне репозитория и уберите `.txt`. Поддерживаемая рабочая версия этих проверок — стенд Grok в `tests/e2e/`.
|
||||||
|
|
||||||
|
Как запускались: рабочая копия нужного коммита (`git worktree add --detach … <commit>`), `npm ci`, PostgreSQL 17 на порту 55432 с суперпользователем `postgres`, мигратор `krona_migrator` без SUPERUSER (владелец базы), роль приложения `krona_staff_app`, база в UTF-8, миграции `node scripts/migrate.mjs up`. Пути к рабочим копиям в начале скриптов (`REPO`) — локальные, их нужно поправить под своё окружение.
|
||||||
|
|
||||||
|
Токены в сценариях генерируются случайно при каждом запуске и никуда не выводятся. Пароли ролей — одноразовые для локального стенда.
|
||||||
|
|
||||||
|
Для сценариев N4, D5 и S1 скрипт временно задаёт значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает дефект N2) и снимает его после сценария. После исправления N2 эти строки нужно удалить.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint
|
||||||
|
device registration → 201; scopes ["attendance:sync"]
|
||||||
|
device push → 200 {"results":[{"idempotency_key":"forge-1790409261814","command_type":"upsert_work_session","status":"rejected","error_code":"COMMAND_NOT_SUPPORTED","error_message":"Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено"},…
|
||||||
|
[S1] не подтверждено — смена от устройства: статус undefined, минут undefined, подтвердил никто, записей в журнале аудита арендатора: 0
|
||||||
|
[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true
|
||||||
|
[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 0 ₽ (48 ч × 300 ₽)
|
||||||
40
docs/audit/2026-09-26/evidence/acceptance-grok-e2e.txt
Normal file
40
docs/audit/2026-09-26/evidence/acceptance-grok-e2e.txt
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
× [N1] менеджер создаёт сотрудника через POST /employees → 201 487ms
|
||||||
|
× [N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка 1795ms
|
||||||
|
× [N4] пересекающиеся расчёты не начисляют одну смену дважды 943ms
|
||||||
|
× [N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1 2834ms
|
||||||
|
× [N9] чужой push-endpoint нельзя перепривязать к другому сотруднику 421ms
|
||||||
|
× [N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400) 386ms
|
||||||
|
× [N10] терминальный токен не читает справочники профессий и точек (403) 31ms
|
||||||
|
× [F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401) 322ms
|
||||||
|
× [F3] cookie сессии не содержит сырой токен и отзывается при выходе 50ms
|
||||||
|
× [F10] мутация по cookie без Origin отклоняется (CSRF, 403) 21ms
|
||||||
|
× [F7] повтор Idempotency-Key с другим телом не создаёт второе событие 790ms
|
||||||
|
× [N11] импорт с неверным kind начисления → 400, а не 500 602ms
|
||||||
|
× [F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток) 744ms
|
||||||
|
× [OWN-01] менеджер точки A запускает расчёт для точки B → 403 978ms
|
||||||
|
× [OWN-02] менеджер точки отправляет пакет на согласование → 403 750ms
|
||||||
|
× [OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление 10ms
|
||||||
|
× [OWN-04] бухгалтер пытается подписать пакет → 403 20ms
|
||||||
|
× [OWN-05] до подписи директора в ledger нет начислений и выплат 5ms
|
||||||
|
× [OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы 10ms
|
||||||
|
× [OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру 5ms
|
||||||
|
× [OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён 10ms
|
||||||
|
× [OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B 16ms
|
||||||
|
× [OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена» 250ms
|
||||||
|
× [OWN-11] согласие отозвано → статус для терминала меняется 297ms
|
||||||
|
× [SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется) 791ms
|
||||||
|
× [SYNC-B] устройство не может утвердить табель (status=approved отклоняется) 392ms
|
||||||
|
× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 427ms
|
||||||
|
× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 960ms
|
||||||
|
× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 518ms
|
||||||
|
× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 279ms
|
||||||
|
× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 664ms
|
||||||
|
× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 350ms
|
||||||
|
× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 180ms
|
||||||
|
× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 180ms
|
||||||
|
× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 308ms
|
||||||
|
× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 25ms
|
||||||
|
× [PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение} 103ms
|
||||||
|
× [PILOT-2] ни один сервис не запускается с NODE_ENV=development 4ms
|
||||||
|
⎯⎯⎯⎯⎯⎯ Failed Tests 38 ⎯⎯⎯⎯⎯⎯⎯
|
||||||
|
Tests 38 failed | 9 passed (47)
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 466ms
|
||||||
|
× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 624ms
|
||||||
|
× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 372ms
|
||||||
|
× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 223ms
|
||||||
|
× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 477ms
|
||||||
|
× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 213ms
|
||||||
|
× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 136ms
|
||||||
|
× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 33ms
|
||||||
|
× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 283ms
|
||||||
|
× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 27ms
|
||||||
|
⎯⎯⎯⎯⎯⎯ Failed Tests 10 ⎯⎯⎯⎯⎯⎯⎯
|
||||||
|
Tests 10 failed | 3 passed (13)
|
||||||
18
docs/audit/2026-09-26/evidence/format-check.txt
Normal file
18
docs/audit/2026-09-26/evidence/format-check.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
Checking formatting...
|
||||||
|
[[33mwarn[39m] apps/KronaStaffService/README.md
|
||||||
|
[[33mwarn[39m] deploy/pilot/compose.yaml
|
||||||
|
[[33mwarn[39m] docs/DRIVER-DISTRIBUTION.md
|
||||||
|
[[33mwarn[39m] docs/HARDWARE-TEST-SLK20R.md
|
||||||
|
[[33mwarn[39m] docs/TERMINAL-API-REQUIREMENTS.md
|
||||||
|
[[33mwarn[39m] scripts/check-migrations.mjs
|
||||||
|
[[33mwarn[39m] src/adapters/identity.ts
|
||||||
|
[[33mwarn[39m] src/app.ts
|
||||||
|
[[33mwarn[39m] src/config.ts
|
||||||
|
[[33mwarn[39m] src/domain/attendance/work-session.ts
|
||||||
|
[[33mwarn[39m] src/domain/notification/web-push.ts
|
||||||
|
[[33mwarn[39m] src/portal.ts
|
||||||
|
[[33mwarn[39m] tests/unit/config.test.ts
|
||||||
|
[[33mwarn[39m] tests/unit/employee-push.test.ts
|
||||||
|
[[33mwarn[39m] tests/unit/tenant-sql.test.ts
|
||||||
|
[[33mwarn[39m] tests/unit/work-session.test.ts
|
||||||
|
[[33mwarn[39m] Code style issues found in 16 files. Run Prettier with --write to fix.
|
||||||
16
docs/audit/2026-09-26/evidence/live-audit-final.txt
Normal file
16
docs/audit/2026-09-26/evidence/live-audit-final.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=manager, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 200, POST /professions 201; POST /session 403
|
||||||
|
[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=employee, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 403, POST /professions 403; POST /session 403
|
||||||
|
[F4] ПОДТВЕРЖДЕНО — 60 неверных POST /session → {"401":60}
|
||||||
|
[F3] ПОДТВЕРЖДЕНО — вход 200, выход 204; cookie содержит сам токен менеджера: true; после DELETE /session старая cookie → GET /portal 200; флаги: Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Strict
|
||||||
|
[N1] ПОДТВЕРЖДЕНО — POST /employees → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}
|
||||||
|
[N2] ПОДТВЕРЖДЕНО — PATCH /work-sessions (confirmed) → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"}; GET /pay-rules → 200 (6 правил, выбрано hourly); PUT pay-assignment → 200; POST /payroll/calculate → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"}
|
||||||
|
[N4] ПОДТВЕРЖДЕНО — повторное подтверждение смены → 200; {"month":"2026-09"}: calculate 200, approve 200; {"from":"2026-09-02","to":"2026-09-30"}: calculate 200, approve 200; {"month":"2026-09","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3"}: calculate 200, approve 200; в реестре начислений за одну смену 8 ч × 300 ₽: записей 3, сумма 7200 ₽
|
||||||
|
[D5] ПОДТВЕРЖДЕНО — отклонение смены, уже начисленной в утверждённой ведомости → 200; записей в реестре после этого: 3 (сторно не создано)
|
||||||
|
[N13] не воспроизведено — два одновременных первых расчёта октября → 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st | 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st
|
||||||
|
[D9] проверка — month=2026-13 → 500; rate_minor=1e20 и лишнее поле → 200 {"id":"01M3E6YXHHCBJPWMF6VT7MYZGY","employee_id":"01M3E6YN08XJM58HP60FWVJSJ5","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3"
|
||||||
|
[N3] ПОДТВЕРЖДЕНО — выдача ключа сотруднику → 200; подписка на https://127.0.0.1:18443 → 201; отметка терминала → 201; входящих соединений на внутренний адрес: 1, TLS ClientHello: true
|
||||||
|
[N9] ПОДТВЕРЖДЕНО — сотрудник Б подписывает endpoint сотрудника А → 201; подписка теперь принадлежит сотруднику Б: true
|
||||||
|
[A4] проверка — 500 с деталями: POST /employees с несуществующей профессией → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}
|
||||||
|
[N10] проверка — терминал: GET /professions → 200 ({"professions":[{"id":"01M3E6YEJRAZQ9ZRQM1XZXWJ8T","code":"profession-01m3e6yejrazq9zrqm1xzxwj8t","name":"audit-open-manager-1790404672076","status":"active","created_at":"2026-09-26T06:37:52.089Z","updated_at":"2026-09-…); GET /org-units → 200 {"org_units":[]}
|
||||||
|
[HDR] проверка — GET /staff: content-security-policy="default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests", strict-transport-security="max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
426
docs/audit/2026-09-26/evidence/live-audit.mjs.txt
Normal file
426
docs/audit/2026-09-26/evidence/live-audit.mjs.txt
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
// Live audit of krona-staff against a real PostgreSQL 17. Read-only for the repository:
|
||||||
|
// data is seeded in a throwaway local database, tokens are random and never printed.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { randomBytes, createECDH } from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-audit';
|
||||||
|
const require = createRequire(`${REPO}/package.json`);
|
||||||
|
const pg = require('pg');
|
||||||
|
const { ulid } = require('ulid');
|
||||||
|
|
||||||
|
const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff';
|
||||||
|
const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff';
|
||||||
|
const PORT = 18080;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`;
|
||||||
|
const TENANT = ulid();
|
||||||
|
const ACTOR = ulid();
|
||||||
|
const TERMINAL_ACTOR = ulid();
|
||||||
|
const MANAGER_TOKEN = randomBytes(32).toString('base64url');
|
||||||
|
const TERMINAL_TOKEN = randomBytes(32).toString('base64url');
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function record(id, verdict, detail) {
|
||||||
|
results.push({ id, verdict, detail });
|
||||||
|
console.log(`[${id}] ${verdict} — ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function superQuery(sql, params = []) {
|
||||||
|
const client = new pg.Client({ connectionString: SUPER });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
return await client.query(sql, params);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(extraEnv) {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
HOST: '127.0.0.1',
|
||||||
|
PORT: String(PORT),
|
||||||
|
LOG_LEVEL: 'silent',
|
||||||
|
STAFF_RUNTIME_MODE: 'standalone',
|
||||||
|
DATABASE_URL: APP,
|
||||||
|
STANDALONE_TENANT_ID: TENANT,
|
||||||
|
STANDALONE_ACTOR_ID: ACTOR,
|
||||||
|
STANDALONE_TERMINAL_ACTOR_ID: TERMINAL_ACTOR,
|
||||||
|
...extraEnv,
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(env)) if (value === undefined) delete env[key];
|
||||||
|
const child = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], {
|
||||||
|
cwd: REPO,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||||||
|
child.stderrText = () => stderr;
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitReady() {
|
||||||
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE}/live`);
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error('server did not start');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(child) {
|
||||||
|
child.kill();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(method, path, { token, body, headers = {} } = {}) {
|
||||||
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
json = text;
|
||||||
|
}
|
||||||
|
return { status: response.status, json, headers: response.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function brief(value) {
|
||||||
|
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
return text.length > 220 ? `${text.slice(0, 220)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- scenario A
|
||||||
|
async function scenarioOpenMode(access) {
|
||||||
|
const server = startServer({ STANDALONE_ACCESS: access });
|
||||||
|
try {
|
||||||
|
await waitReady();
|
||||||
|
const portal = await call('GET', '/portal');
|
||||||
|
const payroll = await call('GET', '/payroll');
|
||||||
|
const finance = await call('GET', '/finance');
|
||||||
|
const writeProfession = await call('POST', '/professions', {
|
||||||
|
body: { name: `audit-open-${access}-${Date.now()}` },
|
||||||
|
});
|
||||||
|
const session = await call('POST', '/session', {
|
||||||
|
body: { access_token: 'x'.repeat(40) },
|
||||||
|
headers: { origin: `http://127.0.0.1:${PORT}` },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'F2',
|
||||||
|
portal.status === 200 || payroll.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`STANDALONE_ACCESS=${access}, без токенов и без Authorization: GET /portal ${portal.status}, GET /payroll ${payroll.status}, GET /finance ${finance.status}, POST /professions ${writeProfession.status}; POST /session ${session.status}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stop(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- scenario B
|
||||||
|
async function seed() {
|
||||||
|
const orgUnit = ulid();
|
||||||
|
const employee = ulid();
|
||||||
|
const pushEmployee = ulid();
|
||||||
|
const session = ulid();
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'audit-unit', 'Аудиторская точка')`,
|
||||||
|
[orgUnit, TENANT],
|
||||||
|
);
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
||||||
|
VALUES ($1, $3, 'A-001', 'Аудит Сотрудник', 'active'), ($2, $3, 'A-002', 'Аудит Push', 'active')`,
|
||||||
|
[employee, pushEmployee, TENANT],
|
||||||
|
);
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_work_sessions
|
||||||
|
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status)
|
||||||
|
VALUES ($1, $2, $3, $4, '2026-09-10', '2026-09-10T02:00:00Z', '2026-09-10T10:00:00Z', 480, 'pending_review')`,
|
||||||
|
[session, TENANT, employee, orgUnit],
|
||||||
|
);
|
||||||
|
return { orgUnit, employee, pushEmployee, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scenarioWithTokens() {
|
||||||
|
const ids = await seed();
|
||||||
|
const server = startServer({
|
||||||
|
STANDALONE_ACCESS: 'employee',
|
||||||
|
STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN,
|
||||||
|
STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await waitReady();
|
||||||
|
const origin = { origin: `http://127.0.0.1:${PORT}` };
|
||||||
|
|
||||||
|
// F4: brute force on /session
|
||||||
|
const statuses = {};
|
||||||
|
for (let index = 0; index < 60; index += 1) {
|
||||||
|
const response = await call('POST', '/session', {
|
||||||
|
body: { access_token: randomBytes(32).toString('base64url') },
|
||||||
|
});
|
||||||
|
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'F4',
|
||||||
|
statuses[429] ? 'не подтверждено' : 'ПОДТВЕРЖДЕНО',
|
||||||
|
`60 неверных POST /session → ${JSON.stringify(statuses)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// F3: cookie carries the raw token and survives logout
|
||||||
|
const login = await call('POST', '/session', { body: { access_token: MANAGER_TOKEN } });
|
||||||
|
const setCookie = login.headers.get('set-cookie') ?? '';
|
||||||
|
const cookieValue = decodeURIComponent(setCookie.split(';')[0].split('=').slice(1).join('='));
|
||||||
|
const cookieIsToken = cookieValue === MANAGER_TOKEN;
|
||||||
|
const cookieHeader = setCookie.split(';')[0];
|
||||||
|
const logout = await call('DELETE', '/session', { headers: { cookie: cookieHeader } });
|
||||||
|
const afterLogout = await call('GET', '/portal', { headers: { cookie: cookieHeader } });
|
||||||
|
record(
|
||||||
|
'F3',
|
||||||
|
cookieIsToken && afterLogout.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'частично',
|
||||||
|
`вход ${login.status}, выход ${logout.status}; cookie содержит сам токен менеджера: ${cookieIsToken}; после DELETE /session старая cookie → GET /portal ${afterLogout.status}; флаги: ${setCookie.split(';').slice(1).map((part) => part.trim()).join('; ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// N1: create employee
|
||||||
|
const created = await call('POST', '/employees', {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { personnel_number: 'A-100', display_name: 'Новый Сотрудник', position: 'Повар' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'N1',
|
||||||
|
created.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`POST /employees → ${created.status} ${brief(created.json)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// N2: confirm session and calculate
|
||||||
|
const confirm = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { status: 'confirmed' },
|
||||||
|
});
|
||||||
|
const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN });
|
||||||
|
const ruleList = rules.json?.pay_rules ?? [];
|
||||||
|
const hourlyRule =
|
||||||
|
ruleList.find((rule) => /hour/.test(String(rule.code))) ?? ruleList[0];
|
||||||
|
const assignment = hourlyRule
|
||||||
|
? await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: {
|
||||||
|
pay_rule_id: hourlyRule.id,
|
||||||
|
org_unit_id: ids.orgUnit,
|
||||||
|
rate_minor: 30000,
|
||||||
|
effective_from: '2026-09-01',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: { status: 'нет правил', json: rules.json };
|
||||||
|
const calculate = await call('POST', '/payroll/calculate', {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { month: '2026-09' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'N2',
|
||||||
|
confirm.status >= 500 || calculate.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`PATCH /work-sessions (confirmed) → ${confirm.status} ${brief(confirm.json)}; GET /pay-rules → ${rules.status} (${ruleList.length} правил, выбрано ${hourlyRule?.code}); PUT pay-assignment → ${assignment.status}; POST /payroll/calculate → ${calculate.status} ${brief(calculate.json)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// N4: overlapping runs, after bypassing N2 in this throwaway database only
|
||||||
|
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
|
||||||
|
try {
|
||||||
|
const confirmAgain = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { status: 'confirmed' },
|
||||||
|
});
|
||||||
|
const periods = [
|
||||||
|
{ month: '2026-09' },
|
||||||
|
{ from: '2026-09-02', to: '2026-09-30' },
|
||||||
|
{ month: '2026-09', org_unit_id: ids.orgUnit },
|
||||||
|
];
|
||||||
|
const steps = [];
|
||||||
|
for (const period of periods) {
|
||||||
|
const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: period });
|
||||||
|
const runId = run.json?.run_id ?? run.json?.id ?? run.json?.run?.id;
|
||||||
|
const approve = runId
|
||||||
|
? await call('POST', `/payroll/runs/${runId}/approve`, { token: MANAGER_TOKEN })
|
||||||
|
: { status: 'нет run_id', json: run.json };
|
||||||
|
steps.push(`${JSON.stringify(period)}: calculate ${run.status}, approve ${approve.status}`);
|
||||||
|
}
|
||||||
|
const ledger = await superQuery(
|
||||||
|
`SELECT count(*)::int AS entries, COALESCE(sum(amount_minor), 0)::bigint AS total
|
||||||
|
FROM staff_payroll_ledger WHERE tenant_id = $1 AND employee_id = $2 AND kind = 'accrual'`,
|
||||||
|
[TENANT, ids.employee],
|
||||||
|
);
|
||||||
|
const { entries, total } = ledger.rows[0];
|
||||||
|
record(
|
||||||
|
'N4',
|
||||||
|
entries > 1 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`повторное подтверждение смены → ${confirmAgain.status}; ${steps.join('; ')}; в реестре начислений за одну смену 8 ч × 300 ₽: записей ${entries}, сумма ${Number(total) / 100} ₽`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// D5: a session already paid by an approved run can still be rejected or edited
|
||||||
|
const rejectPaid = await call('PATCH', `/work-sessions/${ids.session}`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { status: 'rejected', correction_note: 'audit: after approval' },
|
||||||
|
});
|
||||||
|
const ledgerAfter = await superQuery(
|
||||||
|
`SELECT count(*)::int AS entries FROM staff_payroll_ledger
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2`,
|
||||||
|
[TENANT, ids.employee],
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'D5',
|
||||||
|
rejectPaid.status < 300 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`отклонение смены, уже начисленной в утверждённой ведомости → ${rejectPaid.status}; записей в реестре после этого: ${ledgerAfter.rows[0].entries} (сторно не создано)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// N13: two first calculations of the same new period at once
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_work_sessions
|
||||||
|
(id, tenant_id, employee_id, org_unit_id, business_date, worked_minutes, status)
|
||||||
|
VALUES ($1, $2, $3, $4, '2026-10-05', 480, 'confirmed')`,
|
||||||
|
[ulid(), TENANT, ids.employee, ids.orgUnit],
|
||||||
|
);
|
||||||
|
const race = await Promise.all([
|
||||||
|
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
|
||||||
|
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
|
||||||
|
]);
|
||||||
|
record(
|
||||||
|
'N13',
|
||||||
|
race.some((response) => response.status >= 500) ? 'ПОДТВЕРЖДЕНО' : 'не воспроизведено',
|
||||||
|
`два одновременных первых расчёта октября → ${race.map((response) => `${response.status} ${brief(response.json).slice(0, 90)}`).join(' | ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validation holes: impossible month and effective date
|
||||||
|
const badMonth = await call('POST', '/payroll/calculate', {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { month: '2026-13' },
|
||||||
|
});
|
||||||
|
const hugeRate = await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: {
|
||||||
|
pay_rule_id: hourlyRule?.id,
|
||||||
|
org_unit_id: ids.orgUnit,
|
||||||
|
rate_minor: 1e20,
|
||||||
|
effective_from: '2026-11-01',
|
||||||
|
unexpected_field: 'accepted?',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'D9',
|
||||||
|
'проверка',
|
||||||
|
`month=2026-13 → ${badMonth.status}; rate_minor=1e20 и лишнее поле → ${hugeRate.status} ${brief(hugeRate.json).slice(0, 120)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// N3: SSRF through a push subscription
|
||||||
|
const inbound = { connections: 0, tlsHello: false };
|
||||||
|
const listener = net.createServer((socket) => {
|
||||||
|
inbound.connections += 1;
|
||||||
|
socket.once('data', (chunk) => {
|
||||||
|
if (chunk[0] === 0x16) inbound.tlsHello = true;
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => listener.listen(18443, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const issued = await call('POST', `/employees/${ids.pushEmployee}/access-token`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
});
|
||||||
|
const employeeToken = issued.json?.access_token;
|
||||||
|
const ecdh = createECDH('prime256v1');
|
||||||
|
ecdh.generateKeys();
|
||||||
|
const subscribe = await call('POST', '/push/subscriptions', {
|
||||||
|
token: employeeToken,
|
||||||
|
body: {
|
||||||
|
endpoint: 'https://127.0.0.1:18443/internal-admin',
|
||||||
|
keys: {
|
||||||
|
p256dh: ecdh.getPublicKey().toString('base64url'),
|
||||||
|
auth: randomBytes(16).toString('base64url'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const clock = await call('POST', '/attendance/events', {
|
||||||
|
token: TERMINAL_TOKEN,
|
||||||
|
body: {
|
||||||
|
employee_id: ids.pushEmployee,
|
||||||
|
event_type: 'check_in',
|
||||||
|
occurred_at: new Date().toISOString(),
|
||||||
|
source_event_id: `audit-${Date.now()}`,
|
||||||
|
org_unit_id: ids.orgUnit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
record(
|
||||||
|
'N3',
|
||||||
|
inbound.connections > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`выдача ключа сотруднику → ${issued.status}; подписка на https://127.0.0.1:18443 → ${subscribe.status}; отметка терминала → ${clock.status}; входящих соединений на внутренний адрес: ${inbound.connections}, TLS ClientHello: ${inbound.tlsHello}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// N9: another employee re-binds the same endpoint
|
||||||
|
const other = await call('POST', `/employees/${ids.employee}/access-token`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
});
|
||||||
|
const rebind = await call('POST', '/push/subscriptions', {
|
||||||
|
token: other.json?.access_token,
|
||||||
|
body: {
|
||||||
|
endpoint: 'https://127.0.0.1:18443/internal-admin',
|
||||||
|
keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: randomBytes(16).toString('base64url') },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const owner = await superQuery(
|
||||||
|
`SELECT employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
|
||||||
|
[TENANT, 'https://127.0.0.1:18443/internal-admin'],
|
||||||
|
).catch((error) => ({ rows: [{ employee_id: `ошибка: ${error.message}` }] }));
|
||||||
|
record(
|
||||||
|
'N9',
|
||||||
|
rebind.status < 300 && owner.rows[0]?.employee_id === ids.employee ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
|
||||||
|
`сотрудник Б подписывает endpoint сотрудника А → ${rebind.status}; подписка теперь принадлежит сотруднику Б: ${owner.rows[0]?.employee_id === ids.employee}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A4: internal error details leak in 500 responses
|
||||||
|
const badFk = await call('POST', '/employees', {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { personnel_number: 'A-200', display_name: 'X', profession_id: ulid() },
|
||||||
|
});
|
||||||
|
record('A4', 'проверка', `500 с деталями: POST /employees с несуществующей профессией → ${badFk.status} ${brief(badFk.json)}`);
|
||||||
|
|
||||||
|
// N10: reference data without permission checks
|
||||||
|
const professionsByTerminal = await call('GET', '/professions', { token: TERMINAL_TOKEN });
|
||||||
|
const orgUnitsByTerminal = await call('GET', '/org-units', { token: TERMINAL_TOKEN });
|
||||||
|
record(
|
||||||
|
'N10',
|
||||||
|
'проверка',
|
||||||
|
`терминал: GET /professions → ${professionsByTerminal.status} (${Array.isArray(professionsByTerminal.json) ? professionsByTerminal.json.length : brief(professionsByTerminal.json)}); GET /org-units → ${orgUnitsByTerminal.status} ${brief(orgUnitsByTerminal.json)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Security headers on the portal
|
||||||
|
const page = await fetch(`http://127.0.0.1:${PORT}/staff`);
|
||||||
|
record(
|
||||||
|
'HDR',
|
||||||
|
'проверка',
|
||||||
|
`GET /staff: content-security-policy="${page.headers.get('content-security-policy')}", strict-transport-security="${page.headers.get('strict-transport-security')}"`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
listener.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const serverErrors = server.stderrText();
|
||||||
|
await stop(server);
|
||||||
|
if (serverErrors.trim()) console.log(`server stderr: ${brief(serverErrors)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await scenarioOpenMode('manager');
|
||||||
|
await scenarioOpenMode('employee');
|
||||||
|
await scenarioWithTokens();
|
||||||
|
console.log('\nJSON', JSON.stringify(results));
|
||||||
213
docs/audit/2026-09-26/evidence/live-sync.mjs.txt
Normal file
213
docs/audit/2026-09-26/evidence/live-sync.mjs.txt
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Live check of the offline sync branch (5ee7517) on a real PostgreSQL 17.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-sync';
|
||||||
|
const require = createRequire(`${REPO}/package.json`);
|
||||||
|
const pg = require('pg');
|
||||||
|
const { ulid } = require('ulid');
|
||||||
|
|
||||||
|
const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff_sync';
|
||||||
|
const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff_sync';
|
||||||
|
const PORT = 18081;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`;
|
||||||
|
const TENANT = ulid();
|
||||||
|
const MANAGER_TOKEN = randomBytes(32).toString('base64url');
|
||||||
|
const TERMINAL_TOKEN = randomBytes(32).toString('base64url');
|
||||||
|
|
||||||
|
async function superQuery(sql, params = []) {
|
||||||
|
const client = new pg.Client({ connectionString: SUPER });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
return await client.query(sql, params);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(method, path, { token, body } = {}) {
|
||||||
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return { status: response.status, json: JSON.parse(text) };
|
||||||
|
} catch {
|
||||||
|
return { status: response.status, json: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brief = (value) => {
|
||||||
|
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
return text.length > 260 ? `${text.slice(0, 260)}…` : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// S9: deleting an org unit referenced by a sync device
|
||||||
|
const s9Unit = ulid();
|
||||||
|
const s9Device = ulid();
|
||||||
|
await superQuery(`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 's9', 'S9')`, [
|
||||||
|
s9Unit,
|
||||||
|
TENANT,
|
||||||
|
]);
|
||||||
|
const deviceColumns = (
|
||||||
|
await superQuery(
|
||||||
|
`SELECT column_name, is_nullable, column_default FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff_sync_devices' ORDER BY ordinal_position`,
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
const values = {
|
||||||
|
id: s9Device,
|
||||||
|
tenant_id: TENANT,
|
||||||
|
device_code: 's9-device',
|
||||||
|
name: 'S9',
|
||||||
|
token_hash: 'a'.repeat(64),
|
||||||
|
token_hint: 'hint',
|
||||||
|
org_unit_id: s9Unit,
|
||||||
|
created_by: ulid(),
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
const insertable = deviceColumns.filter(
|
||||||
|
(column) => values[column.column_name] !== undefined,
|
||||||
|
);
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_sync_devices (${insertable.map((c) => c.column_name).join(', ')})
|
||||||
|
VALUES (${insertable.map((_, index) => `$${index + 1}`).join(', ')})`,
|
||||||
|
insertable.map((column) => values[column.column_name]),
|
||||||
|
).catch((error) => console.log(`S9 setup: ${error.code} ${error.message}`));
|
||||||
|
try {
|
||||||
|
await superQuery(`DELETE FROM staff_org_units WHERE id = $1`, [s9Unit]);
|
||||||
|
console.log('[S9] не подтверждено — точку с привязанным устройством удалить удалось');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → ${error.code} ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed an employee and a unit for S1
|
||||||
|
const orgUnit = ulid();
|
||||||
|
const employee = ulid();
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'sync-unit', 'Точка')`,
|
||||||
|
[orgUnit, TENANT],
|
||||||
|
);
|
||||||
|
await superQuery(
|
||||||
|
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
||||||
|
VALUES ($1, $2, 'S-001', 'Сотрудник Синхронизации', 'active')`,
|
||||||
|
[employee, TENANT],
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], {
|
||||||
|
cwd: REPO,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
HOST: '127.0.0.1',
|
||||||
|
PORT: String(PORT),
|
||||||
|
LOG_LEVEL: 'silent',
|
||||||
|
STAFF_RUNTIME_MODE: 'standalone',
|
||||||
|
STANDALONE_ACCESS: 'employee',
|
||||||
|
STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN,
|
||||||
|
STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN,
|
||||||
|
DATABASE_URL: APP,
|
||||||
|
STANDALONE_TENANT_ID: TENANT,
|
||||||
|
STANDALONE_ACTOR_ID: ulid(),
|
||||||
|
},
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if ((await fetch(`${BASE}/live`)).ok) break;
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await call('POST', '/sync/devices', {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { device_code: 'kiosk-1', name: 'Киоск', org_unit_id: orgUnit },
|
||||||
|
});
|
||||||
|
const deviceToken = registered.json?.token;
|
||||||
|
console.log(`device registration → ${registered.status}; scopes ${JSON.stringify(registered.json?.device?.allowed_scopes)}`);
|
||||||
|
|
||||||
|
const forged = await call('POST', '/sync/push', {
|
||||||
|
token: deviceToken,
|
||||||
|
body: {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
command_type: 'upsert_work_session',
|
||||||
|
schema_version: 1,
|
||||||
|
idempotency_key: `forge-${Date.now()}`,
|
||||||
|
correlation_id: ulid(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
employee_id: employee,
|
||||||
|
org_unit_id: orgUnit,
|
||||||
|
business_date: '2026-09-15',
|
||||||
|
worked_minutes: 2880,
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command_type: 'record_attendance',
|
||||||
|
schema_version: 1,
|
||||||
|
idempotency_key: `old-${Date.now()}`,
|
||||||
|
correlation_id: ulid(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
employee_id: employee,
|
||||||
|
event_type: 'check_in',
|
||||||
|
occurred_at: '2020-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`device push → ${forged.status} ${brief(forged.json)}`);
|
||||||
|
|
||||||
|
const session = await superQuery(
|
||||||
|
`SELECT status, worked_minutes, confirmed_by, correction_note
|
||||||
|
FROM staff_work_sessions WHERE tenant_id = $1 AND employee_id = $2 AND business_date = '2026-09-15'`,
|
||||||
|
[TENANT, employee],
|
||||||
|
);
|
||||||
|
const oldEvent = await superQuery(
|
||||||
|
`SELECT occurred_at FROM staff_attendance_events
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2 AND occurred_at < '2021-01-01'`,
|
||||||
|
[TENANT, employee],
|
||||||
|
);
|
||||||
|
const audit = await superQuery(
|
||||||
|
`SELECT count(*)::int AS entries FROM staff_audit_log WHERE tenant_id = $1`,
|
||||||
|
[TENANT],
|
||||||
|
);
|
||||||
|
const row = session.rows[0];
|
||||||
|
console.log(
|
||||||
|
`[S1] ${row?.status === 'confirmed' ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — смена от устройства: статус ${row?.status}, минут ${row?.worked_minutes}, подтвердил ${row?.confirmed_by ?? 'никто'}, записей в журнале аудита арендатора: ${audit.rows[0].entries}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[S2] ${oldEvent.rowCount > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — отметка с временем 2020-01-01 через синхронизацию сохранена: ${oldEvent.rowCount > 0}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The forged session flows into payroll (outbox default only in this throwaway database, see N2)
|
||||||
|
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
|
||||||
|
try {
|
||||||
|
const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN });
|
||||||
|
const hourly = (rules.json?.pay_rules ?? []).find((rule) => /hour/.test(rule.code));
|
||||||
|
await call('PUT', `/employees/${employee}/pay-assignment`, {
|
||||||
|
token: MANAGER_TOKEN,
|
||||||
|
body: { pay_rule_id: hourly?.id, org_unit_id: orgUnit, rate_minor: 30000, effective_from: '2026-09-01' },
|
||||||
|
});
|
||||||
|
const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-09' } });
|
||||||
|
const total = run.json?.total_accrued_minor ?? run.json?.totals?.accrued_minor;
|
||||||
|
console.log(
|
||||||
|
`[S1→payroll] расчёт сентября → ${run.status}; начислено по поддельной смене: ${total !== undefined ? Number(total) / 100 : brief(run.json)} ₽ (48 ч × 300 ₽)`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||
5
docs/audit/2026-09-26/evidence/live-sync.txt
Normal file
5
docs/audit/2026-09-26/evidence/live-sync.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint
|
||||||
|
device push → 200 {"results":[{"idempotency_key":"forge-1790405052967","command_type":"upsert_work_session","status":"applied","result":{"id":"01M3E7A2JB9PQ3YZQ4J5TE2V49","revision":1,"status":"applied"}},{"idempotency_key":"old-1790405052969","command_type":"record_attendance"…
|
||||||
|
[S1] ПОДТВЕРЖДЕНО — смена от устройства: статус confirmed, минут 2880, подтвердил никто, записей в журнале аудита арендатора: 0
|
||||||
|
[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true
|
||||||
|
[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 14400 ₽ (48 ч × 300 ₽)
|
||||||
|
|
@ -2,9 +2,10 @@ Unicode True
|
||||||
SetCompressor /SOLID lzma
|
SetCompressor /SOLID lzma
|
||||||
|
|
||||||
!include "MUI2.nsh"
|
!include "MUI2.nsh"
|
||||||
|
!include "x64.nsh"
|
||||||
|
|
||||||
!ifndef APP_VERSION
|
!ifndef APP_VERSION
|
||||||
!define APP_VERSION "0.2.0"
|
!define APP_VERSION "0.3.2"
|
||||||
!endif
|
!endif
|
||||||
!ifndef PUBLISH_DIR
|
!ifndef PUBLISH_DIR
|
||||||
!error "PUBLISH_DIR is required"
|
!error "PUBLISH_DIR is required"
|
||||||
|
|
@ -18,14 +19,17 @@ SetCompressor /SOLID lzma
|
||||||
|
|
||||||
!define APP_NAME "Krona Staff Terminal"
|
!define APP_NAME "Krona Staff Terminal"
|
||||||
!define APP_EXE "KronaStaffTerminal.exe"
|
!define APP_EXE "KronaStaffTerminal.exe"
|
||||||
|
!define SERVICE_EXE "KronaStaffService.exe"
|
||||||
|
!define SERVICE_NAME "KronaStaffService"
|
||||||
|
!define OPERATOR_GROUP "KronaStaffTerminalOperators"
|
||||||
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
|
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
|
||||||
!define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
|
!define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
|
||||||
|
|
||||||
Name "${APP_NAME}"
|
Name "${APP_NAME}"
|
||||||
OutFile "${OUTPUT_DIR}\KronaStaffTerminal-Setup-${APP_VERSION}.exe"
|
OutFile "${OUTPUT_DIR}\KronaStaffTerminal-Setup-${APP_VERSION}.exe"
|
||||||
InstallDir "$LOCALAPPDATA\Programs\Krona Staff Terminal"
|
InstallDir "$PROGRAMFILES64\Krona Staff Terminal"
|
||||||
InstallDirRegKey HKCU "${APP_REGISTRY_KEY}" "InstallLocation"
|
InstallDirRegKey HKLM "${APP_REGISTRY_KEY}" "InstallLocation"
|
||||||
RequestExecutionLevel user
|
RequestExecutionLevel admin
|
||||||
Icon "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
|
Icon "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
|
||||||
BrandingText "Krona"
|
BrandingText "Krona"
|
||||||
ShowInstDetails show
|
ShowInstDetails show
|
||||||
|
|
@ -58,33 +62,114 @@ VIAddVersionKey /LANG=1049 "ProductVersion" "${APP_VERSION}"
|
||||||
|
|
||||||
!insertmacro MUI_LANGUAGE "Russian"
|
!insertmacro MUI_LANGUAGE "Russian"
|
||||||
|
|
||||||
|
Function .onInit
|
||||||
|
${If} ${RunningX64}
|
||||||
|
SetRegView 64
|
||||||
|
${Else}
|
||||||
|
MessageBox MB_ICONSTOP "Для работы программы требуется 64-разрядная версия Windows."
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function un.onInit
|
||||||
|
${If} ${RunningX64}
|
||||||
|
SetRegView 64
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
Section "Krona Staff Terminal" SecMain
|
Section "Krona Staff Terminal" SecMain
|
||||||
SectionIn RO
|
SectionIn RO
|
||||||
SetShellVarContext current
|
SetShellVarContext all
|
||||||
|
|
||||||
|
DetailPrint "Остановка службы ${SERVICE_NAME} (если запущена)..."
|
||||||
|
ExecWait 'sc.exe stop ${SERVICE_NAME}'
|
||||||
|
Sleep 500
|
||||||
|
|
||||||
SetOutPath "$INSTDIR"
|
SetOutPath "$INSTDIR"
|
||||||
SetOverwrite try
|
SetOverwrite try
|
||||||
File /r "${PUBLISH_DIR}\*"
|
File /r "${PUBLISH_DIR}\*"
|
||||||
|
|
||||||
|
DetailPrint "Настройка защищённой локальной группы терминала..."
|
||||||
|
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}"'
|
||||||
|
Pop $0
|
||||||
|
StrCmp $0 "0" group_ready
|
||||||
|
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" /add'
|
||||||
|
Pop $0
|
||||||
|
StrCmp $0 "0" group_ready
|
||||||
|
MessageBox MB_ICONSTOP "Не удалось создать защищённую группу ${OPERATOR_GROUP}. Установка остановлена."
|
||||||
|
Abort
|
||||||
|
group_ready:
|
||||||
|
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /delete'
|
||||||
|
Pop $0
|
||||||
|
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /add'
|
||||||
|
Pop $0
|
||||||
|
StrCmp $0 "0" membership_ready
|
||||||
|
MessageBox MB_ICONSTOP "Не удалось выдать текущему пользователю доступ к терминалу. Установка остановлена."
|
||||||
|
Abort
|
||||||
|
membership_ready:
|
||||||
|
CreateDirectory "$COMMONAPPDATA\Krona\StaffTerminal"
|
||||||
|
nsExec::ExecToLog 'icacls.exe "$COMMONAPPDATA\Krona\StaffTerminal" /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" "$USERDOMAIN\$USERNAME:(OI)(CI)M"'
|
||||||
|
Pop $0
|
||||||
|
StrCmp $0 "0" acl_ready
|
||||||
|
MessageBox MB_ICONSTOP "Не удалось защитить хранилище терминала. Установка остановлена."
|
||||||
|
Abort
|
||||||
|
acl_ready:
|
||||||
|
|
||||||
|
DetailPrint "Настройка службы ${SERVICE_NAME}..."
|
||||||
|
ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"'
|
||||||
|
ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto'
|
||||||
|
ExecWait 'sc.exe description ${SERVICE_NAME} "Служба фоновой синхронизации Krona Staff Terminal"'
|
||||||
|
DetailPrint "Запуск службы ${SERVICE_NAME}..."
|
||||||
|
ExecWait 'sc.exe start ${SERVICE_NAME}'
|
||||||
|
|
||||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||||
CreateDirectory "$SMPROGRAMS\Krona"
|
CreateDirectory "$SMPROGRAMS\Krona"
|
||||||
CreateShortcut "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
CreateShortcut "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
||||||
|
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}"
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}"
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}"
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}"
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "Publisher" "Krona"
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "Publisher" "Krona"
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
|
||||||
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
|
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
|
||||||
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoModify" 1
|
WriteRegDWORD HKLM "${APP_REGISTRY_KEY}" "NoModify" 1
|
||||||
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoRepair" 1
|
WriteRegDWORD HKLM "${APP_REGISTRY_KEY}" "NoRepair" 1
|
||||||
|
|
||||||
|
# Create install log
|
||||||
|
FileOpen $9 "$INSTDIR\install.log" w
|
||||||
|
FileWrite $9 "Krona Staff Terminal v${APP_VERSION} installed successfully at: $INSTDIR$\r$\n"
|
||||||
|
FileClose $9
|
||||||
|
|
||||||
|
# Driver detection check
|
||||||
|
IfFileExists "$SYSDIR\drivers\slk20r.sys" driver_present 0
|
||||||
|
IfFileExists "$SYSDIR\drivers\zkfp.sys" driver_present 0
|
||||||
|
IfFileExists "$SYSDIR\drivers\zkfpos.sys" driver_present 0
|
||||||
|
IfFileExists "$SYSDIR\drivers\ZKUSB.sys" driver_present 0
|
||||||
|
ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\slk20r" "ImagePath"
|
||||||
|
StrCmp $1 "" 0 driver_present
|
||||||
|
ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\zkfp" "ImagePath"
|
||||||
|
StrCmp $1 "" 0 driver_present
|
||||||
|
ReadRegStr $1 HKLM "SYSTEM\CurrentControlSet\Services\ZKUSB" "ImagePath"
|
||||||
|
StrCmp $1 "" 0 driver_present
|
||||||
|
|
||||||
|
DetailPrint "Драйвер сканера ZKTeco SLK20R не обнаружен."
|
||||||
|
IfSilent driver_done
|
||||||
|
MessageBox MB_YESNO|MB_ICONINFORMATION "Драйвер биометрического сканера ZKTeco SLK20R не обнаружен в системе.$\r$\n$\r$\nДля работы со сканером отпечатков пальцев требуется официальный драйвер ZKTeco.$\r$\n$\r$\nОткрыть страницу загрузки официального драйвера сейчас?" IDNO driver_done
|
||||||
|
ExecShell "open" "${DRIVER_URL}"
|
||||||
|
Goto driver_done
|
||||||
|
|
||||||
|
driver_present:
|
||||||
|
DetailPrint "Драйвер биометрического сканера обнаружен."
|
||||||
|
|
||||||
|
driver_done:
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
Section "Значок на рабочем столе" SecDesktop
|
Section "Значок на рабочем столе" SecDesktop
|
||||||
SetShellVarContext current
|
SetShellVarContext all
|
||||||
CreateShortcut "$DESKTOP\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
CreateShortcut "$DESKTOP\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода."
|
LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода со службой синхронизации."
|
||||||
LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок программы на рабочем столе."
|
LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок программы на рабочем столе."
|
||||||
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
|
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
|
||||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecMain} $(DESC_SecMain)
|
!insertmacro MUI_DESCRIPTION_TEXT ${SecMain} $(DESC_SecMain)
|
||||||
|
|
@ -92,10 +177,23 @@ LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок прог
|
||||||
!insertmacro MUI_FUNCTION_DESCRIPTION_END
|
!insertmacro MUI_FUNCTION_DESCRIPTION_END
|
||||||
|
|
||||||
Section "Uninstall"
|
Section "Uninstall"
|
||||||
SetShellVarContext current
|
SetShellVarContext all
|
||||||
|
|
||||||
|
DetailPrint "Остановка службы ${SERVICE_NAME}..."
|
||||||
|
ExecWait 'sc.exe stop ${SERVICE_NAME}'
|
||||||
|
Sleep 1000
|
||||||
|
DetailPrint "Удаление службы ${SERVICE_NAME}..."
|
||||||
|
ExecWait 'sc.exe delete ${SERVICE_NAME}'
|
||||||
|
|
||||||
|
IfSilent keep_data
|
||||||
|
MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES keep_data
|
||||||
|
RMDir /r "$COMMONAPPDATA\Krona\StaffTerminal"
|
||||||
|
RMDir "$COMMONAPPDATA\Krona"
|
||||||
|
keep_data:
|
||||||
|
|
||||||
Delete "$DESKTOP\Krona Staff Terminal.lnk"
|
Delete "$DESKTOP\Krona Staff Terminal.lnk"
|
||||||
Delete "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk"
|
Delete "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk"
|
||||||
RMDir "$SMPROGRAMS\Krona"
|
RMDir "$SMPROGRAMS\Krona"
|
||||||
DeleteRegKey HKCU "${APP_REGISTRY_KEY}"
|
DeleteRegKey HKLM "${APP_REGISTRY_KEY}"
|
||||||
RMDir /r "$INSTDIR"
|
RMDir /r "$INSTDIR"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
|
||||||
4
migrations/0004_revoke_broad_default_privileges.down.sql
Normal file
4
migrations/0004_revoke_broad_default_privileges.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO krona_staff_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO krona_staff_app;
|
||||||
4
migrations/0004_revoke_broad_default_privileges.up.sql
Normal file
4
migrations/0004_revoke_broad_default_privileges.up.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM krona_staff_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
REVOKE USAGE, SELECT ON SEQUENCES FROM krona_staff_app;
|
||||||
3
migrations/0005_attendance_portal.down.sql
Normal file
3
migrations/0005_attendance_portal.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
DROP TABLE IF EXISTS staff_timesheet_entries;
|
||||||
|
DROP TABLE IF EXISTS staff_attendance_events;
|
||||||
|
DROP TABLE IF EXISTS staff_employees;
|
||||||
82
migrations/0005_attendance_portal.up.sql
Normal file
82
migrations/0005_attendance_portal.up.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
CREATE TABLE staff_employees (
|
||||||
|
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}$'),
|
||||||
|
core_user_id VARCHAR(26) CHECK (core_user_id IS NULL OR core_user_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
personnel_number TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
position TEXT NOT NULL DEFAULT '',
|
||||||
|
gender TEXT NOT NULL DEFAULT '' CHECK (gender IN ('', 'male', 'female')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked', 'dismissed')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, personnel_number),
|
||||||
|
UNIQUE (tenant_id, core_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE staff_attendance_events (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
event_type TEXT NOT NULL CHECK (event_type IN ('check_in', 'check_out')),
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('terminal', 'mobile_app', 'manual_entry', 'web_portal')),
|
||||||
|
source_event_id TEXT NOT NULL,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
device_id VARCHAR(26) CHECK (device_id IS NULL OR device_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'rejected', 'review_required')),
|
||||||
|
actor_id VARCHAR(26) NOT NULL CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, source, source_event_id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees(tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_attendance_employee_time_idx
|
||||||
|
ON staff_attendance_events (tenant_id, employee_id, occurred_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE staff_timesheet_entries (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
work_date DATE NOT NULL,
|
||||||
|
minutes_worked INTEGER NOT NULL DEFAULT 0 CHECK (minutes_worked BETWEEN 0 AND 1440),
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')),
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
updated_by VARCHAR(26) NOT NULL CHECK (updated_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, employee_id, work_date),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees(tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_timesheet_period_idx
|
||||||
|
ON staff_timesheet_entries (tenant_id, work_date, employee_id);
|
||||||
|
|
||||||
|
ALTER TABLE staff_employees ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_employees FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_employees_tenant_isolation ON staff_employees
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
ALTER TABLE staff_attendance_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_attendance_events FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_attendance_events_tenant_isolation ON staff_attendance_events
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (
|
||||||
|
tenant_id = current_setting('app.tenant_id', true)
|
||||||
|
AND actor_id = current_setting('app.user_id', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_timesheet_entries ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_timesheet_entries FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_timesheet_entries_tenant_isolation ON staff_timesheet_entries
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (
|
||||||
|
tenant_id = current_setting('app.tenant_id', true)
|
||||||
|
AND updated_by = current_setting('app.user_id', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON staff_employees TO krona_staff_app;
|
||||||
|
GRANT SELECT, INSERT ON staff_attendance_events TO krona_staff_app;
|
||||||
|
REVOKE UPDATE, DELETE, TRUNCATE ON staff_attendance_events FROM krona_staff_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON staff_timesheet_entries TO krona_staff_app;
|
||||||
25
migrations/0006_payroll_foundation.down.sql
Normal file
25
migrations/0006_payroll_foundation.down.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
DROP TABLE IF EXISTS staff_payroll_ledger;
|
||||||
|
DROP TABLE IF EXISTS staff_payouts;
|
||||||
|
DROP TABLE IF EXISTS staff_payroll_lines;
|
||||||
|
DROP TABLE IF EXISTS staff_payroll_runs;
|
||||||
|
DROP TABLE IF EXISTS staff_work_sessions;
|
||||||
|
DROP TABLE IF EXISTS staff_pay_assignments;
|
||||||
|
DROP TABLE IF EXISTS staff_pay_rules;
|
||||||
|
DROP TABLE IF EXISTS staff_work_items;
|
||||||
|
DROP TABLE IF EXISTS staff_daily_metrics;
|
||||||
|
DROP TABLE IF EXISTS staff_metric_definitions;
|
||||||
|
|
||||||
|
ALTER TABLE staff_attendance_events
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_attendance_events_org_unit_fk,
|
||||||
|
DROP COLUMN IF EXISTS payload,
|
||||||
|
DROP COLUMN IF EXISTS org_unit_id;
|
||||||
|
|
||||||
|
ALTER TABLE staff_employees
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_employees_primary_org_unit_fk,
|
||||||
|
DROP COLUMN IF EXISTS terminated_on,
|
||||||
|
DROP COLUMN IF EXISTS hired_on,
|
||||||
|
DROP COLUMN IF EXISTS external_ref,
|
||||||
|
DROP COLUMN IF EXISTS primary_org_unit_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS staff_org_unit_terms;
|
||||||
|
DROP TABLE IF EXISTS staff_org_units;
|
||||||
302
migrations/0006_payroll_foundation.up.sql
Normal file
302
migrations/0006_payroll_foundation.up.sql
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
-- REQ-001 payroll foundation integrated after the attendance portal.
|
||||||
|
-- Money is BIGINT in minor units; rates and weights use basis points (10000 = 100%).
|
||||||
|
|
||||||
|
CREATE TABLE staff_org_units (
|
||||||
|
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}$'),
|
||||||
|
code TEXT NOT NULL CHECK (code ~ '^[a-z0-9_-]{1,40}$'),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'Asia/Novosibirsk',
|
||||||
|
external_ref TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX staff_org_units_code_idx ON staff_org_units (tenant_id, code)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE staff_org_unit_terms (
|
||||||
|
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,
|
||||||
|
effective_from DATE NOT NULL,
|
||||||
|
effective_to DATE,
|
||||||
|
pool_rule JSONB,
|
||||||
|
shift_norm_minutes INTEGER NOT NULL DEFAULT 720 CHECK (shift_norm_minutes BETWEEN 60 AND 2880),
|
||||||
|
rounding_unit_minor BIGINT NOT NULL DEFAULT 100 CHECK (rounding_unit_minor > 0),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
CHECK (effective_to IS NULL OR effective_to >= effective_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_org_unit_terms_lookup_idx
|
||||||
|
ON staff_org_unit_terms (tenant_id, org_unit_id, effective_from DESC);
|
||||||
|
|
||||||
|
ALTER TABLE staff_employees
|
||||||
|
ADD COLUMN primary_org_unit_id VARCHAR(26),
|
||||||
|
ADD COLUMN external_ref TEXT,
|
||||||
|
ADD COLUMN hired_on DATE,
|
||||||
|
ADD COLUMN terminated_on DATE,
|
||||||
|
ADD CONSTRAINT staff_employees_primary_org_unit_fk
|
||||||
|
FOREIGN KEY (tenant_id, primary_org_unit_id) REFERENCES staff_org_units (tenant_id, id);
|
||||||
|
|
||||||
|
ALTER TABLE staff_attendance_events
|
||||||
|
ADD COLUMN org_unit_id VARCHAR(26),
|
||||||
|
ADD COLUMN payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD CONSTRAINT staff_attendance_events_org_unit_fk
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE staff_metric_definitions (
|
||||||
|
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}$'),
|
||||||
|
key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{0,60}$'),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
value_kind TEXT NOT NULL DEFAULT 'money' CHECK (value_kind IN ('money', 'percent', 'number')),
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE staff_daily_metrics (
|
||||||
|
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,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
metric_key TEXT NOT NULL CHECK (metric_key ~ '^[a-z][a-z0-9_]{0,60}$'),
|
||||||
|
value BIGINT NOT NULL,
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
|
||||||
|
breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
|
||||||
|
recorded_by VARCHAR(26) NOT NULL CHECK (recorded_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),
|
||||||
|
UNIQUE (tenant_id, org_unit_id, business_date, metric_key),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, metric_key) REFERENCES staff_metric_definitions (tenant_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE staff_work_items (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
org_unit_id VARCHAR(26) NOT NULL,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
item_kind TEXT NOT NULL CHECK (item_kind ~ '^[a-z][a-z0-9_]{0,40}$'),
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
|
||||||
|
external_key TEXT,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_work_items_day_idx ON staff_work_items (tenant_id, employee_id, business_date);
|
||||||
|
CREATE UNIQUE INDEX staff_work_items_external_idx ON staff_work_items (tenant_id, external_key)
|
||||||
|
WHERE external_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE staff_pay_rules (
|
||||||
|
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}$'),
|
||||||
|
code TEXT NOT NULL CHECK (code ~ '^[a-z][a-z0-9_]{0,40}$'),
|
||||||
|
version INTEGER NOT NULL CHECK (version > 0),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
preset_code TEXT,
|
||||||
|
rule JSONB NOT NULL,
|
||||||
|
param_declarations JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
pool_weight_bp INTEGER NOT NULL DEFAULT 0 CHECK (pool_weight_bp BETWEEN 0 AND 20000),
|
||||||
|
effective_from DATE NOT NULL,
|
||||||
|
effective_to DATE,
|
||||||
|
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, code, version),
|
||||||
|
CHECK (effective_to IS NULL OR effective_to >= effective_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_pay_rules_lookup_idx ON staff_pay_rules (tenant_id, code, effective_from DESC);
|
||||||
|
|
||||||
|
CREATE TABLE staff_pay_assignments (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
org_unit_id VARCHAR(26),
|
||||||
|
pay_rule_id VARCHAR(26) NOT NULL,
|
||||||
|
effective_from DATE NOT NULL,
|
||||||
|
effective_to DATE,
|
||||||
|
params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
pool_weight_bp INTEGER CHECK (pool_weight_bp IS NULL OR pool_weight_bp BETWEEN 0 AND 20000),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id),
|
||||||
|
CHECK (effective_to IS NULL OR effective_to >= effective_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_pay_assignments_lookup_idx
|
||||||
|
ON staff_pay_assignments (tenant_id, employee_id, effective_from DESC);
|
||||||
|
|
||||||
|
CREATE TABLE staff_work_sessions (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
org_unit_id VARCHAR(26) NOT NULL,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes BETWEEN 0 AND 2880),
|
||||||
|
source TEXT NOT NULL DEFAULT 'terminal' CHECK (source IN ('terminal', 'manual')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'pending_review', 'confirmed', 'rejected')),
|
||||||
|
anomaly TEXT,
|
||||||
|
correction_note TEXT,
|
||||||
|
confirmed_by VARCHAR(26) CHECK (confirmed_by IS NULL OR confirmed_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, employee_id, org_unit_id, business_date),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
CHECK (ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_work_sessions_review_idx ON staff_work_sessions (tenant_id, business_date DESC, status);
|
||||||
|
|
||||||
|
CREATE TABLE staff_payroll_runs (
|
||||||
|
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),
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'calculated', 'approved', 'paid')),
|
||||||
|
total_accrued_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
|
||||||
|
calculated_at TIMESTAMPTZ,
|
||||||
|
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
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(),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
CHECK (period_end >= period_start)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX staff_payroll_runs_period_unit_idx
|
||||||
|
ON staff_payroll_runs (tenant_id, org_unit_id, period_start) WHERE org_unit_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX staff_payroll_runs_period_all_idx
|
||||||
|
ON staff_payroll_runs (tenant_id, period_start) WHERE org_unit_id IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE staff_payroll_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}$'),
|
||||||
|
run_id VARCHAR(26) NOT NULL,
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
org_unit_id VARCHAR(26) NOT NULL,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
pay_rule_id VARCHAR(26) NOT NULL,
|
||||||
|
rule_code TEXT NOT NULL,
|
||||||
|
worked_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pool_amount_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
pool_divisor_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pool_share_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
raw_amount_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
amount_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
|
||||||
|
explain JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, run_id, employee_id, org_unit_id, business_date),
|
||||||
|
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_payroll_lines_employee_idx
|
||||||
|
ON staff_payroll_lines (tenant_id, employee_id, business_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE staff_payouts (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
run_id VARCHAR(26),
|
||||||
|
paid_on DATE NOT NULL,
|
||||||
|
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
|
||||||
|
method TEXT NOT NULL DEFAULT 'cash' CHECK (method IN ('cash', 'transfer')),
|
||||||
|
note TEXT,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_payouts_employee_idx ON staff_payouts (tenant_id, employee_id, paid_on DESC);
|
||||||
|
|
||||||
|
CREATE TABLE staff_payroll_ledger (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
business_date DATE NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('accrual', 'payout', 'adjustment')),
|
||||||
|
amount_minor BIGINT NOT NULL,
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
|
||||||
|
ref_type TEXT NOT NULL CHECK (ref_type IN ('payroll_run', 'payout', 'manual')),
|
||||||
|
ref_id VARCHAR(26),
|
||||||
|
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
note TEXT,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX staff_payroll_ledger_balance_idx
|
||||||
|
ON staff_payroll_ledger (tenant_id, employee_id, business_date);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE target_table TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOREACH target_table IN ARRAY ARRAY[
|
||||||
|
'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions',
|
||||||
|
'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments',
|
||||||
|
'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts'
|
||||||
|
] LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))'
|
||||||
|
|| ' WITH CHECK (tenant_id = current_setting(''app.tenant_id'', true))',
|
||||||
|
target_table || '_tenant_isolation', target_table
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger
|
||||||
|
FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
tenant_id = current_setting('app.tenant_id', true)
|
||||||
|
AND created_by = current_setting('app.user_id', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||||
|
staff_org_units, staff_org_unit_terms, staff_metric_definitions, staff_daily_metrics,
|
||||||
|
staff_work_items, staff_pay_rules, staff_pay_assignments, staff_work_sessions,
|
||||||
|
staff_payroll_runs, staff_payroll_lines, staff_payouts
|
||||||
|
TO krona_staff_app;
|
||||||
|
GRANT SELECT, INSERT ON staff_payroll_ledger TO krona_staff_app;
|
||||||
|
REVOKE UPDATE, DELETE, TRUNCATE ON staff_payroll_ledger FROM krona_staff_app;
|
||||||
4
migrations/0007_work_session_trace.down.sql
Normal file
4
migrations/0007_work_session_trace.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE staff_work_sessions
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_work_sessions_source_events_array,
|
||||||
|
DROP COLUMN IF EXISTS auto_closed,
|
||||||
|
DROP COLUMN IF EXISTS source_event_ids;
|
||||||
10
migrations/0007_work_session_trace.up.sql
Normal file
10
migrations/0007_work_session_trace.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
ALTER TABLE staff_work_sessions
|
||||||
|
ADD COLUMN source_event_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD CONSTRAINT staff_work_sessions_source_events_array
|
||||||
|
CHECK (jsonb_typeof(source_event_ids) = 'array');
|
||||||
|
|
||||||
|
COMMENT ON COLUMN staff_work_sessions.source_event_ids IS
|
||||||
|
'Immutable attendance event ids used to derive the latest session revision.';
|
||||||
|
COMMENT ON COLUMN staff_work_sessions.auto_closed IS
|
||||||
|
'True when a scheduler cutoff supplied the end time; manager confirmation is still required.';
|
||||||
3
migrations/0008_employee_access.down.sql
Normal file
3
migrations/0008_employee_access.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
DROP TABLE IF EXISTS staff_standalone_employee_access;
|
||||||
|
DROP INDEX IF EXISTS staff_payroll_ledger_external_key_idx;
|
||||||
|
ALTER TABLE staff_payroll_ledger DROP COLUMN IF EXISTS external_key;
|
||||||
32
migrations/0008_employee_access.up.sql
Normal file
32
migrations/0008_employee_access.up.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Temporary standalone identity mapping. Core remains the owner of users and SSO in platform mode.
|
||||||
|
-- Raw access tokens are never stored; only a SHA-256 digest and a short non-secret hint are kept.
|
||||||
|
|
||||||
|
CREATE TABLE staff_standalone_employee_access (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
subject_id VARCHAR(26) NOT NULL CHECK (subject_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
token_hash CHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||||
|
token_hint VARCHAR(8) NOT NULL,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
rotated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, employee_id),
|
||||||
|
UNIQUE (tenant_id, subject_id),
|
||||||
|
UNIQUE (tenant_id, token_hash),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_standalone_employee_access ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_standalone_employee_access FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_standalone_employee_access_tenant_isolation
|
||||||
|
ON staff_standalone_employee_access
|
||||||
|
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_standalone_employee_access TO krona_staff_app;
|
||||||
|
|
||||||
|
ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;
|
||||||
|
CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx
|
||||||
|
ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;
|
||||||
6
migrations/0009_professions.down.sql
Normal file
6
migrations/0009_professions.down.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
DROP INDEX IF EXISTS staff_pay_assignments_effective_version_idx;
|
||||||
|
DROP INDEX IF EXISTS staff_employees_external_ref_idx;
|
||||||
|
DROP INDEX IF EXISTS staff_employees_profession_idx;
|
||||||
|
ALTER TABLE staff_employees DROP CONSTRAINT IF EXISTS staff_employees_profession_fk;
|
||||||
|
ALTER TABLE staff_employees DROP COLUMN IF EXISTS profession_id;
|
||||||
|
DROP TABLE IF EXISTS staff_professions;
|
||||||
32
migrations/0009_professions.up.sql
Normal file
32
migrations/0009_professions.up.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
CREATE TABLE staff_professions (
|
||||||
|
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}$'),
|
||||||
|
code VARCHAR(80) NOT NULL,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, code),
|
||||||
|
UNIQUE (tenant_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_professions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_professions FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_professions_tenant_isolation ON staff_professions
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
ALTER TABLE staff_employees ADD COLUMN profession_id VARCHAR(26);
|
||||||
|
ALTER TABLE staff_employees ADD CONSTRAINT staff_employees_profession_fk
|
||||||
|
FOREIGN KEY (tenant_id, profession_id) REFERENCES staff_professions (tenant_id, id);
|
||||||
|
CREATE INDEX staff_employees_profession_idx ON staff_employees (tenant_id, profession_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX staff_employees_external_ref_idx
|
||||||
|
ON staff_employees (tenant_id, external_ref) WHERE external_ref IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX staff_pay_assignments_effective_version_idx
|
||||||
|
ON staff_pay_assignments
|
||||||
|
(tenant_id, employee_id, COALESCE(org_unit_id, ''), effective_from);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_professions TO krona_staff_app;
|
||||||
2
migrations/0010_employee_push_notifications.down.sql
Normal file
2
migrations/0010_employee_push_notifications.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS staff_notification_deliveries;
|
||||||
|
DROP TABLE IF EXISTS staff_push_subscriptions;
|
||||||
58
migrations/0010_employee_push_notifications.up.sql
Normal file
58
migrations/0010_employee_push_notifications.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- Push subscriptions and delivery tracking for mobile employee notifications.
|
||||||
|
-- Protected by PostgreSQL Row Level Security (RLS) and tenant isolation.
|
||||||
|
|
||||||
|
CREATE TABLE staff_push_subscriptions (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, endpoint),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX staff_push_subscriptions_employee_idx
|
||||||
|
ON staff_push_subscriptions (tenant_id, employee_id);
|
||||||
|
|
||||||
|
ALTER TABLE staff_push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_push_subscriptions FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_push_subscriptions_tenant_isolation
|
||||||
|
ON staff_push_subscriptions
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
CREATE TABLE staff_notification_deliveries (
|
||||||
|
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}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
outbox_id VARCHAR(26) NOT NULL CHECK (outbox_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed')),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, outbox_id, endpoint),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX staff_notification_deliveries_pending_idx
|
||||||
|
ON staff_notification_deliveries (tenant_id, status) WHERE status = 'pending';
|
||||||
|
|
||||||
|
ALTER TABLE staff_notification_deliveries ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_notification_deliveries FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_notification_deliveries_tenant_isolation
|
||||||
|
ON staff_notification_deliveries
|
||||||
|
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_push_subscriptions TO krona_staff_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_notification_deliveries TO krona_staff_app;
|
||||||
5
migrations/0011_offline_sync_backend.down.sql
Normal file
5
migrations/0011_offline_sync_backend.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
DROP FUNCTION IF EXISTS staff_sync_cleanup_retention(INTEGER);
|
||||||
|
DROP TABLE IF EXISTS staff_sync_device_checkpoints;
|
||||||
|
DROP TABLE IF EXISTS staff_sync_command_inbox;
|
||||||
|
DROP TABLE IF EXISTS staff_sync_change_log;
|
||||||
|
DROP TABLE IF EXISTS staff_sync_devices;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue