krona-staff/tests/unit/payroll-auto-calc.test.ts

632 lines
22 KiB
TypeScript

import { readFileSync } from 'node:fs';
import type { PoolClient, QueryResult, QueryResultRow } from 'pg';
import { describe, expect, it } from 'vitest';
import type { RequestContext } from '../../src/core/context.js';
import { newUlid } from '../../src/core/ids.js';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
import { cateringPreset, defaultParams, findRoleTemplate } from '../../src/domain/payroll/presets.js';
import {
approvePayrollRun,
calculatePayrollPeriod,
getEmployeePayAssignment,
saveEmployeePayAssignment,
} from '../../src/domain/payroll/service.js';
const RUB = 100;
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
function mockContext(overrides?: Partial<RequestContext>): RequestContext {
return {
tenantId: newUlid(),
actorId: newUlid(),
correlationId: newUlid(),
permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']),
...overrides,
};
}
describe('Owner spreadsheet payroll schemes', () => {
it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => {
const revenueRub = 150_000;
const revenueMinor = revenueRub * RUB;
// 3 cooks working full 12-hour shifts
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp, // 10000
};
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250, // 2.5%
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: percentRole.poolWeightBp, // 0
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3, admin],
});
// Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽
// Hourly: 12h * 100 ₽/h = 1,200 ₽
// Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks
const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(lineCook1.poolShareMinor).toBe(550_000);
expect(lineCook1.amountMinor).toBe(670_000);
expect(lineCook2.poolShareMinor).toBe(550_000);
expect(lineCook2.amountMinor).toBe(670_000);
expect(lineCook3.poolShareMinor).toBe(550_000);
expect(lineCook3.amountMinor).toBe(670_000);
// Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.poolShareMinor).toBe(0);
expect(lineAdmin.amountMinor).toBe(495_000);
// Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks)
const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0);
expect(totalDay).toBe(2_505_000);
});
it('correctly redistributes pool when a cook leaves early (time-slice model)', () => {
const revenueMinor = 150_000 * RUB;
// Cook 1 leaves after 6 hours (half shift)
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 360, // 6h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// Cook 2 and Cook 3 work full 12h shifts
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3],
});
// Pool = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// Time-slice redistribution:
// First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each
// Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each
// Cook 1 pool share = 2,750 ₽ (275,000 kopecks)
// Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
// Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(line1.poolShareMinor).toBe(275_000);
expect(line2.poolShareMinor).toBe(687_500);
expect(line3.poolShareMinor).toBe(687_500);
// The entire pool of 16,500 ₽ is completely distributed without loss
expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000);
// Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks)
// Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks)
expect(line1.amountMinor).toBe(335_000);
expect(line2.amountMinor).toBe(807_500);
expect(line3.amountMinor).toBe(807_500);
});
it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => {
// Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽
const revenueMinor = 50_000 * RUB;
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250,
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: 0,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [admin],
});
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks
});
});
describe('Payroll service calculation and warning engine', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!;
it('calculates payroll period from confirmed sessions and revenue metrics', async () => {
const executedQueries: string[] = [];
const unitId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const client = makeMockClient({
query: (sql) => {
executedQueries.push(sql);
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
// Confirmed work sessions
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-01',
worked_minutes: 720,
display_name: 'Иван Поваров',
position: 'Повар',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_daily_metrics')) {
return {
rows: [
{
org_unit_id: unitId,
business_date: '2026-05-01',
revenue_minor: '10000000', // 100,000 ₽
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_pay_assignments a')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
pay_rule_id: ruleId,
effective_from: '2026-01-01',
effective_to: null,
params: { rate_minor: 10000 },
pool_weight_bp: 10000,
rule_code: 'pool_member',
rule: defaultRole.rule,
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_org_unit_terms')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-01',
orgUnitId: unitId,
});
expect(result.status).toBe('calculated');
expect(result.warnings).toHaveLength(0);
expect(result.totals).toHaveLength(1);
const firstTotal = result.totals[0]!;
expect(firstTotal.display_name).toBe('Иван Поваров');
expect(firstTotal.worked_minutes).toBe(720);
// 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks
expect(firstTotal.accrued_minor).toBe('1220000');
expect(result.total_accrued_minor).toBe('1220000');
// Check that staff_payroll_lines insertion occurred
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true);
// Check outbox and audit log
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true);
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true);
});
it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => {
const unitId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-02',
worked_minutes: 600,
display_name: 'Петр Безпрофилев',
position: 'Курьер',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
// Return NO metrics (missing revenue) and NO assignments
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-02',
to: '2026-05-02',
});
expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true);
expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true);
});
it('warns when no confirmed sessions exist for the period', async () => {
const client = makeMockClient({
query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }),
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-06-01',
to: '2026-06-30',
});
expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true);
expect(result.totals).toHaveLength(0);
expect(result.total_accrued_minor).toBe('0');
});
it('rejects recalculation if run is already approved', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return {
rows: [{ id: runId, status: 'approved' }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(
calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-31',
}),
).rejects.toThrow('Расчёт за этот период уже утверждён.');
});
});
describe('Payroll approval and ledger idempotency', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
it('posts accrual lines to ledger with external_key and marks run approved', async () => {
let ledgerInsertQuery = '';
let ledgerInsertParams: unknown[] = [];
const runId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql, params) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'calculated',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_payroll_lines')) {
return {
rows: [
{
employee_id: empId,
amount_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('INSERT INTO staff_payroll_ledger')) {
ledgerInsertQuery = sql;
ledgerInsertParams = params;
return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
},
});
const result = await approvePayrollRun(client, mockContext(), runId);
expect(result.status).toBe('approved');
expect(result.entries_posted).toBe(1);
expect(result.total_accrued_minor).toBe('1220000');
// Verify external_key structure and ON CONFLICT clause
expect(ledgerInsertQuery).toContain('ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING');
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
});
it('rejects approval if run is not in calculated status', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'approved',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
'Утвердить можно только расчёт в статусе calculated.',
);
});
});
describe('Pay assignment management', () => {
it('saves and retrieves pay assignment with default fallbacks', async () => {
let savedParamsJson = '';
const tenantId = newUlid();
const actorId = newUlid();
const corrId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const assignId = newUlid();
const client = {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
if (sql.includes('FROM staff_employees')) {
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) {
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
}
if (sql.includes('INSERT INTO staff_pay_assignments')) {
savedParamsJson = String(params[6]);
return Promise.resolve({ rows: [], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_assignments a')) {
return Promise.resolve({
rows: [
{
id: assignId,
employee_id: empId,
org_unit_id: null,
pay_rule_id: ruleId,
effective_from: '2026-05-01',
effective_to: null,
params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 },
pool_weight_bp: 10000,
note: 'Главный повар',
rule_code: 'pool_member',
rule_name: 'Повар (доля от фонда)',
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
});
}
return Promise.resolve({ rows: [], rowCount: 0 });
},
release: () => {},
} as unknown as PoolClient;
const saved = await saveEmployeePayAssignment(
client,
tenantId,
actorId,
corrId,
empId,
{
pay_rule_id: ruleId,
rate_minor: 12000,
note: 'Главный повар',
},
);
expect(saved.rate_minor).toBe(12000);
expect(saved.pool_weight_bp).toBe(10000);
expect(saved.note).toBe('Главный повар');
expect(savedParamsJson).toContain('"rate_minor":12000');
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
expect(fetched).not.toBeNull();
expect(fetched?.rate_minor).toBe(12000);
});
});
describe('Tenant and employee isolation guarantees', () => {
it('enforces tenant_id in every database query in payroll service', () => {
const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8');
// All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id
const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? [];
expect(sqlStatements.length).toBeGreaterThan(5);
for (const sql of sqlStatements) {
expect(sql).toContain('tenant_id');
}
});
it('restricts /payroll query to actor employee or requires manager permissions', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
// Ensure the employee isolation clause is present in GET /payroll
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
expect(operationsSrc).toContain('context.permissions.has(\'staff:payroll:read\')');
expect(operationsSrc).toContain('context.permissions.has(\'staff:payslip:read_own\')');
});
it('restricts pay-assignment access to owner employee or manager', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
expect(operationsSrc).toContain('!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId');
});
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
expect(migrationSql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;');
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
expect(migrationSql).toContain(
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
);
});
});