150 lines
5 KiB
TypeScript
150 lines
5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
|
||
import { requiredPeriods, validateRule } from '../../src/domain/payroll/rule.js';
|
||
import {
|
||
b2bSalesPreset,
|
||
cateringPreset,
|
||
defaultParams,
|
||
findRoleTemplate,
|
||
payrollPresets,
|
||
shiftWorkPreset,
|
||
} from '../../src/domain/payroll/presets.js';
|
||
|
||
const RUB = 100;
|
||
|
||
function roleParticipant(
|
||
preset: (typeof payrollPresets)[number],
|
||
roleCode: string,
|
||
overrides: Partial<DayParticipant> & { hours: number },
|
||
): DayParticipant {
|
||
const role = findRoleTemplate(preset, roleCode)!;
|
||
const { hours, params, ...rest } = overrides;
|
||
return {
|
||
employeeId: 'employee',
|
||
workedMinutes: Math.round(hours * 60),
|
||
ruleCode: role.code,
|
||
rule: role.rule,
|
||
params: { ...defaultParams(role), ...(params ?? {}) },
|
||
poolWeightBp: role.poolWeightBp,
|
||
...rest,
|
||
};
|
||
}
|
||
|
||
function payRubles(
|
||
participant: DayParticipant,
|
||
options: {
|
||
metrics?: Record<string, number>;
|
||
windows?: Parameters<typeof calculateDay>[0]['windows'];
|
||
} = {},
|
||
): number {
|
||
const result = calculateDay({
|
||
businessDate: '2026-05-01',
|
||
orgUnitId: 'UNIT',
|
||
metrics: options.metrics ?? {},
|
||
shiftNormMinutes: 720,
|
||
participants: [participant],
|
||
...(options.windows ? { windows: options.windows } : {}),
|
||
});
|
||
return result.lines[0]!.amountMinor / RUB;
|
||
}
|
||
|
||
describe('shift work: night, overtime and holiday hours', () => {
|
||
it('adds twenty percent for night hours', () => {
|
||
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
|
||
hours: 12,
|
||
minutesByKind: { night: 8 * 60 },
|
||
});
|
||
// 3000 ₽ shift + 20% of (250 ₽ × 8 night hours) = 3000 + 400
|
||
expect(payRubles(guard)).toBe(3400);
|
||
});
|
||
|
||
it('pays overtime at one and a half for two hours and double beyond', () => {
|
||
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
|
||
hours: 16,
|
||
minutesByKind: { overtime: 4 * 60 },
|
||
});
|
||
// 3000 ₽ shift + 250 × 2 × 0.5 + 250 × 2 × 1 = 3000 + 250 + 500
|
||
expect(payRubles(guard)).toBe(3750);
|
||
});
|
||
|
||
it('adds a second rate for holiday hours', () => {
|
||
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
|
||
hours: 12,
|
||
minutesByKind: { holiday: 12 * 60 },
|
||
});
|
||
expect(payRubles(guard)).toBe(3000 + 250 * 12);
|
||
});
|
||
|
||
it('pays only the shift when there are no special hours', () => {
|
||
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12 });
|
||
expect(payRubles(guard)).toBe(3000);
|
||
});
|
||
});
|
||
describe('scales counted over a month, not over a day', () => {
|
||
const manager = () => roleParticipant(b2bSalesPreset, 'sales_manager', { hours: 8 });
|
||
|
||
it('reads the metric from the month window', () => {
|
||
const pay = payRubles(manager(), {
|
||
windows: { month: { metrics: { paid_margin_minor: 4_500_000 * RUB } } },
|
||
});
|
||
// 4000 ₽ shift + 2% of 2 000 000 + 4% of 1 000 000 + 6% of 1 000 000 + 8% of 500 000
|
||
expect(pay).toBe(4_000 + 40_000 + 40_000 + 60_000 + 40_000);
|
||
});
|
||
|
||
it('pays only the shift rate while the month window is empty', () => {
|
||
expect(payRubles(manager())).toBe(4_000);
|
||
});
|
||
|
||
it('reports which windows a rule needs', () => {
|
||
const role = findRoleTemplate(b2bSalesPreset, 'sales_manager')!;
|
||
expect([...requiredPeriods(role.rule)]).toEqual(['month']);
|
||
});
|
||
});
|
||
|
||
describe('waiter: upsell count and average check', () => {
|
||
const checks = [
|
||
{ amount_minor: 2_000 * RUB, category: 'main' },
|
||
{ amount_minor: 1_000 * RUB, category: 'dessert' },
|
||
{ amount_minor: 1_500 * RUB, category: 'dessert' },
|
||
];
|
||
|
||
it('pays a percent of personal sales, per-item upsell and the average check bonus', () => {
|
||
const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { hours: 10, items: checks });
|
||
// 2000 ₽ shift + 3% of 100 000 ₽ personal sales + 2 desserts × 40 ₽ + 500 ₽ (average check 1500 ₽)
|
||
expect(payRubles(waiter, { metrics: { personal_sales_minor: 100_000 * RUB } })).toBe(
|
||
2_000 + 3_000 + 80 + 500,
|
||
);
|
||
});
|
||
|
||
it('withholds the average check bonus when the target is not met', () => {
|
||
const waiter = roleParticipant(cateringPreset, 'waiter_upsell', {
|
||
hours: 10,
|
||
items: [{ amount_minor: 900 * RUB, category: 'main' }],
|
||
});
|
||
expect(payRubles(waiter, { metrics: { personal_sales_minor: 0 } })).toBe(2_000);
|
||
});
|
||
});
|
||
|
||
describe('preset catalogue', () => {
|
||
it('ships a validated rule for every role of every preset', () => {
|
||
for (const preset of payrollPresets) {
|
||
if (preset.poolRule) validateRule(preset.poolRule);
|
||
for (const role of preset.roles) {
|
||
validateRule(role.rule);
|
||
expect(role.params.length).toBeGreaterThan(0);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('covers the industries the research found', () => {
|
||
expect(payrollPresets.map((preset) => preset.code)).toEqual([
|
||
'catering_revenue_pool',
|
||
'retail_progressive_percent',
|
||
'delivery_per_order',
|
||
'services_master_percent',
|
||
'shift_night_overtime',
|
||
'b2b_sales_progressive',
|
||
'simple_hourly',
|
||
]);
|
||
});
|
||
});
|