644 lines
23 KiB
TypeScript
644 lines
23 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import type { PoolClient, QueryResult, QueryResultRow } from 'pg';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { RequestContext } from '../../src/core/context.js';
|
|
import { newUlid } from '../../src/core/ids.js';
|
|
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
|
|
import {
|
|
cateringPreset,
|
|
defaultParams,
|
|
findRoleTemplate,
|
|
} from '../../src/domain/payroll/presets.js';
|
|
import {
|
|
approvePayrollRun,
|
|
calculatePayrollPeriod,
|
|
getEmployeePayAssignment,
|
|
saveEmployeePayAssignment,
|
|
} from '../../src/domain/payroll/service.js';
|
|
|
|
const RUB = 100;
|
|
|
|
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
|
|
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
|
|
|
|
function mockContext(overrides?: Partial<RequestContext>): RequestContext {
|
|
return {
|
|
tenantId: newUlid(),
|
|
actorId: newUlid(),
|
|
correlationId: newUlid(),
|
|
permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('Owner spreadsheet payroll schemes', () => {
|
|
it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => {
|
|
const revenueRub = 150_000;
|
|
const revenueMinor = revenueRub * RUB;
|
|
|
|
// 3 cooks working full 12-hour shifts
|
|
const cook1: DayParticipant = {
|
|
employeeId: 'COOK1',
|
|
workedMinutes: 720,
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp, // 10000
|
|
};
|
|
const cook2: DayParticipant = {
|
|
employeeId: 'COOK2',
|
|
workedMinutes: 720,
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp,
|
|
};
|
|
const cook3: DayParticipant = {
|
|
employeeId: 'COOK3',
|
|
workedMinutes: 720,
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp,
|
|
};
|
|
|
|
// 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee
|
|
const admin: DayParticipant = {
|
|
employeeId: 'ADMIN',
|
|
workedMinutes: 720,
|
|
ruleCode: percentRole.code,
|
|
rule: percentRole.rule,
|
|
params: {
|
|
rate_minor: 100 * RUB,
|
|
percent_bp: 250, // 2.5%
|
|
guarantee_minor: 3_000 * RUB,
|
|
},
|
|
poolWeightBp: percentRole.poolWeightBp, // 0
|
|
};
|
|
|
|
const dayResult = calculateDay({
|
|
businessDate: '2026-05-15',
|
|
orgUnitId: 'UNIT1',
|
|
metrics: { revenue_minor: revenueMinor },
|
|
poolRule: cateringPreset.poolRule!,
|
|
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
|
roundingUnitMinor: 100,
|
|
currencyCode: 'RUB',
|
|
participants: [cook1, cook2, cook3, admin],
|
|
});
|
|
|
|
// Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks)
|
|
expect(dayResult.poolAmountMinor).toBe(1_650_000);
|
|
|
|
// 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽
|
|
// Hourly: 12h * 100 ₽/h = 1,200 ₽
|
|
// Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks
|
|
const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
|
|
const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
|
|
const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
|
|
|
|
expect(lineCook1.poolShareMinor).toBe(550_000);
|
|
expect(lineCook1.amountMinor).toBe(670_000);
|
|
expect(lineCook2.poolShareMinor).toBe(550_000);
|
|
expect(lineCook2.amountMinor).toBe(670_000);
|
|
expect(lineCook3.poolShareMinor).toBe(550_000);
|
|
expect(lineCook3.amountMinor).toBe(670_000);
|
|
|
|
// Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee
|
|
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
|
|
expect(lineAdmin.poolShareMinor).toBe(0);
|
|
expect(lineAdmin.amountMinor).toBe(495_000);
|
|
|
|
// Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks)
|
|
const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0);
|
|
expect(totalDay).toBe(2_505_000);
|
|
});
|
|
|
|
it('correctly redistributes pool when a cook leaves early (time-slice model)', () => {
|
|
const revenueMinor = 150_000 * RUB;
|
|
|
|
// Cook 1 leaves after 6 hours (half shift)
|
|
const cook1: DayParticipant = {
|
|
employeeId: 'COOK1',
|
|
workedMinutes: 360, // 6h
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp,
|
|
};
|
|
// Cook 2 and Cook 3 work full 12h shifts
|
|
const cook2: DayParticipant = {
|
|
employeeId: 'COOK2',
|
|
workedMinutes: 720, // 12h
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp,
|
|
};
|
|
const cook3: DayParticipant = {
|
|
employeeId: 'COOK3',
|
|
workedMinutes: 720, // 12h
|
|
ruleCode: poolRole.code,
|
|
rule: poolRole.rule,
|
|
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
|
poolWeightBp: poolRole.poolWeightBp,
|
|
};
|
|
|
|
const dayResult = calculateDay({
|
|
businessDate: '2026-05-15',
|
|
orgUnitId: 'UNIT1',
|
|
metrics: { revenue_minor: revenueMinor },
|
|
poolRule: cateringPreset.poolRule!,
|
|
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
|
roundingUnitMinor: 100,
|
|
currencyCode: 'RUB',
|
|
participants: [cook1, cook2, cook3],
|
|
});
|
|
|
|
// Pool = 16,500 ₽ (1,650,000 kopecks)
|
|
expect(dayResult.poolAmountMinor).toBe(1_650_000);
|
|
|
|
// Time-slice redistribution:
|
|
// First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each
|
|
// Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each
|
|
// Cook 1 pool share = 2,750 ₽ (275,000 kopecks)
|
|
// Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
|
|
// Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
|
|
const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
|
|
const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
|
|
const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
|
|
|
|
expect(line1.poolShareMinor).toBe(275_000);
|
|
expect(line2.poolShareMinor).toBe(687_500);
|
|
expect(line3.poolShareMinor).toBe(687_500);
|
|
|
|
// The entire pool of 16,500 ₽ is completely distributed without loss
|
|
expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000);
|
|
|
|
// Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks)
|
|
// Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks)
|
|
expect(line1.amountMinor).toBe(335_000);
|
|
expect(line2.amountMinor).toBe(807_500);
|
|
expect(line3.amountMinor).toBe(807_500);
|
|
});
|
|
|
|
it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => {
|
|
// Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽
|
|
const revenueMinor = 50_000 * RUB;
|
|
|
|
const admin: DayParticipant = {
|
|
employeeId: 'ADMIN',
|
|
workedMinutes: 720,
|
|
ruleCode: percentRole.code,
|
|
rule: percentRole.rule,
|
|
params: {
|
|
rate_minor: 100 * RUB,
|
|
percent_bp: 250,
|
|
guarantee_minor: 3_000 * RUB,
|
|
},
|
|
poolWeightBp: 0,
|
|
};
|
|
|
|
const dayResult = calculateDay({
|
|
businessDate: '2026-05-15',
|
|
orgUnitId: 'UNIT1',
|
|
metrics: { revenue_minor: revenueMinor },
|
|
poolRule: cateringPreset.poolRule!,
|
|
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
|
roundingUnitMinor: 100,
|
|
currencyCode: 'RUB',
|
|
participants: [admin],
|
|
});
|
|
|
|
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
|
|
expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks
|
|
});
|
|
});
|
|
|
|
describe('Payroll service calculation and warning engine', () => {
|
|
function makeMockClient(handlers: {
|
|
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
|
|
}): PoolClient {
|
|
return {
|
|
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
|
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
|
const params =
|
|
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
|
return Promise.resolve(handlers.query(sql, params));
|
|
},
|
|
release: () => {},
|
|
} as unknown as PoolClient;
|
|
}
|
|
|
|
const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!;
|
|
|
|
it('calculates payroll period from confirmed sessions and revenue metrics', async () => {
|
|
const executedQueries: string[] = [];
|
|
const unitId = newUlid();
|
|
const empId = newUlid();
|
|
const ruleId = newUlid();
|
|
|
|
const client = makeMockClient({
|
|
query: (sql) => {
|
|
executedQueries.push(sql);
|
|
|
|
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
}
|
|
if (sql.includes('FROM staff_work_sessions s')) {
|
|
// Confirmed work sessions
|
|
return {
|
|
rows: [
|
|
{
|
|
id: newUlid(),
|
|
employee_id: empId,
|
|
org_unit_id: unitId,
|
|
business_date: '2026-05-01',
|
|
worked_minutes: 720,
|
|
display_name: 'Иван Поваров',
|
|
position: 'Повар',
|
|
org_unit_name: 'Кухня Центр',
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
if (sql.includes('FROM staff_daily_metrics')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
org_unit_id: unitId,
|
|
business_date: '2026-05-01',
|
|
revenue_minor: '10000000', // 100,000 ₽
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
if (sql.includes('FROM staff_pay_assignments a')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
id: newUlid(),
|
|
employee_id: empId,
|
|
org_unit_id: unitId,
|
|
pay_rule_id: ruleId,
|
|
effective_from: '2026-01-01',
|
|
effective_to: null,
|
|
params: { rate_minor: 10000 },
|
|
pool_weight_bp: 10000,
|
|
rule_code: 'pool_member',
|
|
rule: defaultRole.rule,
|
|
default_pool_weight_bp: 10000,
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
if (sql.includes('FROM staff_org_unit_terms')) {
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
}
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
},
|
|
});
|
|
|
|
const result = await calculatePayrollPeriod(client, mockContext(), {
|
|
from: '2026-05-01',
|
|
to: '2026-05-01',
|
|
orgUnitId: unitId,
|
|
});
|
|
|
|
expect(result.status).toBe('calculated');
|
|
expect(result.warnings).toHaveLength(0);
|
|
expect(result.totals).toHaveLength(1);
|
|
const firstTotal = result.totals[0]!;
|
|
expect(firstTotal.display_name).toBe('Иван Поваров');
|
|
expect(firstTotal.worked_minutes).toBe(720);
|
|
// 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks
|
|
expect(firstTotal.accrued_minor).toBe('1220000');
|
|
expect(result.total_accrued_minor).toBe('1220000');
|
|
|
|
// Check that staff_payroll_lines insertion occurred
|
|
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true);
|
|
// Check outbox and audit log
|
|
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true);
|
|
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true);
|
|
});
|
|
|
|
it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => {
|
|
const unitId = newUlid();
|
|
const empId = newUlid();
|
|
|
|
const client = makeMockClient({
|
|
query: (sql) => {
|
|
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
}
|
|
if (sql.includes('FROM staff_work_sessions s')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
id: newUlid(),
|
|
employee_id: empId,
|
|
org_unit_id: unitId,
|
|
business_date: '2026-05-02',
|
|
worked_minutes: 600,
|
|
display_name: 'Петр Безпрофилев',
|
|
position: 'Курьер',
|
|
org_unit_name: 'Кухня Центр',
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
// Return NO metrics (missing revenue) and NO assignments
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
},
|
|
});
|
|
|
|
const result = await calculatePayrollPeriod(client, mockContext(), {
|
|
from: '2026-05-02',
|
|
to: '2026-05-02',
|
|
});
|
|
|
|
expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true);
|
|
expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true);
|
|
});
|
|
|
|
it('warns when no confirmed sessions exist for the period', async () => {
|
|
const client = makeMockClient({
|
|
query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }),
|
|
});
|
|
|
|
const result = await calculatePayrollPeriod(client, mockContext(), {
|
|
from: '2026-06-01',
|
|
to: '2026-06-30',
|
|
});
|
|
|
|
expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true);
|
|
expect(result.totals).toHaveLength(0);
|
|
expect(result.total_accrued_minor).toBe('0');
|
|
});
|
|
|
|
it('rejects recalculation if run is already approved', async () => {
|
|
const runId = newUlid();
|
|
const client = makeMockClient({
|
|
query: (sql) => {
|
|
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
|
return {
|
|
rows: [{ id: runId, status: 'approved' }],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
calculatePayrollPeriod(client, mockContext(), {
|
|
from: '2026-05-01',
|
|
to: '2026-05-31',
|
|
}),
|
|
).rejects.toThrow('Расчёт за этот период уже утверждён.');
|
|
});
|
|
});
|
|
|
|
describe('Payroll approval and ledger idempotency', () => {
|
|
function makeMockClient(handlers: {
|
|
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
|
|
}): PoolClient {
|
|
return {
|
|
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
|
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
|
const params =
|
|
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
|
return Promise.resolve(handlers.query(sql, params));
|
|
},
|
|
release: () => {},
|
|
} as unknown as PoolClient;
|
|
}
|
|
|
|
it('posts accrual lines to ledger with external_key and marks run approved', async () => {
|
|
let ledgerInsertQuery = '';
|
|
let ledgerInsertParams: unknown[] = [];
|
|
const runId = newUlid();
|
|
const empId = newUlid();
|
|
|
|
const client = makeMockClient({
|
|
query: (sql, params) => {
|
|
if (sql.includes('SELECT id, period_start::text AS period_start')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
id: runId,
|
|
period_start: '2026-05-01',
|
|
period_end: '2026-05-31',
|
|
status: 'calculated',
|
|
total_accrued_minor: '1220000',
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
if (sql.includes('FROM staff_payroll_lines')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
employee_id: empId,
|
|
amount_minor: '1220000',
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
if (sql.includes('INSERT INTO staff_payroll_ledger')) {
|
|
ledgerInsertQuery = sql;
|
|
ledgerInsertParams = params;
|
|
return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] };
|
|
}
|
|
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
|
|
},
|
|
});
|
|
|
|
const result = await approvePayrollRun(client, mockContext(), runId);
|
|
|
|
expect(result.status).toBe('approved');
|
|
expect(result.entries_posted).toBe(1);
|
|
expect(result.total_accrued_minor).toBe('1220000');
|
|
|
|
// Verify external_key structure and ON CONFLICT clause
|
|
expect(ledgerInsertQuery).toContain(
|
|
'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING',
|
|
);
|
|
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
|
|
});
|
|
|
|
it('rejects approval if run is not in calculated status', async () => {
|
|
const runId = newUlid();
|
|
const client = makeMockClient({
|
|
query: (sql) => {
|
|
if (sql.includes('SELECT id, period_start::text AS period_start')) {
|
|
return {
|
|
rows: [
|
|
{
|
|
id: runId,
|
|
period_start: '2026-05-01',
|
|
period_end: '2026-05-31',
|
|
status: 'approved',
|
|
total_accrued_minor: '1220000',
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
command: '',
|
|
oid: 0,
|
|
fields: [],
|
|
};
|
|
}
|
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
|
},
|
|
});
|
|
|
|
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
|
|
'Утвердить можно только расчёт в статусе calculated.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Pay assignment management', () => {
|
|
it('saves and retrieves pay assignment with default fallbacks', async () => {
|
|
let savedParamsJson = '';
|
|
let closedPreviousVersion = false;
|
|
const tenantId = newUlid();
|
|
const actorId = newUlid();
|
|
const corrId = newUlid();
|
|
const empId = newUlid();
|
|
const ruleId = newUlid();
|
|
const assignId = newUlid();
|
|
|
|
const client = {
|
|
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
|
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
|
const params =
|
|
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
|
|
|
if (sql.includes('FROM staff_employees')) {
|
|
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
|
|
}
|
|
if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) {
|
|
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
|
|
}
|
|
if (sql.includes('INSERT INTO staff_pay_assignments')) {
|
|
savedParamsJson = String(params[7]);
|
|
return Promise.resolve({ rows: [], rowCount: 1 });
|
|
}
|
|
if (sql.includes('SET effective_to = ($3::date')) {
|
|
closedPreviousVersion = true;
|
|
expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
return Promise.resolve({ rows: [], rowCount: 1 });
|
|
}
|
|
if (sql.includes('FROM staff_pay_assignments a')) {
|
|
return Promise.resolve({
|
|
rows: [
|
|
{
|
|
id: assignId,
|
|
employee_id: empId,
|
|
org_unit_id: null,
|
|
pay_rule_id: ruleId,
|
|
effective_from: '2026-05-01',
|
|
effective_to: null,
|
|
params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 },
|
|
pool_weight_bp: 10000,
|
|
note: 'Главный повар',
|
|
rule_code: 'pool_member',
|
|
rule_name: 'Повар (доля от фонда)',
|
|
default_pool_weight_bp: 10000,
|
|
},
|
|
],
|
|
rowCount: 1,
|
|
});
|
|
}
|
|
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
},
|
|
release: () => {},
|
|
} as unknown as PoolClient;
|
|
|
|
const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
|
|
pay_rule_id: ruleId,
|
|
rate_minor: 12000,
|
|
note: 'Главный повар',
|
|
});
|
|
|
|
expect(saved.rate_minor).toBe(12000);
|
|
expect(saved.pool_weight_bp).toBe(10000);
|
|
expect(saved.note).toBe('Главный повар');
|
|
expect(savedParamsJson).toContain('"rate_minor":12000');
|
|
expect(closedPreviousVersion).toBe(true);
|
|
|
|
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
|
|
expect(fetched).not.toBeNull();
|
|
expect(fetched?.rate_minor).toBe(12000);
|
|
});
|
|
});
|
|
|
|
describe('Tenant and employee isolation guarantees', () => {
|
|
it('enforces tenant_id in every database query in payroll service', () => {
|
|
const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8');
|
|
|
|
// All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id
|
|
const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? [];
|
|
expect(sqlStatements.length).toBeGreaterThan(5);
|
|
|
|
for (const sql of sqlStatements) {
|
|
expect(sql).toContain('tenant_id');
|
|
}
|
|
});
|
|
|
|
it('restricts /payroll query to actor employee or requires manager permissions', () => {
|
|
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
|
|
|
|
// Ensure the employee isolation clause is present in GET /payroll
|
|
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
|
|
expect(operationsSrc).toContain("context.permissions.has('staff:payroll:read')");
|
|
expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
|
|
});
|
|
|
|
it('restricts pay-assignment access to owner employee or manager', () => {
|
|
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
|
|
|
|
expect(operationsSrc).toContain(
|
|
'!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId',
|
|
);
|
|
});
|
|
|
|
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
|
|
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
|
|
expect(migrationSql).toContain(
|
|
'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;',
|
|
);
|
|
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
|
|
expect(migrationSql).toContain(
|
|
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
|
|
);
|
|
});
|
|
});
|