Зачем: на ветке 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. Суммы остаются заведомо синтетическими.
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
// Подготовка данных через публичный 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<OrgUnit[]> {
|
||
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<Employee[]> {
|
||
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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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,00 ₽/ч (заведомо не реальная сумма). Кратна рублю: расчёт
|
||
* округляет строку до rounding_unit_minor (по умолчанию 100 коп.), и 8 ч × ставка
|
||
* должны совпадать с начислением без учёта округления.
|
||
*/
|
||
export const SYNTHETIC_RATE_MINOR = 12_300;
|
||
|
||
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);
|
||
}
|