This commit is contained in:
seb
2026-10-17 19:51:34 +02:00
parent 7197a6ddb7
commit bee21d2c20

View File

@@ -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(() => []),