Files
iistwin/server/documents/transform.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

209 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
}