import { delivery as deliveryApi, quote as quoteApi, order as orderApi, retoure as retoureApi, } from '../../api'; import entityAutomat from '../../automats/entityAutomat'; import deliveryAutomat from '../../automats/deliveryAutomat'; import quoteAutomat from '../../automats/quoteAutomat'; import orderAutomat from '../../automats/orderAutomat'; import retoureAutomat from '../../automats/retoureAutomat'; import { getArticle } from '../../automats/artikelAutomat'; import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; import { netFromGross } from '../../utils/price'; import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber'; import { GREEN_PANEL, ORANGE_PANEL, PURPLE_PANEL, YELLOW_PANEL, RED_PANEL, } from '../../colors'; /** Groups that are not sales customers (filter client-side). */ export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']); export function isSupplierCustomerGroup(group) { return String(group?.name ?? '').trim().toLowerCase() === 'lieferanten'; } export function entityDisplayName(entity) { if (!entity) return ''; const fullName = [entity.firstName, entity.name].filter(Boolean).join(' '); return fullName || entity.name || entity.firstName || (entity.number ? `Kunde ${entity.number}` : (entity.id ? `Kunde #${entity.id}` : '')); } export function sortEntities(entities) { return [...entities].sort((a, b) => { const byName = String(a.name).localeCompare(String(b.name), 'de'); if (byName !== 0) return byName; return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de'); }); } /** * Customers = entities whose customergroup is not Lieferanten. */ export function filterNonSupplierEntities(entities, groups) { const supplierIds = new Set( (groups || []) .filter(isSupplierCustomerGroup) .map((g) => g.id), ); return sortEntities( (entities || []).filter((e) => !supplierIds.has(e.customergroupId)), ); } /** * Returns the customer group with the lowest sort number that is not Lieferanten. */ export function getLowestSortNonSupplierCustomerGroup(groups) { const nonSuppliers = (groups || []).filter((g) => !isSupplierCustomerGroup(g)); if (nonSuppliers.length === 0) return null; return [...nonSuppliers].sort( (a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0), )[0]; } /** * Customers = entities whose customergroup.name is not Lieferanten/POS. */ export function filterCustomerEntities(entities, groups) { const excludedIds = new Set( groups .filter((g) => EXCLUDED_CUSTOMER_GROUP_NAMES.has(g.name)) .map((g) => g.id), ); return sortEntities( entities.filter((e) => !excludedIds.has(e.customergroupId)), ); } export function sortSuppliers(suppliers) { return [...suppliers].sort((a, b) => String(a.name).localeCompare(String(b.name), 'de'), ); } export function sortDocs(docs) { return [...docs].sort((a, b) => String(b.date).localeCompare(String(a.date))); } export function formatDate(iso) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString('de-DE'); } export function formatDateTime(iso) { if (!iso) return '–'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } /** * Delivery documents have a server-allocated, read-only `number` plus an * optional user-entered `externalNumber`. Returns the display value for a * field, defaulting to a dash when empty. */ export function numberDisplay(value) { if (value == null || String(value).trim() === '') return '–'; return String(value); } export function todayInputValue() { const d = new Date(); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } export function dateInputValue(iso) { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10); return d.toISOString().slice(0, 10); } export function emptyLine() { const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19'; return { key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, amount: '1', sku: '', name: '', tax: normTax, taxrate: normTax, rebate: '0', net: '', gross: '', pu: '1', unit: '', netprice: '', shippable: true, }; } export function lineFromApi(row) { const netVal = row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : ''; const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : ''; const netNum = parseNum(netVal); const taxNum = parseNum(taxVal); const rebateNum = parseNum(row.rebate) ?? 0; let grossVal = ''; if (row.gross != null && row.gross !== '') { grossVal = String(row.gross); } else if (netNum != null) { const rate = taxNum ?? 0; const g = Math.round((netNum * (1 + rate / 100) * (1 - rebateNum / 100) + Number.EPSILON) * 100) / 100; grossVal = String(g); } return { id: row.id, key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`, amount: row.amount == null ? '' : String(row.amount), sku: row.sku ?? '', tax: taxVal, rebate: row.rebate == null ? '' : String(row.rebate), net: netVal, gross: grossVal, name: row.name ?? '', pu: row.pu == null ? '' : String(row.pu), unit: row.unit ?? '', netprice: netVal, taxrate: taxVal, shippable: row.shippable !== undefined ? Boolean(row.shippable) : true, }; } export function parseNum(value) { if (value == null || value === '') return null; const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.'); if (cleaned === '') return null; const n = Number(cleaned); return Number.isFinite(n) ? n : null; } export function getFirstVal(...vals) { for (const v of vals) { if (v != null && String(v).trim() !== '') return v; } return null; } export function hasArticleLines(lines) { if (!Array.isArray(lines) || lines.length === 0) return false; return lines.some((l) => { if (!l) return false; const sku = String(l.sku ?? '').trim(); const name = String(l.name ?? '').trim(); const net = String(l.net ?? l.netprice ?? l.gross ?? '').trim(); return Boolean(sku || name || net); }); } /** Build create/update line array. Throws Error on client validation. */ export function buildLines(lines, { requireAtLeastOne, kind } = {}) { const outLines = []; const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const sku = String(line.sku ?? '').trim(); const name = String(line.name ?? (sku ? getArticle(sku)?.name : '') ?? '').trim(); const rawAmount = getFirstVal(line.amount, line.count); const amount = parseNum(rawAmount); const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto); let net = parseNum(rawNet); const rawTax = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst); const tax = parseNum(rawTax); const rawGross = getFirstVal(line.gross); const gross = parseNum(rawGross); const rebate = parseNum(getFirstVal(line.rebate)); if (net == null && gross != null && tax != null) { net = netFromGross(gross, tax, rebate ?? 0); } const rawPu = getFirstVal(line.pu, line.vpe); const pu = parseNum(rawPu); const blank = !sku && !name && net == null && gross == null; if (blank) continue; const missing = []; if (!sku && !name) { missing.push('Artikel (ArtNr oder Name)'); } if (amount == null) { missing.push(rawAmount != null ? 'gültige Menge' : 'Menge'); } if ('pu' in line && pu == null) { missing.push(rawPu != null ? 'gültige VPE' : 'VPE'); } if (net == null) { missing.push(rawNet != null ? 'gültiger Nettopreis' : 'Nettopreis'); } if (tax == null) { missing.push(rawTax != null ? 'gültiger Steuersatz' : 'Steuersatz'); } if (missing.length > 0) { const identifier = name || sku; const rowLabel = `Zeile ${i + 1}${identifier ? ` („${identifier}“)` : ''}`; const fieldsText = missing.length === 1 ? `${missing[0]} fehlt` : `${missing.slice(0, -1).join(', ')} und ${missing[missing.length - 1]} fehlen`; errors.push(`${rowLabel}: ${fieldsText}.`); continue; } const row = { amount, net, taxrate: tax }; if (sku) row.sku = sku; if (name) row.name = name; if (rebate != null) row.rebate = rebate; if (pu != null) row.pu = pu; if (kind !== 'delivery') { row.shippable = line.shippable !== undefined ? Boolean(line.shippable) : true; } outLines.push(row); } if (errors.length > 0) { if (errors.length <= 3) { throw new Error(errors.join(' ')); } const visibleErrors = errors.slice(0, 3).join(' '); const remainingCount = errors.length - 3; throw new Error( `${visibleErrors} (und ${remainingCount} weitere fehlerhafte Zeile${remainingCount === 1 ? '' : 'n'})`, ); } if (requireAtLeastOne && outLines.length === 0) { throw new Error('Mindestens eine Artikelzeile nötig.'); } return outLines; } /** * Convert File objects to base64 for API upload * @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments * @param {{ onlyNew?: boolean }} [options] * @returns {Promise>} */ export async function buildFilesForUpload(attachments = [], { onlyNew = false } = {}) { const files = []; for (const attachment of attachments) { if (!attachment) continue; // Skip already persisted attachments in update mode unless modified if (onlyNew && attachment.isPersisted && !attachment.isDirty) { continue; } if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) { continue; } let fileToUpload = attachment.file; if (!fileToUpload || fileToUpload.size === 0) { if (attachment.text) { fileToUpload = new File([attachment.text], attachment.name || 'file.txt', { type: 'text/plain;charset=utf-8', }); } else { continue; } } try { const base64 = await fileToBase64(fileToUpload); const filePayload = { mimetype: attachment.type || fileToUpload.type, name: attachment.name || fileToUpload.name, content: base64, }; if (attachment.thumbnail) { const thumbBase64 = attachment.thumbnail.includes(',') ? attachment.thumbnail.split(',')[1] : attachment.thumbnail; filePayload.thumbavif = thumbBase64; } files.push(filePayload); } catch (err) { console.error('Fehler beim Konvertieren von Datei zu Base64:', err); } } return files; } /** * Convert File to base64 string * @param {File} file * @returns {Promise} */ function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result; if (typeof result === 'string') { // Remove data URL prefix (e.g., "data:application/pdf;base64,") const base64 = result.split(',')[1] || result; resolve(base64); } else { reject(new Error('Invalid file reader result')); } }; reader.onerror = () => reject(new Error('File reader error')); reader.readAsDataURL(file); }); } /** * Restore attachments from API response * @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles * @param {Array} [existingAttachments] * @returns {Promise>} */ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachments = []) { const existingMap = new Map(); for (const existing of existingAttachments) { if (!existing) continue; if (existing.id != null) existingMap.set(String(existing.id), existing); if (existing.fileId != null) existingMap.set(String(existing.fileId), existing); if (existing.name) existingMap.set(existing.name, existing); } const attachments = []; for (const apiFile of apiFiles) { try { let file = null; let thumbnail = null; // 1. Try to restore thumbnail from apiFile.thumbavif if (apiFile.thumbavif) { try { if (typeof apiFile.thumbavif === 'string') { thumbnail = apiFile.thumbavif.startsWith('data:') ? apiFile.thumbavif : `data:image/avif;base64,${apiFile.thumbavif}`; } else if (typeof apiFile.thumbavif === 'object') { const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif; if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) { const thumbBlob = new Blob([new Uint8Array(rawBytes)], { type: 'image/avif' }); thumbnail = URL.createObjectURL(thumbBlob); } } } catch (thumbErr) { console.error('Fehler beim Dekodieren von thumbavif:', thumbErr); } } // 2. Fallback to existing matching attachment thumbnail const matchingExisting = existingMap.get(String(apiFile.id)) || existingMap.get(String(apiFile.fileId)) || existingMap.get(apiFile.name); if (!thumbnail && matchingExisting?.thumbnail) { thumbnail = matchingExisting.thumbnail; } const placeholderName = apiFile.name || 'file'; const placeholderType = apiFile.mimetype || 'application/octet-stream'; file = matchingExisting?.file instanceof File && matchingExisting.file.size > 0 ? matchingExisting.file : new File([''], placeholderName, { type: placeholderType }); const text = matchingExisting?.text || null; const parentPdfId = matchingExisting?.parentPdfId || null; attachments.push({ id: apiFile.id, fileId: apiFile.fileId ?? apiFile.id, manifestId: apiFile.manifestId, isPersisted: true, file, name: apiFile.name, type: apiFile.mimetype, thumbnail, size: apiFile.size, text, parentPdfId, uploadedAt: apiFile.created, meta: apiFile.meta || matchingExisting?.meta || null, }); } catch (err) { console.error('Fehler beim Wiederherstellen von Datei:', err); } } return attachments; } /** * Collect new or modified attachments for multipart upload * @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments * @param {{ onlyNew?: boolean }} [options] * @returns {Array<{file: File, name: string, mimetype: string, meta?: object}>} */ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}) { const files = []; for (const attachment of attachments) { if (!attachment) continue; // Skip already persisted attachments in update mode unless modified if (onlyNew && attachment.isPersisted && !attachment.isDirty) { continue; } if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) { continue; } let fileToUpload = attachment.file; if (!fileToUpload || fileToUpload.size === 0) { if (attachment.text) { fileToUpload = new File([attachment.text], attachment.name || 'file.txt', { type: 'text/plain;charset=utf-8', }); } else { continue; } } let thumbavif = null; if (attachment.thumbnail) { thumbavif = attachment.thumbnail.includes(',') ? attachment.thumbnail.split(',')[1] : attachment.thumbnail; } files.push({ file: fileToUpload, name: attachment.name || fileToUpload.name, mimetype: attachment.type || fileToUpload.type, thumbavif, }); } return files; } /** * @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[], attachments?: object[] }} form * @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts */ export async function buildManifestBody( form, { partyField, requireAtLeastOne, externalNumber: useExternalNumber, kind, isUpdate, mode, omitLines }, ) { const date = form.date; if (!date) throw new Error('Datum fehlt.'); const externalNumber = String(form.externalNumber ?? '').trim(); const isEdit = isUpdate != null ? Boolean(isUpdate) : (mode === 'edit' || !requireAtLeastOne); const shouldRequireLine = requireAtLeastOne !== false; const shouldIncludeLines = !omitLines && form.lines !== undefined && form.lines !== null; let body; if (kind === 'delivery') { // Delivery: number is server-allocated/read-only. The caller-supplied // reference goes in externalNumber (nullable; empty string → null). body = { externalNumber: externalNumber || null, date, }; if (shouldIncludeLines) { body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind: 'delivery' }); } } else { let number = String(form.number ?? '').trim(); if (!number) { number = await ensureDocumentNumber(kind || (partyField === 'entityId' ? 'quote' : 'doc')); } if (!number) throw new Error('Nummer konnte nicht ermittelt werden.'); body = { number, date, }; if (shouldIncludeLines) { body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind }); } if (useExternalNumber || form.externalNumber != null) { body.externalNumber = externalNumber || null; } } if (form.partyId != null && form.partyId !== '') { const pid = Number(form.partyId); if (!Number.isNaN(pid)) { body[partyField] = pid; } } else if (kind === 'order') { throw new Error('Für eine Bestellung ist ein Kunde erforderlich.'); } else if (isEdit) { body[partyField] = null; } // Add files if attachments exist if (form.attachments && form.attachments.length > 0) { const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isEdit }); if (filesToUpload.length > 0) { const formData = new FormData(); body.filesMeta = filesToUpload.map((item) => ({ name: item.name, thumbavif: item.thumbavif ?? null, })); formData.append('data', JSON.stringify(body)); for (const item of filesToUpload) { formData.append('files', item.file, item.name); } return formData; } } return body; } export const LINE_COLUMNS = [ { id: 'pos', label: 'Pos', width: 44, align: 'center' }, { id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' }, { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, { id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' }, { id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' }, { id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' }, { id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' }, { id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' }, ]; export const QUOTE_LINE_COLUMNS = [ { id: 'pos', label: 'Pos', width: 44, align: 'center' }, { id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' }, { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, { id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' }, { id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' }, { id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' }, { id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' }, { id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' }, { id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' }, ]; export const ORDER_LINE_COLUMNS = [ { id: 'pos', label: 'Pos', width: 44, align: 'center' }, { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, { id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' }, { id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' }, { id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' }, { id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' }, { id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' }, { id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' }, { id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' }, ]; export const DELIVERY_LINE_COLUMNS = [ { id: 'pos', label: 'Pos', width: 44, align: 'center' }, { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, { id: 'name', label: 'Name', width: 220, align: 'left', type: 'text' }, { id: 'amount', label: 'Anzahl', width: 88, align: 'right', type: 'number' }, { id: 'pu', label: 'VPE', width: 80, align: 'right', type: 'number' }, { id: 'taxrate', label: 'MwSt', width: 80, align: 'right', type: 'number' }, { id: 'netprice', label: 'Netto', width: 104, align: 'right', type: 'number' }, ]; /** @typedef {'delivery'|'quote'|'order'} ManifestKind */ /** * Resolves the api namespace for a manifest kind. * * Delivery, quote, and order share one editor/list shape but talk to three * different endpoints. * * @param {ManifestKind} kind Manifest kind. * @returns {{get: Function, create: Function, update: Function, delete: Function}} */ export function apiFor(cfgOrKind) { const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind; if (kind === 'delivery') return deliveryApi; if (kind === 'quote') return quoteApi; if (kind === 'order') return orderApi; if (kind === 'retoure') return retoureApi; throw new Error(`Unknown manifest kind: ${kind}`); } /** * Load party options for Autocomplete / list name lookup. * @param {ManifestKind|object} cfgOrKind */ export async function loadParties(cfgOrKind) { const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind; if (kind === 'delivery') { if (companyAutomat.state.suppliersStatus === 'ready') { return sortSuppliers(companyAutomat.state.suppliers); } const list = await companyAutomat.actions.loadSuppliers(); return sortSuppliers(list); } const [entities, groups] = await Promise.all([ entityAutomat.actions.ensureLoaded(), companyAutomat.actions.ensureCustomergroupsLoaded(), ]); const entityList = entities ?? entityAutomat.state.items ?? []; const groupList = groups ?? companyAutomat.state.customergroups ?? []; return filterNonSupplierEntities(entityList, groupList); } export function partyDisplayName(cfgOrKind, party) { if (!party) return ''; if (typeof party === 'string') return party; const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind; if (kind === 'delivery') return String(party.name ?? ''); return entityDisplayName(party); } /** * @param {ManifestKind|object} cfgOrKind */ export function manifestConfig(cfgOrKind) { if (cfgOrKind && typeof cfgOrKind === 'object' && cfgOrKind.kind && cfgOrKind.api) { return cfgOrKind; } const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind; if (kind === 'delivery') { return { kind: 'delivery', api: 'delivery', listField: 'deliveries', docField: 'delivery', partyField: 'supplierId', listPath: '/lieferung', createPath: '/lieferung/neu', editPath: (id) => `/lieferung/${id}`, dateLabel: 'Lieferdatum', partyLabel: 'Lieferant', emptyList: 'Noch keine Lieferungen.', createButton: 'Neue Lieferung', createdMsg: 'Lieferung angelegt.', savedMsg: 'Lieferung gespeichert.', deletedMsg: 'Lieferung gelöscht.', confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`, invalidId: 'Ungültige Lieferungs-ID.', partyGateMessage: null, getPathLabel: 'GET /api/delivery/:id', postPathLabel: 'POST /api/delivery', patchPathLabel: 'PATCH /api/delivery/:id', listColor: ORANGE_PANEL, headerColor: ORANGE_PANEL, linesColor: GREEN_PANEL, requirePartyBeforeLines: false, listAutomat: deliveryAutomat, partiesByIdFromList: (parties) => Object.fromEntries(parties.map((row) => [row.id, row])), // Delivery uses a server-allocated read-only number plus an optional // caller-supplied external reference. externalNumber: true, columns: DELIVERY_LINE_COLUMNS, }; } if (kind === 'quote') { return { kind: 'quote', api: 'quote', listField: 'quotes', docField: 'quote', partyField: 'entityId', listPath: '/auftrag/angebot', createPath: '/auftrag/neuesangebot', editPath: (id) => `/auftrag/${id}`, dateLabel: 'Gültig bis', partyLabel: 'Kunde', emptyList: 'Noch keine Angebote.', createButton: 'Neues Angebot', createdMsg: 'Angebot angelegt.', savedMsg: 'Angebot gespeichert.', deletedMsg: 'Angebot gelöscht.', confirmDelete: (number) => `Angebot „${number}“ wirklich löschen?`, invalidId: 'Ungültige Angebots-ID.', partyGateMessage: null, getPathLabel: 'GET /api/quote/:id', postPathLabel: 'POST /api/quote', patchPathLabel: 'PATCH /api/quote/:id', listColor: YELLOW_PANEL, headerColor: YELLOW_PANEL, linesColor: GREEN_PANEL, requirePartyBeforeLines: false, listAutomat: quoteAutomat, partiesByIdFromList: (parties) => Object.fromEntries(parties.map((row) => [row.id, row])), columns: QUOTE_LINE_COLUMNS, }; } if (kind === 'order') { return { kind: 'order', api: 'order', listField: 'orders', docField: 'order', partyField: 'entityId', listPath: '/auftrag', createPath: '/auftrag/bestellung/neu', editPath: (id) => `/auftrag/bestellung/${id}`, dateLabel: 'Lieferdatum', partyLabel: 'Kunde', emptyList: 'Noch keine Bestellungen.', createButton: 'Neuer Auftrag', createdMsg: 'Bestellung angelegt.', savedMsg: 'Bestellung gespeichert.', deletedMsg: 'Bestellung gelöscht.', confirmDelete: (number) => `Bestellung „${number}“ wirklich löschen?`, invalidId: 'Ungültige Bestellungs-ID.', partyGateMessage: null, getPathLabel: 'GET /api/order/:id', postPathLabel: 'POST /api/order', patchPathLabel: 'PATCH /api/order/:id', listColor: PURPLE_PANEL, headerColor: PURPLE_PANEL, linesColor: GREEN_PANEL, requirePartyBeforeLines: false, listAutomat: orderAutomat, partiesByIdFromList: (parties) => Object.fromEntries(parties.map((row) => [row.id, row])), externalNumber: true, columns: ORDER_LINE_COLUMNS, }; } if (kind === 'retoure') { return { kind: 'retoure', api: 'retoure', listField: 'retoures', docField: 'retoure', partyField: 'entityId', listPath: '/auftrag/retoure', createPath: '/auftrag/retoure/neu', editPath: (id) => `/auftrag/retoure/${id}`, dateLabel: 'Retourendatum', partyLabel: 'Kunde', emptyList: 'Noch keine Retouren.', createButton: 'Neue Retoure', createdMsg: 'Retoure angelegt.', savedMsg: 'Retoure gespeichert.', deletedMsg: 'Retoure gelöscht.', confirmDelete: (number) => `Retoure „${number}“ wirklich löschen?`, invalidId: 'Ungültige Retouren-ID.', partyGateMessage: null, getPathLabel: 'GET /api/retoure/:id', postPathLabel: 'POST /api/retoure', patchPathLabel: 'PUT /api/retoure/:id', listColor: RED_PANEL, headerColor: RED_PANEL, linesColor: GREEN_PANEL, requirePartyBeforeLines: false, listAutomat: retoureAutomat, partiesByIdFromList: (parties) => Object.fromEntries(parties.map((row) => [row.id, row])), externalNumber: true, columns: ORDER_LINE_COLUMNS, }; } throw new Error(`Unknown manifest kind: ${kind}`); } /** * Resolves supplier SKU and own SKU for a manifest line. */ export function resolveLineSkus(line, delivery, supplierArticlesMap = {}) { const rawSku = line?.sku ? String(line.sku).trim() : ''; const supplierId = delivery?.supplierId ?? delivery?.partyId; // 1. Direct line properties if available let supplierSku = line?.supplierSku || line?.supplier_sku || line?.supplierArticle?.sku || ''; let ownSku = line?.articleSku || line?.article_sku || line?.article?.sku || line?.supplierArticle?.article?.sku || ''; // 2. Check supplierArticlesMap const sa = rawSku ? (supplierArticlesMap[rawSku] || supplierArticlesMap[rawSku.toLowerCase()]) : null; if (sa) { if (!supplierSku && sa.sku) supplierSku = sa.sku; if (!ownSku && sa.article?.sku) ownSku = sa.article.sku; } // 3. Check loaded article details const cachedArticle = line?.article || (ownSku ? getArticle(ownSku) : null) || (rawSku ? getArticle(rawSku) : null); if (cachedArticle) { if (!ownSku && cachedArticle.sku) { ownSku = cachedArticle.sku; } const sup = (supplierId && cachedArticle.suppliers?.find((s) => s.entityId === supplierId)) || cachedArticle.suppliers?.[0]; if (!supplierSku && sup?.sku) { supplierSku = sup.sku; } } // 4. Fallback heuristics if one is still missing if (!supplierSku && !ownSku) { ownSku = rawSku; } else if (!supplierSku && rawSku && rawSku !== ownSku) { supplierSku = rawSku; } else if (!ownSku && rawSku && rawSku !== supplierSku) { ownSku = rawSku; } return { supplierSku: supplierSku || '–', ownSku: ownSku || '–', }; }