krona-staff/tests/unit/payroll-industry.test.ts
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

150 lines
5 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 { 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',
]);
});
});