246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
calculateDay,
|
||
roundAmount,
|
||
summarizePeriod,
|
||
type DayParticipant,
|
||
} from '../../src/domain/payroll/calculator.js';
|
||
import { datesInPeriod, ledgerBalance, payrollWeek } from '../../src/domain/payroll/period.js';
|
||
import {
|
||
cateringPreset,
|
||
defaultParams,
|
||
findRoleTemplate,
|
||
} from '../../src/domain/payroll/presets.js';
|
||
|
||
const RUB = 100;
|
||
|
||
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
|
||
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
|
||
const hourlyRole = findRoleTemplate(cateringPreset, 'hourly')!;
|
||
|
||
function cook(employeeId: string, hours: number, bonusRubles = 500): DayParticipant {
|
||
return {
|
||
employeeId,
|
||
workedMinutes: Math.round(hours * 60),
|
||
ruleCode: poolRole.code,
|
||
rule: poolRole.rule,
|
||
params: { ...defaultParams(poolRole), bonus_minor: bonusRubles * RUB },
|
||
poolWeightBp: poolRole.poolWeightBp,
|
||
};
|
||
}
|
||
|
||
function admin(employeeId: string, hours: number, percentBp: number): DayParticipant {
|
||
return {
|
||
employeeId,
|
||
workedMinutes: Math.round(hours * 60),
|
||
ruleCode: percentRole.code,
|
||
rule: percentRole.rule,
|
||
params: { ...defaultParams(percentRole), percent_bp: percentBp },
|
||
poolWeightBp: percentRole.poolWeightBp,
|
||
};
|
||
}
|
||
|
||
function hourly(employeeId: string, hours: number, rateRubles: number): DayParticipant {
|
||
return {
|
||
employeeId,
|
||
workedMinutes: Math.round(hours * 60),
|
||
ruleCode: hourlyRole.code,
|
||
rule: hourlyRole.rule,
|
||
params: { rate_minor: rateRubles * RUB },
|
||
poolWeightBp: hourlyRole.poolWeightBp,
|
||
};
|
||
}
|
||
|
||
function day(businessDate: string, revenueRubles: number, participants: DayParticipant[]) {
|
||
return calculateDay({
|
||
businessDate,
|
||
orgUnitId: 'GORSKY',
|
||
metrics: { revenue_minor: revenueRubles * RUB },
|
||
poolRule: cateringPreset.poolRule!,
|
||
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
||
participants,
|
||
});
|
||
}
|
||
|
||
function paidRubles(result: ReturnType<typeof day>): Record<string, number> {
|
||
return Object.fromEntries(result.lines.map((line) => [line.employeeId, line.amountMinor / RUB]));
|
||
}
|
||
|
||
describe('payroll: days taken from the owner spreadsheet, calculated through the catering preset', () => {
|
||
it('07.01.2026 Горский: three cooks and an administrator', () => {
|
||
const result = day('2026-01-07', 123_569, [
|
||
cook('bekzad', 12),
|
||
cook('nurzada', 12),
|
||
cook('ksyusha', 12),
|
||
admin('vika', 12, 300),
|
||
]);
|
||
expect(paidRubles(result)).toEqual({
|
||
bekzad: 6231,
|
||
nurzada: 6231,
|
||
ksyusha: 6231,
|
||
vika: 4907,
|
||
});
|
||
});
|
||
|
||
it('08.01.2026 Горский: two cooks share the same pool', () => {
|
||
const result = day('2026-01-08', 89_647, [
|
||
cook('bekzad', 12),
|
||
cook('ksyusha', 12),
|
||
admin('vika', 12, 300),
|
||
]);
|
||
expect(paidRubles(result)).toEqual({ bekzad: 6631, ksyusha: 6631, vika: 3889 });
|
||
});
|
||
|
||
it('10.01.2026 Горский: thirteen-hour shifts, one cook without the personal bonus', () => {
|
||
const result = day('2026-01-10', 134_161, [
|
||
cook('bekzad', 13),
|
||
cook('nurzada', 13),
|
||
cook('ksyusha', 13),
|
||
cook('pulat', 13, 0),
|
||
admin('nastya', 13, 250),
|
||
]);
|
||
expect(paidRubles(result)).toEqual({
|
||
bekzad: 5489,
|
||
nurzada: 5489,
|
||
ksyusha: 5489,
|
||
pulat: 4989,
|
||
nastya: 4654,
|
||
});
|
||
});
|
||
|
||
it('12.01.2026 Горский: the administrator falls back to the guaranteed minimum', () => {
|
||
const result = day('2026-01-12', 56_620, [
|
||
cook('bekzad', 12),
|
||
cook('nurzada', 12),
|
||
admin('nastya', 12, 250),
|
||
]);
|
||
expect(paidRubles(result).nastya).toBe(3000);
|
||
});
|
||
|
||
it('sheet "Расчет ЗП с доп. поваром": a cook leaving after five hours', () => {
|
||
const result = day('2026-01-20', 103_395, [
|
||
cook('leaver', 5, 0),
|
||
cook('cook-1', 12, 0),
|
||
cook('cook-2', 12, 0),
|
||
cook('cook-3', 12, 0),
|
||
]);
|
||
expect(paidRubles(result)).toEqual({
|
||
leaver: 1685,
|
||
'cook-1': 4596,
|
||
'cook-2': 4596,
|
||
'cook-3': 4596,
|
||
});
|
||
});
|
||
|
||
it('keeps hourly staff on a flat rate and out of the pool divisor', () => {
|
||
const withoutTrainee = day('2026-02-06', 141_684, [
|
||
cook('bekzad', 12),
|
||
cook('nurzada', 12),
|
||
cook('pulat', 12, 0),
|
||
]);
|
||
const withTrainee = day('2026-02-06', 141_684, [
|
||
cook('bekzad', 12),
|
||
cook('nurzada', 12),
|
||
cook('pulat', 12, 0),
|
||
hourly('trainee', 12, 200),
|
||
]);
|
||
expect(paidRubles(withTrainee).bekzad).toBe(paidRubles(withoutTrainee).bekzad);
|
||
expect(paidRubles(withTrainee).trainee).toBe(2400);
|
||
});
|
||
|
||
it('counts an additional cook as half a participant when the organization configures it so', () => {
|
||
const extra: DayParticipant = { ...cook('extra', 12, 0), poolWeightBp: 5_000 };
|
||
const result = day('2026-02-07', 100_000, [
|
||
cook('cook-1', 12, 0),
|
||
cook('cook-2', 12, 0),
|
||
extra,
|
||
]);
|
||
const share = (id: string) =>
|
||
result.lines.find((line) => line.employeeId === id)!.poolShareMinor;
|
||
expect(share('extra') * 2).toBeCloseTo(share('cook-1'), -1);
|
||
expect(result.poolDivisorBp).toBe(25_000);
|
||
});
|
||
});
|
||
describe('payroll: invariants', () => {
|
||
it('distributes the whole pool without losing kopecks', () => {
|
||
const result = day('2026-03-01', 77_777, [cook('a', 12), cook('b', 11, 0), cook('c', 7, 0)]);
|
||
expect(result.distributedPoolMinor).toBe(result.poolAmountMinor);
|
||
});
|
||
|
||
it('pays nothing to an employee who did not work', () => {
|
||
const result = day('2026-03-02', 50_000, [cook('present', 12), cook('absent', 0)]);
|
||
expect(paidRubles(result).absent).toBe(0);
|
||
});
|
||
|
||
it('still pays the hourly part when the daily metric is missing', () => {
|
||
const result = day('2026-03-03', 0, [cook('a', 12)]);
|
||
expect(result.poolAmountMinor).toBe(0);
|
||
expect(paidRubles(result).a).toBe(1700);
|
||
});
|
||
|
||
it('rounds every line to whole rubles', () => {
|
||
const result = day('2026-03-04', 98_765, [cook('a', 12), cook('b', 12, 0)]);
|
||
for (const line of result.lines) expect(line.amountMinor % RUB).toBe(0);
|
||
});
|
||
|
||
it('rounds half away from zero', () => {
|
||
expect(roundAmount(150)).toBe(200);
|
||
expect(roundAmount(149)).toBe(100);
|
||
expect(roundAmount(-150)).toBe(-200);
|
||
});
|
||
|
||
it('keeps the unrounded amount and the inputs for the payslip explanation', () => {
|
||
const result = day('2026-03-05', 120_000, [cook('a', 12)]);
|
||
const line = result.lines[0]!;
|
||
expect(line.rawAmountMinor).not.toBe(0);
|
||
expect(line.explain.rule).toContain('доля из фонда');
|
||
expect(line.explain.metrics).toMatchObject({ revenue_minor: 12_000_000 });
|
||
expect(line.explain.pool_share_minor).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('works for an organization without any pool at all', () => {
|
||
const result = calculateDay({
|
||
businessDate: '2026-03-06',
|
||
orgUnitId: 'SHOP',
|
||
metrics: {},
|
||
shiftNormMinutes: 480,
|
||
participants: [hourly('worker', 8, 300)],
|
||
});
|
||
expect(result.poolAmountMinor).toBe(0);
|
||
expect(paidRubles(result).worker).toBe(2400);
|
||
});
|
||
});
|
||
|
||
describe('payroll: periods and balances', () => {
|
||
it('uses Thursday as the first day of the payroll week', () => {
|
||
expect(payrollWeek('2026-01-02')).toEqual({ start: '2026-01-01', end: '2026-01-07' });
|
||
expect(payrollWeek('2026-01-08')).toEqual({ start: '2026-01-08', end: '2026-01-14' });
|
||
});
|
||
|
||
it('supports another week start without touching the calculation', () => {
|
||
expect(payrollWeek('2026-01-07', 1)).toEqual({ start: '2026-01-05', end: '2026-01-11' });
|
||
});
|
||
|
||
it('lists every business date of a period', () => {
|
||
expect(datesInPeriod({ start: '2026-01-01', end: '2026-01-07' })).toHaveLength(7);
|
||
});
|
||
|
||
it('carries the difference between accruals and payouts', () => {
|
||
expect(
|
||
ledgerBalance([
|
||
{ kind: 'accrual', amountMinor: 3_199_525 },
|
||
{ kind: 'payout', amountMinor: -3_190_000 },
|
||
]),
|
||
).toBe(9_525);
|
||
});
|
||
|
||
it('totals a week per employee', () => {
|
||
const days = [
|
||
day('2026-01-01', 100_397, [cook('a', 12), cook('b', 12, 0)]),
|
||
day('2026-01-02', 104_460, [cook('a', 12)]),
|
||
];
|
||
const totals = summarizePeriod(days);
|
||
expect(totals).toHaveLength(2);
|
||
expect(totals.find((total) => total.employeeId === 'a')?.days).toBe(2);
|
||
});
|
||
});
|