merge: integrate server portal and work sessions
This commit is contained in:
commit
df0da37d5c
11 changed files with 977 additions and 14 deletions
147
api/openapi.yaml
147
api/openapi.yaml
|
|
@ -87,6 +87,51 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { type: object }
|
schema: { type: object }
|
||||||
'403': { description: Недостаточно прав }
|
'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:
|
/attendance/events:
|
||||||
post:
|
post:
|
||||||
summary: Принять идемпотентную отметку терминала
|
summary: Принять идемпотентную отметку терминала
|
||||||
|
|
@ -108,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: Сохранить строку табеля
|
||||||
|
|
@ -165,6 +278,40 @@ components:
|
||||||
required: [status]
|
required: [status]
|
||||||
properties:
|
properties:
|
||||||
status: { type: string }
|
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:
|
Problem:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|
|
||||||
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal file
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Design brief: кабинет руководителя
|
||||||
|
|
||||||
|
- **Назначение:** ежедневный контроль присутствия, сотрудников и ручное заполнение табеля руководителем небольшой организации.
|
||||||
|
- **Контекст:** защищённый операционный кабинет, а не маркетинговая страница.
|
||||||
|
- **Направление:** спокойный плотный рабочий интерфейс с природной зелёной основой и золотым акцентом бренда Krona.
|
||||||
|
- **Отличительная идея:** хронология отметок и табель остаются главным рабочим полотном, а управление сотрудниками доступно рядом и не уводит на отдельную техническую страницу.
|
||||||
|
- **Ограничения:** существующий Fastify/PWA без нового frontend-фреймворка; адаптивность от мобильного браузера до рабочего монитора; клавиатурная навигация; reduced motion; никакой биометрии и секретов в браузере.
|
||||||
|
|
||||||
|
## Локальная система
|
||||||
|
|
||||||
|
- Цвета: тёплый рабочий фон, светлые поверхности, зелёный primary, золотой brand accent, семантические danger/info состояния.
|
||||||
|
- Типографика: Georgia для крупных смысловых заголовков, Segoe UI для плотных управляющих элементов и таблиц.
|
||||||
|
- Плотность: 44 px минимальная высота интерактивного элемента, компактные строки таблицы, адаптивный toolbar.
|
||||||
|
- Формы: явные подписи, видимый focus, диалог для карточки сотрудника, встроенная строка создания записи табеля.
|
||||||
|
- Движение: только короткие переходы состояния; полностью отключается через `prefers-reduced-motion`.
|
||||||
4
migrations/0007_work_session_trace.down.sql
Normal file
4
migrations/0007_work_session_trace.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE staff_work_sessions
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_work_sessions_source_events_array,
|
||||||
|
DROP COLUMN IF EXISTS auto_closed,
|
||||||
|
DROP COLUMN IF EXISTS source_event_ids;
|
||||||
10
migrations/0007_work_session_trace.up.sql
Normal file
10
migrations/0007_work_session_trace.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
ALTER TABLE staff_work_sessions
|
||||||
|
ADD COLUMN source_event_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD CONSTRAINT staff_work_sessions_source_events_array
|
||||||
|
CHECK (jsonb_typeof(source_event_ids) = 'array');
|
||||||
|
|
||||||
|
COMMENT ON COLUMN staff_work_sessions.source_event_ids IS
|
||||||
|
'Immutable attendance event ids used to derive the latest session revision.';
|
||||||
|
COMMENT ON COLUMN staff_work_sessions.auto_closed IS
|
||||||
|
'True when a scheduler cutoff supplied the end time; manager confirmation is still required.';
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
113
src/domain/attendance/work-session.ts
Normal file
113
src/domain/attendance/work-session.ts
Normal 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;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
442
src/portal.ts
442
src/portal.ts
|
|
@ -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 {
|
||||||
|
|
@ -23,6 +25,37 @@ interface TimesheetBody {
|
||||||
note?: string;
|
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 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;
|
||||||
}
|
}
|
||||||
|
|
@ -105,16 +138,144 @@ 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(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<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, '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<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, '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', {
|
api.post<{ Body: AttendanceBody }>('/attendance/events', {
|
||||||
schema: {
|
schema: {
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -127,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}$' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -135,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();
|
||||||
|
|
@ -153,11 +316,11 @@ export async function registerPortalRoutes(
|
||||||
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 };
|
||||||
|
|
@ -179,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',
|
||||||
{
|
{
|
||||||
|
|
@ -293,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');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ function fakeDatabase(rows: Array<Array<Record<string, unknown>>>) {
|
||||||
const calls: Array<[string, readonly unknown[] | undefined]> = [];
|
const calls: Array<[string, readonly unknown[] | undefined]> = [];
|
||||||
const query = async (sql: string, parameters?: readonly unknown[]) => {
|
const query = async (sql: string, parameters?: readonly unknown[]) => {
|
||||||
calls.push([sql, parameters]);
|
calls.push([sql, parameters]);
|
||||||
return { rows: rows.shift() ?? [], rowCount: 1 };
|
const resultRows = rows.shift() ?? [];
|
||||||
|
return { rows: resultRows, rowCount: resultRows.length };
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
|
|
@ -104,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();
|
||||||
});
|
});
|
||||||
|
|
@ -134,4 +135,120 @@ describe('attendance portal', () => {
|
||||||
expect(response.statusCode).toBe(403);
|
expect(response.statusCode).toBe(403);
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
93
tests/unit/work-session.test.ts
Normal file
93
tests/unit/work-session.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue