@hed-hog/finance 0.0.232 → 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.
@@ -1,6 +1,7 @@
1
1
  import { LocaleModule } from '@hed-hog/api-locale';
2
2
  import { PaginationModule } from '@hed-hog/api-pagination';
3
3
  import { PrismaModule } from '@hed-hog/api-prisma';
4
+ import { AiModule } from '@hed-hog/core';
4
5
  import { forwardRef, Module } from '@nestjs/common';
5
6
  import { ConfigModule } from '@nestjs/config';
6
7
  import { FinanceBankAccountsController } from './finance-bank-accounts.controller';
@@ -14,7 +15,8 @@ import { FinanceService } from './finance.service';
14
15
  ConfigModule.forRoot(),
15
16
  forwardRef(() => PaginationModule),
16
17
  forwardRef(() => PrismaModule),
17
- forwardRef(() => LocaleModule)
18
+ forwardRef(() => LocaleModule),
19
+ forwardRef(() => AiModule),
18
20
  ],
19
21
  controllers: [
20
22
  FinanceDataController,
@@ -1,10 +1,12 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
+ import { AiService } from '@hed-hog/core';
4
5
  import {
5
6
  BadRequestException,
6
7
  Injectable,
7
- NotFoundException,
8
+ Logger,
9
+ NotFoundException
8
10
  } from '@nestjs/common';
9
11
  import { CreateBankAccountDto } from './dto/create-bank-account.dto';
10
12
  import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
@@ -14,11 +16,679 @@ type TitleType = 'payable' | 'receivable';
14
16
 
15
17
  @Injectable()
16
18
  export class FinanceService {
19
+ private readonly logger = new Logger(FinanceService.name);
20
+
17
21
  constructor(
18
22
  private readonly prisma: PrismaService,
19
23
  private readonly paginationService: PaginationService,
24
+ private readonly ai: AiService,
20
25
  ) {}
21
26
 
27
+ async getAgentExtractInfoFromFile(
28
+ file?: MulterFile,
29
+ fileId?: number,
30
+ titleType: TitleType = 'payable',
31
+ ) {
32
+ if (!file && !fileId) {
33
+ throw new BadRequestException('File upload or file_id is required');
34
+ }
35
+
36
+ const isPayable = titleType === 'payable';
37
+ const slug = isPayable
38
+ ? 'finance-payable-extractor'
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
+ );
44
+
45
+ const schema = isPayable
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}'
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}';
48
+
49
+ const extractionMessage = isPayable
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.';
52
+
53
+ await this.ai.createAgent({
54
+ slug,
55
+ provider: 'openai',
56
+ model: 'gpt-4o',
57
+ instructions:
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}.`,
59
+ });
60
+
61
+ const result = await this.ai.chatWithAgent(
62
+ slug,
63
+ {
64
+ message: extractionMessage,
65
+ file_id: fileId,
66
+ },
67
+ file,
68
+ );
69
+
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
+ );
82
+
83
+ const personName = isPayable
84
+ ? mergedParsed.fornecedor_nome
85
+ : mergedParsed.cliente_nome;
86
+ const personId = await this.matchPersonId(personName);
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
+ });
117
+
118
+ this.logger.warn(
119
+ `[FINANCE-EXTRACT] done type=${titleType} confidence=${confidence ?? 'null'} valor=${valor ?? 'null'} vencimento=${vencimento || 'null'} fallback=${mergedParsed?.__fallbackSource || 'none'}`,
120
+ );
121
+
122
+ return {
123
+ documento,
124
+ fornecedorId: isPayable ? personId : '',
125
+ fornecedorNome: isPayable ? this.toNullableString(personName) : null,
126
+ clienteId: !isPayable ? personId : '',
127
+ clienteNome: !isPayable ? this.toNullableString(personName) : null,
128
+ categoriaId,
129
+ categoriaNome,
130
+ centroCustoId,
131
+ centroCustoNome,
132
+ competencia,
133
+ vencimento,
134
+ valor,
135
+ metodo,
136
+ canal: metodo,
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,
415
+ };
416
+ }
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
+
480
+ private parseAiJson(content: string): any {
481
+ const trimmed = (content || '').trim();
482
+ if (!trimmed) {
483
+ return {};
484
+ }
485
+
486
+ try {
487
+ return JSON.parse(trimmed);
488
+ } catch {
489
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
490
+ if (fenced?.[1]) {
491
+ try {
492
+ return JSON.parse(fenced[1].trim());
493
+ } catch {
494
+ return {};
495
+ }
496
+ }
497
+
498
+ const firstBrace = trimmed.indexOf('{');
499
+ const lastBrace = trimmed.lastIndexOf('}');
500
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
501
+ try {
502
+ return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
503
+ } catch {
504
+ return {};
505
+ }
506
+ }
507
+
508
+ return {};
509
+ }
510
+ }
511
+
512
+ private async matchPersonId(name?: string | null): Promise<string> {
513
+ if (!name?.trim()) return '';
514
+
515
+ const person = await this.prisma.person.findFirst({
516
+ where: {
517
+ name: {
518
+ contains: name.trim(),
519
+ mode: 'insensitive',
520
+ },
521
+ },
522
+ select: {
523
+ id: true,
524
+ },
525
+ orderBy: {
526
+ id: 'asc',
527
+ },
528
+ });
529
+
530
+ return person?.id ? String(person.id) : '';
531
+ }
532
+
533
+ private async matchCategoryId(name?: string | null): Promise<string> {
534
+ if (!name?.trim()) return '';
535
+
536
+ const category = await this.prisma.finance_category.findFirst({
537
+ where: {
538
+ name: {
539
+ contains: name.trim(),
540
+ mode: 'insensitive',
541
+ },
542
+ },
543
+ select: {
544
+ id: true,
545
+ },
546
+ orderBy: {
547
+ id: 'asc',
548
+ },
549
+ });
550
+
551
+ return category?.id ? String(category.id) : '';
552
+ }
553
+
554
+ private async matchCostCenterId(name?: string | null): Promise<string> {
555
+ if (!name?.trim()) return '';
556
+
557
+ const costCenter = await this.prisma.cost_center.findFirst({
558
+ where: {
559
+ name: {
560
+ contains: name.trim(),
561
+ mode: 'insensitive',
562
+ },
563
+ },
564
+ select: {
565
+ id: true,
566
+ },
567
+ orderBy: {
568
+ id: 'asc',
569
+ },
570
+ });
571
+
572
+ return costCenter?.id ? String(costCenter.id) : '';
573
+ }
574
+
575
+ private normalizeDate(value: any): string {
576
+ if (!value) return '';
577
+ const raw = String(value).trim();
578
+
579
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
580
+ return raw;
581
+ }
582
+
583
+ const br = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
584
+ if (br) {
585
+ return `${br[3]}-${br[2]}-${br[1]}`;
586
+ }
587
+
588
+ const dt = new Date(raw);
589
+ if (Number.isNaN(dt.getTime())) {
590
+ return '';
591
+ }
592
+
593
+ return dt.toISOString().slice(0, 10);
594
+ }
595
+
596
+ private normalizeMonth(value: any): string {
597
+ if (!value) return '';
598
+ const raw = String(value).trim();
599
+
600
+ if (/^\d{4}-\d{2}$/.test(raw)) {
601
+ return raw;
602
+ }
603
+
604
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
605
+ return raw.slice(0, 7);
606
+ }
607
+
608
+ const br = raw.match(/^(\d{2})\/(\d{4})$/);
609
+ if (br) {
610
+ return `${br[2]}-${br[1]}`;
611
+ }
612
+
613
+ const dt = new Date(raw);
614
+ if (Number.isNaN(dt.getTime())) {
615
+ return '';
616
+ }
617
+
618
+ return dt.toISOString().slice(0, 7);
619
+ }
620
+
621
+ private normalizePaymentMethod(value: any): string {
622
+ if (!value) return '';
623
+
624
+ const normalized = String(value).trim().toLowerCase();
625
+
626
+ const map: Record<string, string> = {
627
+ boleto: 'boleto',
628
+ pix: 'pix',
629
+ transferencia: 'transferencia',
630
+ transfer: 'transferencia',
631
+ ted: 'transferencia',
632
+ doc: 'transferencia',
633
+ cartao: 'cartao',
634
+ card: 'cartao',
635
+ credito: 'cartao',
636
+ debito: 'cartao',
637
+ dinheiro: 'dinheiro',
638
+ cash: 'dinheiro',
639
+ cheque: 'cheque',
640
+ check: 'cheque',
641
+ };
642
+
643
+ return map[normalized] || '';
644
+ }
645
+
646
+ private toNullableString(value: any): string | null {
647
+ if (value === null || value === undefined) return null;
648
+ const text = String(value).trim();
649
+ return text ? text : null;
650
+ }
651
+
652
+ private toNullableNumber(value: any): number | null {
653
+ if (value === null || value === undefined || value === '') return null;
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);
689
+ return Number.isFinite(num) ? num : null;
690
+ }
691
+
22
692
  async getData() {
23
693
  const [
24
694
  payables,