// Недостающие сценарии из аудита владельца (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 { 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 { 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; 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_111; // синтетические 1 111,11 ₽ за смену // Предполагаемый контракт: параметры пресета передаются объектом `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 }); }); });