u
This commit is contained in:
@@ -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(() => []),
|
||||
|
||||
Reference in New Issue
Block a user