From 32e3c6f7f7ad7762b02a112282400290a049337a Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:32:30 +0700 Subject: [PATCH 1/2] feat: add manager employee and timesheet workflows --- api/openapi.yaml | 79 +++++++++++++++++ docs/DESIGN-BRIEF-MANAGER-PORTAL.md | 15 ++++ src/portal-ui.ts | 26 ++++-- src/portal.ts | 130 ++++++++++++++++++++++++++++ tests/integration/portal.test.ts | 73 +++++++++++++++- 5 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 docs/DESIGN-BRIEF-MANAGER-PORTAL.md diff --git a/api/openapi.yaml b/api/openapi.yaml index c18a19e..25dea2c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -87,6 +87,51 @@ paths: application/json: schema: { type: object } '403': { description: Недостаточно прав } + /employees: + post: + summary: Добавить сотрудника + operationId: createStaffEmployee + tags: [Employees] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmployeeCreate' + responses: + '201': + description: Сотрудник создан + content: + application/json: + schema: { $ref: '#/components/schemas/Employee' } + '403': { description: Недостаточно прав } + '409': { description: Табельный номер уже используется } + /employees/{employeeId}: + patch: + summary: Изменить карточку или статус сотрудника + operationId: updateStaffEmployee + tags: [Employees] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: employeeId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmployeeUpdate' + responses: + '200': + description: Сотрудник изменён + content: + application/json: + schema: { $ref: '#/components/schemas/Employee' } + '403': { description: Недостаточно прав } + '404': { description: Сотрудник не найден } /attendance/events: post: summary: Принять идемпотентную отметку терминала @@ -165,6 +210,40 @@ components: required: [status] properties: status: { type: string } + Employee: + type: object + additionalProperties: false + required: [id, personnel_number, display_name, position, gender, status] + properties: + id: { $ref: '#/components/schemas/Ulid' } + core_user_id: + oneOf: + - { $ref: '#/components/schemas/Ulid' } + - { type: 'null' } + personnel_number: { type: string } + display_name: { type: string } + position: { type: string } + gender: { enum: ['', male, female] } + status: { enum: [active, blocked, dismissed] } + EmployeeCreate: + type: object + additionalProperties: false + required: [personnel_number, display_name] + properties: + personnel_number: { type: string, minLength: 1, maxLength: 80 } + display_name: { type: string, minLength: 1, maxLength: 240 } + position: { type: string, maxLength: 160 } + gender: { enum: ['', male, female] } + EmployeeUpdate: + type: object + additionalProperties: false + minProperties: 1 + properties: + personnel_number: { type: string, minLength: 1, maxLength: 80 } + display_name: { type: string, minLength: 1, maxLength: 240 } + position: { type: string, maxLength: 160 } + gender: { enum: ['', male, female] } + status: { enum: [active, blocked, dismissed] } Problem: type: object additionalProperties: false diff --git a/docs/DESIGN-BRIEF-MANAGER-PORTAL.md b/docs/DESIGN-BRIEF-MANAGER-PORTAL.md new file mode 100644 index 0000000..b129425 --- /dev/null +++ b/docs/DESIGN-BRIEF-MANAGER-PORTAL.md @@ -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`. diff --git a/src/portal-ui.ts b/src/portal-ui.ts index dbee8e5..085d781 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -43,20 +43,21 @@ export const portalHtml = `

Последние события

Отметки терминала

● в реальном времени
- +
-

Учёт времени

Табель за 40 дней

Изменения сохраняет руководитель
ДатаСотрудникЧасыСтатусПримечание
+

Учёт времени

Табель за 40 дней

Изменения сохраняет руководитель
ДатаСотрудникЧасыСтатусПримечание
+

Карточка команды

Новый сотрудник

