241 lines
8.6 KiB
TypeScript
241 lines
8.6 KiB
TypeScript
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 ₽');
|
||
});
|
||
});
|