@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.
- package/dist/finance-installments.controller.d.ts +4 -0
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance.service.d.ts +8 -0
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +287 -19
- package/dist/finance.service.js.map +1 -1
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +76 -6
- package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +76 -6
- package/package.json +5 -5
- package/src/finance.service.ts +442 -20
package/src/finance.service.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
44
|
-
: 'Extract the receivable form fields from the attached file.
|
|
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
|
|
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(
|
|
67
|
-
const centroCustoId = await this.matchCostCenterId(
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
+
categoriaNome,
|
|
79
130
|
centroCustoId,
|
|
80
|
-
centroCustoNome
|
|
81
|
-
competencia
|
|
82
|
-
vencimento
|
|
83
|
-
valor
|
|
131
|
+
centroCustoNome,
|
|
132
|
+
competencia,
|
|
133
|
+
vencimento,
|
|
134
|
+
valor,
|
|
84
135
|
metodo,
|
|
85
136
|
canal: metodo,
|
|
86
|
-
descricao
|
|
87
|
-
confidence
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|