`; -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 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)}.compact{min-height:38px;padding:0 12px}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 auto}.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}.timesheet-create{display:flex;align-items:end;gap:10px;padding:14px;margin-bottom:12px;background:var(--raised);border:1px solid var(--border);border-radius:10px}.timesheet-create label,.employee-dialog label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.timesheet-create input,.timesheet-create select,.employee-dialog input,.employee-dialog select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.timesheet-create .grow{flex:1}.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}.employee-dialog{width:min(92vw,560px);padding:0;border:1px solid var(--border);border-radius:16px;background:var(--surface);color:var(--text);box-shadow:0 28px 90px rgba(48,42,35,.25)}.employee-dialog::backdrop{background:rgba(34,31,27,.48);backdrop-filter:blur(3px)}.employee-dialog form{display:grid;gap:14px;padding:26px}.dialog-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px}.dialog-head h2{margin:2px 0;font:500 28px/1.15 Georgia,serif}.icon-button{min-width:42px;padding:0;background:transparent;border:1px solid var(--border);color:var(--text);font-size:24px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}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}.timesheet-create{display:grid;grid-template-columns:1fr 1fr}.timesheet-create .grow,.timesheet-create button{grid-column:1/-1}}@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 api = '/api/v1/staff'; let latestId = null; let viewerRole = 'employee'; let employeesCache = []; 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)); @@ -65,13 +66,22 @@ export const portalJs = `(() => { 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}); + function render(data,notify){ viewerRole=data.viewer.role; employeesCache=data.employees; 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(); } + 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||'Должность не указана')+' · № '+esc(x.personnel_number)+'
'+(viewerRole==='manager'?'':'')+'
').join(''):'
Сотрудники не найдены
'; + el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'').join(''); + el('timesheet-help').textContent=viewerRole==='manager'?'Можно добавлять, редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'Табель пока пуст — добавьте первую строку выше'; bindSaves(); bindEmployeeEdits(); } 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;}}));} + function bindEmployeeEdits(){document.querySelectorAll('.edit-employee').forEach(button=>button.addEventListener('click',()=>openEmployee(employeesCache.find(x=>x.id===button.dataset.id))));} + function openEmployee(employee){const editing=Boolean(employee);el('employee-dialog-title').textContent=editing?'Редактирование сотрудника':'Новый сотрудник';el('employee-id').value=employee?.id||'';el('employee-number').value=employee?.personnel_number||'';el('employee-name').value=employee?.display_name||'';el('employee-position').value=employee?.position||'';el('employee-gender').value=employee?.gender||'';el('employee-status').value=employee?.status||'active';el('employee-status-wrap').hidden=!editing;el('employee-error').hidden=true;el('employee-dialog').showModal();setTimeout(()=>el('employee-number').focus(),0);} + el('new-time-date').value=new Date().toISOString().slice(0,10); + el('timesheet-create').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{const employeeId=el('new-time-employee').value;const workDate=el('new-time-date').value;const body={minutes_worked:Math.round(Number(el('new-time-hours').value)*60),status:el('new-time-status').value,note:el('new-time-note').value};const response=await fetch(api+'/timesheets/'+employeeId+'/'+workDate,{method:'PUT',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok)throw new Error();el('new-time-note').value='';await load(false);}catch{el('message').textContent='Не удалось добавить строку табеля.';el('message').hidden=false;}finally{button.disabled=false;}}); + el('add-employee').addEventListener('click',()=>openEmployee()); + el('employee-close').addEventListener('click',()=>el('employee-dialog').close()); + el('employee-cancel').addEventListener('click',()=>el('employee-dialog').close()); + el('employee-form').addEventListener('submit',async event=>{event.preventDefault();const employeeId=el('employee-id').value;const button=el('employee-save');const body={personnel_number:el('employee-number').value.trim(),display_name:el('employee-name').value.trim(),position:el('employee-position').value.trim(),gender:el('employee-gender').value};if(employeeId)body.status=el('employee-status').value;button.disabled=true;el('employee-error').hidden=true;try{const response=await fetch(api+'/employees'+(employeeId?'/'+employeeId:''),{method:employeeId?'PATCH':'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){el('employee-dialog').close();showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok){const problem=await response.json().catch(()=>({}));throw new Error(problem.message||'Не удалось сохранить сотрудника.');}el('employee-dialog').close();await load(false);}catch(error){el('employee-error').textContent=error.message||'Не удалось сохранить сотрудника.';el('employee-error').hidden=false;}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'?'Уведомления включены':'Уведомления запрещены';}); @@ -79,4 +89,4 @@ export const portalJs = `(() => { })();`; 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)));});`; +export const portalServiceWorker = `const CACHE='krona-staff-v2';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 index 151ffdb..7e425c9 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -23,6 +23,21 @@ interface TimesheetBody { note?: string; } +interface EmployeeBody { + personnel_number: string; + display_name: string; + position?: string; + gender?: 'male' | 'female' | ''; +} + +interface EmployeePatchBody { + personnel_number?: string; + display_name?: string; + position?: string; + gender?: 'male' | 'female' | ''; + status?: 'active' | 'blocked' | 'dismissed'; +} + interface SessionBody { access_token: string; } @@ -115,6 +130,121 @@ export async function registerPortalRoutes( }); }); + api.post<{ Body: EmployeeBody }>('/employees', { + schema: { + body: { + type: 'object', additionalProperties: false, + required: ['personnel_number', 'display_name'], + properties: { + personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, + display_name: { type: 'string', minLength: 1, maxLength: 240 }, + position: { type: 'string', maxLength: 160 }, + gender: { enum: ['', 'male', 'female'] }, + }, + }, + }, + }, async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = newUlid(); + const personnelNumber = request.body.personnel_number.trim(); + const displayName = request.body.display_name.trim(); + if (!personnelNumber || !displayName) return reply.badRequest('Табельный номер и имя обязательны.'); + + const employee = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const saved = await client.query( + `INSERT INTO staff_employees + (id, tenant_id, personnel_number, display_name, position, gender, status) + VALUES ($1, $2, $3, $4, $5, $6, 'active') + ON CONFLICT (tenant_id, personnel_number) DO NOTHING + RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, + [employeeId, context.tenantId, personnelNumber, displayName, + request.body.position?.trim() ?? '', request.body.gender ?? ''], + ); + if (saved.rowCount === 0) return null; + 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, 'employee.created', 'Employee', $4, $5, $6)`, + [newUlid(), context.tenantId, context.actorId, employeeId, result, context.correlationId], + ); + return result; + }); + if (!employee) return reply.conflict('Сотрудник с таким табельным номером уже существует.'); + return reply.code(201).send(employee); + }); + + api.patch<{ Params: { employeeId: string }; Body: EmployeePatchBody }>('/employees/:employeeId', { + schema: { + params: { type: 'object', required: ['employeeId'], properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + } }, + body: { + type: 'object', additionalProperties: false, minProperties: 1, + properties: { + personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, + display_name: { type: 'string', minLength: 1, maxLength: 240 }, + position: { type: 'string', maxLength: 160 }, + gender: { enum: ['', 'male', 'female'] }, + status: { enum: ['active', 'blocked', 'dismissed'] }, + }, + }, + }, + }, async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = parseUlid(request.params.employeeId); + const personnelNumber = request.body.personnel_number?.trim(); + const displayName = request.body.display_name?.trim(); + if (personnelNumber === '' || displayName === '') return reply.badRequest('Табельный номер и имя не могут быть пустыми.'); + + const outcome = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const current = await client.query( + `SELECT id, core_user_id, personnel_number, display_name, position, gender, status + FROM staff_employees WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, + [context.tenantId, employeeId], + ); + if (current.rowCount === 0) return { kind: 'missing' as const }; + if (personnelNumber) { + const duplicate = await client.query( + `SELECT id FROM staff_employees + WHERE tenant_id = $1 AND personnel_number = $2 AND id <> $3 LIMIT 1`, + [context.tenantId, personnelNumber, employeeId], + ); + if ((duplicate.rowCount ?? 0) > 0) return { kind: 'conflict' as const }; + } + const saved = await client.query( + `UPDATE staff_employees SET + personnel_number = COALESCE($3, personnel_number), + display_name = COALESCE($4, display_name), + position = COALESCE($5, position), + gender = COALESCE($6, gender), + status = COALESCE($7, status), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, + [context.tenantId, employeeId, personnelNumber, displayName, + request.body.position?.trim(), request.body.gender, request.body.status], + ); + 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, + old_values, new_values, correlation_id) + VALUES ($1, $2, $3, 'employee.updated', 'Employee', $4, $5, $6, $7)`, + [newUlid(), context.tenantId, context.actorId, employeeId, + current.rows[0], result, context.correlationId], + ); + return { kind: 'saved' as const, employee: result }; + }); + if (outcome.kind === 'missing') return reply.notFound('Сотрудник не найден.'); + if (outcome.kind === 'conflict') { + return reply.conflict('Сотрудник с таким табельным номером уже существует.'); + } + return reply.send(outcome.employee); + }); + api.post<{ Body: AttendanceBody }>('/attendance/events', { schema: { body: { diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts index c5f6e52..1017539 100644 --- a/tests/integration/portal.test.ts +++ b/tests/integration/portal.test.ts @@ -21,7 +21,8 @@ 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 }; + const resultRows = rows.shift() ?? []; + return { rows: resultRows, rowCount: resultRows.length }; }; return { calls, @@ -134,4 +135,74 @@ describe('attendance portal', () => { expect(response.statusCode).toBe(403); await app.close(); }); + + it('allows a manager to create an employee and writes an audit record', async () => { + const employeeId = '01J00000000000000000000004'; + const { database, calls } = fakeDatabase([[{ + id: employeeId, + personnel_number: 'A-17', + display_name: 'Анна Орлова', + position: 'Администратор', + gender: 'female', + status: 'active', + }]]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/employees', + headers: { authorization: `Bearer ${managerToken}` }, + payload: { + personnel_number: 'A-17', + display_name: 'Анна Орлова', + position: 'Администратор', + gender: 'female', + }, + }); + expect(response.statusCode).toBe(201); + expect(calls).toHaveLength(2); + expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees'); + expect(calls[1]?.[0]).toContain('staff_audit_log'); + await app.close(); + }); + + it('does not allow an employee to create another employee', async () => { + const { database } = fakeDatabase([]); + const app = await buildApp({ + config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), + database, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/employees', + payload: { personnel_number: 'A-18', display_name: 'Посторонний' }, + }); + expect(response.statusCode).toBe(403); + await app.close(); + }); + + it('updates an employee with before and after values in the audit trail', async () => { + const employeeId = '01J00000000000000000000004'; + const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' }; + const after = { ...before, position: 'Старший администратор' }; + const { database, calls } = fakeDatabase([[before], [after], []]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/staff/employees/${employeeId}`, + headers: { authorization: `Bearer ${managerToken}` }, + payload: { position: 'Старший администратор' }, + }); + expect(response.statusCode).toBe(200); + expect(calls).toHaveLength(3); + expect(calls[0]?.[0]).toContain('FOR UPDATE'); + expect(calls[2]?.[1]?.[4]).toEqual(before); + expect(calls[2]?.[1]?.[5]).toEqual(after); + await app.close(); + }); }); From 2f6b367638ccc218228cd8f56c1ae81d03b99cf9 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 17:00:08 +0700 Subject: [PATCH 2/2] feat: reconcile and review attendance sessions --- api/openapi.yaml | 68 +++++ migrations/0007_work_session_trace.down.sql | 4 + migrations/0007_work_session_trace.up.sql | 10 + settings.schema.json | 12 + src/adapters/identity.ts | 2 + src/domain/attendance/work-session.ts | 113 +++++++ src/portal-ui.ts | 8 +- src/portal.ts | 314 +++++++++++++++++++- tests/integration/portal.test.ts | 48 ++- tests/unit/work-session.test.ts | 93 ++++++ 10 files changed, 665 insertions(+), 7 deletions(-) create mode 100644 migrations/0007_work_session_trace.down.sql create mode 100644 migrations/0007_work_session_trace.up.sql create mode 100644 src/domain/attendance/work-session.ts create mode 100644 tests/unit/work-session.test.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 25dea2c..68d254d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -153,10 +153,78 @@ paths: occurred_at: { type: string, format: date-time } source_event_id: { type: string, minLength: 1, maxLength: 200 } device_id: { $ref: '#/components/schemas/Ulid' } + org_unit_id: { $ref: '#/components/schemas/Ulid' } responses: '201': { description: Отметка принята } '200': { description: Повтор уже принятой отметки } '403': { description: Недостаточно прав } + /work-sessions/reconcile: + post: + summary: Сверить отметки терминала и сформировать смены за период + operationId: reconcileStaffWorkSessions + tags: [Work Sessions] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [date_from, date_to] + properties: + date_from: { type: string, format: date } + date_to: { type: string, format: date } + business_day_start_hour: { type: integer, minimum: 0, maximum: 23, default: 4 } + automatic_close_hour: { type: integer, minimum: 0, maximum: 23, default: 2 } + open_shift_threshold_minutes: { type: integer, minimum: 60, maximum: 2880, default: 840 } + responses: + '200': + description: Итоги сверки, включая события без назначенного подразделения + content: + application/json: + schema: + type: object + required: [groups, sessions_created_or_updated, confirmed_sessions_skipped, anomalous_sessions, unresolved_events] + properties: + groups: { type: integer, minimum: 0 } + sessions_created_or_updated: { type: integer, minimum: 0 } + confirmed_sessions_skipped: { type: integer, minimum: 0 } + anomalous_sessions: { type: integer, minimum: 0 } + unresolved_events: { type: integer, minimum: 0 } + '403': { description: Недостаточно прав } + /work-sessions/{sessionId}: + patch: + summary: Подтвердить, отклонить или скорректировать рабочую смену + operationId: reviewStaffWorkSession + tags: [Work Sessions] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: sessionId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [status] + properties: + status: { enum: [confirmed, rejected] } + worked_minutes: { type: integer, minimum: 0, maximum: 2880 } + started_at: + oneOf: [{ type: string, format: date-time }, { type: 'null' }] + ended_at: + oneOf: [{ type: string, format: date-time }, { type: 'null' }] + correction_note: { type: string, maxLength: 1000 } + responses: + '200': { description: Смена проверена; подтверждённые часы перенесены в табель } + '400': { description: Для корректировки не указана причина или интервал некорректен } + '403': { description: Недостаточно прав } + '404': { description: Смена не найдена } /timesheets/{employeeId}/{workDate}: put: summary: Сохранить строку табеля diff --git a/migrations/0007_work_session_trace.down.sql b/migrations/0007_work_session_trace.down.sql new file mode 100644 index 0000000..9cf9fec --- /dev/null +++ b/migrations/0007_work_session_trace.down.sql @@ -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; diff --git a/migrations/0007_work_session_trace.up.sql b/migrations/0007_work_session_trace.up.sql new file mode 100644 index 0000000..2b626da --- /dev/null +++ b/migrations/0007_work_session_trace.up.sql @@ -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.'; diff --git a/settings.schema.json b/settings.schema.json index 0af09e6..a8eeb9e 100644 --- a/settings.schema.json +++ b/settings.schema.json @@ -38,6 +38,18 @@ "label": "Порог незакрытой смены, часов", "description": "После этого срока смена помечается аномалией и не попадает в расчёт без решения управляющего." }, + { + "key": "business_day_start_hour", + "type": "integer", + "label": "Начало операционного дня, час", + "description": "Отметки до этого часа относятся к предыдущему операционному дню; значение по умолчанию — 4." + }, + { + "key": "automatic_checkout_hour", + "type": "integer", + "label": "Час автоматического выхода", + "description": "Незакрытая смена получает технический выход в этот час следующего календарного дня; значение по умолчанию — 2." + }, { "key": "require_shift_confirmation", "type": "boolean", diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 8efd944..7c737e3 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -14,6 +14,8 @@ export class StandaloneIdentityAdapter implements IdentityPort { 'staff:attendance:read_own', 'staff:attendance:manage', 'staff:attendance:approve', + 'staff:shift:read', + 'staff:shift:confirm', ], terminal: ['staff:attendance:clock'], } as const; diff --git a/src/domain/attendance/work-session.ts b/src/domain/attendance/work-session.ts new file mode 100644 index 0000000..9c66113 --- /dev/null +++ b/src/domain/attendance/work-session.ts @@ -0,0 +1,113 @@ +export type AttendanceEventType = 'check_in' | 'check_out'; + +export interface AttendanceEventInput { + id: string; + type: AttendanceEventType; + occurredAt: string; +} + +export type WorkSessionAnomaly = + | 'auto_closed' + | 'missing_check_in' + | 'missing_check_out' + | 'repeated_check_in'; + +export interface ReconcileWorkSessionOptions { + now: string; + /** A still-open shift becomes anomalous after this many minutes. */ + openShiftThresholdMinutes: number; + /** Optional scheduler-provided cutoff. It is applied only after the cutoff has actually passed. */ + automaticCloseAt?: string; +} + +export interface WorkSessionCandidate { + startedAt: string | null; + endedAt: string | null; + workedMinutes: number; + status: 'open' | 'pending_review'; + anomalies: WorkSessionAnomaly[]; + attendanceEventIds: string[]; +} + +interface ParsedEvent extends AttendanceEventInput { + timestamp: number; +} + +export function reconcileWorkSession( + events: readonly AttendanceEventInput[], + options: ReconcileWorkSessionOptions, +): WorkSessionCandidate | null { + if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) { + throw new Error('openShiftThresholdMinutes must be a positive integer'); + } + if (events.length === 0) return null; + + const now = parseInstant(options.now, 'now'); + const automaticCloseAt = options.automaticCloseAt + ? parseInstant(options.automaticCloseAt, 'automaticCloseAt') + : undefined; + const parsed = events.map(parseEvent).sort((left, right) => + left.timestamp - right.timestamp || left.id.localeCompare(right.id)); + + const anomalies = new Set(); + const attendanceEventIds: string[] = []; + let openAt: number | null = null; + let firstStart: number | null = null; + let lastEnd: number | null = null; + let workedMilliseconds = 0; + + for (const event of parsed) { + attendanceEventIds.push(event.id); + if (event.type === 'check_in') { + if (openAt !== null) { + anomalies.add('repeated_check_in'); + continue; + } + openAt = event.timestamp; + firstStart ??= event.timestamp; + continue; + } + + if (openAt === null) { + anomalies.add('missing_check_in'); + lastEnd = Math.max(lastEnd ?? event.timestamp, event.timestamp); + continue; + } + workedMilliseconds += Math.max(0, event.timestamp - openAt); + lastEnd = event.timestamp; + openAt = null; + } + + if (openAt !== null) { + const canAutoClose = automaticCloseAt !== undefined + && automaticCloseAt > openAt + && automaticCloseAt <= now; + if (canAutoClose) { + workedMilliseconds += automaticCloseAt - openAt; + lastEnd = automaticCloseAt; + openAt = null; + anomalies.add('auto_closed'); + } else if (now - openAt >= options.openShiftThresholdMinutes * 60_000) { + anomalies.add('missing_check_out'); + } + } + + return { + startedAt: firstStart === null ? null : new Date(firstStart).toISOString(), + endedAt: openAt === null && lastEnd !== null ? new Date(lastEnd).toISOString() : null, + workedMinutes: Math.floor(workedMilliseconds / 60_000), + status: openAt !== null && anomalies.size === 0 ? 'open' : 'pending_review', + anomalies: [...anomalies].sort(), + attendanceEventIds, + }; +} + +function parseEvent(event: AttendanceEventInput): ParsedEvent { + return { ...event, timestamp: parseInstant(event.occurredAt, `event ${event.id}`) }; +} + +function parseInstant(value: string, label: string): number { + const timestamp = new Date(value).valueOf(); + if (!Number.isFinite(timestamp)) throw new Error(`${label} must be an ISO date-time`); + return timestamp; +} diff --git a/src/portal-ui.ts b/src/portal-ui.ts index 085d781..9de2d06 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -45,6 +45,7 @@ export const portalHtml = `

