Compare commits

...

92 commits

Author SHA1 Message Date
Ochenstarik
14d200851b fix(e2e): fail when database setup runs no tests
Some checks failed
ci / verify (push) Has been cancelled
ci / format (push) Has been cancelled
ci / e2e (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-09-30 21:15:33 +07:00
Ochenstarik
0910afd54d fix(migrations): preserve applied offline sync checksum
Some checks are pending
ci / verify (push) Waiting to run
ci / format (push) Waiting to run
ci / e2e (push) Waiting to run
ci / windows (push) Waiting to run
ci / docker-build (push) Blocked by required conditions
2026-09-30 21:10:43 +07:00
Ochenstarik
94f17c78cf Merge production readiness fixes
Some checks are pending
ci / e2e (push) Waiting to run
ci / windows (push) Waiting to run
ci / docker-build (push) Blocked by required conditions
ci / verify (push) Waiting to run
ci / format (push) Waiting to run
2026-09-30 21:05:46 +07:00
KAgent
e6dd4b743c fix: resolve production readiness blockers, tenant isolation and e2e defects 2026-09-30 18:16:05 +07:00
KAgent
89621ea069 Checkpoint interrupted develop for task staff-prod-ready-0930
Task: staff-prod-ready-0930
Run: 4ea6adb5-b850-4f90-ad7b-74f92749b406
2026-09-30 10:42:45 +00:00
Ochenstarik
29428cee08 fix: rate limit standalone login attempts
Some checks failed
ci / verify (push) Has been cancelled
ci / format (push) Has been cancelled
ci / e2e (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-09-29 13:00:57 +07:00
Ochenstarik
94308e9e5c fix: harden sync database contracts 2026-09-29 12:23:17 +07:00
Ochenstarik
991a068750 chore: align audit integration and test runtime 2026-09-29 12:21:57 +07:00
Ochenstarik
2a2b0bde86 fix: accept audit harness tenant rehome CLI aliases 2026-09-26 17:17:56 +07:00
Ochenstarik
8c22ce83b4 feat: add guarded tenant rehome with rollback verification 2026-09-26 17:17:55 +07:00
Ochenstarik
4fc053850f ops: schedule home pilot backup and retain verified PC copies 2026-09-26 17:17:42 +07:00
Ochenstarik
50ee365882 feat: add encrypted pilot backups and verified PC pull 2026-09-26 17:17:42 +07:00
Ochenstarik
df39565b68 fix: require explicit pilot tenant and reject development placeholder 2026-09-26 17:17:22 +07:00
Ochenstarik
23a6f5ca80 fix(database): make live PostgreSQL workflows valid
Route every transactional outbox write through one complete event-envelope helper so occurred_at is always populated. Separate employee position and profession lookup parameters to avoid PostgreSQL 42P08, and add unit plus live-database regression coverage.
2026-09-26 17:17:06 +07:00
Ochenstarik
7cf5142678 style: rebuild formatting without sync behavior changes
Recreate the formatting baseline from f5ac179 using only Prettier output. Leave sync and tenant-isolation files byte-identical to the base so Antigravity and Grok retain ownership. Normalized comparison confirms all 19 changed files are formatting-only; npm run check passes. Full format check retains five upstream warnings in excluded files.
2026-09-26 17:16:51 +07:00
Ochenstarik
53737f8c36 merge: incorporate reviewed device sync corrections 2026-09-26 17:16:33 +07:00
Ochenstarik
314525aaa1 merge: add independent acceptance harness 2026-09-26 17:16:18 +07:00
f7d3c73f1f docs(audit): add third acceptance for Codex branches
Some checks failed
ci / verify (push) Has been cancelled
Форматирование пересобрано чисто: ни один из 19 файлов не меняет
поведение после нормализации prettier. Пилот больше не может стартовать
с арендатором-заглушкой. Ветку с этой защитой нельзя влить, пока
tests/unit/sync.test.ts падает на новой проверке — записано, как
закрыть без правки чужой зоны.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 16:35:49 +07:00
986843d8d4 docs(e2e): REPORT и NOTES — круг 3
Почему: TASK-GROK-3 — отчёт с командами и фактическим выводом и новые находки для исполнителей.
REPORT: раздел «Круг 3» (ожидания владельца на SHA из задания — все шесть совпали; проверка
форматирования; все открытые ветки после fetch 16:16 NOVT — конфликтов нет; локальная сборка
«после слияния» — храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0;
PILOT-REHOME), новые числа ci-local (46 red / 10 green), «Не проверено».
NOTES: контракт C6 для scripts/rehome-tenant.mjs (с ограничениями схемы: RLS журнала, составные
FK, app.user_id в политиках INSERT) и раздел 7: двойное закрытие пула в sync-bootstrap.test.ts,
неописанный предохранитель live-postgres-remediation.test.ts, N5 в основе, FK outbox_id без tenant_id.
2026-09-26 16:25:47 +07:00
10ba0b440f docs(e2e): доказательства круга 3 — вывод accept-branch
Почему: TASK-GROK-3 требует каждый вывод с командой и фактическим выводом.
round3-accept-owner-shas.md — 1df2019/8baad55/513cdff: все ожидания владельца подтвердились;
round3-accept-owner-branches-first.md — те же ветки по именам (Antigravity уже 6c5bf36);
round3-format-proof.md — prettier-only ветка проходит, 513cdff ловится;
round3-accept-all-open.md — все открытые codex/*, antigravity/* после fetch 16:16 NOVT;
round3-combined-merge.md — локальная сборка основа+live-database+c0(+grok): конфликтов нет,
интеграционные 7/7, храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0;
round3-preset-mode.md — режим E2E_MIGRATOR_URL/E2E_APP_URL (вердикт SKIP).
Файлы прогнаны через prettier (иначе format:check в CI падает), содержимое таблиц не менялось.
2026-09-26 16:24:19 +07:00
6f408bc0aa test(accept): предохранитель изолированной БД и вердикт SKIP для интеграционных тестов
Почему: live-postgres-remediation.test.ts (codex/fix-live-database) запускается только при
DATABASE_TEST_ISOLATED=true, DATABASE_TEST_GUARD_TOKEN, таблице krona_staff_test_guard и имени
БД krona_staff_audit_*; иначе весь файл пропускается, и первый прогон показал его как FAIL
«1 skipped (1)», что неверно в обе стороны. В admin-режиме интеграционная БД одноразовая
(krona_staff_audit_acc_it), поэтому предохранитель создаётся со случайным маркером — тест
реально выполнился: 1 passed. В режиме E2E_MIGRATOR_URL/E2E_APP_URL изоляция не гарантирована,
предохранитель не ставится, файл получает отдельный вердикт SKIP (ничего не проверил).
2026-09-26 16:16:23 +07:00
Ochenstarik
24cc18de0e style(sync): format sync domain and test files with prettier
Нормализация форматирования модулей и тестов офлайн-синхронизации в соответствии с правилами Prettier репозитория без изменения поведения.
2026-09-26 16:12:20 +07:00
Ochenstarik
ef9e00ce80 fix(sync): compute org unit status dynamically in bootstrap pull
Таблица staff_org_units не имеет отдельной колонки status, активность определяется полями is_active и deleted_at. Прямой запрос status при начальной загрузке без курсора приводил к 500 ошибке (дефект SYNC-C0). Статус вычисляется через CASE-выражение. Очистка интеграционного теста выполняется ролью мигратора.
2026-09-26 16:12:20 +07:00
Ochenstarik
b283736312 fix(sync): reject work session and timesheet mutations from devices
Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора, вставка в staff_employees согласована со схемой миграций.
2026-09-26 16:12:02 +07:00
d7b235248a test(e2e): PILOT-REHOME — приёмка будущего scripts/rehome-tenant.mjs
Почему: TASK-GROK-3 п.5 / TASK-CODEX-2 п.2.2 — данные пилота живут под заглушкой
01J00000000000000000000000, перенос на 01M2R6AC5652NPT3MG34DKN4ZP нельзя проверить руками.
[PILOT-REHOME-SEED] (должен быть зелёным): таблицы с tenant_id берутся из information_schema,
в каждую сеется 2+ строки заглушки и постороннего арендатора; новая таблица без сида → красный.
[PILOT-REHOME]: --dry-run печатает те же числа и ничего не меняет; перенос админом БД; под
заглушкой 0 строк, счётчики и SUM(amount_minor) ledger совпадают, посторонний не тронут, запись
в staff_audit_log нового арендатора, krona_staff_app видит тех же сотрудников под новым tenant и
никого под старым, UPDATE staff_payroll_ledger отклоняется, повторный запуск отказывает.
Пока скрипта нет — красный с понятным сообщением, ID в known-defects.json.
Сценарий проверен временным эталоном (не коммитился): зелёный; мутации без аудита / без отказа
на повтор / с пропущенной таблицей / с записью в dry-run — красные (report-evidence/round3-pilot-rehome.txt).
client.ts: dbQueryAs(tenant, …) — запрос ролью приложения под заданным арендатором.
2026-09-26 16:10:07 +07:00
87f85751d0 fix(e2e): проверка секретов в evidence пропускает подкаталоги
Почему: accept-branch.mjs пишет логи в tests/e2e/evidence/accept/<время>/, и run-e2e
падал на readFileSync(каталог) с EISDIR после таблицы результатов. Проверяются только файлы
верхнего уровня (как и раньше); логи accept-branch редактируются самим скриптом (пароли в URL → ***).
2026-09-26 16:10:07 +07:00
644ea7c6cb test(accept): scripts/test/accept-branch.mjs — приёмка веток одной командой
Почему: TASK-GROK-3 п.1–2 — приёмка вручную пропускала красные интеграционные тесты
(ошибка в beforeAll/afterAll даёт «Tests 2 passed» при «Test Files 1 failed») и
конфликты между ветками. Скрипт: worktree на ветку + оверлей текущего стенда, npm run check,
каждый tests/integration/*.test.ts на свежей мигрированной БД (красный по строке Test Files),
e2e и разница с основой по ID, попарный git merge-tree --write-tree, проверка
«форматирование без поведения» (merge-base → prettier с конфигом основы → сравнение, плюс
совпадение лишних строк с другими ветками). Роли создаются один раз, дальше --roles keep;
с E2E_MIGRATOR_URL/E2E_APP_URL роли не трогаются вовсе.
Проверено: на 1df2019 + 8baad55 + 513cdff вывод совпал с ожиданиями владельца
(created_by / Called end on pool more than once при Tests 2 passed / SYNC-C0 зелёный /
конфликты в src/domain/sync/service.ts); синтетическая prettier-only ветка — «ТОЛЬКО ФОРМАТИРОВАНИЕ».
2026-09-26 16:06:59 +07:00
aa7f5f8bbd test(e2e): готовые роли через E2E_MIGRATOR_URL/E2E_APP_URL
Почему: TASK-GROK-3 п.1 — на общем кластере (параллельные прогоны приёмки, чужой стенд)
пересоздание ролей krona_migrator/krona_staff_app со случайными паролями ломает соседей.
Если заданы оба URL, global-setup не вызывает provision и не требует E2E_PG_ADMIN_URL;
по умолчанию поведение прежнее. provision-pg получил --roles keep: пересоздать только БД,
не меняя паролей (нужно accept-branch для отдельной БД на ветку).
ctx.adminDatabaseUrl (E2E_DB_ADMIN_URL или E2E_PG_ADMIN_URL + БД стенда) — только для PILOT-REHOME.
Проверено: run-e2e с E2E_MIGRATOR_URL/E2E_APP_URL без E2E_PG_ADMIN_URL — 45 failed | 9 passed,
как на основе; повторный прогон на уже мигрированной БД — то же.
2026-09-26 16:01:19 +07:00
b0243d5001 docs(audit): add second acceptance and third-round tasks
Some checks are pending
ci / verify (push) Waiting to run
Приёмка второго круга: Grok принят, у Codex форматирование
по-прежнему несёт исправление синхронизации Antigravity, у Antigravity
два интеграционных теста красные на реальной базе и две ветки
конфликтуют между собой. Задания третьего круга фиксируют, что именно
доделать и в каком порядке вливать, плюс перевод пилота с
арендатора-заглушки на настоящего.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:56:31 +07:00
ba59a48ffd test(e2e): перепроверка приёмки на обновлённых ветках исполнителей
Зачем: во время приёмки обе ветки обновили force-push
(codex/fix-live-database ea68e12 → b289dd6, antigravity/fix-reject-device-shifts
795bba0 → 1df2019, теперь на f5ac179). Вердикт по старому SHA не должен
выдаваться за вердикт по новому — «на слово ничего не принимать».

Что: повторный прогон стенда на новых HEAD, результат дописан в
report-evidence/acceptance-*.txt, REPORT.md и NOTES. Итог тот же:
fix-live-database 41 red / 13 green (K3, K4, K13 зелёные),
fix-reject-device-shifts 43 red / 11 green (K13 зелёный).
2026-09-26 15:30:40 +07:00
33daae3593 docs(e2e): REPORT.md второго круга — двойные ID, приёмка веток, Windows
Зачем: PR в Forgejo создаётся без API, описание изменений и доказательства
живут в репозитории; владелец просил обновлённый отчёт с таблицей
«ID владельца / мой ID — цвет — фактический вывод» и приёмочными таблицами
по веткам исполнителей (TASK-GROK-2, раздел «Отчёт»).

Что: что изменилось в круге 2, таблица по всем ID на f5ac179 (45 red /
9 green, храповик OK), приёмка fix-live-database (K3, K4 зелёные; K5, K6
подтверждены по существу), fix-format-baseline (F14 зелёный; находка —
«style»-коммит содержит исправление Antigravity), fix-reject-device-shifts
(K13 зелёный), статус Windows (не проверено: нет раннера), CI, N5, N13,
список «не проверено». Ссылки на evidence ведут в report-evidence/.
2026-09-26 15:29:36 +07:00
51ad03ff65 test(e2e): приёмка веток исполнителей и заметки с двойными ID
Зачем: TASK-GROK-2 §4 — исправления принимаются только по прогону стенда,
«на слово ничего не принимать»; исполнителям нужны ID владельца и
контракты для новых сценариев K8/K9.

Что:
- report-evidence/acceptance-*.txt: для antigravity/fix-reject-device-shifts
  (795bba0), codex/fix-format-baseline (fd1ec3d), codex/fix-live-database
  (ea68e12) — команда, итог vitest, таблица «ID — было — стало — вывод»
  против основной ветки f5ac179 с тем же стендом (worktree, без коммитов в
  их ветки). Итог: fix-live-database — K3/N2, K4/N1 зелёные, открылись K5 и
  K6 по существу; обе ветки Codex — SYNC-A/B зелёные, потому что «style»-
  коммит fd1ec3d содержит исправление Antigravity (после prettier 0 строк
  diff с её веткой) — записано как находка для ревью.
- NOTES: таблица дефектов с двойными ID, строки K6–K9, S9, A4, N12-DATE,
  контракты C4 (verify подписанной ведомости) и C5 (params пресета), CI
  (windows, артефакт evidence), раздел приёмки. Ни одна ветка не влита в
  основную, поэтому known-defects.json не сокращён.
2026-09-26 15:28:10 +07:00
3475133b10 test(e2e): синтетические суммы кратны рублю (учёт округления расчёта)
Зачем: на ветке codex/fix-live-database цепочка подтверждение → расчёт →
утверждение впервые дошла до конца, и K3/N2 остался красным не из-за
дефекта, а из-за теста: 8 ч × 123,45 ₽ = 98760 коп., а расчёт округляет
строку до rounding_unit_minor (по умолчанию 100 коп., service.ts:695) и
начисляет 98800. Округление до рубля — настройка продукта, не дефект;
тест не должен от него зависеть.

Что: SYNTHETIC_RATE_MINOR 12345 → 12300 (123,00 ₽/ч), оплата смены
официанта в K9 111111 → 111100. Суммы остаются заведомо синтетическими.
2026-09-26 15:25:47 +07:00
ac8ab3cc02 test(e2e): сценарии K6–K9, S9, A4 и N12-DATE из аудита владельца
Зачем: TASK-GROK-2 §3 — денежные дефекты K6–K9, S9, A4 и дата расчёта
были в AUDIT.md, но не покрыты стендом; без красных сценариев исправления
Codex нечем принимать.

Что (tests/e2e/50-money-and-errors.e2e.ts, всё красное и внесено в
known-defects.json):
- K6/D5: отклонение смены из подписанной ведомости → 409 или сторно.
  Сейчас падает на предусловии: подтверждение смены 500 (K3).
- K7/D2: две записи начисления за один день (две точки) и повторный
  импорт с другой суммой → итог = сумма по книге. Факт: 22200 вместо 33300.
- K8/D1: после подписи меняется ставка, затем проверочный пересчёт
  (предполагаемый контракт C4 GET /payroll/runs/:id/verify). Блокирован K3.
- K9/D3: официант по пресету waiter_upsell: params.shift_rate_minor не
  сохраняется (сам D3, до K3); далее начисление = оплата смены (после K3).
- S9: удаление точки с устройством мигратором → 23502 вместо удаления или
  23503 (публичного API удаления нет — так же, как в live-sync.mjs владельца).
- A4: тела 500 не должны содержать SQLSTATE и текст PostgreSQL.
- N12-DATE: month=2026-13 → 400 (факт 500, 22008); 2026-02-30 уже 400.
Прогон run-e2e: 45 red / 9 green, храповик OK.
2026-09-26 15:24:02 +07:00
0453168d70 test(e2e): двойные ID «владелец/мой» в названиях сценариев и known-defects
Зачем: аудит владельца (docs/audit/2026-09-26/AUDIT.md) стал основным
реестром дефектов; без сопоставления исполнители и приёмка говорят о
разных ID для одной ошибки (TASK-GROK-2 §2).

Что: теги переименованы по таблице владельца ([K3/N2], [K13/SYNC-A],
[S5/SYNC-C], [D6/OWN-01], [K17/OWN-10] и т. д.), ключи known-defects.json —
так же; N9–N12, F7, F10 остаются одиночными, как в таблице. Сверх таблицы,
по тексту AUDIT.md: K1/SYNC-I (анонимный доступ без токенов — это K1 на
/sync), K1/PILOT-1 (подстановки без :? в пилотном compose прямо названы в
K1), K2/PILOT-2 (NODE_ENV=development в пилоте — суть K2). SYNC-C0, SYNC-K,
OWN-03..06/08/09, PILOT-3 ID владельца не имеют. run-e2e принимает «/» в
теге. Прогон: 38 red / 9 green, храповик OK — те же сценарии, новые имена.
2026-09-26 15:21:22 +07:00
27499da191 ci: задача windows, включаемая переменной, когда появится раннер
Зачем: владелец требует проверку запуска стенда на Windows 11, но
Windows-раннера у git.ochenstarik.ru сейчас нет. Задача без подходящего
раннера висела бы в очереди и держала статус прогона «в ожидании»,
поэтому она выключена по умолчанию и включается переменной репозитория
KRONA_WINDOWS_RUNNER=true.

Что: npm ci, тест запуска без .cmd-обёрток (node vitest.mjs, без npx),
а при заданном секрете WINDOWS_E2E_PG_ADMIN_URL — полный run-e2e с
выгрузкой evidence артефактом. actionlint (метки docker, docker-host,
windows) — 0 замечаний. Сама задача ни разу не запускалась — не проверено.
2026-09-26 15:20:47 +07:00
fc20f75b55 test(e2e): evidence прогонов вне git, в CI — артефакт
Зачем: tests/e2e/evidence/ перезаписывается каждым прогоном (логи серверов
с ULID и портами, JSON vitest), поэтому любой запуск стенда менял
отслеживаемые файлы и засорял дифф (приёмка владельца, TASK-GROK-2 п.1).

Что: tests/e2e/evidence/ убран из индекса (git rm --cached) и добавлен в
корневой .gitignore (его же читает prettier, поэтому форматировать
сгенерированные файлы больше не нужно — вызов prettier из run-e2e убран).
В CI задача e2e выгружает папку артефактом e2e-evidence
(actions/upload-artifact, закреплён SHA v4.6.2; Forgejo 9.0.3 поддерживает
v4). Разовые доказательства, которые скрипты не перезаписывают (N5 до/после,
замер N13), перенесены в tests/e2e/report-evidence/ — на них ссылается
REPORT.md.
2026-09-26 15:20:28 +07:00
0aaa2f4d2d test(e2e): запуск vitest/tsx/prettier без .cmd-обёрток (Windows)
Зачем: проект разрабатывается под Windows, а стенд там не стартовал:
spawn('npx') и spawn('node_modules/.bin/tsx') без shell падают с ENOENT,
потому что в Windows это .cmd-файлы (приёмка владельца, TASK-GROK-2 п.1).

Что: scripts/test/node-commands.mjs находит JS-точки входа через
require.resolve (tsx/cli, vitest.mjs, prettier.cjs) и всегда запускает их
текущим node (process.execPath). Сервер запускается одним процессом
`node --import tsx src/server.ts`: CLI tsx порождает дочерний node, и под
Windows kill() родителя оставил бы сервер-сироту на порту. baseEnv()
передаёт дочерним процессам PATH и под win32 системные переменные
(SystemRoot, TEMP и др.), без которых node на Windows не работает с сетью,
но не наследует секреты родителя.

Проверка на Linux: tests/e2e/harness/node-commands.test.ts (входит в
npm test) — команды начинаются с process.execPath, без .cmd/.bin/npx,
файлы существуют; исходники стенда не вызывают npx и node_modules/.bin.
Реальный прогон на Windows 11 — не проверено (нет Windows-раннера).
2026-09-26 15:19:47 +07:00
537b784da3 docs(e2e): REPORT.md (описание PR) и свежие evidence прогона
Зачем: PR в Forgejo создать нельзя (нет API-токена), поэтому описание
изменений, таблица green/red по ID с командами и фактическим выводом, итог
N5 до/после, валидация CI и N13 лежат в репозитории рядом с тестами.
Evidence перезаписаны финальным прогоном ./scripts/test/ci-local.sh, чтобы
отчёт ссылался на те же числа, что в файлах (38 red / 9 green, храповик OK,
format красный из-за F14). Число файлов F14 в комментарии ci.yml и в
заметках уточнено до 23 по этому замеру.
2026-09-26 15:18:31 +07:00
045f696425 test(e2e): N13 — нагрузочная проверка гонки первого расчёта зарплаты
Зачем: владелец пометил N13 как «не воспроизведено», а в коде окно гонки
(SELECT ... FOR UPDATE по отсутствующей строке, затем INSERT ... ON CONFLICT
DO NOTHING со своим runId) видно. Нужен повторяемый замер на живом сервере
и реальной PostgreSQL 17, а не рассуждение.

Что: scripts/test/n13-race.mjs — 10/25/50 параллельных первых
POST /payroll/calculate, каждая попытка на новой точке, проверка статусов,
числа run_id, строк в staff_payroll_runs и «фантомных» run_id. Режим as-is
показывает, что сейчас всё упирается в N2 (500). Режим n2-bypass ставит
DEFAULT now() только в одноразовой БД скрипта (продукт и миграции не
меняются), чтобы увидеть саму гонку. Итог: 0 некорректных из 30 попыток;
причина (из кода) — случайная сериализация через upsert
ensureDefaultPayRules. Это записано в NOTES-FOR-EXECUTORS вместе с
рекомендацией явной блокировки и замечанием об утечке текста ошибок БД в 500.
2026-09-26 15:18:31 +07:00
82a2a7ca37 test(e2e): сценарии решений владельца (OWN-01..11) и заметки исполнителям
Зачем: решения владельца (роли точки/бухгалтера/директора, пакет зарплаты
с подписью директора, неизменяемость после подписи, зарплата по точке
фактической смены, согласие на биометрию) должны стать исполняемыми
критериями приёмки до того, как Codex начнёт этап 4, иначе реализацию
будет нечем проверить.

API ещё нет, поэтому тесты написаны против наиболее вероятного контракта
C1 (access-grants), C2 (payroll/batches, submit/sign/return,
notifications), C3 (biometric-consent, terminal enrollment); контракт и
инварианты описаны в tests/e2e/NOTES-FOR-EXECUTORS.md. Сейчас все OWN
красные (404 Route not found) и внесены в known-defects.json.
В NOTES также: найденные сценариями дефекты с указанием файлов,
конфликт NODE_ENV/standalone для pilot, N6, требования к раннеру docker-host.
2026-09-26 15:18:31 +07:00
c73c714611 test(e2e): проверка deploy/pilot/compose.yaml (PILOT-1..3)
Зачем: корневой compose.yaml уже проверяется tests/unit/deployment.test.ts,
а pilot-файл, который реально разворачивается у владельца, — нет. В нём
подстановки вида ${STANDALONE_MANAGER_TOKEN} без :? молча дают пустое
значение, а при пустых токенах standalone выдаёт права анониму (F2);
NODE_ENV=development в pilot отключает production-проверки config.ts.

- PILOT-1: каждая подстановка должна быть ${VAR:?сообщение};
- PILOT-2: ни один сервис не с NODE_ENV=development;
- PILOT-3: у app нет учётных данных мигратора (сейчас зелёный).
Файл в tests/e2e как *.e2e.ts: tests/unit вне разрешённых путей, а
красные пункты (дефекты deploy/, которые правят исполнители) идут через
known-defects.json и не ломают `npm run check`. Сейчас PILOT-1 и PILOT-2
красные: 14 подстановок без :?, app с NODE_ENV=development.
2026-09-26 15:18:31 +07:00
bc9b29dec8 ci: PostgreSQL 17 с ролями как в pilot, roundtrip миграций, e2e, SHA-пины
Зачем: прежний CI запускал миграции и тесты под POSTGRES_USER сервиса
(суперпользователь), не проверял откат миграций, не запускал e2e на
реальной БД, держал плавающие теги actions (F13) и выполнял docker build
в контейнере без Docker daemon.

- verify: bootstrap-админ сервиса только создаёт роли (provision-pg.mjs,
  пароли случайные); миграции up -> откат всех -> up -> up (no-op) ролью
  krona_migrator без SUPERUSER; `npm run check` с DATABASE_TEST_URL
  (app-роль) и DATABASE_TEST_MIGRATION_URL (очистка, см. N5); npm audit.
- format: prettier вынесен в отдельную задачу — он красный по 25 файлам
  (F14) и не должен маскировать статус verify/e2e.
- e2e: реальный сервер + PostgreSQL 17 через scripts/test/run-e2e.mjs.
  Выбран «храповик» known-defects.json, а не отдельная задача с
  continue-on-error: так CI зелёный, только если стенд работает и каждый
  красный сценарий — известный открытый дефект; новый красный сценарий
  (регрессия) или исправленный, но не вычеркнутый дефект делают задачу
  красной. continue-on-error скрыл бы и поломку самого стенда.
- docker-build: отдельная задача на метке `docker-host` только для push,
  после verify; без такого раннера она ждёт в очереди, не блокируя
  остальные.
- actions/checkout v4.4.0 и actions/setup-node v4.4.0 закреплены SHA
  (git ls-remote; одинаковы на github.com и code.forgejo.org).

Проверено: actionlint 1.7.12 (метки docker/docker-host объявлены как
self-hosted) — 0 замечаний; scripts/test/ci-local.sh повторяет шаги
verify/format/e2e на локальной PostgreSQL 17.11 (Forgejo-раннер реально
не запускался — не проверено).
2026-09-26 15:18:31 +07:00
c68370b890 test(e2e): форматировать сгенерированные json/md evidence prettier
Зачем: tests/e2e/evidence не входит в .prettierignore (файл вне разрешённых путей), и минифицированный JSON vitest делал бы npm run format:check красным из-за артефактов, а не из-за кода.
2026-09-26 15:18:31 +07:00
2548bd7bdf test(e2e): сценарии офлайн-синхронизации (/sync/*)
Зачем: бэкенд офлайн-синхронизации (миграция 0011) покрыт только
юнит-тестами без реальной БД. Сценарии на живом сервере показывают, как
терминал реально взаимодействует с сервером, и фиксируют границы
доверия к устройству.

Проверяется правильное поведение: устройство не подтверждает смены и не
утверждает табель сам (SYNC-A/B); начальная загрузка работает (SYNC-C0,
сейчас 500 «column status does not exist» — у staff_org_units нет
status) и не теряет сущности после 100-й (SYNC-C); правки из портала
видны в инкрементальном pull (SYNC-D); отметки уволенных и вне окна
времени отклоняются (SYNC-E/J); точка устройства сохраняется, чужая
отклоняется (SYNC-F); битая команда не роняет пакет (SYNC-G); CSRF и
анонимная регистрация устройств запрещены (SYNC-H/I); ack не принимает
курсор из будущего (SYNC-K). SYNC-L — позитивный контроль отзыва.
2026-09-26 15:18:31 +07:00
397954be0a test(e2e): сценарии дефектов основного API (N1-N4, N9-N12, F2-F4, F7, F10)
Зачем: каждый открытый дефект аудита должен иметь исполняемый сценарий
на реальной БД, который проверяет ПРАВИЛЬНОЕ поведение. Тогда красный
тест = открытый дефект, а исправление видно по смене цвета, без ручной
перепроверки.

- N1 POST /employees -> 201; N2 подтверждение смены, расчёт, утверждение
  и сумма начисления 8 ч x синтетическая ставка; N4 пересекающиеся
  периоды не начисляют смену дважды (сейчас блокирован N2 — это видно в
  сообщении assert как «предусловие, см. N2»);
- N3 SSRF: подписка на https://127.0.0.1:<порт> должна отклоняться, и
  локальный слушатель не должен получить соединение;
- N9, N12, N10, N11, F2 (процесс без токенов), F3, F4 (отдельный процесс),
  F7, F10.
Сотрудники для сценариев, не относящихся к N1, создаются импортом, чтобы
один дефект не маскировал остальные. Все данные синтетические (E2E-*),
ставка 123,45 ₽/ч заведомо не реальная.
2026-09-26 15:18:31 +07:00
646e8d4f22 test(e2e): стенд с реальным сервером и PostgreSQL 17
Зачем: 156 зелёных тестов на fakeDatabase не поймали, что ключевые
сценарии (создание сотрудника, подтверждение смены, расчёт зарплаты)
всегда падают на реальной PostgreSQL. Нужен приёмочный стенд, который
проверяет продукт как чёрный ящик: настоящий процесс сервера, настоящая
БД, роли как в pilot, но с минимальными привилегиями.

- tests/e2e/harness/global-setup.ts: создаёт БД и роли через
  scripts/test/provision-pg.mjs, применяет миграции ролью krona_migrator
  (NOSUPERUSER), поднимает `tsx src/server.ts` под krona_staff_app с
  токенами/VAPID из crypto.randomBytes; три процесса: с токенами, без
  токенов (как корневой compose.yaml) и отдельный для проверки rate limit;
  логи серверов пишутся в tests/e2e/evidence/.
- *.e2e.ts и отдельный vitest.e2e.config.ts: e2e не попадают в `npm test`,
  поэтому `npm run check` не требует БД и не краснеет от известных дефектов.
- [HARNESS]/[CTRL] — позитивные контроли (версия PG >= 17, атрибуты ролей,
  app-роль не видит schema_migrations, RBAC, уволенный теряет доступ):
  если они красные, красные дефектные сценарии ничего не доказывают.
- scripts/test/run-e2e.mjs: «храповик» известных дефектов по
  tests/e2e/known-defects.json (новый красный или исправленный-но-не-
  вычеркнутый ID = exit 1), таблица e2e-table.md и проверка, что в
  evidence нет ничего похожего на секреты стенда.

Логи названы *.txt, т.к. *.log в .gitignore.
2026-09-26 15:18:31 +07:00
4f3e5c621a style(test): prettier для tenant-isolation.test.ts
Зачем: файл уже до правки N5 не проходил prettier --check (часть F14); форматирование вынесено в отдельный коммит, чтобы смысловая правка N5 читалась отдельно от шума.
2026-09-26 15:18:31 +07:00
19a028970d test(integration): очистка tenant-isolation ролью мигратора (N5)
Зачем: afterAll удалял строки соединением роли приложения, а у
krona_staff_app намеренно нет DELETE на staff_employees. На реальной
PostgreSQL 17 тест проходил, но файл падал в afterAll ("permission denied
for table staff_employees", exit 1), поэтому любой CI с DATABASE_TEST_URL
был бы красным не из-за дефекта продукта, а из-за самого теста.
Выдавать app-роли DELETE ради теста нельзя: это ослабило бы модель прав.

Что сделано:
- очистка идёт отдельным соединением роли мигратора
  (DATABASE_TEST_MIGRATION_URL или DATABASE_MIGRATION_URL, который CI уже
  задаёт); так как FORCE RLS действует и на владельца таблиц, в транзакции
  выставляется app.tenant_id; удаляются строки обоих тестовых tenant;
- очистка выполняется и в beforeAll, чтобы остатки прошлого упавшего
  прогона не ломали уникальные ключи (EMP-PUSH-1, DEV-A);
- без URL мигратора тест падает с понятной ошибкой, а не оставляет мусор.

До: tests/e2e/evidence/n5-before.txt (1 passed, suite FAIL, exit=1).
После: tests/e2e/evidence/n5-after.txt (два прогона подряд, exit=0).
2026-09-26 15:18:31 +07:00
5ac248660a test(db): стенд PostgreSQL с мигратором без SUPERUSER
Зачем: аудит показал, что 156 зелёных тестов на fakeDatabase не заметили,
что создание сотрудника, подтверждение смены и расчёт зарплаты всегда
падают на реальной PostgreSQL. Чтобы тесты и CI проверяли реальный SQL,
нужен воспроизводимый стенд с ролями как в pilot, но с минимальными
привилегиями:

- scripts/test/provision-pg.mjs: bootstrap-админ (только как DBA/entrypoint)
  создаёт krona_migrator (NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и
  схемы public) и krona_staff_app (NOSUPERUSER NOBYPASSRLS NOINHERIT); пароли
  crypto.randomBytes, пишутся только в env-файл 0600, в вывод не попадают.
  scripts/create-app-role.mjs здесь не используется, т.к. без SUPERUSER он
  падает (находка N6, продуктовый код не трогаем).
- scripts/test/migrate-roundtrip.mjs: up -> откат всех миграций -> up ->
  повторный up должен быть no-op; ролью мигратора, как требует AGENTS.md.

Проверено на PostgreSQL 17.11: 11 миграций применены, откачены, применены
повторно, третий up пустой.
2026-09-26 15:18:31 +07:00
f5ac179623 docs(audit): add 2026-09-26 audit, acceptance and executor tasks
Some checks are pending
ci / verify (push) Waiting to run
Executors could not see the audit: Grok reported that the owner's
AUDIT.md and evidence never reached it, so its scenarios could not be
mapped to the audit IDs. Keeping the audit, the acceptance report and
the task for each executor in the repository lets Codex, Antigravity
and Grok take work and report through git.

The live-check scripts are stored as .mjs.txt so the project linter,
which type-checks every .mjs file, does not break for everyone; the
maintained version of these checks is Grok's harness in tests/e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:13:56 +07:00
Ochenstarik
331e4909c9 feat(portal): rebuild staff operational workspace
Some checks are pending
ci / verify (push) Waiting to run
2026-09-26 14:13:21 +07:00
Ochenstarik
778ba2d15b fix(portal): hide login after successful session
Some checks are pending
ci / verify (push) Waiting to run
2026-09-26 13:50:36 +07:00
Ochenstarik
3595792ef3 merge: offline sync backend with library portal
Some checks are pending
ci / verify (push) Waiting to run
2026-09-26 13:28:50 +07:00
Ochenstarik
5ee75177b9 feat(sync): add offline sync backend foundation
- Add PostgreSQL migrations with strict RLS policies (0011)
- Introduce SyncService for handling auth, token-based registration
- Add payload size and replay attack mitigations
- Provide HTTP fastify routes (pull, push, ack, device management)
- Validate openapi contracts and add comprehensive tests
2026-09-26 13:10:27 +07:00
Ochenstarik
1812870dd3 feat(portal): align workspace with Krona library 2026-09-26 12:58:51 +07:00
Ochenstarik
3e2054480c fix(push): isolate employee data and preserve platform outbox
Some checks are pending
ci / verify (push) Waiting to run
2026-09-26 12:31:10 +07:00
Ochenstarik
2f1bdf549d test(push): tenant isolation and web-push idempotency tests 2026-09-26 12:08:36 +07:00
Ochenstarik
87a6a447c2 feat(pwa): mobile-first employee portal with offline sync and push integration 2026-09-26 12:08:31 +07:00
Ochenstarik
fb1270a0a8 feat(notification): employee push subscriptions, rls migrations, and web-push dispatcher 2026-09-26 12:08:26 +07:00
Ochenstarik
f74aa6d7f5 fix(import): provision workbook profession catalog
Some checks are pending
ci / verify (push) Waiting to run
2026-09-24 21:25:13 +07:00
Ochenstarik
9ea9a004ed feat(ui): align Staff portal with Krona library 2026-09-24 21:14:20 +07:00
Ochenstarik
5b159f785c feat(staff): import workbook professions and dated payroll 2026-09-24 21:14:03 +07:00
Ochenstarik
4fe2f8799a fix(terminal): secure local state and background IPC 2026-09-24 21:13:46 +07:00
Ochenstarik
96a28f575f test(tenant): fix payroll ledger isolation query and verify migration rls 2026-09-24 19:11:45 +07:00
Ochenstarik
37da6f0418 feat(payroll): automatic payroll calculation, owner schemes, and UI integration 2026-09-24 19:00:25 +07:00
Ochenstarik
c08a7e6af1 fix(finance): preserve imported corrections 2026-09-24 18:28:58 +07:00
Ochenstarik
1007f929c2 fix(finance): cast derived revenue parameters 2026-09-24 18:25:56 +07:00
Ochenstarik
88804a0eea feat(staff): add finance calendar and employee portal 2026-09-24 18:12:24 +07:00
Ochenstarik
057ec75b26 fix(terminal): handle locked scans consistently 2026-09-24 18:09:42 +07:00
Ochenstarik
0f530dc633 fix: read terminal release version reliably
Some checks failed
ci / verify (push) Has been cancelled
2026-09-24 17:13:14 +07:00
Ochenstarik
df0da37d5c merge: integrate server portal and work sessions 2026-09-24 17:06:04 +07:00
Ochenstarik
6e5a1ad404 merge: integrate production terminal 2026-09-24 17:06:03 +07:00
Ochenstarik
2f6b367638 feat: reconcile and review attendance sessions 2026-09-24 17:00:08 +07:00
Ochenstarik
29c70e0107 refactor(terminal): decouple scanner via IFingerprintScanner factory and fix test cancellation token 2026-09-24 16:54:41 +07:00
Ochenstarik
39f0840087 Fix IPC framing, add global pipe name, and auto-reconnect tests 2026-09-24 16:49:07 +07:00
Ochenstarik
b3fdec1eb2 test(terminal): add comprehensive unit test suite covering scanner mock, deduplication, sync, and security 2026-09-24 16:37:59 +07:00
Ochenstarik
7ca8966084 docs(terminal): add API requirements, driver distribution policy, and SLK20R hardware report 2026-09-24 16:37:52 +07:00
Ochenstarik
b2f7a68589 feat(installer): update release build script and NSIS installer with Windows Service and driver detection 2026-09-24 16:37:48 +07:00
Ochenstarik
32e3c6f7f7 feat: add manager employee and timesheet workflows 2026-09-24 16:32:30 +07:00
Ochenstarik
71660745a5 feat(terminal): wire attendance tracker, tray states, fingerprint enrollment wizard, and re-authorization 2026-09-24 16:28:20 +07:00
Ochenstarik
fecf7e5911 chore: remove build artifacts from KronaStaffService and update .gitignore 2026-09-24 16:22:50 +07:00
Ochenstarik
0bd88142b8 feat(service): add KronaStaffService Windows Service with IPC and offline sync worker 2026-09-24 16:22:33 +07:00
Ochenstarik
94753c3105 feat(ipc): add NamedPipe contracts, secure IPC server, and client 2026-09-24 16:21:27 +07:00
Ochenstarik
5bf634278d feat(attendance): add AttendanceTracker with thread-safe duplicate guard and scheduled checkout 2026-09-24 16:21:07 +07:00
Ochenstarik
c479bb60ca feat(scanner): add IFingerprintScanner, deduplication, auto-reconnect, and state machine 2026-09-24 16:20:39 +07:00
Ochenstarik
14ce259a5b feat(store): configure DPAPI LocalMachine scope with restricted ACL and migration support 2026-09-24 16:20:00 +07:00
Ochenstarik
c41bdd8654 feat(sync): add configurable HTTPS endpoints, idempotency headers, and auto-checkout support 2026-09-24 16:19:16 +07:00
Ochenstarik
770ef00a28 feat(terminal): add Argon2id PIN hashing with lockout, license port, and model extensions 2026-09-24 16:19:03 +07:00
Ochenstarik
3464998168 feat: add secure attendance portal and payroll foundation
Some checks are pending
ci / verify (push) Waiting to run
2026-09-24 15:59:38 +07:00
Ochenstarik
b4eb3432ae feat(terminal): add scheduled automatic checkout
Some checks failed
ci / verify (push) Has been cancelled
2026-09-21 10:00:11 +07:00
Ochenstarik
4d37350757 feat(terminal): update attendance terminal to 0.2.4
Some checks are pending
ci / verify (push) Waiting to run
2026-09-20 23:40:43 +07:00
209 changed files with 32456 additions and 379 deletions

View file

@ -11,5 +11,6 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000
PLATFORM_JWT_ISSUER=https://platform.example.invalid
PLATFORM_JWT_AUDIENCE=krona-staff
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

View file

@ -3,6 +3,16 @@ on:
push:
branches: [main, codex/bootstrap-platform-foundation]
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:
verify:
runs-on: docker
@ -10,27 +20,125 @@ jobs:
postgres:
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
env:
POSTGRES_DB: krona_staff
POSTGRES_USER: krona_migrator
POSTGRES_PASSWORD: ci-migration-password
ports: ['5432:5432']
POSTGRES_DB: postgres
POSTGRES_USER: ci_bootstrap
POSTGRES_PASSWORD: ci-bootstrap-only
options: >-
--health-cmd "pg_isready -U krona_migrator -d krona_staff"
--health-cmd "pg_isready -U ci_bootstrap -d postgres"
--health-interval 5s
--health-timeout 3s
--health-retries 20
env:
DATABASE_MIGRATION_URL: postgres://krona_migrator:ci-migration-password@postgres:5432/krona_staff
DATABASE_TEST_URL: postgres://krona_staff_app:ci-app-password@postgres:5432/krona_staff
POSTGRES_APP_PASSWORD: ci-app-password
E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- 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: node scripts/create-app-role.mjs
- run: node scripts/migrate.mjs up
- run: npm run check
- run: npm run format:check
- name: provision roles (migrator NOSUPERUSER, app NOBYPASSRLS)
run: node scripts/test/provision-pg.mjs --out "$RUNNER_TEMP/db.env" --db krona_staff
- name: migrations up -> rollback all -> up -> up (no-op)
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: 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
View file

@ -3,6 +3,7 @@ node_modules/
dist/
coverage/
.env
deploy/pilot/secrets/
*.log
.idea/
.vscode/
@ -11,5 +12,12 @@ backups/
restore-work/
apps/KronaStaffTerminal/bin/
apps/KronaStaffTerminal/obj/
apps/KronaStaffService/bin/
apps/KronaStaffService/obj/
apps/KronaStaffTerminal.Tests/bin/
apps/KronaStaffTerminal.Tests/obj/
artifacts/
.audit-build/
# Результаты прогонов e2e (логи серверов, JSON vitest, таблица) — артефакт CI, не исходники
tests/e2e/evidence/

View file

@ -1,13 +1,15 @@
# 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
cp .env.example .env
# Перед следующей командой заполните STANDALONE_TENANT_ID в .env:
# используйте ULID организации из Krona Business Platform, не ID сотрудника.
docker compose --profile tools run --rm migrate
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 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).
## Биометрический терминал

File diff suppressed because it is too large Load diff

View 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>

View 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);
}
}

View 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();

View 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
```

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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("Иван \"Повар\"; смена"));
}
}

View 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);
}
}

View file

@ -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));
}
}

View file

@ -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>

View 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();
}
}

View 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);
}
}

View file

@ -86,7 +86,7 @@ public partial class App : Application
Window = new MainWindow();
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);
_activationCancellation = new CancellationTokenSource();
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
@ -123,6 +123,12 @@ public partial class App : Application
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()
{
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()
{
if (Window is MainWindow mainWindow)

View file

@ -17,10 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion>
<InformationalVersion>0.2.0</InformationalVersion>
<Version>0.3.2</Version>
<AssemblyVersion>0.3.2.0</AssemblyVersion>
<FileVersion>0.3.2.0</FileVersion>
<InformationalVersion>0.3.2</InformationalVersion>
</PropertyGroup>
<ItemGroup>
@ -71,7 +71,9 @@
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<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="System.Windows.Extensions" Version="10.0.12" />
</ItemGroup>
<!--
@ -96,14 +98,8 @@
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_WinUiRootResource)"
DestinationFolder="$(PublishDir)"
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" />
<Copy SourceFiles="@(_WinUiRootResource)" DestinationFolder="$(PublishDir)" 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>
</Project>

View file

@ -10,9 +10,11 @@
Unloaded="Page_Unloaded"
mc:Ignorable="d">
<Grid Padding="24,16,24,24"
RowSpacing="12"
Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid x:Name="ProtectedContent"
Visibility="Collapsed"
Padding="24,16,24,24"
RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -149,6 +151,8 @@
</AppBarButton.Icon>
</AppBarButton>
<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"/>
<AppBarSeparator />
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
@ -204,21 +208,49 @@
<ScrollViewer>
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<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"/>
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" 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="Показывать"/>
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
<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}"/>
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
<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}"/>
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
@ -233,18 +265,42 @@
<PasswordBox x:Name="UpdateTokenBox"
Header="Токен чтения приватного репозитория"
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. Позже этот канал можно заменить Бизнес Платформой без изменения установщика."
TextWrapping="Wrap"
Opacity="0.7"/>
<Button Content="Проверить обновления"
HorizontalAlignment="Left"
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">
<Button Content="Сохранить настройки"
Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}"
Click="SaveSettings_Click"/>
<Button Content="Отправить тест" Click="TestNotification_Click"/>
<Button Content="Тест выбранному сотруднику" Click="TestNotification_Click"/>
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
</StackPanel>
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
@ -253,4 +309,43 @@
</TabViewItem>
</TabView>
</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>

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text.Json.Serialization;
namespace KronaStaffTerminal.Models;
@ -6,11 +7,39 @@ public sealed class EmployeeRecord
{
public string Id { get; set; } = UlidGenerator.New();
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 Gender { get; set; } = "";
public bool IsAdministrator { get; set; }
public bool IsBlocked { get; set; }
public DateTimeOffset? BlockedAtUtc { 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.
public int FingerprintId { get; set; }
@ -29,7 +58,21 @@ public sealed class EmployeeRecord
};
[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]
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
@ -40,6 +83,7 @@ public sealed class FingerprintRecord
public int Id { get; set; }
public string Template { get; set; } = "";
public string Label { get; set; } = "";
public DateTimeOffset EnrolledAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AttendanceRecord
@ -50,8 +94,11 @@ public sealed class AttendanceRecord
public DateTimeOffset OccurredAtUtc { get; set; }
public int ReaderIndex { get; set; }
public string EventType { get; set; } = "Приход";
public bool IsAutomatic { get; set; }
public bool NotificationDelivered { get; set; }
public string? NotificationError { get; set; }
public bool ServerSynced { get; set; }
public string? ServerSyncError { get; set; }
[JsonIgnore]
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 string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
public int ReaderIndex { get; set; }
public bool StartWithWindows { get; set; }
public string AdminPinSalt { get; set; } = "";
@ -71,16 +119,28 @@ public sealed class TerminalSettings
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
public int DuplicateScanGuardSeconds { get; set; } = 120;
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 TelegramEnabled { 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 bool VkEnabled { get; set; }
public string VkGroupToken { get; set; } = "";
public bool MaxEnabled { get; set; }
public string MaxBotToken { get; set; } = "";
public string MaxChatId { get; set; } = "";
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 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; }
}

View file

@ -4,19 +4,23 @@
## Возможности
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
- повторный поиск сканеров по кнопке, ручной выбор одного из подключённых устройств и автоматический переход на рабочее устройство;
- первичная настройка PIN и добавление первого сотрудника-администратора без подключённого сканера;
- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- ежедневный автоматический уход в заданное время для сотрудников, забывших отметить завершение смены;
- локальный журнал последних отметок;
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков;
- приватный режим, скрывающий полные имена на публичном экране;
- уведомления через Telegram Bot API и MAX Bot API;
- личные уведомления сотрудников через Telegram, сообщения сообщества ВКонтакте и MAX;
- блокировка сотрудника с отключением его отпечатков и безвозвратным удалением локальных привязок мессенджеров;
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
- закрытый административный раздел с входом по PIN или отпечатку администратора;
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
- закрытый экран входа, скрывающий сотрудников, журнал и настройки до проверки PIN или отпечатка администратора;
- автоматическая блокировка всей программы при запуске, сворачивании и ручной блокировке;
- автоматическая блокировка административного раздела через 5 минут бездействия;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
@ -55,7 +59,7 @@ ZKTeco. Уже установленный драйвер продолжает р
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
3. Кнопка «Заблокировать» и сворачивание скрывают весь интерфейс до повторного входа. Пять минут бездействия закрывают административные вкладки, не останавливая рабочий терминал.
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
@ -66,7 +70,9 @@ ZKTeco. Уже установленный драйвер продолжает р
## Уведомления
На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
На вкладке «Уведомления и сканер» включите нужные каналы и укажите токены ботов. Затем выберите сотрудника, нажмите «Личные уведомления» и сохраните его личный `chat_id` Telegram, `peer_id` ВКонтакте или `user_id` MAX. Сотрудник должен предварительно начать личный диалог с ботом или разрешить сообщения сообщества. Кнопка «Тест выбранному сотруднику» отправляет сообщение только этому человеку. При успешной отметке сотрудник получает дату, время и тип собственного события без имени и данных других сотрудников.
При блокировке сотрудника его личные идентификаторы удаляются из зашифрованного хранилища, отпечатки перестают создавать отметки, а отправка прекращается. После разблокировки каналы необходимо привязать заново. Удалить весь диалог из приложения мессенджера на устройстве сотрудника бот не может; уже доставленная история остаётся под управлением самого пользователя и соответствующего мессенджера.
## Обновления

View file

@ -1,16 +1,32 @@
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
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)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(pin, salt, CurrentIterations, HashAlgorithmName.SHA256, 32);
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash), CurrentIterations);
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin))
{
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)
@ -23,18 +39,111 @@ public static class AdminPinService
try
{
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,
salt,
Math.Clamp(iterations, 120_000, CurrentIterations),
Math.Clamp(iterations, 120_000, LegacyPbkdf2Iterations),
HashAlgorithmName.SHA256,
expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
legacyExpected.Length);
return CryptographicOperations.FixedTimeEquals(legacyActual, legacyExpected);
}
catch (FormatException)
{
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;
}
}

View 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);
}

View 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();
}
}

View file

@ -5,7 +5,7 @@ public static class CrashLogService
private const long MaximumLogSize = 1_048_576;
private static readonly object Gate = new();
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Krona",
"StaffTerminal",
"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)
{
if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return;

View 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("\"", "\"\"")}\"";
}
}

View file

@ -1,8 +1,47 @@
using System.Text;
using libzkfpcsharp;
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 readonly object _gate = new();
@ -17,54 +56,187 @@ public sealed class FingerprintScanner : IDisposable
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
private bool _fingerOnSensor;
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>? RegistrationProgress;
event Action<int, string>? IFingerprintScanner.RegistrationCompleted
{
add => RegistrationCompleted += value;
remove => RegistrationCompleted -= value;
}
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 ScannerState State { get; private set; } = ScannerState.Disconnected;
public int DeviceCount { get; private set; }
public int ReaderIndex { 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();
SkippedTemplateCount = 0;
var result = zkfp2.Init();
if (result != 0)
lock (_gate)
{
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.
// The vendor C# sample uses GetDeviceCount() - 1 for this SDK generation.
DeviceCount = Math.Max(0, zkfp2.GetDeviceCount() - 1);
if (DeviceCount <= 0)
try
{
zkfp2.Terminate();
throw new InvalidOperationException("Подключённые сканеры не найдены.");
var rawCount = Math.Max(0, zkfp2.GetDeviceCount());
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);
var candidateIndexes = new[] { preferredIndex }
.Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex));
foreach (var candidateIndex in candidateIndexes)
if (handle == IntPtr.Zero)
{
var candidateDevice = zkfp2.OpenDevice(candidateIndex);
if (candidateDevice == IntPtr.Zero) continue;
ReaderIndex = candidateIndex;
_device = candidateDevice;
break;
continue;
}
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)
{
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();
if (_database == IntPtr.Zero)
@ -72,7 +244,10 @@ public sealed class FingerprintScanner : IDisposable
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
zkfp2.Terminate();
throw new InvalidOperationException("Не удалось создать базу распознавания.");
SetState(ScannerState.DriverError, "Не удалось инициализировать базу биометрических шаблонов.");
CrashLogService.WriteDiagnostic("zkfp2.DBInit failed.");
StartReconnectLoop();
throw new InvalidOperationException("Не удалось создать базу биометрических шаблонов.");
}
foreach (var item in templates)
@ -95,7 +270,13 @@ public sealed class FingerprintScanner : IDisposable
var device = _device;
var database = _database;
_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)
@ -107,7 +288,7 @@ public sealed class FingerprintScanner : IDisposable
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
}
StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
SetState(ScannerState.Busy, "Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
}
public void CancelRegistration()
@ -118,6 +299,9 @@ public sealed class FingerprintScanner : IDisposable
_registrationCount = 0;
_registrationExpiresAtUtc = default;
}
SetState(IsConnected ? ScannerState.Connected : ScannerState.Disconnected, "Регистрация отпечатка отменена.");
RegistrationCancelled?.Invoke();
}
private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
@ -145,15 +329,15 @@ public sealed class FingerprintScanner : IDisposable
var healthSize = 4;
if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0)
{
StatusChanged?.Invoke("Связь со сканером потеряна. Выполняется переподключение…");
ConnectionLost?.Invoke();
HandleConnectionLoss("Потеряна связь со сканером (сбой проверки состояния).");
return;
}
nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
}
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;
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
@ -163,6 +347,12 @@ public sealed class FingerprintScanner : IDisposable
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
{
_fingerOnSensor = false;
@ -175,8 +365,70 @@ public sealed class FingerprintScanner : IDisposable
CrashLogService.Write(exception);
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();
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;
_registrationCount = 0;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
SetState(ScannerState.Connected, "Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
RegistrationCancelled?.Invoke();
}
}
@ -208,7 +460,7 @@ public sealed class FingerprintScanner : IDisposable
{
_registrationFingerprintId = null;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена.");
SetState(ScannerState.Connected, "Этот палец уже зарегистрирован. Регистрация отменена.");
RegistrationCancelled?.Invoke();
return;
}
@ -216,7 +468,7 @@ public sealed class FingerprintScanner : IDisposable
if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
{
StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
SetState(ScannerState.Busy, "Это другой палец. Приложите тот же палец ещё раз.");
return;
}
@ -226,7 +478,7 @@ public sealed class FingerprintScanner : IDisposable
RegistrationProgress?.Invoke(_registrationCount);
if (_registrationCount < 3)
{
StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова.");
SetState(ScannerState.Busy, $"Скан принят: {_registrationCount}/3. Уберите палец и приложите снова.");
return;
}
@ -244,12 +496,15 @@ public sealed class FingerprintScanner : IDisposable
_registrationExpiresAtUtc = default;
if (result == 0)
{
RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
StatusChanged?.Invoke("Отпечаток успешно зарегистрирован.");
var templateBase64 = zkfp2.BlobToBase64(merged, mergedLength);
RegistrationCompleted?.Invoke(fingerprintId, templateBase64);
SetState(ScannerState.Connected, "Отпечаток успешно зарегистрирован.");
CrashLogService.WriteDiagnostic($"Fingerprint id {fingerprintId} successfully merged and enrolled.");
}
else
{
StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию.");
SetState(ScannerState.Connected, $"Не удалось объединить сканы (код {result}). Повторите регистрацию.");
CrashLogService.WriteDiagnostic($"Fingerprint enrollment merge failed: code {result}");
}
return;
}
@ -262,11 +517,18 @@ public sealed class FingerprintScanner : IDisposable
}
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()
{
_captureCancellation?.Cancel();
@ -287,7 +549,7 @@ public sealed class FingerprintScanner : IDisposable
TaskScheduler.Default);
}
throw new TimeoutException(
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения.");
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены после завершения операции.");
}
}
catch (AggregateException exception)
@ -317,11 +579,63 @@ public sealed class FingerprintScanner : IDisposable
}
zkfp2.Terminate();
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()
{
_autoReconnectEnabled = false;
StopReconnectLoop();
try
{
Disconnect();

View 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;
}

View file

@ -9,28 +9,33 @@ public sealed class NotificationService
{
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 == "Уход" ? "ушёл(ушла)" : "пришёл(пришла)";
return await SendAsync(settings, $"{employeeName} {action} в {localTime:HH:mm}");
var action = eventType.StartsWith("Уход", StringComparison.Ordinal) ? "уход" : "приход";
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 sent = false;
if (settings.TelegramEnabled)
var telegramConfigured = settings.TelegramEnabled &&
!string.IsNullOrWhiteSpace(settings.TelegramBotToken) &&
!string.IsNullOrWhiteSpace(employee.TelegramChatId);
if (telegramConfigured)
{
try
{
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)
{
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
{
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);
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
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);
}

View 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;
}
}
}

View file

@ -1,4 +1,7 @@
using System.IO;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text.Json;
using KronaStaffTerminal.Models;
using Microsoft.Win32;
@ -7,24 +10,48 @@ namespace KronaStaffTerminal.Services;
public sealed class TerminalStore
{
public const string OperatorGroupName = "KronaStaffTerminalOperators";
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private const string RegistryPath = @"Software\Krona\StaffTerminal";
private const string InitializedValueName = "StateInitialized";
private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12);
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),
"Krona",
"StaffTerminal",
"terminal-state.dat");
public string FilePath => _filePath;
public string? RecoveryNotice { get; private set; }
public TerminalStore(string? filePath = null)
{
_filePath = filePath ?? MachineFilePath;
}
public async Task<TerminalState> LoadAsync()
{
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)
{
var recovered = await TryRecoverFromBackupAsync();
@ -41,13 +68,17 @@ public sealed class TerminalStore
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
}
return new TerminalState();
return await InitializeFreshStateAsync();
}
try
{
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
}
var state = await ReadStateAsync(sourcePath);
var migrated = MigrateFingerprints(state);
var migrated = MigrateEmployees(state) | MigrateSecurity(state);
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
await SaveAsync(state);
@ -66,6 +97,7 @@ public sealed class TerminalStore
try
{
File.Move(_filePath, corruptPath, false);
ApplyRestrictedFileAcl(corruptPath);
}
catch (Exception moveException)
{
@ -108,7 +140,7 @@ public sealed class TerminalStore
try
{
var recovered = await ReadStateAsync(backupPath);
MigrateFingerprints(recovered);
MigrateEmployees(recovered);
return recovered;
}
catch (Exception backupException) when (IsCorruptStateException(backupException))
@ -125,7 +157,18 @@ public sealed class TerminalStore
private static async Task<TerminalState> ReadStateAsync(string 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)
?? throw new InvalidDataException("Файл состояния не содержит данных.");
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;
}
private static bool MigrateFingerprints(TerminalState state)
private static bool MigrateEmployees(TerminalState state)
{
var changed = false;
foreach (var employee in state.Employees)
{
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))
{
employee.Fingerprints.Add(new FingerprintRecord
{
Id = employee.FingerprintId,
Template = employee.FingerprintTemplate,
Label = "Отпечаток 1"
Label = "Отпечаток 1",
EnrolledAtUtc = DateTimeOffset.UtcNow
});
}
employee.FingerprintId = 0;
employee.FingerprintTemplate = null;
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;
}
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)
{
await _saveGate.WaitAsync();
try
{
var directory = Path.GetDirectoryName(_filePath)!;
Directory.CreateDirectory(directory);
EnsureDirectoryWithRestrictedAcl(directory);
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";
await using (var stream = new FileStream(
temporaryPath,
@ -192,6 +282,7 @@ public sealed class TerminalStore
File.Copy(_filePath, previousStatePath, true);
}
File.Move(temporaryPath, _filePath, true);
ApplyRestrictedFileAcl(_filePath);
MarkInitialized();
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)
{
var latestBackup = BackupPaths().First();
@ -244,12 +393,25 @@ public sealed class TerminalStore
private static bool WasPreviouslyInitialized()
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryPath);
return key?.GetValue(InitializedValueName) is int value && value == 1;
using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath);
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()
{
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)
?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала.");
key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);

View file

@ -2,6 +2,14 @@ using System.Runtime.InteropServices;
namespace KronaStaffTerminal.Services;
public enum TrayState
{
Normal,
NoScanner,
NoServer,
Error
}
public sealed class TrayService : IDisposable
{
private const uint CallbackMessage = 0x8001;
@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable
private const uint LrDefaultSize = 0x0040;
private const uint OpenCommand = 1001;
private const uint ExitCommand = 1002;
private const uint StatusCommand = 1003;
private const uint SyncCommand = 1004;
private readonly Action _showWindow;
private readonly Action _requestExit;
private readonly Action? _showStatus;
private readonly Action? _requestSync;
private readonly WindowProcedure _windowProcedure;
private readonly IntPtr _windowHandle;
private readonly IntPtr _iconHandle;
private NotifyIconData _iconData;
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;
_requestExit = requestExit;
_showStatus = showStatus;
_requestSync = requestSync;
_windowProcedure = HandleWindowMessage;
var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
var windowClass = new WindowClass
{
@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable
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()
{
ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1);
}
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1)
{
_iconData.Flags = NifInfo;
_iconData.InfoTitle = "Krona Staff";
_iconData.Info = "Терминал продолжает работать в трее.";
_iconData.InfoFlags = 1;
_iconData.InfoTitle = title.Length > 63 ? title[..63] : title;
_iconData.Info = message.Length > 255 ? message[..255] : message;
_iconData.InfoFlags = infoFlags;
ShellNotifyIcon(NimModify, ref _iconData);
}
@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable
{
var menu = CreatePopupMenu();
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, MfString, ExitCommand, "Выход администратора…");
GetCursorPos(out var point);
SetForegroundWindow(_windowHandle);
var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero);
DestroyMenu(menu);
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);
}

View file

@ -2,7 +2,9 @@ using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
using KronaStaffTerminal.Models;
@ -39,13 +41,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
feedUri.Scheme != Uri.UriSchemeHttps)
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException(
"Адрес обновлений должен быть защищённым 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(
request,
HttpCompletionOption.ResponseHeadersRead,
@ -79,7 +81,10 @@ public sealed class ForgejoUpdateSource : IUpdateSource
checksum is null ||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
!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(
"В релизе нет установщика Windows или файла контрольной суммы.");
@ -104,6 +109,12 @@ public sealed class ForgejoUpdateSource : IUpdateSource
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException("Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
var updateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
@ -119,7 +130,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
using var checksumRequest = CreateRequest(
HttpMethod.Get,
update.ChecksumUrl,
settings.UpdateAccessToken);
settings.UpdateAccessToken,
feedUri);
using var checksumResponse = await HttpClient.SendAsync(
checksumRequest,
cancellationToken);
@ -139,7 +151,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
using var installerRequest = CreateRequest(
HttpMethod.Get,
update.InstallerUrl,
settings.UpdateAccessToken);
settings.UpdateAccessToken,
feedUri);
using var installerResponse = await HttpClient.SendAsync(
installerRequest,
HttpCompletionOption.ResponseHeadersRead,
@ -182,6 +195,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
"Контрольная сумма обновления не совпала. Установка отменена.");
}
VerifyInstallerPublisher(temporaryPath, settings.UpdateTrustedSignerThumbprint);
File.Move(temporaryPath, installerPath, true);
return installerPath;
}
@ -199,8 +214,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
private static HttpRequestMessage CreateRequest(
HttpMethod method,
Uri uri,
string token)
string token,
Uri trustedOrigin)
{
if (!IsSameUpdateOrigin(trustedOrigin, uri))
{
throw new InvalidDataException("Forgejo вернул файл обновления с недоверенного адреса.");
}
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(token))
{
@ -212,11 +232,58 @@ public sealed class ForgejoUpdateSource : IUpdateSource
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");
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)
{
var normalized = (value ?? "")
@ -258,4 +325,66 @@ public sealed class ForgejoUpdateSource : IUpdateSource
[JsonPropertyName("browser_download_url")]
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;
}
}
}

View file

@ -4,14 +4,16 @@ services:
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: ${PORT:-8080}
HOST: 0.0.0.0
LOG_LEVEL: ${LOG_LEVEL:-info}
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
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
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}
ports: ['8080:8080']
depends_on:

View 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=

View 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
View 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
View 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:

View 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

View 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

View 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
View 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

View 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
View 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`.

View file

@ -7,3 +7,101 @@ Restore runbook: остановить записи, проверить checksum/
Тест восстановления обязателен по расписанию в изолированной среде. До принятия RPO/RTO и retention документ является архитектурным шаблоном, а не обещанием показателей.
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.

View file

@ -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 не включаются до появления реального сценария очередей или файлов.
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.

View 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`.

View 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.

View 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 |

View 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).

View 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 ГК РФ), не элемент оплаты труда. Распределение общего котла — внутренняя договорённость; в расчёт зарплаты попадает как отдельный вид начисления.
- **Самозанятые и ГПХ.** «Корректировки вознаграждения» в доставке работают для ГПХ, но для штатных работников по трудовому договору такая модель незаконна. Правило, написанное для курьеров-самозанятых, нельзя механически переносить на штат.
- **Возврат и дебиторка в продажах.** Прямого удержания нет; работающая механика — не включать сделку в базу бонуса до поступления денег и уменьшать базу следующего периода при возврате.

View 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
View 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. Выплаты: только наличные или бывают переводы, и нужно ли фиксировать, кто выдал деньги?

View file

@ -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.
Подтверждённый Payroll foundation: чистый движок правил, отраслевые пресеты, недельные периоды, распределение фонда, типизированные часы, схема версионированных правил/ведомостей/выплат, append-only ledger и тесты на примерах владельца.
## Следующий этап — только после требований владельца
1. Уточнить jurisdiction, terminology, data classification and ownership.
2. Согласовать Employee Directory и Employment workflows, затем создать первую vertical slice.
3. Отдельно описать Organization Structure, Scheduling, Time & Attendance, Leave, Payroll, Documents, Audit/Reporting.
4. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
5. Утвердить event catalog/OpenAPI changes до публикации.
6. Утвердить backup RPO/RTO/retention и провести documented restore drill.
1. Реализовать API и административный интерфейс подразделений, показателей, правил оплаты и ведомостей поверх готового доменного ядра.
2. Связать подтверждённые смены терминала с `WorkSession`, сохранив ручную корректировку и аудит.
3. Добавить импорт и сверку с таблицами владельца перед переходом на расчёт в модуле.
4. Подключить мобильный кабинет сотрудника только к серверному API — без формул и расчёта на устройстве.
5. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
6. Отдельно согласовать официальный расчёт, Scheduling, Leave, Documents и Audit/Reporting.
7. Утвердить backup RPO/RTO/retention и провести documented restore drill.
Никакой пункт не означает автоматически готовую бизнес-функцию или срок.

156
docs/RUN-MANUAL-TESTING.md Normal file
View 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
View 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 или общую базу.

View 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.

View 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`; редактировать ставки без версий; переносить шаблоны отпечатков на сервер; ждать готовности рантайма модулей платформы, ничего не делая.

View 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 |

View 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). Их можно принимать следующими.
Стенды удалены, база остановлена. В этой приёмке ничего не влито и не запушено.

View 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 не останется пересечений с синхронизацией.

View 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 добавляются в этот файл после их проверки.

View 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` на случай сверки.

View 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).

View 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`.

View 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, что осталось.

View 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 — что сделано — коммит — тест» и список того, что не удалось, с причиной.

View 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 теперь воспроизводятся по существу).

View 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 — что сделано — коммит — тест» и список того, что не удалось, с причиной.

View 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 — цвет — фактический вывод», приёмочные таблицы по веткам исполнителей.

View 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`;
- что не запускалось и почему.

View 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 исполнителей.

View 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 эти строки нужно удалить.

View file

@ -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 ₽)

View 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)

View file

@ -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)

View file

@ -0,0 +1,18 @@
Checking formatting...
[warn] apps/KronaStaffService/README.md
[warn] deploy/pilot/compose.yaml
[warn] docs/DRIVER-DISTRIBUTION.md
[warn] docs/HARDWARE-TEST-SLK20R.md
[warn] docs/TERMINAL-API-REQUIREMENTS.md
[warn] scripts/check-migrations.mjs
[warn] src/adapters/identity.ts
[warn] src/app.ts
[warn] src/config.ts
[warn] src/domain/attendance/work-session.ts
[warn] src/domain/notification/web-push.ts
[warn] src/portal.ts
[warn] tests/unit/config.test.ts
[warn] tests/unit/employee-push.test.ts
[warn] tests/unit/tenant-sql.test.ts
[warn] tests/unit/work-session.test.ts
[warn] Code style issues found in 16 files. Run Prettier with --write to fix.

View 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"

View 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));

View 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();
}

View 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 ₽)

View file

@ -2,9 +2,10 @@ Unicode True
SetCompressor /SOLID lzma
!include "MUI2.nsh"
!include "x64.nsh"
!ifndef APP_VERSION
!define APP_VERSION "0.2.0"
!define APP_VERSION "0.3.2"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"
@ -18,14 +19,17 @@ SetCompressor /SOLID lzma
!define APP_NAME "Krona Staff Terminal"
!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 DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
Name "${APP_NAME}"
OutFile "${OUTPUT_DIR}\KronaStaffTerminal-Setup-${APP_VERSION}.exe"
InstallDir "$LOCALAPPDATA\Programs\Krona Staff Terminal"
InstallDirRegKey HKCU "${APP_REGISTRY_KEY}" "InstallLocation"
RequestExecutionLevel user
InstallDir "$PROGRAMFILES64\Krona Staff Terminal"
InstallDirRegKey HKLM "${APP_REGISTRY_KEY}" "InstallLocation"
RequestExecutionLevel admin
Icon "${PROJECT_ROOT}\apps\KronaStaffTerminal\Assets\AppIcon.ico"
BrandingText "Krona"
ShowInstDetails show
@ -58,33 +62,114 @@ VIAddVersionKey /LANG=1049 "ProductVersion" "${APP_VERSION}"
!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
SectionIn RO
SetShellVarContext current
SetShellVarContext all
DetailPrint "Остановка службы ${SERVICE_NAME} (если запущена)..."
ExecWait 'sc.exe stop ${SERVICE_NAME}'
Sleep 500
SetOutPath "$INSTDIR"
SetOverwrite try
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"
CreateDirectory "$SMPROGRAMS\Krona"
CreateShortcut "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "Publisher" "Krona"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
WriteRegStr HKCU "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${APP_REGISTRY_KEY}" "NoRepair" 1
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayVersion" "${APP_VERSION}"
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "Publisher" "Krona"
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
WriteRegStr HKLM "${APP_REGISTRY_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
WriteRegDWORD HKLM "${APP_REGISTRY_KEY}" "NoModify" 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
Section "Значок на рабочем столе" SecDesktop
SetShellVarContext current
SetShellVarContext all
CreateShortcut "$DESKTOP\Krona Staff Terminal.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
SectionEnd
LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода."
LangString DESC_SecMain ${LANG_RUSSIAN} "Основная программа учёта прихода и ухода со службой синхронизации."
LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок программы на рабочем столе."
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecMain} $(DESC_SecMain)
@ -92,10 +177,23 @@ LangString DESC_SecDesktop ${LANG_RUSSIAN} "Создать значок прог
!insertmacro MUI_FUNCTION_DESCRIPTION_END
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 "$SMPROGRAMS\Krona\Krona Staff Terminal.lnk"
RMDir "$SMPROGRAMS\Krona"
DeleteRegKey HKCU "${APP_REGISTRY_KEY}"
DeleteRegKey HKLM "${APP_REGISTRY_KEY}"
RMDir /r "$INSTDIR"
SectionEnd

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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.';

View 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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS staff_notification_deliveries;
DROP TABLE IF EXISTS staff_push_subscriptions;

View 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;

View 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