// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 4 Codex). // Публичного API для ролей точки/бухгалтера/директора, пакетов зарплаты и согласия // на биометрию пока нет, поэтому тесты написаны против наиболее вероятного контракта, // описанного в tests/e2e/NOTES-FOR-EXECUTORS.md (C1–C3). Если Codex выберет другой // контракт — меняется только этот файл и NOTES, смысл проверок остаётся. /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ import { describe, expect, it } from 'vitest'; import { api, ctx, dbQuery, describeResponse } from './harness/client.js'; import { synthetic } from './harness/context.js'; import { assignHourly, createOrgUnits, daysAgo, importEmployees, isoDate, workedShift, type OrgUnit, } from './harness/fixtures.js'; type Role = 'point_manager' | 'accountant' | 'director'; /** C1: главный менеджер выдаёт ключ роли (standalone-аналог Core RBAC). */ async function grant(role: Role, orgUnitIds: string[] = []): Promise { const response = await api('POST', '/access-grants', { token: ctx().managerToken, body: { role, org_unit_ids: orgUnitIds, display_name: synthetic(role) }, }); expect(response.status, `C1 POST /access-grants (${role}): ${describeResponse(response)}`).toBe( 201, ); return response.body.access_token as string; } async function confirm(sessionId: string) { const response = await api('PATCH', `/work-sessions/${sessionId}`, { token: ctx().managerToken, body: { status: 'confirmed' }, }); expect(response.status, `подтверждение смены (см. N2): ${describeResponse(response)}`).toBe(200); } interface World { unitA: OrgUnit; unitB: OrgUnit; employeeId: string; sessionId: string; day: Date; } /** Точка A и B, сотрудник точки A со сменой в точке A (по умолчанию подтверждённой; см. N2). */ async function world(confirmShift = true): Promise { const [unitA, unitB] = await createOrgUnits(2); const [employee] = await importEmployees(1); await assignHourly(employee!.id, unitA!.id); const day = daysAgo(4); const sessionId = await workedShift(employee!.id, unitA!.id, day, 8); if (confirmShift) await confirm(sessionId); return { unitA: unitA!, unitB: unitB!, employeeId: employee!.id, sessionId, day }; } function period(day: Date) { const from = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 1)); const to = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0)); return { from: isoDate(from), to: isoDate(to) }; } /** C2: пакет зарплаты точки за месяц. */ async function createBatch(token: string, orgUnitId: string, day: Date) { return api('POST', '/payroll/batches', { token, body: { org_unit_id: orgUnitId, ...period(day) }, }); } async function submittedBatch(w: World, accountant: string): Promise { const created = await createBatch(accountant, w.unitA.id, w.day); expect(created.status, `C2 создание пакета: ${describeResponse(created)}`).toBe(201); const batchId = created.body.id as string; const submit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); expect(submit.status, `C2 отправка пакета: ${describeResponse(submit)}`).toBe(200); return batchId; } async function ledgerFor(employeeId: string) { return dbQuery<{ kind: string; amount_minor: string }>( `SELECT kind, amount_minor::text AS amount_minor FROM staff_payroll_ledger WHERE employee_id = $1`, [employeeId], ); } describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => { it('[OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => { const w = await world(false); const managerA = await grant('point_manager', [w.unitA.id]); const batch = await createBatch(managerA, w.unitB.id, w.day); const legacy = await api('POST', '/payroll/calculate', { token: managerA, body: { month: isoDate(w.day).slice(0, 7), org_unit_id: w.unitB.id }, }); expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 }); }); it('[OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => { const w = await world(false); const managerA = await grant('point_manager', [w.unitA.id]); const created = await createBatch(managerA, w.unitA.id, w.day); expect( created.status, `C2 создание пакета менеджером своей точки: ${describeResponse(created)}`, ).toBe(201); const submit = await api('POST', `/payroll/batches/${created.body.id}/submit`, { token: managerA, }); expect(submit.status, describeResponse(submit)).toBe(403); }); it('[OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление', async () => { const accountant = await grant('accountant'); const directors = [await grant('director'), await grant('director')]; const w = await world(); const batchId = await submittedBatch(w, accountant); for (const director of directors) { const inbox = await api('GET', '/notifications', { token: director }); expect(inbox.status, `C2 уведомления директора: ${describeResponse(inbox)}`).toBe(200); const aboutBatch = ( inbox.body.notifications as Array<{ type: string; subject_id: string }> ).filter((n) => n.type === 'payroll.batch.submitted' && n.subject_id === batchId); expect(aboutBatch).toHaveLength(1); } }); it('[OWN-04] бухгалтер пытается подписать пакет → 403', async () => { const accountant = await grant('accountant'); const w = await world(); const batchId = await submittedBatch(w, accountant); const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: accountant }); expect(sign.status, describeResponse(sign)).toBe(403); }); it('[OWN-05] до подписи директора в ledger нет начислений и выплат', async () => { const accountant = await grant('accountant'); const w = await world(); await submittedBatch(w, accountant); expect(await ledgerFor(w.employeeId)).toEqual([]); }); it('[OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы', async () => { const accountant = await grant('accountant'); const director = await grant('director'); const w = await world(); const batchId = await submittedBatch(w, accountant); const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); expect(sign.status, describeResponse(sign)).toBe(200); const kinds = new Set((await ledgerFor(w.employeeId)).map((row) => row.kind)); const editShift = await api('PATCH', `/work-sessions/${w.sessionId}`, { token: ctx().managerToken, body: { status: 'confirmed', worked_minutes: 1, correction_note: 'E2E попытка правки после подписи', }, }); const resubmit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); expect({ accrual: kinds.has('accrual'), payout: kinds.has('payout'), editShift: editShift.status, resubmit: resubmit.status, }).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 }); }); it('[OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { const accountant = await grant('accountant'); const director = await grant('director'); const w = await world(); const batchId = await submittedBatch(w, accountant); const edit = await api('PATCH', `/work-sessions/${w.sessionId}`, { token: ctx().managerToken, body: { status: 'confirmed', worked_minutes: 420, correction_note: 'E2E правка после отправки', }, }); expect(edit.status, describeResponse(edit)).toBe(200); const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); expect({ sign: sign.status, status: batch.body?.status }).toEqual({ sign: 409, status: 'returned', }); }); it('[OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён', async () => { const accountant = await grant('accountant'); const director = await grant('director'); const w = await world(); const batchId = await submittedBatch(w, accountant); const comment = synthetic('Комментарий директора'); const back = await api('POST', `/payroll/batches/${batchId}/return`, { token: director, body: { comment }, }); expect(back.status, describeResponse(back)).toBe(200); const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); expect({ status: batch.body?.status, comment: batch.body?.return_comment }).toEqual({ status: 'returned', comment, }); }); it('[OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B', async () => { const accountant = await grant('accountant'); const [unitA, unitB] = await createOrgUnits(2); const [employee] = await importEmployees(1); await assignHourly(employee!.id, unitA!.id); const day = daysAgo(5); const sessionId = await workedShift(employee!.id, unitB!.id, day, 6); await confirm(sessionId); const batchB = await createBatch(accountant, unitB!.id, day); const batchA = await createBatch(accountant, unitA!.id, day); expect(batchB.status, describeResponse(batchB)).toBe(201); const sessionsOf = (batch: typeof batchB) => ((batch.body?.lines ?? []) as Array<{ work_session_id: string }>).map( (l) => l.work_session_id, ); expect({ inB: sessionsOf(batchB).includes(sessionId), inA: sessionsOf(batchA).includes(sessionId), }).toEqual({ inB: true, inA: false, }); }); it('[OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { const [employee] = await importEmployees(1); const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken, }); expect(response.status, `C3: ${describeResponse(response)}`).toBe(200); expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' }); }); it('[OWN-11] согласие отозвано → статус для терминала меняется', async () => { const [employee] = await importEmployees(1); const setConsent = (status: 'granted' | 'revoked') => api('PUT', `/employees/${employee!.id}/biometric-consent`, { token: ctx().managerToken, body: { status, document_ref: synthetic('согласие') }, }); const enrollment = () => api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken }); const granted = await setConsent('granted'); expect(granted.status, `C3 PUT biometric-consent: ${describeResponse(granted)}`).toBe(200); const before = await enrollment(); const revoked = await setConsent('revoked'); expect(revoked.status, describeResponse(revoked)).toBe(200); const after = await enrollment(); expect({ before: before.body?.registration_allowed, after: after.body?.registration_allowed, reason: after.body?.reason, }).toEqual({ before: true, after: false, reason: 'consent_revoked', }); }); });