Последние события

Отметки терминала

● в реальном времени
+

Сверка терминала

Рабочие смены

ДатаСотрудникПодразделениеИнтервалЧасыСостояниеПричина правки

Учёт времени

Табель за 40 дней

Изменения сохраняет руководитель
ДатаСотрудникЧасыСтатусПримечание

Карточка команды

Новый сотрудник

@@ -71,11 +72,14 @@ export const portalJs = `(() => { 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||'Должность не указана')+' · № '+esc(x.personnel_number)+'
'+(viewerRole==='manager'?'':'')+'
').join(''):'
Сотрудники не найдены
'; el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'').join(''); - el('timesheet-help').textContent=viewerRole==='manager'?'Можно добавлять, редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'Табель пока пуст — добавьте первую строку выше'; bindSaves(); bindEmployeeEdits(); } + renderWorkSessions(data.work_sessions||[]); el('reconcile-sessions').hidden=viewerRole!=='manager'; el('timesheet-help').textContent=viewerRole==='manager'?'Можно добавлять, редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'Табель пока пуст — добавьте первую строку выше'; bindSaves(); bindEmployeeEdits(); } 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 renderWorkSessions(rows){const states={open:'Открыта',pending_review:'Нужна проверка',confirmed:'Подтверждена',rejected:'Отклонена'};const anomaly={auto_closed:'Автозакрытие',missing_check_in:'Нет прихода',missing_check_out:'Нет ухода',repeated_check_in:'Повторный приход'};el('work-sessions').innerHTML=rows.length?rows.map(row=>{const review=viewerRole==='manager'&&row.status!=='confirmed';const issue=String(row.anomaly||'').split(',').filter(Boolean).map(x=>anomaly[x]||x).join(', ');const interval=(row.started_at?localTime(row.started_at):'—')+'–'+(row.ended_at?localTime(row.ended_at):'…');return ''+localDate(row.business_date)+''+esc(row.display_name)+''+esc(row.org_unit_name||'—')+''+interval+''+(review?'':(Number(row.worked_minutes)/60).toLocaleString('ru-RU'))+''+esc(states[row.status]||row.status)+''+(issue?''+esc(issue)+'':'')+''+(review?'':esc(row.correction_note||'—'))+''+(review?'
':'')+'';}).join(''):'Смен пока нет — выполните сверку отметок';bindSessionReviews();} + function bindSessionReviews(){document.querySelectorAll('.confirm-session,.reject-session').forEach(button=>button.addEventListener('click',async()=>{const row=button.closest('tr');const confirmed=button.classList.contains('confirm-session');button.disabled=true;try{const body={status:confirmed?'confirmed':'rejected',worked_minutes:Math.round(Number(row.querySelector('.session-hours').value)*60),correction_note:row.querySelector('.session-note').value};const response=await fetch(api+'/work-sessions/'+row.dataset.session,{method:'PATCH',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok){const problem=await response.json().catch(()=>({}));throw new Error(problem.message||'Не удалось проверить смену.');}await load(false);}catch(error){el('message').textContent=error.message||'Не удалось проверить смену.';el('message').hidden=false;}finally{button.disabled=false;}}));} 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;}}));} function bindEmployeeEdits(){document.querySelectorAll('.edit-employee').forEach(button=>button.addEventListener('click',()=>openEmployee(employeesCache.find(x=>x.id===button.dataset.id))));} function openEmployee(employee){const editing=Boolean(employee);el('employee-dialog-title').textContent=editing?'Редактирование сотрудника':'Новый сотрудник';el('employee-id').value=employee?.id||'';el('employee-number').value=employee?.personnel_number||'';el('employee-name').value=employee?.display_name||'';el('employee-position').value=employee?.position||'';el('employee-gender').value=employee?.gender||'';el('employee-status').value=employee?.status||'active';el('employee-status-wrap').hidden=!editing;el('employee-error').hidden=true;el('employee-dialog').showModal();setTimeout(()=>el('employee-number').focus(),0);} + el('reconcile-sessions').addEventListener('click',async()=>{const button=el('reconcile-sessions');const end=new Date();const start=new Date(end);start.setUTCDate(start.getUTCDate()-6);button.disabled=true;try{const response=await fetch(api+'/work-sessions/reconcile',{method:'POST',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify({date_from:start.toISOString().slice(0,10),date_to:end.toISOString().slice(0,10)})});const result=await response.json().catch(()=>({}));if(!response.ok)throw new Error(result.message||'Не удалось сверить отметки.');el('message').textContent='Сверка завершена: смен '+result.sessions_created_or_updated+', аномалий '+result.anomalous_sessions+', без подразделения '+result.unresolved_events+'.';el('message').hidden=false;await load(false);}catch(error){el('message').textContent=error.message||'Не удалось сверить отметки.';el('message').hidden=false;}finally{button.disabled=false;}}); el('new-time-date').value=new Date().toISOString().slice(0,10); el('timesheet-create').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{const employeeId=el('new-time-employee').value;const workDate=el('new-time-date').value;const body={minutes_worked:Math.round(Number(el('new-time-hours').value)*60),status:el('new-time-status').value,note:el('new-time-note').value};const response=await fetch(api+'/timesheets/'+employeeId+'/'+workDate,{method:'PUT',credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(response.status===401){showLogin('Сессия завершена. Войдите снова.');return;}if(!response.ok)throw new Error();el('new-time-note').value='';await load(false);}catch{el('message').textContent='Не удалось добавить строку табеля.';el('message').hidden=false;}finally{button.disabled=false;}}); el('add-employee').addEventListener('click',()=>openEmployee()); @@ -89,4 +93,4 @@ export const portalJs = `(() => { })();`; 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-v2';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)));});`; +export const portalServiceWorker = `const CACHE='krona-staff-v3';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 index 7e425c9..9fb8f63 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -5,6 +5,7 @@ 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'; +import { reconcileWorkSession } from './domain/attendance/work-session.js'; const datePattern = /^\d{4}-\d{2}-\d{2}$/; @@ -15,6 +16,7 @@ interface AttendanceBody { occurred_at: string; source_event_id: string; device_id?: string; + org_unit_id?: string; } interface TimesheetBody { @@ -38,6 +40,22 @@ interface EmployeePatchBody { status?: 'active' | 'blocked' | 'dismissed'; } +interface ReconcileSessionsBody { + date_from: string; + date_to: string; + business_day_start_hour?: number; + automatic_close_hour?: number; + open_shift_threshold_minutes?: number; +} + +interface ConfirmSessionBody { + status: 'confirmed' | 'rejected'; + worked_minutes?: number; + started_at?: string | null; + ended_at?: string | null; + correction_note?: string; +} + interface SessionBody { access_token: string; } @@ -120,11 +138,24 @@ export async function registerPortalRoutes( ORDER BY t.work_date DESC, e.display_name`, [canManage, context.actorId], ); + const workSessions = await client.query( + `SELECT s.id, s.employee_id, e.display_name, s.org_unit_id, u.name AS org_unit_name, + s.business_date, s.started_at, s.ended_at, s.worked_minutes, s.status, + s.anomaly, s.correction_note, s.auto_closed, s.revision + FROM staff_work_sessions s + JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id + JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id + WHERE s.business_date >= (CURRENT_DATE - INTERVAL '40 days') + AND ($1::boolean OR e.core_user_id = $2) + ORDER BY s.business_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, + work_sessions: workSessions.rows, refreshed_at: new Date().toISOString(), }; }); @@ -257,6 +288,7 @@ export async function registerPortalRoutes( 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}$' }, + org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, @@ -265,6 +297,7 @@ export async function registerPortalRoutes( 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 orgUnitId = request.body.org_unit_id ? parseUlid(request.body.org_unit_id) : null; const occurredAt = new Date(request.body.occurred_at); if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.'); const now = Date.now(); @@ -281,13 +314,13 @@ export async function registerPortalRoutes( [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`], ); const inserted = await client.query( - `INSERT INTO staff_attendance_events + `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) + occurred_at, device_id, org_unit_id, actor_id, correlation_id) + VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10) 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, + request.body.source_event_id, occurredAt.toISOString(), deviceId, orgUnitId, context.actorId, context.correlationId], ); if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; @@ -309,6 +342,251 @@ export async function registerPortalRoutes( return reply.code(result.duplicate ? 200 : 201).send(result); }); + api.post<{ Body: ReconcileSessionsBody }>('/work-sessions/reconcile', { + schema: { + body: { + type: 'object', additionalProperties: false, + required: ['date_from', 'date_to'], + properties: { + date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + business_day_start_hour: { type: 'integer', minimum: 0, maximum: 23 }, + automatic_close_hour: { type: 'integer', minimum: 0, maximum: 23 }, + open_shift_threshold_minutes: { type: 'integer', minimum: 60, maximum: 2880 }, + }, + }, + }, + }, async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:shift:confirm'); + const from = parseDateOnly(request.body.date_from); + const to = parseDateOnly(request.body.date_to); + if (!from || !to || to < from) return reply.badRequest('Некорректный период сверки.'); + const periodDays = Math.floor((to.valueOf() - from.valueOf()) / 86_400_000) + 1; + if (periodDays > 40) return reply.badRequest('За один раз можно сверить не более 40 дней.'); + const businessDayStartHour = request.body.business_day_start_hour ?? 4; + const automaticCloseHour = request.body.automatic_close_hour ?? 2; + const thresholdMinutes = request.body.open_shift_threshold_minutes ?? 14 * 60; + const now = new Date().toISOString(); + + const summary = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const attendance = await client.query( + `WITH categorized AS ( + SELECT a.id, a.employee_id, a.event_type, a.occurred_at, + COALESCE(a.org_unit_id, e.primary_org_unit_id) AS org_unit_id, + COALESCE(u.timezone, 'UTC') AS timezone, + ((a.occurred_at AT TIME ZONE COALESCE(u.timezone, 'UTC')) + - make_interval(hours => $3))::date AS business_date + FROM staff_attendance_events a + JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id + LEFT JOIN staff_org_units u ON u.tenant_id = a.tenant_id + AND u.id = COALESCE(a.org_unit_id, e.primary_org_unit_id) + WHERE a.status = 'accepted' + AND a.occurred_at >= ($1::date - INTERVAL '2 days') + AND a.occurred_at < ($2::date + INTERVAL '3 days') + ) + SELECT id, employee_id, event_type, occurred_at, org_unit_id, timezone, + business_date::text, + (((business_date + 1)::timestamp + make_interval(hours => $4)) + AT TIME ZONE timezone) AS automatic_close_at + FROM categorized + WHERE business_date BETWEEN $1::date AND $2::date + ORDER BY employee_id, org_unit_id NULLS LAST, business_date, occurred_at, id`, + [request.body.date_from, request.body.date_to, businessDayStartHour, automaticCloseHour], + ); + + type AttendanceRow = { + id: string; employee_id: string; event_type: 'check_in' | 'check_out'; + occurred_at: Date | string; org_unit_id: string | null; business_date: string; + automatic_close_at: Date | string; + }; + const grouped = new Map(); + let unresolvedEvents = 0; + for (const row of attendance.rows as AttendanceRow[]) { + if (!row.org_unit_id) { + unresolvedEvents += 1; + continue; + } + const key = `${row.employee_id}:${row.org_unit_id}:${row.business_date}`; + const group = grouped.get(key) ?? []; + group.push(row); + grouped.set(key, group); + } + + let createdOrUpdated = 0; + let confirmedSkipped = 0; + let anomalous = 0; + for (const rows of grouped.values()) { + const first = rows[0]; + if (!first?.org_unit_id) continue; + const candidate = reconcileWorkSession( + rows.map((row) => ({ + id: row.id, + type: row.event_type, + occurredAt: toIsoInstant(row.occurred_at), + })), + { + now, + openShiftThresholdMinutes: thresholdMinutes, + automaticCloseAt: toIsoInstant(first.automatic_close_at), + }, + ); + if (!candidate) continue; + if (candidate.anomalies.length > 0) anomalous += 1; + const saved = await client.query( + `INSERT INTO staff_work_sessions + (id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, + worked_minutes, source, status, anomaly, source_event_ids, auto_closed) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'terminal', $9, $10, $11, $12) + ON CONFLICT (tenant_id, employee_id, org_unit_id, business_date) DO UPDATE SET + started_at = EXCLUDED.started_at, + ended_at = EXCLUDED.ended_at, + worked_minutes = EXCLUDED.worked_minutes, + status = EXCLUDED.status, + anomaly = EXCLUDED.anomaly, + source_event_ids = EXCLUDED.source_event_ids, + auto_closed = EXCLUDED.auto_closed, + updated_at = now(), + revision = staff_work_sessions.revision + 1 + WHERE staff_work_sessions.status <> 'confirmed' + RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, + worked_minutes, status, anomaly, source_event_ids, auto_closed, revision`, + [newUlid(), context.tenantId, first.employee_id, first.org_unit_id, + first.business_date, candidate.startedAt, candidate.endedAt, candidate.workedMinutes, + candidate.status, candidate.anomalies.join(',') || null, + JSON.stringify(candidate.attendanceEventIds), candidate.anomalies.includes('auto_closed')], + ); + if (saved.rowCount === 0) { + confirmedSkipped += 1; + continue; + } + createdOrUpdated += 1; + 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, 'work_session.reconciled', 'WorkSession', $4, $5, $6)`, + [newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId], + ); + } + return { + groups: grouped.size, + sessions_created_or_updated: createdOrUpdated, + confirmed_sessions_skipped: confirmedSkipped, + anomalous_sessions: anomalous, + unresolved_events: unresolvedEvents, + }; + }); + return reply.send(summary); + }); + + api.patch<{ Params: { sessionId: string }; Body: ConfirmSessionBody }>( + '/work-sessions/:sessionId', + { + schema: { + params: { type: 'object', required: ['sessionId'], properties: { + sessionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + } }, + body: { type: 'object', additionalProperties: false, required: ['status'], properties: { + status: { enum: ['confirmed', 'rejected'] }, + worked_minutes: { type: 'integer', minimum: 0, maximum: 2880 }, + started_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + ended_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + correction_note: { type: 'string', maxLength: 1000 }, + } }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:shift:confirm'); + const sessionId = parseUlid(request.params.sessionId); + const outcome = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const currentQuery = await client.query( + `SELECT id, employee_id, org_unit_id, business_date, started_at, ended_at, + worked_minutes, status, anomaly, correction_note, source_event_ids, + auto_closed, revision + FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, + [context.tenantId, sessionId], + ); + if (currentQuery.rowCount === 0) return { kind: 'missing' as const }; + const current = currentQuery.rows[0] as Record; + const currentStartedAt = parseDatabaseOptionalInstant(current.started_at); + const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at); + const startedAt = request.body.started_at === undefined + ? currentStartedAt + : parseOptionalInstant(request.body.started_at); + const endedAt = request.body.ended_at === undefined + ? currentEndedAt + : parseOptionalInstant(request.body.ended_at); + const workedMinutes = request.body.worked_minutes ?? Number(current.worked_minutes); + const changed = workedMinutes !== Number(current.worked_minutes) + || startedAt !== currentStartedAt || endedAt !== currentEndedAt; + const correctionNote = request.body.correction_note?.trim() ?? ''; + if (changed && !correctionNote) return { kind: 'note_required' as const }; + if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) { + return { kind: 'invalid_interval' as const }; + } + const saved = await client.query( + `UPDATE staff_work_sessions SET + started_at = $3, ended_at = $4, worked_minutes = $5, status = $6, + correction_note = $7, confirmed_by = CASE WHEN $6 = 'confirmed' THEN $8 ELSE NULL END, + confirmed_at = CASE WHEN $6 = 'confirmed' THEN now() ELSE NULL END, + updated_at = now(), revision = revision + 1 + WHERE tenant_id = $1 AND id = $2 + RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, + worked_minutes, status, anomaly, correction_note, source_event_ids, + auto_closed, confirmed_by, confirmed_at, revision`, + [context.tenantId, sessionId, startedAt, endedAt, workedMinutes, + request.body.status, correctionNote, 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, + old_values, new_values, correlation_id) + VALUES ($1, $2, $3, 'work_session.reviewed', 'WorkSession', $4, $5, $6, $7)`, + [newUlid(), context.tenantId, context.actorId, sessionId, + current, result, context.correlationId], + ); + if (request.body.status === 'confirmed') { + 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, 'submitted', $6, $7) + ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET + minutes_worked = EXCLUDED.minutes_worked, + status = CASE WHEN staff_timesheet_entries.status = 'approved' + THEN staff_timesheet_entries.status ELSE 'submitted' END, + note = EXCLUDED.note, + revision = staff_timesheet_entries.revision + 1, + updated_by = EXCLUDED.updated_by, updated_at = now()`, + [newUlid(), context.tenantId, current.employee_id, current.business_date, + workedMinutes, correctionNote, context.actorId], + ); + 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) + VALUES ($1, $2, 'staff.work_session.confirmed', 1, 'WorkSession', $3, + 'user', $4, $5, $6)`, + [newUlid(), context.tenantId, sessionId, context.actorId, + { employee_id: current.employee_id, business_date: current.business_date, + worked_minutes: workedMinutes }, context.correlationId], + ); + } + return { kind: 'saved' as const, session: result }; + }); + if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.'); + if (outcome.kind === 'note_required') { + return reply.badRequest('Для ручной корректировки смены укажите причину.'); + } + if (outcome.kind === 'invalid_interval') { + return reply.badRequest('Окончание смены не может быть раньше начала.'); + } + return reply.send(outcome.session); + }, + ); + api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>( '/timesheets/:employeeId/:workDate', { @@ -423,3 +701,31 @@ async function requirePermission(identity: IdentityPort, request: FastifyRequest } return context; } + +function parseDateOnly(value: string): Date | null { + if (!datePattern.test(value)) return null; + const parsed = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value + ? null + : parsed; +} + +function toIsoInstant(value: Date | string): string { + const parsed = value instanceof Date ? value : new Date(value); + if (Number.isNaN(parsed.valueOf())) throw new Error('Database returned an invalid timestamp'); + return parsed.toISOString(); +} + +function parseOptionalInstant(value: string | null): string | null { + if (value === null) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) throw new Error('Invalid date-time'); + return parsed.toISOString(); +} + +function parseDatabaseOptionalInstant(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return toIsoInstant(value); + if (typeof value === 'string') return toIsoInstant(value); + throw new Error('Database returned an invalid optional timestamp'); +} diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts index 1017539..11e064c 100644 --- a/tests/integration/portal.test.ts +++ b/tests/integration/portal.test.ts @@ -105,7 +105,7 @@ describe('attendance 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).toHaveLength(4); expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true); await app.close(); }); @@ -205,4 +205,50 @@ describe('attendance portal', () => { expect(calls[2]?.[1]?.[5]).toEqual(after); await app.close(); }); + + it('reconciles terminal events into a reviewable work session', async () => { + const employeeId = '01J00000000000000000000004'; + const orgUnitId = '01J00000000000000000000005'; + const sessionId = '01J00000000000000000000006'; + const attendanceRows = [ + { id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' }, + { id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' }, + ]; + const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 }; + const { database, calls } = fakeDatabase([attendanceRows, [session], []]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/work-sessions/reconcile', + headers: { authorization: `Bearer ${managerToken}` }, + payload: { date_from: '2026-09-24', date_to: '2026-09-24' }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 }); + expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions'); + expect(calls[1]?.[1]?.[7]).toBe(480); + await app.close(); + }); + + it('requires a reason when confirmed hours are corrected', async () => { + const sessionId = '01J00000000000000000000006'; + const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 }; + const { database, calls } = fakeDatabase([[current]]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/staff/work-sessions/${sessionId}`, + headers: { authorization: `Bearer ${managerToken}` }, + payload: { status: 'confirmed', worked_minutes: 450 }, + }); + expect(response.statusCode).toBe(400); + expect(calls).toHaveLength(1); + await app.close(); + }); }); diff --git a/tests/unit/work-session.test.ts b/tests/unit/work-session.test.ts new file mode 100644 index 0000000..37e6b87 --- /dev/null +++ b/tests/unit/work-session.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { reconcileWorkSession } from '../../src/domain/attendance/work-session.js'; + +const options = { + now: '2026-09-24T20:00:00.000Z', + openShiftThresholdMinutes: 14 * 60, +}; + +describe('attendance work-session reconciliation', () => { + it('sums several in/out intervals without inventing time between them', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }, + { id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' }, + { id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' }, + { id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' }, + ], options); + + expect(result).toMatchObject({ + startedAt: '2026-09-24T01:00:00.000Z', + endedAt: '2026-09-24T10:30:00.000Z', + workedMinutes: 510, + status: 'pending_review', + anomalies: [], + }); + }); + + it('keeps the first check-in and flags a repeated check-in', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }, + { id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' }, + { id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }, + ], options); + + expect(result?.workedMinutes).toBe(480); + expect(result?.anomalies).toEqual(['repeated_check_in']); + }); + + it('keeps a recent unfinished shift open', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }, + ], options); + + expect(result).toMatchObject({ status: 'open', endedAt: null, workedMinutes: 0, anomalies: [] }); + }); + + it('flags an old unfinished shift without estimating payable minutes', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }, + ], options); + + expect(result).toMatchObject({ + status: 'pending_review', + endedAt: null, + workedMinutes: 0, + anomalies: ['missing_check_out'], + }); + }); + + it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }, + ], { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' }); + + expect(result).toMatchObject({ + endedAt: '2026-09-23T19:00:00.000Z', + workedMinutes: 420, + status: 'pending_review', + anomalies: ['auto_closed'], + }); + }); + + it('records a check-out without check-in as an anomaly with zero minutes', () => { + const result = reconcileWorkSession([ + { id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }, + ], options); + + expect(result).toMatchObject({ + startedAt: null, + endedAt: '2026-09-24T09:00:00.000Z', + workedMinutes: 0, + status: 'pending_review', + anomalies: ['missing_check_in'], + }); + }); + + it('rejects invalid timestamps and thresholds', () => { + expect(() => reconcileWorkSession([ + { id: '1', type: 'check_in', occurredAt: 'not-a-date' }, + ], options)).toThrow('event 1 must be an ISO date-time'); + expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })) + .toThrow('openShiftThresholdMinutes must be a positive integer'); + }); +});