test(e2e): двойные ID «владелец/мой» в названиях сценариев и known-defects

Зачем: аудит владельца (docs/audit/2026-09-26/AUDIT.md) стал основным
реестром дефектов; без сопоставления исполнители и приёмка говорят о
разных ID для одной ошибки (TASK-GROK-2 §2).

Что: теги переименованы по таблице владельца ([K3/N2], [K13/SYNC-A],
[S5/SYNC-C], [D6/OWN-01], [K17/OWN-10] и т. д.), ключи known-defects.json —
так же; N9–N12, F7, F10 остаются одиночными, как в таблице. Сверх таблицы,
по тексту AUDIT.md: K1/SYNC-I (анонимный доступ без токенов — это K1 на
/sync), K1/PILOT-1 (подстановки без :? в пилотном compose прямо названы в
K1), K2/PILOT-2 (NODE_ENV=development в пилоте — суть K2). SYNC-C0, SYNC-K,
OWN-03..06/08/09, PILOT-3 ID владельца не имеют. run-e2e принимает «/» в
теге. Прогон: 38 red / 9 green, храповик OK — те же сценарии, новые имена.
This commit is contained in:
grok 2026-09-26 15:21:22 +07:00
parent 27499da191
commit 0453168d70
6 changed files with 50 additions and 50 deletions

View file

