fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
208
server/documents/transform.service.ts
Normal file
208
server/documents/transform.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user