Перейти к основному содержимому

Валидация между шагами

Валидация бизнес-правил, охватывающих несколько шагов формы, с пользовательскими и асинхронными валидаторами.

Что мы валидируем

Валидация между шагами применяет бизнес-правила, которые зависят от полей нескольких шагов:

ПравилоЗадействованные поляТип валидации
Первоначальный платёж >= 20% имуществаШаг 1: initialPayment, propertyValueПользовательский
Ежемесячный платёж <= 50% доходаШаг 1: monthlyPayment
Шаг 4: totalIncome
Шаг 5: coBorrowersIncome
Пользовательский
Сумма кредита <= цена автомобиляШаг 1: loanAmount, carPriceПользовательский
Остаток кредита <= оригинальная суммаШаг 5: existingLoans[*].remainingAmount, amountПользовательский
Валидация возраста 18-70Шаг 2: age (рассчитано из birthDate)Пользовательский
Проверка ИННШаг 2: innАсинхронный
Проверка СНИЛСШаг 2: snilsАсинхронный
Уникальность emailШаг 3: emailАсинхронный

Создание файла валидатора

Создайте файл валидатора между шагами:

touch src/schemas/validators/cross-step.ts

Реализация

Валидация первоначального платежа

Убедитесь, что первоначальный платёж составляет минимум 20% от стоимости имущества:

src/schemas/validators/cross-step.ts
import { validate, validateAsync } from '@reformer/core/validators';
import type { ValidationSchemaFn, FieldPath } from '@reformer/core';
import type { CreditApplicationForm } from '@/types';

/**
* Валидация между шагами
*
* Валидирует бизнес-правила, охватывающие несколько шагов формы:
* - Первоначальный платёж >= 20% от стоимости имущества
* - Ежемесячный платёж <= 50% от общего дохода домохозяйства
* - Сумма кредита <= цена автомобиля
* - Остаток кредита <= оригинальная сумма кредита
* - Требования возраста (18-70)
* - Асинхронно: ИНН, СНИЛС, уникальность email
*/
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (
path: FieldPath<CreditApplicationForm>
) => {
// ==========================================
// 1. Первоначальный платёж >= 20% от имущества
// ==========================================
validate(path.initialPayment, (initialPayment, ctx) => {
const loanType = ctx.form.loanType.value.value;
if (loanType !== 'mortgage') return null;

const propertyValue = ctx.form.propertyValue.value.value;
if (!propertyValue || !initialPayment) return null;

const minPayment = propertyValue * 0.2;
if (initialPayment < minPayment) {
return {
code: 'minInitialPayment',
message: `Минимальный первоначальный платёж: ${minPayment.toLocaleString()} (20% от стоимости имущества)`,
};
}

return null;
});
};

Валидация ежемесячного платежа против дохода

Убедитесь, что ежемесячный платёж не превышает 50% от общего дохода домохозяйства:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 2. Ежемесячный платёж <= 50% дохода
// ==========================================
validate(path.monthlyPayment, (monthlyPayment, ctx) => {
const totalIncome = ctx.form.totalIncome.value.value || 0;
const coBorrowersIncome = ctx.form.coBorrowersIncome.value.value || 0;
const householdIncome = totalIncome + coBorrowersIncome;

if (!householdIncome || !monthlyPayment) return null;

const maxPayment = householdIncome * 0.5;
if (monthlyPayment > maxPayment) {
return {
code: 'maxPaymentToIncome',
message: `Ежемесячный платёж превышает 50% дохода домохозяйства (макс: ${maxPayment.toLocaleString()})`,
};
}

return null;
});
};

Валидация суммы кредита для автокредита

Убедитесь, что сумма кредита не превышает цену автомобиля:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 3. Сумма кредита <= цена автомобиля
// ==========================================
validate(path.loanAmount, (loanAmount, ctx) => {
const loanType = ctx.form.loanType.value.value;
if (loanType !== 'car') return null;

const carPrice = ctx.form.carPrice.value.value;
if (!carPrice || !loanAmount) return null;

if (loanAmount > carPrice) {
return {
code: 'loanExceedsCarPrice',
message: 'Сумма кредита не может превышать цену автомобиля',
};
}

return null;
});
};

Валидация остатка кредита

Валидируйте остаток кредита, чтобы он не превышал оригинальную сумму:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...
// ==========================================
// 4. Остаток кредита <= оригинальная сумма (через validateItems)
// ==========================================
// Примечание: Это валидация уровня элемента массива, обычно выполняется
// в файле валидации additional-info.ts с использованием validateItems
};

