1285 lines
50 KiB
TypeScript
1285 lines
50 KiB
TypeScript
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;
|
||
}
|