731 lines
34 KiB
TypeScript
731 lines
34 KiB
TypeScript
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||
import type { AppConfig } from './config.js';
|
||
import type { RequestContext } from './core/context.js';
|
||
import { newUlid, parseUlid } from './core/ids.js';
|
||
import type { IdentityPort } from './core/ports.js';
|
||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||
import type { Database } from './infrastructure/database.js';
|
||
import { reconcileWorkSession } from './domain/attendance/work-session.js';
|
||
|
||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
interface AttendanceBody {
|
||
event_id?: string;
|
||
employee_id: string;
|
||
event_type: 'check_in' | 'check_out';
|
||
occurred_at: string;
|
||
source_event_id: string;
|
||
device_id?: string;
|
||
org_unit_id?: string;
|
||
}
|
||
|
||
interface TimesheetBody {
|
||
minutes_worked: number;
|
||
status: 'draft' | 'submitted' | 'approved' | 'rejected';
|
||
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 {
|
||
access_token: string;
|
||
}
|
||
|
||
const sessionCookieName = '__Host-krona_staff_session';
|
||
const sessionMaxAgeSeconds = 8 * 60 * 60;
|
||
|
||
export async function registerPortalRoutes(
|
||
api: FastifyInstance,
|
||
config: AppConfig,
|
||
database: Database,
|
||
) {
|
||
const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
|
||
? new PlatformIdentityAdapter()
|
||
: new StandaloneIdentityAdapter(config);
|
||
|
||
api.post<{ Body: SessionBody }>('/session', {
|
||
schema: {
|
||
body: {
|
||
type: 'object', additionalProperties: false,
|
||
required: ['access_token'],
|
||
properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } },
|
||
},
|
||
},
|
||
}, async (request, reply) => {
|
||
assertSameOrigin(request);
|
||
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
|
||
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
|
||
}
|
||
const context = await resolveToken(identity, request.body.access_token, request);
|
||
if (!context.permissions.has('staff:attendance:manage')) {
|
||
return reply.forbidden('Для входа требуется ключ администратора.');
|
||
}
|
||
reply
|
||
.header('cache-control', 'no-store')
|
||
.header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
|
||
return { role: 'manager', expires_in: sessionMaxAgeSeconds };
|
||
});
|
||
|
||
api.delete('/session', async (request, reply) => {
|
||
assertSameOrigin(request);
|
||
reply
|
||
.header('cache-control', 'no-store')
|
||
.header('set-cookie', serializeSessionCookie('', 0));
|
||
return reply.code(204).send();
|
||
});
|
||
|
||
api.get('/portal', async (request, reply) => {
|
||
reply.header('cache-control', 'no-store');
|
||
const context = await resolveContext(identity, request);
|
||
const canManage = context.permissions.has('staff:attendance:manage');
|
||
if (!canManage && !context.permissions.has('staff:attendance:read_own')) {
|
||
return reply.forbidden('Недостаточно прав для просмотра посещаемости.');
|
||
}
|
||
|
||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||
const employees = await client.query(
|
||
`SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status
|
||
FROM staff_employees e
|
||
WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2)
|
||
ORDER BY e.display_name`,
|
||
[canManage, context.actorId],
|
||
);
|
||
const attendance = await client.query(
|
||
`SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source,
|
||
a.occurred_at, a.received_at, a.status
|
||
FROM staff_attendance_events a
|
||
JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
|
||
WHERE ($1::boolean OR e.core_user_id = $2)
|
||
ORDER BY a.occurred_at DESC LIMIT 100`,
|
||
[canManage, context.actorId],
|
||
);
|
||
const timesheet = await client.query(
|
||
`SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked,
|
||
t.status, t.note, t.revision, t.updated_at
|
||
FROM staff_timesheet_entries t
|
||
JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id
|
||
WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days')
|
||
AND ($1::boolean OR e.core_user_id = $2)
|
||
ORDER BY t.work_date DESC, e.display_name`,
|
||
[canManage, context.actorId],
|
||
);
|
||
const workSessions = await client.query(
|
||
`SELECT s.id, s.employee_id, e.display_name, s.org_unit_id, u.name AS org_unit_name,
|
||
s.business_date, s.started_at, s.ended_at, s.worked_minutes, s.status,
|
||
s.anomaly, s.correction_note, s.auto_closed, s.revision
|
||
FROM staff_work_sessions s
|
||
JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id
|
||
JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id
|
||
WHERE s.business_date >= (CURRENT_DATE - INTERVAL '40 days')
|
||
AND ($1::boolean OR e.core_user_id = $2)
|
||
ORDER BY s.business_date DESC, e.display_name`,
|
||
[canManage, context.actorId],
|
||
);
|
||
return {
|
||
viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
|
||
employees: employees.rows,
|
||
attendance: attendance.rows,
|
||
timesheet: timesheet.rows,
|
||
work_sessions: workSessions.rows,
|
||
refreshed_at: new Date().toISOString(),
|
||
};
|
||
});
|
||
});
|
||
|
||
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', {
|
||
schema: {
|
||
body: {
|
||
type: 'object', additionalProperties: false,
|
||
required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'],
|
||
properties: {
|
||
event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||
employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||
event_type: { enum: ['check_in', 'check_out'] },
|
||
occurred_at: { type: 'string', format: 'date-time' },
|
||
source_event_id: { type: 'string', minLength: 1, maxLength: 200 },
|
||
device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||
},
|
||
},
|
||
},
|
||
}, async (request, reply) => {
|
||
const context = await requirePermission(identity, request, 'staff:attendance:clock');
|
||
const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid();
|
||
const employeeId = parseUlid(request.body.employee_id);
|
||
const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null;
|
||
const orgUnitId = request.body.org_unit_id ? parseUlid(request.body.org_unit_id) : null;
|
||
const occurredAt = new Date(request.body.occurred_at);
|
||
if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.');
|
||
const now = Date.now();
|
||
if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) {
|
||
return reply.badRequest('Время отметки выходит за допустимый период пилота.');
|
||
}
|
||
|
||
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||
await client.query(
|
||
`INSERT INTO staff_employees
|
||
(id, tenant_id, personnel_number, display_name, position, gender, status)
|
||
VALUES ($1, $2, $3, $4, '', '', 'active')
|
||
ON CONFLICT (id) DO NOTHING`,
|
||
[employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`],
|
||
);
|
||
const inserted = await client.query(
|
||
`INSERT INTO staff_attendance_events
|
||
(id, tenant_id, employee_id, event_type, source, source_event_id,
|
||
occurred_at, device_id, org_unit_id, actor_id, correlation_id)
|
||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
|
||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`,
|
||
[eventId, context.tenantId, employeeId, request.body.event_type,
|
||
request.body.source_event_id, occurredAt.toISOString(), deviceId, orgUnitId,
|
||
context.actorId, context.correlationId],
|
||
);
|
||
if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
|
||
|
||
const eventType = request.body.event_type === 'check_in'
|
||
? 'attendance.clock.in'
|
||
: 'attendance.clock.out';
|
||
await client.query(
|
||
`INSERT INTO staff_outbox
|
||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||
actor_type, actor_id, payload, correlation_id, occurred_at)
|
||
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
|
||
[newUlid(), context.tenantId, eventType, employeeId, context.actorId,
|
||
{ attendance_event_id: eventId, channel: 'platform_notification' },
|
||
context.correlationId, occurredAt.toISOString()],
|
||
);
|
||
return { id: eventId, duplicate: false };
|
||
});
|
||
return reply.code(result.duplicate ? 200 : 201).send(result);
|
||
});
|
||
|
||
api.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 }>(
|
||
'/timesheets/:employeeId/:workDate',
|
||
{
|
||
schema: {
|
||
params: { type: 'object', required: ['employeeId', 'workDate'], properties: {
|
||
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||
workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||
} },
|
||
body: { type: 'object', additionalProperties: false,
|
||
required: ['minutes_worked', 'status'], properties: {
|
||
minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 },
|
||
status: { enum: ['draft', 'submitted', 'approved', 'rejected'] },
|
||
note: { type: 'string', maxLength: 1000 },
|
||
} },
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
assertCookieMutationOrigin(request);
|
||
const context = await requirePermission(identity, request, 'staff:attendance:manage');
|
||
if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.');
|
||
const employeeId = parseUlid(request.params.employeeId);
|
||
if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) {
|
||
return reply.forbidden('Для утверждения табеля требуется отдельное право.');
|
||
}
|
||
|
||
const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||
const saved = await client.query(
|
||
`INSERT INTO staff_timesheet_entries
|
||
(id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET
|
||
minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status,
|
||
note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1,
|
||
updated_by = EXCLUDED.updated_by, updated_at = now()
|
||
RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`,
|
||
[newUlid(), context.tenantId, employeeId, request.params.workDate,
|
||
request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId],
|
||
);
|
||
const result = saved.rows[0] as Record<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, 'timesheet.entry.saved', 'TimesheetEntry', $4, $5, $6)`,
|
||
[newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId],
|
||
);
|
||
return result;
|
||
});
|
||
return reply.send(row);
|
||
},
|
||
);
|
||
}
|
||
|
||
async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise<RequestContext> {
|
||
const authorization = request.headers.authorization;
|
||
const token = authorization?.startsWith('Bearer ')
|
||
? authorization.slice(7)
|
||
: readCookie(request.headers.cookie, sessionCookieName);
|
||
return resolveToken(identity, token, request);
|
||
}
|
||
|
||
async function resolveToken(
|
||
identity: IdentityPort,
|
||
token: string | undefined,
|
||
request: FastifyRequest,
|
||
): Promise<RequestContext> {
|
||
try {
|
||
return await identity.resolve(token, parseUlid(request.id));
|
||
} catch {
|
||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа.');
|
||
}
|
||
}
|
||
|
||
function readCookie(header: string | undefined, name: string): string | undefined {
|
||
if (!header) return undefined;
|
||
for (const part of header.split(';')) {
|
||
const separator = part.indexOf('=');
|
||
if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
|
||
try {
|
||
return decodeURIComponent(part.slice(separator + 1).trim());
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function serializeSessionCookie(value: string, maxAge: number): string {
|
||
return `${sessionCookieName}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Strict`;
|
||
}
|
||
|
||
function assertSameOrigin(request: FastifyRequest) {
|
||
const origin = request.headers.origin;
|
||
if (!origin) return;
|
||
try {
|
||
const parsed = new URL(origin);
|
||
if (parsed.protocol !== 'https:' || parsed.host !== request.headers.host) throw new Error();
|
||
} catch {
|
||
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||
}
|
||
}
|
||
|
||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||
if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) {
|
||
assertSameOrigin(request);
|
||
}
|
||
}
|
||
|
||
async function requirePermission(identity: IdentityPort, request: FastifyRequest, permission: string) {
|
||
const context = await resolveContext(identity, request);
|
||
if (!context.permissions.has(permission)) {
|
||
throw request.server.httpErrors.forbidden('Недостаточно прав для этой операции.');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
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');
|
||
}
|