'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 ? 'BeskrivelseTimerTimepris ekskl. momsLinjetotal ekskl. moms' : 'BeskrivelseAntalEnhedEnhedspris ekskl. momsLinjetotal ekskl. moms'; const body = items.map(line => labor ? `${escapeHtml(line.name)}${escapeHtml(line.totalHours)} timer${formatMoney(line.rate)}${formatMoney(line.lineTotal)}` : `${escapeHtml(line.name)}${escapeHtml(line.quantity)}${escapeHtml(line.unit)}${formatMoney(line.unitPrice)}${formatMoney(line.lineTotal)}`).join(''); return `

${sections[kind]}

${heading}${body}
`; }).join(''); const subtotalRows = [ ['Materialer', subtotals.materials], ['Arbejde', subtotals.labor], ['Leje', subtotals.rentals], ['Øvrige ydelser', subtotals.references], ['Linjer i alt', subtotals.lineTotal] ].map(([label, value]) => `${label}${formatMoney(value)}`).join(''); return `Tilbud – ${escapeHtml(project.project_name)}

Tilbud: ${escapeHtml(project.project_name)}

${escapeHtml(project.customer_name)}\n${escapeHtml(project.customer_address)}

${escapeHtml(quoteText)}

${rows}

Prisoversigt

${subtotalRows}
${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.length ? `

Forbehold

${reservations.map(escapeHtml).join('\n')}

` : ''}`; } } module.exports = { customerDocument, ordrestyringPayload, GenericQuotePdfService };