Зачем: на ветке 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. Суммы остаются заведомо синтетическими.
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
// Недостающие сценарии из аудита владельца (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 });
|
||
});
|
||
});
|