import type { PoolClient } from 'pg'; import type { RequestContext } from '../../core/context.js'; import { newUlid, parseUlid } from '../../core/ids.js'; import { calculateDay, type DayParticipant, type PayrollLine } from './calculator.js'; import { cateringPreset } from './presets.js'; import type { RuleNode } from './rule.js'; export interface PayAssignmentInput { org_unit_id?: string | null; pay_rule_id: string; rate_minor?: number; percent_bp?: number; guarantee_minor?: number; pool_weight_bp?: number; effective_from?: string; note?: string; } export interface PayAssignmentRecord { id: string; employee_id: string; org_unit_id: string | null; pay_rule_id: string; pay_rule_code: string; pay_rule_name: string; rate_minor: number; percent_bp: number; guarantee_minor: number; pool_weight_bp: number; effective_from: string; effective_to: string | null; params: Record; note: string; } export interface PayRuleRow { id: string; code: string; version: number; name: string; description: string | null; preset_code: string | null; pool_weight_bp: number; param_declarations: unknown; effective_from: string; } interface EmployeePayAssignmentDbRow { id: string; employee_id: string; org_unit_id: string | null; pay_rule_id: string; effective_from: string; effective_to: string | null; params: Record | string | null; pool_weight_bp: number | string | null; note: string | null; rule_code: string; rule_name: string; default_pool_weight_bp: number | string | null; } interface PayrollRunDbRow { id: string; period_start: string; period_end: string; status: string; total_accrued_minor: string; } export interface PayrollRunDetailsRow { id: string; org_unit_id: string | null; org_unit_name: string | null; period_start: string; period_end: string; status: string; total_accrued_minor: string; currency_code: string; calculated_at: string | null; approved_by: string | null; approved_at: string | null; revision: number; } export interface PayrollLineDetailsRow { id: string; employee_id: string; display_name: string; position: string | null; org_unit_id: string; org_unit_name: string; business_date: string; pay_rule_id: string; rule_code: string; worked_minutes: number; pool_amount_minor: string; pool_divisor_bp: number; pool_share_minor: string; raw_amount_minor: string; amount_minor: string; currency_code: string; explain: unknown; } export interface PayrollWarning { kind: 'missing_revenue' | 'no_confirmed_sessions' | 'missing_pay_assignment'; message: string; business_date?: string; org_unit_id?: string; employee_id?: string; } export interface EmployeeRunTotal { employee_id: string; display_name: string; position: string; worked_minutes: number; days: number; accrued_minor: string; } export interface CalculatePayrollOptions { from: string; to: string; orgUnitId?: string | null; } export interface CalculatePayrollResult { run_id: string; status: 'calculated'; org_unit_id: string | null; period_start: string; period_end: string; total_accrued_minor: string; currency_code: string; calculated_at: string; totals: EmployeeRunTotal[]; warnings: PayrollWarning[]; lines_count: number; } export interface ApprovePayrollResult { run_id: string; status: 'approved'; approved_by: string; approved_at: string; total_accrued_minor: string; entries_posted: number; } export async function ensureDefaultPayRules( client: PoolClient, tenantId: string, actorId: string, ): Promise { for (const role of cateringPreset.roles) { await client.query( `INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, description, preset_code, rule, param_declarations, pool_weight_bp, effective_from, created_by) VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, '2026-01-01', $10) ON CONFLICT (tenant_id, code, version) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, rule = EXCLUDED.rule, param_declarations = EXCLUDED.param_declarations, pool_weight_bp = EXCLUDED.pool_weight_bp`, [ newUlid(), tenantId, role.code, role.label, role.description, cateringPreset.code, JSON.stringify(role.rule), JSON.stringify(role.params), role.poolWeightBp, actorId, ], ); } } export async function listPayRules( client: PoolClient, tenantId: string, actorId: string, ): Promise { await ensureDefaultPayRules(client, tenantId, actorId); const result = await client.query( `SELECT id, code, version, name, description, preset_code, pool_weight_bp, param_declarations, effective_from::text AS effective_from FROM staff_pay_rules WHERE tenant_id = $1 AND (effective_to IS NULL OR effective_to >= CURRENT_DATE) ORDER BY name`, [tenantId], ); return result.rows; } export async function getEmployeePayAssignment( client: PoolClient, tenantId: string, employeeId: string, ): Promise { const result = await client.query( `SELECT a.id, a.employee_id, a.org_unit_id, a.pay_rule_id, a.effective_from::text AS effective_from, a.effective_to::text AS effective_to, a.params, a.pool_weight_bp, a.note, r.code AS rule_code, r.name AS rule_name, r.pool_weight_bp AS default_pool_weight_bp FROM staff_pay_assignments a JOIN staff_pay_rules r ON r.tenant_id = a.tenant_id AND r.id = a.pay_rule_id WHERE a.tenant_id = $1 AND a.employee_id = $2 ORDER BY a.effective_from DESC, a.created_at DESC LIMIT 1`, [tenantId, employeeId], ); if (result.rowCount === 0 || !result.rows[0]) return null; const row = result.rows[0]; const rawParams = (typeof row.params === 'string' ? (JSON.parse(row.params) as Record) : row.params) ?? {}; const params: Record = {}; for (const [k, v] of Object.entries(rawParams)) { if (typeof v === 'number') params[k] = v; } const poolWeightBp = row.pool_weight_bp !== null && row.pool_weight_bp !== undefined ? Number(row.pool_weight_bp) : Number(row.default_pool_weight_bp ?? 0); return { id: row.id, employee_id: row.employee_id, org_unit_id: row.org_unit_id ?? null, pay_rule_id: row.pay_rule_id, pay_rule_code: row.rule_code, pay_rule_name: row.rule_name, rate_minor: Number(params.rate_minor ?? 10000), percent_bp: Number(params.percent_bp ?? 0), guarantee_minor: Number(params.guarantee_minor ?? 0), pool_weight_bp: poolWeightBp, effective_from: String(row.effective_from).slice(0, 10), effective_to: row.effective_to ? String(row.effective_to).slice(0, 10) : null, params, note: row.note ?? '', }; } export async function saveEmployeePayAssignment( client: PoolClient, tenantId: string, actorId: string, correlationId: string, employeeId: string, data: PayAssignmentInput, ): Promise { const empCheck = await client.query<{ id: string }>( `SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2`, [tenantId, employeeId], ); if (empCheck.rowCount === 0) { throw new Error('Employee not found'); } const orgUnitId = data.org_unit_id ? parseUlid(data.org_unit_id) : null; if (orgUnitId) { const unitCheck = await client.query<{ id: string }>( `SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [tenantId, orgUnitId], ); if (unitCheck.rowCount === 0) { throw new Error('Organization unit not found'); } } const payRuleId = parseUlid(data.pay_rule_id); const ruleCheck = await client.query<{ id: string; pool_weight_bp: number | null }>( `SELECT id, pool_weight_bp FROM staff_pay_rules WHERE tenant_id = $1 AND id = $2`, [tenantId, payRuleId], ); if (ruleCheck.rowCount === 0 || !ruleCheck.rows[0]) { throw new Error('Pay rule not found'); } const defaultRuleWeight = Number(ruleCheck.rows[0].pool_weight_bp ?? 0); const effectiveFrom = data.effective_from ?? new Date().toISOString().slice(0, 10); const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight; const params: Record = { rate_minor: data.rate_minor ?? 10000, percent_bp: data.percent_bp ?? 0, guarantee_minor: data.guarantee_minor ?? 0, }; await client.query( `SELECT pg_advisory_xact_lock(hashtextextended( scope.tenant_id || ':' || $2 || ':' || COALESCE($3::text, 'all-units'), 0)) FROM (SELECT $1::text AS tenant_id) AS scope`, [tenantId, employeeId, orgUnitId], ); const existingAssignment = await client.query<{ id: string }>( `SELECT id FROM staff_pay_assignments WHERE tenant_id = $1 AND employee_id = $2 AND effective_from = $3 AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) LIMIT 1`, [tenantId, employeeId, effectiveFrom, orgUnitId], ); const nextAssignment = await client.query<{ effective_from: string }>( `SELECT effective_from::text AS effective_from FROM staff_pay_assignments WHERE tenant_id = $1 AND employee_id = $2 AND effective_from > $3 AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) ORDER BY effective_from ASC LIMIT 1`, [tenantId, employeeId, effectiveFrom, orgUnitId], ); const nextEffectiveFrom = nextAssignment.rows[0]?.effective_from; await client.query( `UPDATE staff_pay_assignments SET effective_to = ($3::date - INTERVAL '1 day')::date WHERE tenant_id = $1 AND employee_id = $2 AND effective_from < $3 AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) AND (effective_to IS NULL OR effective_to >= $3)`, [tenantId, employeeId, effectiveFrom, orgUnitId], ); const effectiveTo = nextEffectiveFrom ? new Date(`${nextEffectiveFrom}T00:00:00.000Z`).toISOString().slice(0, 10) : null; const boundedEffectiveTo = effectiveTo ? new Date(new Date(`${effectiveTo}T00:00:00.000Z`).valueOf() - 86_400_000) .toISOString() .slice(0, 10) : null; let assignmentId: string; if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) { assignmentId = String(existingAssignment.rows[0].id); await client.query( `UPDATE staff_pay_assignments SET org_unit_id = $3, pay_rule_id = $4, params = $5, pool_weight_bp = $6, note = $7, effective_to = $8 WHERE tenant_id = $1 AND id = $2`, [ tenantId, assignmentId, orgUnitId, payRuleId, JSON.stringify(params), poolWeightBp, data.note ?? '', boundedEffectiveTo, ], ); } else { assignmentId = newUlid(); await client.query( `INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from, effective_to, params, pool_weight_bp, note, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ assignmentId, tenantId, employeeId, orgUnitId, payRuleId, effectiveFrom, boundedEffectiveTo, JSON.stringify(params), poolWeightBp, data.note ?? '', actorId, ], ); } 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, 'pay_assignment.saved', 'PayAssignment', $4, $5, $6)`, [ newUlid(), tenantId, actorId, assignmentId, { employee_id: employeeId, org_unit_id: orgUnitId, pay_rule_id: payRuleId, params, pool_weight_bp: poolWeightBp, }, correlationId, ], ); const updated = await getEmployeePayAssignment(client, tenantId, employeeId); if (!updated) throw new Error('Failed to retrieve saved assignment'); return updated; } export async function calculatePayrollPeriod( client: PoolClient, context: RequestContext, options: CalculatePayrollOptions, ): Promise { const tenantId = context.tenantId; const actorId = context.actorId; const correlationId = context.correlationId; const from = options.from; const to = options.to; const orgUnitId = options.orgUnitId ? parseUlid(options.orgUnitId) : null; await ensureDefaultPayRules(client, tenantId, actorId); // Look for an existing run for this period const existingRunQuery = await client.query<{ id: string; status: string }>( `SELECT id, status FROM staff_payroll_runs WHERE tenant_id = $1 AND (($2::varchar(26) IS NULL AND org_unit_id IS NULL) OR org_unit_id = $2) AND period_start = $3 FOR UPDATE`, [tenantId, orgUnitId, from], ); let runId: string; if ((existingRunQuery.rowCount ?? 0) > 0 && existingRunQuery.rows[0]) { const existing = existingRunQuery.rows[0]; if (existing.status === 'approved' || existing.status === 'paid') { const err = new Error('Расчёт за этот период уже утверждён.'); (err as { statusCode?: number }).statusCode = 409; throw err; } runId = String(existing.id); } else { runId = newUlid(); await client.query( `INSERT INTO staff_payroll_runs (id, tenant_id, org_unit_id, period_start, period_end, status, created_by) VALUES ($1, $2, $3, $4, $5, 'draft', $6) ON CONFLICT DO NOTHING`, [runId, tenantId, orgUnitId, from, to, actorId], ); } // Gather confirmed sessions const sessionsQuery = await client.query<{ id: string; employee_id: string; org_unit_id: string; business_date: string; worked_minutes: number; display_name: string; position: string; org_unit_name: string; }>( `SELECT s.id, s.employee_id, s.org_unit_id, s.business_date::text AS business_date, s.worked_minutes, e.display_name, e.position, u.name AS org_unit_name FROM staff_work_sessions s JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id WHERE s.tenant_id = $1 AND s.business_date BETWEEN $2 AND $3 AND s.status = 'confirmed' AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4) ORDER BY s.business_date, s.org_unit_id, s.employee_id`, [tenantId, from, to, orgUnitId], ); const sessions = sessionsQuery.rows; // Gather daily metrics (revenue) const metricsQuery = await client.query<{ org_unit_id: string; business_date: string; revenue_minor: string | number; }>( `SELECT org_unit_id, business_date::text AS business_date, value::bigint AS revenue_minor FROM staff_daily_metrics WHERE tenant_id = $1 AND metric_key = 'revenue_minor' AND business_date BETWEEN $2 AND $3 AND ($4::varchar(26) IS NULL OR org_unit_id = $4)`, [tenantId, from, to, orgUnitId], ); const revenueMap = new Map(); for (const row of metricsQuery.rows) { revenueMap.set(`${row.business_date}:${row.org_unit_id}`, Number(row.revenue_minor)); } // Gather assignments interface AssignmentRow { id: string; employee_id: string; org_unit_id: string | null; pay_rule_id: string; effective_from: string; effective_to: string | null; params: Record | string | null; pool_weight_bp: number | string | null; rule_code: string; rule: RuleNode | string; default_pool_weight_bp: number | string | null; } const assignmentsQuery = await client.query( `SELECT a.id, a.employee_id, a.org_unit_id, a.pay_rule_id, a.effective_from::text AS effective_from, a.effective_to::text AS effective_to, a.params, a.pool_weight_bp, r.code AS rule_code, r.rule, r.pool_weight_bp AS default_pool_weight_bp FROM staff_pay_assignments a JOIN staff_pay_rules r ON r.tenant_id = a.tenant_id AND r.id = a.pay_rule_id WHERE a.tenant_id = $1 AND a.effective_from <= $3 AND (a.effective_to IS NULL OR a.effective_to >= $2) ORDER BY (a.org_unit_id IS NOT NULL) DESC, a.effective_from DESC, a.created_at DESC`, [tenantId, from, to], ); const assignments = assignmentsQuery.rows; function findAssignment(empId: string, date: string, unitId: string): AssignmentRow | undefined { return assignments.find((a) => { if (a.employee_id !== empId) return false; if (a.effective_from > date) return false; if (a.effective_to && a.effective_to < date) return false; if (a.org_unit_id !== null && a.org_unit_id !== unitId) return false; return true; }); } // Gather unit terms interface OrgUnitTermsRow { org_unit_id: string; effective_from: string; effective_to: string | null; pool_rule: RuleNode | string | null; shift_norm_minutes: number | null; rounding_unit_minor: string | number | null; } const termsQuery = await client.query( `SELECT org_unit_id, effective_from::text AS effective_from, effective_to::text AS effective_to, pool_rule, shift_norm_minutes, rounding_unit_minor FROM staff_org_unit_terms WHERE tenant_id = $1 AND effective_from <= $3 AND (effective_to IS NULL OR effective_to >= $2) ORDER BY effective_from DESC`, [tenantId, from, to], ); const warnings: PayrollWarning[] = []; if (sessions.length === 0) { warnings.push({ kind: 'no_confirmed_sessions', message: 'Нет подтверждённых смен за выбранный период.', }); } // Group sessions by date & org_unit_id const groupMap = new Map(); for (const session of sessions) { const key = `${session.business_date}:${session.org_unit_id}`; const list = groupMap.get(key) ?? []; list.push(session); groupMap.set(key, list); } interface ProcessedLine extends PayrollLine { payRuleId: string; } const allLines: ProcessedLine[] = []; const processedEmployees = new Map< string, { display_name: string; position: string; worked_minutes: number; days: number; accrued_minor: number; } >(); // Initialize all employees with sessions in tracking map for (const session of sessions) { if (!processedEmployees.has(session.employee_id)) { processedEmployees.set(session.employee_id, { display_name: session.display_name, position: session.position, worked_minutes: 0, days: 0, accrued_minor: 0, }); } } for (const [key, groupSessions] of groupMap.entries()) { const [date, unitId] = key.split(':') as [string, string]; const unitName = groupSessions[0]?.org_unit_name ?? unitId; const rev = revenueMap.get(key); if (rev === undefined || rev === null) { warnings.push({ kind: 'missing_revenue', business_date: date, org_unit_id: unitId, message: `Нет выручки за дату ${date} (${unitName})`, }); } const participants: Array = []; for (const session of groupSessions) { const tracker = processedEmployees.get(session.employee_id)!; tracker.worked_minutes += session.worked_minutes; if (session.worked_minutes > 0) tracker.days += 1; const assignment = findAssignment(session.employee_id, date, unitId); if (!assignment) { warnings.push({ kind: 'missing_pay_assignment', business_date: date, employee_id: session.employee_id, org_unit_id: unitId, message: `Отсутствует зарплатный профиль для сотрудника ${session.display_name} на дату ${date}`, }); continue; } const ruleObj: RuleNode = typeof assignment.rule === 'string' ? (JSON.parse(assignment.rule) as RuleNode) : assignment.rule; const rawParams = (typeof assignment.params === 'string' ? (JSON.parse(assignment.params) as Record) : assignment.params) ?? {}; const poolWeight = assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined ? Number(assignment.pool_weight_bp) : Number(assignment.default_pool_weight_bp ?? 0); const params: Record = {}; for (const [k, v] of Object.entries(rawParams)) { if (typeof v === 'number') params[k] = v; } if (params.rate_minor === undefined) params.rate_minor = 10000; if (params.percent_bp === undefined) params.percent_bp = 0; if (params.guarantee_minor === undefined) params.guarantee_minor = 0; participants.push({ employeeId: session.employee_id, workedMinutes: session.worked_minutes, ruleCode: assignment.rule_code, rule: ruleObj, params, poolWeightBp: poolWeight, payRuleId: assignment.pay_rule_id, }); } if (participants.length === 0) continue; const unitTerms = termsQuery.rows.find( (t) => t.org_unit_id === unitId && t.effective_from <= date && (!t.effective_to || t.effective_to >= date), ); let poolRule: RuleNode | undefined = cateringPreset.poolRule; if (unitTerms?.pool_rule) { poolRule = typeof unitTerms.pool_rule === 'string' ? (JSON.parse(unitTerms.pool_rule) as RuleNode) : unitTerms.pool_rule; } const shiftNormMinutes = unitTerms?.shift_norm_minutes ?? cateringPreset.shiftNormMinutes; const roundingUnitMinor = Number(unitTerms?.rounding_unit_minor ?? 100); const dayResult = calculateDay({ businessDate: date, orgUnitId: unitId, metrics: { revenue_minor: rev ?? 0 }, ...(poolRule ? { poolRule } : {}), shiftNormMinutes, roundingUnitMinor, currencyCode: 'RUB', participants, }); const participantRuleMap = new Map(participants.map((p) => [p.employeeId, p.payRuleId])); for (const line of dayResult.lines) { allLines.push({ ...line, payRuleId: participantRuleMap.get(line.employeeId)!, }); const tracker = processedEmployees.get(line.employeeId); if (tracker) { tracker.accrued_minor += line.amountMinor; } } } // Replace old lines for this run await client.query(`DELETE FROM staff_payroll_lines WHERE tenant_id = $1 AND run_id = $2`, [ tenantId, runId, ]); for (const line of allLines) { await client.query( `INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code, worked_minutes, pool_amount_minor, pool_divisor_bp, pool_share_minor, raw_amount_minor, amount_minor, currency_code, explain) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, [ newUlid(), tenantId, runId, line.employeeId, line.orgUnitId, line.businessDate, line.payRuleId, line.ruleCode, line.workedMinutes, line.poolAmountMinor, line.poolDivisorBp, line.poolShareMinor, line.rawAmountMinor, line.amountMinor, line.currencyCode, JSON.stringify(line.explain), ], ); } const totalAccruedMinor = allLines.reduce((sum, line) => sum + BigInt(line.amountMinor), 0n); const calculatedAt = new Date().toISOString(); await client.query( `UPDATE staff_payroll_runs SET status = 'calculated', total_accrued_minor = $3, calculated_at = $4, updated_at = now(), revision = revision + 1 WHERE tenant_id = $1 AND id = $2`, [tenantId, runId, totalAccruedMinor.toString(), calculatedAt], ); await client.query( `INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id) VALUES ($1, $2, 'staff.payroll.run_calculated', 1, 'PayrollRun', $3, 'user', $4, $5, $6)`, [ newUlid(), tenantId, runId, actorId, { period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString() }, correlationId, ], ); 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, 'payroll_run.calculated', 'PayrollRun', $4, $5, $6)`, [ newUlid(), tenantId, actorId, runId, { period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString(), lines: allLines.length, }, correlationId, ], ); const totals: EmployeeRunTotal[] = [...processedEmployees.entries()] .map(([empId, data]) => ({ employee_id: empId, display_name: data.display_name, position: data.position, worked_minutes: data.worked_minutes, days: data.days, accrued_minor: data.accrued_minor.toString(), })) .sort((a, b) => a.display_name.localeCompare(b.display_name)); return { run_id: runId, status: 'calculated', org_unit_id: orgUnitId, period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString(), currency_code: 'RUB', calculated_at: calculatedAt, totals, warnings, lines_count: allLines.length, }; } export async function approvePayrollRun( client: PoolClient, context: RequestContext, runId: string, ): Promise { const tenantId = context.tenantId; const actorId = context.actorId; const correlationId = context.correlationId; const runQuery = await client.query( `SELECT id, period_start::text AS period_start, period_end::text AS period_end, status, total_accrued_minor::text AS total_accrued_minor FROM staff_payroll_runs WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [tenantId, runId], ); if (runQuery.rowCount === 0 || !runQuery.rows[0]) { const err = new Error('Расчёт зарплаты не найден.'); (err as { statusCode?: number }).statusCode = 404; throw err; } const run = runQuery.rows[0]; if (run.status !== 'calculated') { const err = new Error('Утвердить можно только расчёт в статусе calculated.'); (err as { statusCode?: number }).statusCode = 400; throw err; } // Aggregate accrued lines per employee const aggregatesQuery = await client.query<{ employee_id: string; amount_minor: string | number; }>( `SELECT employee_id, SUM(amount_minor)::bigint AS amount_minor FROM staff_payroll_lines WHERE tenant_id = $1 AND run_id = $2 GROUP BY employee_id`, [tenantId, runId], ); let postedCount = 0; for (const row of aggregatesQuery.rows) { const amountMinor = BigInt(row.amount_minor); if (amountMinor <= 0n) continue; const externalKey = `payroll_run:${runId}:${row.employee_id}`; const ledgerResult = await client.query( `INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, ref_id, correlation_id, note, created_by, external_key) VALUES ($1, $2, $3, $4, 'accrual', $5, 'RUB', 'payroll_run', $6, $7, $8, $9, $10) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING RETURNING id`, [ newUlid(), tenantId, row.employee_id, run.period_end, amountMinor.toString(), runId, correlationId, `Начисление зарплаты за период ${run.period_start} – ${run.period_end}`, actorId, externalKey, ], ); if ((ledgerResult.rowCount ?? 0) > 0) { postedCount += 1; } } const approvedAt = new Date().toISOString(); await client.query( `UPDATE staff_payroll_runs SET status = 'approved', approved_by = $3, approved_at = $4, updated_at = now(), revision = revision + 1 WHERE tenant_id = $1 AND id = $2`, [tenantId, runId, actorId, approvedAt], ); await client.query( `INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id) VALUES ($1, $2, 'staff.payroll.run_approved', 1, 'PayrollRun', $3, 'user', $4, $5, $6)`, [ newUlid(), tenantId, runId, actorId, { period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor, }, correlationId, ], ); 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, 'payroll_run.approved', 'PayrollRun', $4, $5, $6)`, [ newUlid(), tenantId, actorId, runId, { period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor, }, correlationId, ], ); return { run_id: runId, status: 'approved', approved_by: actorId, approved_at: approvedAt, total_accrued_minor: run.total_accrued_minor, entries_posted: postedCount, }; } export async function getPayrollRunDetails( client: PoolClient, tenantId: string, runId: string, ): Promise<{ run: PayrollRunDetailsRow; lines: PayrollLineDetailsRow[] } | null> { const runQuery = await client.query( `SELECT r.id, r.org_unit_id, u.name AS org_unit_name, r.period_start::text AS period_start, r.period_end::text AS period_end, r.status, r.total_accrued_minor::text AS total_accrued_minor, r.currency_code, r.calculated_at, r.approved_by, r.approved_at, r.revision FROM staff_payroll_runs r LEFT JOIN staff_org_units u ON u.tenant_id = r.tenant_id AND u.id = r.org_unit_id WHERE r.tenant_id = $1 AND r.id = $2`, [tenantId, runId], ); if (runQuery.rowCount === 0 || !runQuery.rows[0]) return null; const run = runQuery.rows[0]; const linesQuery = await client.query( `SELECT l.id, l.employee_id, e.display_name, e.position, l.org_unit_id, u.name AS org_unit_name, l.business_date::text AS business_date, l.pay_rule_id, l.rule_code, l.worked_minutes, l.pool_amount_minor::text AS pool_amount_minor, l.pool_divisor_bp, l.pool_share_minor::text AS pool_share_minor, l.raw_amount_minor::text AS raw_amount_minor, l.amount_minor::text AS amount_minor, l.currency_code, l.explain FROM staff_payroll_lines l JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id JOIN staff_org_units u ON u.tenant_id = l.tenant_id AND u.id = l.org_unit_id WHERE l.tenant_id = $1 AND l.run_id = $2 ORDER BY l.business_date, e.display_name`, [tenantId, runId], ); return { run, lines: linesQuery.rows }; }