@hed-hog/contact 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/contact.module.d.ts.map +1 -1
- package/dist/contact.module.js +2 -0
- package/dist/contact.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/person/person.service.d.ts +2 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +111 -127
- package/dist/person/person.service.js.map +1 -1
- package/dist/person/person.service.spec.d.ts +2 -0
- package/dist/person/person.service.spec.d.ts.map +1 -0
- package/dist/person/person.service.spec.js +106 -0
- package/dist/person/person.service.spec.js.map +1 -0
- package/dist/proposal/dto/proposal.dto.d.ts +152 -0
- package/dist/proposal/dto/proposal.dto.d.ts.map +1 -0
- package/dist/proposal/dto/proposal.dto.js +396 -0
- package/dist/proposal/dto/proposal.dto.js.map +1 -0
- package/dist/proposal/proposal-contract.subscriber.d.ts +11 -0
- package/dist/proposal/proposal-contract.subscriber.d.ts.map +1 -0
- package/dist/proposal/proposal-contract.subscriber.js +51 -0
- package/dist/proposal/proposal-contract.subscriber.js.map +1 -0
- package/dist/proposal/proposal-event.types.d.ts +122 -0
- package/dist/proposal/proposal-event.types.d.ts.map +1 -0
- package/dist/proposal/proposal-event.types.js +13 -0
- package/dist/proposal/proposal-event.types.js.map +1 -0
- package/dist/proposal/proposal.controller.d.ts +56 -0
- package/dist/proposal/proposal.controller.d.ts.map +1 -0
- package/dist/proposal/proposal.controller.js +191 -0
- package/dist/proposal/proposal.controller.js.map +1 -0
- package/dist/proposal/proposal.module.d.ts +3 -0
- package/dist/proposal/proposal.module.d.ts.map +1 -0
- package/dist/proposal/proposal.module.js +32 -0
- package/dist/proposal/proposal.module.js.map +1 -0
- package/dist/proposal/proposal.service.d.ts +100 -0
- package/dist/proposal/proposal.service.d.ts.map +1 -0
- package/dist/proposal/proposal.service.js +2137 -0
- package/dist/proposal/proposal.service.js.map +1 -0
- package/dist/proposal/proposal.service.spec.d.ts +2 -0
- package/dist/proposal/proposal.service.spec.d.ts.map +1 -0
- package/dist/proposal/proposal.service.spec.js +175 -0
- package/dist/proposal/proposal.service.spec.js.map +1 -0
- package/hedhog/data/menu.yaml +35 -18
- package/hedhog/data/route.yaml +44 -0
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +517 -346
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +42 -17
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +1 -1
- package/hedhog/frontend/app/activities/page.tsx.ejs +315 -101
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +172 -22
- package/hedhog/frontend/app/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1 -1
- package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +253 -210
- package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +1661 -0
- package/hedhog/frontend/app/pipeline/page.tsx.ejs +30 -4
- package/hedhog/frontend/app/proposals/_components/proposals-management-page.tsx.ejs +773 -0
- package/hedhog/frontend/app/proposals/approvals/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/proposals/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/reports/page.tsx.ejs +431 -375
- package/hedhog/frontend/messages/en.json +236 -43
- package/hedhog/frontend/messages/pt.json +235 -42
- package/hedhog/table/proposal.yaml +112 -0
- package/hedhog/table/proposal_approval.yaml +63 -0
- package/hedhog/table/proposal_document.yaml +77 -0
- package/hedhog/table/proposal_item.yaml +64 -0
- package/hedhog/table/proposal_revision.yaml +78 -0
- package/package.json +5 -4
- package/src/contact.module.ts +2 -0
- package/src/index.ts +3 -0
- package/src/person/person.service.spec.ts +143 -0
- package/src/person/person.service.ts +147 -158
- package/src/proposal/dto/proposal.dto.ts +341 -0
- package/src/proposal/proposal-contract.subscriber.ts +43 -0
- package/src/proposal/proposal-event.types.ts +130 -0
- package/src/proposal/proposal.controller.ts +168 -0
- package/src/proposal/proposal.module.ts +19 -0
- package/src/proposal/proposal.service.spec.ts +196 -0
- package/src/proposal/proposal.service.ts +2855 -0
|
@@ -0,0 +1,2137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var ProposalService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.ProposalService = void 0;
|
|
17
|
+
const api_locale_1 = require("@hed-hog/api-locale");
|
|
18
|
+
const api_prisma_1 = require("@hed-hog/api-prisma");
|
|
19
|
+
const core_1 = require("@hed-hog/core");
|
|
20
|
+
const common_1 = require("@nestjs/common");
|
|
21
|
+
const proposal_dto_1 = require("./dto/proposal.dto");
|
|
22
|
+
const proposal_event_types_1 = require("./proposal-event.types");
|
|
23
|
+
let ProposalService = ProposalService_1 = class ProposalService {
|
|
24
|
+
constructor(prisma, integrationApi, fileService, settingService) {
|
|
25
|
+
this.prisma = prisma;
|
|
26
|
+
this.integrationApi = integrationApi;
|
|
27
|
+
this.fileService = fileService;
|
|
28
|
+
this.settingService = settingService;
|
|
29
|
+
this.logger = new common_1.Logger(ProposalService_1.name);
|
|
30
|
+
}
|
|
31
|
+
async list(params) {
|
|
32
|
+
const take = Math.max(1, Number(params.take || params.pageSize || 10));
|
|
33
|
+
const skip = Math.max(0, Number(params.skip || 0));
|
|
34
|
+
const search = String(params.search || '').trim();
|
|
35
|
+
const where = {
|
|
36
|
+
deleted_at: null,
|
|
37
|
+
};
|
|
38
|
+
if (params.person_id) {
|
|
39
|
+
where.person_id = Number(params.person_id);
|
|
40
|
+
}
|
|
41
|
+
if (params.status) {
|
|
42
|
+
where.status = params.status;
|
|
43
|
+
}
|
|
44
|
+
if (search) {
|
|
45
|
+
const matchingCompanyRows = await this.prisma.person_company.findMany({
|
|
46
|
+
where: {
|
|
47
|
+
trade_name: { contains: search, mode: 'insensitive' },
|
|
48
|
+
},
|
|
49
|
+
select: {
|
|
50
|
+
id: true,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const matchingCompanyPersonIds = matchingCompanyRows
|
|
54
|
+
.map((item) => Number(item.id))
|
|
55
|
+
.filter((id) => id > 0);
|
|
56
|
+
where.OR = [
|
|
57
|
+
{ code: { contains: search, mode: 'insensitive' } },
|
|
58
|
+
{ title: { contains: search, mode: 'insensitive' } },
|
|
59
|
+
{
|
|
60
|
+
person: {
|
|
61
|
+
is: {
|
|
62
|
+
name: { contains: search, mode: 'insensitive' },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
...(matchingCompanyPersonIds.length > 0
|
|
67
|
+
? [{ person_id: { in: matchingCompanyPersonIds } }]
|
|
68
|
+
: []),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
const [data, total] = await Promise.all([
|
|
72
|
+
this.prisma.proposal.findMany({
|
|
73
|
+
where,
|
|
74
|
+
include: {
|
|
75
|
+
person: {
|
|
76
|
+
select: {
|
|
77
|
+
id: true,
|
|
78
|
+
name: true,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
proposal_revision: {
|
|
82
|
+
where: {
|
|
83
|
+
deleted_at: null,
|
|
84
|
+
is_current: true,
|
|
85
|
+
},
|
|
86
|
+
orderBy: {
|
|
87
|
+
revision_number: 'desc',
|
|
88
|
+
},
|
|
89
|
+
take: 1,
|
|
90
|
+
include: {
|
|
91
|
+
proposal_item: {
|
|
92
|
+
where: { deleted_at: null },
|
|
93
|
+
orderBy: { order: 'asc' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
orderBy: {
|
|
99
|
+
id: 'desc',
|
|
100
|
+
},
|
|
101
|
+
skip,
|
|
102
|
+
take,
|
|
103
|
+
}),
|
|
104
|
+
this.prisma.proposal.count({ where }),
|
|
105
|
+
]);
|
|
106
|
+
const normalizedData = await this.attachPersonTradeNames(this.prisma, data);
|
|
107
|
+
const page = Math.floor(skip / take) + 1;
|
|
108
|
+
const lastPage = Math.max(1, Math.ceil(total / take));
|
|
109
|
+
return {
|
|
110
|
+
data: normalizedData,
|
|
111
|
+
total,
|
|
112
|
+
page,
|
|
113
|
+
pageSize: take,
|
|
114
|
+
prev: page > 1 ? page - 1 : null,
|
|
115
|
+
next: page < lastPage ? page + 1 : null,
|
|
116
|
+
lastPage,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async getStats() {
|
|
120
|
+
const baseWhere = { deleted_at: null };
|
|
121
|
+
const [total, draft, pendingApproval, approved, rejected, contractGenerated,] = await Promise.all([
|
|
122
|
+
this.prisma.proposal.count({ where: baseWhere }),
|
|
123
|
+
this.prisma.proposal.count({
|
|
124
|
+
where: Object.assign(Object.assign({}, baseWhere), { status: proposal_dto_1.ProposalStatus.DRAFT }),
|
|
125
|
+
}),
|
|
126
|
+
this.prisma.proposal.count({
|
|
127
|
+
where: Object.assign(Object.assign({}, baseWhere), { status: proposal_dto_1.ProposalStatus.PENDING_APPROVAL }),
|
|
128
|
+
}),
|
|
129
|
+
this.prisma.proposal.count({
|
|
130
|
+
where: Object.assign(Object.assign({}, baseWhere), { status: proposal_dto_1.ProposalStatus.APPROVED }),
|
|
131
|
+
}),
|
|
132
|
+
this.prisma.proposal.count({
|
|
133
|
+
where: Object.assign(Object.assign({}, baseWhere), { status: proposal_dto_1.ProposalStatus.REJECTED }),
|
|
134
|
+
}),
|
|
135
|
+
this.prisma.proposal.count({
|
|
136
|
+
where: Object.assign(Object.assign({}, baseWhere), { status: proposal_dto_1.ProposalStatus.CONTRACT_GENERATED }),
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
return {
|
|
140
|
+
total,
|
|
141
|
+
draft,
|
|
142
|
+
pendingApproval,
|
|
143
|
+
approved,
|
|
144
|
+
rejected,
|
|
145
|
+
contractGenerated,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async getById(id, locale) {
|
|
149
|
+
const proposal = await this.loadProposalDetail(this.prisma, id);
|
|
150
|
+
if (!proposal) {
|
|
151
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
152
|
+
}
|
|
153
|
+
const links = await this.integrationApi.findLinksBySource({
|
|
154
|
+
module: 'contact',
|
|
155
|
+
entityType: 'proposal',
|
|
156
|
+
entityId: String(id),
|
|
157
|
+
});
|
|
158
|
+
return Object.assign(Object.assign({}, proposal), { integration_links: links });
|
|
159
|
+
}
|
|
160
|
+
async create(data, locale, userId) {
|
|
161
|
+
await this.assertPersonExists(data.person_id, locale);
|
|
162
|
+
const code = await this.resolveProposalCode(data.code);
|
|
163
|
+
const normalizedItems = this.normalizeItems(data.items);
|
|
164
|
+
const totals = this.resolveTotals(data, normalizedItems);
|
|
165
|
+
const createdByUserId = userId || null;
|
|
166
|
+
const createdProposal = await this.prisma.$transaction(async (tx) => {
|
|
167
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
168
|
+
const proposal = await tx.proposal.create({
|
|
169
|
+
data: Object.assign({ person_id: data.person_id, code, title: data.title.trim(), status: (_a = data.status) !== null && _a !== void 0 ? _a : proposal_dto_1.ProposalStatus.DRAFT, contract_category: (_b = data.contract_category) !== null && _b !== void 0 ? _b : 'client', contract_type: (_c = data.contract_type) !== null && _c !== void 0 ? _c : 'service_agreement', billing_model: (_d = data.billing_model) !== null && _d !== void 0 ? _d : 'fixed_price', currency_code: (data.currency_code || 'BRL').trim() || 'BRL', valid_from: data.valid_from ? new Date(data.valid_from) : null, valid_until: data.valid_until ? new Date(data.valid_until) : null, notes: this.normalizeOptionalText(data.notes), owner_user_id: (_e = data.owner_user_id) !== null && _e !== void 0 ? _e : null, created_by_user_id: createdByUserId, updated_by_user_id: createdByUserId, current_revision_number: 1 }, totals),
|
|
170
|
+
});
|
|
171
|
+
const revision = await tx.proposal_revision.create({
|
|
172
|
+
data: Object.assign({ proposal_id: proposal.id, revision_number: 1, status: proposal.status === proposal_dto_1.ProposalStatus.PENDING_APPROVAL
|
|
173
|
+
? proposal_dto_1.ProposalStatus.PENDING_APPROVAL
|
|
174
|
+
: proposal_dto_1.ProposalStatus.DRAFT, generation_mode: (_f = data.generation_mode) !== null && _f !== void 0 ? _f : 'manual', title: data.title.trim(), summary: this.normalizeOptionalText(data.summary), content_html: this.normalizeOptionalText(data.content_html), snapshot_json: (_g = data.snapshot_json) !== null && _g !== void 0 ? _g : null, valid_until: data.valid_until ? new Date(data.valid_until) : null, is_current: true, created_by_user_id: createdByUserId }, totals),
|
|
175
|
+
});
|
|
176
|
+
if (normalizedItems.length > 0) {
|
|
177
|
+
await tx.proposal_item.createMany({
|
|
178
|
+
data: normalizedItems.map((item, index) => {
|
|
179
|
+
var _a;
|
|
180
|
+
return ({
|
|
181
|
+
proposal_revision_id: revision.id,
|
|
182
|
+
order: index,
|
|
183
|
+
item_type: item.item_type,
|
|
184
|
+
term_type: item.term_type,
|
|
185
|
+
name: item.name,
|
|
186
|
+
description: item.description,
|
|
187
|
+
quantity: item.quantity,
|
|
188
|
+
unit_amount_cents: item.unit_amount_cents,
|
|
189
|
+
total_amount_cents: item.total_amount_cents,
|
|
190
|
+
recurrence: item.recurrence,
|
|
191
|
+
due_day: item.due_day,
|
|
192
|
+
start_date: item.start_date ? new Date(item.start_date) : null,
|
|
193
|
+
end_date: item.end_date ? new Date(item.end_date) : null,
|
|
194
|
+
metadata_json: (_a = item.metadata_json) !== null && _a !== void 0 ? _a : null,
|
|
195
|
+
});
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const documents = this.normalizeDocuments(data.documents, proposal.id, revision.id, createdByUserId);
|
|
200
|
+
if (documents.length > 0) {
|
|
201
|
+
await tx.proposal_document.createMany({
|
|
202
|
+
data: documents,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CREATED, proposal.id, locale, createdByUserId);
|
|
206
|
+
return proposal;
|
|
207
|
+
});
|
|
208
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, createdProposal.person_id);
|
|
209
|
+
return this.getById(createdProposal.id, locale);
|
|
210
|
+
}
|
|
211
|
+
async update(id, data, locale, userId) {
|
|
212
|
+
var _a, _b;
|
|
213
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
214
|
+
if (!current) {
|
|
215
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
216
|
+
}
|
|
217
|
+
this.assertCanEdit(current.status, locale);
|
|
218
|
+
const normalizedItems = data.items
|
|
219
|
+
? this.normalizeItems(data.items)
|
|
220
|
+
: (((_b = (_a = current.proposal_revision) === null || _a === void 0 ? void 0 : _a.find((revision) => revision.is_current)) === null || _b === void 0 ? void 0 : _b.proposal_item) || []);
|
|
221
|
+
const totals = this.resolveTotals(data, normalizedItems);
|
|
222
|
+
await this.prisma.$transaction(async (tx) => {
|
|
223
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
224
|
+
const currentRevision = await tx.proposal_revision.findFirst({
|
|
225
|
+
where: {
|
|
226
|
+
proposal_id: id,
|
|
227
|
+
deleted_at: null,
|
|
228
|
+
is_current: true,
|
|
229
|
+
},
|
|
230
|
+
orderBy: {
|
|
231
|
+
revision_number: 'desc',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (!currentRevision) {
|
|
235
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
236
|
+
}
|
|
237
|
+
const shouldCreateNewRevision = Boolean(data.create_new_revision);
|
|
238
|
+
await tx.proposal.update({
|
|
239
|
+
where: { id },
|
|
240
|
+
data: Object.assign({ title: (_b = (_a = data.title) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : undefined, contract_category: (_c = data.contract_category) !== null && _c !== void 0 ? _c : undefined, contract_type: (_d = data.contract_type) !== null && _d !== void 0 ? _d : undefined, billing_model: (_e = data.billing_model) !== null && _e !== void 0 ? _e : undefined, currency_code: (_g = (_f = data.currency_code) === null || _f === void 0 ? void 0 : _f.trim()) !== null && _g !== void 0 ? _g : undefined, valid_from: data.valid_from ? new Date(data.valid_from) : undefined, valid_until: data.valid_until ? new Date(data.valid_until) : undefined, notes: data.notes !== undefined ? this.normalizeOptionalText(data.notes) : undefined, owner_user_id: (_h = data.owner_user_id) !== null && _h !== void 0 ? _h : undefined, updated_by_user_id: userId || null, current_revision_number: shouldCreateNewRevision
|
|
241
|
+
? currentRevision.revision_number + 1
|
|
242
|
+
: currentRevision.revision_number }, totals),
|
|
243
|
+
});
|
|
244
|
+
if (shouldCreateNewRevision) {
|
|
245
|
+
await tx.proposal_revision.updateMany({
|
|
246
|
+
where: {
|
|
247
|
+
proposal_id: id,
|
|
248
|
+
deleted_at: null,
|
|
249
|
+
is_current: true,
|
|
250
|
+
},
|
|
251
|
+
data: {
|
|
252
|
+
is_current: false,
|
|
253
|
+
status: currentRevision.status === proposal_dto_1.ProposalStatus.DRAFT
|
|
254
|
+
? 'superseded'
|
|
255
|
+
: currentRevision.status,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const newRevision = await tx.proposal_revision.create({
|
|
259
|
+
data: Object.assign({ proposal_id: id, revision_number: currentRevision.revision_number + 1, status: proposal_dto_1.ProposalStatus.DRAFT, generation_mode: (_j = data.generation_mode) !== null && _j !== void 0 ? _j : currentRevision.generation_mode, title: (_l = (_k = data.title) === null || _k === void 0 ? void 0 : _k.trim()) !== null && _l !== void 0 ? _l : currentRevision.title, summary: data.summary !== undefined
|
|
260
|
+
? this.normalizeOptionalText(data.summary)
|
|
261
|
+
: currentRevision.summary, content_html: data.content_html !== undefined
|
|
262
|
+
? this.normalizeOptionalText(data.content_html)
|
|
263
|
+
: currentRevision.content_html, snapshot_json: data.snapshot_json !== undefined
|
|
264
|
+
? data.snapshot_json
|
|
265
|
+
: currentRevision.snapshot_json, valid_until: data.valid_until !== undefined
|
|
266
|
+
? data.valid_until
|
|
267
|
+
? new Date(data.valid_until)
|
|
268
|
+
: null
|
|
269
|
+
: currentRevision.valid_until, is_current: true, created_by_user_id: userId || null }, totals),
|
|
270
|
+
});
|
|
271
|
+
if (data.items) {
|
|
272
|
+
await tx.proposal_item.createMany({
|
|
273
|
+
data: normalizedItems.map((item, index) => {
|
|
274
|
+
var _a;
|
|
275
|
+
return ({
|
|
276
|
+
proposal_revision_id: newRevision.id,
|
|
277
|
+
order: index,
|
|
278
|
+
item_type: item.item_type,
|
|
279
|
+
term_type: item.term_type,
|
|
280
|
+
name: item.name,
|
|
281
|
+
description: item.description,
|
|
282
|
+
quantity: item.quantity,
|
|
283
|
+
unit_amount_cents: item.unit_amount_cents,
|
|
284
|
+
total_amount_cents: item.total_amount_cents,
|
|
285
|
+
recurrence: item.recurrence,
|
|
286
|
+
due_day: item.due_day,
|
|
287
|
+
start_date: item.start_date ? new Date(item.start_date) : null,
|
|
288
|
+
end_date: item.end_date ? new Date(item.end_date) : null,
|
|
289
|
+
metadata_json: (_a = item.metadata_json) !== null && _a !== void 0 ? _a : null,
|
|
290
|
+
});
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (data.documents) {
|
|
295
|
+
const documents = this.normalizeDocuments(data.documents, id, newRevision.id, userId || null);
|
|
296
|
+
if (documents.length > 0) {
|
|
297
|
+
await tx.proposal_document.createMany({ data: documents });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
await tx.proposal_revision.update({
|
|
303
|
+
where: { id: currentRevision.id },
|
|
304
|
+
data: Object.assign({ title: (_o = (_m = data.title) === null || _m === void 0 ? void 0 : _m.trim()) !== null && _o !== void 0 ? _o : undefined, summary: data.summary !== undefined
|
|
305
|
+
? this.normalizeOptionalText(data.summary)
|
|
306
|
+
: undefined, content_html: data.content_html !== undefined
|
|
307
|
+
? this.normalizeOptionalText(data.content_html)
|
|
308
|
+
: undefined, snapshot_json: data.snapshot_json !== undefined ? data.snapshot_json : undefined, valid_until: data.valid_until !== undefined
|
|
309
|
+
? data.valid_until
|
|
310
|
+
? new Date(data.valid_until)
|
|
311
|
+
: null
|
|
312
|
+
: undefined, generation_mode: (_p = data.generation_mode) !== null && _p !== void 0 ? _p : undefined }, totals),
|
|
313
|
+
});
|
|
314
|
+
if (data.items) {
|
|
315
|
+
await tx.proposal_item.updateMany({
|
|
316
|
+
where: {
|
|
317
|
+
proposal_revision_id: currentRevision.id,
|
|
318
|
+
deleted_at: null,
|
|
319
|
+
},
|
|
320
|
+
data: {
|
|
321
|
+
deleted_at: new Date(),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
if (normalizedItems.length > 0) {
|
|
325
|
+
await tx.proposal_item.createMany({
|
|
326
|
+
data: normalizedItems.map((item, index) => {
|
|
327
|
+
var _a;
|
|
328
|
+
return ({
|
|
329
|
+
proposal_revision_id: currentRevision.id,
|
|
330
|
+
order: index,
|
|
331
|
+
item_type: item.item_type,
|
|
332
|
+
term_type: item.term_type,
|
|
333
|
+
name: item.name,
|
|
334
|
+
description: item.description,
|
|
335
|
+
quantity: item.quantity,
|
|
336
|
+
unit_amount_cents: item.unit_amount_cents,
|
|
337
|
+
total_amount_cents: item.total_amount_cents,
|
|
338
|
+
recurrence: item.recurrence,
|
|
339
|
+
due_day: item.due_day,
|
|
340
|
+
start_date: item.start_date ? new Date(item.start_date) : null,
|
|
341
|
+
end_date: item.end_date ? new Date(item.end_date) : null,
|
|
342
|
+
metadata_json: (_a = item.metadata_json) !== null && _a !== void 0 ? _a : null,
|
|
343
|
+
});
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (data.documents) {
|
|
349
|
+
await tx.proposal_document.updateMany({
|
|
350
|
+
where: {
|
|
351
|
+
proposal_id: id,
|
|
352
|
+
proposal_revision_id: currentRevision.id,
|
|
353
|
+
deleted_at: null,
|
|
354
|
+
},
|
|
355
|
+
data: {
|
|
356
|
+
deleted_at: new Date(),
|
|
357
|
+
is_current: false,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const documents = this.normalizeDocuments(data.documents, id, currentRevision.id, userId || null);
|
|
361
|
+
if (documents.length > 0) {
|
|
362
|
+
await tx.proposal_document.createMany({ data: documents });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
368
|
+
return this.getById(id, locale);
|
|
369
|
+
}
|
|
370
|
+
async submitForApproval(id, data, locale, userId) {
|
|
371
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
372
|
+
if (!current) {
|
|
373
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
374
|
+
}
|
|
375
|
+
if (current.status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
376
|
+
current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
377
|
+
throw new common_1.BadRequestException('Approved proposals cannot be resubmitted.');
|
|
378
|
+
}
|
|
379
|
+
if (current.status === proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
380
|
+
return this.getById(id, locale);
|
|
381
|
+
}
|
|
382
|
+
if (current.status === proposal_dto_1.ProposalStatus.CANCELLED) {
|
|
383
|
+
throw new common_1.BadRequestException('Cancelled proposals cannot be resubmitted.');
|
|
384
|
+
}
|
|
385
|
+
this.assertProposalWorkflowReadiness(current, 'submitted for approval');
|
|
386
|
+
await this.prisma.$transaction(async (tx) => {
|
|
387
|
+
var _a, _b;
|
|
388
|
+
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
389
|
+
if (!lockedProposal) {
|
|
390
|
+
throw new common_1.NotFoundException('Proposal not found.');
|
|
391
|
+
}
|
|
392
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
393
|
+
lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
394
|
+
throw new common_1.BadRequestException('Approved proposals cannot be resubmitted.');
|
|
395
|
+
}
|
|
396
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.CANCELLED) {
|
|
400
|
+
throw new common_1.BadRequestException('Cancelled proposals cannot be resubmitted.');
|
|
401
|
+
}
|
|
402
|
+
this.assertProposalWorkflowReadiness(lockedProposal, 'submitted for approval');
|
|
403
|
+
const currentRevision = this.getCurrentProposalRevision(lockedProposal);
|
|
404
|
+
if (!currentRevision) {
|
|
405
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
406
|
+
}
|
|
407
|
+
await tx.proposal.update({
|
|
408
|
+
where: { id },
|
|
409
|
+
data: {
|
|
410
|
+
status: proposal_dto_1.ProposalStatus.PENDING_APPROVAL,
|
|
411
|
+
updated_by_user_id: userId || null,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
await tx.proposal_revision.update({
|
|
415
|
+
where: { id: currentRevision.id },
|
|
416
|
+
data: {
|
|
417
|
+
status: proposal_dto_1.ProposalStatus.PENDING_APPROVAL,
|
|
418
|
+
submitted_at: new Date(),
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
const existingApproval = await tx.proposal_approval.findFirst({
|
|
422
|
+
where: {
|
|
423
|
+
proposal_id: id,
|
|
424
|
+
proposal_revision_id: currentRevision.id,
|
|
425
|
+
deleted_at: null,
|
|
426
|
+
step_order: 1,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
if (existingApproval) {
|
|
430
|
+
await tx.proposal_approval.update({
|
|
431
|
+
where: { id: existingApproval.id },
|
|
432
|
+
data: {
|
|
433
|
+
requester_user_id: userId || null,
|
|
434
|
+
approver_user_id: (_a = data.approver_user_id) !== null && _a !== void 0 ? _a : existingApproval.approver_user_id,
|
|
435
|
+
status: 'pending',
|
|
436
|
+
submitted_at: new Date(),
|
|
437
|
+
decided_at: null,
|
|
438
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
await tx.proposal_approval.create({
|
|
444
|
+
data: {
|
|
445
|
+
proposal_id: id,
|
|
446
|
+
proposal_revision_id: currentRevision.id,
|
|
447
|
+
requester_user_id: userId || null,
|
|
448
|
+
approver_user_id: (_b = data.approver_user_id) !== null && _b !== void 0 ? _b : null,
|
|
449
|
+
step_order: 1,
|
|
450
|
+
status: 'pending',
|
|
451
|
+
submitted_at: new Date(),
|
|
452
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.SENT, id, locale, userId || null, {
|
|
457
|
+
note: this.normalizeOptionalText(data.note),
|
|
458
|
+
metadata: {
|
|
459
|
+
trigger: 'submit_for_approval',
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
return this.getById(id, locale);
|
|
464
|
+
}
|
|
465
|
+
async approve(id, data, locale, userId) {
|
|
466
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
467
|
+
if (!current) {
|
|
468
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
469
|
+
}
|
|
470
|
+
if (current.status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
471
|
+
current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
472
|
+
return this.getById(id, locale);
|
|
473
|
+
}
|
|
474
|
+
if (current.status !== proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
475
|
+
throw new common_1.BadRequestException('Only proposals pending approval can be approved.');
|
|
476
|
+
}
|
|
477
|
+
this.assertProposalWorkflowReadiness(current, 'approved');
|
|
478
|
+
await this.prisma.$transaction(async (tx) => {
|
|
479
|
+
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
480
|
+
if (!lockedProposal) {
|
|
481
|
+
throw new common_1.NotFoundException('Proposal not found.');
|
|
482
|
+
}
|
|
483
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
484
|
+
lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (lockedProposal.status !== proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
488
|
+
throw new common_1.BadRequestException('Only proposals pending approval can be approved.');
|
|
489
|
+
}
|
|
490
|
+
this.assertProposalWorkflowReadiness(lockedProposal, 'approved');
|
|
491
|
+
const currentRevision = this.getCurrentProposalRevision(lockedProposal);
|
|
492
|
+
if (!currentRevision) {
|
|
493
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
494
|
+
}
|
|
495
|
+
const approval = await tx.proposal_approval.findFirst({
|
|
496
|
+
where: {
|
|
497
|
+
proposal_id: id,
|
|
498
|
+
proposal_revision_id: currentRevision.id,
|
|
499
|
+
deleted_at: null,
|
|
500
|
+
step_order: 1,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
const approvedAt = new Date();
|
|
504
|
+
if (approval) {
|
|
505
|
+
await tx.proposal_approval.update({
|
|
506
|
+
where: { id: approval.id },
|
|
507
|
+
data: {
|
|
508
|
+
approver_user_id: userId || approval.approver_user_id || null,
|
|
509
|
+
status: 'approved',
|
|
510
|
+
decided_at: approvedAt,
|
|
511
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await tx.proposal_approval.create({
|
|
517
|
+
data: {
|
|
518
|
+
proposal_id: id,
|
|
519
|
+
proposal_revision_id: currentRevision.id,
|
|
520
|
+
requester_user_id: null,
|
|
521
|
+
approver_user_id: userId || null,
|
|
522
|
+
step_order: 1,
|
|
523
|
+
status: 'approved',
|
|
524
|
+
submitted_at: approvedAt,
|
|
525
|
+
decided_at: approvedAt,
|
|
526
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
await tx.proposal_revision.update({
|
|
531
|
+
where: { id: currentRevision.id },
|
|
532
|
+
data: {
|
|
533
|
+
status: proposal_dto_1.ProposalStatus.APPROVED,
|
|
534
|
+
approved_at: approvedAt,
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
await tx.proposal.update({
|
|
538
|
+
where: { id },
|
|
539
|
+
data: {
|
|
540
|
+
status: proposal_dto_1.ProposalStatus.APPROVED,
|
|
541
|
+
approved_at: approvedAt,
|
|
542
|
+
approved_by_user_id: userId || null,
|
|
543
|
+
updated_by_user_id: userId || null,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.APPROVED, id, locale, userId || null, {
|
|
547
|
+
note: this.normalizeOptionalText(data.note),
|
|
548
|
+
});
|
|
549
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED, id, locale, userId || null, {
|
|
550
|
+
note: this.normalizeOptionalText(data.note),
|
|
551
|
+
metadata: {
|
|
552
|
+
trigger: 'approval',
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
557
|
+
return this.getById(id, locale);
|
|
558
|
+
}
|
|
559
|
+
async reject(id, data, locale, userId) {
|
|
560
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
561
|
+
if (!current) {
|
|
562
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
563
|
+
}
|
|
564
|
+
if (current.status === proposal_dto_1.ProposalStatus.REJECTED ||
|
|
565
|
+
current.status === proposal_dto_1.ProposalStatus.CANCELLED ||
|
|
566
|
+
current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
567
|
+
return this.getById(id, locale);
|
|
568
|
+
}
|
|
569
|
+
if (current.status !== proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
570
|
+
throw new common_1.BadRequestException('Only proposals pending approval can be rejected.');
|
|
571
|
+
}
|
|
572
|
+
await this.prisma.$transaction(async (tx) => {
|
|
573
|
+
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
574
|
+
if (!lockedProposal) {
|
|
575
|
+
throw new common_1.NotFoundException('Proposal not found.');
|
|
576
|
+
}
|
|
577
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.REJECTED ||
|
|
578
|
+
lockedProposal.status === proposal_dto_1.ProposalStatus.CANCELLED ||
|
|
579
|
+
lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (lockedProposal.status !== proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
583
|
+
throw new common_1.BadRequestException('Only proposals pending approval can be rejected.');
|
|
584
|
+
}
|
|
585
|
+
const currentRevision = this.getCurrentProposalRevision(lockedProposal);
|
|
586
|
+
if (!currentRevision) {
|
|
587
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
588
|
+
}
|
|
589
|
+
const approval = await tx.proposal_approval.findFirst({
|
|
590
|
+
where: {
|
|
591
|
+
proposal_id: id,
|
|
592
|
+
proposal_revision_id: currentRevision.id,
|
|
593
|
+
deleted_at: null,
|
|
594
|
+
step_order: 1,
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
const decidedAt = new Date();
|
|
598
|
+
if (approval) {
|
|
599
|
+
await tx.proposal_approval.update({
|
|
600
|
+
where: { id: approval.id },
|
|
601
|
+
data: {
|
|
602
|
+
approver_user_id: userId || approval.approver_user_id || null,
|
|
603
|
+
status: 'rejected',
|
|
604
|
+
decided_at: decidedAt,
|
|
605
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
await tx.proposal_approval.create({
|
|
611
|
+
data: {
|
|
612
|
+
proposal_id: id,
|
|
613
|
+
proposal_revision_id: currentRevision.id,
|
|
614
|
+
requester_user_id: null,
|
|
615
|
+
approver_user_id: userId || null,
|
|
616
|
+
step_order: 1,
|
|
617
|
+
status: 'rejected',
|
|
618
|
+
submitted_at: decidedAt,
|
|
619
|
+
decided_at: decidedAt,
|
|
620
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
await tx.proposal_revision.update({
|
|
625
|
+
where: { id: currentRevision.id },
|
|
626
|
+
data: {
|
|
627
|
+
status: proposal_dto_1.ProposalStatus.REJECTED,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
await tx.proposal.update({
|
|
631
|
+
where: { id },
|
|
632
|
+
data: {
|
|
633
|
+
status: proposal_dto_1.ProposalStatus.REJECTED,
|
|
634
|
+
updated_by_user_id: userId || null,
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.REJECTED, id, locale, userId || null, {
|
|
638
|
+
note: this.normalizeOptionalText(data.note),
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
642
|
+
return this.getById(id, locale);
|
|
643
|
+
}
|
|
644
|
+
async cancel(id, data, locale, userId) {
|
|
645
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
646
|
+
if (!current) {
|
|
647
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
648
|
+
}
|
|
649
|
+
if (current.status === proposal_dto_1.ProposalStatus.CANCELLED) {
|
|
650
|
+
return this.getById(id, locale);
|
|
651
|
+
}
|
|
652
|
+
if (current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
653
|
+
throw new common_1.BadRequestException('Converted proposals can no longer be cancelled.');
|
|
654
|
+
}
|
|
655
|
+
await this.prisma.$transaction(async (tx) => {
|
|
656
|
+
const currentRevision = await tx.proposal_revision.findFirst({
|
|
657
|
+
where: {
|
|
658
|
+
proposal_id: id,
|
|
659
|
+
deleted_at: null,
|
|
660
|
+
is_current: true,
|
|
661
|
+
},
|
|
662
|
+
orderBy: {
|
|
663
|
+
revision_number: 'desc',
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
if (!currentRevision) {
|
|
667
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
668
|
+
}
|
|
669
|
+
await tx.proposal_revision.update({
|
|
670
|
+
where: { id: currentRevision.id },
|
|
671
|
+
data: {
|
|
672
|
+
status: proposal_dto_1.ProposalStatus.CANCELLED,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
await tx.proposal_approval.updateMany({
|
|
676
|
+
where: {
|
|
677
|
+
proposal_id: id,
|
|
678
|
+
proposal_revision_id: currentRevision.id,
|
|
679
|
+
deleted_at: null,
|
|
680
|
+
status: 'pending',
|
|
681
|
+
},
|
|
682
|
+
data: {
|
|
683
|
+
status: 'cancelled',
|
|
684
|
+
decided_at: new Date(),
|
|
685
|
+
decision_note: this.normalizeOptionalText(data.note),
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
await tx.proposal.update({
|
|
689
|
+
where: { id },
|
|
690
|
+
data: {
|
|
691
|
+
status: proposal_dto_1.ProposalStatus.CANCELLED,
|
|
692
|
+
updated_by_user_id: userId || null,
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CANCELLED, id, locale, userId || null, {
|
|
696
|
+
note: this.normalizeOptionalText(data.note),
|
|
697
|
+
metadata: {
|
|
698
|
+
trigger: 'manual_cancel',
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
703
|
+
return this.getById(id, locale);
|
|
704
|
+
}
|
|
705
|
+
async requestConversion(id, data, locale, userId) {
|
|
706
|
+
var _a, _b;
|
|
707
|
+
const current = await this.loadProposalDetail(this.prisma, id);
|
|
708
|
+
if (!current) {
|
|
709
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
710
|
+
}
|
|
711
|
+
if (current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
712
|
+
return this.getById(id, locale);
|
|
713
|
+
}
|
|
714
|
+
if (current.status !== proposal_dto_1.ProposalStatus.APPROVED) {
|
|
715
|
+
throw new common_1.BadRequestException('Only approved proposals can request conversion.');
|
|
716
|
+
}
|
|
717
|
+
this.assertProposalWorkflowReadiness(current, 'converted to contracts');
|
|
718
|
+
const existingLinks = await this.integrationApi.findLinksBySource({
|
|
719
|
+
module: 'contact',
|
|
720
|
+
entityType: 'proposal',
|
|
721
|
+
entityId: String(id),
|
|
722
|
+
});
|
|
723
|
+
const existingContractLink = existingLinks.find((link) => link.targetModule === 'operations' &&
|
|
724
|
+
link.targetEntityType === 'contract');
|
|
725
|
+
if (existingContractLink) {
|
|
726
|
+
this.logger.debug(`[proposal-event] conversion already linked for proposal ${id} -> contract ${existingContractLink.targetEntityId}`);
|
|
727
|
+
return this.markConvertedFromIntegration({
|
|
728
|
+
proposalId: id,
|
|
729
|
+
locale,
|
|
730
|
+
createdByUserId: userId || null,
|
|
731
|
+
correlationId: `proposal:${id}:v${current.current_revision_number || 1}`,
|
|
732
|
+
contractId: Number(existingContractLink.targetEntityId || 0) || null,
|
|
733
|
+
contract: {
|
|
734
|
+
id: Number(existingContractLink.targetEntityId || 0) || null,
|
|
735
|
+
code: (_b = (_a = existingContractLink.metadata) === null || _a === void 0 ? void 0 : _a.contractCode) !== null && _b !== void 0 ? _b : null,
|
|
736
|
+
name: null,
|
|
737
|
+
status: 'draft',
|
|
738
|
+
url: null,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
await this.prisma.$transaction(async (tx) => {
|
|
743
|
+
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
744
|
+
if (!lockedProposal) {
|
|
745
|
+
throw new common_1.NotFoundException('Proposal not found.');
|
|
746
|
+
}
|
|
747
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (lockedProposal.status !== proposal_dto_1.ProposalStatus.APPROVED) {
|
|
751
|
+
throw new common_1.BadRequestException('Only approved proposals can request conversion.');
|
|
752
|
+
}
|
|
753
|
+
this.assertProposalWorkflowReadiness(lockedProposal, 'converted to contracts');
|
|
754
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED, id, locale, userId || null, {
|
|
755
|
+
note: this.normalizeOptionalText(data.note),
|
|
756
|
+
metadata: {
|
|
757
|
+
trigger: 'manual_conversion_request',
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
return this.getById(id, locale);
|
|
762
|
+
}
|
|
763
|
+
async markConvertedFromIntegration(payload) {
|
|
764
|
+
var _a, _b;
|
|
765
|
+
const proposalId = Number((payload === null || payload === void 0 ? void 0 : payload.proposalId) || ((_a = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _a === void 0 ? void 0 : _a.originId) || 0);
|
|
766
|
+
if (!Number.isInteger(proposalId) || proposalId <= 0) {
|
|
767
|
+
this.logger.warn('[proposal-event] ignoring conversion callback without a valid proposalId');
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const locale = String((payload === null || payload === void 0 ? void 0 : payload.locale) || 'en').trim() || 'en';
|
|
771
|
+
const current = await this.loadProposalDetail(this.prisma, proposalId);
|
|
772
|
+
if (!current) {
|
|
773
|
+
this.logger.warn(`[proposal-event] proposal ${proposalId} was not found when marking it as converted`);
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
if (current.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
777
|
+
this.logger.debug(`[proposal-event] proposal ${proposalId} is already marked as converted`);
|
|
778
|
+
return this.getById(proposalId, locale);
|
|
779
|
+
}
|
|
780
|
+
const contractId = Number((payload === null || payload === void 0 ? void 0 : payload.contractId) || ((_b = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _b === void 0 ? void 0 : _b.id) || 0);
|
|
781
|
+
const actorUserId = Number((payload === null || payload === void 0 ? void 0 : payload.createdByUserId) || (payload === null || payload === void 0 ? void 0 : payload.activatedByUserId) || 0) ||
|
|
782
|
+
null;
|
|
783
|
+
await this.prisma.$transaction(async (tx) => {
|
|
784
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
785
|
+
const lockedProposal = await this.lockProposalForTransition(tx, proposalId);
|
|
786
|
+
if (!lockedProposal) {
|
|
787
|
+
this.logger.warn(`[proposal-event] proposal ${proposalId} was not found when locking it for conversion`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const previousStatus = String(lockedProposal.status || proposal_dto_1.ProposalStatus.DRAFT);
|
|
794
|
+
await tx.proposal.update({
|
|
795
|
+
where: { id: proposalId },
|
|
796
|
+
data: {
|
|
797
|
+
status: proposal_dto_1.ProposalStatus.CONTRACT_GENERATED,
|
|
798
|
+
contract_generated_at: new Date(),
|
|
799
|
+
updated_by_user_id: actorUserId,
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CONVERTED, proposalId, locale, actorUserId, {
|
|
803
|
+
contract: {
|
|
804
|
+
contractId: Number.isInteger(contractId) && contractId > 0 ? contractId : null,
|
|
805
|
+
code: (_b = (_a = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _a === void 0 ? void 0 : _a.code) !== null && _b !== void 0 ? _b : null,
|
|
806
|
+
name: (_d = (_c = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _c === void 0 ? void 0 : _c.name) !== null && _d !== void 0 ? _d : null,
|
|
807
|
+
status: (_f = (_e = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _e === void 0 ? void 0 : _e.status) !== null && _f !== void 0 ? _f : 'draft',
|
|
808
|
+
url: (_h = (_g = payload === null || payload === void 0 ? void 0 : payload.contract) === null || _g === void 0 ? void 0 : _g.url) !== null && _h !== void 0 ? _h : null,
|
|
809
|
+
},
|
|
810
|
+
correlationId: (payload === null || payload === void 0 ? void 0 : payload.correlationId) || null,
|
|
811
|
+
metadata: {
|
|
812
|
+
trigger: 'operations.contract.created',
|
|
813
|
+
previousStatus,
|
|
814
|
+
contractId: Number.isInteger(contractId) && contractId > 0 ? contractId : null,
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
819
|
+
return this.getById(proposalId, locale);
|
|
820
|
+
}
|
|
821
|
+
async generateDocument(id, locale, userId) {
|
|
822
|
+
var _a, _b, _c;
|
|
823
|
+
const proposal = await this.loadProposalDetail(this.prisma, id);
|
|
824
|
+
if (!proposal) {
|
|
825
|
+
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
826
|
+
}
|
|
827
|
+
this.assertProposalDocumentReadiness(proposal);
|
|
828
|
+
const currentRevision = this.getCurrentProposalRevision(proposal);
|
|
829
|
+
if (!(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.id)) {
|
|
830
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
831
|
+
}
|
|
832
|
+
const html = await this.buildProposalDocumentHtml(proposal, locale);
|
|
833
|
+
const pdfBuffer = await this.renderProposalPdfBuffer(proposal, locale, html);
|
|
834
|
+
const fileName = this.buildProposalDocumentFileName(proposal, currentRevision);
|
|
835
|
+
const uploadedFile = await this.fileService.upload('contact/proposals/generated', {
|
|
836
|
+
fieldname: 'file',
|
|
837
|
+
originalname: fileName,
|
|
838
|
+
encoding: '7bit',
|
|
839
|
+
mimetype: 'application/pdf',
|
|
840
|
+
size: pdfBuffer.length,
|
|
841
|
+
destination: '',
|
|
842
|
+
filename: fileName,
|
|
843
|
+
path: '',
|
|
844
|
+
buffer: pdfBuffer,
|
|
845
|
+
});
|
|
846
|
+
const generatedAt = new Date().toISOString();
|
|
847
|
+
let renderVersion = 1;
|
|
848
|
+
await this.prisma.$transaction(async (tx) => {
|
|
849
|
+
var _a, _b, _c;
|
|
850
|
+
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
851
|
+
if (!lockedProposal) {
|
|
852
|
+
throw new common_1.NotFoundException('Proposal not found.');
|
|
853
|
+
}
|
|
854
|
+
this.assertProposalDocumentReadiness(lockedProposal);
|
|
855
|
+
const lockedRevision = this.getCurrentProposalRevision(lockedProposal);
|
|
856
|
+
if (!(lockedRevision === null || lockedRevision === void 0 ? void 0 : lockedRevision.id)) {
|
|
857
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
858
|
+
}
|
|
859
|
+
renderVersion = await this.upsertGeneratedProposalDocument(tx, lockedProposal.id, lockedRevision, {
|
|
860
|
+
fileId: uploadedFile.id,
|
|
861
|
+
fileName,
|
|
862
|
+
mimeType: 'application/pdf',
|
|
863
|
+
html,
|
|
864
|
+
uploadedByUserId: Number(userId) || null,
|
|
865
|
+
});
|
|
866
|
+
await tx.proposal_revision.update({
|
|
867
|
+
where: { id: lockedRevision.id },
|
|
868
|
+
data: {
|
|
869
|
+
snapshot_json: this.mergeProposalRevisionSnapshot(lockedRevision.snapshot_json, {
|
|
870
|
+
commercialDocument: {
|
|
871
|
+
template: 'proposal-commercial-v1',
|
|
872
|
+
proposalId: lockedProposal.id,
|
|
873
|
+
proposalRevisionId: lockedRevision.id,
|
|
874
|
+
revisionNumber: Number(lockedRevision.revision_number ||
|
|
875
|
+
lockedProposal.current_revision_number ||
|
|
876
|
+
1) || 1,
|
|
877
|
+
renderVersion,
|
|
878
|
+
generatedAt,
|
|
879
|
+
generatedByUserId: Number(userId) || null,
|
|
880
|
+
fileId: uploadedFile.id,
|
|
881
|
+
fileName,
|
|
882
|
+
mimeType: 'application/pdf',
|
|
883
|
+
acceptance: {
|
|
884
|
+
status: 'pending',
|
|
885
|
+
acceptedAt: null,
|
|
886
|
+
acceptedByName: ((_a = lockedProposal.person) === null || _a === void 0 ? void 0 : _a.trade_name) ||
|
|
887
|
+
((_b = lockedProposal.person) === null || _b === void 0 ? void 0 : _b.name) ||
|
|
888
|
+
null,
|
|
889
|
+
acceptedByEmail: ((_c = lockedProposal.person) === null || _c === void 0 ? void 0 : _c.email) || null,
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
}),
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
return {
|
|
897
|
+
proposalId: proposal.id,
|
|
898
|
+
proposalRevisionId: currentRevision.id,
|
|
899
|
+
version: Number(currentRevision.revision_number || proposal.current_revision_number || 1) ||
|
|
900
|
+
1,
|
|
901
|
+
renderVersion,
|
|
902
|
+
fileId: uploadedFile.id,
|
|
903
|
+
fileName,
|
|
904
|
+
mimeType: 'application/pdf',
|
|
905
|
+
documentType: 'generated_pdf',
|
|
906
|
+
downloadUrl: `/file/open/${uploadedFile.id}`,
|
|
907
|
+
html,
|
|
908
|
+
acceptance: {
|
|
909
|
+
status: 'pending',
|
|
910
|
+
acceptedAt: null,
|
|
911
|
+
expectedSignerName: ((_a = proposal.person) === null || _a === void 0 ? void 0 : _a.trade_name) || ((_b = proposal.person) === null || _b === void 0 ? void 0 : _b.name) || null,
|
|
912
|
+
expectedSignerEmail: ((_c = proposal.person) === null || _c === void 0 ? void 0 : _c.email) || null,
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
async remove(data, locale, userId) {
|
|
917
|
+
const ids = Array.isArray(data === null || data === void 0 ? void 0 : data.ids)
|
|
918
|
+
? data.ids.map((value) => Number(value)).filter((value) => value > 0)
|
|
919
|
+
: [];
|
|
920
|
+
if (ids.length === 0) {
|
|
921
|
+
throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.idsMustBeArray', locale, 'No valid proposal ids were informed'));
|
|
922
|
+
}
|
|
923
|
+
const impactedProposals = await this.prisma.proposal.findMany({
|
|
924
|
+
where: {
|
|
925
|
+
id: { in: ids },
|
|
926
|
+
deleted_at: null,
|
|
927
|
+
},
|
|
928
|
+
select: {
|
|
929
|
+
id: true,
|
|
930
|
+
person_id: true,
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
const impactedPersonIds = Array.from(new Set(impactedProposals
|
|
934
|
+
.map((proposal) => Number((proposal === null || proposal === void 0 ? void 0 : proposal.person_id) || 0))
|
|
935
|
+
.filter((personId) => personId > 0)));
|
|
936
|
+
await this.prisma.$transaction(async (tx) => {
|
|
937
|
+
const now = new Date();
|
|
938
|
+
await tx.proposal.updateMany({
|
|
939
|
+
where: {
|
|
940
|
+
id: { in: ids },
|
|
941
|
+
deleted_at: null,
|
|
942
|
+
},
|
|
943
|
+
data: {
|
|
944
|
+
deleted_at: now,
|
|
945
|
+
updated_by_user_id: userId || null,
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
await tx.proposal_revision.updateMany({
|
|
949
|
+
where: {
|
|
950
|
+
proposal_id: { in: ids },
|
|
951
|
+
deleted_at: null,
|
|
952
|
+
},
|
|
953
|
+
data: {
|
|
954
|
+
deleted_at: now,
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
await tx.proposal_item.updateMany({
|
|
958
|
+
where: {
|
|
959
|
+
proposal_revision: {
|
|
960
|
+
proposal_id: { in: ids },
|
|
961
|
+
},
|
|
962
|
+
deleted_at: null,
|
|
963
|
+
},
|
|
964
|
+
data: {
|
|
965
|
+
deleted_at: now,
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
await tx.proposal_document.updateMany({
|
|
969
|
+
where: {
|
|
970
|
+
proposal_id: { in: ids },
|
|
971
|
+
deleted_at: null,
|
|
972
|
+
},
|
|
973
|
+
data: {
|
|
974
|
+
deleted_at: now,
|
|
975
|
+
is_current: false,
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
await tx.proposal_approval.updateMany({
|
|
979
|
+
where: {
|
|
980
|
+
proposal_id: { in: ids },
|
|
981
|
+
deleted_at: null,
|
|
982
|
+
},
|
|
983
|
+
data: {
|
|
984
|
+
deleted_at: now,
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
await Promise.all(impactedPersonIds.map((personId) => this.syncPersonDealValueFromApprovedProposals(this.prisma, personId)));
|
|
989
|
+
return {
|
|
990
|
+
deleted: ids.length,
|
|
991
|
+
ids,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
async publishProposalLifecycleEvent(client, eventName, proposalId, locale, userId, extras) {
|
|
995
|
+
const proposal = await this.loadProposalIntegrationSnapshot(client, proposalId);
|
|
996
|
+
const payload = this.buildProposalLifecyclePayload(proposal, eventName, locale, userId !== null && userId !== void 0 ? userId : null, extras);
|
|
997
|
+
const recentEvents = await client.outbox_event.findMany({
|
|
998
|
+
where: {
|
|
999
|
+
event_name: eventName,
|
|
1000
|
+
source_module: 'contact',
|
|
1001
|
+
aggregate_type: 'proposal',
|
|
1002
|
+
aggregate_id: String(proposalId),
|
|
1003
|
+
},
|
|
1004
|
+
select: {
|
|
1005
|
+
id: true,
|
|
1006
|
+
status: true,
|
|
1007
|
+
payload: true,
|
|
1008
|
+
},
|
|
1009
|
+
orderBy: {
|
|
1010
|
+
id: 'desc',
|
|
1011
|
+
},
|
|
1012
|
+
take: 25,
|
|
1013
|
+
});
|
|
1014
|
+
const duplicated = recentEvents.some((event) => {
|
|
1015
|
+
var _a;
|
|
1016
|
+
return ((_a = event === null || event === void 0 ? void 0 : event.payload) === null || _a === void 0 ? void 0 : _a.eventKey) === payload.eventKey &&
|
|
1017
|
+
!['failed', 'dead_letter'].includes(String((event === null || event === void 0 ? void 0 : event.status) || ''));
|
|
1018
|
+
});
|
|
1019
|
+
if (duplicated) {
|
|
1020
|
+
this.logger.warn(`[proposal-event] skipped duplicate ${eventName} for proposal ${proposalId} (${payload.eventKey})`);
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
const outboxEvent = await this.integrationApi.publishEvent({
|
|
1024
|
+
eventName,
|
|
1025
|
+
sourceModule: 'contact',
|
|
1026
|
+
aggregateType: 'proposal',
|
|
1027
|
+
aggregateId: String(proposalId),
|
|
1028
|
+
payload,
|
|
1029
|
+
metadata: Object.assign({ producer: 'contact', correlationId: payload.correlationId, eventKey: payload.eventKey, sourceModule: 'contact', sourceEntity: 'proposal', sourceId: String(proposalId), locale, triggeredByUserId: userId !== null && userId !== void 0 ? userId : null }, ((extras === null || extras === void 0 ? void 0 : extras.metadata) || {})),
|
|
1030
|
+
}, {
|
|
1031
|
+
persistenceClient: client,
|
|
1032
|
+
});
|
|
1033
|
+
this.logger.log(`[proposal-event] queued ${eventName} for proposal ${proposalId} as outbox ${outboxEvent.id}`);
|
|
1034
|
+
return outboxEvent;
|
|
1035
|
+
}
|
|
1036
|
+
buildProposalLifecyclePayload(proposal, eventName, locale, userId, extras) {
|
|
1037
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17;
|
|
1038
|
+
const currentRevision = (_b = (_a = proposal.proposal_revision) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : null;
|
|
1039
|
+
const items = ((currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.proposal_item) || []).map((item) => {
|
|
1040
|
+
var _a, _b, _c, _d, _e;
|
|
1041
|
+
return ({
|
|
1042
|
+
id: Number(item.id || 0) || null,
|
|
1043
|
+
name: item.name,
|
|
1044
|
+
description: (_a = item.description) !== null && _a !== void 0 ? _a : null,
|
|
1045
|
+
itemType: (_b = item.item_type) !== null && _b !== void 0 ? _b : null,
|
|
1046
|
+
termType: (_c = item.term_type) !== null && _c !== void 0 ? _c : null,
|
|
1047
|
+
quantity: Number(item.quantity || 1),
|
|
1048
|
+
unitAmountCents: Number(item.unit_amount_cents || 0),
|
|
1049
|
+
totalAmountCents: Number(item.total_amount_cents || 0),
|
|
1050
|
+
amount: Number(item.total_amount_cents || 0) / 100,
|
|
1051
|
+
recurrence: (_d = item.recurrence) !== null && _d !== void 0 ? _d : null,
|
|
1052
|
+
dueDay: (_e = item.due_day) !== null && _e !== void 0 ? _e : null,
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
const version = Number((currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.revision_number) || proposal.current_revision_number || 1);
|
|
1056
|
+
const correlationId = (extras === null || extras === void 0 ? void 0 : extras.correlationId) || `proposal:${proposal.id}:v${version}`;
|
|
1057
|
+
const contractId = (_d = (_c = extras === null || extras === void 0 ? void 0 : extras.contract) === null || _c === void 0 ? void 0 : _c.contractId) !== null && _d !== void 0 ? _d : null;
|
|
1058
|
+
const eventKeyParts = [eventName, String(proposal.id), `v${version}`];
|
|
1059
|
+
if (contractId) {
|
|
1060
|
+
eventKeyParts.push(`contract:${contractId}`);
|
|
1061
|
+
}
|
|
1062
|
+
const personId = Number(proposal.person_id || ((_e = proposal.person) === null || _e === void 0 ? void 0 : _e.id) || 0) || null;
|
|
1063
|
+
const companyId = String(((_f = proposal.person) === null || _f === void 0 ? void 0 : _f.type) || '') === 'company' ? personId : null;
|
|
1064
|
+
return {
|
|
1065
|
+
eventName,
|
|
1066
|
+
eventKey: eventKeyParts.join(':'),
|
|
1067
|
+
correlationId,
|
|
1068
|
+
proposalId: proposal.id,
|
|
1069
|
+
proposalRevisionId: (_g = currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.id) !== null && _g !== void 0 ? _g : null,
|
|
1070
|
+
approvedByUserId: userId !== null && userId !== void 0 ? userId : null,
|
|
1071
|
+
dealId: personId,
|
|
1072
|
+
personId,
|
|
1073
|
+
companyId,
|
|
1074
|
+
customerIds: {
|
|
1075
|
+
personId,
|
|
1076
|
+
companyId,
|
|
1077
|
+
},
|
|
1078
|
+
code: (_h = proposal.code) !== null && _h !== void 0 ? _h : null,
|
|
1079
|
+
title: (_k = (_j = proposal.title) !== null && _j !== void 0 ? _j : currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.title) !== null && _k !== void 0 ? _k : null,
|
|
1080
|
+
version,
|
|
1081
|
+
status: (_m = (_l = proposal.status) !== null && _l !== void 0 ? _l : currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.status) !== null && _m !== void 0 ? _m : null,
|
|
1082
|
+
approvedAt: proposal.approved_at
|
|
1083
|
+
? new Date(proposal.approved_at).toISOString()
|
|
1084
|
+
: (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.approved_at)
|
|
1085
|
+
? new Date(currentRevision.approved_at).toISOString()
|
|
1086
|
+
: null,
|
|
1087
|
+
validUntil: proposal.valid_until
|
|
1088
|
+
? new Date(proposal.valid_until).toISOString()
|
|
1089
|
+
: (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.valid_until)
|
|
1090
|
+
? new Date(currentRevision.valid_until).toISOString()
|
|
1091
|
+
: null,
|
|
1092
|
+
currency: (_o = proposal.currency_code) !== null && _o !== void 0 ? _o : 'BRL',
|
|
1093
|
+
proposal: {
|
|
1094
|
+
code: (_p = proposal.code) !== null && _p !== void 0 ? _p : null,
|
|
1095
|
+
title: (_r = (_q = proposal.title) !== null && _q !== void 0 ? _q : currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.title) !== null && _r !== void 0 ? _r : null,
|
|
1096
|
+
status: (_t = (_s = proposal.status) !== null && _s !== void 0 ? _s : currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.status) !== null && _t !== void 0 ? _t : null,
|
|
1097
|
+
contractCategory: (_u = proposal.contract_category) !== null && _u !== void 0 ? _u : null,
|
|
1098
|
+
contractType: (_v = proposal.contract_type) !== null && _v !== void 0 ? _v : null,
|
|
1099
|
+
billingModel: (_w = proposal.billing_model) !== null && _w !== void 0 ? _w : null,
|
|
1100
|
+
currencyCode: (_x = proposal.currency_code) !== null && _x !== void 0 ? _x : 'BRL',
|
|
1101
|
+
validFrom: proposal.valid_from
|
|
1102
|
+
? new Date(proposal.valid_from).toISOString()
|
|
1103
|
+
: null,
|
|
1104
|
+
validUntil: proposal.valid_until
|
|
1105
|
+
? new Date(proposal.valid_until).toISOString()
|
|
1106
|
+
: (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.valid_until)
|
|
1107
|
+
? new Date(currentRevision.valid_until).toISOString()
|
|
1108
|
+
: null,
|
|
1109
|
+
subtotalAmountCents: Number(proposal.subtotal_amount_cents || 0),
|
|
1110
|
+
discountAmountCents: Number(proposal.discount_amount_cents || 0),
|
|
1111
|
+
taxAmountCents: Number(proposal.tax_amount_cents || 0),
|
|
1112
|
+
totalAmountCents: Number(proposal.total_amount_cents || 0),
|
|
1113
|
+
totalAmount: Number(proposal.total_amount_cents || 0) / 100,
|
|
1114
|
+
notes: (_z = (_y = extras === null || extras === void 0 ? void 0 : extras.note) !== null && _y !== void 0 ? _y : proposal.notes) !== null && _z !== void 0 ? _z : null,
|
|
1115
|
+
},
|
|
1116
|
+
subtotal: Number(proposal.subtotal_amount_cents || 0) / 100,
|
|
1117
|
+
subtotalCents: Number(proposal.subtotal_amount_cents || 0),
|
|
1118
|
+
discount: Number(proposal.discount_amount_cents || 0) / 100,
|
|
1119
|
+
discountCents: Number(proposal.discount_amount_cents || 0),
|
|
1120
|
+
tax: Number(proposal.tax_amount_cents || 0) / 100,
|
|
1121
|
+
taxCents: Number(proposal.tax_amount_cents || 0),
|
|
1122
|
+
total: Number(proposal.total_amount_cents || 0) / 100,
|
|
1123
|
+
totalCents: Number(proposal.total_amount_cents || 0),
|
|
1124
|
+
commercialTerms: {
|
|
1125
|
+
contractCategory: (_0 = proposal.contract_category) !== null && _0 !== void 0 ? _0 : null,
|
|
1126
|
+
contractType: (_1 = proposal.contract_type) !== null && _1 !== void 0 ? _1 : null,
|
|
1127
|
+
billingModel: (_2 = proposal.billing_model) !== null && _2 !== void 0 ? _2 : null,
|
|
1128
|
+
validFrom: proposal.valid_from
|
|
1129
|
+
? new Date(proposal.valid_from).toISOString()
|
|
1130
|
+
: null,
|
|
1131
|
+
validUntil: proposal.valid_until
|
|
1132
|
+
? new Date(proposal.valid_until).toISOString()
|
|
1133
|
+
: (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.valid_until)
|
|
1134
|
+
? new Date(currentRevision.valid_until).toISOString()
|
|
1135
|
+
: null,
|
|
1136
|
+
summary: (_3 = currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.summary) !== null && _3 !== void 0 ? _3 : null,
|
|
1137
|
+
notes: (_5 = (_4 = extras === null || extras === void 0 ? void 0 : extras.note) !== null && _4 !== void 0 ? _4 : proposal.notes) !== null && _5 !== void 0 ? _5 : null,
|
|
1138
|
+
},
|
|
1139
|
+
itemsSummary: items,
|
|
1140
|
+
items,
|
|
1141
|
+
person: proposal.person
|
|
1142
|
+
? {
|
|
1143
|
+
id: (_6 = proposal.person.id) !== null && _6 !== void 0 ? _6 : null,
|
|
1144
|
+
name: (_7 = proposal.person.name) !== null && _7 !== void 0 ? _7 : null,
|
|
1145
|
+
tradeName: (_8 = proposal.person.trade_name) !== null && _8 !== void 0 ? _8 : null,
|
|
1146
|
+
email: (_9 = proposal.person.email) !== null && _9 !== void 0 ? _9 : null,
|
|
1147
|
+
phone: (_10 = proposal.person.phone) !== null && _10 !== void 0 ? _10 : null,
|
|
1148
|
+
document: (_11 = proposal.person.document) !== null && _11 !== void 0 ? _11 : null,
|
|
1149
|
+
type: (_12 = proposal.person.type) !== null && _12 !== void 0 ? _12 : null,
|
|
1150
|
+
}
|
|
1151
|
+
: null,
|
|
1152
|
+
revision: currentRevision
|
|
1153
|
+
? {
|
|
1154
|
+
id: (_13 = currentRevision.id) !== null && _13 !== void 0 ? _13 : null,
|
|
1155
|
+
revisionNumber: version,
|
|
1156
|
+
title: (_14 = currentRevision.title) !== null && _14 !== void 0 ? _14 : null,
|
|
1157
|
+
summary: (_15 = currentRevision.summary) !== null && _15 !== void 0 ? _15 : null,
|
|
1158
|
+
contentHtml: (_16 = currentRevision.content_html) !== null && _16 !== void 0 ? _16 : null,
|
|
1159
|
+
submittedAt: currentRevision.submitted_at
|
|
1160
|
+
? new Date(currentRevision.submitted_at).toISOString()
|
|
1161
|
+
: null,
|
|
1162
|
+
approvedAt: currentRevision.approved_at
|
|
1163
|
+
? new Date(currentRevision.approved_at).toISOString()
|
|
1164
|
+
: null,
|
|
1165
|
+
}
|
|
1166
|
+
: null,
|
|
1167
|
+
contract: (_17 = extras === null || extras === void 0 ? void 0 : extras.contract) !== null && _17 !== void 0 ? _17 : null,
|
|
1168
|
+
triggeredByUserId: userId !== null && userId !== void 0 ? userId : null,
|
|
1169
|
+
emittedAt: new Date().toISOString(),
|
|
1170
|
+
locale: locale || null,
|
|
1171
|
+
sourceModule: 'contact',
|
|
1172
|
+
sourceEntity: 'proposal',
|
|
1173
|
+
sourceId: String(proposal.id),
|
|
1174
|
+
source_module: 'contact',
|
|
1175
|
+
source_entity: 'proposal',
|
|
1176
|
+
source_id: String(proposal.id),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
async assertPersonExists(personId, locale) {
|
|
1180
|
+
const person = await this.prisma.person.findUnique({
|
|
1181
|
+
where: { id: Number(personId) },
|
|
1182
|
+
select: { id: true },
|
|
1183
|
+
});
|
|
1184
|
+
if (!person) {
|
|
1185
|
+
throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, 'Person not found'));
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
async syncPersonDealValueFromApprovedProposals(client, personId) {
|
|
1189
|
+
var _a;
|
|
1190
|
+
const normalizedPersonId = Number(personId || 0);
|
|
1191
|
+
if (!Number.isInteger(normalizedPersonId) || normalizedPersonId <= 0) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const aggregation = await client.proposal.aggregate({
|
|
1195
|
+
where: {
|
|
1196
|
+
deleted_at: null,
|
|
1197
|
+
person_id: normalizedPersonId,
|
|
1198
|
+
status: {
|
|
1199
|
+
in: [proposal_dto_1.ProposalStatus.APPROVED, proposal_dto_1.ProposalStatus.CONTRACT_GENERATED],
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
_sum: {
|
|
1203
|
+
total_amount_cents: true,
|
|
1204
|
+
},
|
|
1205
|
+
});
|
|
1206
|
+
const totalAmountCents = Number(((_a = aggregation === null || aggregation === void 0 ? void 0 : aggregation._sum) === null || _a === void 0 ? void 0 : _a.total_amount_cents) || 0);
|
|
1207
|
+
const dealValue = totalAmountCents > 0 ? (totalAmountCents / 100).toFixed(2) : null;
|
|
1208
|
+
await this.upsertPersonMetadataValue(client, normalizedPersonId, 'deal_value', dealValue);
|
|
1209
|
+
}
|
|
1210
|
+
async upsertPersonMetadataValue(client, personId, key, value) {
|
|
1211
|
+
if (!client.person_metadata) {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const existing = await client.person_metadata.findFirst({
|
|
1215
|
+
where: {
|
|
1216
|
+
person_id: personId,
|
|
1217
|
+
key,
|
|
1218
|
+
},
|
|
1219
|
+
select: { id: true },
|
|
1220
|
+
});
|
|
1221
|
+
const normalizedValue = this.normalizePersonMetadataValue(value);
|
|
1222
|
+
if (normalizedValue == null) {
|
|
1223
|
+
if (existing) {
|
|
1224
|
+
await client.person_metadata.delete({
|
|
1225
|
+
where: { id: existing.id },
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (existing) {
|
|
1231
|
+
await client.person_metadata.update({
|
|
1232
|
+
where: { id: existing.id },
|
|
1233
|
+
data: { value: normalizedValue },
|
|
1234
|
+
});
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
await client.person_metadata.create({
|
|
1238
|
+
data: {
|
|
1239
|
+
person_id: personId,
|
|
1240
|
+
key,
|
|
1241
|
+
value: normalizedValue,
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
normalizePersonMetadataValue(value) {
|
|
1246
|
+
if (value == null)
|
|
1247
|
+
return null;
|
|
1248
|
+
if (typeof value === 'string') {
|
|
1249
|
+
const trimmed = value.trim();
|
|
1250
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1251
|
+
}
|
|
1252
|
+
if (typeof value === 'number') {
|
|
1253
|
+
return Number.isFinite(value) ? value : null;
|
|
1254
|
+
}
|
|
1255
|
+
if (typeof value === 'boolean') {
|
|
1256
|
+
return value;
|
|
1257
|
+
}
|
|
1258
|
+
if (Array.isArray(value) || typeof value === 'object') {
|
|
1259
|
+
try {
|
|
1260
|
+
return JSON.parse(JSON.stringify(value));
|
|
1261
|
+
}
|
|
1262
|
+
catch (_a) {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
async resolveProposalCode(code) {
|
|
1269
|
+
var _a;
|
|
1270
|
+
const normalized = (_a = this.normalizeOptionalText(code)) === null || _a === void 0 ? void 0 : _a.toUpperCase();
|
|
1271
|
+
if (normalized) {
|
|
1272
|
+
const existing = await this.prisma.proposal.findFirst({
|
|
1273
|
+
where: {
|
|
1274
|
+
code: normalized,
|
|
1275
|
+
},
|
|
1276
|
+
select: { id: true },
|
|
1277
|
+
});
|
|
1278
|
+
if (existing) {
|
|
1279
|
+
throw new common_1.BadRequestException('Proposal code already exists.');
|
|
1280
|
+
}
|
|
1281
|
+
return normalized;
|
|
1282
|
+
}
|
|
1283
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1284
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
1285
|
+
const generated = `PROP-${stamp}-${Math.floor(1000 + Math.random() * 9000)}`;
|
|
1286
|
+
const existing = await this.prisma.proposal.findFirst({
|
|
1287
|
+
where: { code: generated },
|
|
1288
|
+
select: { id: true },
|
|
1289
|
+
});
|
|
1290
|
+
if (!existing) {
|
|
1291
|
+
return generated;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return `PROP-${Date.now()}`;
|
|
1295
|
+
}
|
|
1296
|
+
normalizeItems(items) {
|
|
1297
|
+
return (items || []).map((item) => {
|
|
1298
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
1299
|
+
const quantity = Number((_a = item.quantity) !== null && _a !== void 0 ? _a : 1);
|
|
1300
|
+
const unitAmountCents = Number((_b = item.unit_amount_cents) !== null && _b !== void 0 ? _b : 0);
|
|
1301
|
+
const totalAmountCents = Number.isFinite(Number(item.total_amount_cents))
|
|
1302
|
+
? Number(item.total_amount_cents)
|
|
1303
|
+
: Math.round(quantity * unitAmountCents);
|
|
1304
|
+
return {
|
|
1305
|
+
item_type: (_c = item.item_type) !== null && _c !== void 0 ? _c : 'service',
|
|
1306
|
+
term_type: (_d = item.term_type) !== null && _d !== void 0 ? _d : 'value',
|
|
1307
|
+
name: item.name.trim(),
|
|
1308
|
+
description: this.normalizeOptionalText(item.description),
|
|
1309
|
+
quantity,
|
|
1310
|
+
unit_amount_cents: unitAmountCents,
|
|
1311
|
+
total_amount_cents: totalAmountCents,
|
|
1312
|
+
recurrence: (_e = item.recurrence) !== null && _e !== void 0 ? _e : 'one_time',
|
|
1313
|
+
due_day: (_f = item.due_day) !== null && _f !== void 0 ? _f : null,
|
|
1314
|
+
start_date: (_g = item.start_date) !== null && _g !== void 0 ? _g : null,
|
|
1315
|
+
end_date: (_h = item.end_date) !== null && _h !== void 0 ? _h : null,
|
|
1316
|
+
metadata_json: (_j = item.metadata_json) !== null && _j !== void 0 ? _j : null,
|
|
1317
|
+
};
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
normalizeDocuments(documents, proposalId, proposalRevisionId, uploadedByUserId) {
|
|
1321
|
+
return (documents || []).map((document) => {
|
|
1322
|
+
var _a, _b, _c, _d;
|
|
1323
|
+
return ({
|
|
1324
|
+
proposal_id: proposalId,
|
|
1325
|
+
proposal_revision_id: proposalRevisionId,
|
|
1326
|
+
document_type: (_a = document.document_type) !== null && _a !== void 0 ? _a : 'attachment',
|
|
1327
|
+
file_id: (_b = document.file_id) !== null && _b !== void 0 ? _b : null,
|
|
1328
|
+
file_name: document.file_name.trim(),
|
|
1329
|
+
mime_type: document.mime_type.trim(),
|
|
1330
|
+
file_content_base64: this.normalizeOptionalText(document.file_content_base64),
|
|
1331
|
+
is_current: true,
|
|
1332
|
+
source_kind: (_c = document.source_kind) !== null && _c !== void 0 ? _c : 'manual',
|
|
1333
|
+
extraction_status: (_d = document.extraction_status) !== null && _d !== void 0 ? _d : 'skipped',
|
|
1334
|
+
extraction_summary: this.normalizeOptionalText(document.extraction_summary),
|
|
1335
|
+
notes: this.normalizeOptionalText(document.notes),
|
|
1336
|
+
uploaded_by_user_id: uploadedByUserId,
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
resolveTotals(data, normalizedItems) {
|
|
1341
|
+
const computed = normalizedItems.reduce((acc, item) => {
|
|
1342
|
+
const amount = Number(item.total_amount_cents || 0);
|
|
1343
|
+
if (item.item_type === 'discount') {
|
|
1344
|
+
acc.discount += Math.abs(amount);
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
acc.subtotal += amount;
|
|
1348
|
+
}
|
|
1349
|
+
return acc;
|
|
1350
|
+
}, { subtotal: 0, discount: 0 });
|
|
1351
|
+
const subtotalAmountCents = Number.isFinite(Number(data.subtotal_amount_cents))
|
|
1352
|
+
? Number(data.subtotal_amount_cents)
|
|
1353
|
+
: computed.subtotal;
|
|
1354
|
+
const discountAmountCents = Number.isFinite(Number(data.discount_amount_cents))
|
|
1355
|
+
? Number(data.discount_amount_cents)
|
|
1356
|
+
: computed.discount;
|
|
1357
|
+
const taxAmountCents = Number.isFinite(Number(data.tax_amount_cents))
|
|
1358
|
+
? Number(data.tax_amount_cents)
|
|
1359
|
+
: 0;
|
|
1360
|
+
const totalAmountCents = Number.isFinite(Number(data.total_amount_cents))
|
|
1361
|
+
? Number(data.total_amount_cents)
|
|
1362
|
+
: subtotalAmountCents - discountAmountCents + taxAmountCents;
|
|
1363
|
+
return {
|
|
1364
|
+
subtotal_amount_cents: subtotalAmountCents,
|
|
1365
|
+
discount_amount_cents: discountAmountCents,
|
|
1366
|
+
tax_amount_cents: taxAmountCents,
|
|
1367
|
+
total_amount_cents: totalAmountCents,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
getCurrentProposalRevision(proposal) {
|
|
1371
|
+
var _a, _b, _c, _d;
|
|
1372
|
+
return ((_d = (_b = (_a = proposal === null || proposal === void 0 ? void 0 : proposal.proposal_revision) === null || _a === void 0 ? void 0 : _a.find((revision) => revision === null || revision === void 0 ? void 0 : revision.is_current)) !== null && _b !== void 0 ? _b : (_c = proposal === null || proposal === void 0 ? void 0 : proposal.proposal_revision) === null || _c === void 0 ? void 0 : _c[0]) !== null && _d !== void 0 ? _d : null);
|
|
1373
|
+
}
|
|
1374
|
+
assertProposalDocumentReadiness(proposal) {
|
|
1375
|
+
const currentRevision = this.getCurrentProposalRevision(proposal);
|
|
1376
|
+
if (!currentRevision) {
|
|
1377
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
1378
|
+
}
|
|
1379
|
+
const revisionItems = Array.isArray(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.proposal_item)
|
|
1380
|
+
? currentRevision.proposal_item.filter((item) => item && item.deleted_at == null)
|
|
1381
|
+
: [];
|
|
1382
|
+
const hasPositiveTotal = Number((proposal === null || proposal === void 0 ? void 0 : proposal.total_amount_cents) || 0) > 0;
|
|
1383
|
+
if (revisionItems.length === 0 && !hasPositiveTotal) {
|
|
1384
|
+
throw new common_1.BadRequestException('Proposals without items or value cannot generate a commercial document.');
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
async upsertGeneratedProposalDocument(client, proposalId, proposalRevision, document) {
|
|
1388
|
+
var _a, _b;
|
|
1389
|
+
const renderVersion = (await client.proposal_document.count({
|
|
1390
|
+
where: {
|
|
1391
|
+
proposal_id: proposalId,
|
|
1392
|
+
proposal_revision_id: proposalRevision.id,
|
|
1393
|
+
document_type: 'generated_pdf',
|
|
1394
|
+
deleted_at: null,
|
|
1395
|
+
},
|
|
1396
|
+
})) + 1;
|
|
1397
|
+
await client.proposal_document.updateMany({
|
|
1398
|
+
where: {
|
|
1399
|
+
proposal_id: proposalId,
|
|
1400
|
+
proposal_revision_id: proposalRevision.id,
|
|
1401
|
+
document_type: 'generated_pdf',
|
|
1402
|
+
deleted_at: null,
|
|
1403
|
+
},
|
|
1404
|
+
data: {
|
|
1405
|
+
is_current: false,
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
await client.proposal_document.create({
|
|
1409
|
+
data: {
|
|
1410
|
+
proposal_id: proposalId,
|
|
1411
|
+
proposal_revision_id: proposalRevision.id,
|
|
1412
|
+
document_type: 'generated_pdf',
|
|
1413
|
+
file_id: (_a = document.fileId) !== null && _a !== void 0 ? _a : null,
|
|
1414
|
+
file_name: document.fileName,
|
|
1415
|
+
mime_type: document.mimeType,
|
|
1416
|
+
file_content_base64: Buffer.from(document.html, 'utf8').toString('base64'),
|
|
1417
|
+
is_current: true,
|
|
1418
|
+
source_kind: 'generated',
|
|
1419
|
+
extraction_status: 'skipped',
|
|
1420
|
+
extraction_summary: `Commercial proposal render version ${renderVersion}.`,
|
|
1421
|
+
notes: 'Commercial proposal PDF generated from proposal data. The base64 snapshot stores the rendered HTML source for preview and future electronic acceptance.',
|
|
1422
|
+
uploaded_by_user_id: (_b = document.uploadedByUserId) !== null && _b !== void 0 ? _b : null,
|
|
1423
|
+
},
|
|
1424
|
+
});
|
|
1425
|
+
return renderVersion;
|
|
1426
|
+
}
|
|
1427
|
+
async renderProposalPdfBuffer(proposal, locale, html) {
|
|
1428
|
+
var _a, _b, _c;
|
|
1429
|
+
const renderedHtml = html !== null && html !== void 0 ? html : (await this.buildProposalDocumentHtml(proposal, locale));
|
|
1430
|
+
let browser = null;
|
|
1431
|
+
try {
|
|
1432
|
+
const importPlaywright = new Function('moduleName', 'return import(moduleName);');
|
|
1433
|
+
const playwright = await importPlaywright('playwright');
|
|
1434
|
+
browser = await playwright.chromium.launch({ headless: true });
|
|
1435
|
+
const page = await browser.newPage();
|
|
1436
|
+
await page.setContent(renderedHtml, { waitUntil: 'networkidle' });
|
|
1437
|
+
return Buffer.from(await page.pdf({
|
|
1438
|
+
format: 'A4',
|
|
1439
|
+
printBackground: true,
|
|
1440
|
+
margin: {
|
|
1441
|
+
top: '72px',
|
|
1442
|
+
right: '18px',
|
|
1443
|
+
bottom: '56px',
|
|
1444
|
+
left: '18px',
|
|
1445
|
+
},
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1450
|
+
const errorStack = error instanceof Error ? ((_a = error.stack) !== null && _a !== void 0 ? _a : error.message) : String(error);
|
|
1451
|
+
const missingPlaywrightRuntime = /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(errorMessage);
|
|
1452
|
+
this.logger.error(`Failed to generate proposal PDF for proposal ${(_b = proposal === null || proposal === void 0 ? void 0 : proposal.id) !== null && _b !== void 0 ? _b : 'unknown'} (locale=${locale}). ${errorMessage}`, errorStack);
|
|
1453
|
+
throw new common_1.InternalServerErrorException(missingPlaywrightRuntime
|
|
1454
|
+
? 'PDF generation is unavailable because Playwright/Chromium is not installed on the server. Run `pnpm --filter api run playwright:install` in the API environment.'
|
|
1455
|
+
: 'Failed to generate the PDF document. Check server logs for details.');
|
|
1456
|
+
}
|
|
1457
|
+
finally {
|
|
1458
|
+
await ((_c = browser === null || browser === void 0 ? void 0 : browser.close) === null || _c === void 0 ? void 0 : _c.call(browser));
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async buildProposalDocumentHtml(proposal, locale = 'en') {
|
|
1462
|
+
var _a, _b, _c, _d, _e;
|
|
1463
|
+
const labels = this.getProposalDocumentLabels(locale);
|
|
1464
|
+
const logoUrl = await this.resolveProposalDocumentLogoUrl();
|
|
1465
|
+
const companyName = await this.resolveProposalDocumentCompanyName();
|
|
1466
|
+
const currentRevision = this.getCurrentProposalRevision(proposal);
|
|
1467
|
+
const items = Array.isArray(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.proposal_item)
|
|
1468
|
+
? currentRevision.proposal_item.filter((item) => item && item.deleted_at == null)
|
|
1469
|
+
: [];
|
|
1470
|
+
const bodyHtml = this.sanitizeDocumentHtml(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.content_html) ||
|
|
1471
|
+
((_a = this.normalizeOptionalText(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.summary)) === null || _a === void 0 ? void 0 : _a.split(/\n{2,}/).map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`).join('')) ||
|
|
1472
|
+
'<p>No additional commercial narrative was provided yet.</p>';
|
|
1473
|
+
const itemsHtml = items.length
|
|
1474
|
+
? items
|
|
1475
|
+
.map((item) => `
|
|
1476
|
+
<tr>
|
|
1477
|
+
<td>${this.escapeHtml(item.name || 'Item')}</td>
|
|
1478
|
+
<td>${this.escapeHtml(item.description || '—')}</td>
|
|
1479
|
+
<td class="numeric">${this.escapeHtml(String(Number(item.quantity || 1)))}</td>
|
|
1480
|
+
<td>${this.escapeHtml(this.humanizeEnumLabel(item.recurrence || 'one_time'))}</td>
|
|
1481
|
+
<td class="numeric">${this.escapeHtml(this.formatMoney(Number(item.unit_amount_cents || 0) / 100, proposal.currency_code, locale))}</td>
|
|
1482
|
+
<td class="numeric">${this.escapeHtml(this.formatMoney(Number(item.total_amount_cents || 0) / 100, proposal.currency_code, locale))}</td>
|
|
1483
|
+
</tr>`)
|
|
1484
|
+
.join('')
|
|
1485
|
+
: `
|
|
1486
|
+
<tr>
|
|
1487
|
+
<td colspan="6">${this.escapeHtml(labels.emptyItems)}</td>
|
|
1488
|
+
</tr>`;
|
|
1489
|
+
const conditions = [
|
|
1490
|
+
`${labels.validity}: ${this.formatDateLabel(proposal.valid_until, locale, labels.openEnded)}`,
|
|
1491
|
+
`${labels.billingModel}: ${this.humanizeEnumLabel(proposal.billing_model || 'fixed_price')}`,
|
|
1492
|
+
`${labels.proposalType}: ${this.humanizeEnumLabel(proposal.contract_type || 'service_agreement')}`,
|
|
1493
|
+
`${labels.acceptanceReady}`,
|
|
1494
|
+
]
|
|
1495
|
+
.filter(Boolean)
|
|
1496
|
+
.map((item) => `<li>${this.escapeHtml(item)}</li>`)
|
|
1497
|
+
.join('');
|
|
1498
|
+
const notesHtml = this.normalizeOptionalText(proposal.notes)
|
|
1499
|
+
? this.normalizeOptionalText(proposal.notes)
|
|
1500
|
+
.split(/\n{2,}/)
|
|
1501
|
+
.map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`)
|
|
1502
|
+
.join('')
|
|
1503
|
+
: `<p>${this.escapeHtml(labels.noNotes)}</p>`;
|
|
1504
|
+
return `<!DOCTYPE html>
|
|
1505
|
+
<html lang="${this.escapeHtml(locale || 'en')}">
|
|
1506
|
+
<head>
|
|
1507
|
+
<meta charset="utf-8" />
|
|
1508
|
+
<style>
|
|
1509
|
+
@page {
|
|
1510
|
+
size: A4;
|
|
1511
|
+
margin: 72px 18px 56px 18px;
|
|
1512
|
+
}
|
|
1513
|
+
body {
|
|
1514
|
+
color: #0f172a;
|
|
1515
|
+
font-family: Arial, sans-serif;
|
|
1516
|
+
font-size: 12px;
|
|
1517
|
+
line-height: 1.5;
|
|
1518
|
+
margin: 0;
|
|
1519
|
+
}
|
|
1520
|
+
header {
|
|
1521
|
+
align-items: center;
|
|
1522
|
+
border-bottom: 2px solid #dbeafe;
|
|
1523
|
+
display: flex;
|
|
1524
|
+
justify-content: space-between;
|
|
1525
|
+
gap: 16px;
|
|
1526
|
+
margin-bottom: 24px;
|
|
1527
|
+
padding-bottom: 16px;
|
|
1528
|
+
}
|
|
1529
|
+
.brand { display: flex; align-items: center; gap: 12px; }
|
|
1530
|
+
.brand img { height: 42px; max-width: 140px; object-fit: contain; }
|
|
1531
|
+
.eyebrow {
|
|
1532
|
+
color: #2563eb;
|
|
1533
|
+
font-size: 10px;
|
|
1534
|
+
font-weight: 700;
|
|
1535
|
+
letter-spacing: 0.12em;
|
|
1536
|
+
text-transform: uppercase;
|
|
1537
|
+
}
|
|
1538
|
+
h1 { font-size: 22px; margin: 4px 0 0; }
|
|
1539
|
+
.meta-grid {
|
|
1540
|
+
display: grid;
|
|
1541
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1542
|
+
gap: 12px;
|
|
1543
|
+
margin: 18px 0 20px;
|
|
1544
|
+
}
|
|
1545
|
+
.meta-card, .totals-card {
|
|
1546
|
+
background: #f8fafc;
|
|
1547
|
+
border: 1px solid #dbeafe;
|
|
1548
|
+
border-radius: 12px;
|
|
1549
|
+
padding: 12px;
|
|
1550
|
+
}
|
|
1551
|
+
.meta-card strong, .totals-card strong {
|
|
1552
|
+
display: block;
|
|
1553
|
+
font-size: 10px;
|
|
1554
|
+
letter-spacing: 0.08em;
|
|
1555
|
+
margin-bottom: 4px;
|
|
1556
|
+
text-transform: uppercase;
|
|
1557
|
+
}
|
|
1558
|
+
section { margin-top: 18px; }
|
|
1559
|
+
h2 { color: #1d4ed8; font-size: 14px; margin: 0 0 10px; }
|
|
1560
|
+
.items-table {
|
|
1561
|
+
border-collapse: collapse;
|
|
1562
|
+
width: 100%;
|
|
1563
|
+
}
|
|
1564
|
+
.items-table th, .items-table td {
|
|
1565
|
+
border-bottom: 1px solid #e2e8f0;
|
|
1566
|
+
padding: 8px 10px;
|
|
1567
|
+
text-align: left;
|
|
1568
|
+
vertical-align: top;
|
|
1569
|
+
}
|
|
1570
|
+
.items-table th {
|
|
1571
|
+
background: #eff6ff;
|
|
1572
|
+
font-size: 10px;
|
|
1573
|
+
letter-spacing: 0.08em;
|
|
1574
|
+
text-transform: uppercase;
|
|
1575
|
+
}
|
|
1576
|
+
.numeric { text-align: right; }
|
|
1577
|
+
.content, .notes, .acceptance {
|
|
1578
|
+
border: 1px solid #e2e8f0;
|
|
1579
|
+
border-radius: 14px;
|
|
1580
|
+
padding: 16px 18px;
|
|
1581
|
+
}
|
|
1582
|
+
.totals-grid {
|
|
1583
|
+
display: grid;
|
|
1584
|
+
grid-template-columns: 1.5fr 1fr;
|
|
1585
|
+
gap: 16px;
|
|
1586
|
+
align-items: start;
|
|
1587
|
+
}
|
|
1588
|
+
.totals-card .row {
|
|
1589
|
+
display: flex;
|
|
1590
|
+
justify-content: space-between;
|
|
1591
|
+
margin-bottom: 6px;
|
|
1592
|
+
}
|
|
1593
|
+
.totals-card .row.total {
|
|
1594
|
+
border-top: 1px solid #cbd5e1;
|
|
1595
|
+
font-size: 14px;
|
|
1596
|
+
font-weight: 700;
|
|
1597
|
+
margin-top: 10px;
|
|
1598
|
+
padding-top: 10px;
|
|
1599
|
+
}
|
|
1600
|
+
ul { margin: 0; padding-left: 18px; }
|
|
1601
|
+
footer {
|
|
1602
|
+
border-top: 1px solid #dbeafe;
|
|
1603
|
+
color: #475569;
|
|
1604
|
+
font-size: 10px;
|
|
1605
|
+
margin-top: 28px;
|
|
1606
|
+
padding-top: 12px;
|
|
1607
|
+
}
|
|
1608
|
+
</style>
|
|
1609
|
+
</head>
|
|
1610
|
+
<body>
|
|
1611
|
+
<header>
|
|
1612
|
+
<div class="brand">
|
|
1613
|
+
<img src="${logoUrl}" alt="${this.escapeHtml(companyName)}" />
|
|
1614
|
+
<div>
|
|
1615
|
+
<div class="eyebrow">${this.escapeHtml(labels.documentTag)}</div>
|
|
1616
|
+
<h1>${this.escapeHtml(proposal.title || (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.title) || labels.documentTitle)}</h1>
|
|
1617
|
+
<div>${this.escapeHtml(companyName)}</div>
|
|
1618
|
+
</div>
|
|
1619
|
+
</div>
|
|
1620
|
+
<div>
|
|
1621
|
+
<div><strong>${this.escapeHtml(labels.code)}:</strong> ${this.escapeHtml(proposal.code || `PROP-${proposal.id}`)}</div>
|
|
1622
|
+
<div><strong>${this.escapeHtml(labels.version)}:</strong> ${this.escapeHtml(`R${Number((currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.revision_number) || proposal.current_revision_number || 1) || 1}`)}</div>
|
|
1623
|
+
<div><strong>${this.escapeHtml(labels.status)}:</strong> ${this.escapeHtml(this.humanizeEnumLabel(proposal.status || 'draft'))}</div>
|
|
1624
|
+
</div>
|
|
1625
|
+
</header>
|
|
1626
|
+
|
|
1627
|
+
<div class="meta-grid">
|
|
1628
|
+
<div class="meta-card">
|
|
1629
|
+
<strong>${this.escapeHtml(labels.client)}</strong>
|
|
1630
|
+
${this.escapeHtml(((_b = proposal.person) === null || _b === void 0 ? void 0 : _b.trade_name) || ((_c = proposal.person) === null || _c === void 0 ? void 0 : _c.name) || labels.notInformed)}
|
|
1631
|
+
</div>
|
|
1632
|
+
<div class="meta-card">
|
|
1633
|
+
<strong>${this.escapeHtml(labels.validity)}</strong>
|
|
1634
|
+
${this.escapeHtml(this.formatDateLabel(proposal.valid_until, locale, labels.openEnded))}
|
|
1635
|
+
</div>
|
|
1636
|
+
<div class="meta-card">
|
|
1637
|
+
<strong>${this.escapeHtml(labels.email)}</strong>
|
|
1638
|
+
${this.escapeHtml(((_d = proposal.person) === null || _d === void 0 ? void 0 : _d.email) || labels.notInformed)}
|
|
1639
|
+
</div>
|
|
1640
|
+
<div class="meta-card">
|
|
1641
|
+
<strong>${this.escapeHtml(labels.document)}</strong>
|
|
1642
|
+
${this.escapeHtml(((_e = proposal.person) === null || _e === void 0 ? void 0 : _e.document) || labels.notInformed)}
|
|
1643
|
+
</div>
|
|
1644
|
+
</div>
|
|
1645
|
+
|
|
1646
|
+
<section>
|
|
1647
|
+
<h2>${this.escapeHtml(labels.executiveSummary)}</h2>
|
|
1648
|
+
<div class="content">${bodyHtml}</div>
|
|
1649
|
+
</section>
|
|
1650
|
+
|
|
1651
|
+
<section>
|
|
1652
|
+
<h2>${this.escapeHtml(labels.items)}</h2>
|
|
1653
|
+
<table class="items-table">
|
|
1654
|
+
<thead>
|
|
1655
|
+
<tr>
|
|
1656
|
+
<th>${this.escapeHtml(labels.item)}</th>
|
|
1657
|
+
<th>${this.escapeHtml(labels.description)}</th>
|
|
1658
|
+
<th class="numeric">${this.escapeHtml(labels.qty)}</th>
|
|
1659
|
+
<th>${this.escapeHtml(labels.recurrence)}</th>
|
|
1660
|
+
<th class="numeric">${this.escapeHtml(labels.unitPrice)}</th>
|
|
1661
|
+
<th class="numeric">${this.escapeHtml(labels.total)}</th>
|
|
1662
|
+
</tr>
|
|
1663
|
+
</thead>
|
|
1664
|
+
<tbody>${itemsHtml}</tbody>
|
|
1665
|
+
</table>
|
|
1666
|
+
</section>
|
|
1667
|
+
|
|
1668
|
+
<section>
|
|
1669
|
+
<div class="totals-grid">
|
|
1670
|
+
<div class="notes">
|
|
1671
|
+
<h2>${this.escapeHtml(labels.commercialConditions)}</h2>
|
|
1672
|
+
<ul>${conditions}</ul>
|
|
1673
|
+
<h2 style="margin-top: 16px;">${this.escapeHtml(labels.notes)}</h2>
|
|
1674
|
+
${notesHtml}
|
|
1675
|
+
</div>
|
|
1676
|
+
<div class="totals-card">
|
|
1677
|
+
<strong>${this.escapeHtml(labels.totals)}</strong>
|
|
1678
|
+
<div class="row"><span>${this.escapeHtml(labels.subtotal)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.subtotal_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1679
|
+
<div class="row"><span>${this.escapeHtml(labels.discount)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.discount_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1680
|
+
<div class="row"><span>${this.escapeHtml(labels.tax)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.tax_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1681
|
+
<div class="row total"><span>${this.escapeHtml(labels.total)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.total_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1682
|
+
</div>
|
|
1683
|
+
</div>
|
|
1684
|
+
</section>
|
|
1685
|
+
|
|
1686
|
+
<section>
|
|
1687
|
+
<h2>${this.escapeHtml(labels.acceptanceSection)}</h2>
|
|
1688
|
+
<div class="acceptance">
|
|
1689
|
+
<p>${this.escapeHtml(labels.acceptanceMessage)}</p>
|
|
1690
|
+
<p><strong>${this.escapeHtml(labels.acceptanceStatus)}:</strong> ${this.escapeHtml(labels.pending)}</p>
|
|
1691
|
+
</div>
|
|
1692
|
+
</section>
|
|
1693
|
+
|
|
1694
|
+
<footer>
|
|
1695
|
+
${this.escapeHtml(labels.generatedOn)} ${this.escapeHtml(this.formatDateLabel(new Date().toISOString(), locale, labels.notInformed))} · ${this.escapeHtml(companyName)}
|
|
1696
|
+
</footer>
|
|
1697
|
+
</body>
|
|
1698
|
+
</html>`;
|
|
1699
|
+
}
|
|
1700
|
+
async resolveProposalDocumentLogoUrl() {
|
|
1701
|
+
var _a;
|
|
1702
|
+
const settings = await this.settingService.getSettingValues([
|
|
1703
|
+
'image-url',
|
|
1704
|
+
'icon-url',
|
|
1705
|
+
]);
|
|
1706
|
+
const configuredUrl = (_a = this.normalizeOptionalText(settings['image-url'])) !== null && _a !== void 0 ? _a : this.normalizeOptionalText(settings['icon-url']);
|
|
1707
|
+
if (configuredUrl && /^https?:\/\//i.test(configuredUrl)) {
|
|
1708
|
+
return configuredUrl;
|
|
1709
|
+
}
|
|
1710
|
+
const inlineSvg = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
|
|
1711
|
+
<rect width="240" height="72" rx="18" fill="#0f172a"/>
|
|
1712
|
+
<circle cx="38" cy="36" r="18" fill="#3b82f6"/>
|
|
1713
|
+
<text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>
|
|
1714
|
+
</svg>`);
|
|
1715
|
+
return `data:image/svg+xml;charset=UTF-8,${inlineSvg}`;
|
|
1716
|
+
}
|
|
1717
|
+
async resolveProposalDocumentCompanyName() {
|
|
1718
|
+
var _a;
|
|
1719
|
+
const settings = await this.settingService.getSettingValues(['system-name']);
|
|
1720
|
+
return (_a = this.normalizeOptionalText(settings['system-name'])) !== null && _a !== void 0 ? _a : 'HedHog';
|
|
1721
|
+
}
|
|
1722
|
+
buildProposalDocumentFileName(proposal, revision) {
|
|
1723
|
+
var _a, _b;
|
|
1724
|
+
const base = (_b = (_a = this.normalizeOptionalText(proposal.code)) !== null && _a !== void 0 ? _a : this.normalizeOptionalText(proposal.title)) !== null && _b !== void 0 ? _b : `proposal-${proposal.id}`;
|
|
1725
|
+
const revisionLabel = Number((revision === null || revision === void 0 ? void 0 : revision.revision_number) || proposal.current_revision_number || 1);
|
|
1726
|
+
return `${base
|
|
1727
|
+
.toLowerCase()
|
|
1728
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1729
|
+
.replace(/^-+|-+$/g, '')
|
|
1730
|
+
.slice(0, 72) || `proposal-${proposal.id}`}-r${revisionLabel}.pdf`;
|
|
1731
|
+
}
|
|
1732
|
+
mergeProposalRevisionSnapshot(existingSnapshot, patch) {
|
|
1733
|
+
const base = this.normalizeSnapshotJson(existingSnapshot);
|
|
1734
|
+
return Object.assign(Object.assign(Object.assign({}, base), patch), { commercialDocument: Object.assign(Object.assign({}, (base.commercialDocument || {})), (patch.commercialDocument || {})) });
|
|
1735
|
+
}
|
|
1736
|
+
normalizeSnapshotJson(value) {
|
|
1737
|
+
if (!value) {
|
|
1738
|
+
return {};
|
|
1739
|
+
}
|
|
1740
|
+
if (typeof value === 'string') {
|
|
1741
|
+
try {
|
|
1742
|
+
const parsed = JSON.parse(value);
|
|
1743
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
1744
|
+
}
|
|
1745
|
+
catch (_a) {
|
|
1746
|
+
return {};
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1750
|
+
}
|
|
1751
|
+
getProposalDocumentLabels(locale) {
|
|
1752
|
+
const isPt = String(locale || '').toLowerCase().startsWith('pt');
|
|
1753
|
+
return isPt
|
|
1754
|
+
? {
|
|
1755
|
+
documentTag: 'Proposta Comercial',
|
|
1756
|
+
documentTitle: 'Proposta Comercial',
|
|
1757
|
+
code: 'Código',
|
|
1758
|
+
version: 'Versão',
|
|
1759
|
+
status: 'Status',
|
|
1760
|
+
client: 'Cliente',
|
|
1761
|
+
validity: 'Validade',
|
|
1762
|
+
email: 'E-mail',
|
|
1763
|
+
document: 'Documento',
|
|
1764
|
+
executiveSummary: 'Resumo Executivo',
|
|
1765
|
+
items: 'Itens da Proposta',
|
|
1766
|
+
item: 'Item',
|
|
1767
|
+
description: 'Descrição',
|
|
1768
|
+
qty: 'Qtd.',
|
|
1769
|
+
recurrence: 'Recorrência',
|
|
1770
|
+
unitPrice: 'Valor Unitário',
|
|
1771
|
+
total: 'Total',
|
|
1772
|
+
totals: 'Totais',
|
|
1773
|
+
subtotal: 'Subtotal',
|
|
1774
|
+
discount: 'Desconto',
|
|
1775
|
+
tax: 'Impostos',
|
|
1776
|
+
commercialConditions: 'Condições Comerciais',
|
|
1777
|
+
notes: 'Observações',
|
|
1778
|
+
noNotes: 'Nenhuma observação adicional informada.',
|
|
1779
|
+
emptyItems: 'Nenhum item comercial foi registrado nesta versão.',
|
|
1780
|
+
notInformed: 'Não informado',
|
|
1781
|
+
openEnded: 'Em aberto',
|
|
1782
|
+
billingModel: 'Modelo de cobrança',
|
|
1783
|
+
proposalType: 'Tipo comercial',
|
|
1784
|
+
acceptanceReady: 'Documento preparado para aceite eletrônico simples em uma etapa futura.',
|
|
1785
|
+
acceptanceSection: 'Aceite Eletrônico (preparação)',
|
|
1786
|
+
acceptanceMessage: 'Esta versão já preserva o HTML da proposta e o PDF assinado visualmente, facilitando a inclusão de um aceite eletrônico simples no futuro.',
|
|
1787
|
+
acceptanceStatus: 'Status do aceite',
|
|
1788
|
+
pending: 'Pendente',
|
|
1789
|
+
generatedOn: 'Gerado em',
|
|
1790
|
+
}
|
|
1791
|
+
: {
|
|
1792
|
+
documentTag: 'Commercial Proposal',
|
|
1793
|
+
documentTitle: 'Commercial Proposal',
|
|
1794
|
+
code: 'Code',
|
|
1795
|
+
version: 'Version',
|
|
1796
|
+
status: 'Status',
|
|
1797
|
+
client: 'Client',
|
|
1798
|
+
validity: 'Validity',
|
|
1799
|
+
email: 'Email',
|
|
1800
|
+
document: 'Document',
|
|
1801
|
+
executiveSummary: 'Executive Summary',
|
|
1802
|
+
items: 'Proposal Items',
|
|
1803
|
+
item: 'Item',
|
|
1804
|
+
description: 'Description',
|
|
1805
|
+
qty: 'Qty.',
|
|
1806
|
+
recurrence: 'Recurrence',
|
|
1807
|
+
unitPrice: 'Unit Price',
|
|
1808
|
+
total: 'Total',
|
|
1809
|
+
totals: 'Totals',
|
|
1810
|
+
subtotal: 'Subtotal',
|
|
1811
|
+
discount: 'Discount',
|
|
1812
|
+
tax: 'Tax',
|
|
1813
|
+
commercialConditions: 'Commercial Conditions',
|
|
1814
|
+
notes: 'Notes',
|
|
1815
|
+
noNotes: 'No additional notes were provided.',
|
|
1816
|
+
emptyItems: 'No commercial items were registered for this version.',
|
|
1817
|
+
notInformed: 'Not informed',
|
|
1818
|
+
openEnded: 'Open ended',
|
|
1819
|
+
billingModel: 'Billing model',
|
|
1820
|
+
proposalType: 'Commercial type',
|
|
1821
|
+
acceptanceReady: 'This document is ready for a future lightweight electronic acceptance step.',
|
|
1822
|
+
acceptanceSection: 'Electronic Acceptance (prepared)',
|
|
1823
|
+
acceptanceMessage: 'This version already preserves the rendered HTML and visual PDF, which will simplify adding a lightweight electronic acceptance flow later.',
|
|
1824
|
+
acceptanceStatus: 'Acceptance status',
|
|
1825
|
+
pending: 'Pending',
|
|
1826
|
+
generatedOn: 'Generated on',
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
formatMoney(amount, currencyCode, locale = 'en') {
|
|
1830
|
+
var _a;
|
|
1831
|
+
const currency = (_a = this.normalizeOptionalText(currencyCode)) !== null && _a !== void 0 ? _a : 'BRL';
|
|
1832
|
+
try {
|
|
1833
|
+
return new Intl.NumberFormat(locale || 'en', {
|
|
1834
|
+
style: 'currency',
|
|
1835
|
+
currency,
|
|
1836
|
+
}).format(Number(amount || 0));
|
|
1837
|
+
}
|
|
1838
|
+
catch (_b) {
|
|
1839
|
+
return `${currency} ${Number(amount || 0).toFixed(2)}`;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
formatDateLabel(value, locale = 'en', fallback = '—') {
|
|
1843
|
+
if (!value) {
|
|
1844
|
+
return fallback;
|
|
1845
|
+
}
|
|
1846
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
1847
|
+
if (Number.isNaN(date.getTime())) {
|
|
1848
|
+
return fallback;
|
|
1849
|
+
}
|
|
1850
|
+
try {
|
|
1851
|
+
return new Intl.DateTimeFormat(locale || 'en', {
|
|
1852
|
+
dateStyle: 'medium',
|
|
1853
|
+
}).format(date);
|
|
1854
|
+
}
|
|
1855
|
+
catch (_a) {
|
|
1856
|
+
return date.toISOString().slice(0, 10);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
sanitizeDocumentHtml(value) {
|
|
1860
|
+
const html = this.normalizeOptionalText(value);
|
|
1861
|
+
if (!html) {
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
return html
|
|
1865
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
1866
|
+
.replace(/\son\w+=("[^"]*"|'[^']*')/gi, '')
|
|
1867
|
+
.replace(/javascript:/gi, '');
|
|
1868
|
+
}
|
|
1869
|
+
humanizeEnumLabel(value) {
|
|
1870
|
+
return String(value !== null && value !== void 0 ? value : '')
|
|
1871
|
+
.split('_')
|
|
1872
|
+
.filter(Boolean)
|
|
1873
|
+
.map((part) => { var _a; return ((_a = part[0]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) + part.slice(1); })
|
|
1874
|
+
.join(' ');
|
|
1875
|
+
}
|
|
1876
|
+
escapeHtml(value) {
|
|
1877
|
+
return String(value !== null && value !== void 0 ? value : '')
|
|
1878
|
+
.replace(/&/g, '&')
|
|
1879
|
+
.replace(/</g, '<')
|
|
1880
|
+
.replace(/>/g, '>')
|
|
1881
|
+
.replace(/"/g, '"')
|
|
1882
|
+
.replace(/'/g, ''');
|
|
1883
|
+
}
|
|
1884
|
+
async lockProposalForTransition(client, proposalId) {
|
|
1885
|
+
const rows = await client.$queryRawUnsafe(`SELECT id
|
|
1886
|
+
FROM proposal
|
|
1887
|
+
WHERE id = $1
|
|
1888
|
+
AND deleted_at IS NULL
|
|
1889
|
+
FOR UPDATE`, proposalId);
|
|
1890
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
return this.loadProposalDetail(client, proposalId);
|
|
1894
|
+
}
|
|
1895
|
+
assertProposalWorkflowReadiness(proposal, actionLabel) {
|
|
1896
|
+
var _a, _b;
|
|
1897
|
+
const currentRevision = this.getCurrentProposalRevision(proposal);
|
|
1898
|
+
if (!currentRevision) {
|
|
1899
|
+
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
1900
|
+
}
|
|
1901
|
+
const validUntil = (_b = (_a = proposal === null || proposal === void 0 ? void 0 : proposal.valid_until) !== null && _a !== void 0 ? _a : currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.valid_until) !== null && _b !== void 0 ? _b : null;
|
|
1902
|
+
if (validUntil) {
|
|
1903
|
+
const validUntilDate = new Date(validUntil);
|
|
1904
|
+
if (!Number.isNaN(validUntilDate.getTime()) &&
|
|
1905
|
+
validUntilDate.getTime() < Date.now()) {
|
|
1906
|
+
throw new common_1.BadRequestException(`Expired proposals cannot be ${actionLabel}.`);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
const revisionItems = Array.isArray(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.proposal_item)
|
|
1910
|
+
? currentRevision.proposal_item.filter((item) => item && item.deleted_at == null)
|
|
1911
|
+
: [];
|
|
1912
|
+
const hasPositiveTotal = Number((proposal === null || proposal === void 0 ? void 0 : proposal.total_amount_cents) || 0) > 0;
|
|
1913
|
+
if (revisionItems.length === 0 && !hasPositiveTotal) {
|
|
1914
|
+
throw new common_1.BadRequestException(`Proposals without items or value cannot be ${actionLabel}.`);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
assertCanEdit(status, locale) {
|
|
1918
|
+
if (status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
1919
|
+
status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
1920
|
+
throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.invalidStatus', locale, 'Approved proposals can no longer be edited'));
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
normalizeOptionalText(value) {
|
|
1924
|
+
const normalized = String(value || '').trim();
|
|
1925
|
+
return normalized.length > 0 ? normalized : null;
|
|
1926
|
+
}
|
|
1927
|
+
getPrimaryPersonContactValue(contacts, codes) {
|
|
1928
|
+
var _a, _b;
|
|
1929
|
+
const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
|
|
1930
|
+
const items = Array.isArray(contacts)
|
|
1931
|
+
? contacts.filter((contact) => {
|
|
1932
|
+
var _a;
|
|
1933
|
+
return normalizedCodes.has(String(((_a = contact === null || contact === void 0 ? void 0 : contact.contact_type) === null || _a === void 0 ? void 0 : _a.code) || '').toUpperCase());
|
|
1934
|
+
})
|
|
1935
|
+
: [];
|
|
1936
|
+
const primary = items.find((contact) => contact === null || contact === void 0 ? void 0 : contact.is_primary);
|
|
1937
|
+
const fallback = items[0];
|
|
1938
|
+
const value = (_b = (_a = primary === null || primary === void 0 ? void 0 : primary.value) !== null && _a !== void 0 ? _a : fallback === null || fallback === void 0 ? void 0 : fallback.value) !== null && _b !== void 0 ? _b : null;
|
|
1939
|
+
return value != null && String(value).trim().length > 0
|
|
1940
|
+
? String(value).trim()
|
|
1941
|
+
: null;
|
|
1942
|
+
}
|
|
1943
|
+
async attachPersonTradeNames(client, input) {
|
|
1944
|
+
var _a, _b;
|
|
1945
|
+
if (!input) {
|
|
1946
|
+
return input;
|
|
1947
|
+
}
|
|
1948
|
+
const proposals = Array.isArray(input) ? input : [input];
|
|
1949
|
+
const personIds = Array.from(new Set(proposals
|
|
1950
|
+
.map((proposal) => { var _a, _b, _c; return Number((_c = (_b = (_a = proposal === null || proposal === void 0 ? void 0 : proposal.person) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : proposal === null || proposal === void 0 ? void 0 : proposal.person_id) !== null && _c !== void 0 ? _c : 0); })
|
|
1951
|
+
.filter((id) => id > 0)));
|
|
1952
|
+
if (personIds.length === 0) {
|
|
1953
|
+
return input;
|
|
1954
|
+
}
|
|
1955
|
+
const [companyRows, contactRows, documentRows] = await Promise.all([
|
|
1956
|
+
client.person_company.findMany({
|
|
1957
|
+
where: {
|
|
1958
|
+
id: {
|
|
1959
|
+
in: personIds,
|
|
1960
|
+
},
|
|
1961
|
+
},
|
|
1962
|
+
select: {
|
|
1963
|
+
id: true,
|
|
1964
|
+
trade_name: true,
|
|
1965
|
+
},
|
|
1966
|
+
}),
|
|
1967
|
+
client.contact.findMany({
|
|
1968
|
+
where: {
|
|
1969
|
+
person_id: {
|
|
1970
|
+
in: personIds,
|
|
1971
|
+
},
|
|
1972
|
+
},
|
|
1973
|
+
include: {
|
|
1974
|
+
contact_type: {
|
|
1975
|
+
select: {
|
|
1976
|
+
code: true,
|
|
1977
|
+
},
|
|
1978
|
+
},
|
|
1979
|
+
},
|
|
1980
|
+
orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
|
|
1981
|
+
}),
|
|
1982
|
+
client.document.findMany({
|
|
1983
|
+
where: {
|
|
1984
|
+
person_id: {
|
|
1985
|
+
in: personIds,
|
|
1986
|
+
},
|
|
1987
|
+
},
|
|
1988
|
+
select: {
|
|
1989
|
+
id: true,
|
|
1990
|
+
person_id: true,
|
|
1991
|
+
value: true,
|
|
1992
|
+
},
|
|
1993
|
+
orderBy: [{ id: 'asc' }],
|
|
1994
|
+
}),
|
|
1995
|
+
]);
|
|
1996
|
+
const tradeNameByPersonId = new Map(companyRows.map((row) => {
|
|
1997
|
+
var _a;
|
|
1998
|
+
return [
|
|
1999
|
+
row.id,
|
|
2000
|
+
(_a = row.trade_name) !== null && _a !== void 0 ? _a : null,
|
|
2001
|
+
];
|
|
2002
|
+
}));
|
|
2003
|
+
const contactsByPersonId = new Map();
|
|
2004
|
+
for (const contact of contactRows) {
|
|
2005
|
+
const current = (_a = contactsByPersonId.get(Number(contact.person_id))) !== null && _a !== void 0 ? _a : [];
|
|
2006
|
+
current.push(contact);
|
|
2007
|
+
contactsByPersonId.set(Number(contact.person_id), current);
|
|
2008
|
+
}
|
|
2009
|
+
const documentsByPersonId = new Map();
|
|
2010
|
+
for (const document of documentRows) {
|
|
2011
|
+
const current = (_b = documentsByPersonId.get(Number(document.person_id))) !== null && _b !== void 0 ? _b : [];
|
|
2012
|
+
current.push(document);
|
|
2013
|
+
documentsByPersonId.set(Number(document.person_id), current);
|
|
2014
|
+
}
|
|
2015
|
+
const normalized = proposals.map((proposal) => {
|
|
2016
|
+
var _a, _b, _c, _d;
|
|
2017
|
+
if (!(proposal === null || proposal === void 0 ? void 0 : proposal.person)) {
|
|
2018
|
+
return proposal;
|
|
2019
|
+
}
|
|
2020
|
+
const personId = Number(proposal.person.id);
|
|
2021
|
+
const contacts = (_a = contactsByPersonId.get(personId)) !== null && _a !== void 0 ? _a : [];
|
|
2022
|
+
const documents = (_b = documentsByPersonId.get(personId)) !== null && _b !== void 0 ? _b : [];
|
|
2023
|
+
return Object.assign(Object.assign({}, proposal), { person: Object.assign(Object.assign({}, proposal.person), { trade_name: (_c = tradeNameByPersonId.get(personId)) !== null && _c !== void 0 ? _c : null, email: this.getPrimaryPersonContactValue(contacts, ['EMAIL']), phone: this.getPrimaryPersonContactValue(contacts, [
|
|
2024
|
+
'PHONE',
|
|
2025
|
+
'MOBILE',
|
|
2026
|
+
'WHATSAPP',
|
|
2027
|
+
]), document: ((_d = documents[0]) === null || _d === void 0 ? void 0 : _d.value) != null &&
|
|
2028
|
+
String(documents[0].value).trim().length > 0
|
|
2029
|
+
? String(documents[0].value).trim()
|
|
2030
|
+
: null }) });
|
|
2031
|
+
});
|
|
2032
|
+
return Array.isArray(input) ? normalized : normalized[0];
|
|
2033
|
+
}
|
|
2034
|
+
async loadProposalDetail(client, proposalId) {
|
|
2035
|
+
const proposal = await client.proposal.findFirst({
|
|
2036
|
+
where: {
|
|
2037
|
+
id: proposalId,
|
|
2038
|
+
deleted_at: null,
|
|
2039
|
+
},
|
|
2040
|
+
include: {
|
|
2041
|
+
person: {
|
|
2042
|
+
select: {
|
|
2043
|
+
id: true,
|
|
2044
|
+
name: true,
|
|
2045
|
+
},
|
|
2046
|
+
},
|
|
2047
|
+
proposal_revision: {
|
|
2048
|
+
where: {
|
|
2049
|
+
deleted_at: null,
|
|
2050
|
+
},
|
|
2051
|
+
include: {
|
|
2052
|
+
proposal_item: {
|
|
2053
|
+
where: { deleted_at: null },
|
|
2054
|
+
orderBy: { order: 'asc' },
|
|
2055
|
+
},
|
|
2056
|
+
proposal_document: {
|
|
2057
|
+
where: { deleted_at: null },
|
|
2058
|
+
orderBy: { id: 'desc' },
|
|
2059
|
+
},
|
|
2060
|
+
proposal_approval: {
|
|
2061
|
+
where: { deleted_at: null },
|
|
2062
|
+
orderBy: { step_order: 'asc' },
|
|
2063
|
+
},
|
|
2064
|
+
},
|
|
2065
|
+
orderBy: { revision_number: 'desc' },
|
|
2066
|
+
},
|
|
2067
|
+
proposal_document: {
|
|
2068
|
+
where: {
|
|
2069
|
+
deleted_at: null,
|
|
2070
|
+
},
|
|
2071
|
+
orderBy: {
|
|
2072
|
+
id: 'desc',
|
|
2073
|
+
},
|
|
2074
|
+
},
|
|
2075
|
+
proposal_approval: {
|
|
2076
|
+
where: {
|
|
2077
|
+
deleted_at: null,
|
|
2078
|
+
},
|
|
2079
|
+
orderBy: {
|
|
2080
|
+
step_order: 'asc',
|
|
2081
|
+
},
|
|
2082
|
+
},
|
|
2083
|
+
},
|
|
2084
|
+
});
|
|
2085
|
+
return this.attachPersonTradeNames(client, proposal);
|
|
2086
|
+
}
|
|
2087
|
+
async loadProposalIntegrationSnapshot(client, proposalId) {
|
|
2088
|
+
const proposal = await client.proposal.findFirst({
|
|
2089
|
+
where: {
|
|
2090
|
+
id: proposalId,
|
|
2091
|
+
deleted_at: null,
|
|
2092
|
+
},
|
|
2093
|
+
include: {
|
|
2094
|
+
person: {
|
|
2095
|
+
select: {
|
|
2096
|
+
id: true,
|
|
2097
|
+
name: true,
|
|
2098
|
+
type: true,
|
|
2099
|
+
},
|
|
2100
|
+
},
|
|
2101
|
+
proposal_revision: {
|
|
2102
|
+
where: {
|
|
2103
|
+
deleted_at: null,
|
|
2104
|
+
is_current: true,
|
|
2105
|
+
},
|
|
2106
|
+
include: {
|
|
2107
|
+
proposal_item: {
|
|
2108
|
+
where: { deleted_at: null },
|
|
2109
|
+
orderBy: { order: 'asc' },
|
|
2110
|
+
},
|
|
2111
|
+
proposal_document: {
|
|
2112
|
+
where: { deleted_at: null },
|
|
2113
|
+
orderBy: { id: 'desc' },
|
|
2114
|
+
},
|
|
2115
|
+
},
|
|
2116
|
+
orderBy: { revision_number: 'desc' },
|
|
2117
|
+
take: 1,
|
|
2118
|
+
},
|
|
2119
|
+
},
|
|
2120
|
+
});
|
|
2121
|
+
if (!proposal) {
|
|
2122
|
+
throw new common_1.NotFoundException('Proposal snapshot not found.');
|
|
2123
|
+
}
|
|
2124
|
+
return this.attachPersonTradeNames(client, proposal);
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
exports.ProposalService = ProposalService;
|
|
2128
|
+
exports.ProposalService = ProposalService = ProposalService_1 = __decorate([
|
|
2129
|
+
(0, common_1.Injectable)(),
|
|
2130
|
+
__param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.FileService))),
|
|
2131
|
+
__param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.SettingService))),
|
|
2132
|
+
__metadata("design:paramtypes", [api_prisma_1.PrismaService,
|
|
2133
|
+
core_1.IntegrationDeveloperApiService,
|
|
2134
|
+
core_1.FileService,
|
|
2135
|
+
core_1.SettingService])
|
|
2136
|
+
], ProposalService);
|
|
2137
|
+
//# sourceMappingURL=proposal.service.js.map
|