krona-staff/src/operations.ts

1285 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>(financialMetricDefinitions.map(([key]) => key));
interface OperationsAuth {
resolve(request: FastifyRequest): Promise<RequestContext>;
require(request: FastifyRequest, permission: string): Promise<RequestContext>;
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<string, unknown> | 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<string, unknown> | 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<string>();
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<string, string>();
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<string, string>();
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<string>();
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;
}