fix(reports): разрешить 10-значный рег.номер СФР наравне с XXX-XXX-XXXXXX

XSD-схема ЕФС-1 (ТипРегНомерОбщ) допускает регистрационный номер
страхователя в двух форматах: действующий XXX-XXX-XXXXXX (12 цифр,
3-3-6) или прежний — 10 цифр без разделителей. Валидация на бэке и
фронте (форма реквизитов кооператива + редактор нулевого отчёта
ЕФС-1) принимала только 12-значный формат с тире, из-за чего
кооперативы со старым 10-значным номером ФСС не могли его сохранить.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-07-21 08:43:17 +00:00
parent 76b4988da8
commit 845c13605d
7 changed files with 46 additions and 29 deletions
@@ -1,6 +1,7 @@
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { IsOptional, IsString, IsIn, Matches, MaxLength } from 'class-validator';
import { ReportType } from '../../domain/enums/report-type.enum';
import { SFR_REG_NUMBER_PATTERN } from '../../domain/patterns';
export enum RequisiteSource {
DATABASE = 'database',
@@ -65,7 +66,7 @@ export class ReportRequisitesViewDTO {
* ОКТМО — `\d{8}` или `\d{11}`
* ОКПО — `\d{8}` или `\d{10}`
* СНИЛС — `\d{3}-\d{3}-\d{3} \d{2}` (14 симв.) или 11 цифр подряд
* РегНом СФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире)
* РегНом СФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире) или 10 цифр подряд
*/
@InputType('UpdateReportRequisitesInput')
export class UpdateReportRequisitesInputDTO {
@@ -99,10 +100,10 @@ export class UpdateReportRequisitesInputDTO {
@Matches(/^\d{8}(\d{2})?$/, { message: 'ОКПО — 8 или 10 цифр' })
okpo?: string | null;
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — XXX-XXX-XXXXXX' })
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — XXX-XXX-XXXXXX или 10 цифр' })
@IsOptional()
@IsString()
@Matches(/^\d{3}-\d{3}-\d{6}$/, { message: 'Рег. номер СФР — XXX-XXX-XXXXXX (14 симв.)' })
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег. номер СФР — XXX-XXX-XXXXXX или 10 цифр' })
sfrRegNumber?: string | null;
@Field(() => String, { nullable: true })
@@ -200,7 +200,7 @@ export class ZeroReportSignerEditsInputDTO {
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — XXX-XXX-XXXXXX' })
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — XXX-XXX-XXXXXX или 10 цифр' })
sfrRegNumber!: string | null;
@Field(() => String, { nullable: true })
@@ -43,8 +43,9 @@ export const OKPO_PATTERN = /^\d{8}(\d{2})?$/;
// СНИЛС: XXX-XXX-XXX YY (с пробелом/тире) или 11 цифр подряд.
export const SNILS_PATTERN = /^(\d{3}-\d{3}-\d{3} \d{2}|\d{11})$/;
// Регистрационный номер СФР: XXX-XXX-XXXXXX (3-3-6).
export const SFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
// Регистрационный номер СФР: действующий XXX-XXX-XXXXXX (3-3-6, 12 цифр)
// или прежний — 10 цифр без разделителей (ТипРегНомерОбщ в XSD ЕФС-1).
export const SFR_REG_NUMBER_PATTERN = /^(\d{3}-\d{3}-\d{6}|\d{10})$/;
/** Дата в формате `DD.MM.YYYY` (XSD `xs:date`-совместимый ФНС-формат). */
export const DATE_DDMMYYYY_PATTERN = /^\d{2}\.\d{2}\.\d{4}$/;
@@ -36,9 +36,10 @@ const props = defineProps<{
mask?: string
/** Подставлять плейсхолдеры маски при вводе */
fillMask?: boolean
/** Разрешить только цифры и точку (ОКВЭД: 94.99, 46.73.7). Блокирует
* keypress/paste — буквы вообще не появляются в поле. */
digitsDotsOnly?: boolean
/** Разрешить только цифры + перечисленные доп. символы (напр. '.' для
* ОКВЭД: 94.99, 46.73.7; '-' для Рег. номера СФР). Блокирует
* keypress/paste — посторонние символы вообще не появляются в поле. */
digitsExtraChars?: string
/** HTML maxlength. */
maxLength?: number
/** Допустимые точные длины (ИЛИ). Напр. `[8, 11]` для ОКТМО. */
@@ -60,12 +61,20 @@ const labelText = computed(() =>
)
const inputModeComputed = computed<'numeric' | 'decimal' | 'text'>(() => {
if (props.digitsDotsOnly) return 'decimal'
// '.' — десятичная клавиатура мобильных устройств её показывает; для
// прочих доп.символов (напр. '-') нужна полная клавиатура — 'text'.
if (props.digitsExtraChars === '.') return 'decimal'
if (props.digitsExtraChars) return 'text'
// mask из одних `#` = чистая цифровая клавиатура на мобиле.
if (props.mask && /^#+$/.test(props.mask)) return 'numeric'
return 'text'
})
/** Экранирует спецсимволы regex, чтобы вставить строку в символьный класс `[...]`. */
function escapeForCharClass(chars: string): string {
return chars.replace(/[\]\\^-]/g, '\\$&')
}
const NAV_KEYS = new Set([
'Backspace',
'Delete',
@@ -81,19 +90,21 @@ const NAV_KEYS = new Set([
])
// Блокируем keypress не-допустимых символов — буквы не попадают в поле даже
// на миллисекунды. Работает только для digitsDotsOnly (для чисто цифровых
// на миллисекунды. Работает только для digitsExtraChars (для чисто цифровых
// полей используем Quasar-mask — он нативно блокирует не-# символы).
function onKeydown(e: KeyboardEvent): void {
if (!props.digitsDotsOnly) return
if (!props.digitsExtraChars) return
if (e.ctrlKey || e.metaKey || e.altKey) return
if (NAV_KEYS.has(e.key)) return
if (!/^[\d.]$/.test(e.key)) e.preventDefault()
const re = new RegExp(`^[\\d${escapeForCharClass(props.digitsExtraChars)}]$`)
if (!re.test(e.key)) e.preventDefault()
}
function onPaste(e: ClipboardEvent): void {
if (!props.digitsDotsOnly) return
if (!props.digitsExtraChars) return
const text = e.clipboardData?.getData('text') ?? ''
if (!/^[\d.]*$/.test(text)) e.preventDefault()
const re = new RegExp(`^[\\d${escapeForCharClass(props.digitsExtraChars)}]*$`)
if (!re.test(text)) e.preventDefault()
}
const rules = computed(() => {
@@ -128,7 +139,10 @@ function onInput(raw: string | number | null): void {
// Фоллбек-фильтр на случай programmatic ввода (drop/autofill). Для ручного
// набора актуальна либо mask (q-input сам режет), либо keydown-блокер.
let s = String(raw ?? '')
if (props.digitsDotsOnly) s = s.replace(/[^\d.]+/g, '')
if (props.digitsExtraChars) {
const re = new RegExp(`[^\\d${escapeForCharClass(props.digitsExtraChars)}]+`, 'g')
s = s.replace(re, '')
}
if (props.maxLength && s.length > props.maxLength) s = s.slice(0, props.maxLength)
emit('update:value', s)
}
@@ -37,7 +37,7 @@ q-form.settings-page(@submit.prevent='save' @validation-error='onValidationError
:value='manualInput[f.key]'
:placeholder='f.placeholder'
:mask='f.mask'
:digits-dots-only='f.digitsDotsOnly'
:digits-extra-chars='f.digitsExtraChars'
:max-length='f.maxLength'
:exact-lengths='f.exactLengths'
:pattern='f.pattern'
@@ -60,10 +60,11 @@ q-form.settings-page(@submit.prevent='save' @validation-error='onValidationError
id='field-sfrRegNumber'
label='Рег. номер СФР'
:value='manualInput.sfrRegNumber'
placeholder='XXX-XXX-XXXXXX'
mask='###-###-######'
placeholder='XXX-XXX-XXXXXX или 10 цифр'
digits-extra-chars='-'
:max-length='14'
:pattern='SFR_REG_PATTERN'
pattern-message='Формат: XXX-XXX-XXXXXX (12 цифр)'
pattern-message='Формат: XXX-XXX-XXXXXX (12 цифр) или 10 цифр без разделителей'
required
@update:value='v => (manualInput.sfrRegNumber = v)'
)
@@ -202,7 +203,7 @@ interface ClassifierField {
label: string
placeholder?: string
mask?: string
digitsDotsOnly?: boolean
digitsExtraChars?: string
maxLength?: number
exactLengths?: number[]
pattern?: RegExp
@@ -212,13 +213,13 @@ interface ClassifierField {
// Placeholder'ы — правило ввода (кол-во цифр), а не конкретные примеры.
// Для цифровых полей фиксированной/короткой длины — используем Quasar-mask
// (`#` = только цифра, буквы блокируются на keypress нативно).
// Для ОКВЭД (цифры + точка переменной длины) — digitsDotsOnly с @keydown-блокером.
// Для ОКВЭД (цифры + точка переменной длины) — digitsExtraChars='.' с @keydown-блокером.
const classifierFields: ClassifierField[] = [
{
key: 'okved',
label: 'ОКВЭД',
placeholder: 'XX.XX или XX.XX.XX',
digitsDotsOnly: true,
digitsExtraChars: '.',
maxLength: 8,
pattern: /^\d{2}(\.\d{1,2}){0,2}$/,
patternMessage: 'Формат: XX.XX или XX.XX.XX',
@@ -265,7 +266,7 @@ const signerTypeOptions = [
// пользователь прожал пару цифр и нажал «Сохранить»: mask не мешает
// сохранить частичный ввод, паттерн-правило блокирует.
const SNILS_PATTERN = /^\d{3}-\d{3}-\d{3} \d{2}$/
const SFR_REG_PATTERN = /^\d{3}-\d{3}-\d{6}$/
const SFR_REG_PATTERN = /^(\d{3}-\d{3}-\d{6}|\d{10})$/
function getValue(key: keyof IReportRequisitesView): string {
const v = requisites.value?.[key] as any
@@ -162,8 +162,8 @@
:rules='[reportRules.sfrRegNumber()]'
:error='errFor("signer.sfrRegNumber")'
:error-message='msgFor("signer.sfrRegNumber")'
mask='###-###-######'
hint='XXX-XXX-XXXXXX — обязателен для ЕФС-1'
maxlength='14'
hint='XXX-XXX-XXXXXX или 10 цифр — обязателен для ЕФС-1'
dense filled
)
@@ -41,8 +41,8 @@ export const OKPO_PATTERN = /^\d{8}(\d{2})?$/;
/** СНИЛС: XXX-XXX-XXX YY или 11 цифр подряд. */
export const SNILS_PATTERN = /^(\d{3}-\d{3}-\d{3} \d{2}|\d{11})$/;
/** Регистрационный номер СФР: XXX-XXX-XXXXXX (3-3-6). */
export const SFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
/** Регистрационный номер СФР: действующий XXX-XXX-XXXXXX (3-3-6, 12 цифр) или прежний — 10 цифр без разделителей. */
export const SFR_REG_NUMBER_PATTERN = /^(\d{3}-\d{3}-\d{6}|\d{10})$/;
/** Дата в формате DD.MM.YYYY (ФНС). */
export const DATE_DDMMYYYY_PATTERN = /^\d{2}\.\d{2}\.\d{4}$/;
@@ -70,7 +70,7 @@ export const reportRules = {
okopf: (message = 'ОКОПФ — 5 цифр') => (v: unknown) => OKOPF_PATTERN.test(String(v ?? '')) || message,
okpo: (message = 'ОКПО — 8 или 10 цифр') => (v: unknown) => OKPO_PATTERN.test(String(v ?? '')) || message,
snils: (message = 'СНИЛС — XXX-XXX-XXX YY или 11 цифр') => (v: unknown) => SNILS_PATTERN.test(String(v ?? '')) || message,
sfrRegNumber: (message = 'Рег. номер СФР — XXX-XXX-XXXXXX') => (v: unknown) => SFR_REG_NUMBER_PATTERN.test(String(v ?? '')) || message,
sfrRegNumber: (message = 'Рег. номер СФР — XXX-XXX-XXXXXX или 10 цифр') => (v: unknown) => SFR_REG_NUMBER_PATTERN.test(String(v ?? '')) || message,
dateDdMmYyyy: (message = 'Дата — DD.MM.YYYY') => (v: unknown) => DATE_DDMMYYYY_PATTERN.test(String(v ?? '')) || message,
kbk: (message = 'КБК — 20 цифр') => (v: unknown) => KBK_PATTERN.test(String(v ?? '')) || message,
/** Непустая строка длиной от `min` до `max` символов. */