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