This commit is contained in:
seb
2026-10-14 19:43:53 +02:00
parent 04e10cffdb
commit 9c54a8a1d3
3 changed files with 581 additions and 1 deletions

View File

@@ -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]

View File

@@ -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);
}

555
src/services/orderPdf.js Normal file
View File

@@ -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<object>} [params.settings] - Company settings list [{ key, value }].
* @param {object} [params.customer] - Customer entity (firstName, name, number).
* @param {Array<object>} [params.addresses] - Customer addresses.
* @returns {Promise<Buffer>}
*/
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);
}
});
}