@hed-hog/operations 0.0.300 → 0.0.301
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/operations.controller.d.ts +713 -31
- package/dist/operations.controller.d.ts.map +1 -1
- package/dist/operations.controller.js +157 -0
- package/dist/operations.controller.js.map +1 -1
- package/dist/operations.module.d.ts.map +1 -1
- package/dist/operations.module.js +5 -1
- package/dist/operations.module.js.map +1 -1
- package/dist/operations.proposal.subscriber.d.ts +11 -0
- package/dist/operations.proposal.subscriber.d.ts.map +1 -0
- package/dist/operations.proposal.subscriber.js +80 -0
- package/dist/operations.proposal.subscriber.js.map +1 -0
- package/dist/operations.proposal.subscriber.spec.d.ts +2 -0
- package/dist/operations.proposal.subscriber.spec.d.ts.map +1 -0
- package/dist/operations.proposal.subscriber.spec.js +88 -0
- package/dist/operations.proposal.subscriber.spec.js.map +1 -0
- package/dist/operations.service.d.ts +490 -46
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +2442 -119
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.d.ts +2 -0
- package/dist/operations.service.spec.d.ts.map +1 -0
- package/dist/operations.service.spec.js +159 -0
- package/dist/operations.service.spec.js.map +1 -0
- package/hedhog/data/menu.yaml +34 -0
- package/hedhog/data/role_route.yaml +39 -0
- package/hedhog/data/route.yaml +130 -0
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +8 -6
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +1163 -327
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -0
- package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
- package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +631 -0
- package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +353 -27
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +1926 -87
- package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +526 -0
- package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -0
- package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -0
- package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +370 -0
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +826 -0
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1251 -364
- package/hedhog/frontend/app/_components/section-card.tsx.ejs +48 -13
- package/hedhog/frontend/app/_lib/api.ts.ejs +2 -5
- package/hedhog/frontend/app/_lib/types.ts.ejs +76 -33
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +85 -8
- package/hedhog/frontend/app/approvals/page.tsx.ejs +90 -54
- package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +597 -140
- package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/contracts/page.tsx.ejs +941 -262
- package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +384 -0
- package/hedhog/frontend/app/departments/page.tsx.ejs +442 -0
- package/hedhog/frontend/app/page.tsx.ejs +36 -12
- package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/projects/new/page.tsx.ejs +2 -2
- package/hedhog/frontend/app/projects/page.tsx.ejs +264 -102
- package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +50 -28
- package/hedhog/frontend/app/time-off/page.tsx.ejs +57 -31
- package/hedhog/frontend/app/timesheets/page.tsx.ejs +85 -42
- package/hedhog/frontend/messages/en.json +473 -12
- package/hedhog/frontend/messages/pt.json +528 -66
- package/hedhog/table/operations_collaborator.yaml +20 -0
- package/hedhog/table/operations_contract.yaml +22 -1
- package/hedhog/table/operations_contract_document.yaml +33 -16
- package/hedhog/table/operations_contract_template.yaml +58 -0
- package/hedhog/table/operations_department.yaml +24 -0
- package/package.json +6 -4
- package/src/operations.controller.ts +122 -0
- package/src/operations.module.ts +6 -2
- package/src/operations.proposal.subscriber.spec.ts +121 -0
- package/src/operations.proposal.subscriber.ts +86 -0
- package/src/operations.service.spec.ts +210 -0
- package/src/operations.service.ts +3934 -212
|
@@ -1,15 +1,107 @@
|
|
|
1
1
|
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AiService,
|
|
4
|
+
FileService,
|
|
5
|
+
IntegrationDeveloperApiService,
|
|
6
|
+
SettingService,
|
|
7
|
+
} from '@hed-hog/core';
|
|
3
8
|
import {
|
|
4
9
|
BadRequestException,
|
|
5
10
|
ForbiddenException,
|
|
11
|
+
Inject,
|
|
6
12
|
Injectable,
|
|
7
13
|
NotFoundException,
|
|
14
|
+
forwardRef,
|
|
8
15
|
} from '@nestjs/common';
|
|
9
16
|
|
|
10
17
|
const COLLABORATOR_ROLE = 'admin-operations-collaborator';
|
|
11
18
|
const SUPERVISOR_ROLE = 'admin-operations-supervisor';
|
|
12
19
|
const DIRECTOR_ROLE = 'admin-operations-director';
|
|
20
|
+
const CONTRACT_CATEGORY_VALUES = [
|
|
21
|
+
'employee',
|
|
22
|
+
'contractor',
|
|
23
|
+
'client',
|
|
24
|
+
'supplier',
|
|
25
|
+
'vendor',
|
|
26
|
+
'partner',
|
|
27
|
+
'internal',
|
|
28
|
+
'other',
|
|
29
|
+
] as const;
|
|
30
|
+
const CONTRACT_TYPE_VALUES = [
|
|
31
|
+
'clt',
|
|
32
|
+
'pj',
|
|
33
|
+
'freelancer_agreement',
|
|
34
|
+
'service_agreement',
|
|
35
|
+
'fixed_term',
|
|
36
|
+
'recurring_service',
|
|
37
|
+
'nda',
|
|
38
|
+
'amendment',
|
|
39
|
+
'addendum',
|
|
40
|
+
'other',
|
|
41
|
+
] as const;
|
|
42
|
+
const BILLING_MODEL_VALUES = [
|
|
43
|
+
'time_and_material',
|
|
44
|
+
'monthly_retainer',
|
|
45
|
+
'fixed_price',
|
|
46
|
+
] as const;
|
|
47
|
+
const SIGNATURE_STATUS_VALUES = [
|
|
48
|
+
'not_started',
|
|
49
|
+
'pending',
|
|
50
|
+
'partially_signed',
|
|
51
|
+
'signed',
|
|
52
|
+
'expired',
|
|
53
|
+
] as const;
|
|
54
|
+
const CONTRACT_STATUS_VALUES = [
|
|
55
|
+
'draft',
|
|
56
|
+
'under_review',
|
|
57
|
+
'active',
|
|
58
|
+
'renewal',
|
|
59
|
+
'expired',
|
|
60
|
+
'closed',
|
|
61
|
+
'archived',
|
|
62
|
+
] as const;
|
|
63
|
+
const CONTRACT_CREATION_MODE_VALUES = [
|
|
64
|
+
'blank',
|
|
65
|
+
'template',
|
|
66
|
+
'upload',
|
|
67
|
+
'duplicate',
|
|
68
|
+
] as const;
|
|
69
|
+
const ORIGIN_TYPE_VALUES = [
|
|
70
|
+
'manual',
|
|
71
|
+
'employee_hiring',
|
|
72
|
+
'client_project',
|
|
73
|
+
'crm_proposal',
|
|
74
|
+
] as const;
|
|
75
|
+
const CONTRACT_DOCUMENT_TYPE_VALUES = [
|
|
76
|
+
'source_upload',
|
|
77
|
+
'generated_pdf',
|
|
78
|
+
'attachment',
|
|
79
|
+
'other',
|
|
80
|
+
] as const;
|
|
81
|
+
const CONTRACT_DOCUMENT_EXTRACTION_STATUS_VALUES = [
|
|
82
|
+
'pending',
|
|
83
|
+
'processing',
|
|
84
|
+
'completed',
|
|
85
|
+
'failed',
|
|
86
|
+
'skipped',
|
|
87
|
+
] as const;
|
|
88
|
+
const PARTY_ROLE_VALUES = [
|
|
89
|
+
'employee',
|
|
90
|
+
'employer',
|
|
91
|
+
'client',
|
|
92
|
+
'supplier',
|
|
93
|
+
'vendor',
|
|
94
|
+
'partner',
|
|
95
|
+
'witness',
|
|
96
|
+
'internal_owner',
|
|
97
|
+
'other',
|
|
98
|
+
] as const;
|
|
99
|
+
const PARTY_TYPE_VALUES = ['individual', 'company', 'internal_team', 'other'] as const;
|
|
100
|
+
const SIGNATURE_ITEM_STATUS_VALUES = ['pending', 'signed', 'rejected'] as const;
|
|
101
|
+
const FINANCIAL_TERM_TYPE_VALUES = ['value', 'payment', 'revenue', 'fine', 'other'] as const;
|
|
102
|
+
const RECURRENCE_VALUES = ['one_time', 'monthly', 'quarterly', 'yearly', 'other'] as const;
|
|
103
|
+
const REVISION_TYPE_VALUES = ['amendment', 'renewal', 'revision', 'addendum', 'other'] as const;
|
|
104
|
+
const REVISION_STATUS_VALUES = ['draft', 'active', 'completed', 'cancelled'] as const;
|
|
13
105
|
|
|
14
106
|
type ApprovalAction = 'approve' | 'reject';
|
|
15
107
|
type ApprovalTargetType =
|
|
@@ -31,11 +123,13 @@ type ActorContext = {
|
|
|
31
123
|
};
|
|
32
124
|
|
|
33
125
|
type CollaboratorPayload = {
|
|
34
|
-
userId
|
|
35
|
-
|
|
36
|
-
|
|
126
|
+
userId?: number | null;
|
|
127
|
+
personId?: number | null;
|
|
128
|
+
code?: string | null;
|
|
129
|
+
displayName?: string | null;
|
|
37
130
|
collaboratorType?: 'clt' | 'pj' | 'freelancer' | 'intern' | 'other';
|
|
38
131
|
department?: string | null;
|
|
132
|
+
departmentId?: number | null;
|
|
39
133
|
title?: string | null;
|
|
40
134
|
levelLabel?: string | null;
|
|
41
135
|
supervisorCollaboratorId?: number | null;
|
|
@@ -63,10 +157,17 @@ type CollaboratorPayload = {
|
|
|
63
157
|
notes?: string | null;
|
|
64
158
|
};
|
|
65
159
|
|
|
160
|
+
type DepartmentPayload = {
|
|
161
|
+
code?: string | null;
|
|
162
|
+
name?: string | null;
|
|
163
|
+
description?: string | null;
|
|
164
|
+
status?: 'active' | 'inactive';
|
|
165
|
+
};
|
|
166
|
+
|
|
66
167
|
type ContractPayload = {
|
|
67
|
-
code
|
|
68
|
-
name
|
|
69
|
-
clientName
|
|
168
|
+
code?: string | null;
|
|
169
|
+
name?: string | null;
|
|
170
|
+
clientName?: string | null;
|
|
70
171
|
contractCategory?:
|
|
71
172
|
| 'employee'
|
|
72
173
|
| 'contractor'
|
|
@@ -100,9 +201,10 @@ type ContractPayload = {
|
|
|
100
201
|
isActive?: boolean;
|
|
101
202
|
accountManagerCollaboratorId?: number | null;
|
|
102
203
|
relatedCollaboratorId?: number | null;
|
|
103
|
-
|
|
204
|
+
contractTemplateId?: number | null;
|
|
205
|
+
originType?: 'manual' | 'employee_hiring' | 'client_project' | 'crm_proposal';
|
|
104
206
|
originId?: number | null;
|
|
105
|
-
startDate
|
|
207
|
+
startDate?: string | null;
|
|
106
208
|
endDate?: string | null;
|
|
107
209
|
signedAt?: string | null;
|
|
108
210
|
effectiveDate?: string | null;
|
|
@@ -116,6 +218,8 @@ type ContractPayload = {
|
|
|
116
218
|
| 'expired'
|
|
117
219
|
| 'closed'
|
|
118
220
|
| 'archived';
|
|
221
|
+
creationMode?: 'blank' | 'template' | 'upload' | 'duplicate';
|
|
222
|
+
wizardStep?: number | null;
|
|
119
223
|
description?: string | null;
|
|
120
224
|
contentHtml?: string | null;
|
|
121
225
|
parties?: Array<{
|
|
@@ -159,15 +263,248 @@ type ContractPayload = {
|
|
|
159
263
|
summary?: string | null;
|
|
160
264
|
}>;
|
|
161
265
|
replaceUploadedPdfDocument?: {
|
|
266
|
+
fileId?: number | null;
|
|
162
267
|
fileName: string;
|
|
163
268
|
mimeType: string;
|
|
164
|
-
fileContentBase64
|
|
269
|
+
fileContentBase64?: string | null;
|
|
270
|
+
notes?: string | null;
|
|
271
|
+
extractionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped';
|
|
272
|
+
extractionSummary?: string | null;
|
|
273
|
+
} | null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
type ContractDraftPayload = {
|
|
277
|
+
creationMode?: 'blank' | 'template' | 'upload' | 'duplicate' | null;
|
|
278
|
+
templateId?: number | null;
|
|
279
|
+
duplicateFromId?: number | null;
|
|
280
|
+
sourceFileId?: number | null;
|
|
281
|
+
sourceFileName?: string | null;
|
|
282
|
+
sourceMimeType?: string | null;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
type ProposalApprovedEventPayload = {
|
|
286
|
+
proposalId?: number;
|
|
287
|
+
proposalRevisionId?: number | null;
|
|
288
|
+
personId?: number | null;
|
|
289
|
+
approvedByUserId?: number | null;
|
|
290
|
+
approvedAt?: string | null;
|
|
291
|
+
correlationId?: string | null;
|
|
292
|
+
sourceModule?: string | null;
|
|
293
|
+
sourceEntity?: string | null;
|
|
294
|
+
sourceId?: string | null;
|
|
295
|
+
source_module?: string | null;
|
|
296
|
+
source_entity?: string | null;
|
|
297
|
+
source_id?: string | null;
|
|
298
|
+
code?: string | null;
|
|
299
|
+
title?: string | null;
|
|
300
|
+
version?: number | null;
|
|
301
|
+
total?: number | null;
|
|
302
|
+
currency?: string | null;
|
|
303
|
+
locale?: string | null;
|
|
304
|
+
validFrom?: string | null;
|
|
305
|
+
validUntil?: string | null;
|
|
306
|
+
commercialTerms?: {
|
|
307
|
+
contractCategory?: ContractPayload['contractCategory'];
|
|
308
|
+
contractType?: ContractPayload['contractType'];
|
|
309
|
+
billingModel?: ContractPayload['billingModel'];
|
|
310
|
+
validFrom?: string | null;
|
|
311
|
+
validUntil?: string | null;
|
|
312
|
+
notes?: string | null;
|
|
313
|
+
} | null;
|
|
314
|
+
proposal?: {
|
|
315
|
+
code?: string | null;
|
|
316
|
+
title?: string | null;
|
|
317
|
+
contractCategory?: ContractPayload['contractCategory'];
|
|
318
|
+
contractType?: ContractPayload['contractType'];
|
|
319
|
+
billingModel?: ContractPayload['billingModel'];
|
|
320
|
+
validFrom?: string | null;
|
|
321
|
+
validUntil?: string | null;
|
|
322
|
+
totalAmount?: number | null;
|
|
323
|
+
totalAmountCents?: number | null;
|
|
165
324
|
notes?: string | null;
|
|
325
|
+
};
|
|
326
|
+
person?: {
|
|
327
|
+
id?: number | null;
|
|
328
|
+
name?: string | null;
|
|
329
|
+
tradeName?: string | null;
|
|
330
|
+
email?: string | null;
|
|
331
|
+
phone?: string | null;
|
|
332
|
+
document?: string | null;
|
|
333
|
+
} | null;
|
|
334
|
+
revision?: {
|
|
335
|
+
id?: number | null;
|
|
336
|
+
revisionNumber?: number | null;
|
|
337
|
+
title?: string | null;
|
|
338
|
+
summary?: string | null;
|
|
339
|
+
contentHtml?: string | null;
|
|
166
340
|
} | null;
|
|
341
|
+
items?: Array<{
|
|
342
|
+
name?: string | null;
|
|
343
|
+
description?: string | null;
|
|
344
|
+
termType?: ContractPayload['financialTerms'][number]['termType'];
|
|
345
|
+
recurrence?: ContractPayload['financialTerms'][number]['recurrence'];
|
|
346
|
+
dueDay?: number | null;
|
|
347
|
+
amount?: number | null;
|
|
348
|
+
totalAmountCents?: number | null;
|
|
349
|
+
}>;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
type ContractTemplatePayload = {
|
|
353
|
+
code?: string | null;
|
|
354
|
+
name?: string | null;
|
|
355
|
+
description?: string | null;
|
|
356
|
+
contractCategory?:
|
|
357
|
+
| 'employee'
|
|
358
|
+
| 'contractor'
|
|
359
|
+
| 'client'
|
|
360
|
+
| 'supplier'
|
|
361
|
+
| 'vendor'
|
|
362
|
+
| 'partner'
|
|
363
|
+
| 'internal'
|
|
364
|
+
| 'other';
|
|
365
|
+
contractType?:
|
|
366
|
+
| 'clt'
|
|
367
|
+
| 'pj'
|
|
368
|
+
| 'freelancer_agreement'
|
|
369
|
+
| 'service_agreement'
|
|
370
|
+
| 'fixed_term'
|
|
371
|
+
| 'recurring_service'
|
|
372
|
+
| 'nda'
|
|
373
|
+
| 'amendment'
|
|
374
|
+
| 'addendum'
|
|
375
|
+
| 'other';
|
|
376
|
+
billingModel?:
|
|
377
|
+
| 'time_and_material'
|
|
378
|
+
| 'monthly_retainer'
|
|
379
|
+
| 'fixed_price';
|
|
380
|
+
signatureStatus?:
|
|
381
|
+
| 'not_started'
|
|
382
|
+
| 'pending'
|
|
383
|
+
| 'partially_signed'
|
|
384
|
+
| 'signed'
|
|
385
|
+
| 'expired';
|
|
386
|
+
isActive?: boolean;
|
|
387
|
+
status?: 'draft' | 'active' | 'inactive' | 'archived';
|
|
388
|
+
contentHtml?: string | null;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
type ContractExtractDraftPayload = {
|
|
392
|
+
contractId?: number | null;
|
|
393
|
+
fileName?: string | null;
|
|
394
|
+
mimeType?: string | null;
|
|
395
|
+
fileContentBase64?: string | null;
|
|
396
|
+
provider?: 'openai' | 'gemini' | null;
|
|
397
|
+
promptMessage?: string | null;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
type ContractGenerateContentPayload = {
|
|
401
|
+
provider?: 'openai' | 'gemini' | null;
|
|
402
|
+
promptMessage?: string | null;
|
|
403
|
+
overwrite?: boolean;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
type ContractLegalReviewPayload = {
|
|
407
|
+
provider?: 'openai' | 'gemini' | null;
|
|
408
|
+
promptMessage?: string | null;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
type ContractLegalReviewResult = {
|
|
412
|
+
summary: string;
|
|
413
|
+
missingFields: string[];
|
|
414
|
+
warnings: string[];
|
|
415
|
+
checklist: string[];
|
|
416
|
+
status: 'ready_for_revision' | 'attention_required';
|
|
417
|
+
reviewedAt: string;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
type ContractProjectSummary = {
|
|
421
|
+
id: number;
|
|
422
|
+
code: string;
|
|
423
|
+
name: string;
|
|
424
|
+
status: string;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
type ContractScheduleDay = {
|
|
428
|
+
weekday: string;
|
|
429
|
+
isWorkingDay: boolean;
|
|
430
|
+
startTime: string | null;
|
|
431
|
+
endTime: string | null;
|
|
432
|
+
breakMinutes: number | null;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
type ContractDocumentRecord = {
|
|
436
|
+
id: number;
|
|
437
|
+
documentType: (typeof CONTRACT_DOCUMENT_TYPE_VALUES)[number];
|
|
438
|
+
fileId: number | null;
|
|
439
|
+
fileName: string;
|
|
440
|
+
mimeType: string;
|
|
441
|
+
fileContentBase64: string | null;
|
|
442
|
+
isCurrent: boolean;
|
|
443
|
+
extractionStatus:
|
|
444
|
+
| (typeof CONTRACT_DOCUMENT_EXTRACTION_STATUS_VALUES)[number]
|
|
445
|
+
| null;
|
|
446
|
+
extractionSummary: string | null;
|
|
447
|
+
notes: string | null;
|
|
448
|
+
createdAt: string;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
type ContractHistoryRecord = {
|
|
452
|
+
id: number;
|
|
453
|
+
actorUserId: number | null;
|
|
454
|
+
action: string;
|
|
455
|
+
note: string | null;
|
|
456
|
+
metadataJson: string | null;
|
|
457
|
+
createdAt: string;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
type ContractDetailRecord = {
|
|
461
|
+
id: number;
|
|
462
|
+
code: string;
|
|
463
|
+
name: string | null;
|
|
464
|
+
contractCategory: (typeof CONTRACT_CATEGORY_VALUES)[number];
|
|
465
|
+
contractType: (typeof CONTRACT_TYPE_VALUES)[number];
|
|
466
|
+
clientName: string | null;
|
|
467
|
+
signatureStatus: (typeof SIGNATURE_STATUS_VALUES)[number];
|
|
468
|
+
isActive: boolean;
|
|
469
|
+
billingModel: (typeof BILLING_MODEL_VALUES)[number];
|
|
470
|
+
accountManagerCollaboratorId: number | null;
|
|
471
|
+
relatedCollaboratorId: number | null;
|
|
472
|
+
contractTemplateId: number | null;
|
|
473
|
+
contractTemplateName: string | null;
|
|
474
|
+
contractTemplateSlug: string | null;
|
|
475
|
+
contractTemplateCode: string | null;
|
|
476
|
+
originType: (typeof ORIGIN_TYPE_VALUES)[number];
|
|
477
|
+
originId: number | null;
|
|
478
|
+
startDate: string | null;
|
|
479
|
+
endDate: string | null;
|
|
480
|
+
signedAt: string | null;
|
|
481
|
+
effectiveDate: string | null;
|
|
482
|
+
budgetAmount: number | null;
|
|
483
|
+
monthlyHourCap: number | null;
|
|
484
|
+
status: (typeof CONTRACT_STATUS_VALUES)[number];
|
|
485
|
+
creationMode: (typeof CONTRACT_CREATION_MODE_VALUES)[number] | null;
|
|
486
|
+
wizardStep: number | null;
|
|
487
|
+
description: string | null;
|
|
488
|
+
contentHtml: string | null;
|
|
489
|
+
accountManagerName: string | null;
|
|
490
|
+
relatedCollaboratorName: string | null;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
type ContractDetailsRecord = ContractDetailRecord & {
|
|
494
|
+
mainRelatedPartyName: string | null;
|
|
495
|
+
projects: ContractProjectSummary[];
|
|
496
|
+
scheduleSummary: ContractScheduleDay[];
|
|
497
|
+
parties: NonNullable<ContractPayload['parties']>;
|
|
498
|
+
signatures: NonNullable<ContractPayload['signatures']>;
|
|
499
|
+
financialTerms: NonNullable<ContractPayload['financialTerms']>;
|
|
500
|
+
documents: ContractDocumentRecord[];
|
|
501
|
+
revisions: NonNullable<ContractPayload['revisions']>;
|
|
502
|
+
history: ContractHistoryRecord[];
|
|
167
503
|
};
|
|
168
504
|
|
|
169
505
|
type ProjectPayload = {
|
|
170
506
|
contractId?: number | null;
|
|
507
|
+
contractTemplateId?: number | null;
|
|
171
508
|
managerCollaboratorId?: number | null;
|
|
172
509
|
code: string;
|
|
173
510
|
name: string;
|
|
@@ -286,7 +623,12 @@ type PublishAccountsPayableReferencePayload = {
|
|
|
286
623
|
export class OperationsService {
|
|
287
624
|
constructor(
|
|
288
625
|
private readonly prisma: PrismaService,
|
|
626
|
+
private readonly aiService: AiService,
|
|
289
627
|
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
628
|
+
@Inject(forwardRef(() => FileService))
|
|
629
|
+
private readonly fileService: FileService,
|
|
630
|
+
@Inject(forwardRef(() => SettingService))
|
|
631
|
+
private readonly settingService: SettingService,
|
|
290
632
|
) {}
|
|
291
633
|
|
|
292
634
|
async getDashboard(userId: number) {
|
|
@@ -416,10 +758,14 @@ export class OperationsService {
|
|
|
416
758
|
return this.queryRows(
|
|
417
759
|
`SELECT c.id,
|
|
418
760
|
c.user_id AS "userId",
|
|
761
|
+
c.person_id AS "personId",
|
|
419
762
|
c.code,
|
|
420
763
|
c.collaborator_type AS "collaboratorType",
|
|
421
|
-
c.display_name AS "displayName",
|
|
422
|
-
|
|
764
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
|
|
765
|
+
person_record.name AS "personName",
|
|
766
|
+
person_record.avatar_id AS "personAvatarId",
|
|
767
|
+
c.department_id AS "departmentId",
|
|
768
|
+
COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
|
|
423
769
|
c.title,
|
|
424
770
|
c.level_label AS "levelLabel",
|
|
425
771
|
c.weekly_capacity_hours AS "weeklyCapacityHours",
|
|
@@ -433,6 +779,11 @@ export class OperationsService {
|
|
|
433
779
|
hiring_contract.status AS "contractStatus",
|
|
434
780
|
COUNT(DISTINCT pa.id)::int AS "activeAssignments"
|
|
435
781
|
FROM operations_collaborator c
|
|
782
|
+
LEFT JOIN person person_record
|
|
783
|
+
ON person_record.id = c.person_id
|
|
784
|
+
LEFT JOIN operations_department department_record
|
|
785
|
+
ON department_record.id = c.department_id
|
|
786
|
+
AND department_record.deleted_at IS NULL
|
|
436
787
|
LEFT JOIN operations_collaborator s
|
|
437
788
|
ON s.id = c.supervisor_collaborator_id
|
|
438
789
|
LEFT JOIN operations_project_assignment pa
|
|
@@ -450,8 +801,8 @@ export class OperationsService {
|
|
|
450
801
|
) hiring_contract ON TRUE
|
|
451
802
|
WHERE c.deleted_at IS NULL
|
|
452
803
|
AND ${filter.clause}
|
|
453
|
-
GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status
|
|
454
|
-
ORDER BY c.display_name ASC`,
|
|
804
|
+
GROUP BY c.id, person_record.id, department_record.id, s.id, hiring_contract.id, hiring_contract.status
|
|
805
|
+
ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
|
|
455
806
|
filter.params
|
|
456
807
|
);
|
|
457
808
|
}
|
|
@@ -494,10 +845,14 @@ export class OperationsService {
|
|
|
494
845
|
const teamMembers = await this.queryRows(
|
|
495
846
|
`SELECT c.id,
|
|
496
847
|
c.user_id AS "userId",
|
|
848
|
+
c.person_id AS "personId",
|
|
497
849
|
c.code,
|
|
498
|
-
c.display_name AS "displayName",
|
|
850
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
|
|
851
|
+
person_record.name AS "personName",
|
|
852
|
+
person_record.avatar_id AS "personAvatarId",
|
|
499
853
|
c.collaborator_type AS "collaboratorType",
|
|
500
|
-
c.
|
|
854
|
+
c.department_id AS "departmentId",
|
|
855
|
+
COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
|
|
501
856
|
c.title,
|
|
502
857
|
c.status,
|
|
503
858
|
COUNT(DISTINCT pa.id)::int AS "activeAssignments",
|
|
@@ -505,6 +860,11 @@ export class OperationsService {
|
|
|
505
860
|
COUNT(DISTINCT tor.id) FILTER (WHERE tor.status = 'submitted')::int AS "pendingTimeOffRequests",
|
|
506
861
|
COUNT(DISTINCT sar.id) FILTER (WHERE sar.status = 'submitted')::int AS "pendingScheduleAdjustmentRequests"
|
|
507
862
|
FROM operations_collaborator c
|
|
863
|
+
LEFT JOIN person person_record
|
|
864
|
+
ON person_record.id = c.person_id
|
|
865
|
+
LEFT JOIN operations_department department_record
|
|
866
|
+
ON department_record.id = c.department_id
|
|
867
|
+
AND department_record.deleted_at IS NULL
|
|
508
868
|
LEFT JOIN operations_project_assignment pa
|
|
509
869
|
ON pa.collaborator_id = c.id
|
|
510
870
|
AND pa.deleted_at IS NULL
|
|
@@ -522,8 +882,8 @@ export class OperationsService {
|
|
|
522
882
|
AND sar.deleted_at IS NULL
|
|
523
883
|
AND sar.status = 'submitted'
|
|
524
884
|
WHERE c.deleted_at IS NULL AND ${teamFilter.clause}
|
|
525
|
-
GROUP BY c.id
|
|
526
|
-
ORDER BY c.display_name ASC`,
|
|
885
|
+
GROUP BY c.id, person_record.id, department_record.id
|
|
886
|
+
ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
|
|
527
887
|
teamFilter.params
|
|
528
888
|
);
|
|
529
889
|
|
|
@@ -694,17 +1054,40 @@ export class OperationsService {
|
|
|
694
1054
|
async createCollaborator(userId: number, data: CollaboratorPayload) {
|
|
695
1055
|
const actor = await this.getActorContext(userId);
|
|
696
1056
|
this.ensureDirector(actor);
|
|
697
|
-
|
|
1057
|
+
|
|
1058
|
+
const resolvedPerson = data.personId
|
|
1059
|
+
? await this.getPersonById(Number(data.personId))
|
|
1060
|
+
: null;
|
|
1061
|
+
const resolvedDisplayName =
|
|
1062
|
+
resolvedPerson?.name?.trim() ?? data.displayName?.trim() ?? '';
|
|
1063
|
+
|
|
1064
|
+
if (!resolvedDisplayName) {
|
|
1065
|
+
throw new BadRequestException('Field "personId" is required.');
|
|
1066
|
+
}
|
|
698
1067
|
|
|
699
1068
|
const collaboratorId = await this.prisma.$transaction(async (tx) => {
|
|
1069
|
+
const normalizedCode =
|
|
1070
|
+
String(data.code ?? '').trim() ||
|
|
1071
|
+
(await this.generateCollaboratorCode(tx as any));
|
|
1072
|
+
|
|
1073
|
+
const resolvedDepartment = await this.resolveDepartmentReference(
|
|
1074
|
+
tx as any,
|
|
1075
|
+
{
|
|
1076
|
+
departmentId: data.departmentId ?? null,
|
|
1077
|
+
departmentName: data.department,
|
|
1078
|
+
}
|
|
1079
|
+
);
|
|
1080
|
+
|
|
700
1081
|
const created = (await (tx as any).$queryRawUnsafe(
|
|
701
1082
|
`INSERT INTO operations_collaborator (
|
|
702
1083
|
user_id,
|
|
1084
|
+
person_id,
|
|
703
1085
|
supervisor_collaborator_id,
|
|
704
1086
|
code,
|
|
705
1087
|
collaborator_type,
|
|
706
1088
|
display_name,
|
|
707
1089
|
department,
|
|
1090
|
+
department_id,
|
|
708
1091
|
title,
|
|
709
1092
|
level_label,
|
|
710
1093
|
weekly_capacity_hours,
|
|
@@ -715,16 +1098,21 @@ export class OperationsService {
|
|
|
715
1098
|
created_at,
|
|
716
1099
|
updated_at
|
|
717
1100
|
) VALUES (
|
|
718
|
-
$1, $2, $3,
|
|
719
|
-
|
|
1101
|
+
$1, $2, $3, $4,
|
|
1102
|
+
$5::operations_collaborator_collaborator_type_7dd7b0ada2_enum,
|
|
1103
|
+
$6, $7, $8, $9, $10, $11,
|
|
1104
|
+
$12::operations_collaborator_status_ef779877d4_enum,
|
|
1105
|
+
$13::date, $14::date, $15, NOW(), NOW()
|
|
720
1106
|
)
|
|
721
1107
|
RETURNING id`,
|
|
722
|
-
data.userId,
|
|
1108
|
+
data.userId ?? null,
|
|
1109
|
+
resolvedPerson?.id ?? null,
|
|
723
1110
|
data.supervisorCollaboratorId ?? null,
|
|
724
|
-
|
|
1111
|
+
normalizedCode,
|
|
725
1112
|
data.collaboratorType ?? 'other',
|
|
726
|
-
|
|
727
|
-
|
|
1113
|
+
resolvedDisplayName,
|
|
1114
|
+
resolvedDepartment?.name ?? null,
|
|
1115
|
+
resolvedDepartment?.id ?? null,
|
|
728
1116
|
data.title ?? null,
|
|
729
1117
|
data.levelLabel ?? null,
|
|
730
1118
|
data.weeklyCapacityHours ?? null,
|
|
@@ -744,8 +1132,8 @@ export class OperationsService {
|
|
|
744
1132
|
if (data.autoGenerateContractDraft !== false) {
|
|
745
1133
|
await this.createHiringContractDraft(tx as any, actor.userId, {
|
|
746
1134
|
collaboratorId: createdCollaboratorId,
|
|
747
|
-
collaboratorCode:
|
|
748
|
-
displayName:
|
|
1135
|
+
collaboratorCode: normalizedCode,
|
|
1136
|
+
displayName: resolvedDisplayName,
|
|
749
1137
|
collaboratorType: data.collaboratorType ?? 'other',
|
|
750
1138
|
supervisorCollaboratorId: data.supervisorCollaboratorId ?? null,
|
|
751
1139
|
startDate: data.joinedAt ?? null,
|
|
@@ -772,20 +1160,65 @@ export class OperationsService {
|
|
|
772
1160
|
|
|
773
1161
|
const updates: string[] = [];
|
|
774
1162
|
const params: unknown[] = [];
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1163
|
+
const resolvedPerson =
|
|
1164
|
+
data.personId !== undefined && data.personId !== null
|
|
1165
|
+
? await this.getPersonById(Number(data.personId))
|
|
1166
|
+
: null;
|
|
1167
|
+
const resolvedDisplayName =
|
|
1168
|
+
resolvedPerson?.name?.trim() ?? data.displayName?.trim() ?? undefined;
|
|
1169
|
+
|
|
1170
|
+
this.pushUpdate(updates, params, 'user_id', data.userId);
|
|
1171
|
+
this.pushUpdate(updates, params, 'person_id', data.personId);
|
|
1172
|
+
this.pushUpdate(
|
|
1173
|
+
updates,
|
|
1174
|
+
params,
|
|
1175
|
+
'supervisor_collaborator_id',
|
|
1176
|
+
data.supervisorCollaboratorId
|
|
1177
|
+
);
|
|
1178
|
+
this.pushUpdate(updates, params, 'code', data.code?.trim());
|
|
1179
|
+
this.pushUpdate(
|
|
1180
|
+
updates,
|
|
1181
|
+
params,
|
|
1182
|
+
'collaborator_type',
|
|
1183
|
+
data.collaboratorType,
|
|
1184
|
+
'operations_collaborator_collaborator_type_7dd7b0ada2_enum'
|
|
1185
|
+
);
|
|
1186
|
+
if (data.personId !== undefined || data.displayName !== undefined) {
|
|
1187
|
+
this.pushUpdate(updates, params, 'display_name', resolvedDisplayName ?? null);
|
|
1188
|
+
}
|
|
780
1189
|
this.pushUpdate(updates, params, 'title', data.title);
|
|
781
1190
|
this.pushUpdate(updates, params, 'level_label', data.levelLabel);
|
|
782
1191
|
this.pushUpdate(updates, params, 'weekly_capacity_hours', data.weeklyCapacityHours);
|
|
783
|
-
this.pushUpdate(
|
|
784
|
-
|
|
785
|
-
|
|
1192
|
+
this.pushUpdate(
|
|
1193
|
+
updates,
|
|
1194
|
+
params,
|
|
1195
|
+
'status',
|
|
1196
|
+
data.status,
|
|
1197
|
+
'operations_collaborator_status_ef779877d4_enum'
|
|
1198
|
+
);
|
|
1199
|
+
this.pushUpdate(updates, params, 'joined_at', data.joinedAt, 'date');
|
|
1200
|
+
this.pushUpdate(updates, params, 'left_at', data.leftAt, 'date');
|
|
786
1201
|
this.pushUpdate(updates, params, 'notes', data.notes);
|
|
787
1202
|
|
|
788
1203
|
await this.prisma.$transaction(async (tx) => {
|
|
1204
|
+
if (data.department !== undefined || data.departmentId !== undefined) {
|
|
1205
|
+
const resolvedDepartment = await this.resolveDepartmentReference(
|
|
1206
|
+
tx as any,
|
|
1207
|
+
{
|
|
1208
|
+
departmentId: data.departmentId ?? null,
|
|
1209
|
+
departmentName: data.department,
|
|
1210
|
+
}
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
this.pushUpdate(updates, params, 'department', resolvedDepartment?.name ?? null);
|
|
1214
|
+
this.pushUpdate(
|
|
1215
|
+
updates,
|
|
1216
|
+
params,
|
|
1217
|
+
'department_id',
|
|
1218
|
+
resolvedDepartment?.id ?? null
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
789
1222
|
if (updates.length) {
|
|
790
1223
|
params.push(collaboratorId);
|
|
791
1224
|
await (tx as any).$executeRawUnsafe(
|
|
@@ -809,6 +1242,152 @@ export class OperationsService {
|
|
|
809
1242
|
return this.getCollaboratorByIdForUser(userId, collaboratorId);
|
|
810
1243
|
}
|
|
811
1244
|
|
|
1245
|
+
async listDepartments(userId: number) {
|
|
1246
|
+
const actor = await this.getActorContext(userId);
|
|
1247
|
+
this.ensureCollaborator(actor);
|
|
1248
|
+
|
|
1249
|
+
return this.queryRows(
|
|
1250
|
+
`SELECT d.id,
|
|
1251
|
+
d.slug,
|
|
1252
|
+
d.code,
|
|
1253
|
+
d.name,
|
|
1254
|
+
d.description,
|
|
1255
|
+
CASE WHEN d.deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status,
|
|
1256
|
+
COUNT(DISTINCT c.id)::int AS "collaboratorCount",
|
|
1257
|
+
d.created_at AS "createdAt",
|
|
1258
|
+
d.updated_at AS "updatedAt"
|
|
1259
|
+
FROM operations_department d
|
|
1260
|
+
LEFT JOIN operations_collaborator c
|
|
1261
|
+
ON c.deleted_at IS NULL
|
|
1262
|
+
AND (
|
|
1263
|
+
c.department_id = d.id
|
|
1264
|
+
OR (
|
|
1265
|
+
c.department_id IS NULL
|
|
1266
|
+
AND LOWER(COALESCE(c.department, '')) = LOWER(d.name)
|
|
1267
|
+
)
|
|
1268
|
+
)
|
|
1269
|
+
GROUP BY d.id
|
|
1270
|
+
ORDER BY CASE WHEN d.deleted_at IS NULL THEN 0 ELSE 1 END ASC,
|
|
1271
|
+
d.name ASC`
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async createDepartment(userId: number, data: DepartmentPayload) {
|
|
1276
|
+
const actor = await this.getActorContext(userId);
|
|
1277
|
+
this.ensureDirector(actor);
|
|
1278
|
+
|
|
1279
|
+
const name = this.normalizeOptionalText(data.name);
|
|
1280
|
+
if (!name) {
|
|
1281
|
+
throw new BadRequestException('Department name is required.');
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return this.prisma.$transaction(async (tx) => {
|
|
1285
|
+
await this.assertDepartmentNameAvailable(tx as any, name);
|
|
1286
|
+
|
|
1287
|
+
const normalizedCode =
|
|
1288
|
+
this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
|
|
1289
|
+
|
|
1290
|
+
if (normalizedCode) {
|
|
1291
|
+
await this.assertDepartmentCodeAvailable(tx as any, normalizedCode);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const created = (await (tx as any).$queryRawUnsafe(
|
|
1295
|
+
`INSERT INTO operations_department (
|
|
1296
|
+
slug,
|
|
1297
|
+
code,
|
|
1298
|
+
name,
|
|
1299
|
+
description,
|
|
1300
|
+
deleted_at,
|
|
1301
|
+
created_at,
|
|
1302
|
+
updated_at
|
|
1303
|
+
) VALUES (
|
|
1304
|
+
$1, $2, $3, $4,
|
|
1305
|
+
CASE WHEN $5 = 'inactive' THEN NOW() ELSE NULL END,
|
|
1306
|
+
NOW(), NOW()
|
|
1307
|
+
)
|
|
1308
|
+
RETURNING id`,
|
|
1309
|
+
await this.generateUniqueDepartmentSlug(tx as any, name),
|
|
1310
|
+
normalizedCode,
|
|
1311
|
+
name,
|
|
1312
|
+
this.normalizeOptionalText(data.description),
|
|
1313
|
+
data.status ?? 'active'
|
|
1314
|
+
)) as { id: number }[];
|
|
1315
|
+
|
|
1316
|
+
const createdDepartmentId = created[0]?.id;
|
|
1317
|
+
if (!createdDepartmentId) {
|
|
1318
|
+
throw new BadRequestException('Unable to create the department.');
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return this.getDepartmentById(tx as any, createdDepartmentId, true);
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async updateDepartment(
|
|
1326
|
+
userId: number,
|
|
1327
|
+
departmentId: number,
|
|
1328
|
+
data: Partial<DepartmentPayload>
|
|
1329
|
+
) {
|
|
1330
|
+
const actor = await this.getActorContext(userId);
|
|
1331
|
+
this.ensureDirector(actor);
|
|
1332
|
+
|
|
1333
|
+
return this.prisma.$transaction(async (tx) => {
|
|
1334
|
+
const current = await this.getDepartmentById(tx as any, departmentId, true);
|
|
1335
|
+
const nextName =
|
|
1336
|
+
data.name !== undefined
|
|
1337
|
+
? this.normalizeOptionalText(data.name)
|
|
1338
|
+
: current.name;
|
|
1339
|
+
|
|
1340
|
+
if (!nextName) {
|
|
1341
|
+
throw new BadRequestException('Department name is required.');
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
|
|
1345
|
+
await this.assertDepartmentNameAvailable(tx as any, nextName, departmentId);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const nextCode =
|
|
1349
|
+
data.code !== undefined
|
|
1350
|
+
? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
|
|
1351
|
+
: (current.code ?? null);
|
|
1352
|
+
|
|
1353
|
+
if (nextCode) {
|
|
1354
|
+
await this.assertDepartmentCodeAvailable(tx as any, nextCode, departmentId);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const nextDescription =
|
|
1358
|
+
data.description !== undefined
|
|
1359
|
+
? this.normalizeOptionalText(data.description)
|
|
1360
|
+
: (current.description ?? null);
|
|
1361
|
+
const nextStatus = data.status ?? current.status ?? 'active';
|
|
1362
|
+
const nextSlug =
|
|
1363
|
+
String(nextName).toLowerCase() !== String(current.name).toLowerCase()
|
|
1364
|
+
? await this.generateUniqueDepartmentSlug(tx as any, nextName, departmentId)
|
|
1365
|
+
: current.slug;
|
|
1366
|
+
|
|
1367
|
+
await (tx as any).$executeRawUnsafe(
|
|
1368
|
+
`UPDATE operations_department
|
|
1369
|
+
SET slug = $1,
|
|
1370
|
+
code = $2,
|
|
1371
|
+
name = $3,
|
|
1372
|
+
description = $4,
|
|
1373
|
+
deleted_at = CASE
|
|
1374
|
+
WHEN $5 = 'inactive' THEN COALESCE(deleted_at, NOW())
|
|
1375
|
+
ELSE NULL
|
|
1376
|
+
END,
|
|
1377
|
+
updated_at = NOW()
|
|
1378
|
+
WHERE id = $6`,
|
|
1379
|
+
nextSlug,
|
|
1380
|
+
nextCode,
|
|
1381
|
+
nextName,
|
|
1382
|
+
nextDescription,
|
|
1383
|
+
nextStatus,
|
|
1384
|
+
departmentId
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
return this.getDepartmentById(tx as any, departmentId, true);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
812
1391
|
async listProjects(userId: number) {
|
|
813
1392
|
const actor = await this.getActorContext(userId);
|
|
814
1393
|
const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
|
|
@@ -887,8 +1466,11 @@ export class OperationsService {
|
|
|
887
1466
|
created_at,
|
|
888
1467
|
updated_at
|
|
889
1468
|
) VALUES (
|
|
890
|
-
$1, $2, $3, $4, $5, $6,
|
|
891
|
-
|
|
1469
|
+
$1, $2, $3, $4, $5, $6,
|
|
1470
|
+
$7::operations_project_status_965e8d4b2d_enum,
|
|
1471
|
+
$8,
|
|
1472
|
+
$9::operations_project_delivery_model_75ee11b3b7_enum,
|
|
1473
|
+
$10, $11::date, $12::date, NOW(), NOW()
|
|
892
1474
|
)
|
|
893
1475
|
RETURNING id`,
|
|
894
1476
|
data.contractId ?? null,
|
|
@@ -921,6 +1503,7 @@ export class OperationsService {
|
|
|
921
1503
|
actor.userId,
|
|
922
1504
|
{
|
|
923
1505
|
projectId,
|
|
1506
|
+
contractTemplateId: data.contractTemplateId ?? null,
|
|
924
1507
|
projectCode: data.code,
|
|
925
1508
|
projectName: data.name,
|
|
926
1509
|
clientName: data.clientName ?? data.name,
|
|
@@ -955,7 +1538,7 @@ export class OperationsService {
|
|
|
955
1538
|
async updateProject(userId: number, projectId: number, data: Partial<ProjectPayload>) {
|
|
956
1539
|
const actor = await this.getActorContext(userId);
|
|
957
1540
|
this.ensureDirector(actor);
|
|
958
|
-
await this.getProjectById(userId, projectId);
|
|
1541
|
+
const currentProject = await this.getProjectById(userId, projectId);
|
|
959
1542
|
|
|
960
1543
|
const updates: string[] = [];
|
|
961
1544
|
const params: unknown[] = [];
|
|
@@ -965,12 +1548,24 @@ export class OperationsService {
|
|
|
965
1548
|
this.pushUpdate(updates, params, 'name', data.name);
|
|
966
1549
|
this.pushUpdate(updates, params, 'client_name', data.clientName);
|
|
967
1550
|
this.pushUpdate(updates, params, 'summary', data.summary);
|
|
968
|
-
this.pushUpdate(
|
|
1551
|
+
this.pushUpdate(
|
|
1552
|
+
updates,
|
|
1553
|
+
params,
|
|
1554
|
+
'status',
|
|
1555
|
+
data.status,
|
|
1556
|
+
'operations_project_status_965e8d4b2d_enum'
|
|
1557
|
+
);
|
|
969
1558
|
this.pushUpdate(updates, params, 'progress_percent', data.progressPercent);
|
|
970
|
-
this.pushUpdate(
|
|
1559
|
+
this.pushUpdate(
|
|
1560
|
+
updates,
|
|
1561
|
+
params,
|
|
1562
|
+
'delivery_model',
|
|
1563
|
+
data.deliveryModel,
|
|
1564
|
+
'operations_project_delivery_model_75ee11b3b7_enum'
|
|
1565
|
+
);
|
|
971
1566
|
this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
|
|
972
|
-
this.pushUpdate(updates, params, 'start_date', data.startDate);
|
|
973
|
-
this.pushUpdate(updates, params, 'end_date', data.endDate);
|
|
1567
|
+
this.pushUpdate(updates, params, 'start_date', data.startDate, 'date');
|
|
1568
|
+
this.pushUpdate(updates, params, 'end_date', data.endDate, 'date');
|
|
974
1569
|
|
|
975
1570
|
await this.prisma.$transaction(async (tx) => {
|
|
976
1571
|
if (updates.length) {
|
|
@@ -991,102 +1586,374 @@ export class OperationsService {
|
|
|
991
1586
|
data.teamAssignments
|
|
992
1587
|
);
|
|
993
1588
|
}
|
|
1589
|
+
|
|
1590
|
+
const nextContractId =
|
|
1591
|
+
data.contractId !== undefined
|
|
1592
|
+
? data.contractId
|
|
1593
|
+
: (currentProject.contractId ?? null);
|
|
1594
|
+
const shouldGenerateDraft =
|
|
1595
|
+
!nextContractId && data.autoGenerateContractDraft === true;
|
|
1596
|
+
|
|
1597
|
+
if (shouldGenerateDraft) {
|
|
1598
|
+
const contractId = await this.createProjectContractDraft(
|
|
1599
|
+
tx as any,
|
|
1600
|
+
actor.userId,
|
|
1601
|
+
{
|
|
1602
|
+
projectId,
|
|
1603
|
+
contractTemplateId: data.contractTemplateId ?? null,
|
|
1604
|
+
projectCode: data.code ?? currentProject.code,
|
|
1605
|
+
projectName: data.name ?? currentProject.name,
|
|
1606
|
+
clientName:
|
|
1607
|
+
data.clientName ??
|
|
1608
|
+
currentProject.clientName ??
|
|
1609
|
+
currentProject.name,
|
|
1610
|
+
managerCollaboratorId:
|
|
1611
|
+
data.managerCollaboratorId ??
|
|
1612
|
+
currentProject.managerCollaboratorId ??
|
|
1613
|
+
null,
|
|
1614
|
+
startDate: data.startDate ?? currentProject.startDate ?? null,
|
|
1615
|
+
endDate: data.endDate ?? currentProject.endDate ?? null,
|
|
1616
|
+
budgetAmount: data.budgetAmount ?? currentProject.budgetAmount ?? null,
|
|
1617
|
+
monthlyHourCap:
|
|
1618
|
+
data.monthlyHourCap ??
|
|
1619
|
+
currentProject.relatedContract?.monthlyHourCap ??
|
|
1620
|
+
null,
|
|
1621
|
+
billingModel:
|
|
1622
|
+
data.billingModel ??
|
|
1623
|
+
currentProject.relatedContract?.billingModel ??
|
|
1624
|
+
'time_and_material',
|
|
1625
|
+
contractCode:
|
|
1626
|
+
data.contractCode ?? currentProject.relatedContract?.code ?? null,
|
|
1627
|
+
contractName:
|
|
1628
|
+
data.contractName ?? currentProject.relatedContract?.name ?? null,
|
|
1629
|
+
description:
|
|
1630
|
+
data.contractDescription ??
|
|
1631
|
+
currentProject.relatedContract?.description ??
|
|
1632
|
+
data.summary ??
|
|
1633
|
+
currentProject.summary ??
|
|
1634
|
+
null,
|
|
1635
|
+
}
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
await (tx as any).$executeRawUnsafe(
|
|
1639
|
+
`UPDATE operations_project
|
|
1640
|
+
SET contract_id = $1,
|
|
1641
|
+
updated_at = NOW()
|
|
1642
|
+
WHERE id = $2`,
|
|
1643
|
+
contractId,
|
|
1644
|
+
projectId
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
994
1647
|
});
|
|
995
1648
|
|
|
996
1649
|
return this.getProjectById(userId, projectId);
|
|
997
1650
|
}
|
|
998
1651
|
|
|
999
|
-
async
|
|
1652
|
+
async listContractTemplates(userId: number) {
|
|
1000
1653
|
const actor = await this.getActorContext(userId);
|
|
1001
|
-
|
|
1002
|
-
const accessClause = actor.isDirector
|
|
1003
|
-
? 'c.deleted_at IS NULL'
|
|
1004
|
-
: `c.deleted_at IS NULL AND (
|
|
1005
|
-
c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
|
|
1006
|
-
OR EXISTS (
|
|
1007
|
-
SELECT 1
|
|
1008
|
-
FROM operations_project p_access
|
|
1009
|
-
WHERE p_access.contract_id = c.id
|
|
1010
|
-
AND p_access.deleted_at IS NULL
|
|
1011
|
-
AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
|
|
1012
|
-
)
|
|
1013
|
-
)`;
|
|
1654
|
+
this.ensureDirector(actor);
|
|
1014
1655
|
|
|
1015
1656
|
return this.queryRows(
|
|
1016
|
-
`SELECT
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
c.
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1657
|
+
`SELECT t.id,
|
|
1658
|
+
t.slug,
|
|
1659
|
+
t.code,
|
|
1660
|
+
t.name,
|
|
1661
|
+
t.description,
|
|
1662
|
+
t.contract_category AS "contractCategory",
|
|
1663
|
+
t.contract_type AS "contractType",
|
|
1664
|
+
t.billing_model AS "billingModel",
|
|
1665
|
+
t.signature_status AS "signatureStatus",
|
|
1666
|
+
t.is_active AS "isActive",
|
|
1667
|
+
t.status,
|
|
1668
|
+
t.content_html AS "contentHtml",
|
|
1669
|
+
t.created_at AS "createdAt",
|
|
1670
|
+
t.updated_at AS "updatedAt",
|
|
1671
|
+
COUNT(DISTINCT c.id)::int AS "usageCount"
|
|
1672
|
+
FROM operations_contract_template t
|
|
1673
|
+
LEFT JOIN operations_contract c
|
|
1674
|
+
ON c.contract_template_id = t.id
|
|
1675
|
+
AND c.deleted_at IS NULL
|
|
1676
|
+
WHERE t.deleted_at IS NULL
|
|
1677
|
+
GROUP BY t.id
|
|
1678
|
+
ORDER BY CASE
|
|
1679
|
+
WHEN t.status = 'active' THEN 0
|
|
1680
|
+
WHEN t.status = 'draft' THEN 1
|
|
1681
|
+
WHEN t.status = 'inactive' THEN 2
|
|
1682
|
+
ELSE 3
|
|
1683
|
+
END,
|
|
1684
|
+
t.name ASC`
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async getContractTemplateById(userId: number, templateId: number) {
|
|
1689
|
+
const actor = await this.getActorContext(userId);
|
|
1690
|
+
this.ensureDirector(actor);
|
|
1691
|
+
|
|
1692
|
+
return this.getContractTemplateRecord(this.prisma as any, templateId);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async createContractTemplate(userId: number, data: ContractTemplatePayload) {
|
|
1696
|
+
const actor = await this.getActorContext(userId);
|
|
1697
|
+
this.ensureDirector(actor);
|
|
1698
|
+
|
|
1699
|
+
const name = this.normalizeOptionalText(data.name);
|
|
1700
|
+
if (!name) {
|
|
1701
|
+
throw new BadRequestException('Contract template name is required.');
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return this.prisma.$transaction(async (tx) => {
|
|
1705
|
+
await this.assertContractTemplateNameAvailable(tx as any, name);
|
|
1706
|
+
|
|
1707
|
+
const nextCode =
|
|
1708
|
+
this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
|
|
1709
|
+
|
|
1710
|
+
if (nextCode) {
|
|
1711
|
+
await this.assertContractTemplateCodeAvailable(tx as any, nextCode);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const nextStatus = data.status ?? 'active';
|
|
1715
|
+
const isActive =
|
|
1716
|
+
data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
|
|
1717
|
+
|
|
1718
|
+
const created = (await (tx as any).$queryRawUnsafe(
|
|
1719
|
+
`INSERT INTO operations_contract_template (
|
|
1720
|
+
slug,
|
|
1721
|
+
code,
|
|
1722
|
+
name,
|
|
1723
|
+
description,
|
|
1724
|
+
contract_category,
|
|
1725
|
+
contract_type,
|
|
1726
|
+
billing_model,
|
|
1727
|
+
signature_status,
|
|
1728
|
+
is_active,
|
|
1729
|
+
status,
|
|
1730
|
+
content_html,
|
|
1731
|
+
created_at,
|
|
1732
|
+
updated_at
|
|
1733
|
+
) VALUES (
|
|
1734
|
+
$1, $2, $3, $4,
|
|
1735
|
+
$5, $6, $7, $8, $9, $10, $11,
|
|
1736
|
+
NOW(), NOW()
|
|
1737
|
+
)
|
|
1738
|
+
RETURNING id`,
|
|
1739
|
+
await this.generateUniqueContractTemplateSlug(tx as any, name),
|
|
1740
|
+
nextCode,
|
|
1741
|
+
name,
|
|
1742
|
+
this.normalizeOptionalText(data.description),
|
|
1743
|
+
data.contractCategory ?? 'client',
|
|
1744
|
+
data.contractType ?? 'service_agreement',
|
|
1745
|
+
data.billingModel ?? 'time_and_material',
|
|
1746
|
+
data.signatureStatus ?? 'not_started',
|
|
1747
|
+
isActive,
|
|
1748
|
+
nextStatus,
|
|
1749
|
+
this.normalizeOptionalText(data.contentHtml)
|
|
1750
|
+
)) as { id: number }[];
|
|
1751
|
+
|
|
1752
|
+
const templateId = created[0]?.id;
|
|
1753
|
+
if (!templateId) {
|
|
1754
|
+
throw new BadRequestException('Unable to create the contract template.');
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
return this.getContractTemplateRecord(tx as any, templateId, true);
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async updateContractTemplate(
|
|
1762
|
+
userId: number,
|
|
1763
|
+
templateId: number,
|
|
1764
|
+
data: Partial<ContractTemplatePayload>
|
|
1765
|
+
) {
|
|
1766
|
+
const actor = await this.getActorContext(userId);
|
|
1767
|
+
this.ensureDirector(actor);
|
|
1768
|
+
|
|
1769
|
+
return this.prisma.$transaction(async (tx) => {
|
|
1770
|
+
const current = await this.getContractTemplateRecord(
|
|
1771
|
+
tx as any,
|
|
1772
|
+
templateId,
|
|
1773
|
+
true
|
|
1774
|
+
);
|
|
1775
|
+
|
|
1776
|
+
const nextName =
|
|
1777
|
+
data.name !== undefined
|
|
1778
|
+
? this.normalizeOptionalText(data.name)
|
|
1779
|
+
: current.name;
|
|
1780
|
+
|
|
1781
|
+
if (!nextName) {
|
|
1782
|
+
throw new BadRequestException('Contract template name is required.');
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
|
|
1786
|
+
await this.assertContractTemplateNameAvailable(
|
|
1787
|
+
tx as any,
|
|
1788
|
+
nextName,
|
|
1789
|
+
templateId
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
const nextCode =
|
|
1794
|
+
data.code !== undefined
|
|
1795
|
+
? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
|
|
1796
|
+
: (current.code ?? null);
|
|
1797
|
+
|
|
1798
|
+
if (nextCode) {
|
|
1799
|
+
await this.assertContractTemplateCodeAvailable(
|
|
1800
|
+
tx as any,
|
|
1801
|
+
nextCode,
|
|
1802
|
+
templateId
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const nextStatus = data.status ?? current.status ?? 'active';
|
|
1807
|
+
const nextIsActive =
|
|
1808
|
+
data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
|
|
1809
|
+
const nextSlug =
|
|
1810
|
+
String(nextName).toLowerCase() !== String(current.name).toLowerCase()
|
|
1811
|
+
? await this.generateUniqueContractTemplateSlug(
|
|
1812
|
+
tx as any,
|
|
1813
|
+
nextName,
|
|
1814
|
+
templateId
|
|
1815
|
+
)
|
|
1816
|
+
: current.slug;
|
|
1817
|
+
|
|
1818
|
+
await (tx as any).$executeRawUnsafe(
|
|
1819
|
+
`UPDATE operations_contract_template
|
|
1820
|
+
SET slug = $1,
|
|
1821
|
+
code = $2,
|
|
1822
|
+
name = $3,
|
|
1823
|
+
description = $4,
|
|
1824
|
+
contract_category = $5,
|
|
1825
|
+
contract_type = $6,
|
|
1826
|
+
billing_model = $7,
|
|
1827
|
+
signature_status = $8,
|
|
1828
|
+
is_active = $9,
|
|
1829
|
+
status = $10,
|
|
1830
|
+
content_html = $11,
|
|
1831
|
+
updated_at = NOW()
|
|
1832
|
+
WHERE id = $12`,
|
|
1833
|
+
nextSlug,
|
|
1834
|
+
nextCode,
|
|
1835
|
+
nextName,
|
|
1836
|
+
data.description !== undefined
|
|
1837
|
+
? this.normalizeOptionalText(data.description)
|
|
1838
|
+
: (current.description ?? null),
|
|
1839
|
+
data.contractCategory ?? current.contractCategory ?? 'client',
|
|
1840
|
+
data.contractType ?? current.contractType ?? 'service_agreement',
|
|
1841
|
+
data.billingModel ?? current.billingModel ?? 'time_and_material',
|
|
1842
|
+
data.signatureStatus ?? current.signatureStatus ?? 'not_started',
|
|
1843
|
+
nextIsActive,
|
|
1844
|
+
nextStatus,
|
|
1845
|
+
data.contentHtml !== undefined
|
|
1846
|
+
? this.normalizeOptionalText(data.contentHtml)
|
|
1847
|
+
: (current.contentHtml ?? null),
|
|
1848
|
+
templateId
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
return this.getContractTemplateRecord(tx as any, templateId, true);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async listContracts(userId: number) {
|
|
1856
|
+
const actor = await this.getActorContext(userId);
|
|
1857
|
+
const params: unknown[] = [];
|
|
1858
|
+
const accessClause = actor.isDirector
|
|
1859
|
+
? 'c.deleted_at IS NULL'
|
|
1860
|
+
: `c.deleted_at IS NULL AND (
|
|
1861
|
+
c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
|
|
1862
|
+
OR EXISTS (
|
|
1863
|
+
SELECT 1
|
|
1864
|
+
FROM operations_project p_access
|
|
1865
|
+
WHERE p_access.contract_id = c.id
|
|
1866
|
+
AND p_access.deleted_at IS NULL
|
|
1867
|
+
AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
|
|
1868
|
+
)
|
|
1869
|
+
)`;
|
|
1870
|
+
|
|
1871
|
+
return this.queryRows(
|
|
1872
|
+
`SELECT c.id,
|
|
1873
|
+
c.code,
|
|
1874
|
+
c.name,
|
|
1875
|
+
c.contract_category AS "contractCategory",
|
|
1876
|
+
c.contract_type AS "contractType",
|
|
1877
|
+
c.client_name AS "clientName",
|
|
1878
|
+
c.signature_status AS "signatureStatus",
|
|
1879
|
+
c.is_active AS "isActive",
|
|
1880
|
+
c.billing_model AS "billingModel",
|
|
1881
|
+
c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
|
|
1882
|
+
c.related_collaborator_id AS "relatedCollaboratorId",
|
|
1883
|
+
c.contract_template_id AS "contractTemplateId",
|
|
1884
|
+
template.name AS "contractTemplateName",
|
|
1885
|
+
template.slug AS "contractTemplateSlug",
|
|
1886
|
+
template.code AS "contractTemplateCode",
|
|
1887
|
+
c.origin_type AS "originType",
|
|
1888
|
+
c.origin_id AS "originId",
|
|
1889
|
+
c.start_date AS "startDate",
|
|
1890
|
+
c.end_date AS "endDate",
|
|
1891
|
+
c.signed_at AS "signedAt",
|
|
1892
|
+
c.effective_date AS "effectiveDate",
|
|
1893
|
+
c.budget_amount AS "budgetAmount",
|
|
1894
|
+
c.monthly_hour_cap AS "monthlyHourCap",
|
|
1895
|
+
c.status,
|
|
1896
|
+
c.creation_mode AS "creationMode",
|
|
1897
|
+
c.wizard_step AS "wizardStep",
|
|
1898
|
+
c.description,
|
|
1899
|
+
m.display_name AS "accountManagerName",
|
|
1900
|
+
linked.display_name AS "relatedCollaboratorName",
|
|
1901
|
+
MAX(COALESCE(primary_party.display_name, linked.display_name, c.client_name)) AS "mainRelatedPartyName",
|
|
1902
|
+
MAX(COALESCE(financials.value_amount, 0)) AS "valueAmount",
|
|
1903
|
+
MAX(COALESCE(financials.payment_amount, 0)) AS "paymentAmount",
|
|
1904
|
+
MAX(COALESCE(financials.revenue_amount, 0)) AS "revenueAmount",
|
|
1905
|
+
MAX(COALESCE(financials.fine_amount, 0)) AS "fineAmount",
|
|
1906
|
+
MAX(COALESCE(pdf_document.file_name, '')) AS "currentPdfFileName",
|
|
1907
|
+
COUNT(DISTINCT p.id)::int AS "projectCount"
|
|
1908
|
+
FROM operations_contract c
|
|
1909
|
+
LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
|
|
1910
|
+
LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
|
|
1911
|
+
LEFT JOIN operations_contract_template template
|
|
1912
|
+
ON template.id = c.contract_template_id
|
|
1913
|
+
LEFT JOIN LATERAL (
|
|
1914
|
+
SELECT cp.display_name
|
|
1915
|
+
FROM operations_contract_party cp
|
|
1916
|
+
WHERE cp.contract_id = c.id
|
|
1917
|
+
AND cp.deleted_at IS NULL
|
|
1918
|
+
ORDER BY cp.is_primary DESC, cp.id ASC
|
|
1919
|
+
LIMIT 1
|
|
1920
|
+
) primary_party ON TRUE
|
|
1921
|
+
LEFT JOIN LATERAL (
|
|
1922
|
+
SELECT
|
|
1923
|
+
SUM(CASE WHEN term_type = 'value' THEN amount ELSE 0 END) AS value_amount,
|
|
1924
|
+
SUM(CASE WHEN term_type = 'payment' THEN amount ELSE 0 END) AS payment_amount,
|
|
1925
|
+
SUM(CASE WHEN term_type = 'revenue' THEN amount ELSE 0 END) AS revenue_amount,
|
|
1926
|
+
SUM(CASE WHEN term_type = 'fine' THEN amount ELSE 0 END) AS fine_amount
|
|
1927
|
+
FROM operations_contract_financial_term ft
|
|
1928
|
+
WHERE ft.contract_id = c.id
|
|
1929
|
+
AND ft.deleted_at IS NULL
|
|
1930
|
+
) financials ON TRUE
|
|
1931
|
+
LEFT JOIN LATERAL (
|
|
1932
|
+
SELECT cd.file_name
|
|
1933
|
+
FROM operations_contract_document cd
|
|
1934
|
+
WHERE cd.contract_id = c.id
|
|
1935
|
+
AND cd.deleted_at IS NULL
|
|
1936
|
+
AND cd.is_current = true
|
|
1937
|
+
AND cd.document_type IN ('source_upload', 'generated_pdf')
|
|
1938
|
+
ORDER BY cd.id DESC
|
|
1939
|
+
LIMIT 1
|
|
1940
|
+
) pdf_document ON TRUE
|
|
1941
|
+
LEFT JOIN operations_project p
|
|
1078
1942
|
ON p.contract_id = c.id
|
|
1079
1943
|
AND p.deleted_at IS NULL
|
|
1080
1944
|
WHERE ${accessClause}
|
|
1081
|
-
GROUP BY c.id, m.id, linked.id
|
|
1082
|
-
ORDER BY c.name ASC`,
|
|
1945
|
+
GROUP BY c.id, m.id, linked.id, template.id
|
|
1946
|
+
ORDER BY COALESCE(c.name, c.code, CONCAT('draft-', c.id)) ASC`,
|
|
1083
1947
|
params
|
|
1084
1948
|
);
|
|
1085
1949
|
}
|
|
1086
1950
|
|
|
1087
|
-
async getContractById(
|
|
1951
|
+
async getContractById(
|
|
1952
|
+
userId: number,
|
|
1953
|
+
contractId: number
|
|
1954
|
+
): Promise<ContractDetailsRecord> {
|
|
1088
1955
|
const actor = await this.getActorContext(userId);
|
|
1089
|
-
const contract = await this.querySingle(
|
|
1956
|
+
const contract = await this.querySingle<ContractDetailRecord>(
|
|
1090
1957
|
`SELECT c.id,
|
|
1091
1958
|
c.code,
|
|
1092
1959
|
c.name,
|
|
@@ -1098,6 +1965,10 @@ export class OperationsService {
|
|
|
1098
1965
|
c.billing_model AS "billingModel",
|
|
1099
1966
|
c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
|
|
1100
1967
|
c.related_collaborator_id AS "relatedCollaboratorId",
|
|
1968
|
+
c.contract_template_id AS "contractTemplateId",
|
|
1969
|
+
template.name AS "contractTemplateName",
|
|
1970
|
+
template.slug AS "contractTemplateSlug",
|
|
1971
|
+
template.code AS "contractTemplateCode",
|
|
1101
1972
|
c.origin_type AS "originType",
|
|
1102
1973
|
c.origin_id AS "originId",
|
|
1103
1974
|
c.start_date AS "startDate",
|
|
@@ -1107,6 +1978,8 @@ export class OperationsService {
|
|
|
1107
1978
|
c.budget_amount AS "budgetAmount",
|
|
1108
1979
|
c.monthly_hour_cap AS "monthlyHourCap",
|
|
1109
1980
|
c.status,
|
|
1981
|
+
c.creation_mode AS "creationMode",
|
|
1982
|
+
c.wizard_step AS "wizardStep",
|
|
1110
1983
|
c.description,
|
|
1111
1984
|
c.content_html AS "contentHtml",
|
|
1112
1985
|
m.display_name AS "accountManagerName",
|
|
@@ -1114,6 +1987,8 @@ export class OperationsService {
|
|
|
1114
1987
|
FROM operations_contract c
|
|
1115
1988
|
LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
|
|
1116
1989
|
LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
|
|
1990
|
+
LEFT JOIN operations_contract_template template
|
|
1991
|
+
ON template.id = c.contract_template_id
|
|
1117
1992
|
WHERE c.id = $1
|
|
1118
1993
|
AND c.deleted_at IS NULL`,
|
|
1119
1994
|
[contractId]
|
|
@@ -1149,7 +2024,7 @@ export class OperationsService {
|
|
|
1149
2024
|
|
|
1150
2025
|
const [projects, scheduleSummary, parties, signatures, financialTerms, documents, revisions, history] =
|
|
1151
2026
|
await Promise.all([
|
|
1152
|
-
this.queryRows(
|
|
2027
|
+
this.queryRows<ContractProjectSummary>(
|
|
1153
2028
|
`SELECT id, code, name, status
|
|
1154
2029
|
FROM operations_project
|
|
1155
2030
|
WHERE contract_id = $1
|
|
@@ -1158,7 +2033,7 @@ export class OperationsService {
|
|
|
1158
2033
|
[contractId]
|
|
1159
2034
|
),
|
|
1160
2035
|
contract.relatedCollaboratorId
|
|
1161
|
-
? this.queryRows(
|
|
2036
|
+
? this.queryRows<ContractScheduleDay>(
|
|
1162
2037
|
`SELECT weekday,
|
|
1163
2038
|
is_working_day AS "isWorkingDay",
|
|
1164
2039
|
start_time AS "startTime",
|
|
@@ -1170,8 +2045,8 @@ export class OperationsService {
|
|
|
1170
2045
|
ORDER BY id ASC`,
|
|
1171
2046
|
[contract.relatedCollaboratorId]
|
|
1172
2047
|
)
|
|
1173
|
-
: Promise.resolve([]),
|
|
1174
|
-
this.queryRows(
|
|
2048
|
+
: Promise.resolve<ContractScheduleDay[]>([]),
|
|
2049
|
+
this.queryRows<NonNullable<ContractPayload['parties']>[number]>(
|
|
1175
2050
|
`SELECT id,
|
|
1176
2051
|
party_role AS "partyRole",
|
|
1177
2052
|
party_type AS "partyType",
|
|
@@ -1186,7 +2061,7 @@ export class OperationsService {
|
|
|
1186
2061
|
ORDER BY is_primary DESC, id ASC`,
|
|
1187
2062
|
[contractId]
|
|
1188
2063
|
),
|
|
1189
|
-
this.queryRows(
|
|
2064
|
+
this.queryRows<NonNullable<ContractPayload['signatures']>[number]>(
|
|
1190
2065
|
`SELECT id,
|
|
1191
2066
|
signer_name AS "signerName",
|
|
1192
2067
|
signer_role AS "signerRole",
|
|
@@ -1199,7 +2074,7 @@ export class OperationsService {
|
|
|
1199
2074
|
ORDER BY id ASC`,
|
|
1200
2075
|
[contractId]
|
|
1201
2076
|
),
|
|
1202
|
-
this.queryRows(
|
|
2077
|
+
this.queryRows<NonNullable<ContractPayload['financialTerms']>[number]>(
|
|
1203
2078
|
`SELECT id,
|
|
1204
2079
|
term_type AS "termType",
|
|
1205
2080
|
label,
|
|
@@ -1213,13 +2088,16 @@ export class OperationsService {
|
|
|
1213
2088
|
ORDER BY id ASC`,
|
|
1214
2089
|
[contractId]
|
|
1215
2090
|
),
|
|
1216
|
-
this.queryRows(
|
|
2091
|
+
this.queryRows<ContractDocumentRecord>(
|
|
1217
2092
|
`SELECT id,
|
|
1218
2093
|
document_type AS "documentType",
|
|
2094
|
+
file_id AS "fileId",
|
|
1219
2095
|
file_name AS "fileName",
|
|
1220
2096
|
mime_type AS "mimeType",
|
|
1221
2097
|
file_content_base64 AS "fileContentBase64",
|
|
1222
2098
|
is_current AS "isCurrent",
|
|
2099
|
+
extraction_status AS "extractionStatus",
|
|
2100
|
+
extraction_summary AS "extractionSummary",
|
|
1223
2101
|
notes,
|
|
1224
2102
|
created_at AS "createdAt"
|
|
1225
2103
|
FROM operations_contract_document
|
|
@@ -1228,7 +2106,7 @@ export class OperationsService {
|
|
|
1228
2106
|
ORDER BY is_current DESC, id DESC`,
|
|
1229
2107
|
[contractId]
|
|
1230
2108
|
),
|
|
1231
|
-
this.queryRows(
|
|
2109
|
+
this.queryRows<NonNullable<ContractPayload['revisions']>[number]>(
|
|
1232
2110
|
`SELECT id,
|
|
1233
2111
|
revision_type AS "revisionType",
|
|
1234
2112
|
title,
|
|
@@ -1241,7 +2119,7 @@ export class OperationsService {
|
|
|
1241
2119
|
ORDER BY effective_date DESC NULLS LAST, id DESC`,
|
|
1242
2120
|
[contractId]
|
|
1243
2121
|
),
|
|
1244
|
-
this.queryRows(
|
|
2122
|
+
this.queryRows<ContractHistoryRecord>(
|
|
1245
2123
|
`SELECT id,
|
|
1246
2124
|
actor_user_id AS "actorUserId",
|
|
1247
2125
|
action,
|
|
@@ -1275,9 +2153,11 @@ export class OperationsService {
|
|
|
1275
2153
|
async createContract(userId: number, data: ContractPayload) {
|
|
1276
2154
|
const actor = await this.getActorContext(userId);
|
|
1277
2155
|
this.ensureDirector(actor);
|
|
1278
|
-
this.requireFields(data, ['code', 'name', 'clientName', 'startDate']);
|
|
1279
2156
|
|
|
1280
2157
|
const createdId = await this.prisma.$transaction(async (tx) => {
|
|
2158
|
+
const normalizedCode =
|
|
2159
|
+
this.normalizeOptionalText(data.code)?.toUpperCase() ??
|
|
2160
|
+
(await this.generateContractCode(tx as any));
|
|
1281
2161
|
const created = await (tx as any).$queryRawUnsafe(
|
|
1282
2162
|
`INSERT INTO operations_contract (
|
|
1283
2163
|
code,
|
|
@@ -1290,6 +2170,7 @@ export class OperationsService {
|
|
|
1290
2170
|
billing_model,
|
|
1291
2171
|
account_manager_collaborator_id,
|
|
1292
2172
|
related_collaborator_id,
|
|
2173
|
+
contract_template_id,
|
|
1293
2174
|
origin_type,
|
|
1294
2175
|
origin_id,
|
|
1295
2176
|
start_date,
|
|
@@ -1299,96 +2180,1103 @@ export class OperationsService {
|
|
|
1299
2180
|
budget_amount,
|
|
1300
2181
|
monthly_hour_cap,
|
|
1301
2182
|
status,
|
|
2183
|
+
creation_mode,
|
|
2184
|
+
wizard_step,
|
|
1302
2185
|
description,
|
|
1303
2186
|
content_html,
|
|
1304
2187
|
created_at,
|
|
1305
2188
|
updated_at
|
|
1306
2189
|
) VALUES (
|
|
1307
|
-
$1, $2,
|
|
1308
|
-
|
|
1309
|
-
$
|
|
2190
|
+
$1, $2,
|
|
2191
|
+
$3::operations_contract_contract_category_70d553ea09_enum,
|
|
2192
|
+
$4::operations_contract_contract_type_48331e2ebf_enum,
|
|
2193
|
+
$5,
|
|
2194
|
+
$6::operations_contract_signature_status_2cb7282a7b_enum,
|
|
2195
|
+
$7,
|
|
2196
|
+
$8::operations_contract_billing_model_409dc7fea2_enum,
|
|
2197
|
+
$9,
|
|
2198
|
+
$10,
|
|
2199
|
+
$11,
|
|
2200
|
+
$12::operations_contract_origin_type_07a7cc2b5d_enum,
|
|
2201
|
+
$13,
|
|
2202
|
+
$14::date,
|
|
2203
|
+
$15::date, $16::date, $17::date, $18, $19,
|
|
2204
|
+
$20::operations_contract_status_a0395962df_enum,
|
|
2205
|
+
$21::operations_contract_creation_mode_98ba669209_enum,
|
|
2206
|
+
$22,
|
|
2207
|
+
$23,
|
|
2208
|
+
$24,
|
|
2209
|
+
NOW(), NOW()
|
|
1310
2210
|
)
|
|
1311
2211
|
RETURNING id`,
|
|
1312
|
-
|
|
1313
|
-
data.name,
|
|
2212
|
+
normalizedCode,
|
|
2213
|
+
this.normalizeOptionalText(data.name),
|
|
1314
2214
|
data.contractCategory ?? 'client',
|
|
1315
2215
|
data.contractType ?? 'service_agreement',
|
|
1316
|
-
data.clientName,
|
|
2216
|
+
this.normalizeOptionalText(data.clientName),
|
|
1317
2217
|
data.signatureStatus ?? 'not_started',
|
|
1318
2218
|
data.isActive ?? true,
|
|
1319
2219
|
data.billingModel ?? 'time_and_material',
|
|
1320
2220
|
data.accountManagerCollaboratorId ?? null,
|
|
1321
2221
|
data.relatedCollaboratorId ?? null,
|
|
2222
|
+
data.contractTemplateId ?? null,
|
|
1322
2223
|
data.originType ?? 'manual',
|
|
1323
2224
|
data.originId ?? null,
|
|
1324
|
-
data.startDate,
|
|
2225
|
+
this.normalizeOptionalText(data.startDate ?? null),
|
|
1325
2226
|
data.endDate ?? null,
|
|
1326
2227
|
data.signedAt ?? null,
|
|
1327
|
-
data.effectiveDate ?? data.startDate,
|
|
2228
|
+
data.effectiveDate ?? data.startDate ?? null,
|
|
1328
2229
|
data.budgetAmount ?? null,
|
|
1329
2230
|
data.monthlyHourCap ?? null,
|
|
1330
2231
|
data.status ?? 'draft',
|
|
2232
|
+
data.creationMode ?? 'blank',
|
|
2233
|
+
data.wizardStep ?? 0,
|
|
1331
2234
|
data.description ?? null,
|
|
1332
2235
|
data.contentHtml ?? null
|
|
1333
2236
|
);
|
|
1334
2237
|
|
|
1335
2238
|
const contractId = (created as { id: number }[])[0]?.id;
|
|
1336
|
-
await this.replaceContractParties(tx as any, contractId, data.parties);
|
|
1337
|
-
await this.replaceContractSignatures(tx as any, contractId, data.signatures);
|
|
2239
|
+
await this.replaceContractParties(tx as any, contractId, data.parties);
|
|
2240
|
+
await this.replaceContractSignatures(tx as any, contractId, data.signatures);
|
|
2241
|
+
await this.replaceContractFinancialTerms(
|
|
2242
|
+
tx as any,
|
|
2243
|
+
contractId,
|
|
2244
|
+
data.financialTerms
|
|
2245
|
+
);
|
|
2246
|
+
await this.replaceContractRevisions(tx as any, contractId, data.revisions);
|
|
2247
|
+
if (data.replaceUploadedPdfDocument) {
|
|
2248
|
+
await this.replaceContractDocument(
|
|
2249
|
+
tx as any,
|
|
2250
|
+
contractId,
|
|
2251
|
+
'source_upload',
|
|
2252
|
+
data.replaceUploadedPdfDocument
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
await this.insertContractHistory(
|
|
2256
|
+
tx as any,
|
|
2257
|
+
contractId,
|
|
2258
|
+
userId,
|
|
2259
|
+
'created',
|
|
2260
|
+
data.originType === 'manual'
|
|
2261
|
+
? 'Manual contract created from registry.'
|
|
2262
|
+
: `Contract registered from origin ${data.originType}.`
|
|
2263
|
+
);
|
|
2264
|
+
return contractId;
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
return this.getContractById(userId, createdId);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
async createContractDraft(userId: number, data: ContractDraftPayload) {
|
|
2271
|
+
const actor = await this.getActorContext(userId);
|
|
2272
|
+
this.ensureDirector(actor);
|
|
2273
|
+
|
|
2274
|
+
const creationMode = this.normalizeEnumValue(
|
|
2275
|
+
data.creationMode,
|
|
2276
|
+
CONTRACT_CREATION_MODE_VALUES,
|
|
2277
|
+
'blank'
|
|
2278
|
+
);
|
|
2279
|
+
const selectedTemplate =
|
|
2280
|
+
data.templateId && data.templateId > 0
|
|
2281
|
+
? await this.getContractTemplateById(userId, data.templateId)
|
|
2282
|
+
: null;
|
|
2283
|
+
const duplicateSource =
|
|
2284
|
+
data.duplicateFromId && data.duplicateFromId > 0
|
|
2285
|
+
? await this.getContractById(userId, data.duplicateFromId)
|
|
2286
|
+
: null;
|
|
2287
|
+
const storedSourceFile =
|
|
2288
|
+
data.sourceFileId && data.sourceFileId > 0
|
|
2289
|
+
? await this.prisma.file.findUnique({
|
|
2290
|
+
where: { id: data.sourceFileId },
|
|
2291
|
+
include: { file_mimetype: true },
|
|
2292
|
+
})
|
|
2293
|
+
: null;
|
|
2294
|
+
|
|
2295
|
+
if (data.sourceFileId && !storedSourceFile) {
|
|
2296
|
+
throw new NotFoundException('Source contract file not found.');
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
const createdId = await this.prisma.$transaction(async (tx) => {
|
|
2300
|
+
const generatedCode = await this.generateContractCode(tx as any);
|
|
2301
|
+
const duplicateNameBase =
|
|
2302
|
+
this.normalizeOptionalText(duplicateSource?.name) ??
|
|
2303
|
+
this.normalizeOptionalText(duplicateSource?.code) ??
|
|
2304
|
+
'Contract';
|
|
2305
|
+
const created = await (tx as any).$queryRawUnsafe(
|
|
2306
|
+
`INSERT INTO operations_contract (
|
|
2307
|
+
code,
|
|
2308
|
+
name,
|
|
2309
|
+
contract_category,
|
|
2310
|
+
contract_type,
|
|
2311
|
+
client_name,
|
|
2312
|
+
signature_status,
|
|
2313
|
+
is_active,
|
|
2314
|
+
billing_model,
|
|
2315
|
+
account_manager_collaborator_id,
|
|
2316
|
+
related_collaborator_id,
|
|
2317
|
+
contract_template_id,
|
|
2318
|
+
origin_type,
|
|
2319
|
+
origin_id,
|
|
2320
|
+
start_date,
|
|
2321
|
+
end_date,
|
|
2322
|
+
signed_at,
|
|
2323
|
+
effective_date,
|
|
2324
|
+
budget_amount,
|
|
2325
|
+
monthly_hour_cap,
|
|
2326
|
+
status,
|
|
2327
|
+
creation_mode,
|
|
2328
|
+
wizard_step,
|
|
2329
|
+
description,
|
|
2330
|
+
content_html,
|
|
2331
|
+
created_by_user_id,
|
|
2332
|
+
updated_by_user_id,
|
|
2333
|
+
created_at,
|
|
2334
|
+
updated_at
|
|
2335
|
+
) VALUES (
|
|
2336
|
+
$1,
|
|
2337
|
+
$2,
|
|
2338
|
+
$3::operations_contract_contract_category_70d553ea09_enum,
|
|
2339
|
+
$4::operations_contract_contract_type_48331e2ebf_enum,
|
|
2340
|
+
$5,
|
|
2341
|
+
$6::operations_contract_signature_status_2cb7282a7b_enum,
|
|
2342
|
+
$7,
|
|
2343
|
+
$8::operations_contract_billing_model_409dc7fea2_enum,
|
|
2344
|
+
$9,
|
|
2345
|
+
$10,
|
|
2346
|
+
$11,
|
|
2347
|
+
$12::operations_contract_origin_type_07a7cc2b5d_enum,
|
|
2348
|
+
$13,
|
|
2349
|
+
$14::date,
|
|
2350
|
+
$15::date,
|
|
2351
|
+
$16::date,
|
|
2352
|
+
$17::date,
|
|
2353
|
+
$18,
|
|
2354
|
+
$19,
|
|
2355
|
+
$20::operations_contract_status_a0395962df_enum,
|
|
2356
|
+
$21::operations_contract_creation_mode_98ba669209_enum,
|
|
2357
|
+
$22,
|
|
2358
|
+
$23,
|
|
2359
|
+
$24,
|
|
2360
|
+
$25,
|
|
2361
|
+
$25,
|
|
2362
|
+
NOW(),
|
|
2363
|
+
NOW()
|
|
2364
|
+
)
|
|
2365
|
+
RETURNING id`,
|
|
2366
|
+
generatedCode,
|
|
2367
|
+
creationMode === 'duplicate'
|
|
2368
|
+
? `${duplicateNameBase} Copy`
|
|
2369
|
+
: this.normalizeOptionalText(selectedTemplate?.name),
|
|
2370
|
+
duplicateSource?.contractCategory ??
|
|
2371
|
+
selectedTemplate?.contractCategory ??
|
|
2372
|
+
'client',
|
|
2373
|
+
duplicateSource?.contractType ??
|
|
2374
|
+
selectedTemplate?.contractType ??
|
|
2375
|
+
'service_agreement',
|
|
2376
|
+
this.normalizeOptionalText(duplicateSource?.clientName),
|
|
2377
|
+
duplicateSource?.signatureStatus ??
|
|
2378
|
+
selectedTemplate?.signatureStatus ??
|
|
2379
|
+
'not_started',
|
|
2380
|
+
duplicateSource?.isActive ?? true,
|
|
2381
|
+
duplicateSource?.billingModel ??
|
|
2382
|
+
selectedTemplate?.billingModel ??
|
|
2383
|
+
'time_and_material',
|
|
2384
|
+
duplicateSource?.accountManagerCollaboratorId ?? null,
|
|
2385
|
+
duplicateSource?.relatedCollaboratorId ?? null,
|
|
2386
|
+
duplicateSource?.contractTemplateId ?? selectedTemplate?.id ?? null,
|
|
2387
|
+
duplicateSource?.originType ?? 'manual',
|
|
2388
|
+
duplicateSource?.originId ?? null,
|
|
2389
|
+
duplicateSource?.startDate ?? null,
|
|
2390
|
+
duplicateSource?.endDate ?? null,
|
|
2391
|
+
duplicateSource?.signedAt ?? null,
|
|
2392
|
+
duplicateSource?.effectiveDate ?? null,
|
|
2393
|
+
duplicateSource?.budgetAmount ?? null,
|
|
2394
|
+
duplicateSource?.monthlyHourCap ?? null,
|
|
2395
|
+
'draft',
|
|
2396
|
+
creationMode,
|
|
2397
|
+
creationMode === 'upload' ? 0 : creationMode === 'blank' ? 0 : 1,
|
|
2398
|
+
duplicateSource?.description ?? selectedTemplate?.description ?? null,
|
|
2399
|
+
duplicateSource?.contentHtml ?? selectedTemplate?.contentHtml ?? null,
|
|
2400
|
+
userId
|
|
2401
|
+
);
|
|
2402
|
+
|
|
2403
|
+
const contractId = (created as { id: number }[])[0]?.id;
|
|
2404
|
+
|
|
2405
|
+
if (duplicateSource) {
|
|
2406
|
+
await this.replaceContractParties(
|
|
2407
|
+
tx as any,
|
|
2408
|
+
contractId,
|
|
2409
|
+
duplicateSource.parties
|
|
2410
|
+
);
|
|
2411
|
+
await this.replaceContractSignatures(
|
|
2412
|
+
tx as any,
|
|
2413
|
+
contractId,
|
|
2414
|
+
duplicateSource.signatures
|
|
2415
|
+
);
|
|
2416
|
+
await this.replaceContractFinancialTerms(
|
|
2417
|
+
tx as any,
|
|
2418
|
+
contractId,
|
|
2419
|
+
duplicateSource.financialTerms
|
|
2420
|
+
);
|
|
2421
|
+
await this.replaceContractRevisions(
|
|
2422
|
+
tx as any,
|
|
2423
|
+
contractId,
|
|
2424
|
+
duplicateSource.revisions
|
|
2425
|
+
);
|
|
2426
|
+
|
|
2427
|
+
const currentSourceDocument =
|
|
2428
|
+
duplicateSource.documents.find(
|
|
2429
|
+
(document) => document.isCurrent && document.documentType === 'source_upload'
|
|
2430
|
+
) ?? null;
|
|
2431
|
+
|
|
2432
|
+
if (currentSourceDocument) {
|
|
2433
|
+
await this.replaceContractDocument(
|
|
2434
|
+
tx as any,
|
|
2435
|
+
contractId,
|
|
2436
|
+
'source_upload',
|
|
2437
|
+
{
|
|
2438
|
+
fileId: currentSourceDocument.fileId ?? null,
|
|
2439
|
+
fileName: currentSourceDocument.fileName,
|
|
2440
|
+
mimeType: currentSourceDocument.mimeType,
|
|
2441
|
+
fileContentBase64: currentSourceDocument.fileContentBase64 ?? null,
|
|
2442
|
+
notes: currentSourceDocument.notes ?? null,
|
|
2443
|
+
extractionStatus: currentSourceDocument.extractionStatus ?? 'skipped',
|
|
2444
|
+
extractionSummary: currentSourceDocument.extractionSummary ?? null,
|
|
2445
|
+
}
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
if (storedSourceFile) {
|
|
2451
|
+
await this.replaceContractDocument(tx as any, contractId, 'source_upload', {
|
|
2452
|
+
fileId: storedSourceFile.id,
|
|
2453
|
+
fileName:
|
|
2454
|
+
this.normalizeOptionalText(data.sourceFileName) ??
|
|
2455
|
+
storedSourceFile.filename,
|
|
2456
|
+
mimeType:
|
|
2457
|
+
this.normalizeOptionalText(data.sourceMimeType) ??
|
|
2458
|
+
storedSourceFile.file_mimetype?.name ??
|
|
2459
|
+
'application/pdf',
|
|
2460
|
+
notes: 'Source contract document uploaded during draft creation.',
|
|
2461
|
+
extractionStatus: 'pending',
|
|
2462
|
+
extractionSummary: null,
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
await this.insertContractHistory(
|
|
2467
|
+
tx as any,
|
|
2468
|
+
contractId,
|
|
2469
|
+
userId,
|
|
2470
|
+
'draft_created',
|
|
2471
|
+
`Contract draft created with mode ${creationMode}.`,
|
|
2472
|
+
JSON.stringify({
|
|
2473
|
+
creationMode,
|
|
2474
|
+
templateId: selectedTemplate?.id ?? null,
|
|
2475
|
+
duplicateFromId: duplicateSource?.id ?? null,
|
|
2476
|
+
sourceFileId: storedSourceFile?.id ?? null,
|
|
2477
|
+
})
|
|
2478
|
+
);
|
|
2479
|
+
|
|
2480
|
+
return contractId;
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
return this.getContractById(userId, createdId);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
async extractContractSource(
|
|
2487
|
+
userId: number,
|
|
2488
|
+
contractId: number,
|
|
2489
|
+
data: Omit<ContractExtractDraftPayload, 'fileName' | 'mimeType' | 'fileContentBase64' | 'contractId'>
|
|
2490
|
+
) {
|
|
2491
|
+
const actor = await this.getActorContext(userId);
|
|
2492
|
+
this.ensureDirector(actor);
|
|
2493
|
+
|
|
2494
|
+
const contract = await this.getContractById(userId, contractId);
|
|
2495
|
+
const sourceDocument = contract.documents.find(
|
|
2496
|
+
(document) => document.isCurrent && document.documentType === 'source_upload'
|
|
2497
|
+
);
|
|
2498
|
+
|
|
2499
|
+
if (!sourceDocument) {
|
|
2500
|
+
throw new BadRequestException(
|
|
2501
|
+
'No source document is attached to this contract draft.'
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
await this.prisma.$executeRawUnsafe(
|
|
2506
|
+
`UPDATE operations_contract_document
|
|
2507
|
+
SET extraction_status = 'processing',
|
|
2508
|
+
updated_at = NOW()
|
|
2509
|
+
WHERE id = $1`,
|
|
2510
|
+
sourceDocument.id
|
|
2511
|
+
);
|
|
2512
|
+
|
|
2513
|
+
try {
|
|
2514
|
+
const extracted = await this.extractContractDraft(userId, {
|
|
2515
|
+
contractId,
|
|
2516
|
+
provider: data.provider ?? null,
|
|
2517
|
+
promptMessage: data.promptMessage ?? null,
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
await this.prisma.$executeRawUnsafe(
|
|
2521
|
+
`UPDATE operations_contract_document
|
|
2522
|
+
SET extraction_status = 'completed',
|
|
2523
|
+
extraction_summary = $2,
|
|
2524
|
+
updated_at = NOW()
|
|
2525
|
+
WHERE id = $1`,
|
|
2526
|
+
sourceDocument.id,
|
|
2527
|
+
extracted.summary || extracted.description || extracted.name || null
|
|
2528
|
+
);
|
|
2529
|
+
|
|
2530
|
+
await this.insertContractHistory(
|
|
2531
|
+
this.prisma,
|
|
2532
|
+
contractId,
|
|
2533
|
+
userId,
|
|
2534
|
+
'source_extracted',
|
|
2535
|
+
'Source contract document extracted with AI.'
|
|
2536
|
+
);
|
|
2537
|
+
|
|
2538
|
+
return extracted;
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
await this.prisma.$executeRawUnsafe(
|
|
2541
|
+
`UPDATE operations_contract_document
|
|
2542
|
+
SET extraction_status = 'failed',
|
|
2543
|
+
updated_at = NOW()
|
|
2544
|
+
WHERE id = $1`,
|
|
2545
|
+
sourceDocument.id
|
|
2546
|
+
);
|
|
2547
|
+
throw error;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
async generateContractContent(
|
|
2552
|
+
userId: number,
|
|
2553
|
+
contractId: number,
|
|
2554
|
+
data: ContractGenerateContentPayload = {}
|
|
2555
|
+
) {
|
|
2556
|
+
const actor = await this.getActorContext(userId);
|
|
2557
|
+
this.ensureDirector(actor);
|
|
2558
|
+
|
|
2559
|
+
const contract = await this.getContractById(userId, contractId);
|
|
2560
|
+
const contentHtml = await this.generateContractContentHtml(contract, data);
|
|
2561
|
+
|
|
2562
|
+
await this.prisma.$transaction(async (tx) => {
|
|
2563
|
+
await (tx as any).$executeRawUnsafe(
|
|
2564
|
+
`UPDATE operations_contract
|
|
2565
|
+
SET content_html = $2,
|
|
2566
|
+
wizard_step = CASE
|
|
2567
|
+
WHEN COALESCE(wizard_step, 0) < 4 THEN 4
|
|
2568
|
+
ELSE wizard_step
|
|
2569
|
+
END,
|
|
2570
|
+
updated_by_user_id = $3,
|
|
2571
|
+
updated_at = NOW()
|
|
2572
|
+
WHERE id = $1`,
|
|
2573
|
+
contractId,
|
|
2574
|
+
contentHtml,
|
|
2575
|
+
userId
|
|
2576
|
+
);
|
|
2577
|
+
|
|
2578
|
+
await this.insertContractHistory(
|
|
2579
|
+
tx as any,
|
|
2580
|
+
contractId,
|
|
2581
|
+
userId,
|
|
2582
|
+
'content_generated',
|
|
2583
|
+
'Contract content generated automatically for editing.',
|
|
2584
|
+
JSON.stringify({
|
|
2585
|
+
provider: data.provider ?? null,
|
|
2586
|
+
overwrite: Boolean(data.overwrite),
|
|
2587
|
+
hasPrompt: Boolean(this.normalizeOptionalText(data.promptMessage)),
|
|
2588
|
+
})
|
|
2589
|
+
);
|
|
2590
|
+
});
|
|
2591
|
+
|
|
2592
|
+
return this.getContractById(userId, contractId);
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
async reviewContractLegally(
|
|
2596
|
+
userId: number,
|
|
2597
|
+
contractId: number,
|
|
2598
|
+
data: ContractLegalReviewPayload = {}
|
|
2599
|
+
) {
|
|
2600
|
+
const actor = await this.getActorContext(userId);
|
|
2601
|
+
this.ensureDirector(actor);
|
|
2602
|
+
|
|
2603
|
+
const contract = await this.getContractById(userId, contractId);
|
|
2604
|
+
const review = await this.buildContractLegalReview(contract, data);
|
|
2605
|
+
|
|
2606
|
+
await this.insertContractHistory(
|
|
2607
|
+
this.prisma,
|
|
2608
|
+
contractId,
|
|
2609
|
+
userId,
|
|
2610
|
+
'legal_reviewed',
|
|
2611
|
+
'Advisory legal checklist updated for this contract.',
|
|
2612
|
+
JSON.stringify(review)
|
|
2613
|
+
);
|
|
2614
|
+
|
|
2615
|
+
return review;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
async generateContractPdf(userId: number, contractId: number) {
|
|
2619
|
+
const actor = await this.getActorContext(userId);
|
|
2620
|
+
this.ensureDirector(actor);
|
|
2621
|
+
|
|
2622
|
+
const contract = await this.getContractById(userId, contractId);
|
|
2623
|
+
const pdfBuffer = await this.renderContractPdfBuffer(contract);
|
|
2624
|
+
const fileName = this.buildContractPdfFileName(contract);
|
|
2625
|
+
const uploadedFile = await this.fileService.upload('operations/contracts/generated', {
|
|
2626
|
+
fieldname: 'file',
|
|
2627
|
+
originalname: fileName,
|
|
2628
|
+
encoding: '7bit',
|
|
2629
|
+
mimetype: 'application/pdf',
|
|
2630
|
+
size: pdfBuffer.length,
|
|
2631
|
+
destination: '',
|
|
2632
|
+
filename: fileName,
|
|
2633
|
+
path: '',
|
|
2634
|
+
buffer: pdfBuffer,
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
await this.prisma.$transaction(async (tx) => {
|
|
2638
|
+
await this.replaceContractDocument(tx as any, contractId, 'generated_pdf', {
|
|
2639
|
+
fileId: uploadedFile.id,
|
|
2640
|
+
fileName,
|
|
2641
|
+
mimeType: 'application/pdf',
|
|
2642
|
+
notes: 'PDF generated from contract rich text content.',
|
|
2643
|
+
extractionStatus: 'skipped',
|
|
2644
|
+
extractionSummary: null,
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
await this.insertContractHistory(
|
|
2648
|
+
tx as any,
|
|
2649
|
+
contractId,
|
|
2650
|
+
userId,
|
|
2651
|
+
'pdf_generated',
|
|
2652
|
+
'Generated a branded PDF version of the contract.'
|
|
2653
|
+
);
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
return {
|
|
2657
|
+
contractId,
|
|
2658
|
+
fileId: uploadedFile.id,
|
|
2659
|
+
fileName,
|
|
2660
|
+
mimeType: 'application/pdf',
|
|
2661
|
+
documentType: 'generated_pdf',
|
|
2662
|
+
downloadUrl: `/file/open/${uploadedFile.id}`,
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
async extractContractDraft(
|
|
2667
|
+
userId: number,
|
|
2668
|
+
data: ContractExtractDraftPayload
|
|
2669
|
+
) {
|
|
2670
|
+
const actor = await this.getActorContext(userId);
|
|
2671
|
+
this.ensureDirector(actor);
|
|
2672
|
+
|
|
2673
|
+
const uploadFile = await this.resolveContractExtractionFile(userId, data);
|
|
2674
|
+
|
|
2675
|
+
const aiResult = await this.aiService.chat(
|
|
2676
|
+
{
|
|
2677
|
+
provider: data.provider === 'gemini' ? 'gemini' : 'openai',
|
|
2678
|
+
model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
|
|
2679
|
+
message:
|
|
2680
|
+
this.normalizeExtractionString(data.promptMessage) ||
|
|
2681
|
+
'Analyze the attached contract and extract a structured draft for the contract form.',
|
|
2682
|
+
systemPrompt: this.buildContractExtractionSystemPrompt(),
|
|
2683
|
+
},
|
|
2684
|
+
uploadFile,
|
|
2685
|
+
);
|
|
2686
|
+
|
|
2687
|
+
const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
|
|
2688
|
+
const draft = this.normalizeContractExtractDraft(parsed);
|
|
2689
|
+
const warnings = [...draft.warnings];
|
|
2690
|
+
|
|
2691
|
+
if (
|
|
2692
|
+
uploadFile.mimetype === 'application/msword' ||
|
|
2693
|
+
uploadFile.mimetype ===
|
|
2694
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
2695
|
+
) {
|
|
2696
|
+
warnings.push(
|
|
2697
|
+
'Word extraction is best-effort. Review names, dates, and values before saving.'
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
return {
|
|
2702
|
+
...draft,
|
|
2703
|
+
warnings: Array.from(new Set(warnings.filter(Boolean))),
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
async createContractFromProposalIntegration(
|
|
2708
|
+
payload: ProposalApprovedEventPayload,
|
|
2709
|
+
) {
|
|
2710
|
+
const sourceEntityId = String(payload.proposalId || '').trim();
|
|
2711
|
+
|
|
2712
|
+
if (!sourceEntityId) {
|
|
2713
|
+
throw new BadRequestException('proposalId is required for CRM proposal integration.');
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
const proposal = payload.proposal || {
|
|
2717
|
+
code: payload.code ?? null,
|
|
2718
|
+
title: payload.title ?? null,
|
|
2719
|
+
contractCategory: payload.commercialTerms?.contractCategory ?? 'client',
|
|
2720
|
+
contractType: payload.commercialTerms?.contractType ?? 'service_agreement',
|
|
2721
|
+
billingModel: payload.commercialTerms?.billingModel ?? 'fixed_price',
|
|
2722
|
+
validFrom: payload.commercialTerms?.validFrom ?? null,
|
|
2723
|
+
validUntil:
|
|
2724
|
+
payload.validUntil ?? payload.commercialTerms?.validUntil ?? null,
|
|
2725
|
+
totalAmount: payload.total ?? null,
|
|
2726
|
+
totalAmountCents:
|
|
2727
|
+
payload.total != null ? Math.round(Number(payload.total) * 100) : null,
|
|
2728
|
+
notes: payload.commercialTerms?.notes ?? null,
|
|
2729
|
+
};
|
|
2730
|
+
const person = payload.person || {};
|
|
2731
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
2732
|
+
const contractCategory = proposal.contractCategory ?? 'client';
|
|
2733
|
+
const contractType = proposal.contractType ?? 'service_agreement';
|
|
2734
|
+
const billingModel = proposal.billingModel ?? 'fixed_price';
|
|
2735
|
+
const clientName =
|
|
2736
|
+
this.normalizeOptionalText(person.tradeName) ??
|
|
2737
|
+
this.normalizeOptionalText(person.name) ??
|
|
2738
|
+
this.normalizeOptionalText(proposal.title) ??
|
|
2739
|
+
`Proposal ${sourceEntityId}`;
|
|
2740
|
+
const contractName =
|
|
2741
|
+
this.normalizeOptionalText(proposal.title) ?? `Proposal ${sourceEntityId}`;
|
|
2742
|
+
|
|
2743
|
+
const financialTerms = items
|
|
2744
|
+
.map((item) => ({
|
|
2745
|
+
termType: this.normalizeEnumValue(
|
|
2746
|
+
item.termType,
|
|
2747
|
+
FINANCIAL_TERM_TYPE_VALUES,
|
|
2748
|
+
'value',
|
|
2749
|
+
),
|
|
2750
|
+
label: this.normalizeOptionalText(item.name) ?? 'Commercial term',
|
|
2751
|
+
amount: Number(item.amount ?? Number(item.totalAmountCents || 0) / 100),
|
|
2752
|
+
recurrence: this.normalizeEnumValue(
|
|
2753
|
+
item.recurrence,
|
|
2754
|
+
RECURRENCE_VALUES,
|
|
2755
|
+
'one_time',
|
|
2756
|
+
),
|
|
2757
|
+
dueDay: item.dueDay ?? null,
|
|
2758
|
+
notes: this.normalizeOptionalText(item.description),
|
|
2759
|
+
}))
|
|
2760
|
+
.filter((term) => Number.isFinite(term.amount) && term.amount > 0);
|
|
2761
|
+
|
|
2762
|
+
const fallbackAmount = Number(proposal.totalAmount ?? 0);
|
|
2763
|
+
if (financialTerms.length === 0 && Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
|
|
2764
|
+
financialTerms.push({
|
|
2765
|
+
termType: 'revenue',
|
|
2766
|
+
label: contractName,
|
|
2767
|
+
amount: fallbackAmount,
|
|
2768
|
+
recurrence: 'one_time',
|
|
2769
|
+
dueDay: null,
|
|
2770
|
+
notes: this.normalizeOptionalText(proposal.notes),
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
const primaryPartyRole = ['employee', 'contractor'].includes(contractCategory)
|
|
2775
|
+
? 'employee'
|
|
2776
|
+
: ['supplier', 'vendor'].includes(contractCategory)
|
|
2777
|
+
? 'supplier'
|
|
2778
|
+
: contractCategory === 'partner'
|
|
2779
|
+
? 'partner'
|
|
2780
|
+
: 'client';
|
|
2781
|
+
|
|
2782
|
+
return this.prisma.$transaction(async (tx) => {
|
|
2783
|
+
await (tx as any).$queryRawUnsafe(
|
|
2784
|
+
`SELECT pg_advisory_xact_lock(hashtext($1))`,
|
|
2785
|
+
`operations:crm_proposal:${sourceEntityId}`,
|
|
2786
|
+
);
|
|
2787
|
+
|
|
2788
|
+
const existingContracts = await (tx as any).$queryRawUnsafe(
|
|
2789
|
+
`SELECT id, code
|
|
2790
|
+
FROM operations_contract
|
|
2791
|
+
WHERE deleted_at IS NULL
|
|
2792
|
+
AND origin_type = $1::operations_contract_origin_type_07a7cc2b5d_enum
|
|
2793
|
+
AND origin_id::text = $2
|
|
2794
|
+
ORDER BY id DESC
|
|
2795
|
+
LIMIT 1`,
|
|
2796
|
+
'crm_proposal',
|
|
2797
|
+
sourceEntityId,
|
|
2798
|
+
);
|
|
2799
|
+
|
|
2800
|
+
const existingContract = (
|
|
2801
|
+
existingContracts as Array<{ id: number; code: string | null }>
|
|
2802
|
+
)[0];
|
|
2803
|
+
|
|
2804
|
+
if (existingContract?.id) {
|
|
2805
|
+
return {
|
|
2806
|
+
id: existingContract.id,
|
|
2807
|
+
code: existingContract.code ?? `CONTRACT-${existingContract.id}`,
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const generatedCode = await this.generateContractCode(tx as any);
|
|
2812
|
+
const created = await (tx as any).$queryRawUnsafe(
|
|
2813
|
+
`INSERT INTO operations_contract (
|
|
2814
|
+
code,
|
|
2815
|
+
name,
|
|
2816
|
+
contract_category,
|
|
2817
|
+
contract_type,
|
|
2818
|
+
client_name,
|
|
2819
|
+
signature_status,
|
|
2820
|
+
is_active,
|
|
2821
|
+
billing_model,
|
|
2822
|
+
account_manager_collaborator_id,
|
|
2823
|
+
related_collaborator_id,
|
|
2824
|
+
contract_template_id,
|
|
2825
|
+
origin_type,
|
|
2826
|
+
origin_id,
|
|
2827
|
+
start_date,
|
|
2828
|
+
end_date,
|
|
2829
|
+
signed_at,
|
|
2830
|
+
effective_date,
|
|
2831
|
+
budget_amount,
|
|
2832
|
+
monthly_hour_cap,
|
|
2833
|
+
status,
|
|
2834
|
+
creation_mode,
|
|
2835
|
+
wizard_step,
|
|
2836
|
+
description,
|
|
2837
|
+
content_html,
|
|
2838
|
+
created_by_user_id,
|
|
2839
|
+
updated_by_user_id,
|
|
2840
|
+
created_at,
|
|
2841
|
+
updated_at
|
|
2842
|
+
) VALUES (
|
|
2843
|
+
$1,
|
|
2844
|
+
$2,
|
|
2845
|
+
$3::operations_contract_contract_category_70d553ea09_enum,
|
|
2846
|
+
$4::operations_contract_contract_type_48331e2ebf_enum,
|
|
2847
|
+
$5,
|
|
2848
|
+
$6::operations_contract_signature_status_2cb7282a7b_enum,
|
|
2849
|
+
$7,
|
|
2850
|
+
$8::operations_contract_billing_model_409dc7fea2_enum,
|
|
2851
|
+
$9,
|
|
2852
|
+
$10,
|
|
2853
|
+
$11,
|
|
2854
|
+
$12::operations_contract_origin_type_07a7cc2b5d_enum,
|
|
2855
|
+
$13,
|
|
2856
|
+
$14::date,
|
|
2857
|
+
$15::date,
|
|
2858
|
+
$16::date,
|
|
2859
|
+
$17::date,
|
|
2860
|
+
$18,
|
|
2861
|
+
$19,
|
|
2862
|
+
$20::operations_contract_status_a0395962df_enum,
|
|
2863
|
+
$21::operations_contract_creation_mode_98ba669209_enum,
|
|
2864
|
+
$22,
|
|
2865
|
+
$23,
|
|
2866
|
+
$24,
|
|
2867
|
+
$25,
|
|
2868
|
+
$25,
|
|
2869
|
+
NOW(),
|
|
2870
|
+
NOW()
|
|
2871
|
+
)
|
|
2872
|
+
RETURNING id`,
|
|
2873
|
+
generatedCode,
|
|
2874
|
+
contractName,
|
|
2875
|
+
contractCategory,
|
|
2876
|
+
contractType,
|
|
2877
|
+
clientName,
|
|
2878
|
+
'not_started',
|
|
2879
|
+
true,
|
|
2880
|
+
billingModel,
|
|
2881
|
+
null,
|
|
2882
|
+
null,
|
|
2883
|
+
null,
|
|
2884
|
+
'crm_proposal',
|
|
2885
|
+
sourceEntityId,
|
|
2886
|
+
proposal.validFrom ?? null,
|
|
2887
|
+
proposal.validUntil ?? null,
|
|
2888
|
+
null,
|
|
2889
|
+
proposal.validFrom ?? null,
|
|
2890
|
+
fallbackAmount > 0 ? fallbackAmount : null,
|
|
2891
|
+
null,
|
|
2892
|
+
'draft',
|
|
2893
|
+
'blank',
|
|
2894
|
+
1,
|
|
2895
|
+
this.normalizeOptionalText(proposal.notes),
|
|
2896
|
+
this.normalizeOptionalText(payload.revision?.contentHtml),
|
|
2897
|
+
Number(payload.approvedByUserId) || null,
|
|
2898
|
+
);
|
|
2899
|
+
|
|
2900
|
+
const contractId = (created as { id: number }[])[0]?.id;
|
|
2901
|
+
if (!contractId) {
|
|
2902
|
+
throw new BadRequestException('Could not create contract draft from proposal.');
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
await this.replaceContractParties(tx as any, contractId, [
|
|
2906
|
+
{
|
|
2907
|
+
partyRole: primaryPartyRole as any,
|
|
2908
|
+
partyType: person.tradeName ? 'company' : 'individual',
|
|
2909
|
+
displayName: clientName,
|
|
2910
|
+
documentNumber: this.normalizeOptionalText(person.document),
|
|
2911
|
+
email: this.normalizeOptionalText(person.email),
|
|
2912
|
+
phone: this.normalizeOptionalText(person.phone),
|
|
2913
|
+
isPrimary: true,
|
|
2914
|
+
},
|
|
2915
|
+
]);
|
|
2916
|
+
|
|
1338
2917
|
await this.replaceContractFinancialTerms(
|
|
1339
2918
|
tx as any,
|
|
1340
2919
|
contractId,
|
|
1341
|
-
|
|
2920
|
+
financialTerms.map((term) => ({
|
|
2921
|
+
termType: term.termType as any,
|
|
2922
|
+
label: term.label,
|
|
2923
|
+
amount: term.amount,
|
|
2924
|
+
recurrence: term.recurrence as any,
|
|
2925
|
+
dueDay: term.dueDay,
|
|
2926
|
+
notes: term.notes,
|
|
2927
|
+
})),
|
|
1342
2928
|
);
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
2929
|
+
|
|
2930
|
+
await this.replaceContractRevisions(tx as any, contractId, [
|
|
2931
|
+
{
|
|
2932
|
+
revisionType: 'revision',
|
|
2933
|
+
title: payload.revision?.title ?? contractName,
|
|
2934
|
+
effectiveDate: proposal.validFrom ?? null,
|
|
2935
|
+
status: 'draft',
|
|
2936
|
+
summary:
|
|
2937
|
+
this.normalizeOptionalText(payload.revision?.summary) ??
|
|
2938
|
+
this.normalizeOptionalText(proposal.notes),
|
|
2939
|
+
},
|
|
2940
|
+
]);
|
|
2941
|
+
|
|
1351
2942
|
await this.insertContractHistory(
|
|
1352
2943
|
tx as any,
|
|
1353
2944
|
contractId,
|
|
1354
|
-
|
|
2945
|
+
Number(payload.approvedByUserId) || null,
|
|
1355
2946
|
'created',
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
:
|
|
2947
|
+
`Draft contract generated from CRM proposal ${proposal.code ?? sourceEntityId}.`,
|
|
2948
|
+
JSON.stringify({
|
|
2949
|
+
correlationId: payload.correlationId || `proposal:${sourceEntityId}`,
|
|
2950
|
+
proposalId:
|
|
2951
|
+
payload.proposalId ??
|
|
2952
|
+
(Number(sourceEntityId) > 0 ? Number(sourceEntityId) : null),
|
|
2953
|
+
proposalRevisionId: payload.proposalRevisionId ?? null,
|
|
2954
|
+
sourceModule: 'contact',
|
|
2955
|
+
sourceEntity: 'proposal',
|
|
2956
|
+
sourceId: sourceEntityId,
|
|
2957
|
+
approvedByUserId: Number(payload.approvedByUserId) || null,
|
|
2958
|
+
}),
|
|
1359
2959
|
);
|
|
1360
|
-
|
|
2960
|
+
|
|
2961
|
+
await this.integrationApi.publishEvent(
|
|
2962
|
+
{
|
|
2963
|
+
eventName: 'operations.contract.created',
|
|
2964
|
+
sourceModule: 'operations',
|
|
2965
|
+
aggregateType: 'contract',
|
|
2966
|
+
aggregateId: String(contractId),
|
|
2967
|
+
payload: this.buildContractCreatedEventPayload(
|
|
2968
|
+
{
|
|
2969
|
+
id: contractId,
|
|
2970
|
+
code: generatedCode,
|
|
2971
|
+
name: contractName,
|
|
2972
|
+
clientName,
|
|
2973
|
+
contractCategory,
|
|
2974
|
+
contractType,
|
|
2975
|
+
billingModel,
|
|
2976
|
+
originType: 'crm_proposal',
|
|
2977
|
+
originId: sourceEntityId,
|
|
2978
|
+
startDate: proposal.validFrom ?? null,
|
|
2979
|
+
endDate: proposal.validUntil ?? null,
|
|
2980
|
+
description: this.normalizeOptionalText(proposal.notes),
|
|
2981
|
+
financialTerms,
|
|
2982
|
+
},
|
|
2983
|
+
payload,
|
|
2984
|
+
String(payload.locale || '').trim() || 'en',
|
|
2985
|
+
Number(payload.approvedByUserId) || undefined,
|
|
2986
|
+
),
|
|
2987
|
+
metadata: {
|
|
2988
|
+
producer: 'operations',
|
|
2989
|
+
correlationId:
|
|
2990
|
+
payload.correlationId || `proposal:${sourceEntityId}`,
|
|
2991
|
+
sourceModule: 'operations',
|
|
2992
|
+
sourceEntity: 'contract',
|
|
2993
|
+
sourceId: String(contractId),
|
|
2994
|
+
originType: 'crm_proposal',
|
|
2995
|
+
originId: sourceEntityId,
|
|
2996
|
+
},
|
|
2997
|
+
},
|
|
2998
|
+
{
|
|
2999
|
+
persistenceClient: tx as any,
|
|
3000
|
+
},
|
|
3001
|
+
);
|
|
3002
|
+
|
|
3003
|
+
return {
|
|
3004
|
+
id: contractId,
|
|
3005
|
+
code: generatedCode,
|
|
3006
|
+
};
|
|
1361
3007
|
});
|
|
3008
|
+
}
|
|
1362
3009
|
|
|
1363
|
-
|
|
3010
|
+
private buildContractCreatedEventPayload(
|
|
3011
|
+
contract: {
|
|
3012
|
+
id: number;
|
|
3013
|
+
code?: string | null;
|
|
3014
|
+
name?: string | null;
|
|
3015
|
+
clientName?: string | null;
|
|
3016
|
+
contractCategory?: ContractPayload['contractCategory'];
|
|
3017
|
+
contractType?: ContractPayload['contractType'];
|
|
3018
|
+
billingModel?: ContractPayload['billingModel'];
|
|
3019
|
+
originType?: ContractPayload['originType'];
|
|
3020
|
+
originId?: string | number | null;
|
|
3021
|
+
startDate?: string | null;
|
|
3022
|
+
endDate?: string | null;
|
|
3023
|
+
description?: string | null;
|
|
3024
|
+
financialTerms?: ContractPayload['financialTerms'];
|
|
3025
|
+
},
|
|
3026
|
+
payload: ProposalApprovedEventPayload,
|
|
3027
|
+
locale = 'en',
|
|
3028
|
+
createdByUserId?: number,
|
|
3029
|
+
) {
|
|
3030
|
+
return {
|
|
3031
|
+
contractId: contract.id,
|
|
3032
|
+
proposalId: payload.proposalId ?? null,
|
|
3033
|
+
proposalRevisionId: payload.proposalRevisionId ?? null,
|
|
3034
|
+
personId: payload.personId ?? payload.person?.id ?? null,
|
|
3035
|
+
createdByUserId: createdByUserId ?? null,
|
|
3036
|
+
createdAt: new Date().toISOString(),
|
|
3037
|
+
locale,
|
|
3038
|
+
correlationId:
|
|
3039
|
+
payload.correlationId || `proposal:${payload.proposalId ?? contract.originId ?? contract.id}`,
|
|
3040
|
+
sourceModule: 'operations',
|
|
3041
|
+
sourceEntity: 'contract',
|
|
3042
|
+
sourceId: String(contract.id),
|
|
3043
|
+
source_module: 'operations',
|
|
3044
|
+
source_entity: 'contract',
|
|
3045
|
+
source_id: String(contract.id),
|
|
3046
|
+
contract: {
|
|
3047
|
+
id: contract.id,
|
|
3048
|
+
code: contract.code ?? null,
|
|
3049
|
+
name: contract.name ?? null,
|
|
3050
|
+
clientName: contract.clientName ?? null,
|
|
3051
|
+
status: 'draft',
|
|
3052
|
+
contractCategory: contract.contractCategory ?? 'client',
|
|
3053
|
+
contractType: contract.contractType ?? 'service_agreement',
|
|
3054
|
+
billingModel: contract.billingModel ?? 'fixed_price',
|
|
3055
|
+
originType: contract.originType ?? 'manual',
|
|
3056
|
+
originId: contract.originId ?? null,
|
|
3057
|
+
startDate: contract.startDate ?? null,
|
|
3058
|
+
endDate: contract.endDate ?? null,
|
|
3059
|
+
description: contract.description ?? null,
|
|
3060
|
+
financialTerms: contract.financialTerms ?? [],
|
|
3061
|
+
},
|
|
3062
|
+
proposal: payload.proposal ?? {
|
|
3063
|
+
code: payload.code ?? null,
|
|
3064
|
+
title: payload.title ?? null,
|
|
3065
|
+
totalAmount: payload.total ?? null,
|
|
3066
|
+
notes: payload.commercialTerms?.notes ?? null,
|
|
3067
|
+
},
|
|
3068
|
+
person: payload.person ?? null,
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
private async buildContractActivatedEventPayload(
|
|
3073
|
+
contract: {
|
|
3074
|
+
id: number;
|
|
3075
|
+
code?: string | null;
|
|
3076
|
+
name?: string | null;
|
|
3077
|
+
clientName?: string | null;
|
|
3078
|
+
contractCategory?: ContractPayload['contractCategory'];
|
|
3079
|
+
contractType?: ContractPayload['contractType'];
|
|
3080
|
+
billingModel?: ContractPayload['billingModel'];
|
|
3081
|
+
originType?: ContractPayload['originType'];
|
|
3082
|
+
originId?: string | number | null;
|
|
3083
|
+
startDate?: string | null;
|
|
3084
|
+
endDate?: string | null;
|
|
3085
|
+
signedAt?: string | null;
|
|
3086
|
+
effectiveDate?: string | null;
|
|
3087
|
+
budgetAmount?: number | null;
|
|
3088
|
+
description?: string | null;
|
|
3089
|
+
financialTerms?: ContractPayload['financialTerms'];
|
|
3090
|
+
parties?: ContractPayload['parties'];
|
|
3091
|
+
},
|
|
3092
|
+
locale = 'en',
|
|
3093
|
+
activatedByUserId?: number,
|
|
3094
|
+
) {
|
|
3095
|
+
let personId: number | null = null;
|
|
3096
|
+
let proposalId: number | null = null;
|
|
3097
|
+
let personRecord: any = null;
|
|
3098
|
+
|
|
3099
|
+
if (contract.originType === 'crm_proposal' && contract.originId) {
|
|
3100
|
+
const parsedProposalId = Number(contract.originId);
|
|
3101
|
+
if (Number.isInteger(parsedProposalId) && parsedProposalId > 0) {
|
|
3102
|
+
proposalId = parsedProposalId;
|
|
3103
|
+
const proposal = await (this.prisma as any).proposal.findUnique({
|
|
3104
|
+
where: { id: parsedProposalId },
|
|
3105
|
+
include: {
|
|
3106
|
+
person: {
|
|
3107
|
+
select: {
|
|
3108
|
+
id: true,
|
|
3109
|
+
name: true,
|
|
3110
|
+
trade_name: true,
|
|
3111
|
+
email: true,
|
|
3112
|
+
phone: true,
|
|
3113
|
+
document: true,
|
|
3114
|
+
},
|
|
3115
|
+
},
|
|
3116
|
+
},
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
if (proposal) {
|
|
3120
|
+
personId = proposal.person_id ?? proposal.person?.id ?? null;
|
|
3121
|
+
personRecord = proposal.person ?? null;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
const financialTerms = (contract.financialTerms ?? []).map((term) => ({
|
|
3127
|
+
label: term.label,
|
|
3128
|
+
termType: term.termType ?? 'value',
|
|
3129
|
+
amount: Number(term.amount ?? 0),
|
|
3130
|
+
recurrence: term.recurrence ?? 'one_time',
|
|
3131
|
+
dueDay: term.dueDay ?? null,
|
|
3132
|
+
notes: term.notes ?? null,
|
|
3133
|
+
}));
|
|
3134
|
+
|
|
3135
|
+
const totalAmount =
|
|
3136
|
+
financialTerms.reduce(
|
|
3137
|
+
(sum, term) => sum + (Number.isFinite(term.amount) ? term.amount : 0),
|
|
3138
|
+
0,
|
|
3139
|
+
) || Number(contract.budgetAmount ?? 0);
|
|
3140
|
+
|
|
3141
|
+
return {
|
|
3142
|
+
contractId: contract.id,
|
|
3143
|
+
proposalId,
|
|
3144
|
+
personId,
|
|
3145
|
+
activatedByUserId: activatedByUserId ?? null,
|
|
3146
|
+
signedByUserId: activatedByUserId ?? null,
|
|
3147
|
+
activatedAt: new Date().toISOString(),
|
|
3148
|
+
signedAt: contract.signedAt ?? contract.effectiveDate ?? contract.startDate ?? null,
|
|
3149
|
+
locale,
|
|
3150
|
+
correlationId: proposalId ? `proposal:${proposalId}` : `contract:${contract.id}`,
|
|
3151
|
+
sourceModule: 'operations',
|
|
3152
|
+
sourceEntity: 'contract',
|
|
3153
|
+
sourceId: String(contract.id),
|
|
3154
|
+
source_module: 'operations',
|
|
3155
|
+
source_entity: 'contract',
|
|
3156
|
+
source_id: String(contract.id),
|
|
3157
|
+
contract: {
|
|
3158
|
+
code: contract.code ?? null,
|
|
3159
|
+
name: contract.name ?? null,
|
|
3160
|
+
clientName: contract.clientName ?? null,
|
|
3161
|
+
contractCategory: contract.contractCategory ?? 'client',
|
|
3162
|
+
contractType: contract.contractType ?? 'service_agreement',
|
|
3163
|
+
billingModel: contract.billingModel ?? 'fixed_price',
|
|
3164
|
+
originType: contract.originType ?? 'manual',
|
|
3165
|
+
originId: contract.originId ?? null,
|
|
3166
|
+
startDate: contract.startDate ?? null,
|
|
3167
|
+
endDate: contract.endDate ?? null,
|
|
3168
|
+
signedAt: contract.signedAt ?? null,
|
|
3169
|
+
effectiveDate: contract.effectiveDate ?? null,
|
|
3170
|
+
budgetAmount: contract.budgetAmount ?? null,
|
|
3171
|
+
description: contract.description ?? null,
|
|
3172
|
+
financialTerms,
|
|
3173
|
+
parties: contract.parties ?? [],
|
|
3174
|
+
},
|
|
3175
|
+
person: personRecord
|
|
3176
|
+
? {
|
|
3177
|
+
id: personRecord.id ?? null,
|
|
3178
|
+
name: personRecord.name ?? null,
|
|
3179
|
+
tradeName: personRecord.trade_name ?? null,
|
|
3180
|
+
email: personRecord.email ?? null,
|
|
3181
|
+
phone: personRecord.phone ?? null,
|
|
3182
|
+
document: personRecord.document ?? null,
|
|
3183
|
+
}
|
|
3184
|
+
: null,
|
|
3185
|
+
receivable: {
|
|
3186
|
+
personId,
|
|
3187
|
+
documentNumber: contract.code ?? `CONTRACT-${contract.id}`,
|
|
3188
|
+
totalAmount,
|
|
3189
|
+
description:
|
|
3190
|
+
contract.description ?? contract.name ?? contract.code ?? null,
|
|
3191
|
+
},
|
|
3192
|
+
};
|
|
1364
3193
|
}
|
|
1365
3194
|
|
|
1366
3195
|
async updateContract(userId: number, contractId: number, data: Partial<ContractPayload>) {
|
|
1367
3196
|
const actor = await this.getActorContext(userId);
|
|
1368
3197
|
this.ensureDirector(actor);
|
|
1369
|
-
await this.getContractById(userId, contractId);
|
|
3198
|
+
const current = await this.getContractById(userId, contractId);
|
|
3199
|
+
const nextStatus = this.normalizeEnumValue(
|
|
3200
|
+
data.status,
|
|
3201
|
+
CONTRACT_STATUS_VALUES,
|
|
3202
|
+
current.status ?? 'draft',
|
|
3203
|
+
);
|
|
3204
|
+
const nextSignatureStatus = this.normalizeEnumValue(
|
|
3205
|
+
data.signatureStatus,
|
|
3206
|
+
SIGNATURE_STATUS_VALUES,
|
|
3207
|
+
current.signatureStatus ?? 'not_started',
|
|
3208
|
+
);
|
|
3209
|
+
const shouldPublishSigned =
|
|
3210
|
+
current.signatureStatus !== 'signed' && nextSignatureStatus === 'signed';
|
|
3211
|
+
const shouldPublishActivation =
|
|
3212
|
+
current.status !== 'active' && nextStatus === 'active';
|
|
1370
3213
|
|
|
1371
3214
|
const updates: string[] = [];
|
|
1372
3215
|
const params: unknown[] = [];
|
|
1373
3216
|
this.pushUpdate(updates, params, 'code', data.code);
|
|
1374
3217
|
this.pushUpdate(updates, params, 'name', data.name);
|
|
1375
|
-
this.pushUpdate(
|
|
1376
|
-
|
|
3218
|
+
this.pushUpdate(
|
|
3219
|
+
updates,
|
|
3220
|
+
params,
|
|
3221
|
+
'contract_category',
|
|
3222
|
+
data.contractCategory,
|
|
3223
|
+
'operations_contract_contract_category_70d553ea09_enum'
|
|
3224
|
+
);
|
|
3225
|
+
this.pushUpdate(
|
|
3226
|
+
updates,
|
|
3227
|
+
params,
|
|
3228
|
+
'contract_type',
|
|
3229
|
+
data.contractType,
|
|
3230
|
+
'operations_contract_contract_type_48331e2ebf_enum'
|
|
3231
|
+
);
|
|
1377
3232
|
this.pushUpdate(updates, params, 'client_name', data.clientName);
|
|
1378
|
-
this.pushUpdate(
|
|
3233
|
+
this.pushUpdate(
|
|
3234
|
+
updates,
|
|
3235
|
+
params,
|
|
3236
|
+
'signature_status',
|
|
3237
|
+
data.signatureStatus,
|
|
3238
|
+
'operations_contract_signature_status_2cb7282a7b_enum'
|
|
3239
|
+
);
|
|
1379
3240
|
this.pushUpdate(updates, params, 'is_active', data.isActive);
|
|
1380
|
-
this.pushUpdate(
|
|
3241
|
+
this.pushUpdate(
|
|
3242
|
+
updates,
|
|
3243
|
+
params,
|
|
3244
|
+
'billing_model',
|
|
3245
|
+
data.billingModel,
|
|
3246
|
+
'operations_contract_billing_model_409dc7fea2_enum'
|
|
3247
|
+
);
|
|
1381
3248
|
this.pushUpdate(updates, params, 'account_manager_collaborator_id', data.accountManagerCollaboratorId);
|
|
1382
3249
|
this.pushUpdate(updates, params, 'related_collaborator_id', data.relatedCollaboratorId);
|
|
1383
|
-
this.pushUpdate(updates, params, '
|
|
3250
|
+
this.pushUpdate(updates, params, 'contract_template_id', data.contractTemplateId);
|
|
3251
|
+
this.pushUpdate(
|
|
3252
|
+
updates,
|
|
3253
|
+
params,
|
|
3254
|
+
'origin_type',
|
|
3255
|
+
data.originType,
|
|
3256
|
+
'operations_contract_origin_type_07a7cc2b5d_enum'
|
|
3257
|
+
);
|
|
1384
3258
|
this.pushUpdate(updates, params, 'origin_id', data.originId);
|
|
1385
|
-
this.pushUpdate(updates, params, 'start_date', data.startDate);
|
|
1386
|
-
this.pushUpdate(updates, params, 'end_date', data.endDate);
|
|
1387
|
-
this.pushUpdate(updates, params, 'signed_at', data.signedAt);
|
|
1388
|
-
this.pushUpdate(updates, params, 'effective_date', data.effectiveDate);
|
|
3259
|
+
this.pushUpdate(updates, params, 'start_date', data.startDate, 'date');
|
|
3260
|
+
this.pushUpdate(updates, params, 'end_date', data.endDate, 'date');
|
|
3261
|
+
this.pushUpdate(updates, params, 'signed_at', data.signedAt, 'date');
|
|
3262
|
+
this.pushUpdate(updates, params, 'effective_date', data.effectiveDate, 'date');
|
|
1389
3263
|
this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
|
|
1390
3264
|
this.pushUpdate(updates, params, 'monthly_hour_cap', data.monthlyHourCap);
|
|
1391
|
-
this.pushUpdate(
|
|
3265
|
+
this.pushUpdate(
|
|
3266
|
+
updates,
|
|
3267
|
+
params,
|
|
3268
|
+
'status',
|
|
3269
|
+
data.status,
|
|
3270
|
+
'operations_contract_status_a0395962df_enum'
|
|
3271
|
+
);
|
|
3272
|
+
this.pushUpdate(
|
|
3273
|
+
updates,
|
|
3274
|
+
params,
|
|
3275
|
+
'creation_mode',
|
|
3276
|
+
data.creationMode,
|
|
3277
|
+
'operations_contract_creation_mode_98ba669209_enum'
|
|
3278
|
+
);
|
|
3279
|
+
this.pushUpdate(updates, params, 'wizard_step', data.wizardStep);
|
|
1392
3280
|
this.pushUpdate(updates, params, 'description', data.description);
|
|
1393
3281
|
this.pushUpdate(updates, params, 'content_html', data.contentHtml);
|
|
1394
3282
|
|
|
@@ -1429,9 +3317,10 @@ export class OperationsService {
|
|
|
1429
3317
|
);
|
|
1430
3318
|
}
|
|
1431
3319
|
if (data.replaceUploadedPdfDocument) {
|
|
1432
|
-
await this.
|
|
3320
|
+
await this.replaceContractDocument(
|
|
1433
3321
|
tx as any,
|
|
1434
3322
|
contractId,
|
|
3323
|
+
'source_upload',
|
|
1435
3324
|
data.replaceUploadedPdfDocument
|
|
1436
3325
|
);
|
|
1437
3326
|
}
|
|
@@ -1443,11 +3332,152 @@ export class OperationsService {
|
|
|
1443
3332
|
'updated',
|
|
1444
3333
|
'Contract registry data updated.'
|
|
1445
3334
|
);
|
|
3335
|
+
|
|
3336
|
+
if (shouldPublishSigned || shouldPublishActivation) {
|
|
3337
|
+
const contractEventPayload = await this.buildContractActivatedEventPayload(
|
|
3338
|
+
{
|
|
3339
|
+
id: current.id,
|
|
3340
|
+
code: data.code ?? current.code,
|
|
3341
|
+
name: data.name ?? current.name,
|
|
3342
|
+
clientName: data.clientName ?? current.clientName,
|
|
3343
|
+
contractCategory: data.contractCategory ?? current.contractCategory,
|
|
3344
|
+
contractType: data.contractType ?? current.contractType,
|
|
3345
|
+
billingModel: data.billingModel ?? current.billingModel,
|
|
3346
|
+
originType: data.originType ?? current.originType,
|
|
3347
|
+
originId: data.originId ?? current.originId,
|
|
3348
|
+
startDate: data.startDate ?? current.startDate,
|
|
3349
|
+
endDate: data.endDate ?? current.endDate,
|
|
3350
|
+
signedAt: data.signedAt ?? current.signedAt,
|
|
3351
|
+
effectiveDate: data.effectiveDate ?? current.effectiveDate,
|
|
3352
|
+
budgetAmount: data.budgetAmount ?? current.budgetAmount,
|
|
3353
|
+
description: data.description ?? current.description,
|
|
3354
|
+
financialTerms: data.financialTerms ?? current.financialTerms,
|
|
3355
|
+
parties: data.parties ?? current.parties,
|
|
3356
|
+
},
|
|
3357
|
+
'en',
|
|
3358
|
+
userId,
|
|
3359
|
+
);
|
|
3360
|
+
|
|
3361
|
+
if (shouldPublishSigned) {
|
|
3362
|
+
await this.integrationApi.publishEvent(
|
|
3363
|
+
{
|
|
3364
|
+
eventName: 'operations.contract.signed',
|
|
3365
|
+
sourceModule: 'operations',
|
|
3366
|
+
aggregateType: 'contract',
|
|
3367
|
+
aggregateId: String(contractId),
|
|
3368
|
+
payload: {
|
|
3369
|
+
...contractEventPayload,
|
|
3370
|
+
signedByUserId: userId ?? null,
|
|
3371
|
+
signedAt:
|
|
3372
|
+
data.signedAt ??
|
|
3373
|
+
current.signedAt ??
|
|
3374
|
+
contractEventPayload.signedAt ??
|
|
3375
|
+
new Date().toISOString(),
|
|
3376
|
+
},
|
|
3377
|
+
metadata: {
|
|
3378
|
+
producer: 'operations',
|
|
3379
|
+
correlationId:
|
|
3380
|
+
contractEventPayload.correlationId || `contract:${contractId}`,
|
|
3381
|
+
sourceModule: 'operations',
|
|
3382
|
+
sourceEntity: 'contract',
|
|
3383
|
+
sourceId: String(contractId),
|
|
3384
|
+
originType: data.originType ?? current.originType,
|
|
3385
|
+
originId: data.originId ?? current.originId,
|
|
3386
|
+
lifecycle: 'signed',
|
|
3387
|
+
},
|
|
3388
|
+
},
|
|
3389
|
+
{
|
|
3390
|
+
persistenceClient: tx as any,
|
|
3391
|
+
},
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
if (shouldPublishActivation) {
|
|
3396
|
+
await this.integrationApi.publishEvent(
|
|
3397
|
+
{
|
|
3398
|
+
eventName: 'operations.contract.activated',
|
|
3399
|
+
sourceModule: 'operations',
|
|
3400
|
+
aggregateType: 'contract',
|
|
3401
|
+
aggregateId: String(contractId),
|
|
3402
|
+
payload: contractEventPayload,
|
|
3403
|
+
metadata: {
|
|
3404
|
+
producer: 'operations',
|
|
3405
|
+
correlationId:
|
|
3406
|
+
contractEventPayload.correlationId || `contract:${contractId}`,
|
|
3407
|
+
sourceModule: 'operations',
|
|
3408
|
+
sourceEntity: 'contract',
|
|
3409
|
+
sourceId: String(contractId),
|
|
3410
|
+
originType: data.originType ?? current.originType,
|
|
3411
|
+
originId: data.originId ?? current.originId,
|
|
3412
|
+
lifecycle: 'activated',
|
|
3413
|
+
},
|
|
3414
|
+
},
|
|
3415
|
+
{
|
|
3416
|
+
persistenceClient: tx as any,
|
|
3417
|
+
},
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
1446
3421
|
});
|
|
1447
3422
|
|
|
1448
3423
|
return this.getContractById(userId, contractId);
|
|
1449
3424
|
}
|
|
1450
3425
|
|
|
3426
|
+
async removeContract(userId: number, contractId: number) {
|
|
3427
|
+
const actor = await this.getActorContext(userId);
|
|
3428
|
+
this.ensureDirector(actor);
|
|
3429
|
+
const current = await this.getContractById(userId, contractId);
|
|
3430
|
+
|
|
3431
|
+
await this.prisma.$transaction(async (tx) => {
|
|
3432
|
+
await (tx as any).$executeRawUnsafe(
|
|
3433
|
+
`UPDATE operations_project
|
|
3434
|
+
SET contract_id = NULL,
|
|
3435
|
+
updated_at = NOW()
|
|
3436
|
+
WHERE contract_id = $1
|
|
3437
|
+
AND deleted_at IS NULL`,
|
|
3438
|
+
contractId
|
|
3439
|
+
);
|
|
3440
|
+
|
|
3441
|
+
for (const tableName of [
|
|
3442
|
+
'operations_contract_party',
|
|
3443
|
+
'operations_contract_signature',
|
|
3444
|
+
'operations_contract_financial_term',
|
|
3445
|
+
'operations_contract_document',
|
|
3446
|
+
'operations_contract_revision',
|
|
3447
|
+
]) {
|
|
3448
|
+
await (tx as any).$executeRawUnsafe(
|
|
3449
|
+
`UPDATE ${tableName}
|
|
3450
|
+
SET deleted_at = NOW(),
|
|
3451
|
+
updated_at = NOW()
|
|
3452
|
+
WHERE contract_id = $1
|
|
3453
|
+
AND deleted_at IS NULL`,
|
|
3454
|
+
contractId
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
await this.insertContractHistory(
|
|
3459
|
+
tx as any,
|
|
3460
|
+
contractId,
|
|
3461
|
+
userId,
|
|
3462
|
+
'deleted',
|
|
3463
|
+
`Contract "${current.name ?? current.code ?? `#${contractId}`}" removed from registry.`
|
|
3464
|
+
);
|
|
3465
|
+
|
|
3466
|
+
await (tx as any).$executeRawUnsafe(
|
|
3467
|
+
`UPDATE operations_contract
|
|
3468
|
+
SET deleted_at = NOW(),
|
|
3469
|
+
is_active = false,
|
|
3470
|
+
status = 'archived',
|
|
3471
|
+
updated_at = NOW()
|
|
3472
|
+
WHERE id = $1
|
|
3473
|
+
AND deleted_at IS NULL`,
|
|
3474
|
+
contractId
|
|
3475
|
+
);
|
|
3476
|
+
});
|
|
3477
|
+
|
|
3478
|
+
return { success: true };
|
|
3479
|
+
}
|
|
3480
|
+
|
|
1451
3481
|
async listTimesheets(userId: number) {
|
|
1452
3482
|
const actor = await this.getActorContext(userId);
|
|
1453
3483
|
const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 't.collaborator_id', actor.isDirector);
|
|
@@ -1563,7 +3593,7 @@ export class OperationsService {
|
|
|
1563
3593
|
status,
|
|
1564
3594
|
created_at,
|
|
1565
3595
|
updated_at
|
|
1566
|
-
) VALUES ($1, $2, $3, $4, $5, 'draft', NOW(), NOW())
|
|
3596
|
+
) VALUES ($1, $2, $3::date, $4::date, $5, 'draft', NOW(), NOW())
|
|
1567
3597
|
RETURNING id`,
|
|
1568
3598
|
collaboratorId,
|
|
1569
3599
|
collaborator.supervisorId ?? null,
|
|
@@ -1603,8 +3633,8 @@ export class OperationsService {
|
|
|
1603
3633
|
await this.prisma.$transaction(async (tx) => {
|
|
1604
3634
|
const updates: string[] = [];
|
|
1605
3635
|
const params: unknown[] = [];
|
|
1606
|
-
this.pushUpdate(updates, params, 'week_start_date', data.weekStartDate);
|
|
1607
|
-
this.pushUpdate(updates, params, 'week_end_date', data.weekEndDate);
|
|
3636
|
+
this.pushUpdate(updates, params, 'week_start_date', data.weekStartDate, 'date');
|
|
3637
|
+
this.pushUpdate(updates, params, 'week_end_date', data.weekEndDate, 'date');
|
|
1608
3638
|
this.pushUpdate(updates, params, 'notes', data.notes);
|
|
1609
3639
|
|
|
1610
3640
|
if (updates.length) {
|
|
@@ -1732,7 +3762,17 @@ export class OperationsService {
|
|
|
1732
3762
|
submitted_at,
|
|
1733
3763
|
created_at,
|
|
1734
3764
|
updated_at
|
|
1735
|
-
) VALUES (
|
|
3765
|
+
) VALUES (
|
|
3766
|
+
$1,
|
|
3767
|
+
$2,
|
|
3768
|
+
$3::operations_time_off_request_request_type_fdcf258f8e_enum,
|
|
3769
|
+
$4::date,
|
|
3770
|
+
$5::date,
|
|
3771
|
+
$6,
|
|
3772
|
+
'submitted',
|
|
3773
|
+
$7,
|
|
3774
|
+
NOW(), NOW(), NOW()
|
|
3775
|
+
)
|
|
1736
3776
|
RETURNING id`,
|
|
1737
3777
|
collaboratorId,
|
|
1738
3778
|
collaborator.supervisorId ?? null,
|
|
@@ -1909,7 +3949,14 @@ export class OperationsService {
|
|
|
1909
3949
|
created_at,
|
|
1910
3950
|
updated_at
|
|
1911
3951
|
) VALUES (
|
|
1912
|
-
$1,
|
|
3952
|
+
$1,
|
|
3953
|
+
$2,
|
|
3954
|
+
$3::operations_schedule_adjustment_request_request__d3f1202fa6_enum,
|
|
3955
|
+
$4::date,
|
|
3956
|
+
$5::date,
|
|
3957
|
+
'submitted',
|
|
3958
|
+
$6,
|
|
3959
|
+
NOW(), NOW(), NOW()
|
|
1913
3960
|
)
|
|
1914
3961
|
RETURNING id`,
|
|
1915
3962
|
collaboratorId,
|
|
@@ -1932,7 +3979,11 @@ export class OperationsService {
|
|
|
1932
3979
|
break_minutes,
|
|
1933
3980
|
created_at,
|
|
1934
3981
|
updated_at
|
|
1935
|
-
) VALUES (
|
|
3982
|
+
) VALUES (
|
|
3983
|
+
$1,
|
|
3984
|
+
$2::operations_schedule_adjustment_day_weekday_ba6542bf86_enum,
|
|
3985
|
+
$3, $4::time, $5::time, $6, NOW(), NOW()
|
|
3986
|
+
)`,
|
|
1936
3987
|
requestId,
|
|
1937
3988
|
day.weekday,
|
|
1938
3989
|
day.isWorkingDay ?? true,
|
|
@@ -2151,7 +4202,7 @@ export class OperationsService {
|
|
|
2151
4202
|
await this.prisma.$transaction(async (tx) => {
|
|
2152
4203
|
await (tx as any).$executeRawUnsafe(
|
|
2153
4204
|
`UPDATE operations_approval
|
|
2154
|
-
SET status = $1,
|
|
4205
|
+
SET status = $1::operations_approval_status_747aa313d9_enum,
|
|
2155
4206
|
decided_at = NOW(),
|
|
2156
4207
|
decision_note = $2,
|
|
2157
4208
|
updated_at = NOW()
|
|
@@ -2164,7 +4215,7 @@ export class OperationsService {
|
|
|
2164
4215
|
if (approval.targetType === 'timesheet') {
|
|
2165
4216
|
await (tx as any).$executeRawUnsafe(
|
|
2166
4217
|
`UPDATE operations_timesheet
|
|
2167
|
-
SET status = $1,
|
|
4218
|
+
SET status = $1::operations_timesheet_status_128a8ecd30_enum,
|
|
2168
4219
|
reviewed_at = NOW(),
|
|
2169
4220
|
approver_collaborator_id = $2,
|
|
2170
4221
|
updated_at = NOW()
|
|
@@ -2178,7 +4229,7 @@ export class OperationsService {
|
|
|
2178
4229
|
if (approval.targetType === 'time_off_request') {
|
|
2179
4230
|
await (tx as any).$executeRawUnsafe(
|
|
2180
4231
|
`UPDATE operations_time_off_request
|
|
2181
|
-
SET status = $1,
|
|
4232
|
+
SET status = $1::operations_time_off_request_status_c0b28799ee_enum,
|
|
2182
4233
|
reviewed_at = NOW(),
|
|
2183
4234
|
approver_collaborator_id = $2,
|
|
2184
4235
|
submitted_at = COALESCE(submitted_at, NOW()),
|
|
@@ -2193,7 +4244,7 @@ export class OperationsService {
|
|
|
2193
4244
|
if (approval.targetType === 'schedule_adjustment_request') {
|
|
2194
4245
|
await (tx as any).$executeRawUnsafe(
|
|
2195
4246
|
`UPDATE operations_schedule_adjustment_request
|
|
2196
|
-
SET status = $1,
|
|
4247
|
+
SET status = $1::operations_schedule_adjustment_request_status_159e10f34e_enum,
|
|
2197
4248
|
reviewed_at = NOW(),
|
|
2198
4249
|
approver_collaborator_id = $2,
|
|
2199
4250
|
submitted_at = COALESCE(submitted_at, NOW()),
|
|
@@ -2294,10 +4345,12 @@ export class OperationsService {
|
|
|
2294
4345
|
supervisorName: string | null;
|
|
2295
4346
|
}>(
|
|
2296
4347
|
`SELECT c.id,
|
|
2297
|
-
c.display_name AS "displayName",
|
|
4348
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
|
|
2298
4349
|
s.id AS "supervisorId",
|
|
2299
4350
|
s.display_name AS "supervisorName"
|
|
2300
4351
|
FROM operations_collaborator c
|
|
4352
|
+
LEFT JOIN person person_record
|
|
4353
|
+
ON person_record.id = c.person_id
|
|
2301
4354
|
LEFT JOIN operations_collaborator s
|
|
2302
4355
|
ON s.id = c.supervisor_collaborator_id
|
|
2303
4356
|
WHERE c.user_id = $1
|
|
@@ -2314,10 +4367,12 @@ export class OperationsService {
|
|
|
2314
4367
|
supervisorName: string | null;
|
|
2315
4368
|
}>(
|
|
2316
4369
|
`SELECT c.id,
|
|
2317
|
-
c.display_name AS "displayName",
|
|
4370
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
|
|
2318
4371
|
s.id AS "supervisorId",
|
|
2319
4372
|
s.display_name AS "supervisorName"
|
|
2320
4373
|
FROM operations_collaborator c
|
|
4374
|
+
LEFT JOIN person person_record
|
|
4375
|
+
ON person_record.id = c.person_id
|
|
2321
4376
|
LEFT JOIN operations_collaborator s
|
|
2322
4377
|
ON s.id = c.supervisor_collaborator_id
|
|
2323
4378
|
WHERE c.id = $1
|
|
@@ -2330,6 +4385,361 @@ export class OperationsService {
|
|
|
2330
4385
|
return collaborator;
|
|
2331
4386
|
}
|
|
2332
4387
|
|
|
4388
|
+
private async getPersonById(personId: number) {
|
|
4389
|
+
const person = await this.querySingle<{
|
|
4390
|
+
id: number;
|
|
4391
|
+
name: string;
|
|
4392
|
+
type: 'individual' | 'company' | null;
|
|
4393
|
+
}>(
|
|
4394
|
+
`SELECT id, name, type
|
|
4395
|
+
FROM person
|
|
4396
|
+
WHERE id = $1`,
|
|
4397
|
+
[personId]
|
|
4398
|
+
);
|
|
4399
|
+
|
|
4400
|
+
if (!person) {
|
|
4401
|
+
throw new NotFoundException('Person not found.');
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4404
|
+
return person;
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
private async getDepartmentById(
|
|
4408
|
+
client: any,
|
|
4409
|
+
departmentId: number,
|
|
4410
|
+
includeInactive = false
|
|
4411
|
+
) {
|
|
4412
|
+
const departments = (await client.$queryRawUnsafe(
|
|
4413
|
+
`SELECT id,
|
|
4414
|
+
slug,
|
|
4415
|
+
code,
|
|
4416
|
+
name,
|
|
4417
|
+
description,
|
|
4418
|
+
created_at AS "createdAt",
|
|
4419
|
+
updated_at AS "updatedAt",
|
|
4420
|
+
deleted_at AS "deletedAt",
|
|
4421
|
+
CASE WHEN deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status
|
|
4422
|
+
FROM operations_department
|
|
4423
|
+
WHERE id = $1
|
|
4424
|
+
AND ($2::boolean OR deleted_at IS NULL)`,
|
|
4425
|
+
departmentId,
|
|
4426
|
+
includeInactive
|
|
4427
|
+
)) as Array<{
|
|
4428
|
+
id: number;
|
|
4429
|
+
slug: string;
|
|
4430
|
+
code: string | null;
|
|
4431
|
+
name: string;
|
|
4432
|
+
description: string | null;
|
|
4433
|
+
createdAt: string | null;
|
|
4434
|
+
updatedAt: string | null;
|
|
4435
|
+
deletedAt: string | null;
|
|
4436
|
+
status: 'active' | 'inactive';
|
|
4437
|
+
}>;
|
|
4438
|
+
|
|
4439
|
+
const department = departments[0] ?? null;
|
|
4440
|
+
if (!department) {
|
|
4441
|
+
throw new NotFoundException('Department not found.');
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
return department;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
private async assertDepartmentNameAvailable(
|
|
4448
|
+
client: any,
|
|
4449
|
+
name: string,
|
|
4450
|
+
excludeDepartmentId?: number | null
|
|
4451
|
+
) {
|
|
4452
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4453
|
+
`SELECT id
|
|
4454
|
+
FROM operations_department
|
|
4455
|
+
WHERE LOWER(name) = LOWER($1)
|
|
4456
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4457
|
+
LIMIT 1`,
|
|
4458
|
+
name,
|
|
4459
|
+
excludeDepartmentId ?? null
|
|
4460
|
+
)) as { id: number }[];
|
|
4461
|
+
|
|
4462
|
+
if (existing[0]) {
|
|
4463
|
+
throw new BadRequestException('A department with this name already exists.');
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
private async assertDepartmentCodeAvailable(
|
|
4468
|
+
client: any,
|
|
4469
|
+
code: string,
|
|
4470
|
+
excludeDepartmentId?: number | null
|
|
4471
|
+
) {
|
|
4472
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4473
|
+
`SELECT id
|
|
4474
|
+
FROM operations_department
|
|
4475
|
+
WHERE UPPER(COALESCE(code, '')) = UPPER($1)
|
|
4476
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4477
|
+
LIMIT 1`,
|
|
4478
|
+
code,
|
|
4479
|
+
excludeDepartmentId ?? null
|
|
4480
|
+
)) as { id: number }[];
|
|
4481
|
+
|
|
4482
|
+
if (existing[0]) {
|
|
4483
|
+
throw new BadRequestException('A department with this code already exists.');
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
private async resolveDepartmentReference(
|
|
4488
|
+
client: any,
|
|
4489
|
+
input: {
|
|
4490
|
+
departmentId?: number | null;
|
|
4491
|
+
departmentName?: string | null;
|
|
4492
|
+
}
|
|
4493
|
+
) {
|
|
4494
|
+
if (input.departmentName !== undefined) {
|
|
4495
|
+
const normalizedDepartmentName = this.normalizeOptionalText(
|
|
4496
|
+
input.departmentName
|
|
4497
|
+
);
|
|
4498
|
+
|
|
4499
|
+
if (!normalizedDepartmentName) {
|
|
4500
|
+
return null;
|
|
4501
|
+
}
|
|
4502
|
+
|
|
4503
|
+
const existingByName = (await client.$queryRawUnsafe(
|
|
4504
|
+
`SELECT id, name
|
|
4505
|
+
FROM operations_department
|
|
4506
|
+
WHERE deleted_at IS NULL
|
|
4507
|
+
AND LOWER(name) = LOWER($1)
|
|
4508
|
+
ORDER BY id ASC
|
|
4509
|
+
LIMIT 1`,
|
|
4510
|
+
normalizedDepartmentName
|
|
4511
|
+
)) as { id: number; name: string }[];
|
|
4512
|
+
|
|
4513
|
+
if (existingByName[0]) {
|
|
4514
|
+
return existingByName[0];
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
const slug = await this.generateUniqueDepartmentSlug(
|
|
4518
|
+
client,
|
|
4519
|
+
normalizedDepartmentName
|
|
4520
|
+
);
|
|
4521
|
+
const created = (await client.$queryRawUnsafe(
|
|
4522
|
+
`INSERT INTO operations_department (
|
|
4523
|
+
slug,
|
|
4524
|
+
code,
|
|
4525
|
+
name,
|
|
4526
|
+
description,
|
|
4527
|
+
created_at,
|
|
4528
|
+
updated_at
|
|
4529
|
+
) VALUES ($1, NULL, $2, NULL, NOW(), NOW())
|
|
4530
|
+
RETURNING id, name`,
|
|
4531
|
+
slug,
|
|
4532
|
+
normalizedDepartmentName
|
|
4533
|
+
)) as { id: number; name: string }[];
|
|
4534
|
+
|
|
4535
|
+
return created[0] ?? null;
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
if (
|
|
4539
|
+
typeof input.departmentId === 'number' &&
|
|
4540
|
+
Number.isInteger(input.departmentId) &&
|
|
4541
|
+
input.departmentId > 0
|
|
4542
|
+
) {
|
|
4543
|
+
return this.getDepartmentById(client, input.departmentId);
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
return null;
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
private async generateUniqueDepartmentSlug(
|
|
4550
|
+
client: any,
|
|
4551
|
+
label: string,
|
|
4552
|
+
excludeDepartmentId?: number | null
|
|
4553
|
+
) {
|
|
4554
|
+
const baseSlug =
|
|
4555
|
+
this.slugifyValue(label) || `department-${Date.now().toString(36)}`;
|
|
4556
|
+
|
|
4557
|
+
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
4558
|
+
const candidate =
|
|
4559
|
+
attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
|
|
4560
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4561
|
+
`SELECT id
|
|
4562
|
+
FROM operations_department
|
|
4563
|
+
WHERE slug = $1
|
|
4564
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4565
|
+
LIMIT 1`,
|
|
4566
|
+
candidate,
|
|
4567
|
+
excludeDepartmentId ?? null
|
|
4568
|
+
)) as { id: number }[];
|
|
4569
|
+
|
|
4570
|
+
if (!existing.length) {
|
|
4571
|
+
return candidate;
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4575
|
+
return `${baseSlug}-${Date.now().toString(36)}`;
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
private async getContractTemplateRecord(
|
|
4579
|
+
client: any,
|
|
4580
|
+
templateId: number,
|
|
4581
|
+
includeInactive = false
|
|
4582
|
+
): Promise<{
|
|
4583
|
+
id: number;
|
|
4584
|
+
slug: string;
|
|
4585
|
+
code: string | null;
|
|
4586
|
+
name: string;
|
|
4587
|
+
description: string | null;
|
|
4588
|
+
contractCategory: string | null;
|
|
4589
|
+
contractType: string | null;
|
|
4590
|
+
billingModel: string | null;
|
|
4591
|
+
signatureStatus: string | null;
|
|
4592
|
+
isActive: boolean;
|
|
4593
|
+
status: string | null;
|
|
4594
|
+
contentHtml: string | null;
|
|
4595
|
+
usageCount: number;
|
|
4596
|
+
createdAt: Date;
|
|
4597
|
+
updatedAt: Date;
|
|
4598
|
+
}> {
|
|
4599
|
+
const template = (await client.$queryRawUnsafe(
|
|
4600
|
+
`SELECT t.id,
|
|
4601
|
+
t.slug,
|
|
4602
|
+
t.code,
|
|
4603
|
+
t.name,
|
|
4604
|
+
t.description,
|
|
4605
|
+
t.contract_category AS "contractCategory",
|
|
4606
|
+
t.contract_type AS "contractType",
|
|
4607
|
+
t.billing_model AS "billingModel",
|
|
4608
|
+
t.signature_status AS "signatureStatus",
|
|
4609
|
+
t.is_active AS "isActive",
|
|
4610
|
+
t.status,
|
|
4611
|
+
t.content_html AS "contentHtml",
|
|
4612
|
+
COUNT(DISTINCT c.id)::int AS "usageCount",
|
|
4613
|
+
t.created_at AS "createdAt",
|
|
4614
|
+
t.updated_at AS "updatedAt"
|
|
4615
|
+
FROM operations_contract_template t
|
|
4616
|
+
LEFT JOIN operations_contract c
|
|
4617
|
+
ON c.contract_template_id = t.id
|
|
4618
|
+
AND c.deleted_at IS NULL
|
|
4619
|
+
WHERE t.id = $1
|
|
4620
|
+
AND ($2::boolean = true OR t.deleted_at IS NULL)
|
|
4621
|
+
GROUP BY t.id
|
|
4622
|
+
LIMIT 1`,
|
|
4623
|
+
templateId,
|
|
4624
|
+
includeInactive
|
|
4625
|
+
)) as Array<{
|
|
4626
|
+
id: number;
|
|
4627
|
+
slug: string;
|
|
4628
|
+
code: string | null;
|
|
4629
|
+
name: string;
|
|
4630
|
+
description: string | null;
|
|
4631
|
+
contractCategory: string | null;
|
|
4632
|
+
contractType: string | null;
|
|
4633
|
+
billingModel: string | null;
|
|
4634
|
+
signatureStatus: string | null;
|
|
4635
|
+
isActive: boolean;
|
|
4636
|
+
status: string | null;
|
|
4637
|
+
contentHtml: string | null;
|
|
4638
|
+
usageCount: number;
|
|
4639
|
+
createdAt: Date;
|
|
4640
|
+
updatedAt: Date;
|
|
4641
|
+
}>;
|
|
4642
|
+
|
|
4643
|
+
const record = template[0];
|
|
4644
|
+
|
|
4645
|
+
if (!record) {
|
|
4646
|
+
throw new NotFoundException('Contract template not found.');
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
return record;
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
private async assertContractTemplateNameAvailable(
|
|
4653
|
+
client: any,
|
|
4654
|
+
name: string,
|
|
4655
|
+
excludeTemplateId?: number | null
|
|
4656
|
+
) {
|
|
4657
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4658
|
+
`SELECT id
|
|
4659
|
+
FROM operations_contract_template
|
|
4660
|
+
WHERE LOWER(name) = LOWER($1)
|
|
4661
|
+
AND deleted_at IS NULL
|
|
4662
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4663
|
+
LIMIT 1`,
|
|
4664
|
+
name,
|
|
4665
|
+
excludeTemplateId ?? null
|
|
4666
|
+
)) as { id: number }[];
|
|
4667
|
+
|
|
4668
|
+
if (existing[0]) {
|
|
4669
|
+
throw new BadRequestException(
|
|
4670
|
+
'A contract template with this name already exists.'
|
|
4671
|
+
);
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
private async assertContractTemplateCodeAvailable(
|
|
4676
|
+
client: any,
|
|
4677
|
+
code: string,
|
|
4678
|
+
excludeTemplateId?: number | null
|
|
4679
|
+
) {
|
|
4680
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4681
|
+
`SELECT id
|
|
4682
|
+
FROM operations_contract_template
|
|
4683
|
+
WHERE UPPER(COALESCE(code, '')) = UPPER($1)
|
|
4684
|
+
AND deleted_at IS NULL
|
|
4685
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4686
|
+
LIMIT 1`,
|
|
4687
|
+
code,
|
|
4688
|
+
excludeTemplateId ?? null
|
|
4689
|
+
)) as { id: number }[];
|
|
4690
|
+
|
|
4691
|
+
if (existing[0]) {
|
|
4692
|
+
throw new BadRequestException(
|
|
4693
|
+
'A contract template with this code already exists.'
|
|
4694
|
+
);
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
private async generateUniqueContractTemplateSlug(
|
|
4699
|
+
client: any,
|
|
4700
|
+
label: string,
|
|
4701
|
+
excludeTemplateId?: number | null
|
|
4702
|
+
) {
|
|
4703
|
+
const baseSlug =
|
|
4704
|
+
this.slugifyValue(label) || `contract-template-${Date.now().toString(36)}`;
|
|
4705
|
+
|
|
4706
|
+
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
4707
|
+
const candidate =
|
|
4708
|
+
attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
|
|
4709
|
+
const existing = (await client.$queryRawUnsafe(
|
|
4710
|
+
`SELECT id
|
|
4711
|
+
FROM operations_contract_template
|
|
4712
|
+
WHERE slug = $1
|
|
4713
|
+
AND ($2::int IS NULL OR id <> $2)
|
|
4714
|
+
LIMIT 1`,
|
|
4715
|
+
candidate,
|
|
4716
|
+
excludeTemplateId ?? null
|
|
4717
|
+
)) as { id: number }[];
|
|
4718
|
+
|
|
4719
|
+
if (!existing.length) {
|
|
4720
|
+
return candidate;
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4724
|
+
return `${baseSlug}-${Date.now().toString(36)}`;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
private slugifyValue(value: string | null | undefined) {
|
|
4728
|
+
return (value ?? '')
|
|
4729
|
+
.normalize('NFKD')
|
|
4730
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
4731
|
+
.toLowerCase()
|
|
4732
|
+
.trim()
|
|
4733
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
4734
|
+
.replace(/^-+|-+$/g, '')
|
|
4735
|
+
.slice(0, 80);
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
private normalizeOptionalText(value: string | null | undefined) {
|
|
4739
|
+
const normalized = String(value ?? '').trim();
|
|
4740
|
+
return normalized || null;
|
|
4741
|
+
}
|
|
4742
|
+
|
|
2333
4743
|
private async getProjectDetails(
|
|
2334
4744
|
projectId: number,
|
|
2335
4745
|
actorCollaboratorId?: number | null
|
|
@@ -2521,10 +4931,14 @@ export class OperationsService {
|
|
|
2521
4931
|
private async getCollaboratorDetails(collaboratorId: number) {
|
|
2522
4932
|
const collaborator = await this.querySingle<{
|
|
2523
4933
|
id: number;
|
|
2524
|
-
userId: number;
|
|
4934
|
+
userId: number | null;
|
|
4935
|
+
personId: number | null;
|
|
4936
|
+
personName: string | null;
|
|
4937
|
+
personAvatarId: number | null;
|
|
2525
4938
|
code: string;
|
|
2526
4939
|
collaboratorType: string;
|
|
2527
4940
|
displayName: string;
|
|
4941
|
+
departmentId: number | null;
|
|
2528
4942
|
department: string | null;
|
|
2529
4943
|
title: string | null;
|
|
2530
4944
|
levelLabel: string | null;
|
|
@@ -2541,10 +4955,14 @@ export class OperationsService {
|
|
|
2541
4955
|
}>(
|
|
2542
4956
|
`SELECT c.id,
|
|
2543
4957
|
c.user_id AS "userId",
|
|
4958
|
+
c.person_id AS "personId",
|
|
4959
|
+
person_record.name AS "personName",
|
|
4960
|
+
person_record.avatar_id AS "personAvatarId",
|
|
2544
4961
|
c.code,
|
|
2545
4962
|
c.collaborator_type AS "collaboratorType",
|
|
2546
|
-
c.display_name AS "displayName",
|
|
2547
|
-
c.
|
|
4963
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
|
|
4964
|
+
c.department_id AS "departmentId",
|
|
4965
|
+
COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
|
|
2548
4966
|
c.title,
|
|
2549
4967
|
c.level_label AS "levelLabel",
|
|
2550
4968
|
c.weekly_capacity_hours AS "weeklyCapacityHours",
|
|
@@ -2558,6 +4976,11 @@ export class OperationsService {
|
|
|
2558
4976
|
hiring_contract.status AS "contractStatus",
|
|
2559
4977
|
COUNT(DISTINCT pa.id)::int AS "activeAssignments"
|
|
2560
4978
|
FROM operations_collaborator c
|
|
4979
|
+
LEFT JOIN person person_record
|
|
4980
|
+
ON person_record.id = c.person_id
|
|
4981
|
+
LEFT JOIN operations_department department_record
|
|
4982
|
+
ON department_record.id = c.department_id
|
|
4983
|
+
AND department_record.deleted_at IS NULL
|
|
2561
4984
|
LEFT JOIN operations_collaborator s
|
|
2562
4985
|
ON s.id = c.supervisor_collaborator_id
|
|
2563
4986
|
LEFT JOIN operations_project_assignment pa
|
|
@@ -2575,7 +4998,7 @@ export class OperationsService {
|
|
|
2575
4998
|
) hiring_contract ON TRUE
|
|
2576
4999
|
WHERE c.id = $1
|
|
2577
5000
|
AND c.deleted_at IS NULL
|
|
2578
|
-
GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status`,
|
|
5001
|
+
GROUP BY c.id, person_record.id, department_record.id, s.id, hiring_contract.id, hiring_contract.status`,
|
|
2579
5002
|
[collaboratorId]
|
|
2580
5003
|
);
|
|
2581
5004
|
|
|
@@ -2799,7 +5222,7 @@ export class OperationsService {
|
|
|
2799
5222
|
description,
|
|
2800
5223
|
created_at,
|
|
2801
5224
|
updated_at
|
|
2802
|
-
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
|
|
5225
|
+
) VALUES ($1, $2, $3, $4::date, $5, $6, NOW(), NOW())`,
|
|
2803
5226
|
timesheetId,
|
|
2804
5227
|
entry.projectAssignmentId ?? null,
|
|
2805
5228
|
entry.activityLabel ?? null,
|
|
@@ -2837,7 +5260,7 @@ export class OperationsService {
|
|
|
2837
5260
|
const existing = (await client.$queryRawUnsafe(
|
|
2838
5261
|
`SELECT id
|
|
2839
5262
|
FROM operations_approval
|
|
2840
|
-
WHERE target_type = $1
|
|
5263
|
+
WHERE target_type = $1::operations_approval_target_type_32d3f04385_enum
|
|
2841
5264
|
AND target_id = $2
|
|
2842
5265
|
AND deleted_at IS NULL`,
|
|
2843
5266
|
input.targetType,
|
|
@@ -2856,7 +5279,10 @@ export class OperationsService {
|
|
|
2856
5279
|
submitted_at,
|
|
2857
5280
|
created_at,
|
|
2858
5281
|
updated_at
|
|
2859
|
-
) VALUES (
|
|
5282
|
+
) VALUES (
|
|
5283
|
+
$1::operations_approval_target_type_32d3f04385_enum,
|
|
5284
|
+
$2, $3, $4, 'pending', NOW(), NOW(), NOW()
|
|
5285
|
+
)
|
|
2860
5286
|
RETURNING id`,
|
|
2861
5287
|
input.targetType,
|
|
2862
5288
|
input.targetId,
|
|
@@ -2924,7 +5350,13 @@ export class OperationsService {
|
|
|
2924
5350
|
action,
|
|
2925
5351
|
note,
|
|
2926
5352
|
created_at
|
|
2927
|
-
) VALUES (
|
|
5353
|
+
) VALUES (
|
|
5354
|
+
$1,
|
|
5355
|
+
$2,
|
|
5356
|
+
$3::operations_approval_history_action_c8478a05d6_enum,
|
|
5357
|
+
$4,
|
|
5358
|
+
NOW()
|
|
5359
|
+
)`,
|
|
2928
5360
|
approvalId,
|
|
2929
5361
|
actorCollaboratorId,
|
|
2930
5362
|
action,
|
|
@@ -3061,7 +5493,7 @@ export class OperationsService {
|
|
|
3061
5493
|
break_minutes,
|
|
3062
5494
|
created_at,
|
|
3063
5495
|
updated_at
|
|
3064
|
-
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
|
|
5496
|
+
) VALUES ($1, $2::operations_collaborator_schedule_day_weekday_5d79528f6c_enum, $3, $4::time, $5::time, $6, NOW(), NOW())`,
|
|
3065
5497
|
collaboratorId,
|
|
3066
5498
|
day.weekday,
|
|
3067
5499
|
day.isWorkingDay ?? true,
|
|
@@ -3101,7 +5533,9 @@ export class OperationsService {
|
|
|
3101
5533
|
created_at,
|
|
3102
5534
|
updated_at
|
|
3103
5535
|
) VALUES (
|
|
3104
|
-
$1, $2, $3, $4, $5, $6, $7, $8,
|
|
5536
|
+
$1, $2, $3, $4, $5, $6, $7::date, $8::date,
|
|
5537
|
+
$9::operations_project_assignment_status_155b459bbf_enum,
|
|
5538
|
+
NOW(), NOW()
|
|
3105
5539
|
)`,
|
|
3106
5540
|
projectId,
|
|
3107
5541
|
assignment.collaboratorId,
|
|
@@ -3204,8 +5638,13 @@ export class OperationsService {
|
|
|
3204
5638
|
created_at,
|
|
3205
5639
|
updated_at
|
|
3206
5640
|
) VALUES (
|
|
3207
|
-
$1, $2,
|
|
3208
|
-
|
|
5641
|
+
$1, $2,
|
|
5642
|
+
$3::operations_contract_contract_category_70d553ea09_enum,
|
|
5643
|
+
$4::operations_contract_contract_type_48331e2ebf_enum,
|
|
5644
|
+
$5, 'not_started', true,
|
|
5645
|
+
$6::operations_contract_billing_model_409dc7fea2_enum,
|
|
5646
|
+
$7, $8, 'employee_hiring', $9, $10::date, NULL,
|
|
5647
|
+
NULL, $10::date, $11, $12, 'draft', $13, NULL, $14, $14, NOW(), NOW()
|
|
3209
5648
|
)`,
|
|
3210
5649
|
contractCode,
|
|
3211
5650
|
contractName,
|
|
@@ -3231,6 +5670,7 @@ export class OperationsService {
|
|
|
3231
5670
|
createdByUserId: number,
|
|
3232
5671
|
input: {
|
|
3233
5672
|
projectId: number;
|
|
5673
|
+
contractTemplateId: number | null;
|
|
3234
5674
|
projectCode: string;
|
|
3235
5675
|
projectName: string;
|
|
3236
5676
|
clientName: string;
|
|
@@ -3248,6 +5688,83 @@ export class OperationsService {
|
|
|
3248
5688
|
description: string | null;
|
|
3249
5689
|
}
|
|
3250
5690
|
) {
|
|
5691
|
+
const templateRows = input.contractTemplateId
|
|
5692
|
+
? ((await client.$queryRawUnsafe(
|
|
5693
|
+
`SELECT id,
|
|
5694
|
+
code,
|
|
5695
|
+
name,
|
|
5696
|
+
description,
|
|
5697
|
+
contract_category AS "contractCategory",
|
|
5698
|
+
contract_type AS "contractType",
|
|
5699
|
+
billing_model AS "billingModel",
|
|
5700
|
+
signature_status AS "signatureStatus",
|
|
5701
|
+
content_html AS "contentHtml"
|
|
5702
|
+
FROM operations_contract_template
|
|
5703
|
+
WHERE id = $1
|
|
5704
|
+
AND deleted_at IS NULL
|
|
5705
|
+
LIMIT 1`,
|
|
5706
|
+
input.contractTemplateId
|
|
5707
|
+
)) as Array<{
|
|
5708
|
+
id: number;
|
|
5709
|
+
code?: string | null;
|
|
5710
|
+
name?: string | null;
|
|
5711
|
+
description?: string | null;
|
|
5712
|
+
contractCategory?: string | null;
|
|
5713
|
+
contractType?: string | null;
|
|
5714
|
+
billingModel?: string | null;
|
|
5715
|
+
signatureStatus?: string | null;
|
|
5716
|
+
contentHtml?: string | null;
|
|
5717
|
+
}>)
|
|
5718
|
+
: [];
|
|
5719
|
+
|
|
5720
|
+
const selectedTemplate = templateRows[0] ?? null;
|
|
5721
|
+
const templateContext = {
|
|
5722
|
+
project_code: input.projectCode,
|
|
5723
|
+
project_name: input.projectName,
|
|
5724
|
+
client_name: input.clientName,
|
|
5725
|
+
start_date: input.startDate ?? '',
|
|
5726
|
+
end_date: input.endDate ?? '',
|
|
5727
|
+
budget_amount:
|
|
5728
|
+
input.budgetAmount !== null && input.budgetAmount !== undefined
|
|
5729
|
+
? String(input.budgetAmount)
|
|
5730
|
+
: '',
|
|
5731
|
+
monthly_hour_cap:
|
|
5732
|
+
input.monthlyHourCap !== null && input.monthlyHourCap !== undefined
|
|
5733
|
+
? String(input.monthlyHourCap)
|
|
5734
|
+
: '',
|
|
5735
|
+
};
|
|
5736
|
+
|
|
5737
|
+
const applyTemplateVariables = (value: string | null | undefined) => {
|
|
5738
|
+
const source = value ?? '';
|
|
5739
|
+
return Object.entries(templateContext).reduce(
|
|
5740
|
+
(result, [key, replacement]) =>
|
|
5741
|
+
result.split(`{{${key}}}`).join(replacement || ''),
|
|
5742
|
+
source
|
|
5743
|
+
);
|
|
5744
|
+
};
|
|
5745
|
+
|
|
5746
|
+
const templateCodePrefix = this.normalizeOptionalText(selectedTemplate?.code);
|
|
5747
|
+
const generatedContractCode = (
|
|
5748
|
+
this.normalizeOptionalText(input.contractCode) ??
|
|
5749
|
+
(templateCodePrefix
|
|
5750
|
+
? `${templateCodePrefix}-${input.projectCode}`
|
|
5751
|
+
: null) ??
|
|
5752
|
+
`PRJ-${input.projectCode}`
|
|
5753
|
+
).slice(0, 40);
|
|
5754
|
+
|
|
5755
|
+
const generatedContractName =
|
|
5756
|
+
this.normalizeOptionalText(input.contractName) ??
|
|
5757
|
+
this.normalizeOptionalText(applyTemplateVariables(selectedTemplate?.name)) ??
|
|
5758
|
+
`${input.projectName} Service Agreement`;
|
|
5759
|
+
|
|
5760
|
+
const generatedDescription = this.normalizeOptionalText(
|
|
5761
|
+
applyTemplateVariables(input.description ?? selectedTemplate?.description)
|
|
5762
|
+
);
|
|
5763
|
+
|
|
5764
|
+
const generatedContentHtml = this.normalizeOptionalText(
|
|
5765
|
+
applyTemplateVariables(selectedTemplate?.contentHtml)
|
|
5766
|
+
);
|
|
5767
|
+
|
|
3251
5768
|
const created = await client.$queryRawUnsafe(
|
|
3252
5769
|
`INSERT INTO operations_contract (
|
|
3253
5770
|
code,
|
|
@@ -3260,6 +5777,7 @@ export class OperationsService {
|
|
|
3260
5777
|
billing_model,
|
|
3261
5778
|
account_manager_collaborator_id,
|
|
3262
5779
|
related_collaborator_id,
|
|
5780
|
+
contract_template_id,
|
|
3263
5781
|
origin_type,
|
|
3264
5782
|
origin_id,
|
|
3265
5783
|
start_date,
|
|
@@ -3276,21 +5794,50 @@ export class OperationsService {
|
|
|
3276
5794
|
created_at,
|
|
3277
5795
|
updated_at
|
|
3278
5796
|
) VALUES (
|
|
3279
|
-
$1,
|
|
3280
|
-
$
|
|
5797
|
+
$1,
|
|
5798
|
+
$2,
|
|
5799
|
+
$3::operations_contract_contract_category_70d553ea09_enum,
|
|
5800
|
+
$4::operations_contract_contract_type_48331e2ebf_enum,
|
|
5801
|
+
$5,
|
|
5802
|
+
$6::operations_contract_signature_status_2cb7282a7b_enum,
|
|
5803
|
+
true,
|
|
5804
|
+
$7::operations_contract_billing_model_409dc7fea2_enum,
|
|
5805
|
+
$8,
|
|
5806
|
+
NULL,
|
|
5807
|
+
$9,
|
|
5808
|
+
'client_project',
|
|
5809
|
+
$10,
|
|
5810
|
+
$11::date,
|
|
5811
|
+
$12::date,
|
|
5812
|
+
NULL,
|
|
5813
|
+
$11::date,
|
|
5814
|
+
$13,
|
|
5815
|
+
$14,
|
|
5816
|
+
'draft',
|
|
5817
|
+
$15,
|
|
5818
|
+
$16,
|
|
5819
|
+
$17,
|
|
5820
|
+
$17,
|
|
5821
|
+
NOW(),
|
|
5822
|
+
NOW()
|
|
3281
5823
|
)
|
|
3282
5824
|
RETURNING id`,
|
|
3283
|
-
|
|
3284
|
-
|
|
5825
|
+
generatedContractCode,
|
|
5826
|
+
generatedContractName,
|
|
5827
|
+
selectedTemplate?.contractCategory ?? 'client',
|
|
5828
|
+
selectedTemplate?.contractType ?? 'service_agreement',
|
|
3285
5829
|
input.clientName,
|
|
3286
|
-
|
|
5830
|
+
selectedTemplate?.signatureStatus ?? 'not_started',
|
|
5831
|
+
selectedTemplate?.billingModel ?? input.billingModel,
|
|
3287
5832
|
input.managerCollaboratorId,
|
|
5833
|
+
selectedTemplate?.id ?? null,
|
|
3288
5834
|
input.projectId,
|
|
3289
5835
|
input.startDate ?? new Date().toISOString().slice(0, 10),
|
|
3290
5836
|
input.endDate ?? null,
|
|
3291
5837
|
input.budgetAmount ?? null,
|
|
3292
5838
|
input.monthlyHourCap ?? null,
|
|
3293
|
-
|
|
5839
|
+
generatedDescription,
|
|
5840
|
+
generatedContentHtml,
|
|
3294
5841
|
createdByUserId
|
|
3295
5842
|
);
|
|
3296
5843
|
|
|
@@ -3325,7 +5872,10 @@ export class OperationsService {
|
|
|
3325
5872
|
created_at,
|
|
3326
5873
|
updated_at
|
|
3327
5874
|
) VALUES (
|
|
3328
|
-
$1,
|
|
5875
|
+
$1,
|
|
5876
|
+
$2::operations_contract_party_party_role_b66e8515fc_enum,
|
|
5877
|
+
$3::operations_contract_party_party_type_1383bb371c_enum,
|
|
5878
|
+
$4, $5, $6, $7, $8, NOW(), NOW()
|
|
3329
5879
|
)`,
|
|
3330
5880
|
contractId,
|
|
3331
5881
|
party.partyRole ?? 'other',
|
|
@@ -3365,7 +5915,9 @@ export class OperationsService {
|
|
|
3365
5915
|
created_at,
|
|
3366
5916
|
updated_at
|
|
3367
5917
|
) VALUES (
|
|
3368
|
-
$1, $2, $3, $4,
|
|
5918
|
+
$1, $2, $3, $4,
|
|
5919
|
+
$5::operations_contract_signature_signer_status_1e6fbe2519_enum,
|
|
5920
|
+
$6::timestamp, NOW(), NOW()
|
|
3369
5921
|
)`,
|
|
3370
5922
|
contractId,
|
|
3371
5923
|
signature.signerName,
|
|
@@ -3404,7 +5956,14 @@ export class OperationsService {
|
|
|
3404
5956
|
created_at,
|
|
3405
5957
|
updated_at
|
|
3406
5958
|
) VALUES (
|
|
3407
|
-
$1,
|
|
5959
|
+
$1,
|
|
5960
|
+
$2::operations_contract_financial_term_term_type_700635c06a_enum,
|
|
5961
|
+
$3,
|
|
5962
|
+
$4,
|
|
5963
|
+
$5::operations_contract_financial_term_recurrence_ba90bbe3bf_enum,
|
|
5964
|
+
$6,
|
|
5965
|
+
$7,
|
|
5966
|
+
NOW(), NOW()
|
|
3408
5967
|
)`,
|
|
3409
5968
|
contractId,
|
|
3410
5969
|
term.termType ?? 'value',
|
|
@@ -3443,7 +6002,13 @@ export class OperationsService {
|
|
|
3443
6002
|
created_at,
|
|
3444
6003
|
updated_at
|
|
3445
6004
|
) VALUES (
|
|
3446
|
-
$1,
|
|
6005
|
+
$1,
|
|
6006
|
+
$2::operations_contract_revision_revision_type_cf5ba1a538_enum,
|
|
6007
|
+
$3,
|
|
6008
|
+
$4::date,
|
|
6009
|
+
$5::operations_contract_revision_status_f44f35bb66_enum,
|
|
6010
|
+
$6,
|
|
6011
|
+
NOW(), NOW()
|
|
3447
6012
|
)`,
|
|
3448
6013
|
contractId,
|
|
3449
6014
|
revision.revisionType ?? 'revision',
|
|
@@ -3455,9 +6020,10 @@ export class OperationsService {
|
|
|
3455
6020
|
}
|
|
3456
6021
|
}
|
|
3457
6022
|
|
|
3458
|
-
private async
|
|
6023
|
+
private async replaceContractDocument(
|
|
3459
6024
|
client: any,
|
|
3460
6025
|
contractId: number,
|
|
6026
|
+
documentType: (typeof CONTRACT_DOCUMENT_TYPE_VALUES)[number],
|
|
3461
6027
|
document: NonNullable<ContractPayload['replaceUploadedPdfDocument']>
|
|
3462
6028
|
) {
|
|
3463
6029
|
await client.$executeRawUnsafe(
|
|
@@ -3466,32 +6032,445 @@ export class OperationsService {
|
|
|
3466
6032
|
updated_at = NOW()
|
|
3467
6033
|
WHERE contract_id = $1
|
|
3468
6034
|
AND deleted_at IS NULL
|
|
3469
|
-
AND document_type
|
|
3470
|
-
contractId
|
|
6035
|
+
AND document_type = $2`,
|
|
6036
|
+
contractId,
|
|
6037
|
+
documentType
|
|
3471
6038
|
);
|
|
3472
6039
|
|
|
3473
6040
|
await client.$executeRawUnsafe(
|
|
3474
6041
|
`INSERT INTO operations_contract_document (
|
|
3475
6042
|
contract_id,
|
|
3476
6043
|
document_type,
|
|
6044
|
+
file_id,
|
|
3477
6045
|
file_name,
|
|
3478
6046
|
mime_type,
|
|
3479
6047
|
file_content_base64,
|
|
3480
6048
|
is_current,
|
|
6049
|
+
extraction_status,
|
|
6050
|
+
extraction_summary,
|
|
3481
6051
|
notes,
|
|
3482
6052
|
created_at,
|
|
3483
6053
|
updated_at
|
|
3484
6054
|
) VALUES (
|
|
3485
|
-
$1,
|
|
6055
|
+
$1, $2, $3, $4, $5, $6, true, $7, $8, $9, NOW(), NOW()
|
|
3486
6056
|
)`,
|
|
3487
6057
|
contractId,
|
|
6058
|
+
documentType,
|
|
6059
|
+
document.fileId ?? null,
|
|
3488
6060
|
document.fileName,
|
|
3489
6061
|
document.mimeType,
|
|
3490
|
-
document.fileContentBase64,
|
|
6062
|
+
document.fileContentBase64 ?? null,
|
|
6063
|
+
document.extractionStatus ?? 'skipped',
|
|
6064
|
+
document.extractionSummary ?? null,
|
|
3491
6065
|
document.notes ?? null
|
|
3492
6066
|
);
|
|
3493
6067
|
}
|
|
3494
6068
|
|
|
6069
|
+
private async resolveContractExtractionFile(
|
|
6070
|
+
userId: number,
|
|
6071
|
+
data: ContractExtractDraftPayload
|
|
6072
|
+
): Promise<MulterFile> {
|
|
6073
|
+
if (data.contractId && data.contractId > 0) {
|
|
6074
|
+
const contract = await this.getContractById(userId, data.contractId);
|
|
6075
|
+
const sourceDocument = contract.documents.find(
|
|
6076
|
+
(document: any) =>
|
|
6077
|
+
document.isCurrent && document.documentType === 'source_upload'
|
|
6078
|
+
);
|
|
6079
|
+
|
|
6080
|
+
if (!sourceDocument) {
|
|
6081
|
+
throw new BadRequestException(
|
|
6082
|
+
'No source document is attached to this contract draft.'
|
|
6083
|
+
);
|
|
6084
|
+
}
|
|
6085
|
+
|
|
6086
|
+
if (sourceDocument.fileId) {
|
|
6087
|
+
const { buffer } = await this.fileService.getBuffer(sourceDocument.fileId);
|
|
6088
|
+
return this.buildContractExtractionFile(
|
|
6089
|
+
sourceDocument.fileName,
|
|
6090
|
+
sourceDocument.mimeType,
|
|
6091
|
+
buffer
|
|
6092
|
+
);
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6095
|
+
const base64 = this.normalizeExtractionString(
|
|
6096
|
+
sourceDocument.fileContentBase64
|
|
6097
|
+
);
|
|
6098
|
+
|
|
6099
|
+
if (!base64) {
|
|
6100
|
+
throw new BadRequestException(
|
|
6101
|
+
'The current source document does not contain readable file content.'
|
|
6102
|
+
);
|
|
6103
|
+
}
|
|
6104
|
+
|
|
6105
|
+
return this.buildContractExtractionFile(
|
|
6106
|
+
sourceDocument.fileName,
|
|
6107
|
+
sourceDocument.mimeType,
|
|
6108
|
+
Buffer.from(base64, 'base64')
|
|
6109
|
+
);
|
|
6110
|
+
}
|
|
6111
|
+
|
|
6112
|
+
const fileName = this.normalizeExtractionString(data.fileName);
|
|
6113
|
+
const fileContentBase64 = this.normalizeExtractionString(
|
|
6114
|
+
data.fileContentBase64
|
|
6115
|
+
);
|
|
6116
|
+
const mimeType = this.normalizeContractExtractionMimeType(
|
|
6117
|
+
data.mimeType,
|
|
6118
|
+
fileName
|
|
6119
|
+
);
|
|
6120
|
+
|
|
6121
|
+
if (!fileName || !fileContentBase64) {
|
|
6122
|
+
throw new BadRequestException(
|
|
6123
|
+
'fileName and fileContentBase64 are required.'
|
|
6124
|
+
);
|
|
6125
|
+
}
|
|
6126
|
+
|
|
6127
|
+
return this.buildContractExtractionFile(
|
|
6128
|
+
fileName,
|
|
6129
|
+
mimeType,
|
|
6130
|
+
Buffer.from(fileContentBase64, 'base64')
|
|
6131
|
+
);
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
private buildContractExtractionFile(
|
|
6135
|
+
fileName: string,
|
|
6136
|
+
mimeType: string,
|
|
6137
|
+
buffer: Buffer
|
|
6138
|
+
): MulterFile {
|
|
6139
|
+
if (!this.isAllowedContractExtractionMimeType(mimeType)) {
|
|
6140
|
+
throw new BadRequestException(
|
|
6141
|
+
'Only PDF, DOC, and DOCX files are supported for contract extraction.'
|
|
6142
|
+
);
|
|
6143
|
+
}
|
|
6144
|
+
|
|
6145
|
+
if (!buffer.length) {
|
|
6146
|
+
throw new BadRequestException(
|
|
6147
|
+
'Unable to decode the uploaded contract document.'
|
|
6148
|
+
);
|
|
6149
|
+
}
|
|
6150
|
+
|
|
6151
|
+
if (buffer.length > 12 * 1024 * 1024) {
|
|
6152
|
+
throw new BadRequestException(
|
|
6153
|
+
'The uploaded contract document must be smaller than 12 MB.'
|
|
6154
|
+
);
|
|
6155
|
+
}
|
|
6156
|
+
|
|
6157
|
+
return {
|
|
6158
|
+
fieldname: 'file',
|
|
6159
|
+
originalname: fileName,
|
|
6160
|
+
encoding: 'base64',
|
|
6161
|
+
mimetype: mimeType,
|
|
6162
|
+
size: buffer.length,
|
|
6163
|
+
destination: '',
|
|
6164
|
+
filename: fileName,
|
|
6165
|
+
path: '',
|
|
6166
|
+
buffer,
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
|
|
6170
|
+
private async renderContractPdfBuffer(contract: any) {
|
|
6171
|
+
const html = await this.buildContractPdfHtml(contract);
|
|
6172
|
+
|
|
6173
|
+
let browser: any = null;
|
|
6174
|
+
|
|
6175
|
+
try {
|
|
6176
|
+
const importPlaywright = new Function(
|
|
6177
|
+
'moduleName',
|
|
6178
|
+
'return import(moduleName);'
|
|
6179
|
+
) as (moduleName: string) => Promise<any>;
|
|
6180
|
+
const playwright = await importPlaywright('playwright');
|
|
6181
|
+
browser = await playwright.chromium.launch({ headless: true });
|
|
6182
|
+
const page = await browser.newPage();
|
|
6183
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
6184
|
+
return Buffer.from(
|
|
6185
|
+
await page.pdf({
|
|
6186
|
+
format: 'A4',
|
|
6187
|
+
printBackground: true,
|
|
6188
|
+
margin: {
|
|
6189
|
+
top: '72px',
|
|
6190
|
+
right: '18px',
|
|
6191
|
+
bottom: '56px',
|
|
6192
|
+
left: '18px',
|
|
6193
|
+
},
|
|
6194
|
+
})
|
|
6195
|
+
);
|
|
6196
|
+
} catch (error) {
|
|
6197
|
+
throw new BadRequestException(
|
|
6198
|
+
'PDF generation requires Playwright to be installed on the server.'
|
|
6199
|
+
);
|
|
6200
|
+
} finally {
|
|
6201
|
+
await browser?.close?.();
|
|
6202
|
+
}
|
|
6203
|
+
}
|
|
6204
|
+
|
|
6205
|
+
private async buildContractPdfHtml(contract: any) {
|
|
6206
|
+
const logoUrl = await this.resolveContractPdfLogoUrl();
|
|
6207
|
+
const contentHtml =
|
|
6208
|
+
this.normalizeOptionalText(contract.contentHtml) ??
|
|
6209
|
+
this.normalizeOptionalText(contract.description)
|
|
6210
|
+
?.split(/\n{2,}/)
|
|
6211
|
+
.map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`)
|
|
6212
|
+
.join('') ??
|
|
6213
|
+
'<p>No contract body was provided yet.</p>';
|
|
6214
|
+
const partiesHtml = (contract.parties ?? []).length
|
|
6215
|
+
? contract.parties
|
|
6216
|
+
.map(
|
|
6217
|
+
(party: any) => `
|
|
6218
|
+
<li>
|
|
6219
|
+
<strong>${this.escapeHtml(party.displayName || 'Party')}</strong>
|
|
6220
|
+
<span>${this.escapeHtml(
|
|
6221
|
+
[party.partyRole, party.partyType].filter(Boolean).join(' • ')
|
|
6222
|
+
)}</span>
|
|
6223
|
+
</li>`
|
|
6224
|
+
)
|
|
6225
|
+
.join('')
|
|
6226
|
+
: '<li><strong>No parties registered yet.</strong><span>Complete this contract later if needed.</span></li>';
|
|
6227
|
+
const financialTermsHtml = (contract.financialTerms ?? []).length
|
|
6228
|
+
? contract.financialTerms
|
|
6229
|
+
.map(
|
|
6230
|
+
(term: any) => `
|
|
6231
|
+
<li>
|
|
6232
|
+
<strong>${this.escapeHtml(term.label || 'Term')}</strong>
|
|
6233
|
+
<span>${this.escapeHtml(
|
|
6234
|
+
[term.termType, term.amount, term.recurrence]
|
|
6235
|
+
.filter((item) => item !== null && item !== undefined && String(item).trim())
|
|
6236
|
+
.join(' • ')
|
|
6237
|
+
)}</span>
|
|
6238
|
+
</li>`
|
|
6239
|
+
)
|
|
6240
|
+
.join('')
|
|
6241
|
+
: '<li><strong>No financial terms registered yet.</strong><span>The draft is intentionally lightweight.</span></li>';
|
|
6242
|
+
|
|
6243
|
+
return `<!DOCTYPE html>
|
|
6244
|
+
<html lang="en">
|
|
6245
|
+
<head>
|
|
6246
|
+
<meta charset="utf-8" />
|
|
6247
|
+
<style>
|
|
6248
|
+
@page {
|
|
6249
|
+
size: A4;
|
|
6250
|
+
margin: 72px 18px 56px 18px;
|
|
6251
|
+
}
|
|
6252
|
+
body {
|
|
6253
|
+
color: #0f172a;
|
|
6254
|
+
font-family: Arial, sans-serif;
|
|
6255
|
+
font-size: 12px;
|
|
6256
|
+
line-height: 1.5;
|
|
6257
|
+
margin: 0;
|
|
6258
|
+
}
|
|
6259
|
+
header {
|
|
6260
|
+
align-items: center;
|
|
6261
|
+
border-bottom: 2px solid #dbeafe;
|
|
6262
|
+
display: flex;
|
|
6263
|
+
gap: 16px;
|
|
6264
|
+
justify-content: space-between;
|
|
6265
|
+
margin-bottom: 24px;
|
|
6266
|
+
padding-bottom: 16px;
|
|
6267
|
+
}
|
|
6268
|
+
.brand {
|
|
6269
|
+
align-items: center;
|
|
6270
|
+
display: flex;
|
|
6271
|
+
gap: 12px;
|
|
6272
|
+
}
|
|
6273
|
+
.brand img {
|
|
6274
|
+
height: 42px;
|
|
6275
|
+
max-width: 140px;
|
|
6276
|
+
object-fit: contain;
|
|
6277
|
+
}
|
|
6278
|
+
.eyebrow {
|
|
6279
|
+
color: #2563eb;
|
|
6280
|
+
font-size: 10px;
|
|
6281
|
+
font-weight: 700;
|
|
6282
|
+
letter-spacing: 0.12em;
|
|
6283
|
+
text-transform: uppercase;
|
|
6284
|
+
}
|
|
6285
|
+
h1 {
|
|
6286
|
+
font-size: 22px;
|
|
6287
|
+
margin: 4px 0 0;
|
|
6288
|
+
}
|
|
6289
|
+
.meta-grid {
|
|
6290
|
+
display: grid;
|
|
6291
|
+
gap: 12px;
|
|
6292
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
6293
|
+
margin: 20px 0;
|
|
6294
|
+
}
|
|
6295
|
+
.meta-card {
|
|
6296
|
+
background: #f8fafc;
|
|
6297
|
+
border: 1px solid #dbeafe;
|
|
6298
|
+
border-radius: 12px;
|
|
6299
|
+
padding: 12px;
|
|
6300
|
+
}
|
|
6301
|
+
.meta-card strong {
|
|
6302
|
+
display: block;
|
|
6303
|
+
font-size: 10px;
|
|
6304
|
+
letter-spacing: 0.08em;
|
|
6305
|
+
margin-bottom: 4px;
|
|
6306
|
+
text-transform: uppercase;
|
|
6307
|
+
}
|
|
6308
|
+
section {
|
|
6309
|
+
margin-top: 20px;
|
|
6310
|
+
}
|
|
6311
|
+
h2 {
|
|
6312
|
+
color: #1d4ed8;
|
|
6313
|
+
font-size: 14px;
|
|
6314
|
+
margin: 0 0 10px;
|
|
6315
|
+
}
|
|
6316
|
+
ul {
|
|
6317
|
+
list-style: none;
|
|
6318
|
+
margin: 0;
|
|
6319
|
+
padding: 0;
|
|
6320
|
+
}
|
|
6321
|
+
li {
|
|
6322
|
+
border: 1px solid #e2e8f0;
|
|
6323
|
+
border-radius: 10px;
|
|
6324
|
+
margin-bottom: 8px;
|
|
6325
|
+
padding: 10px 12px;
|
|
6326
|
+
}
|
|
6327
|
+
li strong {
|
|
6328
|
+
display: block;
|
|
6329
|
+
margin-bottom: 2px;
|
|
6330
|
+
}
|
|
6331
|
+
footer {
|
|
6332
|
+
border-top: 1px solid #dbeafe;
|
|
6333
|
+
color: #475569;
|
|
6334
|
+
font-size: 10px;
|
|
6335
|
+
margin-top: 28px;
|
|
6336
|
+
padding-top: 12px;
|
|
6337
|
+
}
|
|
6338
|
+
.content {
|
|
6339
|
+
border: 1px solid #e2e8f0;
|
|
6340
|
+
border-radius: 14px;
|
|
6341
|
+
padding: 18px;
|
|
6342
|
+
}
|
|
6343
|
+
.content p:first-child {
|
|
6344
|
+
margin-top: 0;
|
|
6345
|
+
}
|
|
6346
|
+
</style>
|
|
6347
|
+
</head>
|
|
6348
|
+
<body>
|
|
6349
|
+
<header>
|
|
6350
|
+
<div class="brand">
|
|
6351
|
+
<img src="${logoUrl}" alt="System logo" />
|
|
6352
|
+
<div>
|
|
6353
|
+
<div class="eyebrow">Contract Document</div>
|
|
6354
|
+
<h1>${this.escapeHtml(
|
|
6355
|
+
contract.name || contract.code || 'Contract draft'
|
|
6356
|
+
)}</h1>
|
|
6357
|
+
</div>
|
|
6358
|
+
</div>
|
|
6359
|
+
<div>
|
|
6360
|
+
<div><strong>Code:</strong> ${this.escapeHtml(
|
|
6361
|
+
contract.code || 'Draft'
|
|
6362
|
+
)}</div>
|
|
6363
|
+
<div><strong>Status:</strong> ${this.escapeHtml(
|
|
6364
|
+
this.humanizeEnumLabel(contract.status || 'draft')
|
|
6365
|
+
)}</div>
|
|
6366
|
+
</div>
|
|
6367
|
+
</header>
|
|
6368
|
+
|
|
6369
|
+
<div class="meta-grid">
|
|
6370
|
+
<div class="meta-card">
|
|
6371
|
+
<strong>Client</strong>
|
|
6372
|
+
${this.escapeHtml(contract.clientName || 'Not informed yet')}
|
|
6373
|
+
</div>
|
|
6374
|
+
<div class="meta-card">
|
|
6375
|
+
<strong>Type</strong>
|
|
6376
|
+
${this.escapeHtml(
|
|
6377
|
+
this.humanizeEnumLabel(contract.contractType || 'service_agreement')
|
|
6378
|
+
)}
|
|
6379
|
+
</div>
|
|
6380
|
+
<div class="meta-card">
|
|
6381
|
+
<strong>Period</strong>
|
|
6382
|
+
${this.escapeHtml(
|
|
6383
|
+
[contract.startDate || 'Open start', contract.endDate || 'Open end'].join(
|
|
6384
|
+
' - '
|
|
6385
|
+
)
|
|
6386
|
+
)}
|
|
6387
|
+
</div>
|
|
6388
|
+
<div class="meta-card">
|
|
6389
|
+
<strong>Billing</strong>
|
|
6390
|
+
${this.escapeHtml(
|
|
6391
|
+
this.humanizeEnumLabel(contract.billingModel || 'time_and_material')
|
|
6392
|
+
)}
|
|
6393
|
+
</div>
|
|
6394
|
+
</div>
|
|
6395
|
+
|
|
6396
|
+
<section>
|
|
6397
|
+
<h2>Related Parties</h2>
|
|
6398
|
+
<ul>${partiesHtml}</ul>
|
|
6399
|
+
</section>
|
|
6400
|
+
|
|
6401
|
+
<section>
|
|
6402
|
+
<h2>Financial Terms</h2>
|
|
6403
|
+
<ul>${financialTermsHtml}</ul>
|
|
6404
|
+
</section>
|
|
6405
|
+
|
|
6406
|
+
<section>
|
|
6407
|
+
<h2>Contract Body</h2>
|
|
6408
|
+
<div class="content">${contentHtml}</div>
|
|
6409
|
+
</section>
|
|
6410
|
+
|
|
6411
|
+
<footer>
|
|
6412
|
+
Generated on ${this.escapeHtml(new Date().toISOString())} for ${
|
|
6413
|
+
this.escapeHtml(contract.code || 'draft')
|
|
6414
|
+
}.
|
|
6415
|
+
</footer>
|
|
6416
|
+
</body>
|
|
6417
|
+
</html>`;
|
|
6418
|
+
}
|
|
6419
|
+
|
|
6420
|
+
private async resolveContractPdfLogoUrl() {
|
|
6421
|
+
const settings = await this.settingService.getSettingValues([
|
|
6422
|
+
'image-url',
|
|
6423
|
+
'icon-url',
|
|
6424
|
+
]);
|
|
6425
|
+
const configuredUrl =
|
|
6426
|
+
this.normalizeOptionalText(settings['image-url']) ??
|
|
6427
|
+
this.normalizeOptionalText(settings['icon-url']);
|
|
6428
|
+
|
|
6429
|
+
if (configuredUrl && /^https?:\/\//i.test(configuredUrl)) {
|
|
6430
|
+
return configuredUrl;
|
|
6431
|
+
}
|
|
6432
|
+
|
|
6433
|
+
const inlineSvg = encodeURIComponent(
|
|
6434
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
|
|
6435
|
+
<rect width="240" height="72" rx="18" fill="#0f172a"/>
|
|
6436
|
+
<circle cx="38" cy="36" r="18" fill="#3b82f6"/>
|
|
6437
|
+
<text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>
|
|
6438
|
+
</svg>`
|
|
6439
|
+
);
|
|
6440
|
+
|
|
6441
|
+
return `data:image/svg+xml;charset=UTF-8,${inlineSvg}`;
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6444
|
+
private buildContractPdfFileName(contract: any) {
|
|
6445
|
+
const base =
|
|
6446
|
+
this.normalizeOptionalText(contract.code) ??
|
|
6447
|
+
this.normalizeOptionalText(contract.name) ??
|
|
6448
|
+
`contract-${contract.id}`;
|
|
6449
|
+
|
|
6450
|
+
return `${base
|
|
6451
|
+
.toLowerCase()
|
|
6452
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
6453
|
+
.replace(/^-+|-+$/g, '')
|
|
6454
|
+
.slice(0, 80) || `contract-${contract.id}`}.pdf`;
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6457
|
+
private humanizeEnumLabel(value: string) {
|
|
6458
|
+
return String(value ?? '')
|
|
6459
|
+
.split('_')
|
|
6460
|
+
.filter(Boolean)
|
|
6461
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
6462
|
+
.join(' ');
|
|
6463
|
+
}
|
|
6464
|
+
|
|
6465
|
+
private escapeHtml(value: string) {
|
|
6466
|
+
return String(value ?? '')
|
|
6467
|
+
.replace(/&/g, '&')
|
|
6468
|
+
.replace(/</g, '<')
|
|
6469
|
+
.replace(/>/g, '>')
|
|
6470
|
+
.replace(/"/g, '"')
|
|
6471
|
+
.replace(/'/g, ''');
|
|
6472
|
+
}
|
|
6473
|
+
|
|
3495
6474
|
private async insertContractHistory(
|
|
3496
6475
|
client: any,
|
|
3497
6476
|
contractId: number,
|
|
@@ -3519,6 +6498,745 @@ export class OperationsService {
|
|
|
3519
6498
|
);
|
|
3520
6499
|
}
|
|
3521
6500
|
|
|
6501
|
+
private async generateCollaboratorCode(client: any) {
|
|
6502
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
6503
|
+
const candidate = `COL-${Date.now().toString(36).toUpperCase()}${Math.random()
|
|
6504
|
+
.toString(36)
|
|
6505
|
+
.slice(2, 6)
|
|
6506
|
+
.toUpperCase()}`;
|
|
6507
|
+
const existing = (await client.$queryRawUnsafe(
|
|
6508
|
+
`SELECT code
|
|
6509
|
+
FROM operations_collaborator
|
|
6510
|
+
WHERE code = $1
|
|
6511
|
+
LIMIT 1`,
|
|
6512
|
+
candidate
|
|
6513
|
+
)) as { code: string }[];
|
|
6514
|
+
|
|
6515
|
+
if (!existing.length) {
|
|
6516
|
+
return candidate;
|
|
6517
|
+
}
|
|
6518
|
+
}
|
|
6519
|
+
|
|
6520
|
+
throw new BadRequestException('Unable to generate collaborator code.');
|
|
6521
|
+
}
|
|
6522
|
+
|
|
6523
|
+
private async generateContractCode(client: any) {
|
|
6524
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
6525
|
+
const candidate = `CTR-${Date.now().toString(36).toUpperCase()}${Math.random()
|
|
6526
|
+
.toString(36)
|
|
6527
|
+
.slice(2, 6)
|
|
6528
|
+
.toUpperCase()}`.slice(0, 40);
|
|
6529
|
+
const existing = (await client.$queryRawUnsafe(
|
|
6530
|
+
`SELECT code
|
|
6531
|
+
FROM operations_contract
|
|
6532
|
+
WHERE code = $1
|
|
6533
|
+
LIMIT 1`,
|
|
6534
|
+
candidate
|
|
6535
|
+
)) as { code: string }[];
|
|
6536
|
+
|
|
6537
|
+
if (!existing.length) {
|
|
6538
|
+
return candidate;
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
|
|
6542
|
+
throw new BadRequestException('Unable to generate contract code.');
|
|
6543
|
+
}
|
|
6544
|
+
|
|
6545
|
+
private async generateContractContentHtml(
|
|
6546
|
+
contract: any,
|
|
6547
|
+
data: ContractGenerateContentPayload = {}
|
|
6548
|
+
) {
|
|
6549
|
+
const currentContent = this.normalizeOptionalText(contract.contentHtml);
|
|
6550
|
+
|
|
6551
|
+
const primaryParty =
|
|
6552
|
+
(contract.parties ?? []).find((party: any) => party.isPrimary) ??
|
|
6553
|
+
(contract.parties ?? [])[0] ??
|
|
6554
|
+
null;
|
|
6555
|
+
const templateContext = {
|
|
6556
|
+
contract_code: this.normalizeOptionalText(contract.code) ?? '',
|
|
6557
|
+
contract_name: this.normalizeOptionalText(contract.name) ?? '',
|
|
6558
|
+
client_name:
|
|
6559
|
+
this.normalizeOptionalText(contract.clientName) ??
|
|
6560
|
+
this.normalizeOptionalText(primaryParty?.displayName) ??
|
|
6561
|
+
'',
|
|
6562
|
+
contract_category: this.normalizeOptionalText(contract.contractCategory) ?? '',
|
|
6563
|
+
contract_type: this.normalizeOptionalText(contract.contractType) ?? '',
|
|
6564
|
+
start_date: this.normalizeOptionalText(contract.startDate) ?? '',
|
|
6565
|
+
end_date: this.normalizeOptionalText(contract.endDate) ?? '',
|
|
6566
|
+
effective_date: this.normalizeOptionalText(contract.effectiveDate) ?? '',
|
|
6567
|
+
signed_at: this.normalizeOptionalText(contract.signedAt) ?? '',
|
|
6568
|
+
budget_amount:
|
|
6569
|
+
contract.budgetAmount !== null && contract.budgetAmount !== undefined
|
|
6570
|
+
? String(contract.budgetAmount)
|
|
6571
|
+
: '',
|
|
6572
|
+
monthly_hour_cap:
|
|
6573
|
+
contract.monthlyHourCap !== null &&
|
|
6574
|
+
contract.monthlyHourCap !== undefined
|
|
6575
|
+
? String(contract.monthlyHourCap)
|
|
6576
|
+
: '',
|
|
6577
|
+
primary_party_name:
|
|
6578
|
+
this.normalizeOptionalText(primaryParty?.displayName) ?? '',
|
|
6579
|
+
primary_party_document:
|
|
6580
|
+
this.normalizeOptionalText(primaryParty?.documentNumber) ?? '',
|
|
6581
|
+
primary_party_email: this.normalizeOptionalText(primaryParty?.email) ?? '',
|
|
6582
|
+
primary_party_phone: this.normalizeOptionalText(primaryParty?.phone) ?? '',
|
|
6583
|
+
description: this.normalizeOptionalText(contract.description) ?? '',
|
|
6584
|
+
};
|
|
6585
|
+
|
|
6586
|
+
const applyTemplateVariables = (value: string | null | undefined) => {
|
|
6587
|
+
const source = value ?? '';
|
|
6588
|
+
return Object.entries(templateContext).reduce(
|
|
6589
|
+
(result, [key, replacement]) =>
|
|
6590
|
+
result.split(`{{${key}}}`).join(replacement || ''),
|
|
6591
|
+
source
|
|
6592
|
+
);
|
|
6593
|
+
};
|
|
6594
|
+
|
|
6595
|
+
const currentHtmlWithVariables = this.normalizeOptionalText(
|
|
6596
|
+
applyTemplateVariables(currentContent)
|
|
6597
|
+
);
|
|
6598
|
+
|
|
6599
|
+
if (currentHtmlWithVariables && data.overwrite !== true) {
|
|
6600
|
+
return currentHtmlWithVariables;
|
|
6601
|
+
}
|
|
6602
|
+
|
|
6603
|
+
const baseHtml =
|
|
6604
|
+
currentHtmlWithVariables ?? this.buildFallbackContractContent(contract);
|
|
6605
|
+
const promptMessage =
|
|
6606
|
+
this.normalizeOptionalText(data.promptMessage) ??
|
|
6607
|
+
'Generate an initial contract body using the registered metadata and keep the language concise, professional, and ready for legal review.';
|
|
6608
|
+
|
|
6609
|
+
try {
|
|
6610
|
+
const aiResult = await this.aiService.chat({
|
|
6611
|
+
provider: data.provider === 'gemini' ? 'gemini' : 'openai',
|
|
6612
|
+
model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
|
|
6613
|
+
message: promptMessage,
|
|
6614
|
+
systemPrompt: [
|
|
6615
|
+
'You draft professional business contract content for an operations back-office system.',
|
|
6616
|
+
'Return ONLY clean HTML suitable for a rich text editor preview.',
|
|
6617
|
+
'Do not include Markdown fences, commentary, or legal disclaimers inside the HTML.',
|
|
6618
|
+
'Keep the output concise, well-structured, and ready for legal revision.',
|
|
6619
|
+
'If a specific detail is missing, preserve placeholders like {{client_name}}, {{start_date}}, {{end_date}}, {{budget_amount}}, and {{monthly_hour_cap}} instead of inventing facts.',
|
|
6620
|
+
`Contract context:\n${JSON.stringify(templateContext, null, 2)}`,
|
|
6621
|
+
baseHtml
|
|
6622
|
+
? `Current base HTML:\n${baseHtml}`
|
|
6623
|
+
: 'There is no current HTML yet.',
|
|
6624
|
+
].join('\n\n'),
|
|
6625
|
+
});
|
|
6626
|
+
|
|
6627
|
+
return (
|
|
6628
|
+
this.normalizeOptionalText(String(aiResult?.content ?? '')) ?? baseHtml
|
|
6629
|
+
);
|
|
6630
|
+
} catch {
|
|
6631
|
+
return baseHtml;
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
|
|
6635
|
+
private buildFallbackContractContent(contract: any) {
|
|
6636
|
+
const title = this.escapeHtml(
|
|
6637
|
+
this.normalizeOptionalText(contract.name) ??
|
|
6638
|
+
this.normalizeOptionalText(contract.code) ??
|
|
6639
|
+
'Contract Draft'
|
|
6640
|
+
);
|
|
6641
|
+
const description = this.escapeHtml(
|
|
6642
|
+
this.normalizeOptionalText(contract.description) ??
|
|
6643
|
+
'This draft summarizes the main commercial and operational conditions agreed between the parties.'
|
|
6644
|
+
);
|
|
6645
|
+
const validityItems = [
|
|
6646
|
+
contract.startDate ? `Start date: ${contract.startDate}` : null,
|
|
6647
|
+
contract.endDate ? `End date: ${contract.endDate}` : null,
|
|
6648
|
+
contract.effectiveDate ? `Effective date: ${contract.effectiveDate}` : null,
|
|
6649
|
+
contract.signedAt ? `Signed at: ${contract.signedAt}` : null,
|
|
6650
|
+
]
|
|
6651
|
+
.filter(Boolean)
|
|
6652
|
+
.map((item) => `<li>${this.escapeHtml(String(item))}</li>`)
|
|
6653
|
+
.join('');
|
|
6654
|
+
const partiesHtml = (contract.parties ?? []).length
|
|
6655
|
+
? (contract.parties ?? [])
|
|
6656
|
+
.map(
|
|
6657
|
+
(party: any) => `
|
|
6658
|
+
<li>
|
|
6659
|
+
<strong>${this.escapeHtml(party.displayName || 'Party')}</strong>
|
|
6660
|
+
<span>${this.escapeHtml(
|
|
6661
|
+
[party.partyRole, party.partyType, party.documentNumber]
|
|
6662
|
+
.filter(Boolean)
|
|
6663
|
+
.join(' • ')
|
|
6664
|
+
)}</span>
|
|
6665
|
+
</li>`
|
|
6666
|
+
)
|
|
6667
|
+
.join('')
|
|
6668
|
+
: '<li><strong>Main party</strong><span>{{client_name}}</span></li>';
|
|
6669
|
+
const financialTermsHtml = (contract.financialTerms ?? []).length
|
|
6670
|
+
? (contract.financialTerms ?? [])
|
|
6671
|
+
.map(
|
|
6672
|
+
(term: any) => `
|
|
6673
|
+
<li>
|
|
6674
|
+
<strong>${this.escapeHtml(term.label || 'Financial term')}</strong>
|
|
6675
|
+
<span>${this.escapeHtml(
|
|
6676
|
+
[term.termType, term.amount, term.recurrence]
|
|
6677
|
+
.filter(
|
|
6678
|
+
(item) =>
|
|
6679
|
+
item !== null &&
|
|
6680
|
+
item !== undefined &&
|
|
6681
|
+
String(item).trim()
|
|
6682
|
+
)
|
|
6683
|
+
.join(' • ')
|
|
6684
|
+
)}</span>
|
|
6685
|
+
</li>`
|
|
6686
|
+
)
|
|
6687
|
+
.join('')
|
|
6688
|
+
: '<li><strong>Commercial conditions</strong><span>Define prices, billing cadence, and penalties before signature.</span></li>';
|
|
6689
|
+
|
|
6690
|
+
return `
|
|
6691
|
+
<h1>${title}</h1>
|
|
6692
|
+
<p>${description}</p>
|
|
6693
|
+
<h2>1. Parties</h2>
|
|
6694
|
+
<ul>${partiesHtml}</ul>
|
|
6695
|
+
<h2>2. Scope and object</h2>
|
|
6696
|
+
<p>The parties agree to execute the services and obligations described in this contract, according to the agreed operational, technical, and commercial conditions.</p>
|
|
6697
|
+
<h2>3. Validity</h2>
|
|
6698
|
+
${validityItems ? `<ul>${validityItems}</ul>` : '<p>Define start date, term, renewal, and termination conditions.</p>'}
|
|
6699
|
+
<h2>4. Financial terms</h2>
|
|
6700
|
+
<ul>${financialTermsHtml}</ul>
|
|
6701
|
+
<h2>5. General obligations</h2>
|
|
6702
|
+
<p>Review confidentiality, data protection, service levels, termination, and liability clauses before formal approval.</p>
|
|
6703
|
+
`;
|
|
6704
|
+
}
|
|
6705
|
+
|
|
6706
|
+
private async buildContractLegalReview(
|
|
6707
|
+
contract: any,
|
|
6708
|
+
data: ContractLegalReviewPayload = {}
|
|
6709
|
+
): Promise<ContractLegalReviewResult> {
|
|
6710
|
+
const heuristicReview = this.buildHeuristicContractLegalReview(contract);
|
|
6711
|
+
const promptMessage =
|
|
6712
|
+
this.normalizeOptionalText(data.promptMessage) ??
|
|
6713
|
+
'Review this contract draft and return an advisory legal checklist with practical warnings and missing items.';
|
|
6714
|
+
|
|
6715
|
+
try {
|
|
6716
|
+
const aiResult = await this.aiService.chat({
|
|
6717
|
+
provider: data.provider === 'gemini' ? 'gemini' : 'openai',
|
|
6718
|
+
model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
|
|
6719
|
+
message: promptMessage,
|
|
6720
|
+
systemPrompt: [
|
|
6721
|
+
'You are an advisory legal-review assistant for business contracts.',
|
|
6722
|
+
'Return ONLY valid JSON. Do not include Markdown fences or commentary.',
|
|
6723
|
+
'This review is non-blocking and should help a human reviewer spot likely gaps.',
|
|
6724
|
+
'Expected JSON shape:',
|
|
6725
|
+
JSON.stringify(
|
|
6726
|
+
{
|
|
6727
|
+
summary: '',
|
|
6728
|
+
missingFields: [],
|
|
6729
|
+
warnings: [],
|
|
6730
|
+
checklist: [],
|
|
6731
|
+
status: 'attention_required',
|
|
6732
|
+
},
|
|
6733
|
+
null,
|
|
6734
|
+
2
|
|
6735
|
+
),
|
|
6736
|
+
`Contract metadata:\n${JSON.stringify(
|
|
6737
|
+
{
|
|
6738
|
+
code: contract.code,
|
|
6739
|
+
name: contract.name,
|
|
6740
|
+
clientName: contract.clientName,
|
|
6741
|
+
contractCategory: contract.contractCategory,
|
|
6742
|
+
contractType: contract.contractType,
|
|
6743
|
+
startDate: contract.startDate,
|
|
6744
|
+
endDate: contract.endDate,
|
|
6745
|
+
effectiveDate: contract.effectiveDate,
|
|
6746
|
+
signatureStatus: contract.signatureStatus,
|
|
6747
|
+
parties: contract.parties,
|
|
6748
|
+
financialTerms: contract.financialTerms,
|
|
6749
|
+
},
|
|
6750
|
+
null,
|
|
6751
|
+
2
|
|
6752
|
+
)}`,
|
|
6753
|
+
`Contract HTML:\n${this.normalizeOptionalText(contract.contentHtml) ?? ''}`,
|
|
6754
|
+
].join('\n\n'),
|
|
6755
|
+
});
|
|
6756
|
+
|
|
6757
|
+
const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
|
|
6758
|
+
const missingFields = Array.from(
|
|
6759
|
+
new Set([
|
|
6760
|
+
...heuristicReview.missingFields,
|
|
6761
|
+
...this.normalizeStringList(parsed.missingFields),
|
|
6762
|
+
])
|
|
6763
|
+
);
|
|
6764
|
+
const warnings = Array.from(
|
|
6765
|
+
new Set([
|
|
6766
|
+
...heuristicReview.warnings,
|
|
6767
|
+
...this.normalizeStringList(parsed.warnings),
|
|
6768
|
+
])
|
|
6769
|
+
);
|
|
6770
|
+
const checklist = Array.from(
|
|
6771
|
+
new Set([
|
|
6772
|
+
...this.normalizeStringList(parsed.checklist),
|
|
6773
|
+
...heuristicReview.checklist,
|
|
6774
|
+
])
|
|
6775
|
+
);
|
|
6776
|
+
const parsedStatus = this.normalizeExtractionString(parsed.status);
|
|
6777
|
+
const status: ContractLegalReviewResult['status'] =
|
|
6778
|
+
parsedStatus === 'ready_for_revision' ||
|
|
6779
|
+
parsedStatus === 'attention_required'
|
|
6780
|
+
? parsedStatus
|
|
6781
|
+
: missingFields.length || warnings.length
|
|
6782
|
+
? 'attention_required'
|
|
6783
|
+
: 'ready_for_revision';
|
|
6784
|
+
|
|
6785
|
+
return {
|
|
6786
|
+
summary:
|
|
6787
|
+
this.normalizeExtractionString(parsed.summary) ||
|
|
6788
|
+
heuristicReview.summary,
|
|
6789
|
+
missingFields,
|
|
6790
|
+
warnings,
|
|
6791
|
+
checklist,
|
|
6792
|
+
status,
|
|
6793
|
+
reviewedAt: new Date().toISOString(),
|
|
6794
|
+
};
|
|
6795
|
+
} catch {
|
|
6796
|
+
return heuristicReview;
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
|
|
6800
|
+
private buildHeuristicContractLegalReview(
|
|
6801
|
+
contract: any
|
|
6802
|
+
): ContractLegalReviewResult {
|
|
6803
|
+
const contentText = (
|
|
6804
|
+
this.normalizeOptionalText(contract.contentHtml) ?? ''
|
|
6805
|
+
)
|
|
6806
|
+
.replace(/<[^>]+>/g, ' ')
|
|
6807
|
+
.replace(/\s+/g, ' ')
|
|
6808
|
+
.trim()
|
|
6809
|
+
.toLowerCase();
|
|
6810
|
+
const primaryParty =
|
|
6811
|
+
(contract.parties ?? []).find((party: any) => party.isPrimary) ??
|
|
6812
|
+
(contract.parties ?? [])[0] ??
|
|
6813
|
+
null;
|
|
6814
|
+
const missingFields: string[] = [];
|
|
6815
|
+
const warnings: string[] = [];
|
|
6816
|
+
const checklist: string[] = [];
|
|
6817
|
+
|
|
6818
|
+
if (!this.normalizeOptionalText(contract.name)) {
|
|
6819
|
+
missingFields.push('Contract title');
|
|
6820
|
+
checklist.push('Attention: define a clear contract title.');
|
|
6821
|
+
} else {
|
|
6822
|
+
checklist.push('OK: contract title is registered.');
|
|
6823
|
+
}
|
|
6824
|
+
|
|
6825
|
+
if (!this.normalizeOptionalText(contract.clientName)) {
|
|
6826
|
+
missingFields.push('Client or primary party name');
|
|
6827
|
+
checklist.push('Attention: confirm the client or main party identification.');
|
|
6828
|
+
} else {
|
|
6829
|
+
checklist.push('OK: the main party is identified.');
|
|
6830
|
+
}
|
|
6831
|
+
|
|
6832
|
+
if (!contract.startDate && !contract.effectiveDate) {
|
|
6833
|
+
missingFields.push('Start or effective date');
|
|
6834
|
+
checklist.push('Attention: define the term start or effective date.');
|
|
6835
|
+
} else {
|
|
6836
|
+
checklist.push('OK: validity dates were provided.');
|
|
6837
|
+
}
|
|
6838
|
+
|
|
6839
|
+
if (!(contract.parties ?? []).length) {
|
|
6840
|
+
missingFields.push('Related parties');
|
|
6841
|
+
warnings.push('No related parties are registered in the contract draft.');
|
|
6842
|
+
}
|
|
6843
|
+
|
|
6844
|
+
if (!this.normalizeOptionalText(primaryParty?.documentNumber)) {
|
|
6845
|
+
warnings.push('Consider confirming the primary party document number.');
|
|
6846
|
+
}
|
|
6847
|
+
|
|
6848
|
+
if (!(contract.financialTerms ?? []).length && contract.budgetAmount == null) {
|
|
6849
|
+
warnings.push('Commercial conditions are still generic; define prices, recurrence, and penalties.');
|
|
6850
|
+
checklist.push('Attention: validate billing, adjustments, and penalties.');
|
|
6851
|
+
} else {
|
|
6852
|
+
checklist.push('OK: there is at least one financial reference to review.');
|
|
6853
|
+
}
|
|
6854
|
+
|
|
6855
|
+
if (!contentText) {
|
|
6856
|
+
missingFields.push('Contract body content');
|
|
6857
|
+
warnings.push('The contract body is still empty and needs a first draft.');
|
|
6858
|
+
} else {
|
|
6859
|
+
if (contentText.length < 600) {
|
|
6860
|
+
warnings.push('The contract body is still brief; validate scope, obligations, and exceptions in detail.');
|
|
6861
|
+
}
|
|
6862
|
+
if (!/(rescis|terminat|cancel)/.test(contentText)) {
|
|
6863
|
+
warnings.push('Add a termination / rescission clause with notice and penalty rules.');
|
|
6864
|
+
}
|
|
6865
|
+
if (!/(confiden|sigil|data protect|privacy)/.test(contentText)) {
|
|
6866
|
+
warnings.push('Review confidentiality and data protection clauses when applicable.');
|
|
6867
|
+
}
|
|
6868
|
+
if (!/(foro|jurisd|governing law|applicable law)/.test(contentText)) {
|
|
6869
|
+
warnings.push('Review the governing law / forum clause before signature.');
|
|
6870
|
+
}
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
const summary =
|
|
6874
|
+
missingFields.length || warnings.length
|
|
6875
|
+
? `Advisory legal review found ${missingFields.length} missing item(s) and ${warnings.length} point(s) for attention before final approval.`
|
|
6876
|
+
: 'Advisory legal review found the draft structurally consistent for human legal revision.';
|
|
6877
|
+
|
|
6878
|
+
return {
|
|
6879
|
+
summary,
|
|
6880
|
+
missingFields: Array.from(new Set(missingFields)),
|
|
6881
|
+
warnings: Array.from(new Set(warnings)),
|
|
6882
|
+
checklist: Array.from(new Set(checklist)),
|
|
6883
|
+
status:
|
|
6884
|
+
missingFields.length || warnings.length
|
|
6885
|
+
? 'attention_required'
|
|
6886
|
+
: 'ready_for_revision',
|
|
6887
|
+
reviewedAt: new Date().toISOString(),
|
|
6888
|
+
};
|
|
6889
|
+
}
|
|
6890
|
+
|
|
6891
|
+
private buildContractExtractionSystemPrompt() {
|
|
6892
|
+
return [
|
|
6893
|
+
'You extract structured draft data from uploaded business contracts.',
|
|
6894
|
+
'Return ONLY valid JSON. Do not include Markdown fences, prose, or comments.',
|
|
6895
|
+
'Use empty strings or empty arrays when the document is unclear; never invent legal facts.',
|
|
6896
|
+
'Dates must use YYYY-MM-DD whenever the document provides enough confidence.',
|
|
6897
|
+
'For contentHtml, return clean HTML suitable for a rich text editor preview.',
|
|
6898
|
+
'Expected JSON shape:',
|
|
6899
|
+
JSON.stringify(
|
|
6900
|
+
{
|
|
6901
|
+
code: '',
|
|
6902
|
+
name: '',
|
|
6903
|
+
clientName: '',
|
|
6904
|
+
contractCategory: 'client',
|
|
6905
|
+
contractType: 'service_agreement',
|
|
6906
|
+
signatureStatus: 'not_started',
|
|
6907
|
+
billingModel: 'time_and_material',
|
|
6908
|
+
originType: 'manual',
|
|
6909
|
+
originId: '',
|
|
6910
|
+
startDate: '',
|
|
6911
|
+
endDate: '',
|
|
6912
|
+
signedAt: '',
|
|
6913
|
+
effectiveDate: '',
|
|
6914
|
+
budgetAmount: '',
|
|
6915
|
+
monthlyHourCap: '',
|
|
6916
|
+
status: 'draft',
|
|
6917
|
+
description: '',
|
|
6918
|
+
contentHtml: '',
|
|
6919
|
+
parties: [
|
|
6920
|
+
{
|
|
6921
|
+
displayName: '',
|
|
6922
|
+
partyRole: 'client',
|
|
6923
|
+
partyType: 'company',
|
|
6924
|
+
documentNumber: '',
|
|
6925
|
+
email: '',
|
|
6926
|
+
phone: '',
|
|
6927
|
+
isPrimary: true,
|
|
6928
|
+
},
|
|
6929
|
+
],
|
|
6930
|
+
signatures: [
|
|
6931
|
+
{
|
|
6932
|
+
signerName: '',
|
|
6933
|
+
signerRole: '',
|
|
6934
|
+
signerEmail: '',
|
|
6935
|
+
status: 'pending',
|
|
6936
|
+
signedAt: '',
|
|
6937
|
+
},
|
|
6938
|
+
],
|
|
6939
|
+
financialTerms: [
|
|
6940
|
+
{
|
|
6941
|
+
label: '',
|
|
6942
|
+
termType: 'value',
|
|
6943
|
+
amount: '',
|
|
6944
|
+
recurrence: 'one_time',
|
|
6945
|
+
dueDay: '',
|
|
6946
|
+
notes: '',
|
|
6947
|
+
},
|
|
6948
|
+
],
|
|
6949
|
+
revisions: [
|
|
6950
|
+
{
|
|
6951
|
+
title: '',
|
|
6952
|
+
revisionType: 'revision',
|
|
6953
|
+
effectiveDate: '',
|
|
6954
|
+
status: 'draft',
|
|
6955
|
+
summary: '',
|
|
6956
|
+
},
|
|
6957
|
+
],
|
|
6958
|
+
summary: '',
|
|
6959
|
+
missingFields: [],
|
|
6960
|
+
warnings: [],
|
|
6961
|
+
},
|
|
6962
|
+
null,
|
|
6963
|
+
2
|
|
6964
|
+
),
|
|
6965
|
+
].join('\n\n');
|
|
6966
|
+
}
|
|
6967
|
+
|
|
6968
|
+
private parseAiJsonPayload(content: string): Record<string, unknown> {
|
|
6969
|
+
const trimmed = String(content ?? '').trim();
|
|
6970
|
+
|
|
6971
|
+
if (!trimmed) {
|
|
6972
|
+
throw new BadRequestException('AI returned an empty contract draft response.');
|
|
6973
|
+
}
|
|
6974
|
+
|
|
6975
|
+
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
6976
|
+
const candidate = fencedMatch?.[1]?.trim() || trimmed;
|
|
6977
|
+
|
|
6978
|
+
try {
|
|
6979
|
+
return JSON.parse(candidate) as Record<string, unknown>;
|
|
6980
|
+
} catch {
|
|
6981
|
+
const firstBraceIndex = candidate.indexOf('{');
|
|
6982
|
+
const lastBraceIndex = candidate.lastIndexOf('}');
|
|
6983
|
+
|
|
6984
|
+
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
|
6985
|
+
try {
|
|
6986
|
+
return JSON.parse(
|
|
6987
|
+
candidate.slice(firstBraceIndex, lastBraceIndex + 1)
|
|
6988
|
+
) as Record<string, unknown>;
|
|
6989
|
+
} catch {
|
|
6990
|
+
throw new BadRequestException(
|
|
6991
|
+
'AI returned an invalid contract draft payload.'
|
|
6992
|
+
);
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
6995
|
+
|
|
6996
|
+
throw new BadRequestException('AI returned an invalid contract draft payload.');
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
|
|
7000
|
+
private normalizeContractExtractDraft(raw: Record<string, unknown>) {
|
|
7001
|
+
const warnings = this.normalizeStringList(raw.warnings);
|
|
7002
|
+
const missingFields = this.normalizeStringList(raw.missingFields);
|
|
7003
|
+
|
|
7004
|
+
const draft = {
|
|
7005
|
+
code: this.normalizeExtractionString(raw.code),
|
|
7006
|
+
name: this.normalizeExtractionString(raw.name),
|
|
7007
|
+
clientName: this.normalizeExtractionString(raw.clientName),
|
|
7008
|
+
contractCategory: this.normalizeEnumValue(
|
|
7009
|
+
raw.contractCategory,
|
|
7010
|
+
CONTRACT_CATEGORY_VALUES,
|
|
7011
|
+
'client'
|
|
7012
|
+
),
|
|
7013
|
+
contractType: this.normalizeEnumValue(
|
|
7014
|
+
raw.contractType,
|
|
7015
|
+
CONTRACT_TYPE_VALUES,
|
|
7016
|
+
'service_agreement'
|
|
7017
|
+
),
|
|
7018
|
+
signatureStatus: this.normalizeEnumValue(
|
|
7019
|
+
raw.signatureStatus,
|
|
7020
|
+
SIGNATURE_STATUS_VALUES,
|
|
7021
|
+
'not_started'
|
|
7022
|
+
),
|
|
7023
|
+
isActive: raw.isActive === false ? false : true,
|
|
7024
|
+
billingModel: this.normalizeEnumValue(
|
|
7025
|
+
raw.billingModel,
|
|
7026
|
+
BILLING_MODEL_VALUES,
|
|
7027
|
+
'time_and_material'
|
|
7028
|
+
),
|
|
7029
|
+
originType: this.normalizeEnumValue(
|
|
7030
|
+
raw.originType,
|
|
7031
|
+
ORIGIN_TYPE_VALUES,
|
|
7032
|
+
'manual'
|
|
7033
|
+
),
|
|
7034
|
+
originId: this.normalizeExtractionString(raw.originId),
|
|
7035
|
+
startDate: this.normalizeExtractionDate(raw.startDate),
|
|
7036
|
+
endDate: this.normalizeExtractionDate(raw.endDate),
|
|
7037
|
+
signedAt: this.normalizeExtractionDate(raw.signedAt),
|
|
7038
|
+
effectiveDate: this.normalizeExtractionDate(raw.effectiveDate),
|
|
7039
|
+
budgetAmount: this.normalizeExtractionNumber(raw.budgetAmount),
|
|
7040
|
+
monthlyHourCap: this.normalizeExtractionNumber(raw.monthlyHourCap),
|
|
7041
|
+
status: this.normalizeEnumValue(raw.status, CONTRACT_STATUS_VALUES, 'draft'),
|
|
7042
|
+
description: this.normalizeExtractionString(raw.description),
|
|
7043
|
+
contentHtml: this.normalizeExtractionString(raw.contentHtml),
|
|
7044
|
+
summary: this.normalizeExtractionString(raw.summary),
|
|
7045
|
+
parties: this.normalizeObjectList(raw.parties)
|
|
7046
|
+
.map((party) => ({
|
|
7047
|
+
displayName: this.normalizeExtractionString(party.displayName),
|
|
7048
|
+
partyRole: this.normalizeEnumValue(
|
|
7049
|
+
party.partyRole,
|
|
7050
|
+
PARTY_ROLE_VALUES,
|
|
7051
|
+
'client'
|
|
7052
|
+
),
|
|
7053
|
+
partyType: this.normalizeEnumValue(
|
|
7054
|
+
party.partyType,
|
|
7055
|
+
PARTY_TYPE_VALUES,
|
|
7056
|
+
'company'
|
|
7057
|
+
),
|
|
7058
|
+
documentNumber: this.normalizeExtractionString(party.documentNumber),
|
|
7059
|
+
email: this.normalizeExtractionString(party.email),
|
|
7060
|
+
phone: this.normalizeExtractionString(party.phone),
|
|
7061
|
+
isPrimary: this.normalizeExtractionBoolean(party.isPrimary),
|
|
7062
|
+
}))
|
|
7063
|
+
.filter((party) => party.displayName),
|
|
7064
|
+
signatures: this.normalizeObjectList(raw.signatures)
|
|
7065
|
+
.map((signature) => ({
|
|
7066
|
+
signerName: this.normalizeExtractionString(signature.signerName),
|
|
7067
|
+
signerRole: this.normalizeExtractionString(signature.signerRole),
|
|
7068
|
+
signerEmail: this.normalizeExtractionString(signature.signerEmail),
|
|
7069
|
+
status: this.normalizeEnumValue(
|
|
7070
|
+
signature.status,
|
|
7071
|
+
SIGNATURE_ITEM_STATUS_VALUES,
|
|
7072
|
+
'pending'
|
|
7073
|
+
),
|
|
7074
|
+
signedAt: this.normalizeExtractionDate(signature.signedAt),
|
|
7075
|
+
}))
|
|
7076
|
+
.filter((signature) => signature.signerName),
|
|
7077
|
+
financialTerms: this.normalizeObjectList(raw.financialTerms)
|
|
7078
|
+
.map((term) => ({
|
|
7079
|
+
label: this.normalizeExtractionString(term.label),
|
|
7080
|
+
termType: this.normalizeEnumValue(
|
|
7081
|
+
term.termType,
|
|
7082
|
+
FINANCIAL_TERM_TYPE_VALUES,
|
|
7083
|
+
'value'
|
|
7084
|
+
),
|
|
7085
|
+
amount: this.normalizeExtractionNumber(term.amount),
|
|
7086
|
+
recurrence: this.normalizeEnumValue(
|
|
7087
|
+
term.recurrence,
|
|
7088
|
+
RECURRENCE_VALUES,
|
|
7089
|
+
'one_time'
|
|
7090
|
+
),
|
|
7091
|
+
dueDay: this.normalizeExtractionNumber(term.dueDay),
|
|
7092
|
+
notes: this.normalizeExtractionString(term.notes),
|
|
7093
|
+
}))
|
|
7094
|
+
.filter((term) => term.label),
|
|
7095
|
+
revisions: this.normalizeObjectList(raw.revisions)
|
|
7096
|
+
.map((revision) => ({
|
|
7097
|
+
title: this.normalizeExtractionString(revision.title),
|
|
7098
|
+
revisionType: this.normalizeEnumValue(
|
|
7099
|
+
revision.revisionType,
|
|
7100
|
+
REVISION_TYPE_VALUES,
|
|
7101
|
+
'revision'
|
|
7102
|
+
),
|
|
7103
|
+
effectiveDate: this.normalizeExtractionDate(revision.effectiveDate),
|
|
7104
|
+
status: this.normalizeEnumValue(
|
|
7105
|
+
revision.status,
|
|
7106
|
+
REVISION_STATUS_VALUES,
|
|
7107
|
+
'draft'
|
|
7108
|
+
),
|
|
7109
|
+
summary: this.normalizeExtractionString(revision.summary),
|
|
7110
|
+
}))
|
|
7111
|
+
.filter((revision) => revision.title),
|
|
7112
|
+
};
|
|
7113
|
+
|
|
7114
|
+
if (!draft.name) missingFields.push('Contract title');
|
|
7115
|
+
if (!draft.clientName) missingFields.push('Client name');
|
|
7116
|
+
if (!draft.startDate) missingFields.push('Start date');
|
|
7117
|
+
if (!draft.contentHtml && !draft.summary && !draft.description) {
|
|
7118
|
+
warnings.push(
|
|
7119
|
+
'The contract body was not extracted with enough confidence. Use the editor to complete it.'
|
|
7120
|
+
);
|
|
7121
|
+
}
|
|
7122
|
+
if (!draft.parties.length) {
|
|
7123
|
+
warnings.push('No related parties were identified with confidence.');
|
|
7124
|
+
}
|
|
7125
|
+
|
|
7126
|
+
return {
|
|
7127
|
+
...draft,
|
|
7128
|
+
summary: draft.summary || draft.description || draft.name,
|
|
7129
|
+
missingFields: Array.from(new Set(missingFields.filter(Boolean))),
|
|
7130
|
+
warnings: Array.from(new Set(warnings.filter(Boolean))),
|
|
7131
|
+
};
|
|
7132
|
+
}
|
|
7133
|
+
|
|
7134
|
+
private normalizeExtractionString(value: unknown) {
|
|
7135
|
+
return typeof value === 'string'
|
|
7136
|
+
? value.trim()
|
|
7137
|
+
: value === null || value === undefined
|
|
7138
|
+
? ''
|
|
7139
|
+
: String(value).trim();
|
|
7140
|
+
}
|
|
7141
|
+
|
|
7142
|
+
private normalizeExtractionBoolean(value: unknown) {
|
|
7143
|
+
if (typeof value === 'boolean') {
|
|
7144
|
+
return value;
|
|
7145
|
+
}
|
|
7146
|
+
|
|
7147
|
+
const normalized = this.normalizeExtractionString(value).toLowerCase();
|
|
7148
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
|
7149
|
+
}
|
|
7150
|
+
|
|
7151
|
+
private normalizeExtractionNumber(value: unknown) {
|
|
7152
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
7153
|
+
return value;
|
|
7154
|
+
}
|
|
7155
|
+
|
|
7156
|
+
const normalized = this.normalizeExtractionString(value);
|
|
7157
|
+
if (!normalized) {
|
|
7158
|
+
return '';
|
|
7159
|
+
}
|
|
7160
|
+
|
|
7161
|
+
const direct = Number(normalized);
|
|
7162
|
+
if (Number.isFinite(direct)) {
|
|
7163
|
+
return direct;
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
const ptBr = normalized
|
|
7167
|
+
.split('.')
|
|
7168
|
+
.join('')
|
|
7169
|
+
.replace(',', '.');
|
|
7170
|
+
const parsed = Number(ptBr);
|
|
7171
|
+
|
|
7172
|
+
return Number.isFinite(parsed) ? parsed : normalized;
|
|
7173
|
+
}
|
|
7174
|
+
|
|
7175
|
+
private normalizeExtractionDate(value: unknown) {
|
|
7176
|
+
const normalized = this.normalizeExtractionString(value);
|
|
7177
|
+
if (!normalized) {
|
|
7178
|
+
return '';
|
|
7179
|
+
}
|
|
7180
|
+
|
|
7181
|
+
const match = normalized.match(/\d{4}-\d{2}-\d{2}/);
|
|
7182
|
+
return match?.[0] ?? '';
|
|
7183
|
+
}
|
|
7184
|
+
|
|
7185
|
+
private normalizeStringList(value: unknown) {
|
|
7186
|
+
return Array.isArray(value)
|
|
7187
|
+
? value
|
|
7188
|
+
.map((item) => this.normalizeExtractionString(item))
|
|
7189
|
+
.filter(Boolean)
|
|
7190
|
+
: [];
|
|
7191
|
+
}
|
|
7192
|
+
|
|
7193
|
+
private normalizeObjectList(value: unknown): Record<string, unknown>[] {
|
|
7194
|
+
if (!Array.isArray(value)) {
|
|
7195
|
+
return [];
|
|
7196
|
+
}
|
|
7197
|
+
|
|
7198
|
+
return value.filter(
|
|
7199
|
+
(item): item is Record<string, unknown> =>
|
|
7200
|
+
Boolean(item) && typeof item === 'object'
|
|
7201
|
+
);
|
|
7202
|
+
}
|
|
7203
|
+
|
|
7204
|
+
private normalizeEnumValue<T extends string>(
|
|
7205
|
+
value: unknown,
|
|
7206
|
+
allowed: readonly T[],
|
|
7207
|
+
fallback: T
|
|
7208
|
+
): T {
|
|
7209
|
+
const normalized = this.normalizeExtractionString(value) as T;
|
|
7210
|
+
return allowed.includes(normalized) ? normalized : fallback;
|
|
7211
|
+
}
|
|
7212
|
+
|
|
7213
|
+
private normalizeContractExtractionMimeType(
|
|
7214
|
+
mimeType?: string | null,
|
|
7215
|
+
fileName?: string | null
|
|
7216
|
+
) {
|
|
7217
|
+
const current = this.normalizeExtractionString(mimeType).toLowerCase();
|
|
7218
|
+
if (current && current !== 'application/octet-stream') {
|
|
7219
|
+
return current;
|
|
7220
|
+
}
|
|
7221
|
+
|
|
7222
|
+
const name = this.normalizeExtractionString(fileName).toLowerCase();
|
|
7223
|
+
if (name.endsWith('.pdf')) return 'application/pdf';
|
|
7224
|
+
if (name.endsWith('.doc')) return 'application/msword';
|
|
7225
|
+
if (name.endsWith('.docx')) {
|
|
7226
|
+
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
7227
|
+
}
|
|
7228
|
+
|
|
7229
|
+
return current || 'application/octet-stream';
|
|
7230
|
+
}
|
|
7231
|
+
|
|
7232
|
+
private isAllowedContractExtractionMimeType(mimeType: string) {
|
|
7233
|
+
return [
|
|
7234
|
+
'application/pdf',
|
|
7235
|
+
'application/msword',
|
|
7236
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
7237
|
+
].includes(mimeType);
|
|
7238
|
+
}
|
|
7239
|
+
|
|
3522
7240
|
private requireFields(input: Record<string, unknown>, required: string[]) {
|
|
3523
7241
|
for (const field of required) {
|
|
3524
7242
|
const value = input[field];
|
|
@@ -3553,16 +7271,20 @@ export class OperationsService {
|
|
|
3553
7271
|
updates: string[],
|
|
3554
7272
|
params: unknown[],
|
|
3555
7273
|
column: string,
|
|
3556
|
-
value: unknown
|
|
7274
|
+
value: unknown,
|
|
7275
|
+
castType?: string
|
|
3557
7276
|
) {
|
|
3558
7277
|
if (value === undefined) return;
|
|
3559
7278
|
params.push(value);
|
|
3560
|
-
|
|
7279
|
+
const placeholder = castType
|
|
7280
|
+
? `$${params.length}::${castType}`
|
|
7281
|
+
: `$${params.length}`;
|
|
7282
|
+
updates.push(`${column} = ${placeholder}`);
|
|
3561
7283
|
}
|
|
3562
7284
|
|
|
3563
|
-
private param(params: unknown[], value: unknown) {
|
|
7285
|
+
private param(params: unknown[], value: unknown, castType?: string) {
|
|
3564
7286
|
params.push(value);
|
|
3565
|
-
return `$${params.length}`;
|
|
7287
|
+
return castType ? `$${params.length}::${castType}` : `$${params.length}`;
|
|
3566
7288
|
}
|
|
3567
7289
|
|
|
3568
7290
|
private groupBy<T extends Record<string, any>>(items: T[], key: keyof T) {
|