fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { incline, inclineFirstname, inclineLastname, inclineMiddlename } from 'lvovich';
import numberToWordsRuPkg from 'number-to-words-ru';
import { ToWords } from 'to-words';
const convertRu = (numberToWordsRuPkg as any).convert || numberToWordsRuPkg;
export interface TransformConfig {
type: string;
params?: Record<string, any>;
}
export type TransformType =
| 'none'
| 'date_long'
| 'date_short'
| 'currency_text'
| 'currency_number'
| 'uppercase'
| 'lowercase'
| 'initials'
| 'declension'
| 'name_part'
| 'number_to_words';
const toWordsEn = new ToWords({ localeCode: 'en-US' });
export class TransformService {
applyTransform(
value: string | null | undefined,
transform?: TransformConfig | TransformType | string | null,
): string {
if (value === null || value === undefined || value === '') return '';
const config = this.normalizeTransform(transform);
if (!config || config.type === 'none') return String(value);
switch (config.type) {
case 'date_long':
return this.toDateLong(value);
case 'date_short':
return this.toDateShort(value);
case 'currency_text':
return this.toCurrencyText(value, config.params);
case 'currency_number':
return this.toCurrencyNumber(value);
case 'uppercase':
return String(value).toUpperCase();
case 'lowercase':
return String(value).toLowerCase();
case 'initials':
return this.toInitials(value);
case 'declension':
return this.toDeclension(value, config.params);
case 'name_part':
case 'namePart':
return this.toNamePart(value, config.params);
case 'number_to_words':
case 'numberToWords':
return this.toNumberToWords(value, config.params);
default:
return String(value);
}
}
private normalizeTransform(
transform?: TransformConfig | TransformType | string | null,
): TransformConfig | null {
if (!transform) return null;
if (typeof transform === 'string') {
return { type: transform };
}
return transform;
}
private toDateLong(value: string): string {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return format(date, '«dd» MMMM yyyy\'г.\'', { locale: ru });
} catch {
return value;
}
}
private toDateShort(value: string): string {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return format(date, 'dd.MM.yyyy');
} catch {
return value;
}
}
private parseNumber(value: string): number | null {
const cleaned = String(value)
.replace(/\s/g, '')
.replace(',', '.');
const num = parseFloat(cleaned);
return Number.isFinite(num) ? num : null;
}
private toCurrencyText(value: string, params?: Record<string, any>): string {
const num = this.parseNumber(value);
if (num === null) return value;
const currency = params?.currency || 'rub';
try {
return convertRu(String(num), { currency });
} catch {
return value;
}
}
private toCurrencyNumber(value: string): string {
const num = this.parseNumber(value);
if (num === null) return value;
return num.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
private toInitials(value: string): string {
const parts = String(value).trim().split(/\s+/);
if (parts.length >= 3) {
const [last, first, middle] = parts;
return `${last} ${first[0]}.${middle[0]}.`;
}
if (parts.length === 2) {
const [last, first] = parts;
return `${last} ${first[0]}.`;
}
return value;
}
private toDeclension(value: string, params?: Record<string, any>): string {
const raw = String(value).trim();
if (!raw) return value;
const parts = raw.split(/\s+/);
const declension = params?.case || 'dative';
if (parts.length >= 3) {
const [last, first, middle] = parts;
const result = incline({ last, first, middle }, declension);
return [result.last, result.first, result.middle].filter(Boolean).join(' ');
}
if (parts.length === 2) {
const [last, first] = parts;
const result = incline({ last, first }, declension);
return [result.last, result.first].filter(Boolean).join(' ');
}
if (parts.length === 1) {
const [last] = parts;
return inclineLastname(last, declension) || value;
}
return value;
}
private toNamePart(value: string, params?: Record<string, any>): string {
const raw = String(value).trim();
if (!raw) return value;
const parts = raw.split(/\s+/);
const part = params?.part || 'firstNameWithMiddleName';
switch (part) {
case 'lastName':
return parts[0] || '';
case 'firstName':
return parts[1] || '';
case 'middleName':
return parts[2] || '';
case 'firstNameWithMiddleName':
return parts.slice(1, 3).join(' ');
case 'shortName':
return this.toInitials(value);
case 'fullName':
default:
return value;
}
}
private toNumberToWords(value: string, params?: Record<string, any>): string {
const num = this.parseNumber(value);
if (num === null) return value;
const currency = params?.currency || 'rub';
try {
return convertRu(String(num), { currency });
} catch {
return value;
}
}
numberToWords(amount: number, locale: string = 'ru'): string {
if (locale === 'ru') {
try {
return convertRu(String(amount), { currency: 'rub' });
} catch {
return String(amount);
}
}
try {
return toWordsEn.convert(amount);
} catch {
return String(amount);
}
}
}