Валидация возраста

Валидируйте возраст между 18 и 70:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 4. Требования возраста (18-70)
// ==========================================
validate(path.age, (age) => {
if (age === null || age === undefined) return null;

if (age < 18) {
return {
code: 'minAge',
message: 'Заявитель должен быть не моложе 18 лет',
};
}

if (age > 70) {
return {
code: 'maxAge',
message: 'Заявитель должен быть не старше 70 лет',
};
}

return null;
});
};

Асинхронная валидация: Проверка ИНН

Добавьте асинхронную валидацию для проверки ИНН:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 5. Асинхронно: Проверка ИНН
// ==========================================
validateAsync(
path.inn,
async (inn) => {
if (!inn || typeof inn !== 'string') return null;
if (inn.length < 10) return null;

try {
const response = await fetch(`/api/validate/inn?value=${inn}`);
const result = await response.json();

if (!result.valid) {
return {
code: 'invalidInn',
message: result.message || 'Неверный ИНН',
};
}

return null;
} catch (error) {
console.error('Ошибка валидации ИНН:', error);
return null;
}
},
{ debounce: 500 }
);
};

Асинхронная валидация: Проверка СНИЛС

Добавьте асинхронную валидацию для проверки СНИЛС:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 6. Асинхронно: Проверка СНИЛС
// ==========================================
validateAsync(
path.snils,
async (snils) => {
if (!snils || typeof snils !== 'string') return null;
if (snils.length < 11) return null;

try {
const response = await fetch(`/api/validate/snils?value=${snils}`);
const result = await response.json();

if (!result.valid) {
return {
code: 'invalidSnils',
message: result.message || 'Неверный СНИЛС',
};
}

return null;
} catch (error) {
console.error('Ошибка валидации СНИЛС:', error);
return null;
}
},
{ debounce: 500 }
);
};

Асинхронная валидация: Уникальность email

Добавьте асинхронную валидацию для уникальности email:

src/schemas/validators/cross-step.ts
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (path) => {
// ... предыдущая валидация ...

// ==========================================
// 7. Асинхронно: Проверка уникальности email
// ==========================================
validateAsync(
path.email,
async (email) => {
if (!email || typeof email !== 'string') return null;

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) return null;

try {
const response = await fetch(
`/api/validate/email-unique?email=${encodeURIComponent(email)}`
);
const result = await response.json();

if (!result.unique) {
return {
code: 'emailNotUnique',
message: 'Этот email уже зарегистрирован. Используйте другой email или войдите.',
};
}

return null;
} catch (error) {
console.error('Ошибка проверки уникальности email:', error);
return null;
}
},
{ debounce: 800 }
);
};

Полный код

Вот полный валидатор между шагами:

src/schemas/validators/cross-step.ts
import { validate, validateAsync } from '@reformer/core/validators';
import type { ValidationSchemaFn, FieldPath } from '@reformer/core';
import type { CreditApplicationForm } from '@/types';

