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 & { 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 = {}): 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 ₽'); }); });