import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { PoolClient } from 'pg'; import type { RequestContext } from './core/context.js'; import { newUlid, parseUlid } from './core/ids.js'; import type { Database } from './infrastructure/database.js'; import { approvePayrollRun, calculatePayrollPeriod, ensureDefaultPayRules, getEmployeePayAssignment, getPayrollRunDetails, listPayRules, saveEmployeePayAssignment, type PayAssignmentInput, } from './domain/payroll/service.js'; const datePattern = /^\d{4}-\d{2}-\d{2}$/; export const financialMetricDefinitions = [ ['income_cash', 'Наличные', 'income'], ['income_card', 'Безналичные', 'income'], ['income_transfer', 'Переводы', 'income'], ['income_sber_market', 'Сбер Маркет', 'income'], ['income_yandex', 'Яндекс', 'income'], ['income_certificate', 'Сертификаты', 'income'], ['income_recycling', 'Масло и вторсырьё', 'income'], ['expense_products', 'Продукты', 'expense'], ['expense_supplies', 'Расходные товары', 'expense'], ['expense_taxi', 'Такси', 'expense'], ['expense_communications', 'Связь', 'expense'], ['expense_advertising', 'Реклама', 'expense'], ['expense_owner_payroll', 'Зарплата руководства', 'expense'], ['expense_employee_payroll', 'Зарплата сотрудников', 'expense'], ['expense_taxes', 'Налоги', 'expense'], ['expense_rent', 'Аренда', 'expense'], ['expense_utilities', 'Коммунальные услуги', 'expense'], ['expense_other', 'Дополнительные расходы', 'expense'], ['expense_gifts', 'Подарки', 'expense'], ] as const; const financialMetricKeys = new Set(financialMetricDefinitions.map(([key]) => key)); interface OperationsAuth { resolve(request: FastifyRequest): Promise; require(request: FastifyRequest, permission: string): Promise; assertMutationOrigin(request: FastifyRequest): void; } interface PeriodQuery { from?: string; to?: string; } interface FinanceParams { orgUnitId: string; businessDate: string; metricKey: string; } interface FinanceBody { value_minor: number; currency_code?: string; } interface ScheduleParams { employeeId: string; businessDate: string; } interface ScheduleBody { org_unit_id: string; planned_minutes: number; start_time?: string; end_time?: string; note?: string; } interface ImportUnit { code: string; name: string; external_ref?: string; } interface ImportFinanceEntry { org_unit_code: string; business_date: string; metric_key: string; value_minor: number; currency_code?: string; } interface ImportEmployee { external_ref: string; personnel_number: string; display_name: string; position?: string; } interface ImportScheduleItem { external_key: string; employee_external_ref: string; org_unit_code: string; business_date: string; planned_minutes: number; location_code?: string; } interface ImportPayrollEntry { external_key: string; employee_external_ref: string; business_date: string; kind: 'accrual' | 'payout' | 'adjustment'; amount_minor: number; note?: string; } interface OwnerWorkbookImportBody { source_name: string; units?: ImportUnit[]; financial_entries?: ImportFinanceEntry[]; employees?: ImportEmployee[]; schedule_items?: ImportScheduleItem[]; payroll_entries?: ImportPayrollEntry[]; } export async function registerOperationsRoutes( api: FastifyInstance, database: Database, auth: OperationsAuth, ) { api.get('/org-units', async (request) => { const context = await auth.resolve(request); const canManage = context.permissions.has('staff:org_unit:read'); if (!canManage && !context.permissions.has('staff:employee:read')) { throw request.server.httpErrors.forbidden( 'Недостаточно прав для просмотра списка подразделений.', ); } return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const rows = await client.query( `SELECT DISTINCT u.id, u.code, u.name, u.timezone FROM staff_org_units u WHERE u.deleted_at IS NULL AND ( $1::boolean OR EXISTS ( SELECT 1 FROM staff_employees e WHERE e.tenant_id = u.tenant_id AND e.primary_org_unit_id = u.id AND e.core_user_id = $2 ) ) ORDER BY u.name`, [canManage, context.actorId], ); return { org_units: rows.rows }; }); }); api.get<{ Querystring: PeriodQuery }>( '/finance', { schema: { querystring: periodSchema }, }, async (request) => { const context = await auth.require(request, 'staff:metric:read'); const period = parsePeriod(request.query); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const entries = await client.query( `SELECT d.id, d.org_unit_id, u.code AS org_unit_code, u.name AS org_unit_name, d.business_date, d.metric_key, m.label, d.value::text AS value_minor, d.currency_code FROM staff_daily_metrics d JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id JOIN staff_metric_definitions m ON m.tenant_id = d.tenant_id AND m.key = d.metric_key WHERE d.business_date BETWEEN $1 AND $2 AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') ORDER BY d.business_date, u.name, d.metric_key`, [period.from, period.to], ); const daily = await client.query( `SELECT d.business_date, d.org_unit_id, u.name AS org_unit_name, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor FROM staff_daily_metrics d JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id WHERE d.business_date BETWEEN $1 AND $2 AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') GROUP BY d.business_date, d.org_unit_id, u.name ORDER BY d.business_date, u.name`, [period.from, period.to], ); const monthly = await client.query( `SELECT to_char(date_trunc('month', d.business_date), 'YYYY-MM') AS month, d.org_unit_id, u.name AS org_unit_name, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor FROM staff_daily_metrics d JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id WHERE d.business_date BETWEEN $1 AND $2 AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') GROUP BY date_trunc('month', d.business_date), d.org_unit_id, u.name ORDER BY month, u.name`, [period.from, period.to], ); return { period, categories: financialMetricDefinitions.map(([key, label, kind]) => ({ key, label, kind, })), entries: entries.rows, daily: daily.rows, monthly: monthly.rows, }; }); }, ); api.put<{ Params: FinanceParams; Body: FinanceBody }>( '/finance/:orgUnitId/:businessDate/:metricKey', { schema: { params: { type: 'object', additionalProperties: false, required: ['orgUnitId', 'businessDate', 'metricKey'], properties: { orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, metricKey: { type: 'string', pattern: '^[a-z][a-z0-9_]{0,60}$' }, }, }, body: { type: 'object', additionalProperties: false, required: ['value_minor'], properties: { value_minor: { type: 'integer', minimum: -9_000_000_000_000_000, maximum: 9_000_000_000_000_000, }, currency_code: { type: 'string', pattern: '^[A-Z]{3}$' }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const context = await auth.require(request, 'staff:metric:write'); const orgUnitId = parseUlid(request.params.orgUnitId); const businessDate = parseDate(request.params.businessDate); if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) { return reply.badRequest('Неизвестная дата или категория.'); } const row = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { await ensureMetricDefinitions(client, context.tenantId); const unit = await client.query( 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL', [context.tenantId, orgUnitId], ); if (unit.rowCount === 0) return null; const saved = await client.query( `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, currency_code, source, recorded_by) VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual', $8) ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code, source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now() RETURNING id, org_unit_id, business_date, metric_key, value::text AS value_minor, currency_code`, [ newUlid(), context.tenantId, orgUnitId, request.params.businessDate, request.params.metricKey, request.body.value_minor, request.body.currency_code ?? 'RUB', context.actorId, ], ); await refreshRevenueMetric( client, context.tenantId, context.actorId, orgUnitId, request.params.businessDate, ); const savedRow = saved.rows[0] as Record | undefined; await writeAudit( client, context, 'finance.metric.saved', 'DailyMetric', String(savedRow?.id), savedRow ?? {}, ); return savedRow; }, ); if (!row) return reply.notFound('Торговая точка не найдена.'); return reply.send(row); }, ); api.get<{ Querystring: PeriodQuery }>( '/schedule', { schema: { querystring: periodSchema }, }, async (request, reply) => { const context = await auth.resolve(request); const canManage = context.permissions.has('staff:attendance:manage'); if (!canManage && !context.permissions.has('staff:shift:read')) { return reply.forbidden('Недостаточно прав для просмотра графика.'); } const period = parsePeriod(request.query); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const rows = await client.query( `SELECT w.id, w.employee_id, e.display_name, e.position, w.org_unit_id, u.name AS org_unit_name, w.business_date, COALESCE((w.attributes->>'planned_minutes')::integer, 0) AS planned_minutes, w.attributes->>'start_time' AS start_time, w.attributes->>'end_time' AS end_time, w.attributes->>'note' AS note, s.started_at AS actual_started_at, s.ended_at AS actual_ended_at, s.worked_minutes AS actual_minutes, s.status AS actual_status, s.anomaly AS actual_anomaly FROM staff_work_items w JOIN staff_employees e ON e.tenant_id = w.tenant_id AND e.id = w.employee_id JOIN staff_org_units u ON u.tenant_id = w.tenant_id AND u.id = w.org_unit_id LEFT JOIN staff_work_sessions s ON s.tenant_id = w.tenant_id AND s.employee_id = w.employee_id AND s.org_unit_id = w.org_unit_id AND s.business_date = w.business_date WHERE w.item_kind = 'planned_shift' AND w.business_date BETWEEN $1 AND $2 AND ($3::boolean OR e.core_user_id = $4) ORDER BY w.business_date, e.display_name`, [period.from, period.to, canManage, context.actorId], ); return { period, shifts: rows.rows }; }); }, ); api.put<{ Params: ScheduleParams; Body: ScheduleBody }>( '/schedule/:employeeId/:businessDate', { schema: { params: { type: 'object', additionalProperties: false, required: ['employeeId', 'businessDate'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, }, }, body: { type: 'object', additionalProperties: false, required: ['org_unit_id', 'planned_minutes'], properties: { org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, planned_minutes: { type: 'integer', minimum: 0, maximum: 1440 }, start_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, end_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, note: { type: 'string', maxLength: 500 }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const context = await auth.require(request, 'staff:shift:confirm'); const employeeId = parseUlid(request.params.employeeId); const orgUnitId = parseUlid(request.body.org_unit_id); if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.'); const externalKey = `schedule:${employeeId}:${request.params.businessDate}`; const result = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { if (request.body.planned_minutes === 0) { const removed = await client.query( `DELETE FROM staff_work_items WHERE tenant_id = $1 AND external_key = $2 RETURNING id`, [context.tenantId, externalKey], ); const removedRow = removed.rows[0] as { id?: string } | undefined; await writeAudit( client, context, 'schedule.shift.removed', 'WorkItem', String(removedRow?.id ?? employeeId), { business_date: request.params.businessDate }, ); return { removed: (removed.rowCount ?? 0) > 0 }; } const exists = await client.query( `SELECT e.id FROM staff_employees e JOIN staff_org_units u ON u.tenant_id = e.tenant_id WHERE e.tenant_id = $1 AND e.id = $2 AND u.id = $3 AND e.status = 'active' AND u.deleted_at IS NULL`, [context.tenantId, employeeId, orgUnitId], ); if (exists.rowCount === 0) return null; const attributes = { planned_minutes: request.body.planned_minutes, start_time: request.body.start_time ?? null, end_time: request.body.end_time ?? null, note: request.body.note?.trim() ?? '', }; const saved = await client.query( `INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, attributes, source, external_key, created_by) VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'manual', $7, $8) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id, attributes = EXCLUDED.attributes, source = 'manual' RETURNING id, employee_id, org_unit_id, business_date, attributes`, [ newUlid(), context.tenantId, employeeId, orgUnitId, request.params.businessDate, attributes, externalKey, context.actorId, ], ); const savedRow = saved.rows[0] as Record | undefined; await writeAudit( client, context, 'schedule.shift.saved', 'WorkItem', String(savedRow?.id), savedRow ?? {}, ); return savedRow; }, ); if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.'); return reply.send(result); }, ); api.get<{ Querystring: PeriodQuery }>( '/payroll', { schema: { querystring: periodSchema }, }, async (request, reply) => { const context = await auth.resolve(request); const canManage = context.permissions.has('staff:payroll:read'); if (!canManage && !context.permissions.has('staff:payslip:read_own')) { return reply.forbidden('Недостаточно прав для просмотра зарплаты.'); } const period = parsePeriod(request.query); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const totals = await client.query( `WITH ledger AS ( SELECT employee_id, COALESCE(SUM(amount_minor) FILTER (WHERE kind IN ('accrual', 'adjustment')), 0) AS accrued, COALESCE(SUM(-amount_minor) FILTER (WHERE kind = 'payout'), 0) AS paid, COALESCE(SUM(amount_minor), 0) AS balance FROM staff_payroll_ledger WHERE business_date BETWEEN $1 AND $2 GROUP BY employee_id ), worked AS ( SELECT employee_id, COALESCE(SUM(worked_minutes), 0) AS worked_minutes FROM staff_work_sessions WHERE business_date BETWEEN $1 AND $2 AND status = 'confirmed' GROUP BY employee_id ) SELECT e.id AS employee_id, e.display_name, e.position, COALESCE(l.accrued, 0)::text AS accrued_minor, COALESCE(l.paid, 0)::text AS paid_minor, COALESCE(l.balance, 0)::text AS balance_minor, COALESCE(w.worked_minutes, 0)::integer AS worked_minutes FROM staff_employees e LEFT JOIN ledger l ON l.employee_id = e.id LEFT JOIN worked w ON w.employee_id = e.id WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY e.display_name`, [period.from, period.to, canManage, context.actorId], ); const entries = await client.query( `SELECT l.id, l.employee_id, e.display_name, l.business_date, l.kind, l.amount_minor::text AS amount_minor, l.currency_code, l.note, l.created_at FROM staff_payroll_ledger l JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id WHERE l.business_date BETWEEN $1 AND $2 AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY l.business_date DESC, e.display_name`, [period.from, period.to, canManage, context.actorId], ); return { period, totals: totals.rows, entries: entries.rows }; }); }, ); api.get<{ Params: { employeeId: string } }>( '/employees/:employeeId/pay-assignment', { schema: { params: { type: 'object', required: ['employeeId'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { const context = await auth.resolve(request); const employeeId = parseUlid(request.params.employeeId); const canManage = context.permissions.has('staff:pay_rule:read') || context.permissions.has('staff:employee:read') || context.permissions.has('staff:payroll:read'); if (!canManage && !context.permissions.has('staff:payslip:read_own')) { return reply.forbidden('Недостаточно прав для просмотра зарплатного профиля.'); } return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const empQuery = await client.query<{ id: string; core_user_id: string | null }>( `SELECT id, core_user_id FROM staff_employees WHERE tenant_id = $1 AND id = $2`, [context.tenantId, employeeId], ); if (empQuery.rowCount === 0 || !empQuery.rows[0]) return reply.notFound('Сотрудник не найден.'); const emp = empQuery.rows[0]; if (!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId) { return reply.forbidden('Доступ запрещён.'); } await ensureDefaultPayRules(client, context.tenantId, context.actorId); const assignment = await getEmployeePayAssignment(client, context.tenantId, employeeId); if (!assignment) return reply.notFound('Зарплатный профиль не настроен.'); return reply.send(assignment); }); }, ); api.put<{ Params: { employeeId: string }; Body: PayAssignmentInput }>( '/employees/:employeeId/pay-assignment', { schema: { params: { type: 'object', required: ['employeeId'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, body: { type: 'object', additionalProperties: false, required: ['pay_rule_id'], properties: { org_unit_id: { anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }], }, pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, rate_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 }, percent_bp: { type: 'integer', minimum: 0, maximum: 10000 }, guarantee_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 }, pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 }, effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, note: { type: 'string', maxLength: 500 }, params: { type: 'object', additionalProperties: { type: 'integer', minimum: 0, maximum: 1_000_000_000 }, }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const context = await auth.require(request, 'staff:pay_rule:write'); const employeeId = parseUlid(request.params.employeeId); try { const saved = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { await ensureDefaultPayRules(client, context.tenantId, context.actorId); return saveEmployeePayAssignment( client, context.tenantId, context.actorId, context.correlationId, employeeId, request.body, ); }, ); return reply.send(saved); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Ошибка сохранения зарплатного профиля'; if (message.includes('not found') || message.includes('Employee not found')) { return reply.notFound(message); } throw err; } }, ); api.get('/pay-rules', async (request, reply) => { const context = await auth.resolve(request); const canManage = context.permissions.has('staff:pay_rule:read') || context.permissions.has('staff:employee:read') || context.permissions.has('staff:attendance:manage') || context.permissions.has('staff:payroll:read'); if (!canManage) { return reply.forbidden('Недостаточно прав для просмотра правил оплаты.'); } return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const rules = await listPayRules(client, context.tenantId, context.actorId); return { pay_rules: rules }; }); }); interface CalculatePayrollBody { month?: string; from?: string; to?: string; org_unit_id?: string; } api.post<{ Body: CalculatePayrollBody }>( '/payroll/calculate', { schema: { body: { type: 'object', properties: { month: { type: 'string', pattern: '^\\d{4}-\\d{2}$' }, from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const context = await auth.require(request, 'staff:payroll:calculate'); if (context.role === 'point_manager') { if ( !request.body.org_unit_id || !(context.orgUnitIds ?? []).includes(request.body.org_unit_id) ) { return reply.forbidden( 'Менеджер точки может рассчитывать зарплату только для своей точки.', ); } } let from: string; let to: string; if (request.body.month) { if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(request.body.month)) { return reply.badRequest('Некорректный формат месяца (ожидается YYYY-MM от 01 до 12).'); } from = `${request.body.month}-01`; const parts = request.body.month.split('-').map(Number); const year = parts[0]!; const monthNum = parts[1]!; const lastDay = new Date(Date.UTC(year, monthNum, 0)).getUTCDate(); to = `${request.body.month}-${String(lastDay).padStart(2, '0')}`; } else if (request.body.from && request.body.to) { if ( !parseDate(request.body.from) || !parseDate(request.body.to) || request.body.from > request.body.to ) { return reply.badRequest('Некорректный период расчёта.'); } from = request.body.from; to = request.body.to; } else { return reply.badRequest('Укажите месяц (month) или период (from и to).'); } try { const result = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { return calculatePayrollPeriod(client, context, { from, to, ...(request.body.org_unit_id ? { orgUnitId: request.body.org_unit_id } : {}), }); }, ); return reply.send(result); } catch (err: unknown) { if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 409) { const message = err instanceof Error ? err.message : 'Конфликт расчёта'; return reply.conflict(message); } throw err; } }, ); api.post<{ Params: { runId: string } }>( '/payroll/runs/:runId/approve', { schema: { params: { type: 'object', required: ['runId'], properties: { runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const context = await auth.require(request, 'staff:payroll:approve'); const runId = parseUlid(request.params.runId); try { const result = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { return approvePayrollRun(client, context, runId); }, ); return reply.send(result); } catch (err: unknown) { if (err && typeof err === 'object' && 'statusCode' in err) { const status = (err as { statusCode: number }).statusCode; const message = err instanceof Error ? err.message : 'Ошибка утверждения'; if (status === 404) return reply.notFound(message); if (status === 400) return reply.badRequest(message); } throw err; } }, ); api.get<{ Params: { runId: string } }>( '/payroll/runs/:runId', { schema: { params: { type: 'object', required: ['runId'], properties: { runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { const context = await auth.resolve(request); const canManage = context.permissions.has('staff:payroll:read'); if (!canManage) { return reply.forbidden('Недостаточно прав для просмотра расчёта.'); } const runId = parseUlid(request.params.runId); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const details = await getPayrollRunDetails(client, context.tenantId, runId); if (!details) return reply.notFound('Расчёт зарплаты не найден.'); return reply.send(details); }); }, ); api.get<{ Params: { runId: string } }>( '/payroll/runs/:runId/verify', { schema: { params: { type: 'object', required: ['runId'], properties: { runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { const context = await auth.resolve(request); if (!context.permissions.has('staff:payroll:read')) { return reply.forbidden('Недостаточно прав для проверки расчёта.'); } const runId = parseUlid(request.params.runId); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const runRes = await client.query<{ total_accrued_minor: string }>( `SELECT total_accrued_minor::text AS total_accrued_minor FROM staff_payroll_runs WHERE tenant_id = $1 AND id = $2`, [context.tenantId, runId], ); if (runRes.rowCount === 0 || !runRes.rows[0]) { return reply.notFound('Расчёт зарплаты не найден.'); } const linesRes = await client.query<{ lines_total: string }>( `SELECT COALESCE(SUM(amount_minor), 0)::text AS lines_total FROM staff_payroll_lines WHERE tenant_id = $1 AND run_id = $2`, [context.tenantId, runId], ); const storedTotal = Number(runRes.rows[0].total_accrued_minor); const recalculatedTotal = Number(linesRes.rows[0]?.lines_total ?? 0); return reply.send({ run_id: runId, stored_total_minor: storedTotal, recalculated_total_minor: recalculatedTotal, matches: storedTotal === recalculatedTotal, }); }); }, ); api.post<{ Body: OwnerWorkbookImportBody }>( '/imports/owner-workbook', { schema: { body: { type: 'object', additionalProperties: false, required: ['source_name'], properties: { source_name: { type: 'string', minLength: 1, maxLength: 240 }, units: { type: 'array', maxItems: 50, items: { type: 'object' } }, financial_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, employees: { type: 'array', maxItems: 250, items: { type: 'object' } }, schedule_items: { type: 'array', maxItems: 500, items: { type: 'object' } }, payroll_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, }, }, }, }, async (request, reply) => { auth.assertMutationOrigin(request); const body = request.body; if (!validateImport(body)) return reply.badRequest('Пакет импорта содержит некорректные данные.'); const requiredPermissions = new Set(); if ((body.units?.length ?? 0) > 0) requiredPermissions.add('staff:org_unit:write'); if ((body.employees?.length ?? 0) > 0) requiredPermissions.add('staff:employee:write'); if ((body.financial_entries?.length ?? 0) > 0) requiredPermissions.add('staff:metric:write'); if ((body.schedule_items?.length ?? 0) > 0) requiredPermissions.add('staff:shift:confirm'); if ((body.payroll_entries?.length ?? 0) > 0) requiredPermissions.add('staff:payout:write'); if (requiredPermissions.size === 0) { return reply.badRequest('Пакет импорта не содержит данных.'); } let context = await auth.resolve(request); for (const permission of requiredPermissions) { context = await auth.require(request, permission); } const counts = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { await ensureMetricDefinitions(client, context.tenantId); const unitIds = new Map(); for (const unit of body.units ?? []) { const saved = await client.query( `INSERT INTO staff_org_units (id, tenant_id, code, name, external_ref) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO UPDATE SET name = EXCLUDED.name, external_ref = COALESCE(staff_org_units.external_ref, EXCLUDED.external_ref), updated_at = now() RETURNING id`, [newUlid(), context.tenantId, unit.code, unit.name, unit.external_ref ?? null], ); const savedRow = saved.rows[0] as { id?: string } | undefined; unitIds.set(unit.code, String(savedRow?.id)); } for (const code of new Set([ ...(body.financial_entries ?? []).map((entry) => entry.org_unit_code), ...(body.schedule_items ?? []).map((entry) => entry.org_unit_code), ])) { if (unitIds.has(code)) continue; const row = await client.query( 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND code = $2 AND deleted_at IS NULL', [context.tenantId, code], ); if (row.rowCount === 0) throw new Error(`Unknown organization unit: ${code}`); const found = row.rows[0] as { id?: string } | undefined; unitIds.set(code, String(found?.id)); } const employeeIds = new Map(); for (const employee of body.employees ?? []) { let professionId: string | null = null; const professionName = employee.position?.trim(); if (professionName) { const profession = await client.query( `INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, $3, $4) ON CONFLICT (tenant_id, name) DO UPDATE SET updated_at = now() RETURNING id`, [newUlid(), context.tenantId, `import-${newUlid().toLowerCase()}`, professionName], ); professionId = String((profession.rows[0] as { id?: string } | undefined)?.id ?? '') || null; } const existing = await client.query<{ id: string }>( `SELECT id FROM staff_employees WHERE tenant_id = $1 AND ( external_ref = $2 OR ($2 LIKE 'xlsx:staff:%' AND external_ref LIKE 'xlsx:staff:%' AND lower(display_name) = lower($3)) ) ORDER BY (external_ref = $2) DESC LIMIT 1 FOR UPDATE`, [context.tenantId, employee.external_ref, employee.display_name], ); const saved = existing.rows[0] ? await client.query( `UPDATE staff_employees SET display_name = $3, position = $4, profession_id = $5, external_ref = $6, updated_at = now() WHERE tenant_id = $1 AND id = $2 RETURNING id`, [ context.tenantId, existing.rows[0].id, employee.display_name, professionName ?? '', professionId, employee.external_ref, ], ) : await client.query( `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, position, profession_id, status, external_ref) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7) ON CONFLICT (tenant_id, personnel_number) DO UPDATE SET external_ref = EXCLUDED.external_ref, display_name = EXCLUDED.display_name, position = EXCLUDED.position, profession_id = EXCLUDED.profession_id, updated_at = now() RETURNING id`, [ newUlid(), context.tenantId, employee.personnel_number, employee.display_name, professionName ?? '', professionId, employee.external_ref, ], ); const savedRow = saved.rows[0] as { id?: string } | undefined; employeeIds.set(employee.external_ref, String(savedRow?.id)); } for (const externalRef of new Set([ ...(body.schedule_items ?? []).map((entry) => entry.employee_external_ref), ...(body.payroll_entries ?? []).map((entry) => entry.employee_external_ref), ])) { if (employeeIds.has(externalRef)) continue; const row = await client.query( 'SELECT id FROM staff_employees WHERE tenant_id = $1 AND external_ref = $2', [context.tenantId, externalRef], ); if (row.rowCount === 0) throw new Error(`Unknown employee: ${externalRef}`); const found = row.rows[0] as { id?: string } | undefined; employeeIds.set(externalRef, String(found?.id)); } const affectedRevenueDays = new Set(); for (const entry of body.financial_entries ?? []) { const orgUnitId = unitIds.get(entry.org_unit_code)!; await client.query( `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, currency_code, breakdown, source, recorded_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'import', $9) ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code, breakdown = EXCLUDED.breakdown, source = 'import', recorded_by = EXCLUDED.recorded_by, updated_at = now()`, [ newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key, entry.value_minor, entry.currency_code ?? 'RUB', { source_name: body.source_name }, context.actorId, ], ); if (entry.metric_key.startsWith('income_')) { affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`); } } for (const key of affectedRevenueDays) { const [orgUnitId, businessDate] = key.split('|') as [string, string]; await refreshRevenueMetric( client, context.tenantId, context.actorId, orgUnitId, businessDate, ); } for (const shift of body.schedule_items ?? []) { const attributes = { planned_minutes: shift.planned_minutes, location_code: shift.location_code ?? '', imported_from: body.source_name, }; const employeeId = employeeIds.get(shift.employee_external_ref); const existingShift = await client.query<{ id: string }>( `SELECT id FROM staff_work_items WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3 AND item_kind = 'planned_shift' AND source = 'import' AND (external_key = $4 OR external_key LIKE 'xlsx:schedule:%') ORDER BY (external_key = $4) DESC LIMIT 1 FOR UPDATE`, [context.tenantId, employeeId, shift.business_date, shift.external_key], ); if (existingShift.rows[0]) { await client.query( `UPDATE staff_work_items SET org_unit_id = $3, attributes = $4, external_key = $5 WHERE tenant_id = $1 AND id = $2`, [ context.tenantId, existingShift.rows[0].id, unitIds.get(shift.org_unit_code), attributes, shift.external_key, ], ); continue; } await client.query( `INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, attributes, source, external_key, created_by) VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'import', $7, $8) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO UPDATE SET employee_id = EXCLUDED.employee_id, org_unit_id = EXCLUDED.org_unit_id, business_date = EXCLUDED.business_date, attributes = EXCLUDED.attributes, source = 'import'`, [ newUlid(), context.tenantId, employeeId, unitIds.get(shift.org_unit_code), shift.business_date, attributes, shift.external_key, context.actorId, ], ); } for (const entry of body.payroll_entries ?? []) { const desiredAmount = entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor; const employeeId = employeeIds.get(entry.employee_external_ref); const existing = await client.query<{ amount_minor: string }>( `SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor FROM staff_payroll_ledger WHERE tenant_id = $1 AND employee_id = $2 AND (external_key = $3 OR external_key LIKE $3 || ':%')`, [context.tenantId, employeeId, entry.external_key], ); const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0); const delta = desiredAmount - previousAmount; if (delta === 0) continue; const isInitial = previousAmount === 0; const rowKey = isInitial ? entry.external_key : `${entry.external_key}:adj:${newUlid()}`; await client.query( `INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, note, created_by, external_key) VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`, [ newUlid(), context.tenantId, employeeId, entry.business_date, isInitial ? entry.kind : 'adjustment', delta, context.correlationId, entry.note?.trim() ?? `Импорт: ${body.source_name}`, context.actorId, rowKey, ], ); } const imported = { units: body.units?.length ?? 0, employees: body.employees?.length ?? 0, financial_entries: body.financial_entries?.length ?? 0, schedule_items: body.schedule_items?.length ?? 0, payroll_entries: body.payroll_entries?.length ?? 0, }; await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch', newUlid(), { source_name: body.source_name, ...imported, }); return imported; }, ); return reply.code(202).send(counts); }, ); } const periodSchema = { type: 'object', additionalProperties: false, properties: { from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, }, } as const; function parsePeriod(query: PeriodQuery) { const now = new Date(); const first = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const last = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); const from = query.from ?? first.toISOString().slice(0, 10); const to = query.to ?? last.toISOString().slice(0, 10); if (!parseDate(from) || !parseDate(to) || from > to) throw new Error('Некорректный период.'); return { from, to }; } function parseDate(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; } async function ensureMetricDefinitions(client: PoolClient, tenantId: string) { for (const [key, label] of financialMetricDefinitions) { await client.query( `INSERT INTO staff_metric_definitions (id, tenant_id, key, label, value_kind, source) VALUES ($1, $2, $3, $4, 'money', 'manual') ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`, [newUlid(), tenantId, key, label], ); } await client.query( `INSERT INTO staff_metric_definitions (id, tenant_id, key, label, value_kind, source) VALUES ($1, $2, 'revenue_minor', 'Общая выручка', 'money', 'module') ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`, [newUlid(), tenantId], ); } async function refreshRevenueMetric( client: PoolClient, tenantId: string, actorId: string, orgUnitId: string, businessDate: string, ) { await client.query( `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, currency_code, source, recorded_by) SELECT $1::varchar(26), $2::varchar(26), $3::varchar(26), $4::date, 'revenue_minor', COALESCE(SUM(value) FILTER (WHERE metric_key LIKE 'income\\_%' ESCAPE '\\'), 0), 'RUB', 'module', $5::varchar(26) FROM staff_daily_metrics WHERE tenant_id = $2::varchar(26) AND org_unit_id = $3::varchar(26) AND business_date = $4::date ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) DO UPDATE SET value = EXCLUDED.value, source = 'module', recorded_by = EXCLUDED.recorded_by, updated_at = now()`, [newUlid(), tenantId, orgUnitId, businessDate, actorId], ); } async function writeAudit( client: PoolClient, context: RequestContext, action: string, entityType: string, entityId: string, values: 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, $4, $5, $6, $7, $8)`, [ newUlid(), context.tenantId, context.actorId, action, entityType, entityId, values, context.correlationId, ], ); } function validateImport(body: OwnerWorkbookImportBody): boolean { if (!body.source_name.trim()) return false; for (const unit of body.units ?? []) { if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false; } for (const entry of body.financial_entries ?? []) { if ( !financialMetricKeys.has(entry.metric_key) || !parseDate(entry.business_date) || !Number.isSafeInteger(entry.value_minor) || Math.abs(entry.value_minor) > 9_000_000_000_000_000 ) return false; } for (const employee of body.employees ?? []) { if ( !employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim() ) { return false; } } for (const shift of body.schedule_items ?? []) { if ( !shift.external_key || !parseDate(shift.business_date) || !Number.isInteger(shift.planned_minutes) || shift.planned_minutes < 0 || shift.planned_minutes > 1440 ) return false; } for (const entry of body.payroll_entries ?? []) { if ( !entry.external_key || !parseDate(entry.business_date) || !Number.isSafeInteger(entry.amount_minor) || !['accrual', 'payout', 'adjustment'].includes(entry.kind) ) return false; } return true; }