krona-staff/tests/unit/payroll-rule.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

241 lines
8.6 KiB
TypeScript
Raw Permalink 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 {
RuleError,
describeRule,
evaluateRule,
validateRule,
type RuleContext,
type RuleNode,
} from '../../src/domain/payroll/rule.js';
import {
cateringPreset,
defaultParams,
deliveryPreset,
findRoleTemplate,
retailPreset,
servicesPreset,
} from '../../src/domain/payroll/presets.js';
const RUB = 100;
function participantOf(
preset: typeof cateringPreset,
roleCode: string,
overrides: Partial<DayParticipant> & { hours: number },
): DayParticipant {
const role = findRoleTemplate(preset, roleCode)!;
const { hours, ...rest } = overrides;
return {
employeeId: 'employee',
workedMinutes: Math.round(hours * 60),
ruleCode: role.code,
rule: role.rule,
params: { ...defaultParams(role), ...(rest.params ?? {}) },
poolWeightBp: role.poolWeightBp,
...(rest.items ? { items: rest.items } : {}),
};
}
function amountRubles(participant: DayParticipant, metrics: Record<string, number> = {}): number {
const result = calculateDay({
businessDate: '2026-04-01',
orgUnitId: 'UNIT',
metrics,
shiftNormMinutes: 600,
participants: [participant],
});
return result.lines[0]!.amountMinor / RUB;
}
describe('pay rules: delivery, the case described by the owner', () => {
const orders = [
{ amount_minor: 3_000 * RUB, distance_km: 3 },
{ amount_minor: 7_000 * RUB, distance_km: 3 },
{ amount_minor: 9_000 * RUB, distance_km: 12 },
];
it('prices large orders per order by distance and keeps them out of the percent base', () => {
const courier = participantOf(deliveryPreset, 'courier', { hours: 8, items: orders });
// 8 h × 150 ₽ = 1200; 5% of the 3000 ₽ order = 150; 7000 ₽ order (3 km) = 250; 9000 ₽ order (12 km) = 500.
expect(amountRubles(courier)).toBe(2100);
});
it('pays a flat amount per delivery with a surcharge for the distant ones', () => {
const courier = participantOf(deliveryPreset, 'courier_per_delivery', {
hours: 8,
items: orders,
});
// three deliveries × 200 ₽ + one over 10 km × 100 ₽
expect(amountRubles(courier)).toBe(700);
});
});
describe('pay rules: progressive and flat percent scales', () => {
const sales = { personal_sales_minor: 400_000 * RUB };
it('applies a progressive scale layer by layer', () => {
const seller = participantOf(retailPreset, 'seller_progressive', { hours: 10 });
// 2500 ₽ shift + 3% of the first 100k + 5% of the next 200k + 7% of the remaining 100k
expect(amountRubles(seller, sales)).toBe(22_500);
});
it('applies a flat scale to the whole amount at the reached level', () => {
const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 });
expect(amountRubles(seller, sales)).toBe(30_000);
});
it('uses the lowest band when the result is below the first threshold', () => {
const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 });
expect(amountRubles(seller, { personal_sales_minor: 50_000 * RUB })).toBe(3_500);
});
});
describe('pay rules: conditional bonuses and guarantees', () => {
it('adds the team bonus only when both conditions hold', () => {
const rule = findRoleTemplate(cateringPreset, 'pool_member_with_target')!;
const base: DayParticipant = {
employeeId: 'cook',
workedMinutes: 720,
ruleCode: rule.code,
rule: rule.rule,
params: defaultParams(rule),
poolWeightBp: rule.poolWeightBp,
};
const withThreeCooks = calculateDay({
businessDate: '2026-04-02',
orgUnitId: 'UNIT',
metrics: { revenue_minor: 120_000 * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: 720,
participants: [base, { ...base, employeeId: 'cook-2' }, { ...base, employeeId: 'cook-3' }],
});
const withTwoCooks = calculateDay({
businessDate: '2026-04-02',
orgUnitId: 'UNIT',
metrics: { revenue_minor: 120_000 * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: 720,
participants: [base, { ...base, employeeId: 'cook-2' }],
});
const bonusPaid = withThreeCooks.lines[0]!.rawAmountMinor;
const poolPerCook = withThreeCooks.lines[0]!.poolShareMinor;
expect(bonusPaid - poolPerCook - 1200 * RUB).toBe(500 * RUB);
expect(withTwoCooks.lines[0]!.rawAmountMinor - withTwoCooks.lines[0]!.poolShareMinor).toBe(
1200 * RUB,
);
});
it('keeps a master at the shift guarantee on an empty day', () => {
const master = participantOf(servicesPreset, 'master', { hours: 12, items: [] });
expect(amountRubles(master)).toBe(2_000);
});
it('pays a master different percents for own and house clients', () => {
const master = participantOf(servicesPreset, 'master', {
hours: 12,
items: [
{ amount_minor: 20_000 * RUB, is_own_client: 1 },
{ amount_minor: 10_000 * RUB, is_own_client: 0 },
],
});
expect(amountRubles(master)).toBe(13_500);
});
});
describe('pay rules: engine behaviour', () => {
const context: RuleContext = {
metrics: { revenue_minor: 100_000 * RUB },
params: { percent_bp: 250 },
minutes: 720,
participants: 3,
poolShareMinor: 500_000,
shiftNormMinutes: 720,
items: [],
};
it('treats a missing metric or parameter as zero instead of failing', () => {
expect(evaluateRule({ type: 'metric', key: 'unknown_metric' }, context)).toBe(0);
expect(evaluateRule({ type: 'param', key: 'unknown_param' }, context)).toBe(0);
});
it('never divides by zero', () => {
const node: RuleNode = {
type: 'div',
left: { type: 'const', value: 100 },
right: { type: 'const', value: 0 },
};
expect(evaluateRule(node, context)).toBe(0);
});
it('rejects an item field used outside a per-item block', () => {
expect(() => evaluateRule({ type: 'itemField', field: 'amount_minor' }, context)).toThrow(
RuleError,
);
});
it('rejects scales whose steps are out of order', () => {
expect(() =>
validateRule({
type: 'tier',
on: { type: 'metric', key: 'revenue_minor' },
bands: [
{ upTo: 1000, value: { type: 'const', value: 1 } },
{ upTo: 500, value: { type: 'const', value: 2 } },
],
}),
).toThrow(/по возрастанию/);
});
it('rejects an open step that is not the last one', () => {
expect(() =>
validateRule({
type: 'progressive',
base: { type: 'metric', key: 'revenue_minor' },
bands: [
{ upTo: null, value: { type: 'const', value: 1 } },
{ upTo: 1000, value: { type: 'const', value: 2 } },
],
}),
).toThrow(/только последней/);
});
it('rejects keys that the editor must never produce', () => {
expect(() => validateRule({ type: 'metric', key: 'Выручка' })).toThrow(RuleError);
});
it('accepts every rule shipped in the presets', () => {
for (const preset of [cateringPreset, retailPreset, deliveryPreset, servicesPreset]) {
if (preset.poolRule) validateRule(preset.poolRule);
for (const role of preset.roles) validateRule(role.rule);
}
});
it('describes a rule in words for the editor and the payslip', () => {
const role = findRoleTemplate(cateringPreset, 'revenue_percent')!;
const text = describeRule(role.rule, {
metrics: { revenue_minor: 'выручка точки' },
params: {
percent_bp: 'личный процент',
rate_minor: 'ставка за час',
guarantee_minor: 'гарантия смены',
},
});
expect(text).toBe(
'наибольшее из (личный процент от «выручка точки» + ставка за час × часы смены; гарантия смены)',
);
});
it('describes the delivery rule with its filters', () => {
const role = findRoleTemplate(deliveryPreset, 'courier')!;
const text = describeRule(role.rule, {
params: { rate_minor: 'ставка за час', percent_bp: 'процент' },
itemFields: { amount_minor: 'сумма заказа', distance_km: 'дальность' },
itemFieldUnits: { amount_minor: 'money', distance_km: 'number' },
});
// Thresholds are printed in rubles, not in the kopecks the rule actually stores.
expect(text).toMatch(/сумма «сумма заказа» по позициям \(сумма заказа ≤ 5\s?000 ₽\)/);
expect(text).toMatch(/за каждую позицию \(сумма заказа > 5\s?000 ₽\)/);
expect(text).toContain('по шкале от «дальность»: до 5 — 250 ₽; свыше — 500 ₽');
});
});