@hed-hog/finance 0.0.233 → 0.0.236
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/dto/create-cost-center.dto.d.ts +4 -0
- package/dist/dto/create-cost-center.dto.d.ts.map +1 -0
- package/dist/dto/create-cost-center.dto.js +24 -0
- package/dist/dto/create-cost-center.dto.js.map +1 -0
- package/dist/dto/create-finance-category.dto.d.ts +6 -0
- package/dist/dto/create-finance-category.dto.d.ts.map +1 -0
- package/dist/dto/create-finance-category.dto.js +37 -0
- package/dist/dto/create-finance-category.dto.js.map +1 -0
- package/dist/dto/create-period-close.dto.d.ts +7 -0
- package/dist/dto/create-period-close.dto.d.ts.map +1 -0
- package/dist/dto/create-period-close.dto.js +44 -0
- package/dist/dto/create-period-close.dto.js.map +1 -0
- package/dist/dto/move-finance-category.dto.d.ts +5 -0
- package/dist/dto/move-finance-category.dto.d.ts.map +1 -0
- package/dist/dto/move-finance-category.dto.js +32 -0
- package/dist/dto/move-finance-category.dto.js.map +1 -0
- package/dist/dto/update-cost-center.dto.d.ts +5 -0
- package/dist/dto/update-cost-center.dto.d.ts.map +1 -0
- package/dist/dto/update-cost-center.dto.js +32 -0
- package/dist/dto/update-cost-center.dto.js.map +1 -0
- package/dist/dto/update-finance-category.dto.d.ts +7 -0
- package/dist/dto/update-finance-category.dto.d.ts.map +1 -0
- package/dist/dto/update-finance-category.dto.js +46 -0
- package/dist/dto/update-finance-category.dto.js.map +1 -0
- package/dist/finance-audit-logs.controller.d.ts +13 -0
- package/dist/finance-audit-logs.controller.d.ts.map +1 -0
- package/dist/finance-audit-logs.controller.js +54 -0
- package/dist/finance-audit-logs.controller.js.map +1 -0
- package/dist/finance-categories.controller.d.ts +42 -0
- package/dist/finance-categories.controller.d.ts.map +1 -0
- package/dist/finance-categories.controller.js +84 -0
- package/dist/finance-categories.controller.js.map +1 -0
- package/dist/finance-cost-centers.controller.d.ts +32 -0
- package/dist/finance-cost-centers.controller.d.ts.map +1 -0
- package/dist/finance-cost-centers.controller.js +72 -0
- package/dist/finance-cost-centers.controller.js.map +1 -0
- package/dist/finance-installments.controller.d.ts +4 -0
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-period-close.controller.d.ts +27 -0
- package/dist/finance-period-close.controller.d.ts.map +1 -0
- package/dist/finance-period-close.controller.js +64 -0
- package/dist/finance-period-close.controller.js.map +1 -0
- package/dist/finance.module.d.ts.map +1 -1
- package/dist/finance.module.js +8 -0
- package/dist/finance.module.js.map +1 -1
- package/dist/finance.service.d.ts +119 -0
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +733 -36
- package/dist/finance.service.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/hedhog/data/route.yaml +108 -0
- 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/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +309 -0
- package/hedhog/frontend/app/administration/categories/page.tsx.ejs +642 -0
- package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +371 -0
- package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +502 -0
- package/hedhog/frontend/messages/en.json +225 -0
- package/hedhog/frontend/messages/pt.json +225 -0
- package/package.json +5 -5
- package/src/dto/create-cost-center.dto.ts +9 -0
- package/src/dto/create-finance-category.dto.ts +21 -0
- package/src/dto/create-period-close.dto.ts +34 -0
- package/src/dto/move-finance-category.dto.ts +18 -0
- package/src/dto/update-cost-center.dto.ts +17 -0
- package/src/dto/update-finance-category.dto.ts +30 -0
- package/src/finance-audit-logs.controller.ts +30 -0
- package/src/finance-categories.controller.ts +52 -0
- package/src/finance-cost-centers.controller.ts +43 -0
- package/src/finance-period-close.controller.ts +34 -0
- package/src/finance.module.ts +8 -0
- package/src/finance.service.ts +1020 -29
- package/src/index.ts +4 -0
package/src/finance.service.ts
CHANGED
|
@@ -3,18 +3,27 @@ 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';
|
|
12
|
+
import { CreateCostCenterDto } from './dto/create-cost-center.dto';
|
|
13
|
+
import { CreateFinanceCategoryDto } from './dto/create-finance-category.dto';
|
|
11
14
|
import { CreateFinancialTitleDto } from './dto/create-financial-title.dto';
|
|
15
|
+
import { CreatePeriodCloseDto } from './dto/create-period-close.dto';
|
|
16
|
+
import { MoveFinanceCategoryDto } from './dto/move-finance-category.dto';
|
|
12
17
|
import { UpdateBankAccountDto } from './dto/update-bank-account.dto';
|
|
18
|
+
import { UpdateCostCenterDto } from './dto/update-cost-center.dto';
|
|
19
|
+
import { UpdateFinanceCategoryDto } from './dto/update-finance-category.dto';
|
|
13
20
|
|
|
14
21
|
type TitleType = 'payable' | 'receivable';
|
|
15
22
|
|
|
16
23
|
@Injectable()
|
|
17
24
|
export class FinanceService {
|
|
25
|
+
private readonly logger = new Logger(FinanceService.name);
|
|
26
|
+
|
|
18
27
|
constructor(
|
|
19
28
|
private readonly prisma: PrismaService,
|
|
20
29
|
private readonly paginationService: PaginationService,
|
|
@@ -34,20 +43,25 @@ export class FinanceService {
|
|
|
34
43
|
const slug = isPayable
|
|
35
44
|
? 'finance-payable-extractor'
|
|
36
45
|
: 'finance-receivable-extractor';
|
|
46
|
+
const llmAttachmentDebug = await this.ai.debugAttachmentForLlm(file, fileId);
|
|
47
|
+
this.logger.warn(
|
|
48
|
+
`[FINANCE-EXTRACT] start type=${titleType} fileId=${fileId || 'upload'} mode=${llmAttachmentDebug?.mode || 'none'} mime=${llmAttachmentDebug?.mimeType || 'n/a'} textLength=${llmAttachmentDebug?.textLength || 0}`,
|
|
49
|
+
);
|
|
37
50
|
|
|
38
51
|
const schema = isPayable
|
|
39
52
|
? '{"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
53
|
: '{"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
54
|
|
|
42
55
|
const extractionMessage = isPayable
|
|
43
|
-
? 'Extract the payable form fields from the attached file.
|
|
44
|
-
: 'Extract the receivable form fields from the attached file.
|
|
56
|
+
? '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.'
|
|
57
|
+
: '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
58
|
|
|
46
59
|
await this.ai.createAgent({
|
|
47
60
|
slug,
|
|
48
61
|
provider: 'openai',
|
|
62
|
+
model: 'gpt-4o',
|
|
49
63
|
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}.`,
|
|
64
|
+
`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
65
|
});
|
|
52
66
|
|
|
53
67
|
const result = await this.ai.chatWithAgent(
|
|
@@ -60,35 +74,415 @@ export class FinanceService {
|
|
|
60
74
|
);
|
|
61
75
|
|
|
62
76
|
const parsed = this.parseAiJson(result?.content || '{}');
|
|
77
|
+
this.logger.warn(
|
|
78
|
+
`[FINANCE-EXTRACT] pass1 type=${titleType} hasDocumento=${!!this.toNullableString(parsed?.documento)} hasValor=${this.toNullableNumber(parsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(parsed?.vencimento)}`,
|
|
79
|
+
);
|
|
80
|
+
const mergedParsed = await this.enhanceParsedExtraction(
|
|
81
|
+
parsed,
|
|
82
|
+
slug,
|
|
83
|
+
isPayable,
|
|
84
|
+
llmAttachmentDebug,
|
|
85
|
+
fileId,
|
|
86
|
+
file,
|
|
87
|
+
);
|
|
63
88
|
|
|
64
|
-
const personName = isPayable
|
|
89
|
+
const personName = isPayable
|
|
90
|
+
? mergedParsed.fornecedor_nome
|
|
91
|
+
: mergedParsed.cliente_nome;
|
|
65
92
|
const personId = await this.matchPersonId(personName);
|
|
66
|
-
const categoriaId = await this.matchCategoryId(
|
|
67
|
-
const centroCustoId = await this.matchCostCenterId(
|
|
93
|
+
const categoriaId = await this.matchCategoryId(mergedParsed.categoria_nome);
|
|
94
|
+
const centroCustoId = await this.matchCostCenterId(
|
|
95
|
+
mergedParsed.centro_custo_nome,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const metodo = this.normalizePaymentMethod(
|
|
99
|
+
mergedParsed.metodo || mergedParsed.canal,
|
|
100
|
+
);
|
|
101
|
+
const documento = this.toNullableString(mergedParsed.documento);
|
|
102
|
+
const categoriaNome = this.toNullableString(mergedParsed.categoria_nome);
|
|
103
|
+
const centroCustoNome = this.toNullableString(mergedParsed.centro_custo_nome);
|
|
104
|
+
const competencia = this.normalizeMonth(mergedParsed.competencia);
|
|
105
|
+
const vencimento = this.normalizeDate(mergedParsed.vencimento);
|
|
106
|
+
const valor = this.toNullableNumber(mergedParsed.valor);
|
|
107
|
+
const descricao = this.toNullableString(mergedParsed.descricao);
|
|
108
|
+
const confidence = this.resolveExtractionConfidence(
|
|
109
|
+
this.toNullableNumber(mergedParsed.confidence),
|
|
110
|
+
{
|
|
111
|
+
documento,
|
|
112
|
+
personId,
|
|
113
|
+
categoriaId,
|
|
114
|
+
centroCustoId,
|
|
115
|
+
vencimento,
|
|
116
|
+
valor,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
const warnings = this.buildExtractionWarnings(confidence, {
|
|
120
|
+
valor,
|
|
121
|
+
vencimento,
|
|
122
|
+
});
|
|
68
123
|
|
|
69
|
-
|
|
124
|
+
this.logger.warn(
|
|
125
|
+
`[FINANCE-EXTRACT] done type=${titleType} confidence=${confidence ?? 'null'} valor=${valor ?? 'null'} vencimento=${vencimento || 'null'} fallback=${mergedParsed?.__fallbackSource || 'none'}`,
|
|
126
|
+
);
|
|
70
127
|
|
|
71
128
|
return {
|
|
72
|
-
documento
|
|
129
|
+
documento,
|
|
73
130
|
fornecedorId: isPayable ? personId : '',
|
|
74
131
|
fornecedorNome: isPayable ? this.toNullableString(personName) : null,
|
|
75
132
|
clienteId: !isPayable ? personId : '',
|
|
76
133
|
clienteNome: !isPayable ? this.toNullableString(personName) : null,
|
|
77
134
|
categoriaId,
|
|
78
|
-
categoriaNome
|
|
135
|
+
categoriaNome,
|
|
79
136
|
centroCustoId,
|
|
80
|
-
centroCustoNome
|
|
81
|
-
competencia
|
|
82
|
-
vencimento
|
|
83
|
-
valor
|
|
137
|
+
centroCustoNome,
|
|
138
|
+
competencia,
|
|
139
|
+
vencimento,
|
|
140
|
+
valor,
|
|
84
141
|
metodo,
|
|
85
142
|
canal: metodo,
|
|
86
|
-
descricao
|
|
87
|
-
confidence
|
|
88
|
-
|
|
143
|
+
descricao,
|
|
144
|
+
confidence,
|
|
145
|
+
confidenceLevel:
|
|
146
|
+
confidence === null ? null : confidence < 70 ? 'low' : 'high',
|
|
147
|
+
warnings,
|
|
148
|
+
raw: {
|
|
149
|
+
...mergedParsed,
|
|
150
|
+
__llmAttachmentDebug: llmAttachmentDebug,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async enhanceParsedExtraction(
|
|
156
|
+
parsed: any,
|
|
157
|
+
slug: string,
|
|
158
|
+
isPayable: boolean,
|
|
159
|
+
llmAttachmentDebug?: any,
|
|
160
|
+
fileId?: number,
|
|
161
|
+
file?: MulterFile,
|
|
162
|
+
) {
|
|
163
|
+
const base = parsed || {};
|
|
164
|
+
|
|
165
|
+
const hasDocumento = !!this.toNullableString(base.documento);
|
|
166
|
+
const hasValor = this.toNullableNumber(base.valor) !== null;
|
|
167
|
+
const hasVencimento = !!this.normalizeDate(base.vencimento);
|
|
168
|
+
const hasPessoa = !!this.toNullableString(
|
|
169
|
+
isPayable ? base.fornecedor_nome : base.cliente_nome,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (hasDocumento || hasValor || hasVencimento || hasPessoa) {
|
|
173
|
+
this.logger.warn('[FINANCE-EXTRACT] pass2 skipped: pass1 already has key fields');
|
|
174
|
+
return base;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const fallbackSchema = isPayable
|
|
178
|
+
? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
|
|
179
|
+
: '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
|
|
180
|
+
|
|
181
|
+
const fallbackMessage = isPayable
|
|
182
|
+
? `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}`
|
|
183
|
+
: `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}`;
|
|
184
|
+
|
|
185
|
+
const retry = await this.ai.chatWithAgent(
|
|
186
|
+
slug,
|
|
187
|
+
{
|
|
188
|
+
message: fallbackMessage,
|
|
189
|
+
file_id: fileId,
|
|
190
|
+
},
|
|
191
|
+
file,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const retryParsed = this.parseAiJson(retry?.content || '{}');
|
|
195
|
+
this.logger.warn(
|
|
196
|
+
`[FINANCE-EXTRACT] pass2 hasDocumento=${!!this.toNullableString(retryParsed?.documento)} hasValor=${this.toNullableNumber(retryParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(retryParsed?.vencimento)}`,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const mergedAfterRetry = {
|
|
200
|
+
...retryParsed,
|
|
201
|
+
...base,
|
|
202
|
+
documento: this.toNullableString(base.documento)
|
|
203
|
+
? base.documento
|
|
204
|
+
: retryParsed.documento,
|
|
205
|
+
valor:
|
|
206
|
+
this.toNullableNumber(base.valor) !== null ? base.valor : retryParsed.valor,
|
|
207
|
+
vencimento: this.normalizeDate(base.vencimento)
|
|
208
|
+
? base.vencimento
|
|
209
|
+
: retryParsed.vencimento,
|
|
210
|
+
fornecedor_nome: isPayable
|
|
211
|
+
? this.toNullableString(base.fornecedor_nome)
|
|
212
|
+
? base.fornecedor_nome
|
|
213
|
+
: retryParsed.fornecedor_nome
|
|
214
|
+
: base.fornecedor_nome,
|
|
215
|
+
cliente_nome: !isPayable
|
|
216
|
+
? this.toNullableString(base.cliente_nome)
|
|
217
|
+
? base.cliente_nome
|
|
218
|
+
: retryParsed.cliente_nome
|
|
219
|
+
: base.cliente_nome,
|
|
220
|
+
confidence:
|
|
221
|
+
this.toNullableNumber(base.confidence) !== null
|
|
222
|
+
? base.confidence
|
|
223
|
+
: retryParsed.confidence,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const stillEmpty =
|
|
227
|
+
!this.toNullableString(mergedAfterRetry.documento) &&
|
|
228
|
+
this.toNullableNumber(mergedAfterRetry.valor) === null &&
|
|
229
|
+
!this.normalizeDate(mergedAfterRetry.vencimento) &&
|
|
230
|
+
!this.toNullableString(
|
|
231
|
+
isPayable
|
|
232
|
+
? mergedAfterRetry.fornecedor_nome
|
|
233
|
+
: mergedAfterRetry.cliente_nome,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (!stillEmpty) {
|
|
237
|
+
this.logger.warn('[FINANCE-EXTRACT] regex fallback skipped: pass2 produced data');
|
|
238
|
+
return mergedAfterRetry;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rawText = await this.ai.extractAttachmentText(file, fileId);
|
|
242
|
+
const regexFallback = this.extractFinancialFieldsFromRawText(rawText, isPayable);
|
|
243
|
+
this.logger.warn(
|
|
244
|
+
`[FINANCE-EXTRACT] regex fallback activated hasText=${!!rawText} hasDocumento=${!!this.toNullableString(regexFallback?.documento)} hasValor=${this.toNullableNumber(regexFallback?.valor) !== null} hasVencimento=${!!this.normalizeDate(regexFallback?.vencimento)}`,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const stillEmptyAfterRegex =
|
|
248
|
+
!this.toNullableString(regexFallback.documento) &&
|
|
249
|
+
this.toNullableNumber(regexFallback.valor) === null &&
|
|
250
|
+
!this.normalizeDate(regexFallback.vencimento);
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
stillEmptyAfterRegex &&
|
|
254
|
+
llmAttachmentDebug?.mode === 'pdf-text' &&
|
|
255
|
+
Number(llmAttachmentDebug?.textLength || 0) === 0
|
|
256
|
+
) {
|
|
257
|
+
this.logger.warn(
|
|
258
|
+
'[FINANCE-EXTRACT] gemini fallback activated for scanned PDF (no text layer)',
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const geminiSchema = isPayable
|
|
262
|
+
? '{"documento":"string|null","fornecedor_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}'
|
|
263
|
+
: '{"documento":"string|null","cliente_nome":"string|null","vencimento":"YYYY-MM-DD|null","valor":number|null,"confidence":number|null}';
|
|
264
|
+
|
|
265
|
+
const geminiPrompt = isPayable
|
|
266
|
+
? `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}`
|
|
267
|
+
: `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}`;
|
|
268
|
+
|
|
269
|
+
const geminiModels = [
|
|
270
|
+
'gemini-2.5-flash',
|
|
271
|
+
'gemini-2.5-pro',
|
|
272
|
+
'gemini-2.0-flash',
|
|
273
|
+
'gemini-2.0-flash-lite',
|
|
274
|
+
'gemini-1.5-flash',
|
|
275
|
+
'gemini-1.5-pro',
|
|
276
|
+
'gemini-1.5-flash-latest',
|
|
277
|
+
'gemini-1.5-pro-latest',
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
for (const model of geminiModels) {
|
|
281
|
+
try {
|
|
282
|
+
this.logger.warn(
|
|
283
|
+
`[FINANCE-EXTRACT] gemini fallback trying model=${model}`,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const geminiResult = await this.ai.chat(
|
|
287
|
+
{
|
|
288
|
+
provider: 'gemini',
|
|
289
|
+
model,
|
|
290
|
+
message: geminiPrompt,
|
|
291
|
+
file_id: fileId,
|
|
292
|
+
},
|
|
293
|
+
file,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const geminiParsed = this.parseAiJson(geminiResult?.content || '{}');
|
|
297
|
+
this.logger.warn(
|
|
298
|
+
`[FINANCE-EXTRACT] gemini fallback result model=${model} hasDocumento=${!!this.toNullableString(geminiParsed?.documento)} hasValor=${this.toNullableNumber(geminiParsed?.valor) !== null} hasVencimento=${!!this.normalizeDate(geminiParsed?.vencimento)}`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
...geminiParsed,
|
|
303
|
+
...regexFallback,
|
|
304
|
+
documento: this.toNullableString(geminiParsed?.documento)
|
|
305
|
+
? geminiParsed.documento
|
|
306
|
+
: regexFallback.documento,
|
|
307
|
+
valor:
|
|
308
|
+
this.toNullableNumber(geminiParsed?.valor) !== null
|
|
309
|
+
? geminiParsed.valor
|
|
310
|
+
: regexFallback.valor,
|
|
311
|
+
vencimento: this.normalizeDate(geminiParsed?.vencimento)
|
|
312
|
+
? geminiParsed.vencimento
|
|
313
|
+
: regexFallback.vencimento,
|
|
314
|
+
competencia: this.normalizeMonth(geminiParsed?.competencia)
|
|
315
|
+
? geminiParsed.competencia
|
|
316
|
+
: regexFallback.competencia,
|
|
317
|
+
fornecedor_nome: isPayable
|
|
318
|
+
? this.toNullableString(geminiParsed?.fornecedor_nome)
|
|
319
|
+
? geminiParsed.fornecedor_nome
|
|
320
|
+
: regexFallback.fornecedor_nome
|
|
321
|
+
: null,
|
|
322
|
+
cliente_nome: !isPayable
|
|
323
|
+
? this.toNullableString(geminiParsed?.cliente_nome)
|
|
324
|
+
? geminiParsed.cliente_nome
|
|
325
|
+
: regexFallback.cliente_nome
|
|
326
|
+
: null,
|
|
327
|
+
confidence:
|
|
328
|
+
this.toNullableNumber(geminiParsed?.confidence) ?? regexFallback.confidence,
|
|
329
|
+
__fallbackSource: 'gemini-pdf-binary',
|
|
330
|
+
__fallbackModel: model,
|
|
331
|
+
__fallbackHasText: !!rawText,
|
|
332
|
+
};
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const statusCode = (error as any)?.response?.status;
|
|
335
|
+
const message = (error as any)?.message || 'unknown error';
|
|
336
|
+
const apiError =
|
|
337
|
+
(error as any)?.response?.data?.error?.message ||
|
|
338
|
+
JSON.stringify((error as any)?.response?.data || {});
|
|
339
|
+
this.logger.warn(
|
|
340
|
+
`[FINANCE-EXTRACT] gemini fallback failed model=${model} status=${statusCode ?? 'n/a'} message=${message} apiError=${apiError}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
...regexFallback,
|
|
348
|
+
...mergedAfterRetry,
|
|
349
|
+
documento: this.toNullableString(mergedAfterRetry.documento)
|
|
350
|
+
? mergedAfterRetry.documento
|
|
351
|
+
: regexFallback.documento,
|
|
352
|
+
valor:
|
|
353
|
+
this.toNullableNumber(mergedAfterRetry.valor) !== null
|
|
354
|
+
? mergedAfterRetry.valor
|
|
355
|
+
: regexFallback.valor,
|
|
356
|
+
vencimento: this.normalizeDate(mergedAfterRetry.vencimento)
|
|
357
|
+
? mergedAfterRetry.vencimento
|
|
358
|
+
: regexFallback.vencimento,
|
|
359
|
+
competencia: this.normalizeMonth(mergedAfterRetry.competencia)
|
|
360
|
+
? mergedAfterRetry.competencia
|
|
361
|
+
: regexFallback.competencia,
|
|
362
|
+
fornecedor_nome: isPayable
|
|
363
|
+
? this.toNullableString(mergedAfterRetry.fornecedor_nome)
|
|
364
|
+
? mergedAfterRetry.fornecedor_nome
|
|
365
|
+
: regexFallback.fornecedor_nome
|
|
366
|
+
: mergedAfterRetry.fornecedor_nome,
|
|
367
|
+
cliente_nome: !isPayable
|
|
368
|
+
? this.toNullableString(mergedAfterRetry.cliente_nome)
|
|
369
|
+
? mergedAfterRetry.cliente_nome
|
|
370
|
+
: regexFallback.cliente_nome
|
|
371
|
+
: mergedAfterRetry.cliente_nome,
|
|
372
|
+
__fallbackSource: 'regex-pdf-text',
|
|
373
|
+
__fallbackHasText: !!rawText,
|
|
89
374
|
};
|
|
90
375
|
}
|
|
91
376
|
|
|
377
|
+
private extractFinancialFieldsFromRawText(text: string, isPayable: boolean) {
|
|
378
|
+
const source = text || '';
|
|
379
|
+
|
|
380
|
+
const documento =
|
|
381
|
+
this.firstMatch(
|
|
382
|
+
source,
|
|
383
|
+
[
|
|
384
|
+
/\b(?:n[úu]mero\s+da\s+nota|nota\s+fiscal|nfs-?e|nf-?e|nf)\s*[:#-]?\s*([a-z0-9._\/-]{4,})/i,
|
|
385
|
+
],
|
|
386
|
+
) || null;
|
|
387
|
+
|
|
388
|
+
const emissao = this.firstMatch(source, [
|
|
389
|
+
/\b(?:data\s+de\s+emiss[aã]o|emiss[aã]o)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
const vencimento = this.firstMatch(source, [
|
|
393
|
+
/\b(?:data\s+de\s+vencimento|vencimento)\s*[:#-]?\s*(\d{2}\/\d{2}\/\d{4})/i,
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
const valorLabeled = this.firstMatch(source, [
|
|
397
|
+
/\bvalor\s+dos\s+servi[cç]os\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
|
|
398
|
+
/\bvalor\s+total\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
|
|
399
|
+
/\btotal\s+da\s+nota\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
|
|
400
|
+
/\bvalor\s+l[ií]quido\b[^\d]{0,20}(?:r\$\s*)?([\d.,]+)/i,
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
const fallbackAnyMoney = this.firstMatch(source, [
|
|
404
|
+
/(?:r\$\s*)([\d]{1,3}(?:\.[\d]{3})*,\d{2})/i,
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
const valor = this.toNullableNumber(valorLabeled || fallbackAnyMoney);
|
|
408
|
+
|
|
409
|
+
const personName = this.firstMatch(source, [
|
|
410
|
+
/\b(?:tomador|cliente|fornecedor|prestador)\b\s*[:#-]?\s*([^\n\r]{3,80})/i,
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
documento,
|
|
415
|
+
fornecedor_nome: isPayable ? this.toNullableString(personName) : null,
|
|
416
|
+
cliente_nome: !isPayable ? this.toNullableString(personName) : null,
|
|
417
|
+
competencia: emissao ? this.normalizeMonth(emissao) : null,
|
|
418
|
+
vencimento: vencimento ? this.normalizeDate(vencimento) : null,
|
|
419
|
+
valor,
|
|
420
|
+
confidence: valor !== null ? 68 : 52,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private firstMatch(text: string, patterns: RegExp[]): string {
|
|
425
|
+
for (const pattern of patterns) {
|
|
426
|
+
const match = text.match(pattern);
|
|
427
|
+
if (match?.[1]) {
|
|
428
|
+
return match[1].trim();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return '';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private resolveExtractionConfidence(
|
|
436
|
+
aiConfidence: number | null,
|
|
437
|
+
data: {
|
|
438
|
+
documento: string | null;
|
|
439
|
+
personId: string;
|
|
440
|
+
categoriaId: string;
|
|
441
|
+
centroCustoId: string;
|
|
442
|
+
vencimento: string;
|
|
443
|
+
valor: number | null;
|
|
444
|
+
},
|
|
445
|
+
): number | null {
|
|
446
|
+
if (aiConfidence !== null) {
|
|
447
|
+
const normalized = aiConfidence <= 1 ? aiConfidence * 100 : aiConfidence;
|
|
448
|
+
return Math.max(0, Math.min(100, Math.round(normalized)));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let score = 35;
|
|
452
|
+
if (data.documento) score += 15;
|
|
453
|
+
if (data.vencimento) score += 10;
|
|
454
|
+
if (data.valor !== null && data.valor > 0) score += 20;
|
|
455
|
+
if (data.personId) score += 10;
|
|
456
|
+
if (data.categoriaId) score += 5;
|
|
457
|
+
if (data.centroCustoId) score += 5;
|
|
458
|
+
|
|
459
|
+
return Math.max(0, Math.min(100, score));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private buildExtractionWarnings(
|
|
463
|
+
confidence: number | null,
|
|
464
|
+
data: {
|
|
465
|
+
valor: number | null;
|
|
466
|
+
vencimento: string;
|
|
467
|
+
},
|
|
468
|
+
): string[] {
|
|
469
|
+
const warnings: string[] = [];
|
|
470
|
+
|
|
471
|
+
if (confidence !== null && confidence < 70) {
|
|
472
|
+
warnings.push('Baixa confiança na extração. Revise os campos antes de salvar.');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (data.valor === null || data.valor <= 0) {
|
|
476
|
+
warnings.push('Valor não identificado com segurança no documento.');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!data.vencimento) {
|
|
480
|
+
warnings.push('Data de vencimento não identificada com segurança.');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return warnings;
|
|
484
|
+
}
|
|
485
|
+
|
|
92
486
|
private parseAiJson(content: string): any {
|
|
93
487
|
const trimmed = (content || '').trim();
|
|
94
488
|
if (!trimmed) {
|
|
@@ -263,7 +657,41 @@ export class FinanceService {
|
|
|
263
657
|
|
|
264
658
|
private toNullableNumber(value: any): number | null {
|
|
265
659
|
if (value === null || value === undefined || value === '') return null;
|
|
266
|
-
|
|
660
|
+
if (typeof value === 'number') {
|
|
661
|
+
return Number.isFinite(value) ? value : null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const raw = String(value).trim();
|
|
665
|
+
if (!raw) return null;
|
|
666
|
+
|
|
667
|
+
const cleaned = raw.replace(/[^\d,.-]/g, '');
|
|
668
|
+
if (!cleaned) return null;
|
|
669
|
+
|
|
670
|
+
const lastComma = cleaned.lastIndexOf(',');
|
|
671
|
+
const lastDot = cleaned.lastIndexOf('.');
|
|
672
|
+
|
|
673
|
+
let normalized = cleaned;
|
|
674
|
+
|
|
675
|
+
if (lastComma >= 0 && lastDot >= 0) {
|
|
676
|
+
if (lastComma > lastDot) {
|
|
677
|
+
normalized = cleaned.replace(/\./g, '').replace(',', '.');
|
|
678
|
+
} else {
|
|
679
|
+
normalized = cleaned.replace(/,/g, '');
|
|
680
|
+
}
|
|
681
|
+
} else if (lastComma >= 0) {
|
|
682
|
+
normalized = cleaned.replace(/\./g, '').replace(',', '.');
|
|
683
|
+
} else {
|
|
684
|
+
const dotParts = cleaned.split('.');
|
|
685
|
+
if (
|
|
686
|
+
dotParts.length > 1 &&
|
|
687
|
+
dotParts[dotParts.length - 1].length === 3 &&
|
|
688
|
+
dotParts.length <= 4
|
|
689
|
+
) {
|
|
690
|
+
normalized = cleaned.replace(/\./g, '');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const num = Number(normalized);
|
|
267
695
|
return Number.isFinite(num) ? num : null;
|
|
268
696
|
}
|
|
269
697
|
|
|
@@ -375,6 +803,221 @@ export class FinanceService {
|
|
|
375
803
|
return bankAccounts.map((bankAccount) => this.mapBankAccountToFront(bankAccount));
|
|
376
804
|
}
|
|
377
805
|
|
|
806
|
+
async listCostCenters() {
|
|
807
|
+
const costCenters = await this.prisma.cost_center.findMany({
|
|
808
|
+
orderBy: [{ code: 'asc' }, { name: 'asc' }],
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
return costCenters.map((costCenter) => this.mapCostCenterToFront(costCenter));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async listAuditLogs(
|
|
815
|
+
paginationParams,
|
|
816
|
+
filters?: {
|
|
817
|
+
search?: string;
|
|
818
|
+
action?: string;
|
|
819
|
+
entity_table?: string;
|
|
820
|
+
actor_user_id?: string;
|
|
821
|
+
from?: string;
|
|
822
|
+
to?: string;
|
|
823
|
+
},
|
|
824
|
+
) {
|
|
825
|
+
const actorUserId = filters?.actor_user_id
|
|
826
|
+
? Number.parseInt(filters.actor_user_id, 10)
|
|
827
|
+
: undefined;
|
|
828
|
+
|
|
829
|
+
const fromDate = filters?.from ? new Date(filters.from) : undefined;
|
|
830
|
+
const toDate = filters?.to ? new Date(filters.to) : undefined;
|
|
831
|
+
|
|
832
|
+
const where: any = {
|
|
833
|
+
...(filters?.action ? { action: filters.action } : {}),
|
|
834
|
+
...(filters?.entity_table ? { entity_table: filters.entity_table } : {}),
|
|
835
|
+
...(Number.isNaN(actorUserId) || !actorUserId
|
|
836
|
+
? {}
|
|
837
|
+
: { actor_user_id: actorUserId }),
|
|
838
|
+
...((fromDate || toDate) &&
|
|
839
|
+
!(fromDate && Number.isNaN(fromDate.getTime())) &&
|
|
840
|
+
!(toDate && Number.isNaN(toDate.getTime()))
|
|
841
|
+
? {
|
|
842
|
+
created_at: {
|
|
843
|
+
...(fromDate ? { gte: fromDate } : {}),
|
|
844
|
+
...(toDate ? { lte: toDate } : {}),
|
|
845
|
+
},
|
|
846
|
+
}
|
|
847
|
+
: {}),
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const search = filters?.search?.trim();
|
|
851
|
+
if (search) {
|
|
852
|
+
where.OR = [
|
|
853
|
+
{ action: { contains: search, mode: 'insensitive' } },
|
|
854
|
+
{ entity_table: { contains: search, mode: 'insensitive' } },
|
|
855
|
+
{ entity_id: { contains: search, mode: 'insensitive' } },
|
|
856
|
+
{ summary: { contains: search, mode: 'insensitive' } },
|
|
857
|
+
{ ip_address: { contains: search, mode: 'insensitive' } },
|
|
858
|
+
];
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const paginated = await this.paginationService.paginatePrismaModel(
|
|
862
|
+
this.prisma.audit_log,
|
|
863
|
+
{
|
|
864
|
+
page: paginationParams?.page,
|
|
865
|
+
pageSize: paginationParams?.pageSize,
|
|
866
|
+
sortField: paginationParams?.sortField || 'created_at',
|
|
867
|
+
sortOrder: paginationParams?.sortOrder || 'desc',
|
|
868
|
+
validSortFields: [
|
|
869
|
+
'id',
|
|
870
|
+
'created_at',
|
|
871
|
+
'action',
|
|
872
|
+
'entity_table',
|
|
873
|
+
'entity_id',
|
|
874
|
+
],
|
|
875
|
+
where,
|
|
876
|
+
include: {
|
|
877
|
+
user: {
|
|
878
|
+
select: {
|
|
879
|
+
id: true,
|
|
880
|
+
name: true,
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
...paginated,
|
|
889
|
+
data: (paginated.data || []).map((log) => ({
|
|
890
|
+
id: String(log.id),
|
|
891
|
+
actorUserId: log.actor_user_id ? String(log.actor_user_id) : null,
|
|
892
|
+
actorName: log.user?.name || '-',
|
|
893
|
+
actorEmail: '',
|
|
894
|
+
action: log.action,
|
|
895
|
+
entityTable: log.entity_table,
|
|
896
|
+
entityId: log.entity_id,
|
|
897
|
+
summary: log.summary || '',
|
|
898
|
+
ipAddress: log.ip_address || '',
|
|
899
|
+
beforeData: log.before_data || '',
|
|
900
|
+
afterData: log.after_data || '',
|
|
901
|
+
createdAt: log.created_at?.toISOString?.() || null,
|
|
902
|
+
})),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async listFinanceCategories() {
|
|
907
|
+
const categories = await this.prisma.finance_category.findMany({
|
|
908
|
+
orderBy: [{ parent_id: 'asc' }, { updated_at: 'asc' }, { name: 'asc' }],
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
return categories.map((category) => this.mapFinanceCategoryToFront(category));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async listPeriodClose(
|
|
915
|
+
paginationParams: PaginationDTO,
|
|
916
|
+
filters?: {
|
|
917
|
+
search?: string;
|
|
918
|
+
status?: string;
|
|
919
|
+
user?: string;
|
|
920
|
+
from?: string;
|
|
921
|
+
to?: string;
|
|
922
|
+
},
|
|
923
|
+
) {
|
|
924
|
+
const fromDate = filters?.from ? new Date(filters.from) : undefined;
|
|
925
|
+
const toDate = filters?.to ? new Date(filters.to) : undefined;
|
|
926
|
+
const userNumericId = filters?.user
|
|
927
|
+
? Number.parseInt(filters.user, 10)
|
|
928
|
+
: undefined;
|
|
929
|
+
|
|
930
|
+
const andConditions: any[] = [];
|
|
931
|
+
|
|
932
|
+
if (filters?.status && filters.status !== 'all') {
|
|
933
|
+
andConditions.push({ status: filters.status });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (
|
|
937
|
+
(fromDate && !Number.isNaN(fromDate.getTime())) ||
|
|
938
|
+
(toDate && !Number.isNaN(toDate.getTime()))
|
|
939
|
+
) {
|
|
940
|
+
andConditions.push({
|
|
941
|
+
period_start: {
|
|
942
|
+
...(fromDate && !Number.isNaN(fromDate.getTime())
|
|
943
|
+
? { gte: fromDate }
|
|
944
|
+
: {}),
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
andConditions.push({
|
|
949
|
+
period_end: {
|
|
950
|
+
...(toDate && !Number.isNaN(toDate.getTime()) ? { lte: toDate } : {}),
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const search = filters?.search?.trim();
|
|
956
|
+
if (search) {
|
|
957
|
+
andConditions.push({
|
|
958
|
+
OR: [
|
|
959
|
+
{ notes: { contains: search, mode: 'insensitive' } },
|
|
960
|
+
{ user: { is: { name: { contains: search, mode: 'insensitive' } } } },
|
|
961
|
+
{
|
|
962
|
+
user: { is: { email: { contains: search, mode: 'insensitive' } } },
|
|
963
|
+
},
|
|
964
|
+
],
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (filters?.user?.trim()) {
|
|
969
|
+
andConditions.push({
|
|
970
|
+
OR: [
|
|
971
|
+
...(Number.isNaN(userNumericId) || !userNumericId
|
|
972
|
+
? []
|
|
973
|
+
: [{ closed_by_user_id: userNumericId }]),
|
|
974
|
+
{ user: { is: { name: { contains: filters.user, mode: 'insensitive' } } } },
|
|
975
|
+
{
|
|
976
|
+
user: {
|
|
977
|
+
is: { email: { contains: filters.user, mode: 'insensitive' } },
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
],
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const where = andConditions.length > 0 ? { AND: andConditions } : {};
|
|
985
|
+
|
|
986
|
+
const paginated = await this.paginationService.paginatePrismaModel(
|
|
987
|
+
this.prisma.period_close,
|
|
988
|
+
{
|
|
989
|
+
page: paginationParams?.page,
|
|
990
|
+
pageSize: paginationParams?.pageSize,
|
|
991
|
+
sortField: paginationParams?.sortField || 'period_start',
|
|
992
|
+
sortOrder: paginationParams?.sortOrder || 'desc',
|
|
993
|
+
validSortFields: [
|
|
994
|
+
'id',
|
|
995
|
+
'period_start',
|
|
996
|
+
'period_end',
|
|
997
|
+
'status',
|
|
998
|
+
'closed_at',
|
|
999
|
+
'created_at',
|
|
1000
|
+
],
|
|
1001
|
+
where,
|
|
1002
|
+
include: {
|
|
1003
|
+
user: {
|
|
1004
|
+
select: {
|
|
1005
|
+
id: true,
|
|
1006
|
+
name: true,
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
...paginated,
|
|
1015
|
+
data: (paginated.data || []).map((period) =>
|
|
1016
|
+
this.mapPeriodCloseToFront(period),
|
|
1017
|
+
),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
378
1021
|
async listBankStatements(bankAccountId?: number) {
|
|
379
1022
|
const statements = await this.prisma.bank_statement_line.findMany({
|
|
380
1023
|
where: {
|
|
@@ -457,6 +1100,89 @@ export class FinanceService {
|
|
|
457
1100
|
return this.mapBankAccountToFront(account);
|
|
458
1101
|
}
|
|
459
1102
|
|
|
1103
|
+
async createCostCenter(data: CreateCostCenterDto) {
|
|
1104
|
+
const code = await this.generateCostCenterCode(data.name);
|
|
1105
|
+
|
|
1106
|
+
const created = await this.prisma.cost_center.create({
|
|
1107
|
+
data: {
|
|
1108
|
+
code,
|
|
1109
|
+
name: data.name.trim(),
|
|
1110
|
+
status: 'active',
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
return this.mapCostCenterToFront(created);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async createFinanceCategory(data: CreateFinanceCategoryDto) {
|
|
1118
|
+
if (data.parent_id) {
|
|
1119
|
+
await this.ensureFinanceCategoryExists(data.parent_id);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const created = await this.prisma.finance_category.create({
|
|
1123
|
+
data: {
|
|
1124
|
+
code: await this.generateFinanceCategoryCode(),
|
|
1125
|
+
name: data.name.trim(),
|
|
1126
|
+
kind: this.mapCategoryKindFromPt(data.kind),
|
|
1127
|
+
status: 'active',
|
|
1128
|
+
parent_id: data.parent_id || null,
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
return this.mapFinanceCategoryToFront(created);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async createPeriodClose(data: CreatePeriodCloseDto, userId?: number) {
|
|
1136
|
+
const periodStart = new Date(data.period_start);
|
|
1137
|
+
const periodEnd = new Date(data.period_end);
|
|
1138
|
+
|
|
1139
|
+
if (
|
|
1140
|
+
Number.isNaN(periodStart.getTime()) ||
|
|
1141
|
+
Number.isNaN(periodEnd.getTime())
|
|
1142
|
+
) {
|
|
1143
|
+
throw new BadRequestException('Invalid period dates');
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (periodStart > periodEnd) {
|
|
1147
|
+
throw new BadRequestException('period_start must be before period_end');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const overlapped = await this.prisma.period_close.findFirst({
|
|
1151
|
+
where: {
|
|
1152
|
+
period_start: { lte: periodEnd },
|
|
1153
|
+
period_end: { gte: periodStart },
|
|
1154
|
+
},
|
|
1155
|
+
select: { id: true },
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
if (overlapped) {
|
|
1159
|
+
throw new BadRequestException('There is already a period in this range');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const status = data.status || 'closed';
|
|
1163
|
+
|
|
1164
|
+
const created = await this.prisma.period_close.create({
|
|
1165
|
+
data: {
|
|
1166
|
+
period_start: periodStart,
|
|
1167
|
+
period_end: periodEnd,
|
|
1168
|
+
status,
|
|
1169
|
+
closed_at: status === 'closed' ? new Date() : null,
|
|
1170
|
+
closed_by_user_id: status === 'closed' ? userId || null : null,
|
|
1171
|
+
notes: data.notes?.trim() || null,
|
|
1172
|
+
},
|
|
1173
|
+
include: {
|
|
1174
|
+
user: {
|
|
1175
|
+
select: {
|
|
1176
|
+
id: true,
|
|
1177
|
+
name: true,
|
|
1178
|
+
},
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
return this.mapPeriodCloseToFront(created);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
460
1186
|
async updateBankAccount(id: number, data: UpdateBankAccountDto) {
|
|
461
1187
|
const current = await this.prisma.bank_account.findUnique({
|
|
462
1188
|
where: { id },
|
|
@@ -490,6 +1216,104 @@ export class FinanceService {
|
|
|
490
1216
|
return this.mapBankAccountToFront(updated);
|
|
491
1217
|
}
|
|
492
1218
|
|
|
1219
|
+
async updateCostCenter(id: number, data: UpdateCostCenterDto) {
|
|
1220
|
+
const current = await this.prisma.cost_center.findUnique({
|
|
1221
|
+
where: { id },
|
|
1222
|
+
select: { id: true },
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
if (!current) {
|
|
1226
|
+
throw new NotFoundException('Cost center not found');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const updated = await this.prisma.cost_center.update({
|
|
1230
|
+
where: { id },
|
|
1231
|
+
data: {
|
|
1232
|
+
name: data.name?.trim(),
|
|
1233
|
+
status: data.status,
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
return this.mapCostCenterToFront(updated);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async updateFinanceCategory(id: number, data: UpdateFinanceCategoryDto) {
|
|
1241
|
+
const current = await this.prisma.finance_category.findUnique({
|
|
1242
|
+
where: { id },
|
|
1243
|
+
select: { id: true },
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
if (!current) {
|
|
1247
|
+
throw new NotFoundException('Finance category not found');
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (data.parent_id) {
|
|
1251
|
+
await this.ensureFinanceCategoryExists(data.parent_id);
|
|
1252
|
+
await this.ensureNoFinanceCategoryCycle(id, data.parent_id);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const updated = await this.prisma.finance_category.update({
|
|
1256
|
+
where: { id },
|
|
1257
|
+
data: {
|
|
1258
|
+
name: data.name?.trim(),
|
|
1259
|
+
kind: data.kind ? this.mapCategoryKindFromPt(data.kind) : undefined,
|
|
1260
|
+
parent_id: data.parent_id,
|
|
1261
|
+
status: data.status,
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
return this.mapFinanceCategoryToFront(updated);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async moveFinanceCategory(id: number, data: MoveFinanceCategoryDto) {
|
|
1269
|
+
const current = await this.prisma.finance_category.findUnique({
|
|
1270
|
+
where: { id },
|
|
1271
|
+
select: { id: true, parent_id: true },
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
if (!current) {
|
|
1275
|
+
throw new NotFoundException('Finance category not found');
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const targetParentId = data.parent_id || null;
|
|
1279
|
+
|
|
1280
|
+
if (targetParentId) {
|
|
1281
|
+
await this.ensureFinanceCategoryExists(targetParentId);
|
|
1282
|
+
await this.ensureNoFinanceCategoryCycle(id, targetParentId);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
await this.prisma.finance_category.update({
|
|
1286
|
+
where: { id },
|
|
1287
|
+
data: { parent_id: targetParentId },
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const siblings = await this.prisma.finance_category.findMany({
|
|
1291
|
+
where: { parent_id: targetParentId },
|
|
1292
|
+
select: { id: true },
|
|
1293
|
+
orderBy: [{ updated_at: 'asc' }, { name: 'asc' }],
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
const siblingIds = siblings.map((item) => item.id).filter((item) => item !== id);
|
|
1297
|
+
const targetPosition = Math.max(
|
|
1298
|
+
0,
|
|
1299
|
+
Math.min(data.position ?? siblingIds.length, siblingIds.length),
|
|
1300
|
+
);
|
|
1301
|
+
siblingIds.splice(targetPosition, 0, id);
|
|
1302
|
+
|
|
1303
|
+
await this.prisma.$transaction(
|
|
1304
|
+
siblingIds.map((categoryId) =>
|
|
1305
|
+
this.prisma.finance_category.update({
|
|
1306
|
+
where: { id: categoryId },
|
|
1307
|
+
data: {
|
|
1308
|
+
parent_id: targetParentId,
|
|
1309
|
+
},
|
|
1310
|
+
}),
|
|
1311
|
+
),
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
return { success: true };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
493
1317
|
async deleteBankAccount(id: number) {
|
|
494
1318
|
const current = await this.prisma.bank_account.findUnique({
|
|
495
1319
|
where: { id },
|
|
@@ -510,6 +1334,46 @@ export class FinanceService {
|
|
|
510
1334
|
return { success: true };
|
|
511
1335
|
}
|
|
512
1336
|
|
|
1337
|
+
async deleteCostCenter(id: number) {
|
|
1338
|
+
const current = await this.prisma.cost_center.findUnique({
|
|
1339
|
+
where: { id },
|
|
1340
|
+
select: { id: true },
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
if (!current) {
|
|
1344
|
+
throw new NotFoundException('Cost center not found');
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
await this.prisma.cost_center.update({
|
|
1348
|
+
where: { id },
|
|
1349
|
+
data: {
|
|
1350
|
+
status: 'inactive',
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
return { success: true };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async deleteFinanceCategory(id: number) {
|
|
1358
|
+
const current = await this.prisma.finance_category.findUnique({
|
|
1359
|
+
where: { id },
|
|
1360
|
+
select: { id: true },
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
if (!current) {
|
|
1364
|
+
throw new NotFoundException('Finance category not found');
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
await this.prisma.finance_category.update({
|
|
1368
|
+
where: { id },
|
|
1369
|
+
data: {
|
|
1370
|
+
status: 'inactive',
|
|
1371
|
+
},
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
return { success: true };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
513
1377
|
private async listTitles(
|
|
514
1378
|
titleType: TitleType,
|
|
515
1379
|
paginationParams: PaginationDTO,
|
|
@@ -674,14 +1538,6 @@ export class FinanceService {
|
|
|
674
1538
|
|
|
675
1539
|
private async loadPeople() {
|
|
676
1540
|
const people = await this.prisma.person.findMany({
|
|
677
|
-
include: {
|
|
678
|
-
document: {
|
|
679
|
-
select: {
|
|
680
|
-
value: true,
|
|
681
|
-
},
|
|
682
|
-
take: 1,
|
|
683
|
-
},
|
|
684
|
-
},
|
|
685
1541
|
orderBy: { name: 'asc' },
|
|
686
1542
|
});
|
|
687
1543
|
|
|
@@ -689,7 +1545,7 @@ export class FinanceService {
|
|
|
689
1545
|
id: String(person.id),
|
|
690
1546
|
nome: person.name,
|
|
691
1547
|
tipo: 'ambos',
|
|
692
|
-
documento:
|
|
1548
|
+
documento: '',
|
|
693
1549
|
}));
|
|
694
1550
|
}
|
|
695
1551
|
|
|
@@ -972,6 +1828,76 @@ export class FinanceService {
|
|
|
972
1828
|
};
|
|
973
1829
|
}
|
|
974
1830
|
|
|
1831
|
+
private mapCostCenterToFront(costCenter: any) {
|
|
1832
|
+
return {
|
|
1833
|
+
id: String(costCenter.id),
|
|
1834
|
+
codigo: costCenter.code,
|
|
1835
|
+
nome: costCenter.name,
|
|
1836
|
+
status: costCenter.status,
|
|
1837
|
+
ativo: costCenter.status === 'active',
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
private mapCategoryKindToPt(kind?: string | null) {
|
|
1842
|
+
const kindMap = {
|
|
1843
|
+
revenue: 'receita',
|
|
1844
|
+
expense: 'despesa',
|
|
1845
|
+
transfer: 'transferencia',
|
|
1846
|
+
adjustment: 'ajuste',
|
|
1847
|
+
other: 'outro',
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
return kindMap[kind] || 'outro';
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private mapCategoryKindFromPt(kind?: string | null) {
|
|
1854
|
+
const normalized = (kind || '').toLowerCase();
|
|
1855
|
+
const kindMap = {
|
|
1856
|
+
receita: 'revenue',
|
|
1857
|
+
revenue: 'revenue',
|
|
1858
|
+
despesa: 'expense',
|
|
1859
|
+
expense: 'expense',
|
|
1860
|
+
transferencia: 'transfer',
|
|
1861
|
+
transferência: 'transfer',
|
|
1862
|
+
transfer: 'transfer',
|
|
1863
|
+
ajuste: 'adjustment',
|
|
1864
|
+
adjustment: 'adjustment',
|
|
1865
|
+
outro: 'other',
|
|
1866
|
+
other: 'other',
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
return kindMap[normalized] || 'other';
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private mapFinanceCategoryToFront(category: any) {
|
|
1873
|
+
return {
|
|
1874
|
+
id: String(category.id),
|
|
1875
|
+
codigo: category.code,
|
|
1876
|
+
nome: category.name,
|
|
1877
|
+
parentId: category.parent_id ? String(category.parent_id) : null,
|
|
1878
|
+
natureza: this.mapCategoryKindToPt(category.kind),
|
|
1879
|
+
status: category.status,
|
|
1880
|
+
ativo: category.status === 'active',
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
private mapPeriodCloseToFront(period: any) {
|
|
1885
|
+
return {
|
|
1886
|
+
id: String(period.id),
|
|
1887
|
+
periodStart: period.period_start?.toISOString?.() || null,
|
|
1888
|
+
periodEnd: period.period_end?.toISOString?.() || null,
|
|
1889
|
+
status: period.status,
|
|
1890
|
+
closedAt: period.closed_at?.toISOString?.() || null,
|
|
1891
|
+
closedByUserId: period.closed_by_user_id
|
|
1892
|
+
? String(period.closed_by_user_id)
|
|
1893
|
+
: null,
|
|
1894
|
+
closedByName: period.user?.name || '-',
|
|
1895
|
+
closedByEmail: '',
|
|
1896
|
+
notes: period.notes || '',
|
|
1897
|
+
createdAt: period.created_at?.toISOString?.() || null,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
|
|
975
1901
|
private mapStatementStatusToPt(status?: string | null) {
|
|
976
1902
|
const statusMap = {
|
|
977
1903
|
pending: 'pendente',
|
|
@@ -997,6 +1923,71 @@ export class FinanceService {
|
|
|
997
1923
|
return `${bankPrefix || 'ACC'}-${accountSuffix}`;
|
|
998
1924
|
}
|
|
999
1925
|
|
|
1926
|
+
private async generateCostCenterCode(name?: string) {
|
|
1927
|
+
const sanitizedName = (name || 'CENTRO')
|
|
1928
|
+
.trim()
|
|
1929
|
+
.replace(/\s+/g, '-')
|
|
1930
|
+
.replace(/[^A-Za-z0-9-]/g, '')
|
|
1931
|
+
.toUpperCase();
|
|
1932
|
+
|
|
1933
|
+
const basePrefix = sanitizedName.slice(0, 8) || 'CENTRO';
|
|
1934
|
+
|
|
1935
|
+
const lastCostCenter = await this.prisma.cost_center.findFirst({
|
|
1936
|
+
orderBy: { id: 'desc' },
|
|
1937
|
+
select: { id: true },
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
const suffix = String((lastCostCenter?.id || 0) + 1).padStart(4, '0');
|
|
1941
|
+
|
|
1942
|
+
return `${basePrefix}-${suffix}`;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
private async generateFinanceCategoryCode() {
|
|
1946
|
+
const lastCategory = await this.prisma.finance_category.findFirst({
|
|
1947
|
+
orderBy: { id: 'desc' },
|
|
1948
|
+
select: { id: true },
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
const suffix = String((lastCategory?.id || 0) + 1).padStart(4, '0');
|
|
1952
|
+
|
|
1953
|
+
return `CAT-${suffix}`;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private async ensureFinanceCategoryExists(id: number) {
|
|
1957
|
+
const category = await this.prisma.finance_category.findUnique({
|
|
1958
|
+
where: { id },
|
|
1959
|
+
select: { id: true },
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
if (!category) {
|
|
1963
|
+
throw new NotFoundException('Finance category not found');
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
private async ensureNoFinanceCategoryCycle(
|
|
1968
|
+
categoryId: number,
|
|
1969
|
+
targetParentId: number,
|
|
1970
|
+
) {
|
|
1971
|
+
if (categoryId === targetParentId) {
|
|
1972
|
+
throw new BadRequestException('Category cannot be parent of itself');
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
let currentParentId: number | null = targetParentId;
|
|
1976
|
+
|
|
1977
|
+
while (currentParentId) {
|
|
1978
|
+
if (currentParentId === categoryId) {
|
|
1979
|
+
throw new BadRequestException('Invalid parent category hierarchy');
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const parent = await this.prisma.finance_category.findUnique({
|
|
1983
|
+
where: { id: currentParentId },
|
|
1984
|
+
select: { parent_id: true },
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
currentParentId = parent?.parent_id || null;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1000
1991
|
private toCents(value: number) {
|
|
1001
1992
|
return Math.round(value * 100);
|
|
1002
1993
|
}
|