feat: reconcile and review attendance sessions

This commit is contained in:
Ochenstarik 2026-09-24 17:00:08 +07:00
parent 32e3c6f7f7
commit 2f6b367638
10 changed files with 665 additions and 7 deletions

View file

@ -153,10 +153,78 @@ paths:
occurred_at: { type: string, format: date-time } occurred_at: { type: string, format: date-time }
source_event_id: { type: string, minLength: 1, maxLength: 200 } source_event_id: { type: string, minLength: 1, maxLength: 200 }
device_id: { $ref: '#/components/schemas/Ulid' } device_id: { $ref: '#/components/schemas/Ulid' }
org_unit_id: { $ref: '#/components/schemas/Ulid' }
responses: responses:
'201': { description: Отметка принята } '201': { description: Отметка принята }
'200': { description: Повтор уже принятой отметки } '200': { description: Повтор уже принятой отметки }
'403': { 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}: /timesheets/{employeeId}/{workDate}:
put: put:
summary: Сохранить строку табеля summary: Сохранить строку табеля

View file

@ -0,0 +1,4 @@
ALTER TABLE staff_work_sessions
DROP CONSTRAINT IF EXISTS staff_work_sessions_source_events_array,
DROP COLUMN IF EXISTS auto_closed,
DROP COLUMN IF EXISTS source_event_ids;

View file

@ -0,0 +1,10 @@
ALTER TABLE staff_work_sessions
ADD COLUMN source_event_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
ADD CONSTRAINT staff_work_sessions_source_events_array
CHECK (jsonb_typeof(source_event_ids) = 'array');
COMMENT ON COLUMN staff_work_sessions.source_event_ids IS
'Immutable attendance event ids used to derive the latest session revision.';
COMMENT ON COLUMN staff_work_sessions.auto_closed IS
'True when a scheduler cutoff supplied the end time; manager confirmation is still required.';

View file

@ -38,6 +38,18 @@
"label": "Порог незакрытой смены, часов", "label": "Порог незакрытой смены, часов",
"description": "После этого срока смена помечается аномалией и не попадает в расчёт без решения управляющего." "description": "После этого срока смена помечается аномалией и не попадает в расчёт без решения управляющего."
}, },
{
"key": "business_day_start_hour",
"type": "integer",
"label": "Начало операционного дня, час",
"description": "Отметки до этого часа относятся к предыдущему операционному дню; значение по умолчанию — 4."
},
{
"key": "automatic_checkout_hour",
"type": "integer",
"label": "Час автоматического выхода",
"description": "Незакрытая смена получает технический выход в этот час следующего календарного дня; значение по умолчанию — 2."
},
{ {
"key": "require_shift_confirmation", "key": "require_shift_confirmation",
"type": "boolean", "type": "boolean",

View file

@ -14,6 +14,8 @@ export class StandaloneIdentityAdapter implements IdentityPort {
'staff:attendance:read_own', 'staff:attendance:read_own',
'staff:attendance:manage', 'staff:attendance:manage',
'staff:attendance:approve', 'staff:attendance:approve',
'staff:shift:read',
'staff:shift:confirm',
], ],
terminal: ['staff:attendance:clock'], terminal: ['staff:attendance:clock'],
} as const; } as const;

View file

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

View file