/**
* Валидация между шагами
*
* Валидирует бизнес-правила, охватывающие несколько шагов формы
*/
export const crossStepValidation: ValidationSchemaFn<CreditApplicationForm> = (
path: FieldPath<CreditApplicationForm>
) => {
// ==========================================
// 1. Первоначальный платёж >= 20% от имущества
// ==========================================
createValidator(
path.initialPayment,
[path.propertyValue, path.loanType],
(initialPayment, [propertyValue, loanType]) => {
if (loanType !== 'mortgage') return null;
if (!propertyValue || !initialPayment) return null;

const minPayment = (propertyValue as number) * 0.2;
if ((initialPayment as number) < minPayment) {
return {
type: 'minInitialPayment',
message: `Минимальный первоначальный платёж: ${minPayment.toLocaleString()} (20% от стоимости имущества)`,
};
}

return null;
}
);

// ==========================================
// 2. Ежемесячный платёж <= 50% дохода
// ==========================================
createValidator(
path.monthlyPayment,
[path.totalIncome, path.coBorrowersIncome],
(monthlyPayment, [totalIncome, coBorrowersIncome]) => {
const householdIncome = ((totalIncome as number) || 0) + ((coBorrowersIncome as number) || 0);
if (!householdIncome || !monthlyPayment) return null;

const maxPayment = householdIncome * 0.5;
if ((monthlyPayment as number) > maxPayment) {
return {
type: 'maxPaymentToIncome',
message: `Ежемесячный платёж превышает 50% дохода домохозяйства (макс: ${maxPayment.toLocaleString()})`,
};
}

return null;
}
);

// ==========================================
// 3. Сумма кредита <= цена автомобиля
// ==========================================
createValidator(
path.loanAmount,
[path.carPrice, path.loanType],
(loanAmount, [carPrice, loanType]) => {
if (loanType !== 'car') return null;
if (!carPrice || !loanAmount) return null;

if ((loanAmount as number) > (carPrice as number)) {
return {
type: 'loanExceedsCarPrice',
message: 'Сумма кредита не может превышать цену автомобиля',
};
}

return null;
}
);

// ==========================================
// 4. Остаток кредита <= оригинальная сумма (через validateItems)
// ==========================================
// Примечание: Это валидация уровня элемента массива, обычно выполняется
// в файле валидации additional-info.ts с использованием validateItems

// ==========================================
// 4. Требования возраста (18-70)
// ==========================================
validate(path.age, (age) => {
if (age === null || age === undefined) return null;

if (age < 18) {
return {
code: 'minAge',
message: 'Заявитель должен быть не моложе 18 лет',
};
}

if (age > 70) {
return {
code: 'maxAge',
message: 'Заявитель должен быть не старше 70 лет',
};
}

return null;
});

// ==========================================
// 6. Асинхронно: Проверка ИНН
// ==========================================
createAsyncValidator(
path.inn,
async (inn) => {
if (!inn || typeof inn !== 'string') return null;
if (inn.length < 10) return null;

try {
const response = await fetch(`/api/validate/inn?value=${inn}`);
const result = await response.json();

if (!result.valid) {
return {
type: 'invalidInn',
message: result.message || 'Неверный ИНН',
};
}

return null;
} catch (error) {
console.error('Ошибка валидации ИНН:', error);
return null;
}
},
{ debounce: 500 }
);

// ==========================================
// 6. Асинхронно: Проверка СНИЛС
// ==========================================
validateAsync(
path.snils,
async (snils) => {
if (!snils || typeof snils !== 'string') return null;
if (snils.length < 11) return null;

try {
const response = await fetch(`/api/validate/snils?value=${snils}`);
const result = await response.json();

if (!result.valid) {
return {
code: 'invalidSnils',
message: result.message || 'Неверный СНИЛС',
};
}

return null;
} catch (error) {
console.error('Ошибка валидации СНИЛС:', error);
return null;
}
},
{ debounce: 500 }
);

// ==========================================
// 8. Асинхронно: Проверка уникальности email
// ==========================================
createAsyncValidator(
path.email,
async (email) => {
if (!email || typeof email !== 'string') return null;

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) return null;

try {
const response = await fetch(
`/api/validate/email-unique?email=${encodeURIComponent(email)}`
);
const result = await response.json();

if (!result.unique) {
return {
type: 'emailNotUnique',
message: 'Этот email уже зарегистрирован. Используйте другой email или войдите.',
};
}

return null;
} catch (error) {
console.error('Ошибка проверки уникальности email:', error);
return null;
}
},
{ debounce: 800 }
);
};

Как это работает

Пользовательские валидаторы с доступом к контексту

validate(path.monthlyPayment, (monthlyPayment, ctx) => {
// Получите зависимые значения через контекст
const totalIncome = ctx.form.totalIncome.value.value || 0;
const coBorrowersIncome = ctx.form.coBorrowersIncome.value.value || 0;

// Логика валидации
// Возвращайте null если валидно
// Возвращайте { code, message } если невалидно
});

Ключевые моменты:

  • Первый параметр: поле для валидации
  • Второй параметр: функция валидации с доступом к значению и контексту
  • Используйте ctx.form для доступа к другим полям формы
  • Валидатор переиспускается когда любое поле формы изменяется

Асинхронные валидаторы

validateAsync(
path.inn,
async (inn) => {
// Асинхронная логика валидации (можно использовать fetch, promises, и т.д.)
// Возвращайте null если валидно
// Возвращайте { code, message } если невалидно
},
{ debounce: 500 } // Опции: задержка debounce
);

Ключевые особенности:

  • Можно делать API вызовы, запросы БД, и т.д.
  • Debouncing предотвращает чрезмерные запросы
  • Показывает состояние загрузки во время валидации
  • Сетевые ошибки не должны ломать валидацию (возвращайте null)

