krona-staff/tests/e2e/50-money-and-errors.e2e.ts
grok 3475133b10 test(e2e): синтетические суммы кратны рублю (учёт округления расчёта)
Зачем: на ветке codex/fix-live-database цепочка подтверждение → расчёт →
утверждение впервые дошла до конца, и K3/N2 остался красным не из-за
дефекта, а из-за теста: 8 ч × 123,45 ₽ = 98760 коп., а расчёт округляет
строку до rounding_unit_minor (по умолчанию 100 коп., service.ts:695) и
начисляет 98800. Округление до рубля — настройка продукта, не дефект;
тест не должен от него зависеть.

Что: SYNTHETIC_RATE_MINOR 12345 → 12300 (123,00 ₽/ч), оплата смены
официанта в K9 111111 → 111100. Суммы остаются заведомо синтетическими.
2026-09-26 15:25:47 +07:00

287 lines
13 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.

// Недостающие сценарии из аудита владельца (TASK-GROK-2 §3): денежная логика K6–K9,
// удаление точки с устройством (S9), утечка ошибок PostgreSQL (A4), дата расчёта (N12-DATE).
// Каждый тест проверяет ПРАВИЛЬНОЕ поведение: красный = открытый дефект. Где путь
// упирается в K3 (outbox без occurred_at), assert предусловия прямо называет K3.
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
import pg from 'pg';
import { describe, expect, it } from 'vitest';
import { api, ctx, dbQuery, describeResponse, type ApiResponse } from './harness/client.js';
import { randomUlid, synthetic } from './harness/context.js';
import {
SYNTHETIC_RATE_MINOR,
assignHourly,
createOrgUnits,
daysAgo,
importEmployees,
isoDate,
workedShift,
} from './harness/fixtures.js';
const monthOf = (date: Date) => date.toISOString().slice(0, 7);
async function ledgerTotal(employeeId: string, kinds = ['accrual', 'adjustment']) {
const rows = await dbQuery<{ total: string }>(
`SELECT COALESCE(SUM(amount_minor), 0)::text AS total FROM staff_payroll_ledger
WHERE employee_id = $1 AND kind = ANY($2::text[])`,
[employeeId, kinds],
);
return BigInt(rows[0]!.total);
}
async function confirmShift(sessionId: string) {
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'confirmed' },
});
expect(
confirm.status,
`подтверждение смены (предусловие; падает, пока открыт K3): ${describeResponse(confirm)}`,
).toBe(200);
}
/** Расчёт и утверждение (подпись) ведомости точки за месяц дня `day`. */
async function calculateAndApprove(unitId: string, day: Date): Promise<string> {
const calculate = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: monthOf(day), org_unit_id: unitId },
});
expect(calculate.status, `расчёт (предусловие, K3): ${describeResponse(calculate)}`).toBe(200);
const runId = (calculate.body.run_id ?? calculate.body.id) as string;
const approve = await api('POST', `/payroll/runs/${runId}/approve`, {
token: ctx().managerToken,
});
expect(approve.status, `утверждение (предусловие, K3): ${describeResponse(approve)}`).toBe(200);
return runId;
}
async function payRuleId(code: string): Promise<string> {
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
expect(response.status, describeResponse(response)).toBe(200);
const rule = (response.body.pay_rules as Array<{ id: string; code: string }>).find(
(item) => item.code === code,
);
expect(rule, `правило ${code} должно быть в пресете`).toBeTruthy();
return rule!.id;
}
/** Признаки ошибки PostgreSQL в теле ответа клиенту (SQLSTATE, имена отношений/столбцов). */
function postgresLeak(response: ApiResponse): string[] {
const leaks: string[] = [];
const body = (response.body ?? {}) as Record<string, unknown>;
if (typeof body.code === 'string' && /^[0-9A-Z]{5}$/.test(body.code)) {
leaks.push(`code=${body.code}`);
}
if (
/relation "|column "|violates|constraint|syntax for type|deduced for parameter/i.test(
response.text,
)
) {
leaks.push(`text=${response.text.slice(0, 120)}`);
}
return leaks;
}
describe('Деньги, S9, A4 и даты на реальной PostgreSQL', () => {
it('[K6/D5] отклонение смены из подписанной ведомости → 409 или сторно (начисление не остаётся)', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(4);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
await calculateAndApprove(unit!.id, day);
expect(await ledgerTotal(employee!.id)).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
const reject = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'rejected', correction_note: synthetic('отказ после подписи') },
});
if (reject.status === 409) return; // смена подписанной ведомости неизменяема — верно
expect(reject.status, describeResponse(reject)).toBe(200);
expect(
await ledgerTotal(employee!.id),
'после отклонения оплаченной смены должно быть сторно: итог начислений 0',
).toBe(0n);
});
it('[K7/D2] повторный импорт книги: итог в реестре равен сумме по книге (две точки в один день)', async () => {
const [employee] = await importEmployees(1);
const date = isoDate(daysAgo(5));
const keyA = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-a`;
const keyB = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-b`;
const entry = (externalKey: string, amount: number) => ({
external_key: externalKey,
employee_external_ref: employee!.external_ref,
business_date: date,
kind: 'accrual',
amount_minor: amount,
});
const importBook = (amountA: number, amountB: number) =>
api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: {
source_name: synthetic('import-book'),
payroll_entries: [entry(keyA, amountA), entry(keyB, amountB)],
},
});
const first = await importBook(11_100, 22_200);
expect(first.status, describeResponse(first)).toBeLessThan(300);
const afterFirst = await ledgerTotal(employee!.id);
const second = await importBook(15_500, 22_200);
expect(second.status, describeResponse(second)).toBeLessThan(300);
const afterSecond = await ledgerTotal(employee!.id);
expect({ afterFirst, afterSecond }).toEqual({
afterFirst: 33_300n, // 111,00 ₽ + 222,00 ₽ по книге
afterSecond: 37_700n, // 155,00 ₽ + 222,00 ₽ после исправления суммы точки A
});
});
it('[K8/D1] подписанная ведомость воспроизводится после смены ставки (сумма та же)', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(6);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
const runId = await calculateAndApprove(unit!.id, day);
const signed = await api('GET', `/payroll/runs/${runId}`, { token: ctx().managerToken });
expect(signed.status, describeResponse(signed)).toBe(200);
// Меняем ставку «задним числом» (то же effective_from) после подписи.
const change = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await payRuleId('hourly'),
org_unit_id: unit!.id,
rate_minor: SYNTHETIC_RATE_MINOR * 3,
effective_from: '2026-01-01',
},
});
expect(change.status, describeResponse(change)).toBe(200);
// Предполагаемый контракт C4 (NOTES-FOR-EXECUTORS): пересчёт подписанной ведомости
// для проверки по снимку правил и ставок на момент подписи.
const verify = await api('GET', `/payroll/runs/${runId}/verify`, {
token: ctx().managerToken,
});
expect(verify.status, `C4 verify: ${describeResponse(verify)}`).toBe(200);
expect({
matches: verify.body.matches,
recalculated: String(verify.body.recalculated_total_minor),
}).toEqual({ matches: true, recalculated: String(8 * SYNTHETIC_RATE_MINOR) });
});
it('[K9/D3] официант по пресету получает оплату смены из параметров пресета', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
const shiftRate = 111_100; // синтетические 1 111,00 ₽ за смену (кратно рублю, см. округление)
// Предполагаемый контракт: параметры пресета передаются объектом `params`
// (ключи — param_declarations правила).
const assign = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await payRuleId('waiter_upsell'),
org_unit_id: unit!.id,
effective_from: '2026-01-01',
params: {
shift_rate_minor: shiftRate,
percent_bp: 0,
upsell_rate_minor: 0,
avg_check_target_minor: 99_999_999,
avg_check_bonus_minor: 0,
},
},
});
expect(assign.status, describeResponse(assign)).toBe(200);
const saved = await api('GET', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
});
expect(
saved.body.params?.shift_rate_minor,
`параметр пресета должен сохраниться: ${describeResponse(saved)}`,
).toBe(shiftRate);
const day = daysAgo(7);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
await calculateAndApprove(unit!.id, day);
expect(await ledgerTotal(employee!.id)).toBe(BigInt(shiftRate));
});
it('[S9] удаление точки с привязанным устройством: удалена или понятный отказ, не 23502', async () => {
const [unit] = await createOrgUnits(1);
const device = await api('POST', '/sync/devices', {
token: ctx().managerToken,
body: {
device_code: `E2E-${randomUlid().slice(-10)}`,
name: synthetic('Терминал S9'),
org_unit_id: unit!.id,
},
});
expect(device.status, describeResponse(device)).toBe(201);
// Публичного API удаления точки нет, поэтому удаляем так же, как аудит владельца:
// SQL-запросом владельца схемы (мигратор), в tenant-контексте (FORCE RLS).
const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl });
await migrator.connect();
let outcome = 'deleted';
try {
await migrator.query('BEGIN');
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [ctx().tenantId]);
await migrator.query('DELETE FROM staff_org_units WHERE id = $1', [unit!.id]);
await migrator.query('ROLLBACK');
} catch (error) {
outcome = `${(error as { code?: string }).code} ${(error as Error).message}`;
await migrator.query('ROLLBACK').catch(() => undefined);
} finally {
await migrator.end();
}
// Верно: удалено, либо отказ внешнего ключа 23503 (понятный запрет).
expect(outcome, 'удаление точки с устройством').toMatch(/^(deleted|23503 )/);
});
it('[A4] ответы 500 не содержат кода и текста ошибки PostgreSQL', async () => {
const [unit] = await createOrgUnits(1);
const probes: Array<[string, ApiResponse]> = [
[
'POST /employees',
await api('POST', '/employees', {
token: ctx().managerToken,
body: {
personnel_number: `E2E-${randomUlid().slice(-8)}`,
display_name: synthetic('Сотрудник'),
position: 'E2E-должность',
},
}),
],
[
'POST /payroll/calculate month=2026-13',
await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: '2026-13', org_unit_id: unit!.id },
}),
],
];
const leaks = probes
.filter(([, response]) => response.status >= 500)
.map(([name, response]) => ({ name, leaks: postgresLeak(response) }))
.filter((item) => item.leaks.length > 0);
expect(leaks, 'тела 500 с деталями PostgreSQL').toEqual([]);
});
it('[N12-DATE] расчёт с несуществующей датой (month=2026-13, 2026-02-30) → 400', async () => {
const [unit] = await createOrgUnits(1);
const month = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: '2026-13', org_unit_id: unit!.id },
});
const range = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { from: '2026-02-01', to: '2026-02-30', org_unit_id: unit!.id },
});
expect(
{ month: month.status, range: range.status },
`${describeResponse(month)} | ${describeResponse(range)}`,
).toEqual({ month: 400, range: 400 });
});
});