diff --git a/src/api/openapi.js b/src/api/openapi.js index 6abbe74..7359276 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -1057,6 +1057,11 @@ * type: array * items: * $ref: '#/components/schemas/Shipment' + * files: + * type: array + * description: Attached documents (e.g. order PDF) for the order + * items: + * $ref: '#/components/schemas/DeliveryFile' * ArticleImage: * type: object * required: [picId, sort, full, thumb, hash, thumbHash] diff --git a/src/db/order.js b/src/db/order.js index 7c96269..1dd1549 100644 --- a/src/db/order.js +++ b/src/db/order.js @@ -46,16 +46,18 @@ function calculateShippedQuantity(shipments = []) { async function enrichSingleOrder(store, googleAuthId, order) { if (!order) return order; - const [shippings, payments, shipments] = await Promise.all([ + const [shippings, payments, shipments, files] = await Promise.all([ store.shipping.listByManifestId(googleAuthId, order.id).catch(() => []), store.payment.listByManifestId(googleAuthId, order.id).catch(() => []), store.shipment.listByOrderId(googleAuthId, order.id).catch(() => []), + store.manifestattachment.listByManifestId(googleAuthId, order.id).catch(() => []), ]); const orderLines = Array.isArray(order.lines) ? order.lines : []; const paymentsList = Array.isArray(payments) ? payments : []; const shipmentsList = Array.isArray(shipments) ? shipments : []; const shippingsList = Array.isArray(shippings) ? shippings : []; + const filesList = Array.isArray(files) ? files : []; const orderTotal = calculateOrderGrossTotal(orderLines); const paidTotal = calculatePaidTotal(paymentsList); @@ -74,6 +76,7 @@ async function enrichSingleOrder(store, googleAuthId, order) { payment: paymentsList[0] ?? null, payments: paymentsList, shipments: shipmentsList, + files: filesList, orderTotal, paidTotal, orderedQuantity, @@ -99,6 +102,7 @@ function formatEnrichedFromSummary(order) { payment: null, payments: [], shipments: [], + files: [], orderTotal: grossTotal, paidTotal, orderedQuantity, @@ -154,3 +158,19 @@ export async function remove(googleAuthId, id) { return store.order.delete(googleAuthId, id); } +export async function getFile(googleAuthId, orderId, fileId) { + const store = await getStore(); + return store.manifestattachment.getById(googleAuthId, orderId, fileId); +} + +export async function addAttachment(googleAuthId, orderId, data) { + const store = await getStore(); + return store.manifestattachment.add(googleAuthId, orderId, data); +} + +export async function listAttachments(googleAuthId, orderId) { + const store = await getStore(); + return store.manifestattachment.listByManifestId(googleAuthId, orderId); +} + + diff --git a/src/services/orderPdf.js b/src/services/orderPdf.js new file mode 100644 index 0000000..2af27d5 --- /dev/null +++ b/src/services/orderPdf.js @@ -0,0 +1,555 @@ +import PDFDocument from 'pdfkit'; + +/** + * Formats a Date or date string to German format DD.MM.YYYY. + * @param {Date|string} dateVal + * @returns {string} + */ +export function formatDate(dateVal) { + if (!dateVal) return ''; + const d = new Date(dateVal); + if (Number.isNaN(d.getTime())) return String(dateVal); + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + return `${day}.${month}.${year}`; +} + +/** + * Formats a number as German currency string (e.g. 1.234,50 €). + * @param {number} val + * @returns {string} + */ +export function formatCurrency(val) { + const n = Number(val) || 0; + return ( + n.toLocaleString('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ' €' + ); +} + +/** + * Formats a number with up to 3 decimal places (e.g. 1 or 2,5). + * @param {number} val + * @returns {string} + */ +export function formatQuantity(val) { + const n = Number(val) || 0; + return n.toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: 3, + }); +} + +/** + * Generates an A4 PDF document Buffer for an order. + * + * @param {object} params + * @param {object} params.order - Order object with lines, orderTotal, etc. + * @param {object} [params.company] - Company details (name, etc.). + * @param {Array} [params.settings] - Company settings list [{ key, value }]. + * @param {object} [params.customer] - Customer entity (firstName, name, number). + * @param {Array} [params.addresses] - Customer addresses. + * @returns {Promise} + */ +export function generateOrderPdf({ order, company = {}, settings = [], customer = null, addresses = [] }) { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: 'A4', + margin: 45, + bufferPages: true, + info: { + Title: `Auftrag ${order.number || order.id}`, + Author: company.name || 'Dash', + Subject: `Auftragsbestätigung #${order.number || order.id}`, + CreationDate: new Date(), + }, + }); + + const chunks = []; + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + // Palette + const primaryColor = '#0f172a'; // slate-900 + const secondaryColor = '#2563eb'; // blue-600 + const textColor = '#1e293b'; // slate-800 + const mutedColor = '#64748b'; // slate-500 + const lightBorder = '#e2e8f0'; // slate-200 + const lightBg = '#f8fafc'; // slate-50 + + // Helper to lookup settings + const settingsMap = new Map(); + if (Array.isArray(settings)) { + for (const s of settings) { + if (s?.key) settingsMap.set(s.key, s.value); + } + } + + const companyName = company?.name || settingsMap.get('company.name') || 'Firma'; + const companyStreet = settingsMap.get('company.street') || ''; + const companyZipCity = [settingsMap.get('company.zip'), settingsMap.get('company.city')].filter(Boolean).join(' '); + const companyEmail = settingsMap.get('company.email') || ''; + const companyPhone = settingsMap.get('company.phone') || ''; + const companyTaxId = settingsMap.get('company.taxId') || settingsMap.get('company.vatId') || ''; + + // Selected Address (prefer billing, then first) + let chosenAddress = null; + if (Array.isArray(addresses) && addresses.length > 0) { + chosenAddress = addresses.find((a) => a.addressType === 'billing') || addresses[0]; + } + + const customerName = customer + ? [customer.firstName, customer.name].filter(Boolean).join(' ') + : ''; + + // ---------------------------------------------------- + // 1. Header (Top bar) + // ---------------------------------------------------- + let y = 45; + + // Company Title (Left) + doc.font('Helvetica-Bold') + .fontSize(16) + .fillColor(primaryColor) + .text(companyName, 45, y); + + // Document Title & Number (Right) + doc.font('Helvetica-Bold') + .fontSize(20) + .fillColor(secondaryColor) + .text('AUFTRAG', 350, y, { align: 'right', width: 200 }); + + y += 24; + + // Company subline + const companyAddressLine = [companyStreet, companyZipCity].filter(Boolean).join(' • '); + if (companyAddressLine) { + doc.font('Helvetica') + .fontSize(8) + .fillColor(mutedColor) + .text(companyAddressLine, 45, y); + } + + // Order Number under Document Title + doc.font('Helvetica-Bold') + .fontSize(11) + .fillColor(primaryColor) + .text(`Auftrags-Nr. ${order.number || order.id}`, 350, y, { align: 'right', width: 200 }); + + y += 18; + + // Separator rule + doc.moveTo(45, y) + .lineTo(550, y) + .strokeColor(lightBorder) + .lineWidth(1) + .stroke(); + + y += 20; + + // ---------------------------------------------------- + // 2. Customer Address & Order Meta Box + // ---------------------------------------------------- + const addressBlockTop = y; + + // Small return-address header line (standard DIN 5008 style) + if (companyName || companyZipCity) { + doc.font('Helvetica') + .fontSize(7) + .fillColor(mutedColor) + .text(`${companyName} ${companyAddressLine ? '• ' + companyAddressLine : ''}`, 45, y); + y += 14; + } + + // Customer Address + if (customerName || chosenAddress) { + doc.font('Helvetica-Bold') + .fontSize(10) + .fillColor(textColor); + + const displayName = chosenAddress?.name + ? [chosenAddress.firstName, chosenAddress.name].filter(Boolean).join(' ') + : customerName; + + if (displayName) { + doc.text(displayName, 45, y); + y += 14; + } + + doc.font('Helvetica') + .fontSize(9) + .fillColor(textColor); + + if (chosenAddress?.street) { + const streetLine = [chosenAddress.street, chosenAddress.streetNumber].filter(Boolean).join(' '); + doc.text(streetLine, 45, y); + y += 13; + } + + if (chosenAddress?.zip || chosenAddress?.city) { + const zipCity = [chosenAddress.zip, chosenAddress.city].filter(Boolean).join(' '); + doc.text(zipCity, 45, y); + y += 13; + } + + if (chosenAddress?.county) { + doc.text(chosenAddress.county, 45, y); + y += 13; + } + } else { + doc.font('Helvetica') + .fontSize(9) + .fillColor(mutedColor) + .text('Keine Kundenadresse hinterlegt', 45, y); + y += 14; + } + + // Right side Meta Box (Cards) + const metaBoxX = 330; + const metaBoxWidth = 220; + const metaBoxY = addressBlockTop; + const metaBoxHeight = 85; + + doc.roundedRect(metaBoxX, metaBoxY, metaBoxWidth, metaBoxHeight, 4) + .fillColor(lightBg) + .fill() + .strokeColor(lightBorder) + .lineWidth(0.75) + .stroke(); + + let metaY = metaBoxY + 10; + const drawMetaRow = (label, val) => { + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(mutedColor) + .text(label, metaBoxX + 10, metaY, { width: 90 }); + doc.font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(textColor) + .text(val || '–', metaBoxX + 105, metaY, { width: 105, align: 'right' }); + metaY += 15; + }; + + drawMetaRow('Auftragsnummer:', String(order.number || order.id)); + drawMetaRow('Auftragsdatum:', formatDate(order.date || order.createdAt)); + if (order.externalNumber) { + drawMetaRow('Externe Referenz:', String(order.externalNumber)); + } + if (customer?.number) { + drawMetaRow('Kundennummer:', String(customer.number)); + } + + // Ensure y is past the address & meta blocks + y = Math.max(y + 15, metaBoxY + metaBoxHeight + 20); + + // Intro text + doc.font('Helvetica') + .fontSize(9) + .fillColor(textColor) + .text('Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:', 45, y); + + y += 20; + + // ---------------------------------------------------- + // 3. Line Items Table + // ---------------------------------------------------- + const colX = { + pos: 45, + sku: 75, + name: 165, + amount: 345, + net: 405, + tax: 465, + total: 495, + }; + + const colWidth = { + pos: 25, + sku: 85, + name: 175, + amount: 55, + net: 55, + tax: 25, + total: 55, + }; + + const drawTableHeader = (atY) => { + doc.rect(45, atY, 505, 20) + .fillColor(primaryColor) + .fill(); + + doc.font('Helvetica-Bold') + .fontSize(8) + .fillColor('#ffffff'); + + doc.text('Pos', colX.pos + 3, atY + 6, { width: colWidth.pos }); + doc.text('Art.-Nr.', colX.sku, atY + 6, { width: colWidth.sku }); + doc.text('Bezeichnung', colX.name, atY + 6, { width: colWidth.name }); + doc.text('Menge', colX.amount, atY + 6, { width: colWidth.amount, align: 'right' }); + doc.text('E-Preis Netto', colX.net, atY + 6, { width: colWidth.net, align: 'right' }); + doc.text('MwSt', colX.tax, atY + 6, { width: colWidth.tax, align: 'right' }); + doc.text('Gesamt Netto', colX.total, atY + 6, { width: colWidth.total, align: 'right' }); + + return atY + 22; + }; + + y = drawTableHeader(y); + + const lines = Array.isArray(order.lines) ? order.lines : []; + let totalNet = 0; + const taxBreakdown = new Map(); // rate -> { net, tax } + + lines.forEach((line, index) => { + const amount = Number(line.amount || 0); + const net = Number(line.net || 0); + const rebate = Number(line.rebate || 0); + const taxrate = Number(line.taxrate || 0); + + const unitNetAfterRebate = net * (1 - rebate / 100); + const lineNet = amount * unitNetAfterRebate; + const lineTax = lineNet * (taxrate / 100); + + totalNet += lineNet; + + const currTax = taxBreakdown.get(taxrate) || { net: 0, tax: 0 }; + currTax.net += lineNet; + currTax.tax += lineTax; + taxBreakdown.set(taxrate, currTax); + + // Row height estimation + const hasComment = Boolean(line.comment); + const hasRebate = rebate > 0; + let extraLines = 0; + if (hasComment) extraLines += 1; + if (hasRebate) extraLines += 1; + const rowHeight = 18 + extraLines * 11; + + // Check page overflow + if (y + rowHeight > 730) { + doc.addPage(); + y = drawTableHeader(45); + } + + // Zebra striping + if (index % 2 === 1) { + doc.rect(45, y - 2, 505, rowHeight) + .fillColor(lightBg) + .fill(); + } + + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(textColor); + + // Pos + doc.text(String(index + 1), colX.pos + 3, y, { width: colWidth.pos }); + + // SKU + doc.text(line.sku || '–', colX.sku, y, { width: colWidth.sku, ellipsis: true }); + + // Name + const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : ''; + doc.text((line.name || 'Position') + puSuffix, colX.name, y, { + width: colWidth.name, + ellipsis: true, + }); + + // Amount + doc.text(formatQuantity(amount), colX.amount, y, { + width: colWidth.amount, + align: 'right', + }); + + // Unit Price Net + doc.text(formatCurrency(net), colX.net, y, { + width: colWidth.net, + align: 'right', + }); + + // Tax Rate + doc.text(`${taxrate}%`, colX.tax, y, { + width: colWidth.tax, + align: 'right', + }); + + // Line Total Net + doc.font('Helvetica-Bold') + .text(formatCurrency(lineNet), colX.total, y, { + width: colWidth.total, + align: 'right', + }); + + let subY = y + 12; + + // Optional rebate notice + if (hasRebate) { + doc.font('Helvetica') + .fontSize(7.5) + .fillColor(secondaryColor) + .text(`Rabatt: ${rebate}% (effektiver E-Preis: ${formatCurrency(unitNetAfterRebate)})`, colX.name, subY, { + width: colWidth.name, + }); + subY += 10; + } + + // Optional comment + if (hasComment) { + doc.font('Helvetica-Oblique') + .fontSize(7.5) + .fillColor(mutedColor) + .text(line.comment, colX.name, subY, { + width: colWidth.name, + }); + } + + // Bottom row separator + doc.moveTo(45, y + rowHeight - 2) + .lineTo(550, y + rowHeight - 2) + .strokeColor(lightBorder) + .lineWidth(0.5) + .stroke(); + + y += rowHeight; + }); + + // ---------------------------------------------------- + // 4. Summary & Totals Block + // ---------------------------------------------------- + let totalTax = 0; + for (const item of taxBreakdown.values()) { + totalTax += item.tax; + } + const grandTotal = totalNet + totalTax; + + // Summary block fits ~90pt + if (y + 110 > 750) { + doc.addPage(); + y = 50; + } else { + y += 15; + } + + const summaryX = 330; + const summaryWidth = 220; + + // Subtotal Net + doc.font('Helvetica') + .fontSize(9) + .fillColor(mutedColor) + .text('Summe Netto:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold') + .fontSize(9) + .fillColor(textColor) + .text(formatCurrency(totalNet), summaryX + 110, y, { width: 110, align: 'right' }); + y += 15; + + // Tax Breakdown + const sortedRates = Array.from(taxBreakdown.entries()).sort((a, b) => a[0] - b[0]); + for (const [rate, val] of sortedRates) { + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(mutedColor) + .text(`zzgl. ${rate}% MwSt:`, summaryX, y, { width: 110 }); + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(textColor) + .text(formatCurrency(val.tax), summaryX + 110, y, { width: 110, align: 'right' }); + y += 14; + } + + // Divider before Grand Total + doc.moveTo(summaryX, y + 2) + .lineTo(summaryX + summaryWidth, y + 2) + .strokeColor(lightBorder) + .lineWidth(1) + .stroke(); + y += 8; + + // Grand Total (Brutto) + doc.font('Helvetica-Bold') + .fontSize(12) + .fillColor(primaryColor) + .text('Gesamtsumme:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold') + .fontSize(12) + .fillColor(secondaryColor) + .text(formatCurrency(grandTotal), summaryX + 110, y, { width: 110, align: 'right' }); + y += 24; + + // Payment / status note + if (order.paidTotal !== undefined && order.paidTotal > 0) { + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(mutedColor) + .text('Bereits bezahlt:', summaryX, y, { width: 110 }); + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(textColor) + .text(formatCurrency(order.paidTotal), summaryX + 110, y, { width: 110, align: 'right' }); + y += 14; + + const openAmount = Math.max(0, grandTotal - Number(order.paidTotal || 0)); + if (openAmount > 0.01) { + doc.font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(textColor) + .text('Offener Betrag:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(textColor) + .text(formatCurrency(openAmount), summaryX + 110, y, { width: 110, align: 'right' }); + y += 16; + } + } + + // ---------------------------------------------------- + // 5. Footers on all pages + // ---------------------------------------------------- + const range = doc.bufferedPageRange(); + for (let i = 0; i < range.count; i++) { + doc.switchToPage(i); + + // Footer rule + doc.moveTo(45, 785) + .lineTo(550, 785) + .strokeColor(lightBorder) + .lineWidth(0.5) + .stroke(); + + // Footer text left: Company info / Thank you + const footerInfo = [ + companyName, + companyTaxId ? `USt-IdNr.: ${companyTaxId}` : null, + companyEmail, + companyPhone, + ].filter(Boolean).join(' • '); + + doc.font('Helvetica') + .fontSize(7.5) + .fillColor(mutedColor) + .text(footerInfo || 'Vielen Dank für Ihren Auftrag!', 45, 793, { + width: 350, + ellipsis: true, + lineBreak: false, + }); + + // Footer text right: Page X of Y + doc.text( + `Seite ${i + 1} von ${range.count}`, + 450, + 793, + { width: 100, align: 'right', lineBreak: false } + ); + } + + doc.end(); + } catch (err) { + reject(err); + } + }); +}