@ -59,7 +59,7 @@ const known = strict
const byId = new Map(); const byId = new Map();
for (const file of results.testResults) { for (const file of results.testResults) {
for (const test of file.assertionResults) { for (const test of file.assertionResults) {
const id = /^\[([A-Z0-9-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED'; const id = /^\[([A-Z0-9/-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED';
const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] }; const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] };
if (test.status === 'passed') entry.passed += 1; if (test.status === 'passed') entry.passed += 1;
else if (test.status === 'failed') entry.failed += 1; else if (test.status === 'failed') entry.failed += 1;

View file

@ -42,7 +42,7 @@ async function accrualsFor(employeeId: string) {
} }
describe('Основной API на реальной PostgreSQL', () => { describe('Основной API на реальной PostgreSQL', () => {
it('[N1] менеджер создаёт сотрудника через POST /employees → 201', async () => { it('[K4/N1] менеджер создаёт сотрудника через POST /employees → 201', async () => {
const number = `E2E-${randomUlid().slice(-8)}`; const number = `E2E-${randomUlid().slice(-8)}`;
const response = await api('POST', '/employees', { const response = await api('POST', '/employees', {
token: ctx().managerToken, token: ctx().managerToken,
@ -59,7 +59,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
}); });
it('[N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => { it('[K3/N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => {
const [unit] = await createOrgUnits(1); const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id); await assignHourly(employee!.id, unit!.id);
@ -88,7 +88,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR)); expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
}); });
it('[N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => { it('[K5/N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => {
const [unit] = await createOrgUnits(1); const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id); await assignHourly(employee!.id, unit!.id);
@ -136,7 +136,7 @@ describe('Основной API на реальной PostgreSQL', () => {
); );
}); });
it('[N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => { it('[K12/N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const employeeToken = await issueEmployeeToken(employee!.id); const employeeToken = await issueEmployeeToken(employee!.id);
let connections = 0; let connections = 0;
@ -227,7 +227,7 @@ describe('Основной API на реальной PostgreSQL', () => {
}); });
}); });
it('[F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => { it('[K1/F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => {
const portal = await api('GET', '/portal', { base: 'open' }); const portal = await api('GET', '/portal', { base: 'open' });
const payroll = await api('GET', '/payroll', { base: 'open' }); const payroll = await api('GET', '/payroll', { base: 'open' });
const write = await api('POST', '/professions', { const write = await api('POST', '/professions', {
@ -241,7 +241,7 @@ describe('Основной API на реальной PostgreSQL', () => {
}); });
}); });
it('[F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => { it('[K10/F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => {
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
expect(login.status, describeResponse(login)).toBe(200); expect(login.status, describeResponse(login)).toBe(200);
const setCookie = login.headers.get('set-cookie') ?? ''; const setCookie = login.headers.get('set-cookie') ?? '';
@ -311,7 +311,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(response.status, describeResponse(response)).toBe(400); expect(response.status, describeResponse(response)).toBe(400);
}); });
it('[F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => { it('[K11/F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => {
const statuses: number[] = []; const statuses: number[] = [];
for (let attempt = 0; attempt < 30; attempt += 1) { for (let attempt = 0; attempt < 30; attempt += 1) {
const response = await api('POST', '/session', { const response = await api('POST', '/session', {

View file

@ -57,7 +57,7 @@ async function pullAll(device: Device, cursor: string | null = null) {
} }
describe('Офлайн-синхронизация на реальной PostgreSQL', () => { describe('Офлайн-синхронизация на реальной PostgreSQL', () => {
it('[SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => { it('[K13/SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => {
const [unit] = await createOrgUnits(1); const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const device = await registerDevice(unit!.id); const device = await registerDevice(unit!.id);
@ -86,7 +86,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ http: 200, result: 'rejected', stored: null }); ).toEqual({ http: 200, result: 'rejected', stored: null });
}); });
it('[SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => { it('[K13/SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const device = await registerDevice(); const device = await registerDevice();
const entryId = randomUlid(); const entryId = randomUlid();
@ -124,7 +124,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(types.has('employee') && types.has('org_unit')).toBe(true); expect(types.has('employee') && types.has('org_unit')).toBe(true);
}); });
it('[SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => { it('[S5/SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => {
const employees = await importEmployees(120); const employees = await importEmployees(120);
const device = await registerDevice(); const device = await registerDevice();
const { changes } = await pullAll(device, null); const { changes } = await pullAll(device, null);
@ -135,7 +135,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0); expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0);
}); });
it('[SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => { it('[S5/SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => {
const device = await registerDevice(); const device = await registerDevice();
// Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0). // Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0).
const status = await api('GET', '/sync/status', { token: device.token }); const status = await api('GET', '/sync/status', { token: device.token });
@ -153,7 +153,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
); );
}); });
it('[SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => { it('[S4/SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const dismiss = await api('PATCH', `/employees/${employee!.id}`, { const dismiss = await api('PATCH', `/employees/${employee!.id}`, {
token: ctx().managerToken, token: ctx().managerToken,
@ -171,7 +171,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected'); expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected');
}); });
it('[SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => { it('[S4/SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => {
const [unitA, unitB] = await createOrgUnits(2); const [unitA, unitB] = await createOrgUnits(2);
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const device = await registerDevice(unitA!.id); const device = await registerDevice(unitA!.id);
@ -202,7 +202,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' }); ).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' });
}); });
it('[SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => { it('[S3/SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const device = await registerDevice(); const device = await registerDevice();
const good = command('attendance_event', { const good = command('attendance_event', {
@ -225,7 +225,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ http: 200, statuses: ['applied', 'rejected'] }); ).toEqual({ http: 200, statuses: ['applied', 'rejected'] });
}); });
it('[SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => { it('[S8/SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => {
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
const cookie = const cookie =
/(__Host-krona_staff_session=[^;]*)/.exec(login.headers.get('set-cookie') ?? '')?.[1] ?? ''; /(__Host-krona_staff_session=[^;]*)/.exec(login.headers.get('set-cookie') ?? '')?.[1] ?? '';
@ -236,7 +236,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.status, describeResponse(response)).toBe(403); expect(response.status, describeResponse(response)).toBe(403);
}); });
it('[SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => { it('[K1/SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => {
const response = await api('POST', '/sync/devices', { const response = await api('POST', '/sync/devices', {
base: 'open', base: 'open',
body: { body: {
@ -247,7 +247,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.status, describeResponse(response)).toBe(401); expect(response.status, describeResponse(response)).toBe(401);
}); });
it('[SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => { it('[S2/SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const device = await registerDevice(); const device = await registerDevice();
const response = await push(device, [ const response = await push(device, [

View file

@ -89,7 +89,7 @@ async function ledgerFor(employeeId: string) {
} }
describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => { describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => {
it('[OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => { it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => {
const w = await world(false); const w = await world(false);
const managerA = await grant('point_manager', [w.unitA.id]); const managerA = await grant('point_manager', [w.unitA.id]);
const batch = await createBatch(managerA, w.unitB.id, w.day); const batch = await createBatch(managerA, w.unitB.id, w.day);
@ -100,7 +100,7 @@ describe('Решения владельца: роли, пакеты зарпла
expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 }); expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 });
}); });
it('[OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => { it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => {
const w = await world(false); const w = await world(false);
const managerA = await grant('point_manager', [w.unitA.id]); const managerA = await grant('point_manager', [w.unitA.id]);
const created = await createBatch(managerA, w.unitA.id, w.day); const created = await createBatch(managerA, w.unitA.id, w.day);
@ -169,7 +169,7 @@ describe('Решения владельца: роли, пакеты зарпла
}).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 }); }).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 });
}); });
it('[OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => {
const accountant = await grant('accountant'); const accountant = await grant('accountant');
const director = await grant('director'); const director = await grant('director');
const w = await world(); const w = await world();
@ -233,7 +233,7 @@ describe('Решения владельца: роли, пакеты зарпла
}); });
}); });
it('[OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, { const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, {
token: ctx().terminalToken, token: ctx().terminalToken,
@ -242,7 +242,7 @@ describe('Решения владельца: роли, пакеты зарпла
expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' }); expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' });
}); });
it('[OWN-11] согласие отозвано → статус для терминала меняется', async () => { it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => {
const [employee] = await importEmployees(1); const [employee] = await importEmployees(1);
const setConsent = (status: 'granted' | 'revoked') => const setConsent = (status: 'granted' | 'revoked') =>
api('PUT', `/employees/${employee!.id}/biometric-consent`, { api('PUT', `/employees/${employee!.id}/biometric-consent`, {

View file

@ -27,14 +27,14 @@ function environmentOf(service: string): Record<string, string> {
} }
describe('deploy/pilot/compose.yaml', () => { describe('deploy/pilot/compose.yaml', () => {
it('[PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => { it('[K1/PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => {
const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!); const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!);
expect(interpolations.length).toBeGreaterThan(0); expect(interpolations.length).toBeGreaterThan(0);
const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner)); const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner));
expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]); expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]);
}); });
it('[PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => { it('[K2/PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => {
const offenders = Object.keys(compose.services).filter( const offenders = Object.keys(compose.services).filter(
(service) => environmentOf(service).NODE_ENV === 'development', (service) => environmentOf(service).NODE_ENV === 'development',
); );

View file

@ -1,43 +1,43 @@
{ {
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.", "comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
"defects": { "defects": {
"N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL", "K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
"N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)", "K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
"N3": "SSRF через push-endpoint на внутренние адреса", "K12/N3": "SSRF через push-endpoint на внутренние адреса",
"N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)", "K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
"N9": "перепривязка чужого push-endpoint", "N9": "перепривязка чужого push-endpoint",
"N10": "справочники доступны терминалу", "N10": "справочники доступны терминалу",
"N11": "импорт с неверным kind → 500", "N11": "импорт с неверным kind → 500",
"N12": "pay-assignment без additionalProperties:false и верхних границ", "N12": "pay-assignment без additionalProperties:false и верхних границ",
"F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму", "K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
"F3": "cookie = сырой токен, logout не отзывает", "K10/F3": "cookie = сырой токен, logout не отзывает",
"F4": "нет rate limit на вход", "K11/F4": "нет rate limit на вход",
"F7": "Idempotency-Key не используется", "F7": "Idempotency-Key не используется",
"F10": "CSRF: мутации по cookie без Origin разрешены", "F10": "CSRF: мутации по cookie без Origin разрешены",
"SYNC-A": "устройство само подтверждает смену через upsert_work_session", "K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
"SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry", "K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
"SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)", "SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)",
"SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)", "S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
"SYNC-D": "изменения из портала не пишутся в staff_sync_change_log", "S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
"SYNC-E": "отметки уволенных сотрудников принимаются через sync", "S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
"SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку", "S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
"SYNC-G": "одна битая команда роняет весь пакет (500)", "S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
"SYNC-H": "регистрация устройства по cookie без проверки Origin", "S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
"SYNC-I": "без токенов анонимно регистрируется устройство", "K1/SYNC-I": "без токенов анонимно регистрируется устройство",
"SYNC-J": "sync принимает отметки вне окна времени", "S2/SYNC-J": "sync принимает отметки вне окна времени",
"SYNC-K": "ack принимает курсор больше выданного сервером", "SYNC-K": "ack принимает курсор больше выданного сервером",
"PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)", "K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
"PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development", "K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
"OWN-01": "решение владельца: роли точки (этап 4 Codex)", "D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
"OWN-02": "решение владельца: пакет отправляет только бухгалтер", "D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
"OWN-03": "решение владельца: уведомления директорам", "OWN-03": "решение владельца: уведомления директорам",
"OWN-04": "решение владельца: подписывает только директор", "OWN-04": "решение владельца: подписывает только директор",
"OWN-05": "решение владельца: ledger пуст до подписи", "OWN-05": "решение владельца: ledger пуст до подписи",
"OWN-06": "решение владельца: подпись → ledger, неизменяемость", "OWN-06": "решение владельца: подпись → ledger, неизменяемость",
"OWN-07": "решение владельца: изменение смены после отправки возвращает пакет", "K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
"OWN-08": "решение владельца: возврат с комментарием", "OWN-08": "решение владельца: возврат с комментарием",
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
"OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", "K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
"OWN-11": "решение владельца: отзыв согласия меняет статус для терминала" "K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала"
} }
} }