@hed-hog/finance 0.0.233 → 0.0.235

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,10 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import { AiService } from '@hed-hog/core';
5
5
  import {
6
- BadRequestException,
7
- Injectable,
8
- NotFoundException
6
+ BadRequestException,
7
+ Injectable,
8
+ Logger,
9
+ NotFoundException
9
10
  } from '@nestjs/common';
10
11
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
11
12
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
@@ -15,6 +16,8 @@ type TitleType = 'payable' | 'receivable';
15
16
 
16
17
  @Injectable()
17
18
  export class FinanceService {
19
+ private readonly logger = new Logger(FinanceService.name);
20
+
18
21
  constructor(
19
22
  private readonly prisma: PrismaService,
20
23
  private readonly paginationService: PaginationService,
@@ -34,20 +37,25 @@ export class FinanceService {
34
37
  const slug = isPayable
35
38
  ? 'finance-payable-extractor'
36
39
  : 'finance-receivable-extractor';
40
+ const llmAttachmentDebug = await this.ai.debugAttachmentForLlm(file, fileId);
41
+ this.logger.warn(
42
+ `[FINANCE-EXTRACT] start type=${titleType} fileId=${fileId || 'upload'} mode=${llmAttachmentDebug?.mode || 'none'} mime=${llmAttachmentDebug?.mimeType || 'n/a'} textLength=${llmAttachmentDebug?.textLength || 0}`,
43
+ );
37
44
 
38
45
  const schema = isPayable
39
46
  ? '{"documento":"string|null","fornecedor_nome":"string|null","categoria_nome":"string|null","centro_custo_nome":"string|null","competencia":"YYYY-MM|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"metodo":"boleto|pix|transferencia|cartao|dinheiro|cheque|null","descricao":"string|null","confidence":number|null}'
40
47
  : '{"documento":"string|null","cliente_nome":"string|null","categoria_nome":"string|null","centro_custo_nome":"string|null","competencia":"YYYY-MM|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"canal":"boleto|pix|transferencia|cartao|null","descricao":"string|null","confidence":number|null}';
41
48
 
42
49
  const extractionMessage = isPayable
43
- ? 'Extract the payable form fields from the attached file. If uncertain, return null for that field. Response must be JSON only.'
44
- : 'Extract the receivable form fields from the attached file. If uncertain, return null for that field. Response must be JSON only.';
50
+ ? 'Extract the payable form fields from the attached file. IMPORTANT: prioritize Brazilian invoice formats (pt-BR). Monetary values may come as 1.900,00 or 1900.00. Always return numeric value in JSON (example: 1900). Prefer the field explicitly labeled as valor total / total da nota / total a pagar. Never guess; if uncertain, return null. Response must be JSON only.'
51
+ : 'Extract the receivable form fields from the attached file. IMPORTANT: prioritize Brazilian invoice formats (pt-BR). Monetary values may come as 1.900,00 or 1900.00. Always return numeric value in JSON (example: 1900). Prefer the field explicitly labeled as valor total / total da nota / total a receber. Never guess; if uncertain, return null. Response must be JSON only.';
45
52
 
46
53
  await this.ai.createAgent({
47
54
  slug,
48
55
  provider: 'openai',
56
+ model: 'gpt-4o',
49
57
  instructions:
50
- `You are a financial OCR extraction assistant. Extract only ${titleType} title fields from invoice/bill files. Always return valid JSON only, no markdown. Use this schema: ${schema}.`,
58
+ `You are a financial OCR extraction assistant. Extract only ${titleType} title fields from invoice/bill files. Be strict with monetary values in pt-BR format: 1.900,00 means 1900.00. Never confuse punctuation separators. Prefer explicit labels in this order: "valor dos serviços" > "valor total da nota" > "valor líquido". For vencimento, use fields like "vencimento" or "data de vencimento" only; do not use data de emissão as vencimento. If confidence is low, return null. Always return valid JSON only, no markdown. Use this schema: ${schema}.`,
51
59
  });
52
60
 
53
61
  const result = await this.ai.chatWithAgent(
@@ -60,35 +68,415 @@ export class FinanceService {
60
68
  );
61
69
 
62
70
  const parsed = this.parseAiJson(result?.content || '{}');
71
+ this.logger.warn(
72
+ `[FINANCE-EXTRACT] pass1 type=${titleType} hasDocumento=${!!this.toNullableString(parsed?.documento)} hasValor=${this.toNullableNumber(parsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(parsed?.vencimento)}`,
73
+ );
74
+ const mergedParsed = await this.enhanceParsedExtraction(
75
+ parsed,
76
+ slug,
77
+ isPayable,
78
+ llmAttachmentDebug,
79
+ fileId,
80
+ file,
81
+ );
63
82
 
64
- const personName = isPayable ? parsed.fornecedor_nome : parsed.cliente_nome;
83
+ const personName = isPayable
84
+ ? mergedParsed.fornecedor_nome
85
+ : mergedParsed.cliente_nome;
65
86
  const personId = await this.matchPersonId(personName);
66
- const categoriaId = await this.matchCategoryId(parsed.categoria_nome);
67
- const centroCustoId = await this.matchCostCenterId(parsed.centro_custo_nome);
87
+ const categoriaId = await this.matchCategoryId(mergedParsed.categoria_nome);
88
+ const centroCustoId = await this.matchCostCenterId(
89
+ mergedParsed.centro_custo_nome,
90
+ );
91
+
92
+ const metodo = this.normalizePaymentMethod(
93
+ mergedParsed.metodo || mergedParsed.canal,
94
+ );
95
+ const documento = this.toNullableString(mergedParsed.documento);
96
+ const categoriaNome = this.toNullableString(mergedParsed.categoria_nome);
97
+ const centroCustoNome = this.toNullableString(mergedParsed.centro_custo_nome);
98
+ const competencia = this.normalizeMonth(mergedParsed.competencia);
99
+ const vencimento = this.normalizeDate(mergedParsed.vencimento);
100
+ const valor = this.toNullableNumber(mergedParsed.valor);
101
+ const descricao = this.toNullableString(mergedParsed.descricao);
102
+ const confidence = this.resolveExtractionConfidence(
103
+ this.toNullableNumber(mergedParsed.confidence),
104
+ {
105
+ documento,
106
+ personId,
107
+ categoriaId,
108
+ centroCustoId,
109
+ vencimento,
110
+ valor,
111
+ },
112
+ );
113
+ const warnings = this.buildExtractionWarnings(confidence, {
114
+ valor,
115
+ vencimento,
116
+ });
68
117
 
69
- const metodo = this.normalizePaymentMethod(parsed.metodo || parsed.canal);
118
+ this.logger.warn(
119
+ `[FINANCE-EXTRACT] done type=${titleType} confidence=${confidence ?? 'null'} valor=${valor ?? 'null'} vencimento=${vencimento || 'null'} fallback=${mergedParsed?.__fallbackSource || 'none'}`,
120
+ );
70
121
 
71
122
  return {
72
- documento: this.toNullableString(parsed.documento),
123
+ documento,
73
124
  fornecedorId: isPayable ? personId : '',
74
125
  fornecedorNome: isPayable ? this.toNullableString(personName) : null,
75
126
  clienteId: !isPayable ? personId : '',
76
127
  clienteNome: !isPayable ? this.toNullableString(personName) : null,
77
128
  categoriaId,
78
- categoriaNome: this.toNullableString(parsed.categoria_nome),
129
+ categoriaNome,
79
130
  centroCustoId,
80
- centroCustoNome: this.toNullableString(parsed.centro_custo_nome),
81
- competencia: this.normalizeMonth(parsed.competencia),
82
- vencimento: this.normalizeDate(parsed.vencimento),
83
- valor: this.toNullableNumber(parsed.valor),
131
+ centroCustoNome,
132
+ competencia,
133
+ vencimento,
134
+ valor,
84
135
  metodo,
85
136
  canal: metodo,
86
- descricao: this.toNullableString(parsed.descricao),
87
- confidence: this.toNullableNumber(parsed.confidence),
88
- raw: parsed,
137
+ descricao,
138
+ confidence,
139
+ confidenceLevel:
140
+ confidence === null ? null : confidence < 70 ? 'low' : 'high',
141
+ warnings,
142
+ raw: {
143
+ ...mergedParsed,
144
+ __llmAttachmentDebug: llmAttachmentDebug,
145
+ },
146
+ };
147
+ }
148
+
149
+ private async enhanceParsedExtraction(
150
+ parsed: any,
151
+ slug: string,
152
+ isPayable: boolean,
153
+ llmAttachmentDebug?: any,
154
+ fileId?: number,
155
+ file?: MulterFile,
156
+ ) {
157
+ const base = parsed || {};
158
+
159
+ const hasDocumento = !!this.toNullableString(base.documento);
160
+ const hasValor = this.toNullableNumber(base.valor) !== null;
161
+ const hasVencimento = !!this.normalizeDate(base.vencimento);
162
+ const hasPessoa = !!this.toNullableString(
163
+ isPayable ? base.fornecedor_nome : base.cliente_nome,
164
+ );
165
+
166
+ if (hasDocumento || hasValor || hasVencimento || hasPessoa) {
167
+ this.logger.warn('[FINANCE-EXTRACT] pass2 skipped: pass1 already has key fields');
168
+ return base;
169
+ }
170
+
171
+ const fallbackSchema = isPayable
172
+ ? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
173
+ : '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
174
+
175
+ const fallbackMessage = isPayable
176
+ ? `Second pass. Extract ONLY documento, fornecedor_nome, vencimento, valor. Prefer "valor dos serviços" then "valor total" then "valor líquido". Return JSON only with this schema: ${fallbackSchema}`
177
+ : `Second pass. Extract ONLY documento, cliente_nome, vencimento, valor. Prefer "valor dos serviços" then "valor total" then "valor líquido". Return JSON only with this schema: ${fallbackSchema}`;
178
+
179
+ const retry = await this.ai.chatWithAgent(
180
+ slug,
181
+ {
182
+ message: fallbackMessage,
183
+ file_id: fileId,
184
+ },
185
+ file,
186
+ );
187
+
188
+ const retryParsed = this.parseAiJson(retry?.content || '{}');
189
+ this.logger.warn(
190
+ `[FINANCE-EXTRACT] pass2 hasDocumento=${!!this.toNullableString(retryParsed?.documento)} hasValor=${this.toNullableNumber(retryParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(retryParsed?.vencimento)}`,
191
+ );
192
+
193
+ const mergedAfterRetry = {
194
+ ...retryParsed,
195
+ ...base,
196
+ documento: this.toNullableString(base.documento)
197
+ ? base.documento
198
+ : retryParsed.documento,
199
+ valor:
200
+ this.toNullableNumber(base.valor) !== null ? base.valor : retryParsed.valor,
201
+ vencimento: this.normalizeDate(base.vencimento)
202
+ ? base.vencimento
203
+ : retryParsed.vencimento,
204
+ fornecedor_nome: isPayable
205
+ ? this.toNullableString(base.fornecedor_nome)
206
+ ? base.fornecedor_nome
207
+ : retryParsed.fornecedor_nome
208
+ : base.fornecedor_nome,
209
+ cliente_nome: !isPayable
210
+ ? this.toNullableString(base.cliente_nome)
211
+ ? base.cliente_nome
212
+ : retryParsed.cliente_nome
213
+ : base.cliente_nome,
214
+ confidence:
215
+ this.toNullableNumber(base.confidence) !== null
216
+ ? base.confidence
217
+ : retryParsed.confidence,
218
+ };
219
+
220
+ const stillEmpty =
221
+ !this.toNullableString(mergedAfterRetry.documento) &&
222
+ this.toNullableNumber(mergedAfterRetry.valor) === null &&
223
+ !this.normalizeDate(mergedAfterRetry.vencimento) &&
224
+ !this.toNullableString(
225
+ isPayable
226
+ ? mergedAfterRetry.fornecedor_nome
227
+ : mergedAfterRetry.cliente_nome,
228
+ );
229
+
230
+ if (!stillEmpty) {
231
+ this.logger.warn('[FINANCE-EXTRACT] regex fallback skipped: pass2 produced data');
232
+ return mergedAfterRetry;
233
+ }
234
+
235
+ const rawText = await this.ai.extractAttachmentText(file, fileId);
236
+ const regexFallback = this.extractFinancialFieldsFromRawText(rawText, isPayable);
237
+ this.logger.warn(
238
+ `[FINANCE-EXTRACT] regex fallback activated hasText=${!!rawText} hasDocumento=${!!this.toNullableString(regexFallback?.documento)} hasValor=${this.toNullableNumber(regexFallback?.valor) !== null} hasVencimento=${!!this.normalizeDate(regexFallback?.vencimento)}`,
239
+ );
240
+
241
+ const stillEmptyAfterRegex =
242
+ !this.toNullableString(regexFallback.documento) &&
243
+ this.toNullableNumber(regexFallback.valor) === null &&
244
+ !this.normalizeDate(regexFallback.vencimento);
245
+
246
+ if (
247
+ stillEmptyAfterRegex &&
248
+ llmAttachmentDebug?.mode === 'pdf-text' &&
249
+ Number(llmAttachmentDebug?.textLength || 0) === 0
250
+ ) {
251
+ this.logger.warn(
252
+ '[FINANCE-EXTRACT] gemini fallback activated for scanned PDF (no text layer)',
253
+ );
254
+
255
+ const geminiSchema = isPayable
256
+ ? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
257
+ : '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
258
+
259
+ const geminiPrompt = isPayable
260
+ ? `Extract documento, fornecedor_nome, vencimento and valor from this invoice PDF. For valor, prefer 'valor dos serviços', then 'valor total', then 'valor líquido'. Return JSON only using schema: ${geminiSchema}`
261
+ : `Extract documento, cliente_nome, vencimento and valor from this invoice PDF. For valor, prefer 'valor dos serviços', then 'valor total', then 'valor líquido'. Return JSON only using schema: ${geminiSchema}`;
262
+
263
+ const geminiModels = [
264
+ 'gemini-2.5-flash',
265
+ 'gemini-2.5-pro',
266
+ 'gemini-2.0-flash',
267
+ 'gemini-2.0-flash-lite',
268
+ 'gemini-1.5-flash',
269
+ 'gemini-1.5-pro',
270
+ 'gemini-1.5-flash-latest',
271
+ 'gemini-1.5-pro-latest',
272
+ ];
273
+
274
+ for (const model of geminiModels) {
275
+ try {
276
+ this.logger.warn(
277
+ `[FINANCE-EXTRACT] gemini fallback trying model=${model}`,
278
+ );
279
+
280
+ const geminiResult = await this.ai.chat(
281
+ {
282
+ provider: 'gemini',
283
+ model,
284
+ message: geminiPrompt,
285
+ file_id: fileId,
286
+ },
287
+ file,
288
+ );
289
+
290
+ const geminiParsed = this.parseAiJson(geminiResult?.content || '{}');
291
+ this.logger.warn(
292
+ `[FINANCE-EXTRACT] gemini fallback result model=${model} hasDocumento=${!!this.toNullableString(geminiParsed?.documento)} hasValor=${this.toNullableNumber(geminiParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(geminiParsed?.vencimento)}`,
293
+ );
294
+
295
+ return {
296
+ ...geminiParsed,
297
+ ...regexFallback,
298
+ documento: this.toNullableString(geminiParsed?.documento)
299
+ ? geminiParsed.documento
300
+ : regexFallback.documento,
301
+ valor:
302
+ this.toNullableNumber(geminiParsed?.valor) !== null
303
+ ? geminiParsed.valor
304
+ : regexFallback.valor,
305
+ vencimento: this.normalizeDate(geminiParsed?.vencimento)
306
+ ? geminiParsed.vencimento
307
+ : regexFallback.vencimento,
308
+ competencia: this.normalizeMonth(geminiParsed?.competencia)
309
+ ? geminiParsed.competencia
310
+ : regexFallback.competencia,
311
+ fornecedor_nome: isPayable
312
+ ? this.toNullableString(geminiParsed?.fornecedor_nome)
313
+ ? geminiParsed.fornecedor_nome
314
+ : regexFallback.fornecedor_nome
315
+ : null,
316
+ cliente_nome: !isPayable
317
+ ? this.toNullableString(geminiParsed?.cliente_nome)
318
+ ? geminiParsed.cliente_nome
319
+ : regexFallback.cliente_nome
320
+ : null,
321
+ confidence:
322
+ this.toNullableNumber(geminiParsed?.confidence) ?? regexFallback.confidence,
323
+ __fallbackSource: 'gemini-pdf-binary',
324
+ __fallbackModel: model,
325
+ __fallbackHasText: !!rawText,
326
+ };
327
+ } catch (error) {
328
+ const statusCode = (error as any)?.response?.status;
329
+ const message = (error as any)?.message || 'unknown error';
330
+ const apiError =
331
+ (error as any)?.response?.data?.error?.message ||
332
+ JSON.stringify((error as any)?.response?.data || {});
333
+ this.logger.warn(
334
+ `[FINANCE-EXTRACT] gemini fallback failed model=${model} status=${statusCode ?? 'n/a'} message=${message} apiError=${apiError}`,
335
+ );
336
+ }
337
+ }
338
+ }
339
+
340
+ return {
341
+ ...regexFallback,
342
+ ...mergedAfterRetry,
343
+ documento: this.toNullableString(mergedAfterRetry.documento)
344
+ ? mergedAfterRetry.documento
345
+ : regexFallback.documento,
346
+ valor:
347
+ this.toNullableNumber(mergedAfterRetry.valor) !== null
348
+ ? mergedAfterRetry.valor
349
+ : regexFallback.valor,
350
+ vencimento: this.normalizeDate(mergedAfterRetry.vencimento)
351
+ ? mergedAfterRetry.vencimento
352
+ : regexFallback.vencimento,
353
+ competencia: this.normalizeMonth(mergedAfterRetry.competencia)
354
+ ? mergedAfterRetry.competencia
355
+ : regexFallback.competencia,
356
+ fornecedor_nome: isPayable
357
+ ? this.toNullableString(mergedAfterRetry.fornecedor_nome)
358
+ ? mergedAfterRetry.fornecedor_nome
359
+ : regexFallback.fornecedor_nome
360
+ : mergedAfterRetry.fornecedor_nome,
361
+ cliente_nome: !isPayable
362
+ ? this.toNullableString(mergedAfterRetry.cliente_nome)
363
+ ? mergedAfterRetry.cliente_nome
364
+ : regexFallback.cliente_nome
365
+ : mergedAfterRetry.cliente_nome,
366
+ __fallbackSource: 'regex-pdf-text',
367
+ __fallbackHasText: !!rawText,
368
+ };
369
+ }
370
+
371
+ private extractFinancialFieldsFromRawText(text: string, isPayable: boolean) {
372
+ const source = text || '';
373
+
374
+ const documento =
375
+ this.firstMatch(
376
+ source,
377
+ [
378
+ /\b(?:n[úu]mero\s+da\s+nota|nota\s+fiscal|nfs-?e|nf-?e|nf)\s*[:#-]?\s*([a-z0-9._\/-]{4,})/i,
379
+ ],
380
+ ) || null;
381
+
382
+ const emissao = this.firstMatch(source, [
383
+ /\b(?:data\s+de\s+emiss[aã]o|emiss[aã]o)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
384
+ ]);
385
+
386
+ const vencimento = this.firstMatch(source, [
387
+ /\b(?:data\s+de\s+vencimento|vencimento)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
388
+ ]);
389
+
390
+ const valorLabeled = this.firstMatch(source, [
391
+ /\bvalor\s+dos\s+servi[cç]os\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
392
+ /\bvalor\s+total\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
393
+ /\btotal\s+da\s+nota\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
394
+ /\bvalor\s+l[ií]quido\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
395
+ ]);
396
+
397
+ const fallbackAnyMoney = this.firstMatch(source, [
398
+ /(?:r\$\s*)([\d]{1,3}(?:\.[\d]{3})*,\d{2})/i,
399
+ ]);
400
+
401
+ const valor = this.toNullableNumber(valorLabeled || fallbackAnyMoney);
402
+
403
+ const personName = this.firstMatch(source, [
404
+ /\b(?:tomador|cliente|fornecedor|prestador)\b\s*[:#-]?\s*([^\n\r]{3,80})/i,
405
+ ]);
406
+
407
+ return {
408
+ documento,
409
+ fornecedor_nome: isPayable ? this.toNullableString(personName) : null,
410
+ cliente_nome: !isPayable ? this.toNullableString(personName) : null,
411
+ competencia: emissao ? this.normalizeMonth(emissao) : null,
412
+ vencimento: vencimento ? this.normalizeDate(vencimento) : null,
413
+ valor,
414
+ confidence: valor !== null ? 68 : 52,
89
415
  };
90
416
  }
91
417
 
418
+ private firstMatch(text: string, patterns: RegExp[]): string {
419
+ for (const pattern of patterns) {
420
+ const match = text.match(pattern);
421
+ if (match?.[1]) {
422
+ return match[1].trim();
423
+ }
424
+ }
425
+
426
+ return '';
427
+ }
428
+
429
+ private resolveExtractionConfidence(
430
+ aiConfidence: number | null,
431
+ data: {
432
+ documento: string | null;
433
+ personId: string;
434
+ categoriaId: string;
435
+ centroCustoId: string;
436
+ vencimento: string;
437
+ valor: number | null;
438
+ },
439
+ ): number | null {
440
+ if (aiConfidence !== null) {
441
+ const normalized = aiConfidence <= 1 ? aiConfidence * 100 : aiConfidence;
442
+ return Math.max(0, Math.min(100, Math.round(normalized)));
443
+ }
444
+
445
+ let score = 35;
446
+ if (data.documento) score += 15;
447
+ if (data.vencimento) score += 10;
448
+ if (data.valor !== null && data.valor > 0) score += 20;
449
+ if (data.personId) score += 10;
450
+ if (data.categoriaId) score += 5;
451
+ if (data.centroCustoId) score += 5;
452
+
453
+ return Math.max(0, Math.min(100, score));
454
+ }
455
+
456
+ private buildExtractionWarnings(
457
+ confidence: number | null,
458
+ data: {
459
+ valor: number | null;
460
+ vencimento: string;
461
+ },
462
+ ): string[] {
463
+ const warnings: string[] = [];
464
+
465
+ if (confidence !== null && confidence < 70) {
466
+ warnings.push('Baixa confiança na extração. Revise os campos antes de salvar.');
467
+ }
468
+
469
+ if (data.valor === null || data.valor <= 0) {
470
+ warnings.push('Valor não identificado com segurança no documento.');
471
+ }
472
+
473
+ if (!data.vencimento) {
474
+ warnings.push('Data de vencimento não identificada com segurança.');
475
+ }
476
+
477
+ return warnings;
478
+ }
479
+
92
480
  private parseAiJson(content: string): any {
93
481
  const trimmed = (content || '').trim();
94
482
  if (!trimmed) {
@@ -263,7 +651,41 @@ export class FinanceService {
263
651
 
264
652
  private toNullableNumber(value: any): number | null {
265
653
  if (value === null || value === undefined || value === '') return null;
266
- const num = Number(value);
654
+ if (typeof value === 'number') {
655
+ return Number.isFinite(value) ? value : null;
656
+ }
657
+
658
+ const raw = String(value).trim();
659
+ if (!raw) return null;
660
+
661
+ const cleaned = raw.replace(/[^\d,.-]/g, '');
662
+ if (!cleaned) return null;
663
+
664
+ const lastComma = cleaned.lastIndexOf(',');
665
+ const lastDot = cleaned.lastIndexOf('.');
666
+
667
+ let normalized = cleaned;
668
+
669
+ if (lastComma >= 0 && lastDot >= 0) {
670
+ if (lastComma > lastDot) {
671
+ normalized = cleaned.replace(/\./g, '').replace(',', '.');
672
+ } else {
673
+ normalized = cleaned.replace(/,/g, '');
674
+ }
675
+ } else if (lastComma >= 0) {
676
+ normalized = cleaned.replace(/\./g, '').replace(',', '.');
677
+ } else {
678
+ const dotParts = cleaned.split('.');
679
+ if (
680
+ dotParts.length > 1 &&
681
+ dotParts[dotParts.length - 1].length === 3 &&
682
+ dotParts.length <= 4
683
+ ) {
684
+ normalized = cleaned.replace(/\./g, '');
685
+ }
686
+ }
687
+
688
+ const num = Number(normalized);
267
689
  return Number.isFinite(num) ? num : null;
268
690
  }
269
691