Debouncing

{
debounce: 500;
} // Ждите 500ms после остановки набора текста

Почему debounce?:

  • Предотвращает API вызов при каждом нажатии клавиши
  • Улучшает пользовательский опыт
  • Уменьшает нагрузку на сервер
  • Типичные значения: 300-800ms

Тестирование валидации

Протестируйте эти сценарии:

Валидация первоначального платежа

  • Выберите тип кредита ипотека
  • Введите стоимость имущества: 5 000 000
  • Введите первоначальный платёж < 1 000 000 (20%) → Ошибка показана
  • Введите первоначальный платёж >= 1 000 000 → Ошибки нет
  • Переключитесь на другой тип кредита → Ошибка исчезает

Ежемесячный платёж против дохода

  • Введите ежемесячный доход: 100 000
  • Доход созаёмщиков: 50 000 (всего: 150 000)
  • Ежемесячный платёж > 75 000 (50%) → Ошибка показана
  • Ежемесячный платёж <= 75 000 → Ошибки нет
  • Измените доход → Валидация переиспускается

Сумма кредита на автомобиль

  • Выберите тип кредита автокредит
  • Введите цену автомобиля: 2 000 000
  • Введите сумму кредита > 2 000 000 → Ошибка показана
  • Введите сумму кредита <= 2 000 000 → Ошибки нет

Остаток кредита

  • Добавьте существующий кредит с суммой: 500 000
  • Введите остаток > 500 000 → Ошибка показана
  • Введите остаток <= 500 000 → Ошибки нет

Валидация возраста

  • Введите дату рождения которая делает возраст < 18 → Ошибка показана
  • Введите дату рождения которая делает возраст > 70 → Ошибка показана
  • Введите валидный возраст (18-70) → Ошибки нет

Асинхронная валидация: Проверка ИНН

  • Введите ИНН → Видите индикатор загрузки
  • После 500ms → Сделан API вызов
  • Невалидный ИНН → Ошибка показана с сервера
  • Валидный ИНН → Ошибки нет

Асинхронная валидация: Уникальность email

  • Введите email → Видите индикатор загрузки
  • После 800ms → Сделан API вызов
  • Email уже зарегистрирован → Ошибка показана
  • Уникальный email → Ошибки нет

Макеты API ответов

Для тестирования создайте макеты API конечных точек:

// /api/validate/inn
{
valid: true | false,
message: 'Неверная контрольная сумма ИНН' // Когда невалидно
}

// /api/validate/snils
{
valid: true | false,
message: 'Неверный СНИЛС' // Когда невалидно
}

// /api/validate/email-unique
{
unique: true | false
}

Ключевые выводы

  1. Пользовательские валидаторы - Создавайте сложные бизнес-правила с validate()
  2. Доступ к контексту - Используйте ctx.form для доступа к другим полям
  3. Асинхронные валидаторы - Делайте серверные вызовы валидации с validateAsync()
  4. Debouncing - Уменьшайте ненужные API вызовы
  5. Обработка ошибок - Грациозно обрабатывайте сетевые ошибки
  6. Типобезопасность - Полная поддержка TypeScript для всех валидаторов

Лучшие практики

1. Ранние возвраты

validate(path.field, (value, ctx) => {
// Возвращайте ранее для случаев которые не нуждаются в валидации
if (!value) return null;

const dep = ctx.form.dependency.value.value;
if (!dep) return null;

// Основная логика валидации
if (invalid) {
return { code: 'error', message: 'Сообщение об ошибке' };
}

return null;
});

2. Грациозный асинхронный отказ

validateAsync(
path.field,
async (value) => {
try {
// API вызов
} catch (error) {
console.error('Ошибка валидации:', error);
return null; // Не ломайте на сетевых ошибках
}
},
{ debounce: 500 }
);

3. Ясные сообщения об ошибках

return {
code: 'descriptiveErrorCode',
message: 'Ясное, действенное сообщение об ошибке с контекстом',
};

Что дальше?

В финальном разделе мы объединим все валидаторы и зарегистрируем их с формой:

  • Создадим основной файл валидатора
  • Импортируем все валидаторы шагов
  • Зарегистрируем с созданием формы
  • Протестируем полную валидацию
  • Проверим полную структуру файлов

Давайте всё свяжем вместе!