diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index 5c3e9cc..883b531 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -1,12 +1,9 @@ import { LLMOrbiter, OpenRouterProvider } from 'libllmorbiter'; import * as db from '../db/modelconversation.js'; -import { getStore } from '../db/store.js'; import env from '../config/env.js'; import { emit as emitCompanyEvent } from '../api/sse.js'; -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { resolve, dirname, extname } from 'path'; -import { fileURLToPath } from 'url'; -import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; import { buildSystemPrompt } from './prompts.js'; import { createDashbackRequestTool } from '../tools/dashback-request.js'; import { createAskUserTool } from '../tools/ask-user.js'; @@ -31,109 +28,119 @@ import { createNetGrossCalcTool } from '../tools/net-gross-calc.js'; import { createSetUserRouteTool } from '../tools/set-user-route.js'; import { createDraftDeliveryManifestTool } from '../tools/draft-delivery-manifest.js'; import { createSubagentTool } from '../tools/subagent.js'; -import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js'; +import { + resolveOpenRouterKey, + resolveDefaultModel, + resolveModelReasoning, + RETIRED_MODELS, + hasConfiguredOpenRouterKey, +} from '../tools/llm-key.js'; -// #region Constants +// #region Constants & Precompiled Helpers const KEEP_ALIVE_MS = 20_000; -// #endregion +const ISO_NO_TZ_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/; +const HAS_TZ_RE = /[+-]\d{2}(:?\d{2})?$/; +const TIME_PREFIX_RE = /^\s*\[\d{2}:\d{2}[A-Za-z]+\]\s*/; +const DATA_MIME_RE = /^data:([^;,]+)/; +const PATH_PREFIX_RE = /^.*[\\/]/; +const UNSAFE_NAME_CHARS_RE = /[\0\r\n"]/g; +// Reuse a single Intl.DateTimeFormat instance instead of re-instantiating per message +const BERLIN_DATE_FORMATTER = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Berlin', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZoneName: 'short', +}); + +// #endregion // #region Message Helpers -/** - * Per-conversation manager. Owns the SSE subscribers of one conversation and - * runs the LLM (via LLMOrbiter) for each posted user message. - * - * Persistence: the database holds the transcript (`modelmessage`) and a binary - * `idle`/`busy` state. The finer SSE states (`thinking`, `working`, `done`) - * and the streamed `delta` chunks are SSE-only. - */ function parseUtcDate(val) { if (!val) return new Date(); if (val instanceof Date) return isNaN(val.getTime()) ? new Date() : val; if (typeof val === 'string') { const s = val.trim(); - if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(s) && !s.endsWith('Z') && !/[+-]\d{2}(:?\d{2})?$/.test(s)) { - const parsed = new Date(s.replace(' ', 'T') + 'Z'); - if (!isNaN(parsed.getTime())) return parsed; - } - const parsed = new Date(s); + const normalized = (ISO_NO_TZ_RE.test(s) && !s.endsWith('Z') && !HAS_TZ_RE.test(s)) + ? `${s.replace(' ', 'T')}Z` + : s; + const parsed = new Date(normalized); if (!isNaN(parsed.getTime())) return parsed; } return new Date(); } export function formatTimePrefix(createdDate) { - const d = parseUtcDate(createdDate); - const parts = new Intl.DateTimeFormat('en-GB', { - timeZone: 'Europe/Berlin', - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZoneName: 'short', - }).formatToParts(d); - const hour = parts.find((p) => p.type === 'hour')?.value.padStart(2, '0') || '00'; - const minute = parts.find((p) => p.type === 'minute')?.value.padStart(2, '0') || '00'; - let tz = parts.find((p) => p.type === 'timeZoneName')?.value || 'MEST'; - if (tz === 'CEST') tz = 'MEST'; - else if (tz === 'CET') tz = 'MET'; + const parts = BERLIN_DATE_FORMATTER.formatToParts(parseUtcDate(createdDate)); + let hour = '00'; + let minute = '00'; + let tz = 'MEST'; + + for (const p of parts) { + if (p.type === 'hour') hour = p.value.padStart(2, '0'); + else if (p.type === 'minute') minute = p.value.padStart(2, '0'); + else if (p.type === 'timeZoneName') { + tz = p.value === 'CEST' ? 'MEST' : p.value === 'CET' ? 'MET' : p.value; + } + } return `[${hour}:${minute}${tz}] `; } +function tryParseJson(str) { + if (typeof str !== 'string') return null; + const t = str.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { + try { + return JSON.parse(t); + } catch { + return null; + } + } + return null; +} + +function sanitizeAttachmentName(raw, fallback = 'attachment') { + if (!raw || typeof raw !== 'string') return fallback; + return raw.replace(PATH_PREFIX_RE, '').replace(UNSAFE_NAME_CHARS_RE, '').trim() || fallback; +} + +function formatAttachmentTag(name, vfsRelPath, mime) { + const q = (val) => { + const s = String(val); + return s.includes(' ') ? `"${s}"` : s; + }; + return `[ATTACHMENT name=${q(name)} path=${q(vfsRelPath)} type=${q(mime)}]`; +} + export function parseIncomingUserMessage({ message, attachments: directAttachments, content: directContent } = {}) { let text = ''; const attachments = Array.isArray(directAttachments) ? [...directAttachments] : []; - const tryParseJson = (str) => { - if (typeof str !== 'string') return null; - const trimmed = str.trim(); - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - try { - return JSON.parse(trimmed); - } catch { - return null; - } - } - return null; - }; - let candidate = message !== undefined ? message : directContent; - if (typeof candidate === 'string') { const parsed = tryParseJson(candidate); - if (parsed && typeof parsed === 'object') { - candidate = parsed; - } else { - text = candidate; - } + if (parsed && typeof parsed === 'object') candidate = parsed; + else text = candidate; } if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { - if (typeof candidate.content === 'string') { - const innerParsed = tryParseJson(candidate.content); - if (innerParsed && typeof innerParsed === 'object') { - if (typeof innerParsed.content === 'string') { - text = innerParsed.content; - } - if (Array.isArray(innerParsed.attachments)) { - attachments.push(...innerParsed.attachments); - } + const rawInner = typeof candidate.content === 'string' + ? candidate.content + : typeof candidate.message === 'string' + ? candidate.message + : null; + + if (rawInner !== null) { + const innerParsed = tryParseJson(rawInner); + if (innerParsed && typeof innerParsed === 'object' && !Array.isArray(innerParsed)) { + if (typeof innerParsed.content === 'string') text = innerParsed.content; + if (Array.isArray(innerParsed.attachments)) attachments.push(...innerParsed.attachments); } else { - text = candidate.content; - } - } else if (typeof candidate.message === 'string') { - const innerParsed = tryParseJson(candidate.message); - if (innerParsed && typeof innerParsed === 'object') { - if (typeof innerParsed.content === 'string') { - text = innerParsed.content; - } - if (Array.isArray(innerParsed.attachments)) { - attachments.push(...innerParsed.attachments); - } - } else { - text = candidate.message; + text = rawInner; } } @@ -146,7 +153,7 @@ export function parseIncomingUserMessage({ message, attachments: directAttachmen const seen = new Set(); for (const att of attachments) { if (!att || typeof att !== 'object') continue; - const key = att.id || `${att.name || att.filename}_${att.size || ''}`; + const key = att.id || `${att.name || att.filename}_${att.size ?? ''}`; if (!seen.has(key)) { seen.add(key); uniqueAttachments.push(att); @@ -161,23 +168,21 @@ export function parseIncomingUserMessage({ message, attachments: directAttachmen export function cleanUserMessageContent(rawContent) { if (!rawContent || typeof rawContent !== 'string') return ''; - if (rawContent.includes('"attachments"') && (rawContent.trim().startsWith('{') || rawContent.includes('{"content"'))) { + if (rawContent.includes('"attachments"') && (rawContent.trimStart().startsWith('{') || rawContent.includes('{"content"'))) { try { const parsed = parseIncomingUserMessage({ message: rawContent }); if (parsed.attachments.length > 0) { const tags = parsed.attachments.map((att) => { - const name = (att.name || att.filename || 'attachment').replace(/^.*[\\\/]/, '').replace(/[\0\r\n"]/g, '').trim() || 'attachment'; + const name = sanitizeAttachmentName(att.name || att.filename); const mime = att.mimeType || att.type || getMimeType(name); - const vfsRelPath = `attachments/${name}`; - const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val); - return `[ATTACHMENT name=${formatAttr(name)} path=${formatAttr(vfsRelPath)} type=${formatAttr(mime)}]`; + return formatAttachmentTag(name, `attachments/${name}`, mime); }); const prefix = tags.join(' '); return parsed.content ? `${prefix}\n\n${parsed.content}` : prefix; } if (parsed.content) return parsed.content; } catch { - // keep rawContent if parsing fails + // Fall through to rawContent } } return rawContent; @@ -187,54 +192,51 @@ export function historyToLlmMessages(history) { const messages = []; for (const m of history) { if (m.role === 'usage' || m.role === 'reasoning') continue; + let payload; - try { - payload = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; - } catch { - payload = { content: m.message }; + if (typeof m.message === 'string') { + try { + payload = JSON.parse(m.message); + } catch { + payload = { content: m.message }; + } + } else { + payload = m.message || {}; } + if (m.role === 'user') { const rawContent = payload.content ?? (typeof m.message === 'string' ? m.message : ''); - const cleanedContent = cleanUserMessageContent(rawContent); - const timePrefix = formatTimePrefix(m.created_at); - const content = /^\s*\[\d{2}:\d{2}[A-Za-z]+\]\s*/.test(cleanedContent) - ? cleanedContent - : `${timePrefix}${cleanedContent}`; + const cleaned = cleanUserMessageContent(rawContent); + const content = TIME_PREFIX_RE.test(cleaned) + ? cleaned + : `${formatTimePrefix(m.created_at)}${cleaned}`; messages.push({ role: 'user', content }); } else if (m.role === 'assistant') { - messages.push({ - role: 'assistant', - content: payload.content ?? null, - ...(Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? { tool_calls: payload.tool_calls } : {}), - }); + const msg = { role: 'assistant', content: payload.content ?? null }; + if (Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0) { + msg.tool_calls = payload.tool_calls; + } + messages.push(msg); } else if (m.role === 'tool') { const toolCallId = payload.tool_call_id ?? ''; const toolName = payload.name ?? ''; const toolParams = payload.parameters ?? payload.arguments ?? '{}'; + const callEntry = { + id: toolCallId, + type: 'function', + function: { + name: toolName, + arguments: typeof toolParams === 'string' ? toolParams : JSON.stringify(toolParams), + }, + }; + const prevMsg = messages[messages.length - 1]; if (!prevMsg || prevMsg.role !== 'assistant' || !prevMsg.tool_calls) { - messages.push({ - role: 'assistant', - content: null, - tool_calls: [{ - id: toolCallId, - type: 'function', - function: { - name: toolName, - arguments: typeof toolParams === 'string' ? toolParams : JSON.stringify(toolParams), - }, - }], - }); + messages.push({ role: 'assistant', content: null, tool_calls: [callEntry] }); } else if (!prevMsg.tool_calls.some((tc) => tc.id === toolCallId)) { - prevMsg.tool_calls.push({ - id: toolCallId, - type: 'function', - function: { - name: toolName, - arguments: typeof toolParams === 'string' ? toolParams : JSON.stringify(toolParams), - }, - }); + prevMsg.tool_calls.push(callEntry); } + messages.push({ role: 'tool', tool_call_id: toolCallId, @@ -259,8 +261,10 @@ export class ConversationManager { this.conversationId = conversationId; this.companyId = companyId; this.googleAuthId = googleAuthId; - /** @type {Set<{ res: import('express').Response, keepAlive: NodeJS.Timeout }>} */ + /** @type {Set} */ this.subscribers = new Set(); + /** @type {NodeJS.Timeout|null} */ + this.keepAliveTimer = null; /** @type {LLMOrbiter|null} */ this.orbiter = null; this.destroyed = false; @@ -269,27 +273,16 @@ export class ConversationManager { // #region Subscription & Broadcast - /** - * Broadcast an SSE event to all subscribers of this conversation. - * @param {string} event - * @param {object} data - */ broadcast(event, data) { - if (this.destroyed) return; + if (this.destroyed || this.subscribers.size === 0) return; const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - for (const client of this.subscribers) { - if (!client.res.writableEnded && !client.res.destroyed) { - client.res.write(payload); + for (const res of this.subscribers) { + if (!res.writableEnded && !res.destroyed) { + res.write(payload); } } } - /** - * Add an SSE subscriber for this conversation. - * @param {import('express').Response} res - * @param {import('express').Request} [req] - * @returns {() => void} unsubscribe - */ subscribe(res, req) { req?.socket?.setTimeout(0); res.setTimeout?.(0); @@ -303,22 +296,32 @@ export class ConversationManager { res.flushHeaders?.(); res.write(': ping\n\n'); - const client = { - res, - keepAlive: setInterval(() => { - if (res.writableEnded || res.destroyed) return; - res.write(': heartbeat\n\n'); - }, KEEP_ALIVE_MS), - }; - this.subscribers.add(client); + this.subscribers.add(res); + // Single shared heartbeat timer per conversation instead of one interval per connected client + if (!this.keepAliveTimer) { + this.keepAliveTimer = setInterval(() => { + for (const sub of this.subscribers) { + if (!sub.writableEnded && !sub.destroyed) { + sub.write(': heartbeat\n\n'); + } + } + }, KEEP_ALIVE_MS); + } + + let cleanedUp = false; const unsubscribe = () => { - clearInterval(client.keepAlive); - this.subscribers.delete(client); + if (cleanedUp) return; + cleanedUp = true; + this.subscribers.delete(res); + if (this.subscribers.size === 0 && this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } }; - res.on('close', unsubscribe); - req?.on('close', unsubscribe); + res.once('close', unsubscribe); + req?.once('close', unsubscribe); return unsubscribe; } @@ -326,14 +329,6 @@ export class ConversationManager { // #region Attachments - /** - * Saves attachments sent with a user message to the company's VFS - * under `attachments/` and registers them in `modelvfs`. - * - * @param {number} googleAuthId - * @param {Array} attachments - * @returns {Promise>} Array of `[ATTACHMENT name= path= type=]` tags - */ async saveAttachmentsToVfs(googleAuthId, attachments) { if (!Array.isArray(attachments) || attachments.length === 0) return []; @@ -345,27 +340,26 @@ export class ConversationManager { return []; } - const store = await getStore(); const attachmentsDir = resolve(vfsRoot, 'attachments'); try { - mkdirSync(attachmentsDir, { recursive: true }); + await mkdir(attachmentsDir, { recursive: true }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to create attachments directory:`, err); } const tags = []; const usedNames = new Set(); + const writePromises = []; for (const att of attachments) { if (!att || typeof att !== 'object') continue; - const rawName = (att.name || att.filename || `attachment-${Date.now()}`).replace(/^.*[\\\/]/, ''); - const cleanName = rawName.replace(/[\0\r\n"]/g, '').trim() || 'attachment'; - + const cleanName = sanitizeAttachmentName(att.name || att.filename, `attachment-${Date.now()}`); let finalName = cleanName; let counter = 1; const ext = extname(cleanName); const base = ext ? cleanName.slice(0, -ext.length) : cleanName; + while (usedNames.has(finalName.toLowerCase())) { counter++; finalName = `${base}_${counter}${ext}`; @@ -373,47 +367,42 @@ export class ConversationManager { usedNames.add(finalName.toLowerCase()); let fileBuffer = null; - if (att.dataUrl && typeof att.dataUrl === 'string') { + if (typeof att.dataUrl === 'string') { const commaIdx = att.dataUrl.indexOf(','); if (commaIdx !== -1) { const metaPart = att.dataUrl.slice(0, commaIdx); const dataPart = att.dataUrl.slice(commaIdx + 1); - if (metaPart.includes(';base64')) { - fileBuffer = Buffer.from(dataPart, 'base64'); - } else { - fileBuffer = Buffer.from(decodeURIComponent(dataPart), 'utf8'); - } + fileBuffer = metaPart.includes(';base64') + ? Buffer.from(dataPart, 'base64') + : Buffer.from(decodeURIComponent(dataPart), 'utf8'); } - } - if (!fileBuffer && att.textContent != null) { + } else if (att.textContent != null) { fileBuffer = Buffer.from(String(att.textContent), 'utf8'); - } - if (!fileBuffer && att.content != null) { + } else if (att.content != null) { fileBuffer = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content)); } let mime = att.mimeType || att.type || ''; - if (!mime && att.dataUrl && typeof att.dataUrl === 'string') { - const match = att.dataUrl.match(/^data:([^;,]+)/); - if (match) mime = match[1]; - } - if (!mime) { - mime = getMimeType(finalName); + if (!mime && typeof att.dataUrl === 'string') { + mime = att.dataUrl.match(DATA_MIME_RE)?.[1] || ''; } + if (!mime) mime = getMimeType(finalName); const vfsRelPath = `attachments/${finalName}`; - const diskPath = resolve(vfsRoot, vfsRelPath); - if (fileBuffer) { - try { - writeFileSync(diskPath, fileBuffer); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err); - } + const diskPath = resolve(vfsRoot, vfsRelPath); + writePromises.push( + writeFile(diskPath, fileBuffer).catch((err) => { + console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err); + }) + ); } - const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val); - tags.push(`[ATTACHMENT name=${formatAttr(finalName)} path=${formatAttr(vfsRelPath)} type=${formatAttr(mime)}]`); + tags.push(formatAttachmentTag(finalName, vfsRelPath, mime)); + } + + if (writePromises.length > 0) { + await Promise.all(writePromises); } return tags; @@ -423,32 +412,39 @@ export class ConversationManager { // #region Message Handlers - /** - * Handle an incoming user message. - * 1. Extracts text content and saves any attachments into the company VFS (`attachments/`). - * 2. Prefixes user message with `[ATTACHMENT name= path= type=]` tags. - * 3. Persists the user message (DB refuses unless `idle`). - * 4. Broadcasts it, marks the conversation `busy` (DB) and `thinking` (SSE). - * 5. Starts the LLM run in the background; the HTTP request returns immediately. - * - * @param {number} googleAuthId - * @param {{ message?: any, attachments?: Array, content?: any }} input - * @returns {Promise} The created user message - * @throws {Error} 'Conversation is not idle' while a run is active. - */ + async _enqueueAndRun(googleAuthId, role, payloadObj, initialSseState) { + let savedMsg; + try { + const payload = JSON.stringify(payloadObj); + savedMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role }); + await db.setState(googleAuthId, this.conversationId, 'busy'); + } catch (err) { + this.isProcessing = false; + throw err; + } + + this.broadcast('state', { state: initialSseState }); + emitCompanyEvent('modelconversation', { companyId: this.companyId }); + + this.runGeneration(googleAuthId).catch((err) => { + console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err); + }); + + return savedMsg; + } + async handleUserMessage(googleAuthId, { message, attachments, content }) { if (this.isProcessing) throw new Error('Conversation is not idle'); this.isProcessing = true; - let userMsg; + let finalMessage; try { const parsed = parseIncomingUserMessage({ message, attachments, content }); if (!parsed.content && parsed.attachments.length === 0) { throw new Error('Message required'); } - let finalMessage = parsed.content; - + finalMessage = parsed.content; if (parsed.attachments.length > 0) { const tags = await this.saveAttachmentsToVfs(googleAuthId, parsed.attachments); if (tags.length > 0) { @@ -456,110 +452,123 @@ export class ConversationManager { finalMessage = parsed.content ? `${prefix}\n\n${parsed.content}` : prefix; } } - - const payload = JSON.stringify({ content: finalMessage }); - userMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'user' }); - await db.setState(googleAuthId, this.conversationId, 'busy'); } catch (err) { this.isProcessing = false; throw err; } - this.broadcast('state', { state: 'thinking' }); - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - - this.runGeneration(googleAuthId).catch((err) => { - console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err); - }); - - return userMsg; + return this._enqueueAndRun(googleAuthId, 'user', { content: finalMessage }, 'thinking'); } - /** - * Handle an incoming tool response message (e.g. from the user answering ask_user). - * 1. Persists the tool response message (DB refuses unless `idle`). - * 2. Broadcasts it, marks the conversation `busy` (DB) and `working` (SSE). - * 3. Starts the LLM run in the background; the HTTP request returns immediately. - * - * @param {number} googleAuthId - * @param {{ tool_call_id: string, content?: any, answer?: any }} input - * @returns {Promise} The created tool message - * @throws {Error} 'Conversation is not idle' while a run is active. - */ async handleToolMessage(googleAuthId, { tool_call_id, content, answer, name }) { if (!tool_call_id) throw new Error('tool_call_id is required'); if (this.isProcessing) throw new Error('Conversation is not idle'); this.isProcessing = true; - let toolMsg; - try { - const rawContent = content !== undefined - ? (typeof content === 'string' ? content : JSON.stringify(content)) - : JSON.stringify({ answer: answer ?? '' }); - const payload = JSON.stringify({ - content: rawContent, - tool_call_id, - name: name || 'ask_user', - }); - toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' }); - await db.setState(googleAuthId, this.conversationId, 'busy'); + const rawContent = content !== undefined + ? (typeof content === 'string' ? content : JSON.stringify(content)) + : JSON.stringify({ answer: answer ?? '' }); - } catch (err) { - this.isProcessing = false; - throw err; - } - - this.broadcast('state', { state: 'working' }); - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - - this.runGeneration(googleAuthId).catch((err) => { - console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err); - }); - - return toolMsg; + return this._enqueueAndRun( + googleAuthId, + 'tool', + { content: rawContent, tool_call_id, name: name || 'ask_user' }, + 'working' + ); } // #endregion // #region Run - /** - * Run the LLM over the stored transcript, stream the answer over SSE, - * persist reasoning, tool calls, and assistant messages, then return to `idle`. - * @param {number} googleAuthId - */ + + async _persistMessageSafe(googleAuthId, role, payloadObj, emitEvent = false) { + try { + await db.addMessage(googleAuthId, this.conversationId, { + role, + message: JSON.stringify(payloadObj), + }); + if (emitEvent) { + emitCompanyEvent('modelconversation', { companyId: this.companyId }); + } + } catch (err) { + console.error(`[ConversationManager ${this.conversationId}] failed to save ${role} message:`, err); + } + } + + buildTools(googleAuthId, model) { + return [ + createDashbackRequestTool({ googleAuthId }), + createAskUserTool(), + createJavascriptExecuteTool({ googleAuthId }), + createFindFilesTool({ googleAuthId }), + createReadFileTool({ googleAuthId }), + createPatchTool({ googleAuthId }), + createRmTool({ googleAuthId }), + createSearchImagesTool({ googleAuthId }), + createAnalyzeImageTool({ googleAuthId, model }), + createEditImageTool({ googleAuthId, model }), + createWebSearchTool({ googleAuthId }), + createSearchWebPriceTool({ googleAuthId }), + createUrlGetTool({ googleAuthId }), + createPdfReadTool({ googleAuthId }), + createResetVfsTool({ googleAuthId }), + createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), + createSearchArticlesTool({ googleAuthId, companyId: this.companyId }), + createEanChecksumTool(), + createNetGrossCalcTool(), + createSetUserRouteTool({ + onRoute: (route) => this.broadcast('route', { route }), + }), + createDraftDeliveryManifestTool(), + createSubagentTool({ + googleAuthId, + companyId: this.companyId, + conversationId: this.conversationId, + parentModel: model, + depth: 0, + }), + ]; + } + async runGeneration(googleAuthId) { let error = null; try { - const conversation = await db.get(googleAuthId, this.conversationId); + // Parallelize all independent DB, config, and VFS lookups + const [ + conversation, + defaultModel, + hasCustomKey, + apiKey, + history, + reasoningEffort, + vfsRoot, + ] = await Promise.all([ + db.get(googleAuthId, this.conversationId), + resolveDefaultModel(googleAuthId), + hasConfiguredOpenRouterKey(googleAuthId), + resolveOpenRouterKey(googleAuthId), + db.listMessages(googleAuthId, this.conversationId), + resolveModelReasoning(googleAuthId), + resolveVfsRoot(googleAuthId).catch((err) => { + console.warn('conversation: could not resolve VFS root for skill index:', err.message); + return null; + }), + ]); + if (conversation.provider !== 'openrouter') { throw new Error(`Unsupported provider: ${conversation.provider}`); } - // Fall back to the configured default when the stored model is missing, blank, or - // retired. If the stored model was defaulted to the free model and the company now has - // a custom key configured, transition to the configured paid default model. - const defaultModel = await resolveDefaultModel(googleAuthId); - const hasCustomKey = await hasConfiguredOpenRouterKey(googleAuthId); + const storedModel = typeof conversation.model === 'string' ? conversation.model.trim() : ''; - const model = (!storedModel || RETIRED_MODELS.has(storedModel) || (storedModel === env.openrouterFreeModel && (hasCustomKey || defaultModel !== env.openrouterFreeModel))) - ? defaultModel - : storedModel; - const apiKey = await resolveOpenRouterKey(googleAuthId); - - // Transcript comes from the database (includes user, assistant, tool, reasoning). - const history = await db.listMessages(googleAuthId, this.conversationId); - - // Resolve the VFS root so the prompt can list the company's SKILL files. A failure - // here must not abort the turn — the prompt simply falls back to "no skills". - let vfsRoot = null; - try { - vfsRoot = await resolveVfsRoot(googleAuthId); - } catch (err) { - console.warn('conversation: could not resolve VFS root for skill index:', err.message); - } + const shouldFallback = !storedModel + || RETIRED_MODELS.has(storedModel) + || (storedModel === env.openrouterFreeModel && (hasCustomKey || defaultModel !== env.openrouterFreeModel)); + const model = shouldFallback ? defaultModel : storedModel; + const systemPrompt = await buildSystemPrompt(vfsRoot, googleAuthId); const messages = [ - { role: 'system', content: await buildSystemPrompt(vfsRoot, googleAuthId) }, + { role: 'system', content: systemPrompt }, ...historyToLlmMessages(history), ]; @@ -579,37 +588,29 @@ export class ConversationManager { this.broadcast('delta', { delta: chunk, text }); }, onAssistantMessage: async ({ content, tool_calls, reasoning_details, reasoning }) => { - const plain = (reasoning || currentReasoning.trim() || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary || '').trim(); + const plain = ( + reasoning + || currentReasoning.trim() + || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary + || '' + ).trim(); const encrypted = reasoning_details?.find((d) => d?.type === 'reasoning.encrypted')?.data || ''; currentReasoning = ''; if (plain || encrypted) { - try { - await db.addMessage(googleAuthId, this.conversationId, { - role: 'reasoning', - message: JSON.stringify({ plain, encrypted }), - }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to save reasoning message:`, err); - } + await this._persistMessageSafe(googleAuthId, 'reasoning', { plain, encrypted }); } const trimmedContent = (content || text).trim(); const hasToolCalls = Array.isArray(tool_calls) && tool_calls.length > 0; if (trimmedContent || hasToolCalls) { - try { - await db.addMessage(googleAuthId, this.conversationId, { - role: 'assistant', - message: JSON.stringify({ - content: trimmedContent, - ...(hasToolCalls ? { tool_calls } : {}), - }), - }); - text = ''; - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to save assistant message:`, err); - } + text = ''; + await this._persistMessageSafe( + googleAuthId, + 'assistant', + { content: trimmedContent, ...(hasToolCalls ? { tool_calls } : {}) }, + true + ); } }, onToolCall: async ({ name, args, id, callItem }) => { @@ -625,90 +626,67 @@ export class ConversationManager { }, onToolResult: async ({ name, result, id, callItem }) => { const rawArgs = callItem?.function?.arguments || ''; + const toolCallId = id || callItem?.id || ''; this.broadcast('state', { state: 'toolcall', - id: id || callItem?.id || '', + id: toolCallId, name, arguments: rawArgs, content: rawArgs, result, }); - try { - await db.addMessage(googleAuthId, this.conversationId, { - role: 'tool', - message: JSON.stringify({ - name, - parameters: rawArgs, - content: typeof result === 'string' ? result : JSON.stringify(result), - tool_call_id: id || callItem?.id || '', - }), - }); - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to save tool result message:`, err); - } + await this._persistMessageSafe( + googleAuthId, + 'tool', + { + name, + parameters: rawArgs, + content: typeof result === 'string' ? result : JSON.stringify(result), + tool_call_id: toolCallId, + }, + true + ); }, - onToolError: async ({ name, error, id, callItem }) => { - console.error(`[ConversationManager ${this.conversationId}] Tool "${name}" errored:`, error); + onToolError: async ({ name, error: toolErr, id, callItem }) => { + console.error(`[ConversationManager ${this.conversationId}] Tool "${name}" errored:`, toolErr); const rawArgs = callItem?.function?.arguments || ''; + const toolCallId = id || callItem?.id || ''; this.broadcast('state', { state: 'toolcall', - id: id || callItem?.id || '', + id: toolCallId, name, arguments: rawArgs, content: rawArgs, - error: String(error), + error: String(toolErr), }); - try { - await db.addMessage(googleAuthId, this.conversationId, { - role: 'tool', - message: JSON.stringify({ - name, - parameters: rawArgs, - content: `Tool error [${name}]: ${error}`, - tool_call_id: id || callItem?.id || '', - }), - }); - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to save tool error message:`, err); - } + await this._persistMessageSafe( + googleAuthId, + 'tool', + { + name, + parameters: rawArgs, + content: `Tool error [${name}]: ${toolErr}`, + tool_call_id: toolCallId, + }, + true + ); }, onUsage: async (u) => { - if (u) { - this.broadcast('usage', { - input: u.input ?? 0, - cached: u.cached ?? 0, - output: u.output ?? 0, - ...(u.cost != null ? { cost: u.cost } : {}), - }); - try { - await db.addMessage(googleAuthId, this.conversationId, { - role: 'usage', - message: JSON.stringify({ - input: u.input ?? 0, - cached: u.cached ?? 0, - output: u.output ?? 0, - ...(u.cost != null ? { cost: u.cost } : {}), - }), - }); - emitCompanyEvent('modelconversation', { companyId: this.companyId }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to save usage message:`, err); - } - } - // A call that consumed 0 input tokens AND produced 0 output means the - // provider returned nothing at all — not a real generation. LLMOrbiter - // treats "no content and no tool calls" as "keep looping", so several of - // these in a row burn iterations for nothing. Flag them loudly so the - // cause is visible instead of showing up as a silent weird loop. + if (!u) return; + const usagePayload = { + input: u.input ?? 0, + cached: u.cached ?? 0, + output: u.output ?? 0, + ...(u.cost != null ? { cost: u.cost } : {}), + }; + this.broadcast('usage', usagePayload); + await this._persistMessageSafe(googleAuthId, 'usage', usagePayload, true); + if (u.input === 0 && u.output === 0) { console.warn( `[conversation ${this.conversationId}] EMPTY LLM RESPONSE ` + `(iteration reported no usage at all). The provider returned no content ` + - `and no tool calls, so the orchestrator will retry. ` + - `If this repeats, the upstream model/API is failing — check the model id ` + - `and the key.` + `and no tool calls, so the orchestrator will retry.` ); } console.log( @@ -725,59 +703,15 @@ export class ConversationManager { }, }); - const reasoningEffort = await resolveModelReasoning(googleAuthId); - const extraBody = reasoningEffort - ? { reasoning: { effort: reasoningEffort } } - : {}; - const result = await this.orbiter.run({ messages, model, - extraBody, - tools: [ - createDashbackRequestTool({ googleAuthId }), - createAskUserTool(), - // VFS tools take no companyId on purpose — each resolves the user's - // currently selected company itself, so the on-disk VFS and the database - // can never end up pointed at different companies. - createJavascriptExecuteTool({ googleAuthId }), - createFindFilesTool({ googleAuthId }), - createReadFileTool({ googleAuthId }), - createPatchTool({ googleAuthId }), - createRmTool({ googleAuthId }), - createSearchImagesTool({ googleAuthId }), - createAnalyzeImageTool({ googleAuthId, model }), - createEditImageTool({ googleAuthId, model }), - createWebSearchTool({ googleAuthId }), - createSearchWebPriceTool({ googleAuthId }), - createUrlGetTool({ googleAuthId }), - createPdfReadTool({ googleAuthId }), - createResetVfsTool({ googleAuthId }), - createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), - createSearchArticlesTool({ googleAuthId, companyId: this.companyId }), - createEanChecksumTool(), - createNetGrossCalcTool(), - createSetUserRouteTool({ - onRoute: (route) => { - this.broadcast('route', { route }); - }, - }), - createDraftDeliveryManifestTool(), - createSubagentTool({ - googleAuthId, - companyId: this.companyId, - conversationId: this.conversationId, - parentModel: model, - depth: 0, - }), - ], + extraBody: reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}, + tools: this.buildTools(googleAuthId, model), }); if (this.destroyed) return; - // Log the shape of every run, including degenerate ones. When nothing comes back - // there is nothing persisted and the UI just sits there — this line is the only - // clue, so make it explicit about which field was empty. console.log( `[conversation ${this.conversationId}] run finished: ` + `iterations=${result?.iterations}, ` + @@ -786,27 +720,22 @@ export class ConversationManager { `directReturn=${result?.directReturn === true}` ); - // Direct return (e.g. from ask_user) halts without adding an additional assistant message. if (result?.directReturn) { - console.log( - `[conversation ${this.conversationId}] direct return halted loop: ` + - JSON.stringify(result.data) - ); + console.log(`[conversation ${this.conversationId}] direct return halted loop: ${JSON.stringify(result.data)}`); return; } - const content = (result.content ?? '').trim(); - const finalReasoningDetails = (Array.isArray(result.reasoning_details) && result.reasoning_details.length > 0) - ? result.reasoning_details - : undefined; - const finalReasoning = result.reasoning || currentReasoning.trim() || undefined; + const hasContent = Boolean(result.content?.trim()); + const hasReasoning = Boolean( + (Array.isArray(result.reasoning_details) && result.reasoning_details.length > 0) + || result.reasoning + || currentReasoning.trim() + ); - // Nothing came back at all: no content, no reasoning. Surface it to the user rather - // than silently returning to `idle` with an empty turn, which reads as a hang. - if (!content && !finalReasoningDetails && !finalReasoning) { + if (!hasContent && !hasReasoning) { console.error( `[conversation ${this.conversationId}] generation produced no content after ` + - `${result?.iterations ?? 0} iteration(s). The model returned neither text nor tool calls.` + `${result?.iterations ?? 0} iteration(s).` ); error = 'The model returned an empty response. Please try again.'; } @@ -835,10 +764,13 @@ export class ConversationManager { destroy() { this.destroyed = true; this.orbiter?.abort('Conversation manager closed'); - for (const client of this.subscribers) { - clearInterval(client.keepAlive); - if (!client.res.writableEnded && !client.res.destroyed) { - client.res.end(); + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + for (const res of this.subscribers) { + if (!res.writableEnded && !res.destroyed) { + res.end(); } } this.subscribers.clear(); @@ -854,12 +786,6 @@ export class ConversationManager { /** @type {Map} */ const activeManagers = new Map(); -/** - * Get or create an active ConversationManager for a conversation. - * @param {number} conversationId - * @param {{ companyId: number, googleAuthId: number }} meta - * @returns {ConversationManager} - */ export function getOrCreateManager(conversationId, { companyId, googleAuthId }) { let manager = activeManagers.get(conversationId); if (!manager) { @@ -872,19 +798,10 @@ export function getOrCreateManager(conversationId, { companyId, googleAuthId }) return manager; } -/** - * Get an active ConversationManager if one exists. - * @param {number} conversationId - * @returns {ConversationManager|undefined} - */ export function getActiveManager(conversationId) { return activeManagers.get(conversationId); } -/** - * Close, destroy, and remove an active ConversationManager if one exists. - * @param {number} conversationId - */ export function removeManager(conversationId) { const manager = activeManagers.get(conversationId); if (manager) { @@ -893,14 +810,11 @@ export function removeManager(conversationId) { } } -/** - * Close and destroy all active conversation managers. - */ export function closeAllManagers() { - for (const [id, manager] of activeManagers.entries()) { + for (const manager of activeManagers.values()) { manager.destroy(); - activeManagers.delete(id); } + activeManagers.clear(); } -// #endregion +// #endregion \ No newline at end of file diff --git a/src/services/manifestPdf.js b/src/services/manifestPdf.js index bf790d0..5c1a3a1 100644 --- a/src/services/manifestPdf.js +++ b/src/services/manifestPdf.js @@ -7,114 +7,27 @@ 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; - } -} +// 1. Pre-instantiate Intl formatters (much faster than repeated .toLocaleString) +const currencyFormatter = new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); -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; -} +const quantityFormatter = new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: 3, +}); +const PALETTE = { + primary: '#0f172a', // slate-900 + secondary: '#2563eb', // blue-600 + text: '#1e293b', // slate-800 + muted: '#64748b', // slate-500 + border: '#e2e8f0', // slate-200 + bg: '#f8fafc', // slate-50 + green: '#16a34a', +}; -/** - * 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', @@ -154,17 +67,99 @@ const TYPE_CONFIG = { }, }; +const COL_PRICES = { + x: { pos: 45, sku: 70, name: 150, amount: 335, gross: 385, tax: 450, total: 482 }, + w: { pos: 22, sku: 75, name: 180, amount: 45, gross: 60, tax: 30, total: 65 }, +}; + +const COL_NO_PRICES = { + x: { pos: 45, sku: 80, name: 185, amount: 455 }, + w: { pos: 30, sku: 100, name: 265, amount: 92 }, +}; + +const COL_DELIV = { + pos: 45, posW: 20, + thumb: 67, thumbW: 30, + suppSku: 99, suppSkuW: 52, + ownSku: 153, ownSkuW: 92, + ean: 247, eanW: 78, + name: 327, nameW: 81, + check: 410, checkW: 18, + deliv: 428, delivW: 40, + added: 468, addedW: 42, + stock: 510, stockW: 38, +}; + +/** + * Generates a PNG barcode buffer for embedding in PDFDocument. + * Reuses a single canvas instance on format fallback. + */ +export function generateBarcodePng(text, preferredFormat = 'CODE128', options = {}) { + const str = text ? String(text).trim() : ''; + if (!str) return null; + + const width = options.width || 120; + const height = options.height || 36; + const canvas = createCanvas(width, height); + const baseOpts = { + displayValue: options.displayValue ?? true, + fontSize: options.fontSize || 9, + textMargin: options.textMargin || 1, + margin: options.margin || 2, + }; + + try { + JsBarcode(canvas, str, { ...baseOpts, format: preferredFormat }); + return canvas.toBuffer('image/png'); + } catch { + if (preferredFormat !== 'CODE128') { + try { + JsBarcode(canvas, str, { ...baseOpts, format: 'CODE128' }); + return canvas.toBuffer('image/png'); + } catch { + return null; + } + } + return null; + } +} + +async function resolveThumbPng(store, googleAuthId, picHash, smallthumbbinary) { + if (smallthumbbinary) { + try { + return await sharp(smallthumbbinary).png().toBuffer(); + } catch { /* fallthrough */ } + } + if (picHash) { + try { + const thumb = await store.pic.getThumbByHash(googleAuthId, picHash); + if (thumb?.buffer) { + return await sharp(thumb.buffer).png().toBuffer(); + } + } catch { /* ignore */ } + } + return null; +} + +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'); + return `${day}.${month}.${d.getFullYear()}`; +} + +export function formatCurrency(val) { + return `${currencyFormatter.format(Number(val) || 0)} €`; +} + +export function formatQuantity(val) { + return quantityFormatter.format(Number(val) || 0); +} + /** * 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} [params.settings] - Company settings list [{ key, value }]. - * @param {object} [params.partner] - Entity (customer or supplier). - * @param {Array} [params.addresses] - Entity addresses. - * @returns {Promise} */ export function generateManifestPdf({ type = 'order', @@ -180,12 +175,7 @@ export function generateManifestPdf({ try { const doc = new PDFDocument({ size: 'A4', - margins: { - top: 45, - bottom: 25, - left: 45, - right: 45, - }, + margins: { top: 45, bottom: 25, left: 45, right: 45 }, bufferPages: true, info: { Title: `${config.docTitle} ${manifest.number || manifest.id}`, @@ -200,15 +190,6 @@ export function generateManifestPdf({ 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) { @@ -223,55 +204,35 @@ export function generateManifestPdf({ 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 targetAddrType = type === 'shipment' ? 'shipping' : 'billing'; + const chosenAddress = Array.isArray(addresses) && addresses.length > 0 + ? (addresses.find((a) => a.addressType === targetAddrType) || addresses[0]) + : null; - const partnerName = partner - ? [partner.firstName, partner.name].filter(Boolean).join(' ') - : ''; + const partnerName = partner ? [partner.firstName, partner.name].filter(Boolean).join(' ') : ''; // ---------------------------------------------------- // 1. Header (Top bar) // ---------------------------------------------------- let y = 45; - // Document Title (Right) doc.font('Helvetica-Bold') .fontSize(20) - .fillColor(secondaryColor) + .fillColor(PALETTE.secondary) .text(config.docTitle, 350, y, { align: 'right', width: 200 }); if (type === 'delivery') { - try { - doc.initForm(); - } catch (_) { } + try { doc.initForm(); } catch { /* ignore */ } } y += 24; - // Company subline const companyAddressLine = [companyStreet, companyZipCity].filter(Boolean).join(' • '); if (companyAddressLine) { - doc.font('Helvetica') - .fontSize(8) - .fillColor(mutedColor) - .text(companyAddressLine, 45, y); + doc.font('Helvetica').fontSize(8).fillColor(PALETTE.muted).text(companyAddressLine, 45, y); } - // Separator rule - doc.moveTo(45, y) - .lineTo(550, y) - .strokeColor(lightBorder) - .lineWidth(1) - .stroke(); - + doc.moveTo(45, y).lineTo(550, y).strokeColor(PALETTE.border).lineWidth(1).stroke(); y += 20; // ---------------------------------------------------- @@ -279,34 +240,25 @@ export function generateManifestPdf({ // ---------------------------------------------------- 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); + .fillColor(PALETTE.muted) + .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); + doc.font('Helvetica-Bold').fontSize(10).fillColor(PALETTE.text).text(displayName, 45, y); y += 14; } - doc.font('Helvetica') - .fontSize(9) - .fillColor(textColor); + doc.font('Helvetica').fontSize(9).fillColor(PALETTE.text); if (chosenAddress?.street) { const streetLine = [chosenAddress.street, chosenAddress.streetNumber].filter(Boolean).join(' '); @@ -333,34 +285,32 @@ export function generateManifestPdf({ const metaBoxHeight = 85; doc.roundedRect(metaBoxX, metaBoxY, metaBoxWidth, metaBoxHeight, 4) - .fillColor(lightBg) - .fill() - .strokeColor(lightBorder) - .lineWidth(0.75) - .stroke(); + .fillColor(PALETTE.bg).fill() + .strokeColor(PALETTE.border).lineWidth(0.75).stroke(); let metaY = metaBoxY + 10; const drawMetaRow = (label, val) => { - doc.font('Helvetica') - .fontSize(8.5) - .fillColor(mutedColor) + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.muted) .text(label, metaBoxX + 10, metaY, { width: 72, lineBreak: false }); const valText = val || '–'; doc.font('Helvetica-Bold'); - let valFontSize = 8.5; const maxValWidth = 128; - while (valFontSize > 6.5 && doc.fontSize(valFontSize).widthOfString(valText) > maxValWidth) { - valFontSize -= 0.5; - } - doc.fontSize(valFontSize) - .fillColor(textColor) + const baseWidth = doc.fontSize(8.5).widthOfString(valText); + const valFontSize = baseWidth > maxValWidth + ? Math.max(6.5, Math.floor((8.5 * (maxValWidth / baseWidth)) * 2) / 2) + : 8.5; + + doc.fontSize(valFontSize).fillColor(PALETTE.text) .text(valText, metaBoxX + 82, metaY, { width: maxValWidth, align: 'right', lineBreak: false }); metaY += 15; }; - const numLabel = config.numberPrefix ? (config.numberPrefix.endsWith(':') ? config.numberPrefix : `${config.numberPrefix}:`) : 'Beleg-Nr.:'; + const numLabel = config.numberPrefix + ? (config.numberPrefix.endsWith(':') ? config.numberPrefix : `${config.numberPrefix}:`) + : 'Beleg-Nr.:'; drawMetaRow(numLabel, String(manifest.number || manifest.id)); + const dateLabel = type === 'shipment' ? 'Versanddatum:' : (type === 'delivery' ? 'Eingangsdatum:' : 'Datum:'); drawMetaRow(dateLabel, formatDate(manifest.date || manifest.createdAt)); @@ -368,103 +318,71 @@ export function generateManifestPdf({ drawMetaRow('Externe Referenz:', String(manifest.externalNumber)); } if (partner?.number) { - const numLabel = type === 'delivery' ? 'Lieferantennr.:' : 'Kundennummer:'; - drawMetaRow(numLabel, String(partner.number)); + drawMetaRow(type === 'delivery' ? 'Lieferantennr.:' : 'Kundennummer:', 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(); + .fillColor(PALETTE.bg).fill() + .strokeColor(PALETTE.border).lineWidth(0.5).stroke(); - doc.font('Helvetica-Bold').fontSize(8.5).fillColor(primaryColor).text('Pakete & Sendungsverfolgung:', 55, y + 6); + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(PALETTE.primary).text('Pakete & Sendungsverfolgung:', 55, y + 6); let pkgY = y + 20; + doc.font('Helvetica').fontSize(8).fillColor(PALETTE.text); 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 }); + const pkgText = `Paket ${pIdx + 1}: ${pkg.carrier || 'Paketdienst'}${pkg.number ? ' #' + pkg.number : ''}${pkg.trackingCode ? ' • Tracking: ' + pkg.trackingCode : ''} (${pkg.state || 'gepackt'})`; + doc.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); - + if (config.introText) { + doc.font('Helvetica').fontSize(9).fillColor(PALETTE.text).text(config.introText, 45, y); + } y += 18; // ---------------------------------------------------- // 3. Line Items Table // ---------------------------------------------------- - const showPrices = config.showPrices; - + const { showPrices } = config; 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 }; - - 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 = { - pos: 45, posW: 20, - thumb: 67, thumbW: 30, - suppSku: 99, suppSkuW: 52, - ownSku: 153, ownSkuW: 92, - ean: 247, eanW: 78, - name: 327, nameW: 81, - check: 410, checkW: 18, - deliv: 428, delivW: 40, - added: 468, addedW: 42, - stock: 510, stockW: 38, - }; - const drawTableHeader = (atY) => { - doc.rect(45, atY, 505, 20) - .fillColor(primaryColor) - .fill(); - - doc.font('Helvetica-Bold') - .fontSize(7.5) - .fillColor('#ffffff'); + doc.rect(45, atY, 505, 20).fillColor(PALETTE.primary).fill(); + doc.font('Helvetica-Bold').fillColor('#ffffff'); 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('Gebucht', colDeliv.added, atY + 6, { width: colDeliv.addedW, align: 'right' }); - doc.text('Bestand', colDeliv.stock, atY + 6, { width: colDeliv.stockW, align: 'right' }); + doc.fontSize(7.5); + doc.text('Pos', COL_DELIV.pos, atY + 6, { width: COL_DELIV.posW, align: 'center' }); + doc.text('Bild', COL_DELIV.thumb, atY + 6, { width: COL_DELIV.thumbW, align: 'center' }); + doc.text('Lief.-SKU', COL_DELIV.suppSku, atY + 6, { width: COL_DELIV.suppSkuW }); + doc.text('Eigene SKU', COL_DELIV.ownSku, atY + 6, { width: COL_DELIV.ownSkuW }); + doc.text('EAN', COL_DELIV.ean, atY + 6, { width: COL_DELIV.eanW }); + doc.text('Bezeichnung', COL_DELIV.name, atY + 6, { width: COL_DELIV.nameW }); + doc.text('Geliefert', COL_DELIV.deliv, atY + 6, { width: COL_DELIV.delivW, align: 'right' }); + doc.text('Gebucht', COL_DELIV.added, atY + 6, { width: COL_DELIV.addedW, align: 'right' }); + doc.text('Bestand', COL_DELIV.stock, atY + 6, { width: COL_DELIV.stockW, align: 'right' }); } else if (showPrices) { + const { x, w } = COL_PRICES; 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 }); - doc.text('Menge', colXPrices.amount, atY + 6, { width: colWidthPrices.amount, align: 'right' }); - doc.text('E-Preis Brutto', colXPrices.gross, atY + 6, { width: colWidthPrices.gross, align: 'right' }); - 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' }); + doc.text('Pos', x.pos + 3, atY + 6, { width: w.pos }); + doc.text('Art.-Nr.', x.sku, atY + 6, { width: w.sku }); + doc.text('Bezeichnung', x.name, atY + 6, { width: w.name }); + doc.text('Menge', x.amount, atY + 6, { width: w.amount, align: 'right' }); + doc.text('E-Preis Brutto', x.gross, atY + 6, { width: w.gross, align: 'right' }); + doc.text('MwSt', x.tax, atY + 6, { width: w.tax, align: 'right' }); + doc.text('Gesamt Brutto', x.total, atY + 6, { width: w.total, align: 'right' }); } else { + const { x, w } = COL_NO_PRICES; 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 }); - doc.text('Liefermenge', colXNoPrices.amount, atY + 6, { width: colWidthNoPrices.amount, align: 'right' }); + doc.text('Pos', x.pos + 3, atY + 6, { width: w.pos }); + doc.text('Art.-Nr.', x.sku, atY + 6, { width: w.sku }); + doc.text('Bezeichnung', x.name, atY + 6, { width: w.name }); + doc.text('Liefermenge', x.amount, atY + 6, { width: w.amount, align: 'right' }); } return atY + 22; @@ -477,7 +395,8 @@ export function generateManifestPdf({ let totalQty = 0; const taxBreakdown = new Map(); - lines.forEach((line, index) => { + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; const amount = Number(line.amount || 0); const net = Number(line.net || 0); const rebate = Number(line.rebate || 0); @@ -486,32 +405,31 @@ export function generateManifestPdf({ totalQty += amount; - let lineNet = 0; let lineGross = 0; let unitGross = net * taxFactor; - let unitNetAfterRebate = net; let unitGrossAfterRebate = unitGross; + if (showPrices) { - unitNetAfterRebate = net * (1 - rebate / 100); + const unitNetAfterRebate = net * (1 - rebate / 100); unitGrossAfterRebate = unitNetAfterRebate * taxFactor; - lineNet = amount * unitNetAfterRebate; + const lineNet = amount * unitNetAfterRebate; const lineTax = lineNet * (taxrate / 100); lineGross = lineNet + lineTax; - totalNet += lineNet; - const currTax = taxBreakdown.get(taxrate) || { net: 0, tax: 0 }; + let currTax = taxBreakdown.get(taxrate); + if (!currTax) { + currTax = { net: 0, tax: 0 }; + taxBreakdown.set(taxrate, currTax); + } currTax.net += lineNet; currTax.tax += lineTax; - taxBreakdown.set(taxrate, currTax); } - const nameWidth = showPrices ? colWidthPrices.name : colWidthNoPrices.name; + const nameWidth = showPrices ? COL_PRICES.w.name : COL_NO_PRICES.w.name; const puSuffix = line.pu && line.pu > 1 ? ` (VPE ${line.pu})` : ''; const ownName = (line.ownArticleName || line.articleName || '').trim(); - const baseName = (isDelivery && line.ownSku && ownName) - ? ownName - : (line.name || 'Position'); + const baseName = (isDelivery && line.ownSku && ownName) ? ownName : (line.name || 'Position'); const nameText = baseName + puSuffix; if (isDelivery) { @@ -522,81 +440,56 @@ export function generateManifestPdf({ } if (index % 2 === 1) { - doc.rect(45, y - 2, 505, rowHeight) - .fillColor(lightBg) - .fill(); + doc.rect(45, y - 2, 505, rowHeight).fillColor(PALETTE.bg).fill(); } const centerY = y + 12; - // 1. Pos - doc.font('Helvetica') - .fontSize(8.5) - .fillColor(textColor) - .text(String(index + 1), colDeliv.pos, centerY, { width: colDeliv.posW, align: 'center' }); + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.text) + .text(String(index + 1), COL_DELIV.pos, centerY, { width: COL_DELIV.posW, align: 'center' }); - // 2. 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(); + doc.image(line.thumbPng, COL_DELIV.thumb + 2, y + 4, { fit: [26, 26], align: 'center', valign: 'center' }); + } catch { + doc.roundedRect(COL_DELIV.thumb + 2, y + 4, 26, 26, 2) + .fillColor('#f1f5f9').fill().strokeColor('#cbd5e1').lineWidth(0.5).stroke(); } } else { - doc.roundedRect(colDeliv.thumb + 2, 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' }); + doc.roundedRect(COL_DELIV.thumb + 2, y + 4, 26, 26, 2) + .fillColor('#f1f5f9').fill().strokeColor('#cbd5e1').lineWidth(0.5).stroke(); + doc.font('Helvetica').fontSize(6).fillColor(PALETTE.muted) + .text('Kein Bild', COL_DELIV.thumb + 2, y + 14, { width: 26, align: 'center' }); } - // 3. Supplier SKU - doc.font('Helvetica').fontSize(7.5).fillColor(textColor) - .text(line.supplierSku || '–', colDeliv.suppSku, y + 12, { width: colDeliv.suppSkuW, ellipsis: true }); + doc.font('Helvetica').fontSize(7.5).fillColor(PALETTE.text) + .text(line.supplierSku || '–', COL_DELIV.suppSku, centerY, { width: COL_DELIV.suppSkuW, ellipsis: true }); - // 4. Eigene SKU as text + Code128 Barcode - const ownSkuText = line.ownSku || '–'; - doc.font('Helvetica-Bold').fontSize(7).fillColor(primaryColor) - .text(ownSkuText, colDeliv.ownSku, y + 2, { width: colDeliv.ownSkuW, ellipsis: true }); + doc.font('Helvetica-Bold').fontSize(7).fillColor(PALETTE.primary) + .text(line.ownSku || '–', COL_DELIV.ownSku, y + 2, { width: COL_DELIV.ownSkuW, ellipsis: true }); if (line.ownSkuBarcode) { - try { - doc.image(line.ownSkuBarcode, colDeliv.ownSku, y + 12, { width: 88, height: 20 }); - } catch (_) { } + try { doc.image(line.ownSkuBarcode, COL_DELIV.ownSku, centerY, { width: 88, height: 20 }); } catch { /* ignore */ } } - // 5. 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 }); + doc.font('Helvetica').fontSize(6.5).fillColor(PALETTE.muted) + .text(line.ean, COL_DELIV.ean, y + 2, { width: COL_DELIV.eanW, ellipsis: true }); if (line.eanBarcode) { - try { - doc.image(line.eanBarcode, colDeliv.ean, y + 12, { width: 76, height: 20 }); - } catch (_) { } + try { doc.image(line.eanBarcode, COL_DELIV.ean, centerY, { width: 76, height: 20 }); } catch { /* ignore */ } } } else { - doc.font('Helvetica').fontSize(8).fillColor(mutedColor) - .text('–', colDeliv.ean, y + 12, { width: colDeliv.eanW, align: 'center' }); + doc.font('Helvetica').fontSize(8).fillColor(PALETTE.muted) + .text('–', COL_DELIV.ean, centerY, { width: COL_DELIV.eanW, align: 'center' }); } - // 6. Name (own article name if own SKU is connected) - doc.font('Helvetica').fontSize(7.5).fillColor(textColor) - .text(nameText, colDeliv.name, y + 6, { width: colDeliv.nameW }); - - // 7. Checkbox (interactive PDF form field, checkable in PDF Reader) - doc.rect(colDeliv.check + 3, centerY - 1, 12, 12) - .strokeColor('#64748b') - .lineWidth(0.8) - .stroke(); + doc.font('Helvetica').fontSize(7.5).fillColor(PALETTE.text) + .text(nameText, COL_DELIV.name, y + 6, { width: COL_DELIV.nameW }); + doc.rect(COL_DELIV.check + 3, centerY - 1, 12, 12).strokeColor(PALETTE.muted).lineWidth(0.8).stroke(); try { const checkFieldName = `check_${line.id ? line.id : index + 1}`; - doc.formCheckbox(checkFieldName, colDeliv.check + 3, centerY - 1, 12, 12, { - borderColor: '#64748b', + doc.formCheckbox(checkFieldName, COL_DELIV.check + 3, centerY - 1, 12, 12, { + borderColor: PALETTE.muted, backgroundColor: '#ffffff', }); const lastAnnot = doc.page.annotations[doc.page.annotations.length - 1]; @@ -604,37 +497,30 @@ export function generateManifestPdf({ lastAnnot.data.AS = 'Off'; lastAnnot.data.V = 'Off'; } - } catch (_) { } + } catch { /* ignore */ } - // 8. Menge geliefert - doc.font('Helvetica-Bold').fontSize(8).fillColor(textColor) - .text(formatQuantity(amount) + ' Stk', colDeliv.deliv, y + 12, { width: colDeliv.delivW, align: 'right' }); + doc.font('Helvetica-Bold').fontSize(8).fillColor(PALETTE.text) + .text(`${formatQuantity(amount)} Stk`, COL_DELIV.deliv, centerY, { width: COL_DELIV.delivW, align: 'right' }); - // 9. Menge added / gebucht (swapped before bestand) 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' : '–')) + ? (hasBooked ? `+${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' }); + doc.font('Helvetica-Bold').fontSize(8).fillColor(hasBooked ? PALETTE.green : PALETTE.muted) + .text(bookedText, COL_DELIV.added, centerY, { width: COL_DELIV.addedW, align: 'right' }); - // 10. Menge already in warehouse (bestand) - 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' }); + const stockText = line.trackStock ? `${formatQuantity(line.currentStock ?? 0)} Stk` : '–'; + doc.font('Helvetica').fontSize(7.5).fillColor(line.trackStock ? PALETTE.text : PALETTE.muted) + .text(stockText, COL_DELIV.stock, centerY, { width: COL_DELIV.stockW, align: 'right' }); - // Row separator - doc.moveTo(45, y + rowHeight - 2) - .lineTo(550, y + rowHeight - 2) - .strokeColor(lightBorder) - .lineWidth(0.5) - .stroke(); + doc.moveTo(45, y + rowHeight - 2).lineTo(550, y + rowHeight - 2) + .strokeColor(PALETTE.border).lineWidth(0.5).stroke(); y += rowHeight; - return; + continue; } - // Measure actual rendered text heights to prevent overlaps + // Standard / Price Row Measurement doc.font('Helvetica').fontSize(8.5); const nameHeight = doc.heightOfString(nameText, { width: nameWidth }); @@ -653,118 +539,66 @@ export function generateManifestPdf({ commentHeight = doc.heightOfString(line.comment, { width: nameWidth }) + 2; } - const contentHeight = nameHeight + rebateHeight + commentHeight; - const rowHeight = Math.max(18, Math.ceil(contentHeight + 6)); + const rowHeight = Math.max(18, Math.ceil(nameHeight + rebateHeight + commentHeight + 6)); - // 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.rect(45, y - 2, 505, rowHeight).fillColor(PALETTE.bg).fill(); } const textY = y + 1; if (showPrices) { - doc.font('Helvetica') - .fontSize(8.5) - .fillColor(textColor); - - // Pos - doc.text(String(index + 1), colXPrices.pos + 3, textY, { width: colWidthPrices.pos }); - // SKU - doc.text(line.sku || '–', colXPrices.sku, textY, { width: colWidthPrices.sku, ellipsis: true }); - // Name (wraps naturally without clipping) - doc.text(nameText, colXPrices.name, textY, { width: nameWidth }); - // Amount - doc.text(formatQuantity(amount), colXPrices.amount, textY, { - width: colWidthPrices.amount, - align: 'right', - }); - // Unit Price Gross - doc.text(formatCurrency(unitGross), colXPrices.gross, textY, { - width: colWidthPrices.gross, - align: 'right', - }); - // Tax Rate - doc.text(`${taxrate}%`, colXPrices.tax, textY, { - width: colWidthPrices.tax, - align: 'right', - }); - // Line Total Gross - doc.font('Helvetica-Bold') - .text(formatCurrency(lineGross), colXPrices.total, textY, { - width: colWidthPrices.total, - align: 'right', - }); + const { x, w } = COL_PRICES; + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.text); + doc.text(String(index + 1), x.pos + 3, textY, { width: w.pos }); + doc.text(line.sku || '–', x.sku, textY, { width: w.sku, ellipsis: true }); + doc.text(nameText, x.name, textY, { width: nameWidth }); + doc.text(formatQuantity(amount), x.amount, textY, { width: w.amount, align: 'right' }); + doc.text(formatCurrency(unitGross), x.gross, textY, { width: w.gross, align: 'right' }); + doc.text(`${taxrate}%`, x.tax, textY, { width: w.tax, align: 'right' }); + doc.font('Helvetica-Bold').text(formatCurrency(lineGross), x.total, textY, { width: w.total, align: 'right' }); let subY = textY + nameHeight + 2; if (hasRebate) { - doc.font('Helvetica') - .fontSize(7.5) - .fillColor(secondaryColor) - .text(rebateText, colXPrices.name, subY, { - width: nameWidth, - }); + doc.font('Helvetica').fontSize(7.5).fillColor(PALETTE.secondary) + .text(rebateText, x.name, subY, { width: nameWidth }); subY += rebateHeight; } if (hasComment) { - doc.font('Helvetica-Oblique') - .fontSize(7.5) - .fillColor(mutedColor) - .text(line.comment, colXPrices.name, subY, { - width: nameWidth, - }); + doc.font('Helvetica-Oblique').fontSize(7.5).fillColor(PALETTE.muted) + .text(line.comment, x.name, subY, { width: nameWidth }); } } else { - doc.font('Helvetica') - .fontSize(8.5) - .fillColor(textColor); - - // Without Prices - doc.text(String(index + 1), colXNoPrices.pos + 3, textY, { width: colWidthNoPrices.pos }); - doc.text(line.sku || '–', colXNoPrices.sku, textY, { width: colWidthNoPrices.sku, ellipsis: true }); - doc.text(nameText, colXNoPrices.name, textY, { width: nameWidth }); - doc.font('Helvetica-Bold') - .text(formatQuantity(amount) + ' Stk', colXNoPrices.amount, textY, { - width: colWidthNoPrices.amount, - align: 'right', - }); + const { x, w } = COL_NO_PRICES; + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.text); + doc.text(String(index + 1), x.pos + 3, textY, { width: w.pos }); + doc.text(line.sku || '–', x.sku, textY, { width: w.sku, ellipsis: true }); + doc.text(nameText, x.name, textY, { width: nameWidth }); + doc.font('Helvetica-Bold').text(`${formatQuantity(amount)} Stk`, x.amount, textY, { width: w.amount, align: 'right' }); if (hasComment) { - doc.font('Helvetica-Oblique') - .fontSize(7.5) - .fillColor(mutedColor) - .text(line.comment, colXNoPrices.name, textY + nameHeight + 2, { - width: nameWidth, - }); + doc.font('Helvetica-Oblique').fontSize(7.5).fillColor(PALETTE.muted) + .text(line.comment, x.name, textY + nameHeight + 2, { width: nameWidth }); } } - // Bottom row separator - doc.moveTo(45, y + rowHeight - 2) - .lineTo(550, y + rowHeight - 2) - .strokeColor(lightBorder) - .lineWidth(0.5) - .stroke(); + doc.moveTo(45, y + rowHeight - 2).lineTo(550, y + rowHeight - 2) + .strokeColor(PALETTE.border).lineWidth(0.5).stroke(); y += rowHeight; - }); + } // ---------------------------------------------------- // 4. Summary & Totals Block // ---------------------------------------------------- if (showPrices) { let totalTax = 0; - for (const item of taxBreakdown.values()) { - totalTax += item.tax; - } + for (const item of taxBreakdown.values()) totalTax += item.tax; const grandTotal = totalNet + totalTax; if (y + 110 > 750) { @@ -777,104 +611,44 @@ export function generateManifestPdf({ 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) + doc.font('Helvetica').fontSize(9).fillColor(PALETTE.muted).text('Summe Netto:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold').fontSize(9).fillColor(PALETTE.text) .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) + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.muted) .text(`zzgl. ${rate}% MwSt:`, summaryX, y, { width: 110 }); - doc.font('Helvetica') - .fontSize(8.5) - .fillColor(textColor) + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.text) .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(); + doc.moveTo(summaryX, y + 2).lineTo(summaryX + summaryWidth, y + 2) + .strokeColor(PALETTE.border).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) + doc.font('Helvetica-Bold').fontSize(12).fillColor(PALETTE.primary).text('Gesamtsumme:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold').fontSize(12).fillColor(PALETTE.secondary) .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) + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.muted).text('Bereits bezahlt:', summaryX, y, { width: 110 }); + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.text) .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) + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(PALETTE.text).text('Offener Betrag:', summaryX, y, { width: 110 }); + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(PALETTE.text) .text(formatCurrency(openAmount), summaryX + 110, y, { width: 110, align: 'right' }); 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) { doc.addPage(); y = 50; @@ -883,79 +657,54 @@ export function generateManifestPdf({ } 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', - }); + .fillColor(PALETTE.bg).fill() + .strokeColor(PALETTE.border).lineWidth(0.5).stroke(); + if (isDelivery) { + 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(PALETTE.text) + .text(summaryText, 55, y + 9, { width: 485, align: 'right' }); + } else { + doc.font('Helvetica').fontSize(9).fillColor(PALETTE.muted).text('Gesamtübersicht:', 55, y + 9); + doc.font('Helvetica-Bold').fontSize(9).fillColor(PALETTE.text) + .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 }); + doc.font('Helvetica').fontSize(8.5).fillColor(PALETTE.muted).text(config.closingText, 45, y, { width: 505 }); } // ---------------------------------------------------- // 5. Footers on all pages // ---------------------------------------------------- + const footerInfo = [ + companyName, + companyTaxId ? `USt-IdNr.: ${companyTaxId}` : null, + companyEmail, + companyPhone, + ].filter(Boolean).join(' • ') || 'Vielen Dank für Ihre Zusammenarbeit!'; + const range = doc.bufferedPageRange(); for (let i = 0; i < range.count; i++) { doc.switchToPage(i); doc.page.margins.bottom = 0; - // Footer rule - doc.moveTo(45, 785) - .lineTo(550, 785) - .strokeColor(lightBorder) - .lineWidth(0.5) - .stroke(); + doc.moveTo(45, 785).lineTo(550, 785).strokeColor(PALETTE.border).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(PALETTE.muted) + .text(footerInfo, 45, 793, { width: 350, ellipsis: true, lineBreak: false }); - 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.text(`Seite ${i + 1} von ${range.count}`, 450, 793, { width: 100, align: 'right', lineBreak: false }); } doc.end(); @@ -965,147 +714,185 @@ export function generateManifestPdf({ }); } +/** + * Enriches delivery lines concurrently while deduplicating identical SKU/barcode/thumbnail work. + */ +async function enrichDeliveryLines(store, googleAuthId, companyId, partnerId, lines) { + const isSqlite = store.provider?.dialect === 'sqlite3'; + + const findSql = isSqlite + ? `SELECT a.id, a.sku, a.track_stock, + (SELECT at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC LIMIT 1) AS article_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 + 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, + (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC) AS article_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 + 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 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 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')`; + + // Cache per-SKU lookup/assets within this PDF generation pass + const skuCache = new Map(); + + const fetchSkuData = (rawSku) => { + if (skuCache.has(rawSku)) return skuCache.get(rawSku); + + const promise = (async () => { + const artRes = await store.provider.query(findSql, { companyId, sku: rawSku, partnerId }).catch(() => ({ rows: [] })); + const art = artRes.rows?.[0]; + if (!art) { + return { + ownSku: '', + ownArticleName: '', + supplierSku: rawSku, + ean: '', + trackStock: false, + currentStock: null, + thumbPng: null, + ownSkuBarcode: null, + eanBarcode: null, + }; + } + + const articleId = art.id; + const ownSku = art.sku || rawSku; + const ownArticleName = (art.article_name || art.name || '').trim(); + const supplierSku = art.supplier_sku || (rawSku !== art.sku ? rawSku : ''); + const ean = art.ean || ''; + const trackStock = Number(art.track_stock ?? 0) === 1; + + // Run thumbnail conversion and stock query concurrently + const [thumbPng, stockRes] = await Promise.all([ + resolveThumbPng(store, googleAuthId, art.pic_hash, art.smallthumbbinary), + trackStock + ? store.provider.query(stockSql, { companyId, ownSku, articleId }).catch(() => ({ rows: [] })) + : null, + ]); + + const currentStock = trackStock ? Number(stockRes?.rows?.[0]?.stock ?? 0) : null; + 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 { + ownSku, + ownArticleName, + supplierSku, + ean, + trackStock, + currentStock, + thumbPng, + ownSkuBarcode, + eanBarcode, + }; + })(); + + skuCache.set(rawSku, promise); + return promise; + }; + + return Promise.all( + lines.map(async (line) => { + const rawSku = line.sku ? String(line.sku).trim() : ''; + + // Run article/SKU enrichment and line-specific ledger lookup concurrently + const [skuData, ledgerRes] = await Promise.all([ + rawSku ? fetchSkuData(rawSku) : null, + line.id + ? store.provider.query(ledgerSql, { lineId: line.id }).catch(() => ({ rows: [] })) + : null, + ]); + + let addedQuantity = null; + const lRow = ledgerRes?.rows?.[0]; + if (lRow && Number(lRow.has_warehouse || 0) > 0) { + addedQuantity = Number(lRow.added_amount || 0); + } + + return { + ...line, + ownSku: skuData?.ownSku ?? '', + ownArticleName: skuData?.ownArticleName ?? '', + articleName: skuData?.ownArticleName ?? '', + supplierSku: skuData?.supplierSku ?? '', + ean: skuData?.ean ?? '', + trackStock: skuData?.trackStock ?? false, + currentStock: skuData?.currentStock ?? null, + addedQuantity, + thumbPng: skuData?.thumbPng ?? null, + ownSkuBarcode: skuData?.ownSkuBarcode ?? null, + eanBarcode: skuData?.eanBarcode ?? null, + }; + }) + ); +} + /** * 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 { + const loaders = { + quote: () => store.quote.getById(googleAuthId, manifestId), + order: () => dbOrder.getById(googleAuthId, manifestId), + shipment: () => store.shipment.getById(googleAuthId, manifestId), + delivery: () => store.delivery.getById(googleAuthId, manifestId), + }; + + const loadManifest = loaders[type]; + if (!loadManifest) { throw new Error(`Unsupported manifest type: ${type}`); } + const manifest = await loadManifest(); if (!manifest) { throw new Error('Manifest not found'); } 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 = ''; - let ownArticleName = ''; - 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, - (SELECT at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC LIMIT 1) AS article_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 - 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, - (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id ORDER BY CASE WHEN at.language_id = (SELECT default_language_id FROM company WHERE id = @companyId) THEN 0 ELSE 1 END, at.id ASC) AS article_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 - 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; - ownArticleName = (art.article_name || art.name || '').trim(); - 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); - } - } else { - supplierSku = rawSku; - } - } - - 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, - ownArticleName, - articleName: ownArticleName, - supplierSku, - ean, - trackStock, - currentStock, - addedQuantity, - thumbPng, - ownSkuBarcode, - eanBarcode, - }; - }) - ); - } - - const [company, settings, partner, addresses] = await Promise.all([ + // Fetch company, settings, partner, addresses, AND enrich delivery lines all in parallel + const [company, settings, partner, addresses, enrichedLines] = 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(() => []) : [], + type === 'delivery' && Array.isArray(manifest.lines) + ? enrichDeliveryLines(store, googleAuthId, companyId, partnerId, manifest.lines) + : Promise.resolve(manifest.lines), ]); + if (type === 'delivery' && Array.isArray(enrichedLines)) { + manifest.lines = enrichedLines; + } + const pdfBuffer = await generateManifestPdf({ type, manifest, @@ -1133,5 +920,4 @@ export async function createAndAttachManifestPdf(googleAuthId, companyId, manife return { manifest, attachment, pdfBuffer, filename }; } -// Re-export alias for order backwards compatibility -export { generateManifestPdf as generateOrderPdf }; +export { generateManifestPdf as generateOrderPdf }; \ No newline at end of file