1000 lines
31 KiB
TypeScript
1000 lines
31 KiB
TypeScript
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 };
|
||
}
|