'use strict'; const PdfGenerationService = require('./pdfGenerationService'); const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage'); const { SCHEMA, GenericQuoteSnapshotService } = require('./genericQuoteSnapshotService'); const pick = (object, keys) => Object.fromEntries(keys.filter(key => object[key] !== undefined).map(key => [key, object[key]])); const error = (code, message) => { throw Object.assign(new Error(message), { status: 422, code }); }; function customerDocument(snapshot) { if (snapshot?.artifact?.schema !== SCHEMA || !snapshot.approved || snapshot.approval?.signature !== snapshot.signature) { error('GENERIC_QUOTE_APPROVAL_REQUIRED', 'Approved generic snapshot required'); } new GenericQuoteSnapshotService().assertExpectedSignature(snapshot, snapshot.signature); const artifact = snapshot.artifact; const lines = Object.fromEntries(Object.entries(artifact.lines).map(([kind, rows]) => [kind, rows.map(line => pick(line, kind === 'tasks' ? ['name', 'totalHours', 'rate', 'lineTotal'] : ['name', 'quantity', 'unit', 'unitPrice', 'lineTotal']))])); const categoryTotal = kind => Math.round((lines[kind] || []).reduce((sum, line) => sum + Number(line.lineTotal || 0), 0) * 100) / 100; const subtotals = { materials: categoryTotal('materials'), labor: categoryTotal('tasks'), rentals: categoryTotal('rentals'), references: categoryTotal('references') }; subtotals.lineTotal = Math.round(Object.values(subtotals).reduce((sum, value) => sum + value, 0) * 100) / 100; const totals = pick(artifact.economics, ['totalExclVat', 'vatPercentage', 'vatAmount', 'totalInclVat']); const customerPriceAdjustment = { label: 'Aftalt pristilpasning', explanation: 'Forskellen mellem de viste linjer og den aftalte samlede pris for projektets levering og udførelse.', amount: Math.round((totals.totalExclVat - subtotals.lineTotal) * 100) / 100 }; const document = { project: pick(artifact.customerProject, ['id', 'project_name', 'customer_number', 'customer_name', 'customer_address', 'customer_email']), quoteText: artifact.quoteText, reservations: artifact.reservations, lines, subtotals, customerPriceAdjustment, totals }; // Do not silently rewrite signed customer text or expose internal labels through it. const customerText = [document.quoteText, ...Object.values(document.project), ...document.reservations, ...Object.values(lines).flat().map(line => line.name)]; assertCustomerDocumentLanguage(customerText); return document; } function ordrestyringPayload(snapshot) { const document = customerDocument(snapshot); return { projectId: document.project.id, customerNumber: document.project.customer_number, description: document.quoteText, reservations: document.reservations, lines: Object.entries(document.lines).flatMap(([type, rows]) => rows.map(line => ({ type, ...line }))), ...document.totals, snapshotSignature: snapshot.signature, snapshotSchema: SCHEMA, approval: snapshot.approval }; } const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); const formatMoney = value => `${Number(value || 0).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} kr.`; class GenericQuotePdfService extends PdfGenerationService { async generatePdfHtml(document) { const { project, lines, totals, subtotals, customerPriceAdjustment, quoteText, reservations } = document; assertCustomerDocumentLanguage([ quoteText, ...Object.values(project || {}), ...(Array.isArray(reservations) ? reservations : []), ...Object.values(lines || {}).flat().map(line => line?.name) ]); const sections = { materials: 'Materialer', tasks: 'Arbejde', rentals: 'Leje', references: 'Øvrige ydelser' }; const rows = Object.entries(lines).map(([kind, items]) => { if (!items.length) return ''; const labor = kind === 'tasks'; const heading = labor ? '
${escapeHtml(project.customer_name)}\n${escapeHtml(project.customer_address)}
${escapeHtml(quoteText)}
${rows}| ${escapeHtml(customerPriceAdjustment.label)} | ${formatMoney(customerPriceAdjustment.amount)} |
|---|
${escapeHtml(customerPriceAdjustment.explanation)}
| Samlet pris ekskl. moms | ${formatMoney(totals.totalExclVat)} |
|---|---|
| Moms (${escapeHtml(totals.vatPercentage)}%) | ${formatMoney(totals.vatAmount)} |
| I alt inkl. moms | ${formatMoney(totals.totalInclVat)} |
${reservations.map(escapeHtml).join('\n')}
` : ''}`; } } module.exports = { customerDocument, ordrestyringPayload, GenericQuotePdfService };