Files
dashback/src/services/manifestPdf.js
2026-10-14 20:50:07 +02:00

767 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import PDFDocument from 'pdfkit';
import { getStore } from '../db/store.js';
import * as dbOrder from '../db/order.js';
import { emit } from '../api/sse.js';
/**
* 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,
});
}
/**
* Configuration per manifest type.
*/
const TYPE_CONFIG = {
quote: {
docTitle: 'ANGEBOT',
numberPrefix: 'Angebots-Nr.',
showPrices: true,
filenamePrefix: 'angebot',
introText: 'Wir freuen uns über Ihr Interesse und bieten Ihnen freibleibend wie folgt an:',
closingText: 'Dieses Angebot ist freibleibend und 30 Tage ab Ausstellungsdatum gültig. Wir freuen uns auf Ihren Auftrag!',
defaultSubject: 'Angebot',
},
order: {
docTitle: 'AUFTRAG',
numberPrefix: 'Auftrags-Nr.',
showPrices: true,
filenamePrefix: 'auftrag',
introText: 'Wir bestätigen dankend die Annahme Ihres Auftrags zu folgenden Positionen:',
closingText: 'Vielen Dank für Ihren Auftrag!',
defaultSubject: 'Auftragsbestätigung',
},
shipment: {
docTitle: 'LIEFERSCHEIN',
numberPrefix: 'Lieferschein-Nr.',
showPrices: false,
filenamePrefix: 'lieferschein',
introText: 'Mit dieser Sendung liefern wir Ihnen folgende Artikel:',
closingText: 'Die Ware bleibt bis zur vollständigen Bezahlung unser Eigentum. Bitte prüfen Sie die Sendung sofort auf Vollständigkeit und eventuelle Transportschäden.',
defaultSubject: 'Lieferschein',
},
delivery: {
docTitle: 'WARENEINGANG',
numberPrefix: 'Beleg-Nr.',
showPrices: false,
filenamePrefix: 'wareneingang',
introText: 'Wareneingangsdokumentation zu folgenden Positionen:',
closingText: 'Wareneingang ordnungsgemäß im Lager erfasst.',
defaultSubject: 'Wareneingang',
},
};
/**
* Generates an A4 PDF document Buffer for any manifest document type.
*
* @param {object} params
* @param {'quote'|'order'|'shipment'|'delivery'} [params.type='order'] - Manifest document type.
* @param {object} params.manifest - Manifest document object (order, quote, shipment, or delivery).
* @param {object} [params.company] - Company details (name, etc.).
* @param {Array<object>} [params.settings] - Company settings list [{ key, value }].
* @param {object} [params.partner] - Entity (customer or supplier).
* @param {Array<object>} [params.addresses] - Entity addresses.
* @returns {Promise<Buffer>}
*/
export function generateManifestPdf({
type = 'order',
manifest,
company = {},
settings = [],
partner = null,
addresses = [],
}) {
const config = TYPE_CONFIG[type] || TYPE_CONFIG.order;
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 45,
bufferPages: true,
info: {
Title: `${config.docTitle} ${manifest.number || manifest.id}`,
Author: company.name || 'Dash',
Subject: `${config.defaultSubject} #${manifest.number || manifest.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 (for shipment prefer shipping address, for others prefer billing, fallback to first)
let chosenAddress = null;
if (Array.isArray(addresses) && addresses.length > 0) {
if (type === 'shipment') {
chosenAddress = addresses.find((a) => a.addressType === 'shipping') || addresses[0];
} else {
chosenAddress = addresses.find((a) => a.addressType === 'billing') || addresses[0];
}
}
const partnerName = partner
? [partner.firstName, partner.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 (Right)
doc.font('Helvetica-Bold')
.fontSize(20)
.fillColor(secondaryColor)
.text(config.docTitle, 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);
}
// Document Number under Document Title
doc.font('Helvetica-Bold')
.fontSize(11)
.fillColor(primaryColor)
.text(`${config.numberPrefix} ${manifest.number || manifest.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. Partner Address & Meta Box
// ----------------------------------------------------
const addressBlockTop = y;
// Small return-address header line (DIN 5008 style)
if (companyName || companyZipCity) {
doc.font('Helvetica')
.fontSize(7)
.fillColor(mutedColor)
.text(`${companyName} ${companyAddressLine ? '• ' + companyAddressLine : ''}`, 45, y);
y += 14;
}
// Partner Address
const partnerLabel = type === 'delivery' ? 'Lieferant' : (type === 'shipment' ? 'Lieferadresse' : 'Rechnungsadresse');
if (partnerName || chosenAddress) {
doc.font('Helvetica-Bold')
.fontSize(10)
.fillColor(textColor);
const displayName = chosenAddress?.name
? [chosenAddress.firstName, chosenAddress.name].filter(Boolean).join(' ')
: partnerName;
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 ${partnerLabel} hinterlegt`, 45, y);
y += 14;
}
// Right side Meta Box
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: 95 });
doc.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(textColor)
.text(val || '–', metaBoxX + 105, metaY, { width: 105, align: 'right' });
metaY += 15;
};
drawMetaRow(`${config.docTitle.slice(0, 1) + config.docTitle.slice(1).toLowerCase()}-Nr.:`, String(manifest.number || manifest.id));
const dateLabel = type === 'shipment' ? 'Versanddatum:' : (type === 'delivery' ? 'Eingangsdatum:' : 'Belegdatum:');
drawMetaRow(dateLabel, formatDate(manifest.date || manifest.createdAt));
if (manifest.externalNumber) {
drawMetaRow('Externe Referenz:', String(manifest.externalNumber));
}
if (partner?.number) {
const numLabel = type === 'delivery' ? 'Lieferantennr.:' : 'Kundennummer:';
drawMetaRow(numLabel, String(partner.number));
}
// Ensure y is past the address & meta blocks
y = Math.max(y + 15, metaBoxY + metaBoxHeight + 15);
// If shipment has package tracking info, show a compact card
if (type === 'shipment' && Array.isArray(manifest.shippings) && manifest.shippings.length > 0) {
const pkgBoxHeight = 16 + manifest.shippings.length * 13;
doc.roundedRect(45, y, 505, pkgBoxHeight, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(primaryColor).text('Pakete & Sendungsverfolgung:', 55, y + 6);
let pkgY = y + 20;
manifest.shippings.forEach((pkg, pIdx) => {
const pkgText = `Paket ${pIdx + 1}: ${pkg.carrier || 'Paketdienst'} ${pkg.number ? '#' + pkg.number : ''} ${pkg.trackingCode ? '• Tracking: ' + pkg.trackingCode : ''} (${pkg.state || 'gepackt'})`;
doc.font('Helvetica').fontSize(8).fillColor(textColor).text(pkgText, 55, pkgY, { width: 485, ellipsis: true });
pkgY += 13;
});
y += pkgBoxHeight + 15;
}
// Intro text
doc.font('Helvetica')
.fontSize(9)
.fillColor(textColor)
.text(config.introText, 45, y);
y += 18;
// ----------------------------------------------------
// 3. Line Items Table
// ----------------------------------------------------
const showPrices = config.showPrices;
// Column setups
const colXPrices = { pos: 45, sku: 75, name: 165, amount: 345, net: 405, tax: 465, total: 495 };
const colWidthPrices = { pos: 25, sku: 85, name: 175, amount: 55, net: 55, tax: 25, total: 55 };
const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 };
const colWidthNoPrices = { pos: 30, sku: 100, name: 265, amount: 95 };
const drawTableHeader = (atY) => {
doc.rect(45, atY, 505, 20)
.fillColor(primaryColor)
.fill();
doc.font('Helvetica-Bold')
.fontSize(8)
.fillColor('#ffffff');
if (showPrices) {
doc.text('Pos', colXPrices.pos + 3, atY + 6, { width: colWidthPrices.pos });
doc.text('Art.-Nr.', colXPrices.sku, atY + 6, { width: colWidthPrices.sku });
doc.text('Bezeichnung', colXPrices.name, atY + 6, { width: colWidthPrices.name });
doc.text('Menge', colXPrices.amount, atY + 6, { width: colWidthPrices.amount, align: 'right' });
doc.text('E-Preis Netto', colXPrices.net, atY + 6, { width: colWidthPrices.net, align: 'right' });
doc.text('MwSt', colXPrices.tax, atY + 6, { width: colWidthPrices.tax, align: 'right' });
doc.text('Gesamt Netto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' });
} else {
doc.text('Pos', colXNoPrices.pos + 3, atY + 6, { width: colWidthNoPrices.pos });
doc.text('Art.-Nr.', colXNoPrices.sku, atY + 6, { width: colWidthNoPrices.sku });
doc.text('Bezeichnung', colXNoPrices.name, atY + 6, { width: colWidthNoPrices.name });
doc.text('Liefermenge', colXNoPrices.amount, atY + 6, { width: colWidthNoPrices.amount, align: 'right' });
}
return atY + 22;
};
y = drawTableHeader(y);
const lines = Array.isArray(manifest.lines) ? manifest.lines : [];
let totalNet = 0;
let totalQty = 0;
const taxBreakdown = new Map();
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);
totalQty += amount;
let lineNet = 0;
let unitNetAfterRebate = net;
if (showPrices) {
unitNetAfterRebate = net * (1 - rebate / 100);
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 = showPrices && 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);
const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : '';
if (showPrices) {
// Pos
doc.text(String(index + 1), colXPrices.pos + 3, y, { width: colWidthPrices.pos });
// SKU
doc.text(line.sku || '–', colXPrices.sku, y, { width: colWidthPrices.sku, ellipsis: true });
// Name
doc.text((line.name || 'Position') + puSuffix, colXPrices.name, y, {
width: colWidthPrices.name,
ellipsis: true,
});
// Amount
doc.text(formatQuantity(amount), colXPrices.amount, y, {
width: colWidthPrices.amount,
align: 'right',
});
// Unit Price Net
doc.text(formatCurrency(net), colXPrices.net, y, {
width: colWidthPrices.net,
align: 'right',
});
// Tax Rate
doc.text(`${taxrate}%`, colXPrices.tax, y, {
width: colWidthPrices.tax,
align: 'right',
});
// Line Total Net
doc.font('Helvetica-Bold')
.text(formatCurrency(lineNet), colXPrices.total, y, {
width: colWidthPrices.total,
align: 'right',
});
let subY = y + 12;
if (hasRebate) {
doc.font('Helvetica')
.fontSize(7.5)
.fillColor(secondaryColor)
.text(`Rabatt: ${rebate}% (effektiver E-Preis: ${formatCurrency(unitNetAfterRebate)})`, colXPrices.name, subY, {
width: colWidthPrices.name,
});
subY += 10;
}
if (hasComment) {
doc.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(mutedColor)
.text(line.comment, colXPrices.name, subY, {
width: colWidthPrices.name,
});
}
} else {
// Without Prices
doc.text(String(index + 1), colXNoPrices.pos + 3, y, { width: colWidthNoPrices.pos });
doc.text(line.sku || '–', colXNoPrices.sku, y, { width: colWidthNoPrices.sku, ellipsis: true });
doc.text((line.name || 'Position') + puSuffix, colXNoPrices.name, y, {
width: colWidthNoPrices.name,
ellipsis: true,
});
doc.font('Helvetica-Bold')
.text(formatQuantity(amount) + ' Stk', colXNoPrices.amount, y, {
width: colWidthNoPrices.amount,
align: 'right',
});
if (hasComment) {
doc.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(mutedColor)
.text(line.comment, colXNoPrices.name, y + 12, {
width: colWidthNoPrices.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
// ----------------------------------------------------
if (showPrices) {
let totalTax = 0;
for (const item of taxBreakdown.values()) {
totalTax += item.tax;
}
const grandTotal = totalNet + totalTax;
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
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;
// Payments / open balance note (for orders)
if (manifest.paidTotal !== undefined && manifest.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(manifest.paidTotal), summaryX + 110, y, { width: 110, align: 'right' });
y += 14;
const openAmount = Math.max(0, grandTotal - Number(manifest.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;
}
}
} else {
// Non-price summary (total quantity and positions count)
if (y + 50 > 750) {
doc.addPage();
y = 50;
} else {
y += 15;
}
doc.roundedRect(45, y, 505, 30, 4)
.fillColor(lightBg)
.fill()
.strokeColor(lightBorder)
.lineWidth(0.5)
.stroke();
doc.font('Helvetica')
.fontSize(9)
.fillColor(mutedColor)
.text('Gesamtübersicht:', 55, y + 9);
doc.font('Helvetica-Bold')
.fontSize(9)
.fillColor(textColor)
.text(`${lines.length} Positionen • Gesamtmenge: ${formatQuantity(totalQty)} Stück`, 150, y + 9, {
width: 385,
align: 'right',
});
y += 45;
}
// Closing note
if (config.closingText) {
if (y + 40 > 750) {
doc.addPage();
y = 50;
}
doc.font('Helvetica')
.fontSize(8.5)
.fillColor(mutedColor)
.text(config.closingText, 45, y, { width: 505 });
}
// ----------------------------------------------------
// 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
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 Ihre Zusammenarbeit!', 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);
}
});
}
/**
* Creates and attaches a PDF to any manifest (quote, order, shipment, delivery)
* and emits SSE updates.
*
* @param {number} googleAuthId - Google auth user ID.
* @param {number} companyId - Active company ID.
* @param {number} manifestId - Manifest ID.
* @param {'quote'|'order'|'shipment'|'delivery'} type - Manifest type.
* @returns {Promise<{manifest: object, attachment: object, pdfBuffer: Buffer, filename: string}>}
*/
export async function createAndAttachManifestPdf(googleAuthId, companyId, manifestId, type = 'order') {
const store = await getStore();
let manifest;
if (type === 'quote') {
manifest = await store.quote.getById(googleAuthId, manifestId);
} else if (type === 'order') {
manifest = await dbOrder.getById(googleAuthId, manifestId);
} else if (type === 'shipment') {
manifest = await store.shipment.getById(googleAuthId, manifestId);
} else if (type === 'delivery') {
manifest = await store.delivery.getById(googleAuthId, manifestId);
} else {
throw new Error(`Unsupported manifest type: ${type}`);
}
if (!manifest) {
throw new Error('Manifest not found');
}
const partnerId = manifest.entityId ?? manifest.supplierId ?? null;
const [company, settings, partner, addresses] = await Promise.all([
store.company.getById(companyId).catch(() => null),
store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []),
partnerId ? store.entity.getById(googleAuthId, partnerId).catch(() => null) : null,
partnerId ? store.entityaddress.list(googleAuthId, partnerId).catch(() => []) : [],
]);
const pdfBuffer = await generateManifestPdf({
type,
manifest,
company: company || {},
settings: settings || [],
partner: partner || null,
addresses: addresses || [],
});
const config = TYPE_CONFIG[type] || TYPE_CONFIG.order;
const safeNumber = String(manifest.number || manifest.id).replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${config.filenamePrefix}-${safeNumber}.pdf`;
const attachment = await store.manifestattachment.add(googleAuthId, manifest.id, {
mimetype: 'application/pdf',
name: filename,
content: pdfBuffer,
meta: { type: `${type}_pdf` },
});
emit(type, { companyId });
return { manifest, attachment, pdfBuffer, filename };
}
// Re-export alias for order backwards compatibility
export { generateManifestPdf as generateOrderPdf };