From 3464998168c45d29fdaedc6471bc8161faddfd07 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 15:59:38 +0700 Subject: [PATCH] feat: add secure attendance portal and payroll foundation --- .gitignore | 1 + README.md | 10 +- api/openapi.yaml | 102 ++- apps/KronaStaffTerminal/MainPage.xaml | 24 + apps/KronaStaffTerminal/MainPage.xaml.cs | 38 +- .../Models/TerminalModels.cs | 7 + .../Services/AttendanceSyncService.cs | 90 +++ .../Services/UpdateService.cs | 143 +++- compose.yaml | 2 + deploy/pilot/compose.yaml | 78 ++ deploy/pilot/setup.sh | 44 + deploy/pilot/smoke-test.sh | 19 + docs/APP-CORE.md | 116 +++ docs/PAYROLL-LEGAL-LIMITS.md | 45 ++ docs/PAYROLL-SCHEMES-CATALOGUE.md | 131 +++ docs/REQ-001-PAYROLL.md | 131 +++ docs/ROADMAP.md | 15 +- ...2-payroll-domain-and-platform-alignment.md | 81 ++ ...4_revoke_broad_default_privileges.down.sql | 4 + ...004_revoke_broad_default_privileges.up.sql | 4 + migrations/0005_attendance_portal.down.sql | 3 + migrations/0005_attendance_portal.up.sql | 82 ++ migrations/0006_payroll_foundation.down.sql | 25 + migrations/0006_payroll_foundation.up.sql | 302 +++++++ module.json | 41 +- schemas/module.schema.json | 2 +- schemas/settings.schema.schema.json | 1 + scripts/check-migrations.mjs | 7 + scripts/configure-terminal-pilot.ps1 | 52 ++ scripts/create-app-role.mjs | 2 +- settings.schema.json | 50 ++ src/adapters/identity.ts | 37 +- src/app.ts | 25 +- src/config.ts | 18 + src/domain/payroll/calculator.ts | 287 +++++++ src/domain/payroll/period.ts | 59 ++ src/domain/payroll/presets.ts | 632 +++++++++++++++ src/domain/payroll/rule.ts | 757 ++++++++++++++++++ src/portal-ui.ts | 82 ++ src/portal.ts | 295 +++++++ src/server.ts | 2 +- tests/integration/openapi-runtime.test.ts | 10 +- tests/integration/portal.test.ts | 137 ++++ tests/unit/payroll-calculator.test.ts | 246 ++++++ tests/unit/payroll-industry.test.ts | 150 ++++ tests/unit/payroll-rule.test.ts | 241 ++++++ tests/unit/tenant-sql.test.ts | 68 ++ 47 files changed, 4663 insertions(+), 35 deletions(-) create mode 100644 apps/KronaStaffTerminal/Services/AttendanceSyncService.cs create mode 100644 deploy/pilot/compose.yaml create mode 100644 deploy/pilot/setup.sh create mode 100644 deploy/pilot/smoke-test.sh create mode 100644 docs/APP-CORE.md create mode 100644 docs/PAYROLL-LEGAL-LIMITS.md create mode 100644 docs/PAYROLL-SCHEMES-CATALOGUE.md create mode 100644 docs/REQ-001-PAYROLL.md create mode 100644 docs/adr/ADR-002-payroll-domain-and-platform-alignment.md create mode 100644 migrations/0004_revoke_broad_default_privileges.down.sql create mode 100644 migrations/0004_revoke_broad_default_privileges.up.sql create mode 100644 migrations/0005_attendance_portal.down.sql create mode 100644 migrations/0005_attendance_portal.up.sql create mode 100644 migrations/0006_payroll_foundation.down.sql create mode 100644 migrations/0006_payroll_foundation.up.sql create mode 100644 scripts/configure-terminal-pilot.ps1 create mode 100644 src/domain/payroll/calculator.ts create mode 100644 src/domain/payroll/period.ts create mode 100644 src/domain/payroll/presets.ts create mode 100644 src/domain/payroll/rule.ts create mode 100644 src/portal-ui.ts create mode 100644 src/portal.ts create mode 100644 tests/integration/portal.test.ts create mode 100644 tests/unit/payroll-calculator.test.ts create mode 100644 tests/unit/payroll-industry.test.ts create mode 100644 tests/unit/payroll-rule.test.ts diff --git a/.gitignore b/.gitignore index 196613b..619fe86 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ coverage/ .env +deploy/pilot/secrets/ *.log .idea/ .vscode/ diff --git a/README.md b/README.md index 28e5501..9ab3a19 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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. ## Быстрый старт @@ -24,6 +24,12 @@ docker compose up --build app postgres Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.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). + ## Биометрический терминал Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует несколько отпечатков по трём сканам, учитывает приход/уход и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md). diff --git a/api/openapi.yaml b/api/openapi.yaml index 655efc8..c18a19e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Krona Staff API version: 0.2.5 - description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved. + description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя. license: name: Proprietary url: https://git.ochenstarik.ru/ochenstarik/krona-staff/src/branch/main/LICENSE @@ -47,7 +47,107 @@ paths: application/json: schema: { $ref: '#/components/schemas/Version' } '400': { $ref: '#/components/responses/BadRequest' } + /session: + post: + summary: Открыть защищённую сессию администратора + operationId: createStaffSession + tags: [Authentication] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [access_token] + properties: + access_token: { type: string, minLength: 32, maxLength: 4096, writeOnly: true } + responses: + '200': + description: Сессия открыта; токен перенесён в HttpOnly Secure cookie + '401': { description: Ключ недействителен } + '403': { description: Ключ не имеет прав администратора } + delete: + summary: Завершить сессию администратора + operationId: deleteStaffSession + tags: [Authentication] + responses: + '204': { description: Сессия завершена } + '403': { description: Запрос с постороннего сайта отклонён } + /portal: + get: + summary: Получить доступную пользователю сводку посещаемости и табеля + operationId: getStaffPortal + tags: [Attendance] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Сводка с автоматическим ограничением данными текущего сотрудника + content: + application/json: + schema: { type: object } + '403': { description: Недостаточно прав } + /attendance/events: + post: + summary: Принять идемпотентную отметку терминала + operationId: recordAttendanceEvent + tags: [Attendance] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [employee_id, event_type, occurred_at, source_event_id] + properties: + event_id: { $ref: '#/components/schemas/Ulid' } + employee_id: { $ref: '#/components/schemas/Ulid' } + event_type: { enum: [check_in, check_out] } + occurred_at: { type: string, format: date-time } + source_event_id: { type: string, minLength: 1, maxLength: 200 } + device_id: { $ref: '#/components/schemas/Ulid' } + responses: + '201': { description: Отметка принята } + '200': { description: Повтор уже принятой отметки } + '403': { description: Недостаточно прав } + /timesheets/{employeeId}/{workDate}: + put: + summary: Сохранить строку табеля + operationId: saveTimesheetEntry + tags: [Timesheet] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: employeeId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + - in: path + name: workDate + required: true + schema: { type: string, format: date } + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [minutes_worked, status] + properties: + minutes_worked: { type: integer, minimum: 0, maximum: 1440 } + status: { enum: [draft, submitted, approved, rejected] } + note: { type: string, maxLength: 1000 } + responses: + '200': { description: Строка табеля сохранена } + '403': { description: Недостаточно прав } components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT responses: BadRequest: description: Invalid request diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml index e581853..69c4525 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml +++ b/apps/KronaStaffTerminal/MainPage.xaml @@ -265,12 +265,36 @@ + + + + + Сессия действует 8 часов и хранится только в защищённой cookie. + + + + + +`; + +export const portalCss = `:root{--bg:#EFE3CC;--workspace:#F3EAD7;--surface:#F8F2E6;--raised:#FCF8EF;--text:#302A23;--muted:#756A5B;--border:#D9C9AC;--primary:#657255;--gold:#B78335;--danger:#A44E3F;--info:#5F7480;--shadow:0 14px 40px rgba(48,42,35,.09);font-family:Inter,"Segoe UI",system-ui,sans-serif;color:var(--text);background:var(--workspace);font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 85% 0,rgba(183,131,53,.12),transparent 28rem),var(--workspace)}button,input,select{font:inherit}.topbar{height:72px;display:flex;align-items:center;justify-content:space-between;padding:0 max(20px,calc((100vw - 1240px)/2));background:rgba(248,242,230,.94);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5;backdrop-filter:blur(14px)}.brand{display:flex;align-items:center;gap:12px;color:var(--text);text-decoration:none;font-size:19px;letter-spacing:.01em}.mark{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(145deg,var(--gold),#8a642f);color:white;font-family:Georgia,serif;font-size:22px;box-shadow:0 6px 16px rgba(154,116,61,.22)}.actions{display:flex;align-items:center;gap:10px}.token{width:180px;min-height:44px;border:1px solid var(--border);border-radius:10px;background:var(--raised);padding:0 12px;color:var(--text)}.status{font-size:13px;color:var(--muted);margin-right:4px}.status.ok{color:var(--primary)}button{min-height:44px;padding:0 16px;border:0;border-radius:10px;background:var(--primary);color:white;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid rgba(95,116,128,.35);outline-offset:2px}.quiet{background:transparent;color:var(--primary);border:1px solid var(--border)}main{max-width:1240px;margin:auto;padding:36px 20px 56px}.hero{display:grid;grid-template-columns:1fr auto;gap:28px;align-items:end;padding:34px 38px;border-radius:16px;background:linear-gradient(120deg,#3b4536,#657255);color:#fff;box-shadow:var(--shadow)}.hero h1{margin:4px 0 10px;font:500 clamp(30px,4vw,52px)/1.04 Georgia,serif;max-width:720px}.hero p{margin:0;color:rgba(255,255,255,.75)}.eyebrow{text-transform:uppercase;letter-spacing:.13em;font-size:11px!important;font-weight:750;color:var(--gold)!important}.today{min-width:185px;padding:18px 20px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.18);border-radius:12px}.today span,.today small{display:block;color:rgba(255,255,255,.7)}.today strong{display:block;font:500 25px/1.3 Georgia,serif;margin:3px 0}.message{margin-top:18px;padding:14px 16px;background:#fff1ee;border:1px solid #dfb2aa;border-radius:10px;color:#78382d}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:24px 0}.stats article,.panel{background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 5px 18px rgba(48,42,35,.045)}.stats article{padding:20px 22px}.stats span{display:block;color:var(--muted);font-size:13px}.stats strong{font:500 30px/1.2 Georgia,serif}.layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(270px,.75fr);gap:18px}.panel{padding:22px}.panel-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:18px}.panel h2{font:500 25px/1.2 Georgia,serif;margin:2px 0}.panel-head span{font-size:12px;color:var(--muted)}.live{color:var(--primary)!important}.feed,.people{display:grid;gap:8px}.event,.person{display:grid;align-items:center;gap:12px;padding:13px 14px;border-radius:10px;background:var(--raised)}.event{grid-template-columns:42px 1fr auto}.person{grid-template-columns:38px 1fr}.avatar,.event-icon{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;background:#e7dcc4;color:var(--gold);font-weight:750}.event-icon{border-radius:10px}.event.out .event-icon{background:#e4e7df;color:var(--primary)}.event strong,.person strong{display:block}.event small,.person small{color:var(--muted)}.event time{font-variant-numeric:tabular-nums;color:var(--muted);font-size:13px}.empty{padding:24px;text-align:center;color:var(--muted);background:var(--raised);border-radius:10px}.timesheet-panel{margin-top:18px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:760px}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--border);font-size:14px}th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}td input,td select{width:100%;min-height:40px;border:1px solid var(--border);border-radius:8px;background:var(--raised);color:var(--text);padding:7px 9px}td button{min-height:40px}.tag{display:inline-flex;padding:5px 9px;border-radius:99px;background:#e5e8df;color:var(--primary);font-size:12px}footer{text-align:center;padding:24px;color:var(--muted);font-size:12px}@media(max-width:800px){.topbar{height:auto;min-height:68px;padding:12px 16px;flex-wrap:wrap}.actions{flex-wrap:wrap;justify-content:flex-end}.status,#notify{display:none}.token{width:150px}.hero{grid-template-columns:1fr;padding:26px 22px}.today{min-width:0}.stats{grid-template-columns:1fr 1fr}.stats article:last-child{grid-column:1/-1}.layout{grid-template-columns:1fr}.people-panel{order:-1}main{padding:22px 14px 42px}.panel{padding:18px}.panel-head{align-items:center}.live{display:none}}@media(prefers-reduced-motion:no-preference){.panel,.stats article{transition:transform .18s ease,box-shadow .18s ease}.panel:hover,.stats article:hover{transform:translateY(-1px);box-shadow:var(--shadow)}}`; + +export const portalJs = `(() => { + const api = '/api/v1/staff'; let latestId = null; let viewerRole = 'employee'; + const el = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const localDate = value => new Intl.DateTimeFormat('ru-RU',{day:'2-digit',month:'short',year:'numeric'}).format(new Date(value)); + const localTime = value => new Intl.DateTimeFormat('ru-RU',{hour:'2-digit',minute:'2-digit'}).format(new Date(value)); + el('today').textContent = new Intl.DateTimeFormat('ru-RU',{weekday:'long',day:'numeric',month:'long'}).format(new Date()); + const jsonHeaders = {'content-type':'application/json'}; + function showLogin(message){el('app-shell').hidden=true;el('auth-screen').hidden=false;el('login-error').textContent=message||'';el('login-error').hidden=!message;setTimeout(()=>el('login-token').focus(),0);} + function showApp(){el('auth-screen').hidden=true;el('app-shell').hidden=false;} + async function load(notify){ try { const response=await fetch(api+'/portal',{credentials:'same-origin',cache:'no-store'}); if(response.status===401){showLogin('Введите ключ администратора.');return;} if(!response.ok) throw new Error('Сервер вернул '+response.status); const data=await response.json(); showApp(); render(data,notify); el('connection').textContent='Синхронизировано'; el('connection').className='status ok'; el('message').hidden=true; } catch(error){if(el('app-shell').hidden){showLogin('Сервер сейчас недоступен. Повторите попытку позже.');return;}el('connection').textContent='Нет связи';el('connection').className='status';el('message').textContent='Не удалось загрузить данные. Проверьте подключение.';el('message').hidden=false;} } + function render(data,notify){ viewerRole=data.viewer.role; el('viewer').textContent=viewerRole==='manager'?'Кабинет руководителя':'Кабинет сотрудника'; el('greeting').textContent=viewerRole==='manager'?'Команда и рабочее время':'Ваш рабочий день'; el('last-refresh').textContent='Обновлено '+localTime(data.refreshed_at); el('employees-count').textContent=data.employees.length; const today=new Date().toDateString(); const todayEvents=data.attendance.filter(x=>new Date(x.occurred_at).toDateString()===today); el('today-count').textContent=todayEvents.length; el('hours-count').textContent=(data.timesheet.reduce((sum,x)=>sum+Number(x.minutes_worked),0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}); + const first=data.attendance[0]; if(notify&&first&&latestId&&first.id!==latestId&&Notification.permission==='granted') new Notification('Krona Staff',{body:first.display_name+': '+(first.event_type==='check_in'?'приход':'уход')+' в '+localTime(first.occurred_at),tag:first.id}); if(first) latestId=first.id; + el('attendance').innerHTML=data.attendance.length?data.attendance.slice(0,20).map(x=>'
'+(x.event_type==='check_in'?'↘':'↗')+'
'+esc(x.display_name)+''+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'
').join(''):'
Отметок пока нет
'; + el('employees').innerHTML=data.employees.length?data.employees.map(x=>'
'+esc(x.display_name).slice(0,1).toUpperCase()+'
'+esc(x.display_name)+''+esc(x.position||'Должность не указана')+'
').join(''):'
Сотрудники не найдены
'; + el('timesheet-help').textContent=viewerRole==='manager'?'Можно редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'Табель пока пуст'; bindSaves(); } + function timesheetRow(row){const editable=viewerRole==='manager'; const statuses={draft:'Черновик',submitted:'На проверке',approved:'Утверждён',rejected:'Возвращён'}; return ''+localDate(row.work_date)+''+esc(row.display_name)+''+(editable?'':(Number(row.minutes_worked)/60).toLocaleString('ru-RU'))+''+(editable?'':''+esc(statuses[row.status]||row.status)+'')+''+(editable?'':esc(row.note||'—'))+''+(editable?'':'')+'';} + function bindSaves(){document.querySelectorAll('.save').forEach(button=>button.addEventListener('click',async()=>{const row=button.closest('tr'); button.disabled=true; try{const body={minutes_worked:Math.round(Number(row.querySelector('.hours').value)*60),status:row.querySelector('.state').value,note:row.querySelector('.note').value};const response=await fetch(api+'/timesheets/'+row.dataset.employee+'/'+row.dataset.date,{method:'PUT',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok)throw new Error();button.textContent='Сохранено';setTimeout(()=>button.textContent='Сохранить',1400);}catch{button.textContent='Ошибка';}finally{button.disabled=false;}}));} + el('login-form').addEventListener('submit',async event=>{event.preventDefault();const button=el('login-submit');const token=el('login-token').value.trim();button.disabled=true;el('login-error').hidden=true;try{const response=await fetch(api+'/session',{method:'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify({access_token:token})});if(!response.ok){showLogin(response.status===401||response.status===403?'Неверный ключ администратора.':'Не удалось выполнить вход.');return;}el('login-token').value='';await load(false);}catch{showLogin('Сервер сейчас недоступен.');}finally{button.disabled=false;}}); + el('logout').addEventListener('click',async()=>{try{await fetch(api+'/session',{method:'DELETE',credentials:'same-origin'});}finally{latestId=null;showLogin('Вы вышли из кабинета.');}}); + el('refresh').addEventListener('click',()=>load(false)); el('notify').addEventListener('click',async()=>{if(!('Notification' in window)){el('message').textContent='Этот браузер не поддерживает системные уведомления.';el('message').hidden=false;return;} const permission=await Notification.requestPermission(); el('notify').textContent=permission==='granted'?'Уведомления включены':'Уведомления запрещены';}); + if('serviceWorker' in navigator) navigator.serviceWorker.register('/staff/sw.js'); load(false); setInterval(()=>{if(!el('app-shell').hidden)load(true);},15000); +})();`; + +export const portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', lang: 'ru' }); +export const portalServiceWorker = `const CACHE='krona-staff-v1';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`; diff --git a/src/portal.ts b/src/portal.ts new file mode 100644 index 0000000..151ffdb --- /dev/null +++ b/src/portal.ts @@ -0,0 +1,295 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { AppConfig } from './config.js'; +import type { RequestContext } from './core/context.js'; +import { newUlid, parseUlid } from './core/ids.js'; +import type { IdentityPort } from './core/ports.js'; +import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; +import type { Database } from './infrastructure/database.js'; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; + +interface AttendanceBody { + event_id?: string; + employee_id: string; + event_type: 'check_in' | 'check_out'; + occurred_at: string; + source_event_id: string; + device_id?: string; +} + +interface TimesheetBody { + minutes_worked: number; + status: 'draft' | 'submitted' | 'approved' | 'rejected'; + note?: string; +} + +interface SessionBody { + access_token: string; +} + +const sessionCookieName = '__Host-krona_staff_session'; +const sessionMaxAgeSeconds = 8 * 60 * 60; + +export async function registerPortalRoutes( + api: FastifyInstance, + config: AppConfig, + database: Database, +) { + const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' + ? new PlatformIdentityAdapter() + : new StandaloneIdentityAdapter(config); + + api.post<{ Body: SessionBody }>('/session', { + schema: { + body: { + type: 'object', additionalProperties: false, + required: ['access_token'], + properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } }, + }, + }, + }, async (request, reply) => { + assertSameOrigin(request); + if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { + return reply.serviceUnavailable('Вход администратора ещё не настроен.'); + } + const context = await resolveToken(identity, request.body.access_token, request); + if (!context.permissions.has('staff:attendance:manage')) { + return reply.forbidden('Для входа требуется ключ администратора.'); + } + reply + .header('cache-control', 'no-store') + .header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds)); + return { role: 'manager', expires_in: sessionMaxAgeSeconds }; + }); + + api.delete('/session', async (request, reply) => { + assertSameOrigin(request); + reply + .header('cache-control', 'no-store') + .header('set-cookie', serializeSessionCookie('', 0)); + return reply.code(204).send(); + }); + + api.get('/portal', async (request, reply) => { + reply.header('cache-control', 'no-store'); + const context = await resolveContext(identity, request); + const canManage = context.permissions.has('staff:attendance:manage'); + if (!canManage && !context.permissions.has('staff:attendance:read_own')) { + return reply.forbidden('Недостаточно прав для просмотра посещаемости.'); + } + + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const employees = await client.query( + `SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status + FROM staff_employees e + WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2) + ORDER BY e.display_name`, + [canManage, context.actorId], + ); + const attendance = await client.query( + `SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source, + a.occurred_at, a.received_at, a.status + FROM staff_attendance_events a + JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id + WHERE ($1::boolean OR e.core_user_id = $2) + ORDER BY a.occurred_at DESC LIMIT 100`, + [canManage, context.actorId], + ); + const timesheet = await client.query( + `SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked, + t.status, t.note, t.revision, t.updated_at + FROM staff_timesheet_entries t + JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id + WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days') + AND ($1::boolean OR e.core_user_id = $2) + ORDER BY t.work_date DESC, e.display_name`, + [canManage, context.actorId], + ); + return { + viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' }, + employees: employees.rows, + attendance: attendance.rows, + timesheet: timesheet.rows, + refreshed_at: new Date().toISOString(), + }; + }); + }); + + api.post<{ Body: AttendanceBody }>('/attendance/events', { + schema: { + body: { + type: 'object', additionalProperties: false, + required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'], + properties: { + event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + event_type: { enum: ['check_in', 'check_out'] }, + occurred_at: { type: 'string', format: 'date-time' }, + source_event_id: { type: 'string', minLength: 1, maxLength: 200 }, + device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, + }, + }, + }, async (request, reply) => { + const context = await requirePermission(identity, request, 'staff:attendance:clock'); + const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid(); + const employeeId = parseUlid(request.body.employee_id); + const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null; + const occurredAt = new Date(request.body.occurred_at); + if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.'); + const now = Date.now(); + if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) { + return reply.badRequest('Время отметки выходит за допустимый период пилота.'); + } + + const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + await client.query( + `INSERT INTO staff_employees + (id, tenant_id, personnel_number, display_name, position, gender, status) + VALUES ($1, $2, $3, $4, '', '', 'active') + ON CONFLICT (id) DO NOTHING`, + [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`], + ); + const inserted = await client.query( + `INSERT INTO staff_attendance_events + (id, tenant_id, employee_id, event_type, source, source_event_id, + occurred_at, device_id, actor_id, correlation_id) + VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9) + ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`, + [eventId, context.tenantId, employeeId, request.body.event_type, + request.body.source_event_id, occurredAt.toISOString(), deviceId, + context.actorId, context.correlationId], + ); + if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; + + const eventType = request.body.event_type === 'check_in' + ? 'attendance.clock.in' + : 'attendance.clock.out'; + await client.query( + `INSERT INTO staff_outbox + (id, tenant_id, event_type, event_version, subject_type, subject_id, + actor_type, actor_id, payload, correlation_id, occurred_at) + VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`, + [newUlid(), context.tenantId, eventType, employeeId, context.actorId, + { attendance_event_id: eventId, channel: 'platform_notification' }, + context.correlationId, occurredAt.toISOString()], + ); + return { id: eventId, duplicate: false }; + }); + return reply.code(result.duplicate ? 200 : 201).send(result); + }); + + api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>( + '/timesheets/:employeeId/:workDate', + { + schema: { + params: { type: 'object', required: ['employeeId', 'workDate'], properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + } }, + body: { type: 'object', additionalProperties: false, + required: ['minutes_worked', 'status'], properties: { + minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 }, + status: { enum: ['draft', 'submitted', 'approved', 'rejected'] }, + note: { type: 'string', maxLength: 1000 }, + } }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:attendance:manage'); + if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.'); + const employeeId = parseUlid(request.params.employeeId); + if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) { + return reply.forbidden('Для утверждения табеля требуется отдельное право.'); + } + + const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const saved = await client.query( + `INSERT INTO staff_timesheet_entries + (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET + minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status, + note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1, + updated_by = EXCLUDED.updated_by, updated_at = now() + RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`, + [newUlid(), context.tenantId, employeeId, request.params.workDate, + request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId], + ); + const result = saved.rows[0] as Record; + await client.query( + `INSERT INTO staff_audit_log + (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) + VALUES ($1, $2, $3, 'timesheet.entry.saved', 'TimesheetEntry', $4, $5, $6)`, + [newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId], + ); + return result; + }); + return reply.send(row); + }, + ); +} + +async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise { + const authorization = request.headers.authorization; + const token = authorization?.startsWith('Bearer ') + ? authorization.slice(7) + : readCookie(request.headers.cookie, sessionCookieName); + return resolveToken(identity, token, request); +} + +async function resolveToken( + identity: IdentityPort, + token: string | undefined, + request: FastifyRequest, +): Promise { + try { + return await identity.resolve(token, parseUlid(request.id)); + } catch { + throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа.'); + } +} + +function readCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(';')) { + const separator = part.indexOf('='); + if (separator < 0 || part.slice(0, separator).trim() !== name) continue; + try { + return decodeURIComponent(part.slice(separator + 1).trim()); + } catch { + return undefined; + } + } + return undefined; +} + +function serializeSessionCookie(value: string, maxAge: number): string { + return `${sessionCookieName}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Strict`; +} + +function assertSameOrigin(request: FastifyRequest) { + const origin = request.headers.origin; + if (!origin) return; + try { + const parsed = new URL(origin); + if (parsed.protocol !== 'https:' || parsed.host !== request.headers.host) throw new Error(); + } catch { + throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.'); + } +} + +function assertCookieMutationOrigin(request: FastifyRequest) { + if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) { + assertSameOrigin(request); + } +} + +async function requirePermission(identity: IdentityPort, request: FastifyRequest, permission: string) { + const context = await resolveContext(identity, request); + if (!context.permissions.has(permission)) { + throw request.server.httpErrors.forbidden('Недостаточно прав для этой операции.'); + } + return context; +} diff --git a/src/server.ts b/src/server.ts index 05a80a2..f967681 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,4 +28,4 @@ const shutdown = (signal: string) => { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); -await app.listen({ host: '0.0.0.0', port: config.PORT }); +await app.listen({ host: config.HOST, port: config.PORT }); diff --git a/tests/integration/openapi-runtime.test.ts b/tests/integration/openapi-runtime.test.ts index 5980174..ece494a 100644 --- a/tests/integration/openapi-runtime.test.ts +++ b/tests/integration/openapi-runtime.test.ts @@ -46,11 +46,17 @@ describe('OpenAPI runtime contract', () => { if (!prefix) throw new Error('OpenAPI server prefix is missing'); const documentedPaths = Object.keys(documented.paths).sort(); const runtimePaths = Object.keys(runtime.paths ?? {}) - .filter((path) => path !== '/' && path !== '/openapi.json') + .filter((path) => path !== '/' && path !== '/openapi.json' && !path.startsWith('/staff')) .sort(); expect(runtimePaths).toEqual(documentedPaths); for (const path of documentedPaths) { - expect((await app.inject(`${prefix}${path}`)).statusCode).not.toBe(404); + const runtimePath = path.replace('{employeeId}', '01J00000000000000000000000').replace('{workDate}', '2026-09-24'); + const operations = documented.paths[path] as Record; + const methods = ['get', 'post', 'put', 'patch', 'delete'] as const; + const method = methods.find((candidate) => candidate in operations); + if (!method) throw new Error(`No HTTP operation documented for ${path}`); + const response = await app.inject({ method, url: `${prefix}${runtimePath}` }); + expect(response.statusCode).not.toBe(404); } await app.close(); }); diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts new file mode 100644 index 0000000..c5f6e52 --- /dev/null +++ b/tests/integration/portal.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildApp } from '../../src/app.js'; +import { loadConfig } from '../../src/config.js'; + +const base = { + DATABASE_URL: 'postgres://localhost/staff', + STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_ACTOR_ID: '01J00000000000000000000001', + LOG_LEVEL: 'silent', + NODE_ENV: 'test', +} as const; + +const managerToken = 'm'.repeat(64); +const terminalToken = 't'.repeat(64); +const tokenConfig = { + STANDALONE_MANAGER_TOKEN: managerToken, + STANDALONE_TERMINAL_TOKEN: terminalToken, +} as const; + +function fakeDatabase(rows: Array>>) { + const calls: Array<[string, readonly unknown[] | undefined]> = []; + const query = async (sql: string, parameters?: readonly unknown[]) => { + calls.push([sql, parameters]); + return { rows: rows.shift() ?? [], rowCount: 1 }; + }; + return { + calls, + database: { + ping: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + tenantTransaction: async ( + _tenant: string, + _actor: string, + run: (client: { query: typeof query }) => Promise, + ) => run({ query }), + } as never, + }; +} + +describe('attendance portal', () => { + it('requires authentication when standalone tokens are configured', async () => { + const { database } = fakeDatabase([]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + + const response = await app.inject('/api/v1/staff/portal'); + + expect(response.statusCode).toBe(401); + await app.close(); + }); + + it('creates an HttpOnly manager session and accepts its secure cookie', async () => { + const { database } = fakeDatabase([[], [], []]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + + const login = await app.inject({ + method: 'POST', + url: '/api/v1/staff/session', + payload: { access_token: managerToken }, + }); + const setCookie = login.headers['set-cookie']; + expect(login.statusCode).toBe(200); + expect(setCookie).toContain('__Host-krona_staff_session='); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('SameSite=Strict'); + + const response = await app.inject({ + url: '/api/v1/staff/portal', + headers: { cookie: String(setCookie).split(';')[0] }, + }); + expect(response.statusCode).toBe(200); + expect(response.json<{ viewer: { role: string } }>().viewer.role).toBe('manager'); + await app.close(); + }); + + it('does not allow the terminal key to create a browser session', async () => { + const { database } = fakeDatabase([]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/session', + payload: { access_token: terminalToken }, + }); + + expect(response.statusCode).toBe(403); + expect(response.headers['set-cookie']).toBeUndefined(); + await app.close(); + }); + + it('scopes an employee portal to the linked Core user', async () => { + const { database, calls } = fakeDatabase([[], [], []]); + const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database }); + const response = await app.inject('/api/v1/staff/portal'); + expect(response.statusCode).toBe(200); + const body = response.json<{ viewer: { role: string } }>(); + expect(body.viewer.role).toBe('employee'); + expect(calls).toHaveLength(3); + expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true); + await app.close(); + }); + + it('writes a terminal event and its outbox notification atomically', async () => { + const eventId = '01J00000000000000000000002'; + const employeeId = '01J00000000000000000000003'; + const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]); + const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/attendance/events', + payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId }, + }); + expect(response.statusCode).toBe(201); + expect(calls).toHaveLength(3); + expect(calls[0]?.[0]).toContain('staff_employees'); + expect(calls[2]?.[0]).toContain('staff_outbox'); + expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in'); + await app.close(); + }); + + it('does not give an employee manager access to the timesheet', async () => { + const { database } = fakeDatabase([]); + const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database }); + const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } }); + expect(response.statusCode).toBe(403); + await app.close(); + }); +}); diff --git a/tests/unit/payroll-calculator.test.ts b/tests/unit/payroll-calculator.test.ts new file mode 100644 index 0000000..ab0e165 --- /dev/null +++ b/tests/unit/payroll-calculator.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateDay, + roundAmount, + summarizePeriod, + type DayParticipant, +} from '../../src/domain/payroll/calculator.js'; +import { datesInPeriod, ledgerBalance, payrollWeek } from '../../src/domain/payroll/period.js'; +import { + cateringPreset, + defaultParams, + findRoleTemplate, +} from '../../src/domain/payroll/presets.js'; + +const RUB = 100; + +const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!; +const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!; +const hourlyRole = findRoleTemplate(cateringPreset, 'hourly')!; + +function cook(employeeId: string, hours: number, bonusRubles = 500): DayParticipant { + return { + employeeId, + workedMinutes: Math.round(hours * 60), + ruleCode: poolRole.code, + rule: poolRole.rule, + params: { ...defaultParams(poolRole), bonus_minor: bonusRubles * RUB }, + poolWeightBp: poolRole.poolWeightBp, + }; +} + +function admin(employeeId: string, hours: number, percentBp: number): DayParticipant { + return { + employeeId, + workedMinutes: Math.round(hours * 60), + ruleCode: percentRole.code, + rule: percentRole.rule, + params: { ...defaultParams(percentRole), percent_bp: percentBp }, + poolWeightBp: percentRole.poolWeightBp, + }; +} + +function hourly(employeeId: string, hours: number, rateRubles: number): DayParticipant { + return { + employeeId, + workedMinutes: Math.round(hours * 60), + ruleCode: hourlyRole.code, + rule: hourlyRole.rule, + params: { rate_minor: rateRubles * RUB }, + poolWeightBp: hourlyRole.poolWeightBp, + }; +} + +function day(businessDate: string, revenueRubles: number, participants: DayParticipant[]) { + return calculateDay({ + businessDate, + orgUnitId: 'GORSKY', + metrics: { revenue_minor: revenueRubles * RUB }, + poolRule: cateringPreset.poolRule!, + shiftNormMinutes: cateringPreset.shiftNormMinutes, + participants, + }); +} + +function paidRubles(result: ReturnType): Record { + return Object.fromEntries(result.lines.map((line) => [line.employeeId, line.amountMinor / RUB])); +} + +describe('payroll: days taken from the owner spreadsheet, calculated through the catering preset', () => { + it('07.01.2026 Горский: three cooks and an administrator', () => { + const result = day('2026-01-07', 123_569, [ + cook('bekzad', 12), + cook('nurzada', 12), + cook('ksyusha', 12), + admin('vika', 12, 300), + ]); + expect(paidRubles(result)).toEqual({ + bekzad: 6231, + nurzada: 6231, + ksyusha: 6231, + vika: 4907, + }); + }); + + it('08.01.2026 Горский: two cooks share the same pool', () => { + const result = day('2026-01-08', 89_647, [ + cook('bekzad', 12), + cook('ksyusha', 12), + admin('vika', 12, 300), + ]); + expect(paidRubles(result)).toEqual({ bekzad: 6631, ksyusha: 6631, vika: 3889 }); + }); + + it('10.01.2026 Горский: thirteen-hour shifts, one cook without the personal bonus', () => { + const result = day('2026-01-10', 134_161, [ + cook('bekzad', 13), + cook('nurzada', 13), + cook('ksyusha', 13), + cook('pulat', 13, 0), + admin('nastya', 13, 250), + ]); + expect(paidRubles(result)).toEqual({ + bekzad: 5489, + nurzada: 5489, + ksyusha: 5489, + pulat: 4989, + nastya: 4654, + }); + }); + + it('12.01.2026 Горский: the administrator falls back to the guaranteed minimum', () => { + const result = day('2026-01-12', 56_620, [ + cook('bekzad', 12), + cook('nurzada', 12), + admin('nastya', 12, 250), + ]); + expect(paidRubles(result).nastya).toBe(3000); + }); + + it('sheet "Расчет ЗП с доп. поваром": a cook leaving after five hours', () => { + const result = day('2026-01-20', 103_395, [ + cook('leaver', 5, 0), + cook('cook-1', 12, 0), + cook('cook-2', 12, 0), + cook('cook-3', 12, 0), + ]); + expect(paidRubles(result)).toEqual({ + leaver: 1685, + 'cook-1': 4596, + 'cook-2': 4596, + 'cook-3': 4596, + }); + }); + + it('keeps hourly staff on a flat rate and out of the pool divisor', () => { + const withoutTrainee = day('2026-02-06', 141_684, [ + cook('bekzad', 12), + cook('nurzada', 12), + cook('pulat', 12, 0), + ]); + const withTrainee = day('2026-02-06', 141_684, [ + cook('bekzad', 12), + cook('nurzada', 12), + cook('pulat', 12, 0), + hourly('trainee', 12, 200), + ]); + expect(paidRubles(withTrainee).bekzad).toBe(paidRubles(withoutTrainee).bekzad); + expect(paidRubles(withTrainee).trainee).toBe(2400); + }); + + it('counts an additional cook as half a participant when the organization configures it so', () => { + const extra: DayParticipant = { ...cook('extra', 12, 0), poolWeightBp: 5_000 }; + const result = day('2026-02-07', 100_000, [ + cook('cook-1', 12, 0), + cook('cook-2', 12, 0), + extra, + ]); + const share = (id: string) => + result.lines.find((line) => line.employeeId === id)!.poolShareMinor; + expect(share('extra') * 2).toBeCloseTo(share('cook-1'), -1); + expect(result.poolDivisorBp).toBe(25_000); + }); +}); +describe('payroll: invariants', () => { + it('distributes the whole pool without losing kopecks', () => { + const result = day('2026-03-01', 77_777, [cook('a', 12), cook('b', 11, 0), cook('c', 7, 0)]); + expect(result.distributedPoolMinor).toBe(result.poolAmountMinor); + }); + + it('pays nothing to an employee who did not work', () => { + const result = day('2026-03-02', 50_000, [cook('present', 12), cook('absent', 0)]); + expect(paidRubles(result).absent).toBe(0); + }); + + it('still pays the hourly part when the daily metric is missing', () => { + const result = day('2026-03-03', 0, [cook('a', 12)]); + expect(result.poolAmountMinor).toBe(0); + expect(paidRubles(result).a).toBe(1700); + }); + + it('rounds every line to whole rubles', () => { + const result = day('2026-03-04', 98_765, [cook('a', 12), cook('b', 12, 0)]); + for (const line of result.lines) expect(line.amountMinor % RUB).toBe(0); + }); + + it('rounds half away from zero', () => { + expect(roundAmount(150)).toBe(200); + expect(roundAmount(149)).toBe(100); + expect(roundAmount(-150)).toBe(-200); + }); + + it('keeps the unrounded amount and the inputs for the payslip explanation', () => { + const result = day('2026-03-05', 120_000, [cook('a', 12)]); + const line = result.lines[0]!; + expect(line.rawAmountMinor).not.toBe(0); + expect(line.explain.rule).toContain('доля из фонда'); + expect(line.explain.metrics).toMatchObject({ revenue_minor: 12_000_000 }); + expect(line.explain.pool_share_minor).toBeGreaterThan(0); + }); + + it('works for an organization without any pool at all', () => { + const result = calculateDay({ + businessDate: '2026-03-06', + orgUnitId: 'SHOP', + metrics: {}, + shiftNormMinutes: 480, + participants: [hourly('worker', 8, 300)], + }); + expect(result.poolAmountMinor).toBe(0); + expect(paidRubles(result).worker).toBe(2400); + }); +}); + +describe('payroll: periods and balances', () => { + it('uses Thursday as the first day of the payroll week', () => { + expect(payrollWeek('2026-01-02')).toEqual({ start: '2026-01-01', end: '2026-01-07' }); + expect(payrollWeek('2026-01-08')).toEqual({ start: '2026-01-08', end: '2026-01-14' }); + }); + + it('supports another week start without touching the calculation', () => { + expect(payrollWeek('2026-01-07', 1)).toEqual({ start: '2026-01-05', end: '2026-01-11' }); + }); + + it('lists every business date of a period', () => { + expect(datesInPeriod({ start: '2026-01-01', end: '2026-01-07' })).toHaveLength(7); + }); + + it('carries the difference between accruals and payouts', () => { + expect( + ledgerBalance([ + { kind: 'accrual', amountMinor: 3_199_525 }, + { kind: 'payout', amountMinor: -3_190_000 }, + ]), + ).toBe(9_525); + }); + + it('totals a week per employee', () => { + const days = [ + day('2026-01-01', 100_397, [cook('a', 12), cook('b', 12, 0)]), + day('2026-01-02', 104_460, [cook('a', 12)]), + ]; + const totals = summarizePeriod(days); + expect(totals).toHaveLength(2); + expect(totals.find((total) => total.employeeId === 'a')?.days).toBe(2); + }); +}); diff --git a/tests/unit/payroll-industry.test.ts b/tests/unit/payroll-industry.test.ts new file mode 100644 index 0000000..7e1d8b5 --- /dev/null +++ b/tests/unit/payroll-industry.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js'; +import { requiredPeriods, validateRule } from '../../src/domain/payroll/rule.js'; +import { + b2bSalesPreset, + cateringPreset, + defaultParams, + findRoleTemplate, + payrollPresets, + shiftWorkPreset, +} from '../../src/domain/payroll/presets.js'; + +const RUB = 100; + +function roleParticipant( + preset: (typeof payrollPresets)[number], + roleCode: string, + overrides: Partial & { hours: number }, +): DayParticipant { + const role = findRoleTemplate(preset, roleCode)!; + const { hours, params, ...rest } = overrides; + return { + employeeId: 'employee', + workedMinutes: Math.round(hours * 60), + ruleCode: role.code, + rule: role.rule, + params: { ...defaultParams(role), ...(params ?? {}) }, + poolWeightBp: role.poolWeightBp, + ...rest, + }; +} + +function payRubles( + participant: DayParticipant, + options: { + metrics?: Record; + windows?: Parameters[0]['windows']; + } = {}, +): number { + const result = calculateDay({ + businessDate: '2026-05-01', + orgUnitId: 'UNIT', + metrics: options.metrics ?? {}, + shiftNormMinutes: 720, + participants: [participant], + ...(options.windows ? { windows: options.windows } : {}), + }); + return result.lines[0]!.amountMinor / RUB; +} + +describe('shift work: night, overtime and holiday hours', () => { + it('adds twenty percent for night hours', () => { + const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { + hours: 12, + minutesByKind: { night: 8 * 60 }, + }); + // 3000 ₽ shift + 20% of (250 ₽ × 8 night hours) = 3000 + 400 + expect(payRubles(guard)).toBe(3400); + }); + + it('pays overtime at one and a half for two hours and double beyond', () => { + const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { + hours: 16, + minutesByKind: { overtime: 4 * 60 }, + }); + // 3000 ₽ shift + 250 × 2 × 0.5 + 250 × 2 × 1 = 3000 + 250 + 500 + expect(payRubles(guard)).toBe(3750); + }); + + it('adds a second rate for holiday hours', () => { + const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { + hours: 12, + minutesByKind: { holiday: 12 * 60 }, + }); + expect(payRubles(guard)).toBe(3000 + 250 * 12); + }); + + it('pays only the shift when there are no special hours', () => { + const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12 }); + expect(payRubles(guard)).toBe(3000); + }); +}); +describe('scales counted over a month, not over a day', () => { + const manager = () => roleParticipant(b2bSalesPreset, 'sales_manager', { hours: 8 }); + + it('reads the metric from the month window', () => { + const pay = payRubles(manager(), { + windows: { month: { metrics: { paid_margin_minor: 4_500_000 * RUB } } }, + }); + // 4000 ₽ shift + 2% of 2 000 000 + 4% of 1 000 000 + 6% of 1 000 000 + 8% of 500 000 + expect(pay).toBe(4_000 + 40_000 + 40_000 + 60_000 + 40_000); + }); + + it('pays only the shift rate while the month window is empty', () => { + expect(payRubles(manager())).toBe(4_000); + }); + + it('reports which windows a rule needs', () => { + const role = findRoleTemplate(b2bSalesPreset, 'sales_manager')!; + expect([...requiredPeriods(role.rule)]).toEqual(['month']); + }); +}); + +describe('waiter: upsell count and average check', () => { + const checks = [ + { amount_minor: 2_000 * RUB, category: 'main' }, + { amount_minor: 1_000 * RUB, category: 'dessert' }, + { amount_minor: 1_500 * RUB, category: 'dessert' }, + ]; + + it('pays a percent of personal sales, per-item upsell and the average check bonus', () => { + const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { hours: 10, items: checks }); + // 2000 ₽ shift + 3% of 100 000 ₽ personal sales + 2 desserts × 40 ₽ + 500 ₽ (average check 1500 ₽) + expect(payRubles(waiter, { metrics: { personal_sales_minor: 100_000 * RUB } })).toBe( + 2_000 + 3_000 + 80 + 500, + ); + }); + + it('withholds the average check bonus when the target is not met', () => { + const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { + hours: 10, + items: [{ amount_minor: 900 * RUB, category: 'main' }], + }); + expect(payRubles(waiter, { metrics: { personal_sales_minor: 0 } })).toBe(2_000); + }); +}); + +describe('preset catalogue', () => { + it('ships a validated rule for every role of every preset', () => { + for (const preset of payrollPresets) { + if (preset.poolRule) validateRule(preset.poolRule); + for (const role of preset.roles) { + validateRule(role.rule); + expect(role.params.length).toBeGreaterThan(0); + } + } + }); + + it('covers the industries the research found', () => { + expect(payrollPresets.map((preset) => preset.code)).toEqual([ + 'catering_revenue_pool', + 'retail_progressive_percent', + 'delivery_per_order', + 'services_master_percent', + 'shift_night_overtime', + 'b2b_sales_progressive', + 'simple_hourly', + ]); + }); +}); diff --git a/tests/unit/payroll-rule.test.ts b/tests/unit/payroll-rule.test.ts new file mode 100644 index 0000000..a684d14 --- /dev/null +++ b/tests/unit/payroll-rule.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js'; +import { + RuleError, + describeRule, + evaluateRule, + validateRule, + type RuleContext, + type RuleNode, +} from '../../src/domain/payroll/rule.js'; +import { + cateringPreset, + defaultParams, + deliveryPreset, + findRoleTemplate, + retailPreset, + servicesPreset, +} from '../../src/domain/payroll/presets.js'; + +const RUB = 100; + +function participantOf( + preset: typeof cateringPreset, + roleCode: string, + overrides: Partial & { hours: number }, +): DayParticipant { + const role = findRoleTemplate(preset, roleCode)!; + const { hours, ...rest } = overrides; + return { + employeeId: 'employee', + workedMinutes: Math.round(hours * 60), + ruleCode: role.code, + rule: role.rule, + params: { ...defaultParams(role), ...(rest.params ?? {}) }, + poolWeightBp: role.poolWeightBp, + ...(rest.items ? { items: rest.items } : {}), + }; +} + +function amountRubles(participant: DayParticipant, metrics: Record = {}): number { + const result = calculateDay({ + businessDate: '2026-04-01', + orgUnitId: 'UNIT', + metrics, + shiftNormMinutes: 600, + participants: [participant], + }); + return result.lines[0]!.amountMinor / RUB; +} + +describe('pay rules: delivery, the case described by the owner', () => { + const orders = [ + { amount_minor: 3_000 * RUB, distance_km: 3 }, + { amount_minor: 7_000 * RUB, distance_km: 3 }, + { amount_minor: 9_000 * RUB, distance_km: 12 }, + ]; + + it('prices large orders per order by distance and keeps them out of the percent base', () => { + const courier = participantOf(deliveryPreset, 'courier', { hours: 8, items: orders }); + // 8 h × 150 ₽ = 1200; 5% of the 3000 ₽ order = 150; 7000 ₽ order (3 km) = 250; 9000 ₽ order (12 km) = 500. + expect(amountRubles(courier)).toBe(2100); + }); + + it('pays a flat amount per delivery with a surcharge for the distant ones', () => { + const courier = participantOf(deliveryPreset, 'courier_per_delivery', { + hours: 8, + items: orders, + }); + // three deliveries × 200 ₽ + one over 10 km × 100 ₽ + expect(amountRubles(courier)).toBe(700); + }); +}); +describe('pay rules: progressive and flat percent scales', () => { + const sales = { personal_sales_minor: 400_000 * RUB }; + + it('applies a progressive scale layer by layer', () => { + const seller = participantOf(retailPreset, 'seller_progressive', { hours: 10 }); + // 2500 ₽ shift + 3% of the first 100k + 5% of the next 200k + 7% of the remaining 100k + expect(amountRubles(seller, sales)).toBe(22_500); + }); + + it('applies a flat scale to the whole amount at the reached level', () => { + const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 }); + expect(amountRubles(seller, sales)).toBe(30_000); + }); + + it('uses the lowest band when the result is below the first threshold', () => { + const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 }); + expect(amountRubles(seller, { personal_sales_minor: 50_000 * RUB })).toBe(3_500); + }); +}); + +describe('pay rules: conditional bonuses and guarantees', () => { + it('adds the team bonus only when both conditions hold', () => { + const rule = findRoleTemplate(cateringPreset, 'pool_member_with_target')!; + const base: DayParticipant = { + employeeId: 'cook', + workedMinutes: 720, + ruleCode: rule.code, + rule: rule.rule, + params: defaultParams(rule), + poolWeightBp: rule.poolWeightBp, + }; + const withThreeCooks = calculateDay({ + businessDate: '2026-04-02', + orgUnitId: 'UNIT', + metrics: { revenue_minor: 120_000 * RUB }, + poolRule: cateringPreset.poolRule!, + shiftNormMinutes: 720, + participants: [base, { ...base, employeeId: 'cook-2' }, { ...base, employeeId: 'cook-3' }], + }); + const withTwoCooks = calculateDay({ + businessDate: '2026-04-02', + orgUnitId: 'UNIT', + metrics: { revenue_minor: 120_000 * RUB }, + poolRule: cateringPreset.poolRule!, + shiftNormMinutes: 720, + participants: [base, { ...base, employeeId: 'cook-2' }], + }); + + const bonusPaid = withThreeCooks.lines[0]!.rawAmountMinor; + const poolPerCook = withThreeCooks.lines[0]!.poolShareMinor; + expect(bonusPaid - poolPerCook - 1200 * RUB).toBe(500 * RUB); + expect(withTwoCooks.lines[0]!.rawAmountMinor - withTwoCooks.lines[0]!.poolShareMinor).toBe( + 1200 * RUB, + ); + }); + + it('keeps a master at the shift guarantee on an empty day', () => { + const master = participantOf(servicesPreset, 'master', { hours: 12, items: [] }); + expect(amountRubles(master)).toBe(2_000); + }); + + it('pays a master different percents for own and house clients', () => { + const master = participantOf(servicesPreset, 'master', { + hours: 12, + items: [ + { amount_minor: 20_000 * RUB, is_own_client: 1 }, + { amount_minor: 10_000 * RUB, is_own_client: 0 }, + ], + }); + expect(amountRubles(master)).toBe(13_500); + }); +}); + +describe('pay rules: engine behaviour', () => { + const context: RuleContext = { + metrics: { revenue_minor: 100_000 * RUB }, + params: { percent_bp: 250 }, + minutes: 720, + participants: 3, + poolShareMinor: 500_000, + shiftNormMinutes: 720, + items: [], + }; + + it('treats a missing metric or parameter as zero instead of failing', () => { + expect(evaluateRule({ type: 'metric', key: 'unknown_metric' }, context)).toBe(0); + expect(evaluateRule({ type: 'param', key: 'unknown_param' }, context)).toBe(0); + }); + + it('never divides by zero', () => { + const node: RuleNode = { + type: 'div', + left: { type: 'const', value: 100 }, + right: { type: 'const', value: 0 }, + }; + expect(evaluateRule(node, context)).toBe(0); + }); + + it('rejects an item field used outside a per-item block', () => { + expect(() => evaluateRule({ type: 'itemField', field: 'amount_minor' }, context)).toThrow( + RuleError, + ); + }); + + it('rejects scales whose steps are out of order', () => { + expect(() => + validateRule({ + type: 'tier', + on: { type: 'metric', key: 'revenue_minor' }, + bands: [ + { upTo: 1000, value: { type: 'const', value: 1 } }, + { upTo: 500, value: { type: 'const', value: 2 } }, + ], + }), + ).toThrow(/по возрастанию/); + }); + + it('rejects an open step that is not the last one', () => { + expect(() => + validateRule({ + type: 'progressive', + base: { type: 'metric', key: 'revenue_minor' }, + bands: [ + { upTo: null, value: { type: 'const', value: 1 } }, + { upTo: 1000, value: { type: 'const', value: 2 } }, + ], + }), + ).toThrow(/только последней/); + }); + + it('rejects keys that the editor must never produce', () => { + expect(() => validateRule({ type: 'metric', key: 'Выручка' })).toThrow(RuleError); + }); + + it('accepts every rule shipped in the presets', () => { + for (const preset of [cateringPreset, retailPreset, deliveryPreset, servicesPreset]) { + if (preset.poolRule) validateRule(preset.poolRule); + for (const role of preset.roles) validateRule(role.rule); + } + }); + + it('describes a rule in words for the editor and the payslip', () => { + const role = findRoleTemplate(cateringPreset, 'revenue_percent')!; + const text = describeRule(role.rule, { + metrics: { revenue_minor: 'выручка точки' }, + params: { + percent_bp: 'личный процент', + rate_minor: 'ставка за час', + guarantee_minor: 'гарантия смены', + }, + }); + expect(text).toBe( + 'наибольшее из (личный процент от «выручка точки» + ставка за час × часы смены; гарантия смены)', + ); + }); + + it('describes the delivery rule with its filters', () => { + const role = findRoleTemplate(deliveryPreset, 'courier')!; + const text = describeRule(role.rule, { + params: { rate_minor: 'ставка за час', percent_bp: 'процент' }, + itemFields: { amount_minor: 'сумма заказа', distance_km: 'дальность' }, + itemFieldUnits: { amount_minor: 'money', distance_km: 'number' }, + }); + // Thresholds are printed in rubles, not in the kopecks the rule actually stores. + expect(text).toMatch(/сумма «сумма заказа» по позициям \(сумма заказа ≤ 5\s?000 ₽\)/); + expect(text).toMatch(/за каждую позицию \(сумма заказа > 5\s?000 ₽\)/); + expect(text).toContain('по шкале от «дальность»: до 5 — 250 ₽; свыше — 500 ₽'); + }); +}); diff --git a/tests/unit/tenant-sql.test.ts b/tests/unit/tenant-sql.test.ts index 0a2fdc0..3aca4dc 100644 --- a/tests/unit/tenant-sql.test.ts +++ b/tests/unit/tenant-sql.test.ts @@ -20,3 +20,71 @@ describe('tenant isolation migration', () => { expect(source).toContain("set_config('app.user_id', $2, true)"); }); }); + +describe('attendance portal migrations', () => { + const sql = readFileSync('migrations/0005_attendance_portal.up.sql', 'utf8'); + const tables = ['staff_employees', 'staff_attendance_events', 'staff_timesheet_entries']; + + it.each(tables)('%s is tenant scoped and forces RLS', (table) => { + expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`); + expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`); + expect(sql).toMatch(new RegExp(`CREATE POLICY ${table}_[a-z_]+ ON ${table}`)); + }); + + it('keeps raw attendance immutable for the runtime role', () => { + expect(sql).toContain('GRANT SELECT, INSERT ON staff_attendance_events TO krona_staff_app'); + expect(sql).toContain( + 'REVOKE UPDATE, DELETE, TRUNCATE ON staff_attendance_events FROM krona_staff_app', + ); + }); +}); + +describe('payroll foundation migration', () => { + const sql = readFileSync('migrations/0006_payroll_foundation.up.sql', 'utf8'); + const tables = [ + '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', + ]; + + it.each(tables)('%s is tenant scoped and included in the RLS setup', (table) => { + expect(sql).toContain(`CREATE TABLE ${table}`); + expect(sql).toContain(`'${table}'`); + }); + + it('enables and forces RLS with a tenant policy for every listed payroll table', () => { + expect(sql).toContain("EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)"); + expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)"); + expect(sql).toContain("target_table || '_tenant_isolation', target_table"); + }); + + it('uses tenant-scoped foreign keys for payroll ownership', () => { + expect(sql).toContain( + 'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id)', + ); + expect(sql).toContain( + 'FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id)', + ); + expect(sql).toContain( + 'FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id)', + ); + }); + + it('keeps the money ledger append-only for the runtime role', () => { + expect(sql).toContain('CREATE TABLE staff_payroll_ledger'); + expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY'); + expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY'); + expect(sql).toContain('GRANT SELECT, INSERT ON staff_payroll_ledger TO krona_staff_app'); + expect(sql).toContain( + 'REVOKE UPDATE, DELETE, TRUNCATE ON staff_payroll_ledger FROM krona_staff_app', + ); + }); +});