// Подготовка данных через публичный API. Везде только синтетические данные. /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { expect } from 'vitest'; import { api, ctx, dbQuery, describeResponse } from './client.js'; import { randomUlid, synthetic } from './context.js'; export interface OrgUnit { id: string; code: string; } export interface Employee { id: string; external_ref: string; personnel_number: string; } /** Создаёт торговые точки через импорт (единственный публичный способ в текущем API). */ export async function createOrgUnits(count: number): Promise { const units = Array.from({ length: count }, () => { const code = `e2e-${randomUlid().slice(-10).toLowerCase()}`; return { code, name: synthetic('Точка') }; }); const response = await api('POST', '/imports/owner-workbook', { token: ctx().managerToken, body: { source_name: synthetic('import-units'), units }, }); expect(response.status, describeResponse(response)).toBeLessThan(300); const rows = await dbQuery<{ id: string; code: string }>( 'SELECT id, code FROM staff_org_units WHERE code = ANY($1::text[])', [units.map((unit) => unit.code)], ); return units.map((unit) => ({ code: unit.code, id: rows.find((row) => row.code === unit.code)!.id, })); } /** * Создаёт сотрудников через импорт книги владельца: POST /employees на реальной БД * сейчас падает (N1), а сценарии, не относящиеся к N1, не должны зависеть от этого дефекта. */ export async function importEmployees(count: number): Promise { const employees = Array.from({ length: count }, () => { const suffix = randomUlid().slice(-8); return { external_ref: `e2e:${suffix}`, personnel_number: `E2E-${suffix}`, display_name: synthetic('Сотрудник'), }; }); const response = await api('POST', '/imports/owner-workbook', { token: ctx().managerToken, body: { source_name: synthetic('import-employees'), employees }, }); expect(response.status, describeResponse(response)).toBeLessThan(300); const rows = await dbQuery<{ id: string; external_ref: string }>( 'SELECT id, external_ref FROM staff_employees WHERE external_ref = ANY($1::text[])', [employees.map((employee) => employee.external_ref)], ); return employees.map((employee) => ({ ...employee, id: rows.find((row) => row.external_ref === employee.external_ref)!.id, })); } export async function issueEmployeeToken(employeeId: string): Promise { const response = await api('POST', `/employees/${employeeId}/access-token`, { token: ctx().managerToken, }); expect(response.status, describeResponse(response)).toBe(200); return response.body.access_token as string; } export async function clock( employeeId: string, eventType: 'check_in' | 'check_out', occurredAt: Date, orgUnitId?: string, ) { return api('POST', '/attendance/events', { token: ctx().terminalToken, body: { employee_id: employeeId, event_type: eventType, occurred_at: occurredAt.toISOString(), source_event_id: `e2e-${randomUlid()}`, ...(orgUnitId ? { org_unit_id: orgUnitId } : {}), }, }); } /** Дата (UTC) N дней назад в полдень: всегда в окне терминала (<=180 дней) и не в будущем. */ export function daysAgo(days: number, hourUtc = 9): Date { const date = new Date(); date.setUTCDate(date.getUTCDate() - days); date.setUTCHours(hourUtc, 0, 0, 0); return date; } export function isoDate(date: Date): string { return date.toISOString().slice(0, 10); } /** Отработанная смена: check_in/check_out терминалом + сверка; возвращает id смены. */ export async function workedShift( employeeId: string, orgUnitId: string, day: Date, hours = 8, ): Promise { const start = new Date(day); const end = new Date(start.getTime() + hours * 3_600_000); const inResponse = await clock(employeeId, 'check_in', start, orgUnitId); expect(inResponse.status, describeResponse(inResponse)).toBe(201); const outResponse = await clock(employeeId, 'check_out', end, orgUnitId); expect(outResponse.status, describeResponse(outResponse)).toBe(201); const reconcile = await api('POST', '/work-sessions/reconcile', { token: ctx().managerToken, body: { date_from: isoDate(start), date_to: isoDate(start) }, }); expect(reconcile.status, describeResponse(reconcile)).toBe(200); const rows = await dbQuery<{ id: string }>( `SELECT id FROM staff_work_sessions WHERE employee_id = $1 AND org_unit_id = $2 AND business_date = $3::date`, [employeeId, orgUnitId, isoDate(start)], ); expect(rows.length, 'сверка должна создать смену').toBe(1); return rows[0]!.id; } export async function hourlyRuleId(): Promise { const response = await api('GET', '/pay-rules', { token: ctx().managerToken }); expect(response.status, describeResponse(response)).toBe(200); const rules = response.body.pay_rules as Array<{ id: string; code: string }>; const hourly = rules.find((rule) => rule.code === 'hourly') ?? rules[0]; expect(hourly, 'должно существовать хотя бы одно правило оплаты').toBeTruthy(); return hourly!.id; } /** Синтетическая ставка 123,45 ₽/ч (заведомо не реальная сумма). */ export const SYNTHETIC_RATE_MINOR = 12_345; export async function assignHourly(employeeId: string, orgUnitId: string | null) { const response = await api('PUT', `/employees/${employeeId}/pay-assignment`, { token: ctx().managerToken, body: { pay_rule_id: await hourlyRuleId(), org_unit_id: orgUnitId, rate_minor: SYNTHETIC_RATE_MINOR, effective_from: '2026-01-01', }, }); expect(response.status, describeResponse(response)).toBe(200); }