From bee21d2c208b7b12019579ca46eb2acca89982d3 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 17 Oct 2026 19:51:34 +0200 Subject: [PATCH] u --- src/services/manifestPdf.js | 330 +++++++++++++++++++++++++++++++++++- 1 file changed, 327 insertions(+), 3 deletions(-) diff --git a/src/services/manifestPdf.js b/src/services/manifestPdf.js index 4f92d06..5c435b0 100644 --- a/src/services/manifestPdf.js +++ b/src/services/manifestPdf.js @@ -1,9 +1,74 @@ import PDFDocument from 'pdfkit'; +import { createCanvas } from 'canvas'; +import JsBarcode from 'jsbarcode'; +import sharp from 'sharp'; import { getStore } from '../db/store.js'; import * as dbOrder from '../db/order.js'; import { emit } from '../api/sse.js'; import { formatAttachment } from '../util/manifestAttachment.js'; +/** + * Generates a PNG barcode buffer for embedding in PDFDocument. + * + * @param {string} text + * @param {'CODE128'|'EAN13'|'EAN8'} [preferredFormat='CODE128'] + * @param {object} [options] + * @returns {Buffer|null} + */ +export function generateBarcodePng(text, preferredFormat = 'CODE128', options = {}) { + if (!text) return null; + const str = String(text).trim(); + if (!str) return null; + const width = options.width || 120; + const height = options.height || 36; + try { + const canvas = createCanvas(width, height); + JsBarcode(canvas, str, { + format: preferredFormat, + displayValue: options.displayValue ?? true, + fontSize: options.fontSize || 9, + textMargin: options.textMargin || 1, + margin: options.margin || 2, + }); + return canvas.toBuffer('image/png'); + } catch (e1) { + if (preferredFormat !== 'CODE128') { + try { + const canvas = createCanvas(width, height); + JsBarcode(canvas, str, { + format: 'CODE128', + displayValue: options.displayValue ?? true, + fontSize: options.fontSize || 9, + textMargin: options.textMargin || 1, + margin: options.margin || 2, + }); + return canvas.toBuffer('image/png'); + } catch (e2) { + return null; + } + } + return null; + } +} + +async function resolveThumbPng(store, googleAuthId, picHash, smallthumbbinary) { + if (smallthumbbinary) { + try { + return await sharp(smallthumbbinary).png().toBuffer(); + } catch (_) {} + } + if (picHash) { + try { + const thumb = await store.pic.getThumbByHash(googleAuthId, picHash).catch(() => null); + if (thumb?.buffer) { + return await sharp(thumb.buffer).png().toBuffer(); + } + } catch (_) {} + } + return null; +} + + /** * Formats a Date or date string to German format DD.MM.YYYY. * @param {Date|string} dateVal @@ -84,7 +149,7 @@ const TYPE_CONFIG = { showPrices: false, filenamePrefix: 'wareneingang', introText: 'Wareneingangsdokumentation zu folgenden Positionen:', - closingText: 'Wareneingang ordnungsgemäß im Lager erfasst.', + closingText: '', defaultSubject: 'Wareneingang', }, }; @@ -337,6 +402,8 @@ export function generateManifestPdf({ // ---------------------------------------------------- const showPrices = config.showPrices; + const isDelivery = type === 'delivery'; + // Column setups const colXPrices = { pos: 45, sku: 70, name: 150, amount: 335, gross: 385, tax: 450, total: 482 }; const colWidthPrices = { pos: 22, sku: 75, name: 180, amount: 45, gross: 60, tax: 30, total: 65 }; @@ -344,16 +411,42 @@ export function generateManifestPdf({ const colXNoPrices = { pos: 45, sku: 80, name: 185, amount: 455 }; const colWidthNoPrices = { pos: 30, sku: 100, name: 265, amount: 92 }; + // Delivery columns (Wareneingang) + const colDeliv = { + check: 53, checkW: 18, + pos: 71, posW: 18, + thumb: 89, thumbW: 30, + suppSku: 119, suppSkuW: 52, + ownSku: 171, ownSkuW: 92, + ean: 263, eanW: 78, + name: 341, nameW: 85, + deliv: 426, delivW: 38, + stock: 464, stockW: 38, + added: 502, addedW: 40, + }; + const drawTableHeader = (atY) => { doc.rect(45, atY, 505, 20) .fillColor(primaryColor) .fill(); doc.font('Helvetica-Bold') - .fontSize(8) + .fontSize(7.5) .fillColor('#ffffff'); - if (showPrices) { + if (isDelivery) { + + doc.text('Pos', colDeliv.pos, atY + 6, { width: colDeliv.posW, align: 'center' }); + doc.text('Bild', colDeliv.thumb, atY + 6, { width: colDeliv.thumbW, align: 'center' }); + doc.text('Lief.-SKU', colDeliv.suppSku, atY + 6, { width: colDeliv.suppSkuW }); + doc.text('Eigene SKU', colDeliv.ownSku, atY + 6, { width: colDeliv.ownSkuW }); + doc.text('EAN', colDeliv.ean, atY + 6, { width: colDeliv.eanW }); + doc.text('Bezeichnung', colDeliv.name, atY + 6, { width: colDeliv.nameW }); + doc.text('Geliefert', colDeliv.deliv, atY + 6, { width: colDeliv.delivW, align: 'right' }); + doc.text('Bestand', colDeliv.stock, atY + 6, { width: colDeliv.stockW, align: 'right' }); + doc.text('Gebucht', colDeliv.added, atY + 6, { width: colDeliv.addedW, align: 'right' }); + } else if (showPrices) { + doc.fontSize(8); 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 }); @@ -362,6 +455,7 @@ export function generateManifestPdf({ doc.text('MwSt', colXPrices.tax, atY + 6, { width: colWidthPrices.tax, align: 'right' }); doc.text('Gesamt Brutto', colXPrices.total, atY + 6, { width: colWidthPrices.total, align: 'right' }); } else { + doc.fontSize(8); 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 }); @@ -448,6 +542,113 @@ export function generateManifestPdf({ const textY = y + 1; + if (isDelivery) { + const rowHeight = 38; + if (y + rowHeight > 730) { + doc.addPage(); + y = drawTableHeader(45); + } + + if (index % 2 === 1) { + doc.rect(45, y - 2, 505, rowHeight) + .fillColor(lightBg) + .fill(); + } + + const centerY = y + 12; + + // 1. Checkbox outline (to cross with pencil) + doc.rect(colDeliv.check + 3, centerY - 1, 12, 12) + .strokeColor('#64748b') + .lineWidth(0.8) + .stroke(); + + // 2. Pos + doc.font('Helvetica') + .fontSize(8.5) + .fillColor(textColor) + .text(String(index + 1), colDeliv.pos, centerY, { width: colDeliv.posW, align: 'center' }); + + // 3. Thumbpic + if (line.thumbPng) { + try { + doc.image(line.thumbPng, colDeliv.thumb + 2, y + 4, { + fit: [26, 26], + align: 'center', + valign: 'center', + }); + } catch (_) { + doc.roundedRect(colDeliv.thumb + 2, y + 4, 26, 26, 2) + .fillColor('#f1f5f9').fill() + .strokeColor('#cbd5e1').lineWidth(0.5).stroke(); + } + } else { + doc.roundedRect(colDeliv.thumb + 3, y + 4, 26, 26, 2) + .fillColor('#f1f5f9').fill() + .strokeColor('#cbd5e1').lineWidth(0.5).stroke(); + doc.font('Helvetica').fontSize(6).fillColor(mutedColor).text('Kein Bild', colDeliv.thumb + 2, y + 14, { width: 26, align: 'center' }); + } + + // 4. Supplier SKU + doc.font('Helvetica').fontSize(7.5).fillColor(textColor) + .text(line.supplierSku || '–', colDeliv.suppSku, y + 12, { width: colDeliv.suppSkuW, ellipsis: true }); + + // 5. Eigene SKU as text + Code128 Barcode + const ownSkuText = line.ownSku || line.sku || '–'; + doc.font('Helvetica-Bold').fontSize(7).fillColor(primaryColor) + .text(ownSkuText, colDeliv.ownSku, y + 2, { width: colDeliv.ownSkuW, ellipsis: true }); + if (line.ownSkuBarcode) { + try { + doc.image(line.ownSkuBarcode, colDeliv.ownSku, y + 12, { width: 88, height: 20 }); + } catch (_) {} + } + + // 6. EAN as barcode + if (line.ean) { + doc.font('Helvetica').fontSize(6.5).fillColor(mutedColor) + .text(line.ean, colDeliv.ean, y + 2, { width: colDeliv.eanW, ellipsis: true }); + if (line.eanBarcode) { + try { + doc.image(line.eanBarcode, colDeliv.ean, y + 12, { width: 76, height: 20 }); + } catch (_) {} + } + } else { + doc.font('Helvetica').fontSize(8).fillColor(mutedColor) + .text('–', colDeliv.ean, y + 12, { width: colDeliv.eanW, align: 'center' }); + } + + // 7. Name + doc.font('Helvetica').fontSize(7.5).fillColor(textColor) + .text(nameText, colDeliv.name, y + 6, { width: colDeliv.nameW }); + + // 8. Menge geliefert + doc.font('Helvetica-Bold').fontSize(8).fillColor(textColor) + .text(formatQuantity(amount) + ' Stk', colDeliv.deliv, y + 12, { width: colDeliv.delivW, align: 'right' }); + + // 9. Menge already in warehouse + const stockText = line.trackStock ? (formatQuantity(line.currentStock ?? 0) + ' Stk') : '–'; + doc.font('Helvetica').fontSize(7.5).fillColor(line.trackStock ? textColor : mutedColor) + .text(stockText, colDeliv.stock, y + 12, { width: colDeliv.stockW, align: 'right' }); + + // 10. Menge added / gebucht + const hasBooked = line.trackStock && line.addedQuantity != null && line.addedQuantity > 0; + const bookedText = line.trackStock + ? (line.addedQuantity != null && line.addedQuantity > 0 ? `+${formatQuantity(line.addedQuantity)} Stk` : (line.addedQuantity === 0 ? '0 Stk' : '–')) + : '–'; + doc.font('Helvetica-Bold').fontSize(8).fillColor(hasBooked ? '#16a34a' : mutedColor) + .text(bookedText, colDeliv.added, y + 12, { width: colDeliv.addedW, align: 'right' }); + + // Row separator + doc.moveTo(45, y + rowHeight - 2) + .lineTo(550, y + rowHeight - 2) + .strokeColor(lightBorder) + .lineWidth(0.5) + .stroke(); + + y += rowHeight; + return; + } + if (showPrices) { doc.font('Helvetica') .fontSize(8.5) @@ -623,6 +824,33 @@ export function generateManifestPdf({ y += 16; } } + } else if (isDelivery) { + 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(); + + const totalBooked = lines.reduce((sum, l) => sum + Number(l.addedQuantity || 0), 0); + const summaryText = `${lines.length} Positionen • Geliefert: ${formatQuantity(totalQty)} Stk • Ins Lager gebucht: ${formatQuantity(totalBooked)} Stk`; + + doc.font('Helvetica-Bold') + .fontSize(9) + .fillColor(textColor) + .text(summaryText, 55, y + 9, { + width: 485, + align: 'right', + }); + + y += 45; } else { // Non-price summary (total quantity and positions count) if (y + 50 > 750) { @@ -747,6 +975,102 @@ export async function createAndAttachManifestPdf(googleAuthId, companyId, manife const partnerId = manifest.entityId ?? manifest.supplierId ?? null; + if (type === 'delivery' && Array.isArray(manifest.lines)) { + const isSqlite = store.provider?.dialect === 'sqlite3'; + + manifest.lines = await Promise.all( + manifest.lines.map(async (line) => { + const rawSku = line.sku ? String(line.sku).trim() : ''; + let ownSku = rawSku; + let supplierSku = ''; + let ean = ''; + let trackStock = false; + let currentStock = null; + let addedQuantity = null; + let thumbPng = null; + let articleId = null; + + if (rawSku) { + const findSql = isSqlite + ? `SELECT a.id, a.sku, a.track_stock, at.name, + (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id AND (asup.entity_id = @partnerId OR @partnerId IS NULL) LIMIT 1) AS supplier_sku, + (SELECT ae.ean FROM articleean ae WHERE ae.article_id = a.id ORDER BY ae.id ASC LIMIT 1) AS ean, + (SELECT p.hash FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC LIMIT 1) AS pic_hash, + (SELECT p.smallthumbbinary FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC LIMIT 1) AS smallthumbbinary + FROM article a + LEFT JOIN articletext at ON at.article_id = a.id + WHERE a.company_id = @companyId + AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku)) + LIMIT 1` + : `SELECT TOP 1 a.id, a.sku, a.track_stock, at.name, + (SELECT TOP 1 asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id AND (asup.entity_id = @partnerId OR @partnerId IS NULL)) AS supplier_sku, + (SELECT TOP 1 ae.ean FROM articleean ae WHERE ae.article_id = a.id ORDER BY ae.id ASC) AS ean, + (SELECT TOP 1 p.hash FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC) AS pic_hash, + (SELECT TOP 1 p.smallthumbbinary FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = a.id ORDER BY ap.sort ASC) AS smallthumbbinary + FROM article a + LEFT JOIN articletext at ON at.article_id = a.id + WHERE a.company_id = @companyId + AND (a.sku = @sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku))`; + + const artRes = await store.provider.query(findSql, { companyId, sku: rawSku, partnerId }).catch(() => ({ rows: [] })); + const art = artRes.rows?.[0]; + if (art) { + articleId = art.id; + ownSku = art.sku || rawSku; + supplierSku = art.supplier_sku || (rawSku !== art.sku ? rawSku : ''); + ean = art.ean || ''; + trackStock = Number(art.track_stock ?? 0) === 1; + + thumbPng = await resolveThumbPng(store, googleAuthId, art.pic_hash, art.smallthumbbinary); + + if (trackStock) { + const stockSql = `SELECT COALESCE(SUM(l.amount), 0) AS stock + FROM ledger l + WHERE l.warehouse_id IS NOT NULL + AND l.manifestarticle_id IN ( + SELECT ma.id FROM manifestarticle ma + INNER JOIN manifest m ON m.id = ma.manifest_id + WHERE m.company_id = @companyId + AND (ma.sku = @ownSku OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)) + )`; + const stockRes = await store.provider.query(stockSql, { companyId, ownSku, articleId }).catch(() => ({ rows: [] })); + currentStock = Number(stockRes.rows?.[0]?.stock ?? 0); + } + } + } + + if (line.id) { + const ledgerSql = `SELECT COALESCE(SUM(l.amount), 0) AS added_amount, + COUNT(CASE WHEN l.warehouse_id IS NOT NULL THEN 1 END) AS has_warehouse + FROM ledger l + WHERE l.manifestarticle_id = @lineId + AND l.transaction_type IN ('WAREHOUSE', 'ADJUSTMENT')`; + const ledgerRes = await store.provider.query(ledgerSql, { lineId: line.id }).catch(() => ({ rows: [] })); + const lRow = ledgerRes.rows?.[0]; + if (lRow && Number(lRow.has_warehouse || 0) > 0) { + addedQuantity = Number(lRow.added_amount || 0); + } + } + + const ownSkuBarcode = ownSku ? generateBarcodePng(ownSku, 'CODE128', { width: 110, height: 26, displayValue: false }) : null; + const eanBarcode = ean ? generateBarcodePng(ean, 'EAN13', { width: 100, height: 26, displayValue: false }) : null; + + return { + ...line, + ownSku, + supplierSku, + ean, + trackStock, + currentStock, + addedQuantity, + thumbPng, + ownSkuBarcode, + eanBarcode, + }; + }) + ); + } + const [company, settings, partner, addresses] = await Promise.all([ store.company.getById(companyId).catch(() => null), store.companysetting.list(googleAuthId, { frontendOnly: false, maskWriteonly: false }).catch(() => []),