krona-staff/src/domain/payroll/service.ts

1000 lines
31 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 { 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<string, number>;
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, unknown> | 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<void> {
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<PayRuleRow[]> {
await ensureDefaultPayRules(client, tenantId, actorId);
const result = await client.query<PayRuleRow>(
`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<PayAssignmentRecord | null> {
const result = await client.query<EmployeePayAssignmentDbRow>(
`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<string, unknown>)
: row.params) ?? {};
const params: Record<string, number> = {};
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<PayAssignmentRecord> {
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<string, number> = {
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<CalculatePayrollResult> {
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<string, number>();
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, unknown> | 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<AssignmentRow>(
`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<OrgUnitTermsRow>(
`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<string, typeof sessions>();
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<DayParticipant & { payRuleId: string }> = [];
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<string, unknown>)
: 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<string, number> = {};
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<ApprovePayrollResult> {
const tenantId = context.tenantId;
const actorId = context.actorId;
const correlationId = context.correlationId;
const runQuery = await client.query<PayrollRunDbRow>(
`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<PayrollRunDetailsRow>(
`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<PayrollLineDetailsRow>(
`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 };
}