@ -45,6 +45,7 @@ export const portalHtml = `<!doctype html>
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article> <article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><button id="add-employee" class="quiet compact" type="button">Добавить</button></div><div id="employees" class="people"></div></aside> <aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><button id="add-employee" class="quiet compact" type="button">Добавить</button></div><div id="employees" class="people"></div></aside>
</section> </section>
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section> <section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
</main> </main>
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Должность<input id="employee-position" maxlength="160"></label><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog> <dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Должность<input id="employee-position" maxlength="160"></label><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
@ -71,11 +72,14 @@ export const portalJs = `(() => {
el('attendance').innerHTML=data.attendance.length?data.attendance.slice(0,20).map(x=>'<div class="event '+(x.event_type==='check_out'?'out':'')+'"><span class="event-icon">'+(x.event_type==='check_in'?'↘':'↗')+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'</small></div><time>'+localDate(x.occurred_at)+' · '+localTime(x.occurred_at)+'</time></div>').join(''):'<div class="empty">Отметок пока нет</div>'; el('attendance').innerHTML=data.attendance.length?data.attendance.slice(0,20).map(x=>'<div class="event '+(x.event_type==='check_out'?'out':'')+'"><span class="event-icon">'+(x.event_type==='check_in'?'↘':'↗')+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+(x.event_type==='check_in'?'Приход':'Уход')+' · '+esc(x.source)+'</small></div><time>'+localDate(x.occurred_at)+' · '+localTime(x.occurred_at)+'</time></div>').join(''):'<div class="empty">Отметок пока нет</div>';
el('employees').innerHTML=data.employees.length?data.employees.map(x=>'<div class="person"><span class="avatar">'+esc(x.display_name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+esc(x.position||'Должность не указана')+' · № '+esc(x.personnel_number)+'</small></div>'+(viewerRole==='manager'?'<button class="quiet compact edit-employee" type="button" data-id="'+esc(x.id)+'">Изменить</button>':'')+'</div>').join(''):'<div class="empty">Сотрудники не найдены</div>'; el('employees').innerHTML=data.employees.length?data.employees.map(x=>'<div class="person"><span class="avatar">'+esc(x.display_name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.display_name)+'</strong><small>'+esc(x.position||'Должность не указана')+' · № '+esc(x.personnel_number)+'</small></div>'+(viewerRole==='manager'?'<button class="quiet compact edit-employee" type="button" data-id="'+esc(x.id)+'">Изменить</button>':'')+'</div>').join(''):'<div class="empty">Сотрудники не найдены</div>';
el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'<option value="'+esc(x.id)+'">'+esc(x.display_name)+'</option>').join(''); el('add-employee').hidden=viewerRole!=='manager'; el('timesheet-create').hidden=viewerRole!=='manager'; el('new-time-employee').innerHTML=data.employees.map(x=>'<option value="'+esc(x.id)+'">'+esc(x.display_name)+'</option>').join('');
el('timesheet-help').textContent=viewerRole==='manager'?'Можно добавлять, редактировать и утверждать':'Только просмотр'; el('timesheet').innerHTML=data.timesheet.length?data.timesheet.map(row=>timesheetRow(row)).join(''):'<tr><td colspan="6" class="empty">Табель пока пуст — добавьте первую строку выше</td></tr>'; 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(''):'<tr><td colspan="6" class="empty">Табель пока пуст — добавьте первую строку выше</td></tr>'; bindSaves(); bindEmployeeEdits(); }
function timesheetRow(row){const editable=viewerRole==='manager'; const statuses={draft:'Черновик',submitted:'На проверке',approved:'Утверждён',rejected:'Возвращён'}; return '<tr data-employee="'+esc(row.employee_id)+'" data-date="'+esc(String(row.work_date).slice(0,10))+'"><td>'+localDate(row.work_date)+'</td><td>'+esc(row.display_name)+'</td><td>'+(editable?'<input class="hours" type="number" min="0" max="24" step="0.25" value="'+(Number(row.minutes_worked)/60)+'" aria-label="Часы">':(Number(row.minutes_worked)/60).toLocaleString('ru-RU'))+'</td><td>'+(editable?'<select class="state">'+Object.entries(statuses).map(([k,v])=>'<option value="'+k+'" '+(k===row.status?'selected':'')+'>'+v+'</option>').join('')+'</select>':'<span class="tag">'+esc(statuses[row.status]||row.status)+'</span>')+'</td><td>'+(editable?'<input class="note" maxlength="1000" value="'+esc(row.note||'')+'" aria-label="Примечание">':esc(row.note||'—'))+'</td><td>'+(editable?'<button class="save" type="button">Сохранить</button>':'')+'</td></tr>';} function timesheetRow(row){const editable=viewerRole==='manager'; const statuses={draft:'Черновик',submitted:'На проверке',approved:'Утверждён',rejected:'Возвращён'}; return '<tr data-employee="'+esc(row.employee_id)+'" data-date="'+esc(String(row.work_date).slice(0,10))+'"><td>'+localDate(row.work_date)+'</td><td>'+esc(row.display_name)+'</td><td>'+(editable?'<input class="hours" type="number" min="0" max="24" step="0.25" value="'+(Number(row.minutes_worked)/60)+'" aria-label="Часы">':(Number(row.minutes_worked)/60).toLocaleString('ru-RU'))+'</td><td>'+(editable?'<select class="state">'+Object.entries(statuses).map(([k,v])=>'<option value="'+k+'" '+(k===row.status?'selected':'')+'>'+v+'</option>').join('')+'</select>':'<span class="tag">'+esc(statuses[row.status]||row.status)+'</span>')+'</td><td>'+(editable?'<input class="note" maxlength="1000" value="'+esc(row.note||'')+'" aria-label="Примечание">':esc(row.note||'—'))+'</td><td>'+(editable?'<button class="save" type="button">Сохранить</button>':'')+'</td></tr>';}
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 '<tr data-session="'+esc(row.id)+'"><td>'+localDate(row.business_date)+'</td><td>'+esc(row.display_name)+'</td><td>'+esc(row.org_unit_name||'—')+'</td><td>'+interval+'</td><td>'+(review?'<input class="session-hours" type="number" min="0" max="48" step="0.25" value="'+(Number(row.worked_minutes)/60)+'" aria-label="Часы смены">':(Number(row.worked_minutes)/60).toLocaleString('ru-RU'))+'</td><td><span class="tag">'+esc(states[row.status]||row.status)+'</span>'+(issue?'<small class="anomaly">'+esc(issue)+'</small>':'')+'</td><td>'+(review?'<input class="session-note" maxlength="1000" value="'+esc(row.correction_note||'')+'" placeholder="Причина при изменении">':esc(row.correction_note||'—'))+'</td><td>'+(review?'<div class="session-actions"><button class="confirm-session" type="button">Подтвердить</button><button class="reject-session quiet" type="button">Отклонить</button></div>':'')+'</td></tr>';}).join(''):'<tr><td colspan="8" class="empty">Смен пока нет — выполните сверку отметок</td></tr>';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 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 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);} 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('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('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('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 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)));});`;

View file

@ -5,6 +5,7 @@ import { newUlid, parseUlid } from './core/ids.js';
import type { IdentityPort } from './core/ports.js'; import type { IdentityPort } from './core/ports.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { Database } from './infrastructure/database.js'; import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/; const datePattern = /^\d{4}-\d{2}-\d{2}$/;
@ -15,6 +16,7 @@ interface AttendanceBody {
occurred_at: string; occurred_at: string;
source_event_id: string; source_event_id: string;
device_id?: string; device_id?: string;
org_unit_id?: string;
} }
interface TimesheetBody { interface TimesheetBody {
@ -38,6 +40,22 @@ interface EmployeePatchBody {
status?: 'active' | 'blocked' | 'dismissed'; 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 { interface SessionBody {
access_token: string; access_token: string;
} }
@ -120,11 +138,24 @@ export async function registerPortalRoutes(
ORDER BY t.work_date DESC, e.display_name`, ORDER BY t.work_date DESC, e.display_name`,
[canManage, context.actorId], [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 { return {
viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' }, viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
employees: employees.rows, employees: employees.rows,
attendance: attendance.rows, attendance: attendance.rows,
timesheet: timesheet.rows, timesheet: timesheet.rows,
work_sessions: workSessions.rows,
refreshed_at: new Date().toISOString(), refreshed_at: new Date().toISOString(),
}; };
}); });
@ -257,6 +288,7 @@ export async function registerPortalRoutes(
occurred_at: { type: 'string', format: 'date-time' }, occurred_at: { type: 'string', format: 'date-time' },
source_event_id: { type: 'string', minLength: 1, maxLength: 200 }, source_event_id: { type: 'string', minLength: 1, maxLength: 200 },
device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, 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 eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid();
const employeeId = parseUlid(request.body.employee_id); const employeeId = parseUlid(request.body.employee_id);
const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null; 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); const occurredAt = new Date(request.body.occurred_at);
if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.'); if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.');
const now = Date.now(); const now = Date.now();
@ -281,13 +314,13 @@ export async function registerPortalRoutes(
[employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`], [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`],
); );
const inserted = await client.query( 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, (id, tenant_id, employee_id, event_type, source, source_event_id,
occurred_at, device_id, actor_id, correlation_id) occurred_at, device_id, org_unit_id, actor_id, correlation_id)
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`, ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`,
[eventId, context.tenantId, employeeId, request.body.event_type, [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], context.actorId, context.correlationId],
); );
if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; 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); 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<string, AttendanceRow[]>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 }>( api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>(
'/timesheets/:employeeId/:workDate', '/timesheets/:employeeId/:workDate',
{ {
@ -423,3 +701,31 @@ async function requirePermission(identity: IdentityPort, request: FastifyRequest
} }
return context; 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');
}

View file

@ -105,7 +105,7 @@ describe('attendance portal', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const body = response.json<{ viewer: { role: string } }>(); const body = response.json<{ viewer: { role: string } }>();
expect(body.viewer.role).toBe('employee'); 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); expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
await app.close(); await app.close();
}); });
@ -205,4 +205,50 @@ describe('attendance portal', () => {
expect(calls[2]?.[1]?.[5]).toEqual(after); expect(calls[2]?.[1]?.[5]).toEqual(after);
await app.close(); 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();
});
}); });

View file

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