{ "name": "Lidl Frankrijk - Opdracht naar Postgres en Transpas -", "nodes": [ { "parameters": { "jsCode": "// Run Once for All Items\n// Ontvangt bestanden die door de FTP-downloadnode in binary.data zijn gezet.\n// Alleen XLSX, XLS en CSV gaan door.\n\nconst inputItems = $input.all();\nconst output = [];\nconst skipped = [];\n\nfor (let itemIndex = 0; itemIndex < inputItems.length; itemIndex++) {\n const item = inputItems[itemIndex];\n const file = item.binary?.data;\n\n if (!file) {\n skipped.push(`item ${itemIndex + 1}: binary.data ontbreekt`);\n continue;\n }\n\n const fileName = String(\n item.json?.source_file_name ??\n file.fileName ??\n item.json?.name ??\n `bestand_${itemIndex + 1}`\n );\n\n const extension = String(\n file.fileExtension ??\n fileName.split('.').pop() ??\n ''\n )\n .replace(/^\\./, '')\n .toLowerCase()\n .trim();\n\n if (!['xlsx', 'xls', 'csv'].includes(extension)) {\n skipped.push(`${fileName}: bestandstype niet toegestaan`);\n continue;\n }\n\n output.push({\n json: {\n ...(item.json ?? {}),\n source_file_name: fileName,\n source_extension: extension,\n source_ftp_path:\n item.json?.source_ftp_path ??\n item.json?.full_path ??\n item.json?.path ??\n null,\n\n // Deze velden blijven bestaan voor compatibiliteit met PostgreSQL,\n // maar er is geen e-mailbron meer.\n source_message_id: null,\n source_subject: null,\n source_from: 'FTP',\n source_received_at:\n item.json?.modified_at ??\n item.json?.modifiedTime ??\n item.json?.modified ??\n new Date().toISOString(),\n\n is_csv: extension === 'csv',\n },\n binary: {\n data: file,\n },\n pairedItem: {\n item: itemIndex,\n },\n });\n}\n\nif (output.length === 0) {\n const details = skipped.length\n ? ` ${skipped.join(' | ')}`\n : ' Er zijn geen bestanden ontvangen.';\n\n throw new Error(\n `Geen bruikbaar XLSX-, XLS- of CSV-bestand gevonden.${details}`\n );\n}\n\nreturn output;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1136, 416 ], "id": "2d78e121-cdb5-48b6-a98b-0aeec510cbb0", "name": "Selecteer Excel-bijlagen", "notesInFlow": true, "onError": "continueErrorOutput", "notes": "Ontvangt gedownloade FTP-bestanden, accepteert XLSX/XLS/CSV en zet het bestand in binary.data." }, { "parameters": { "jsCode": "// Run once for all items.\n// De Excel wordt zonder headernamen gelezen. Dat is bewust: Postcode en Landcode komen twee keer voor.\n// Kolom A = index 0, kolom AF = index 31.\nconst inputItems = $input.all();\nconst sourceItems = $('Selecteer Excel-bijlagen').all();\nconst output = [];\nconst errors = [];\n\nfunction sourceFor(item) {\n const paired = Array.isArray(item.pairedItem) ? item.pairedItem[0] : item.pairedItem;\n const index = Number.isInteger(paired?.item) ? paired.item : 0;\n return sourceItems[index]?.json ?? sourceItems[0]?.json ?? {};\n}\n\nfunction valueAt(row, index) {\n return row?.[String(index)] ?? row?.[index] ?? null;\n}\n\nfunction clean(value) {\n if (value === undefined || value === null) return null;\n const s = String(value).replace(/\\u00a0/g, ' ').trim();\n return s === '' ? null : s;\n}\n\nfunction numberValue(value) {\n if (value === undefined || value === null || value === '') return null;\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n let s = String(value).trim().replace(/\\s/g, '');\n if (s.includes(',') && s.includes('.')) {\n s = s.lastIndexOf(',') > s.lastIndexOf('.')\n ? s.replace(/\\./g, '').replace(',', '.')\n : s.replace(/,/g, '');\n } else {\n s = s.replace(',', '.');\n }\n const n = Number(s);\n return Number.isFinite(n) ? n : null;\n}\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction isoDate(value) {\n if (value === undefined || value === null || value === '') return null;\n\n if (typeof value === 'number' && Number.isFinite(value)) {\n const utc = new Date(Date.UTC(1899, 11, 30) + Math.round(value) * 86400000);\n return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())}`;\n }\n\n const s = String(value).trim();\n let m = s.match(/^(\\d{4})[-/.](\\d{1,2})[-/.](\\d{1,2})/);\n if (m) return `${m[1]}-${pad2(m[2])}-${pad2(m[3])}`;\n\n m = s.match(/^(\\d{1,2})[-/.](\\d{1,2})[-/.](\\d{2}|\\d{4})$/);\n if (m) {\n let year = Number(m[3]);\n if (year < 100) year += 2000;\n return `${year}-${pad2(m[2])}-${pad2(m[1])}`;\n }\n\n const parsed = new Date(s);\n if (!Number.isNaN(parsed.getTime())) {\n return `${parsed.getUTCFullYear()}-${pad2(parsed.getUTCMonth() + 1)}-${pad2(parsed.getUTCDate())}`;\n }\n\n throw new Error(`Ongeldige datum: ${s}`);\n}\n\nfunction hhmm(value) {\n if (value === undefined || value === null || value === '') return null;\n\n if (typeof value === 'number' && Number.isFinite(value)) {\n const fraction = ((value % 1) + 1) % 1;\n let minutes = Math.round(fraction * 24 * 60) % (24 * 60);\n return `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`;\n }\n\n const s = String(value).trim();\n const match = s.match(/(?:T|^)(\\d{1,2}):(\\d{2})(?::\\d{2})?/);\n if (!match) throw new Error(`Ongeldige tijd: ${s}`);\n return `${pad2(match[1])}:${pad2(match[2])}`;\n}\n\nfunction country(value) {\n const c = (clean(value) ?? '').toUpperCase();\n if (!c) return null;\n return c === 'UK' ? 'GB' : c.slice(0, 2);\n}\n\nfunction temperatures(value) {\n const raw = clean(value);\n if (!raw) return { min: null, max: null };\n const normalized = raw.replace(/,/g, '.').replace(/°/g, '');\n const range = normalized.match(/(-?\\d+(?:\\.\\d+)?)\\s*(?:-|tot|t\\/?m)\\s*(-?\\d+(?:\\.\\d+)?)/i);\n if (range) {\n const a = Number(range[1]);\n const b = Number(range[2]);\n return { min: Math.min(a, b), max: Math.max(a, b) };\n }\n const single = normalized.match(/-?\\d+(?:\\.\\d+)?/);\n if (!single) return { min: null, max: null };\n const n = Number(single[0]);\n return { min: n, max: n };\n}\n\nfunction cancelledFromStatus(value) {\n const status = (clean(value) ?? '').toLowerCase();\n if (!status) return false;\n if (/\\b(niet|non)\\s+(geannuleerd|cancelled|canceled)\\b/.test(status)) return false;\n return /geannul|annule|cancel|vervallen|verwijderd|deleted/.test(status);\n}\n\nfor (let index = 0; index < inputItems.length; index++) {\n const item = inputItems[index];\n const row = item.json?.row && typeof item.json.row === 'object'\n ? item.json.row\n : item.json;\n\n const values = Array.from({ length: 32 }, (_, i) => valueAt(row, i));\n const filled = values.filter(v => clean(v) !== null).length;\n const first = (clean(values[0]) ?? '').toLowerCase();\n const second = (clean(values[1]) ?? '').toLowerCase();\n\n // Kopregel en lege/samengevoegde tussenregels overslaan.\n if (first === 'status' && second.includes('transport')) continue;\n if (filled < 3) continue;\n\n try {\n const source = sourceFor(item);\n const status = clean(values[0]);\n const transportNumber = (clean(values[1]) ?? '').toUpperCase();\n\n if (!transportNumber) throw new Error('Transportnummer ontbreekt');\n if (!/^TR[A-Z0-9_-]{5,}$/i.test(transportNumber)) {\n throw new Error(`Onverwacht transportnummer: ${transportNumber}`);\n }\n\n const temperature = temperatures(values[26]);\n const normalized = {\n status: status ?? 'Onbekend',\n transport_number: transportNumber,\n pickup_date: isoDate(values[2]),\n pickup_time: hhmm(values[3]),\n pickup_time_till: hhmm(values[4]),\n pickup_name: clean(values[5]),\n pickup_address1: clean(values[6]),\n pickup_zipcode: clean(values[7]),\n pickup_city: clean(values[8]),\n pickup_phone: clean(values[9]),\n pickup_country: country(values[10]),\n delivery_date: isoDate(values[11]),\n delivery_date_till: isoDate(values[11]),\n delivery_name: clean(values[12]),\n delivery_address1: clean(values[13]),\n delivery_zipcode: clean(values[14]),\n delivery_city: clean(values[15]),\n transport_code: clean(values[16]),\n delivery_country: country(values[17]),\n delivery_time: hhmm(values[18]),\n delivery_time_till: hhmm(values[19]),\n colli: numberValue(values[20]),\n loose_boxes: numberValue(values[21]),\n transport_units: numberValue(values[22]),\n product_group: clean(values[23]),\n load_carrier_count: numberValue(values[24]),\n load_carrier_type: clean(values[25]),\n temperature_group: clean(values[26]),\n min_temperature: temperature.min,\n max_temperature: temperature.max,\n reference_text: clean(values[27]),\n external_document_number: clean(values[28]),\n external_document_branch: clean(values[29]),\n forwarder: clean(values[30]),\n gross_weight: numberValue(values[31]),\n is_cancelled: cancelledFromStatus(status),\n };\n\n if (!normalized.pickup_date) throw new Error('Laaddatum ontbreekt');\n if (!normalized.delivery_date) throw new Error('Losdatum ontbreekt');\n if (!normalized.pickup_name || !normalized.pickup_address1) throw new Error('Laadadres is onvolledig');\n if (!normalized.delivery_name || !normalized.delivery_address1) throw new Error('Losadres is onvolledig');\n\n const businessPayload = { ...normalized };\n\n output.push({\n json: {\n ...normalized,\n source_file_name: source.source_file_name ?? null,\n source_subject: source.source_subject ?? null,\n source_from: source.source_from ?? null,\n source_message_id: source.source_message_id ?? null,\n source_received_at: source.source_received_at ?? new Date().toISOString(),\n raw_row: row,\n business_payload: businessPayload,\n },\n pairedItem: {\n item: index,\n },\n });\n } catch (error) {\n errors.push(`Bestandsrij ${index + 1}: ${error.message}`);\n }\n}\n\nif (errors.length) {\n throw new Error(`Bestand bevat ongeldige orderregels:\\n${errors.join('\\n')}`);\n}\nif (!output.length) {\n throw new Error('Geen orderregels gevonden na de kopregel.');\n}\n\nreturn output;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2352, 368 ], "id": "6d4504e4-7b7b-4369-827c-2cee9951b0cf", "name": "Normaliseer en valideer orders", "onError": "continueErrorOutput" }, { "parameters": { "operation": "executeQuery", "query": "WITH input AS MATERIALIZED (\n SELECT $1::jsonb AS j\n),\nsrc AS MATERIALIZED (\n SELECT\n j,\n j->'business_payload' AS p,\n md5((j->'business_payload')::text) AS payload_hash\n FROM input\n),\nprevious AS MATERIALIZED (\n SELECT o.*\n FROM public.lidl_fr_orders o\n JOIN src s ON s.p->>'transport_number' = o.transport_number\n FOR UPDATE\n),\nupserted AS (\n INSERT INTO public.lidl_fr_orders AS existing (\n transport_number, current_status, is_cancelled, revision, current_payload_hash,\n pickup_date, pickup_time, pickup_time_till, pickup_name, pickup_address1,\n pickup_zipcode, pickup_city, pickup_phone, pickup_country,\n delivery_date, delivery_date_till, delivery_time, delivery_time_till,\n delivery_name, delivery_address1, delivery_zipcode, delivery_city, delivery_country,\n transport_code, colli, loose_boxes, transport_units, product_group,\n load_carrier_count, load_carrier_type, temperature_group,\n min_temperature, max_temperature, reference_text,\n external_document_number, external_document_branch, forwarder, gross_weight,\n source_file_name, source_message_id, source_subject, source_from,\n source_received_at, raw_row\n )\n SELECT\n p->>'transport_number',\n p->>'status',\n COALESCE((p->>'is_cancelled')::boolean, false),\n 1,\n payload_hash,\n NULLIF(p->>'pickup_date', '')::date,\n NULLIF(p->>'pickup_time', '')::time,\n NULLIF(p->>'pickup_time_till', '')::time,\n p->>'pickup_name',\n p->>'pickup_address1',\n p->>'pickup_zipcode',\n p->>'pickup_city',\n p->>'pickup_phone',\n p->>'pickup_country',\n NULLIF(p->>'delivery_date', '')::date,\n NULLIF(p->>'delivery_date_till', '')::date,\n NULLIF(p->>'delivery_time', '')::time,\n NULLIF(p->>'delivery_time_till', '')::time,\n p->>'delivery_name',\n p->>'delivery_address1',\n p->>'delivery_zipcode',\n p->>'delivery_city',\n p->>'delivery_country',\n p->>'transport_code',\n NULLIF(p->>'colli', '')::numeric,\n NULLIF(p->>'loose_boxes', '')::numeric,\n NULLIF(p->>'transport_units', '')::numeric,\n p->>'product_group',\n NULLIF(p->>'load_carrier_count', '')::numeric,\n p->>'load_carrier_type',\n p->>'temperature_group',\n NULLIF(p->>'min_temperature', '')::numeric,\n NULLIF(p->>'max_temperature', '')::numeric,\n p->>'reference_text',\n p->>'external_document_number',\n p->>'external_document_branch',\n p->>'forwarder',\n NULLIF(p->>'gross_weight', '')::numeric,\n j->>'source_file_name',\n j->>'source_message_id',\n j->>'source_subject',\n j->>'source_from',\n NULLIF(j->>'source_received_at', '')::timestamptz,\n COALESCE(j->'raw_row', '{}'::jsonb)\n FROM src\n ON CONFLICT (transport_number) DO UPDATE SET\n current_status = EXCLUDED.current_status,\n is_cancelled = EXCLUDED.is_cancelled,\n revision = CASE\n WHEN existing.current_payload_hash IS DISTINCT FROM EXCLUDED.current_payload_hash\n THEN existing.revision + 1\n ELSE existing.revision\n END,\n current_payload_hash = EXCLUDED.current_payload_hash,\n pickup_date = EXCLUDED.pickup_date,\n pickup_time = EXCLUDED.pickup_time,\n pickup_time_till = EXCLUDED.pickup_time_till,\n pickup_name = EXCLUDED.pickup_name,\n pickup_address1 = EXCLUDED.pickup_address1,\n pickup_zipcode = EXCLUDED.pickup_zipcode,\n pickup_city = EXCLUDED.pickup_city,\n pickup_phone = EXCLUDED.pickup_phone,\n pickup_country = EXCLUDED.pickup_country,\n delivery_date = EXCLUDED.delivery_date,\n delivery_date_till = EXCLUDED.delivery_date_till,\n delivery_time = EXCLUDED.delivery_time,\n delivery_time_till = EXCLUDED.delivery_time_till,\n delivery_name = EXCLUDED.delivery_name,\n delivery_address1 = EXCLUDED.delivery_address1,\n delivery_zipcode = EXCLUDED.delivery_zipcode,\n delivery_city = EXCLUDED.delivery_city,\n delivery_country = EXCLUDED.delivery_country,\n transport_code = EXCLUDED.transport_code,\n colli = EXCLUDED.colli,\n loose_boxes = EXCLUDED.loose_boxes,\n transport_units = EXCLUDED.transport_units,\n product_group = EXCLUDED.product_group,\n load_carrier_count = EXCLUDED.load_carrier_count,\n load_carrier_type = EXCLUDED.load_carrier_type,\n temperature_group = EXCLUDED.temperature_group,\n min_temperature = EXCLUDED.min_temperature,\n max_temperature = EXCLUDED.max_temperature,\n reference_text = EXCLUDED.reference_text,\n external_document_number = EXCLUDED.external_document_number,\n external_document_branch = EXCLUDED.external_document_branch,\n forwarder = EXCLUDED.forwarder,\n gross_weight = EXCLUDED.gross_weight,\n source_file_name = EXCLUDED.source_file_name,\n source_message_id = EXCLUDED.source_message_id,\n source_subject = EXCLUDED.source_subject,\n source_from = EXCLUDED.source_from,\n source_received_at = EXCLUDED.source_received_at,\n raw_row = EXCLUDED.raw_row,\n last_seen_at = now(),\n status_changed_at = CASE\n WHEN existing.current_status IS DISTINCT FROM EXCLUDED.current_status\n OR existing.is_cancelled IS DISTINCT FROM EXCLUDED.is_cancelled\n THEN now()\n ELSE existing.status_changed_at\n END,\n reactivated_at = CASE\n WHEN existing.is_cancelled = true AND EXCLUDED.is_cancelled = false\n THEN now()\n ELSE existing.reactivated_at\n END,\n last_error = CASE\n WHEN existing.current_payload_hash IS DISTINCT FROM EXCLUDED.current_payload_hash\n THEN NULL\n ELSE existing.last_error\n END\n RETURNING *\n),\nclassified AS MATERIALIZED (\n SELECT\n u.*,\n CASE\n WHEN p.id IS NULL THEN 'created'\n WHEN p.is_cancelled = false AND u.is_cancelled = true THEN 'cancelled'\n WHEN p.is_cancelled = true AND u.is_cancelled = false THEN 'reactivated'\n WHEN p.current_payload_hash IS DISTINCT FROM u.current_payload_hash THEN 'updated'\n ELSE 'unchanged'\n END AS event_type,\n (p.id IS NULL OR p.current_payload_hash IS DISTINCT FROM u.current_payload_hash) AS state_changed\n FROM upserted u\n LEFT JOIN previous p ON p.id = u.id\n),\nevent_insert AS (\n INSERT INTO public.lidl_fr_order_events (\n order_id, transport_number, revision, event_type, status,\n is_cancelled, payload_hash, source_file_name, source_message_id, row_data\n )\n SELECT\n c.id, c.transport_number, c.revision, c.event_type, c.current_status,\n c.is_cancelled, c.current_payload_hash, c.source_file_name,\n c.source_message_id, c.raw_row\n FROM classified c\n WHERE c.state_changed\n ON CONFLICT (transport_number, revision) DO NOTHING\n RETURNING id\n),\nlatest_document AS MATERIALIZED (\n SELECT d.*\n FROM public.lidl_fr_documents d\n JOIN classified c ON c.transport_number = d.transport_number\n WHERE d.document_kind = 'PAKBON'\n AND d.active = true\n AND d.storage_status = 'stored'\n AND d.file_path IS NOT NULL\n ORDER BY d.received_at DESC, d.id DESC\n LIMIT 1\n)\nSELECT\n c.id AS order_id,\n c.transport_number,\n c.current_status,\n c.is_cancelled,\n c.revision,\n c.current_payload_hash AS payload_hash,\n c.event_type,\n (c.revision > COALESCE(c.last_transpas_export_revision, 0)) AS should_export,\n CASE\n WHEN c.is_cancelled THEN 'DELETE'\n WHEN c.has_active_export = false THEN 'NEW'\n ELSE 'UPDATE'\n END AS transpas_action,\n\n c.pickup_date,\n to_char(c.pickup_time, 'HH24:MI') AS pickup_time,\n to_char(c.pickup_time_till, 'HH24:MI') AS pickup_time_till,\n c.pickup_name,\n c.pickup_address1,\n c.pickup_zipcode,\n c.pickup_city,\n c.pickup_phone,\n c.pickup_country,\n\n c.delivery_date,\n c.delivery_date_till,\n to_char(c.delivery_time, 'HH24:MI') AS delivery_time,\n to_char(c.delivery_time_till, 'HH24:MI') AS delivery_time_till,\n c.delivery_name,\n c.delivery_address1,\n c.delivery_zipcode,\n c.delivery_city,\n c.delivery_country,\n\n c.transport_code,\n c.colli,\n c.loose_boxes,\n c.transport_units,\n c.product_group,\n c.load_carrier_count,\n c.load_carrier_type,\n c.temperature_group,\n c.min_temperature,\n c.max_temperature,\n c.reference_text,\n c.external_document_number,\n c.external_document_branch,\n c.forwarder,\n c.gross_weight,\n c.source_file_name,\n\n d.id AS pakbon_id,\n d.file_name AS pakbon_file_name,\n d.stored_file_name AS pakbon_stored_file_name,\n d.file_path AS pakbon_file_path,\n d.file_type AS pakbon_file_type,\n d.mime_type AS pakbon_mime_type,\n d.document_hash AS pakbon_document_hash\nFROM classified c\nLEFT JOIN latest_document d ON true;", "options": { "queryReplacement": "={{ [JSON.stringify($json)] }}" } }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 2672, 352 ], "id": "8ef1a7e3-819c-46c5-b04c-cc1db01de2d7", "name": "Upsert order status en zoek pakbon", "credentials": { "postgres": { "id": "m4DQjg3b1iDYGlBp", "name": "Postgres Flower direct" } }, "onError": "continueErrorOutput" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "0f2ae688-8cf4-47ff-ab51-3457409c88fb", "leftValue": "={{ $json.should_export }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [ 2960, 320 ], "id": "fa659a1a-13b4-4897-b2e6-e637da769216", "name": "Alleen nieuwe of gewijzigde revisies", "disabled": true }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// Per item uitvoeren.\n// De ordergegevens komen rechtstreeks uit PostgreSQL of via de gekoppelde item-link\n// wanneer het pakbonbestand eerst van de NAS is gelezen.\n\nconst CFG = {\n customerId: '60971',\n departmentId: '',\n shipmentKindId: '',\n ediproviderId: '',\n companyId: '1',\n unitMatchmode: '1',\n documentTypeId: 'PAKBON',\n documentTypeMatchmode: '1',\n ftpFolder: '/test/ToTP/TPE',\n\n deliveryDriverInfo: [\n 'Transport flowers at +4°C to +6°C – Transport plants at +15°C –',\n 'When transporting a mixed load of flowers and plants, set the temperature to +12°C.',\n 'Exchange the pallets at the unloading location using PAKi eVoucher number 048849 04-CAMBRAI',\n ].join(' '),\n};\n\nconst linkedOrder = $('Upsert order status en zoek pakbon').item.json;\nconst j = $json?.order_id ? $json : linkedOrder;\nconst inputItem = $input.item;\n\nfunction xmlEsc(value) {\n return String(value ?? '')\n .replace(\n /&(?!amp;|lt;|gt;|quot;|apos;|#\\d+;|#x[0-9A-Fa-f]+;)/g,\n '&',\n )\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction tag(name, value, attrs = '') {\n if (\n value === undefined ||\n value === null ||\n String(value).trim() === ''\n ) {\n return '';\n }\n\n const attrText = attrs ? ` ${attrs}` : '';\n return `<${name}${attrText}>${xmlEsc(value)}`;\n}\n\nfunction emptyTag(name, attrs = '') {\n const attrText = attrs ? ` ${attrs}` : '';\n return `<${name}${attrText} />`;\n}\n\nfunction safeFilePart(value) {\n return String(value ?? '')\n .replace(/[^a-zA-Z0-9._-]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 100) || 'order';\n}\n\nfunction block(name, lines, indent = ' ') {\n const present = lines.filter(Boolean);\n\n if (!present.length) {\n return '';\n }\n\n return [\n `${indent}<${name}>`,\n ...present.map(line => `${indent} ${line}`),\n `${indent}`,\n ].join('\\n');\n}\n\n/**\n * Zet een PostgreSQL-/ISO-datum om naar de lokale kalenderdatum\n * in Nederland.\n *\n * Voorbeeld:\n * 2026-08-02T22:00:00.000Z -> 2026-08-03\n */\nfunction formatDate(value) {\n if (value === undefined || value === null || value === '') {\n return null;\n }\n\n const text = String(value).trim();\n\n // Een datum zonder tijd kan direct worden gebruikt.\n const dateOnly = text.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n\n if (dateOnly) {\n return `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`;\n }\n\n const date = new Date(text);\n\n if (Number.isNaN(date.getTime())) {\n throw new Error(`Ongeldige datum ontvangen: ${text}`);\n }\n\n const parts = new Intl.DateTimeFormat('en-CA', {\n timeZone: 'Europe/Amsterdam',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n }).formatToParts(date);\n\n const result = Object.fromEntries(\n parts\n .filter(part => part.type !== 'literal')\n .map(part => [part.type, part.value]),\n );\n\n return `${result.year}-${result.month}-${result.day}`;\n}\n\nconst pickupDate = formatDate(j.pickup_date);\nconst deliveryDate = formatDate(j.delivery_date);\nconst deliveryDateTill = formatDate(\n j.delivery_date_till || j.delivery_date,\n);\n\nlet pakbonBase64 = '';\n\nif (!j.is_cancelled && inputItem.binary?.pakbon) {\n const buffer = await this.helpers.getBinaryDataBuffer(0, 'pakbon');\n pakbonBase64 = buffer.toString('base64');\n}\n\nconst rootOptional = [\n tag('ediprovider_id', CFG.ediproviderId),\n tag('company_id', CFG.companyId),\n].filter(Boolean);\n\nconst bookingOptional = [\n tag('department_id', CFG.departmentId),\n].filter(Boolean);\n\nconst shipmentOptional = [\n tag(\n 'shipmentkind_id',\n CFG.shipmentKindId,\n 'matchmode=\"1\"',\n ),\n].filter(Boolean);\n\nconst pickup = block(\n 'pickupaddress',\n [\n emptyTag('address_id', 'matchmode=\"11\"'),\n tag('date', pickupDate),\n tag('datetill', pickupDate),\n tag('time', j.pickup_time),\n tag('timetill', j.pickup_time_till),\n tag('name', j.pickup_name),\n tag('address1', j.pickup_address1),\n tag('zipcode', j.pickup_zipcode),\n tag('city_id', j.pickup_city, 'matchmode=\"4\"'),\n tag('country_id', j.pickup_country, 'matchmode=\"2\"'),\n tag('phone', j.pickup_phone),\n\n // Transportnummer is de laadreferentie.\n tag('reference', j.transport_number),\n\n // De naam/code van het afleveradres komt als chauffeursinformatie\n // bij het laadadres.\n tag('driverinfo', j.delivery_name),\n ],\n ' ',\n);\n\nconst delivery = block(\n 'deliveryaddress',\n [\n emptyTag('address_id', 'matchmode=\"11\"'),\n tag('date', deliveryDate),\n tag('datetill', deliveryDateTill),\n tag('time', j.delivery_time),\n tag('timetill', j.delivery_time_till),\n tag('name', j.delivery_name),\n tag('address1', j.delivery_address1),\n tag('zipcode', j.delivery_zipcode),\n tag('city_id', j.delivery_city, 'matchmode=\"4\"'),\n tag('country_id', j.delivery_country, 'matchmode=\"2\"'),\n\n // Transportnummer is ook de losreferentie.\n tag('reference', j.transport_number),\n\n // Vaste Lidl-instructie bij het losadres.\n tag('driverinfo', CFG.deliveryDriverInfo),\n ],\n ' ',\n);\n\nconst sender = block(\n 'sender',\n [\n tag('name', j.pickup_name),\n tag('address1', j.pickup_address1),\n tag('zipcode', j.pickup_zipcode),\n tag('city_id', j.pickup_city, 'matchmode=\"4\"'),\n tag('country_id', j.pickup_country, 'matchmode=\"2\"'),\n tag('phone', j.pickup_phone),\n ],\n ' ',\n);\n\nconst receiver = block(\n 'receiver',\n [\n tag('name', j.delivery_name),\n tag('address1', j.delivery_address1),\n tag('zipcode', j.delivery_zipcode),\n tag('city_id', j.delivery_city, 'matchmode=\"4\"'),\n tag('country_id', j.delivery_country, 'matchmode=\"2\"'),\n ],\n ' ',\n);\n\nconst cargo = block(\n 'cargo',\n [\n tag('colli', j.colli),\n tag(\n 'unitamount',\n j.load_carrier_count ?? j.transport_units,\n ),\n tag(\n 'unit_id',\n j.load_carrier_type,\n `matchmode=\"${CFG.unitMatchmode}\"`,\n ),\n tag('product_id', j.product_group, 'matchmode=\"1\"'),\n tag('productdescription', j.product_group),\n tag('weight', j.gross_weight),\n tag('mintemperature', j.min_temperature),\n tag('maxtemperature', j.max_temperature),\n ],\n ' ',\n);\n\nconst includePakbon =\n !j.is_cancelled &&\n Boolean(pakbonBase64) &&\n Boolean(j.pakbon_file_name);\n\nlet documents = '';\n\nif (includePakbon) {\n documents = [\n ' ',\n ' ',\n ` ${tag(\n 'documenttype_id',\n CFG.documentTypeId,\n `matchmode=\"${CFG.documentTypeMatchmode}\"`,\n )}`,\n ` ${tag('filename', j.pakbon_file_name)}`,\n ` ${tag('filedata', pakbonBase64)}`,\n ` ${tag('reference', j.transport_number)}`,\n ` ${tag('concerns', 'Pakbon')}`,\n ' ',\n ' ',\n ].join('\\n');\n}\n\nconst xmlLines = [\n '',\n '',\n ' ',\n\n ...rootOptional.map(value => ` ${value}`),\n\n ` ${tag(\n 'ediproviderstatus_id',\n j.transpas_action,\n 'matchmode=\"1\"',\n )}`,\n\n ` ${tag('edireference', j.transport_number)}`,\n ` ${tag('reference', j.transport_number)}`,\n ` ${tag(\n 'customer_id',\n CFG.customerId,\n 'matchmode=\"1\"',\n )}`,\n\n ...bookingOptional.map(value => ` ${value}`),\n\n ' ',\n ' ',\n\n ` ${tag(\n 'plangroup_id',\n '17',\n 'matchmode=\"0\"',\n )}`,\n\n ` ${tag(\n 'edireference',\n j.transport_number,\n )}`,\n\n ` ${tag(\n 'reference',\n j.transport_number,\n )}`,\n\n ` ${tag(\n 'cancellation',\n j.is_cancelled ? 'true' : 'false',\n )}`,\n\n ...shipmentOptional.map(value => ` ${value}`),\n\n sender,\n receiver,\n pickup,\n delivery,\n cargo,\n documents,\n\n ' ',\n ' ',\n ' ',\n '',\n].filter(Boolean);\n\nconst xml = xmlLines.join('\\n');\n\nconst stamp = $now\n .setZone('Europe/Amsterdam')\n .toFormat('yyyyLLdd_HHmmss_SSS');\n\nconst xmlFileName =\n `${stamp}_` +\n `${safeFilePart(j.transport_number)}_` +\n `R${j.revision}_` +\n `${j.transpas_action}.xml`;\n\nconst ftpFolder = String(CFG.ftpFolder || '').replace(/\\/+$/, '');\n\nreturn {\n json: {\n order_id: j.order_id,\n transport_number: j.transport_number,\n revision: j.revision,\n payload_hash: j.payload_hash,\n transpas_action: j.transpas_action,\n is_cancelled: j.is_cancelled,\n\n pickup_date: pickupDate,\n delivery_date: deliveryDate,\n delivery_date_till: deliveryDateTill,\n\n pakbon_id: j.pakbon_id ?? null,\n pakbon_file_path: j.pakbon_file_path ?? null,\n document_included: includePakbon,\n\n xml_file_name: xmlFileName,\n ftp_path: `${ftpFolder}/${xmlFileName}`,\n xml,\n },\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3632, 320 ], "id": "13565bdb-edfc-4501-b165-741a6c86ddd6", "name": "Bouw Transpas TPE XML", "onError": "continueErrorOutput" }, { "parameters": { "operation": "executeQuery", "query": "WITH input AS (\n SELECT $1::jsonb AS j\n),\nlogged AS (\n INSERT INTO public.lidl_fr_transpas_export_log AS existing (\n order_id, transport_number, revision, payload_hash, transpas_action,\n is_cancelled, document_id, document_included,\n xml_file_name, ftp_path, xml_payload, xml_payload_hash, xml_size_bytes,\n export_status\n )\n SELECT\n (j->>'order_id')::bigint,\n j->>'transport_number',\n (j->>'revision')::integer,\n j->>'payload_hash',\n j->>'transpas_action',\n (j->>'is_cancelled')::boolean,\n NULLIF(j->>'pakbon_id', '')::bigint,\n COALESCE((j->>'document_included')::boolean, false),\n j->>'xml_file_name',\n j->>'ftp_path',\n CASE\n WHEN COALESCE((j->>'document_included')::boolean, false) THEN NULL\n ELSE j->>'xml'\n END,\n md5(j->>'xml'),\n octet_length(j->>'xml')::bigint,\n 'pending'\n FROM input\n ON CONFLICT (transport_number, revision) DO UPDATE SET\n payload_hash = EXCLUDED.payload_hash,\n transpas_action = EXCLUDED.transpas_action,\n is_cancelled = EXCLUDED.is_cancelled,\n document_id = EXCLUDED.document_id,\n document_included = EXCLUDED.document_included,\n xml_file_name = EXCLUDED.xml_file_name,\n ftp_path = EXCLUDED.ftp_path,\n xml_payload = EXCLUDED.xml_payload,\n xml_payload_hash = EXCLUDED.xml_payload_hash,\n xml_size_bytes = EXCLUDED.xml_size_bytes,\n export_status = 'pending',\n error_text = NULL,\n attempt_count = existing.attempt_count + 1,\n updated_at = now()\n RETURNING id\n)\nSELECT\n (j->>'order_id')::bigint AS order_id,\n j->>'transport_number' AS transport_number,\n (j->>'revision')::integer AS revision,\n j->>'payload_hash' AS payload_hash,\n j->>'transpas_action' AS transpas_action,\n (j->>'is_cancelled')::boolean AS is_cancelled,\n NULLIF(j->>'pakbon_id', '')::bigint AS pakbon_id,\n COALESCE((j->>'document_included')::boolean, false) AS document_included,\n j->>'xml_file_name' AS xml_file_name,\n j->>'ftp_path' AS ftp_path,\n j->>'xml' AS xml,\n logged.id AS export_log_id\nFROM input, logged;", "options": { "queryReplacement": "={{ [JSON.stringify($json)] }}" } }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 3872, 304 ], "id": "0ce5c72a-9b85-477d-a1cf-f8814821411d", "name": "Log exportpoging", "credentials": { "postgres": { "id": "m4DQjg3b1iDYGlBp", "name": "Postgres Flower direct" } }, "onError": "continueErrorOutput" }, { "parameters": { "operation": "upload", "path": "={{ $json.ftp_path }}", "binaryData": false, "fileContent": "={{ $json.xml }}", "options": {} }, "type": "n8n-nodes-base.ftp", "typeVersion": 1, "position": [ 4176, 288 ], "id": "cddb120b-27ae-4e65-91f5-63e9a78e045d", "name": "Upload order naar Transpas FTP", "credentials": { "ftp": { "id": "oLAZ4OgmkOopMHAq", "name": "FTP De Wit Transport" } }, "onError": "continueErrorOutput" }, { "parameters": { "operation": "executeQuery", "query": "WITH input AS (\n SELECT $1::jsonb AS j\n),\nlog_update AS (\n UPDATE public.lidl_fr_transpas_export_log l\n SET\n export_status = 'exported',\n error_text = NULL,\n exported_at = now(),\n updated_at = now()\n FROM input\n WHERE l.id = (j->>'export_log_id')::bigint\n RETURNING l.id\n),\norder_update AS (\n UPDATE public.lidl_fr_orders o\n SET\n last_transpas_export_revision = (j->>'revision')::integer,\n last_transpas_export_hash = j->>'payload_hash',\n last_transpas_export_action = j->>'transpas_action',\n last_transpas_export_at = now(),\n last_transpas_export_file = j->>'xml_file_name',\n has_active_export = o.has_active_export OR NOT (j->>'is_cancelled')::boolean,\n last_error = NULL\n FROM input\n WHERE o.id = (j->>'order_id')::bigint\n AND o.revision = (j->>'revision')::integer\n RETURNING o.id\n),\ndocument_update AS (\n UPDATE public.lidl_fr_documents d\n SET\n last_included_in_order_at = now(),\n transpas_delivery_status = 'sent_with_order',\n waiting_for_order_since = NULL,\n transpas_error = NULL,\n updated_at = now()\n FROM input\n WHERE d.id = NULLIF(j->>'pakbon_id', '')::bigint\n AND COALESCE((j->>'document_included')::boolean, false) = true\n RETURNING d.id\n)\nSELECT\n j->>'transport_number' AS transport_number,\n (j->>'revision')::integer AS revision,\n j->>'transpas_action' AS transpas_action,\n j->>'xml_file_name' AS xml_file_name,\n COALESCE((SELECT count(*) FROM log_update), 0)::integer AS export_log_updated,\n COALESCE((SELECT count(*) FROM order_update), 0)::integer AS order_updated,\n COALESCE((SELECT count(*) FROM document_update), 0)::integer AS document_updated\nFROM input;", "options": { "queryReplacement": "={{ [JSON.stringify($('Log exportpoging').item.json)] }}" } }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 4576, 176 ], "id": "74d1eabc-f4c6-4bf3-9e54-393da0f7a5d0", "name": "Markeer export geslaagd", "credentials": { "postgres": { "id": "m4DQjg3b1iDYGlBp", "name": "Postgres Flower direct" } }, "onError": "continueErrorOutput" }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const original = $('Log exportpoging').item.json;\nconst error = $json.error ?? $json;\nconst errorText = error?.message ?? error?.description ?? JSON.stringify(error);\nreturn {\n json: {\n ...original,\n error_text: String(errorText).slice(0, 4000),\n },\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4464, 352 ], "id": "99f0884c-a60c-4b8d-b142-72176bf12618", "name": "Maak FTP foutmelding", "onError": "continueErrorOutput" }, { "parameters": { "operation": "executeQuery", "query": "WITH input AS (\n SELECT $1::jsonb AS j\n),\nlog_update AS (\n UPDATE public.lidl_fr_transpas_export_log l\n SET\n export_status = 'error',\n error_text = j->>'error_text',\n updated_at = now()\n FROM input\n WHERE l.id = (j->>'export_log_id')::bigint\n RETURNING l.id\n),\norder_update AS (\n UPDATE public.lidl_fr_orders o\n SET last_error = j->>'error_text'\n FROM input\n WHERE o.id = (j->>'order_id')::bigint\n RETURNING o.id\n)\nSELECT\n j->>'transport_number' AS transport_number,\n (j->>'revision')::integer AS revision,\n j->>'error_text' AS error_text,\n COALESCE((SELECT count(*) FROM log_update), 0)::integer AS export_log_updated,\n COALESCE((SELECT count(*) FROM order_update), 0)::integer AS order_updated\nFROM input;", "options": { "queryReplacement": "={{ [JSON.stringify($json)] }}" } }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 4752, 336 ], "id": "ed112513-d2d0-4f4c-ad41-adee052c4bff", "name": "Markeer exportfout", "credentials": { "postgres": { "id": "m4DQjg3b1iDYGlBp", "name": "Postgres Flower direct" } }, "onError": "continueErrorOutput" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "combinator": "and", "conditions": [ { "id": "47c2d0f6-3a28-484c-ae8f-dbf5b9667a9c", "leftValue": "={{ Boolean($json.pakbon_file_path) && !$json.is_cancelled }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ] }, "options": {} }, "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [ 3184, 224 ], "id": "4d787260-1d2c-47d6-b2ac-022636fecd5a", "name": "Met opgeslagen pakbon" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "combinator": "and", "conditions": [ { "id": "d0853ec1-0c52-4493-b2ab-c4dd7cabd921", "leftValue": "={{ !Boolean($json.pakbon_file_path) || $json.is_cancelled }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ] }, "options": {} }, "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [ 3312, 400 ], "id": "ebc1c06d-376d-4268-ba8b-a4014de05a94", "name": "Zonder pakbonbestand" }, { "parameters": { "fileSelector": "={{ $json.pakbon_file_path }}", "options": { "fileName": "={{ $json.pakbon_file_name }}", "mimeType": "={{ $json.pakbon_mime_type }}", "dataPropertyName": "pakbon" } }, "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1.1, "position": [ 3408, 224 ], "id": "b38a126c-6526-4122-909f-e783d9f582af", "name": "Lees pakbon van NAS", "onError": "continueErrorOutput" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "42b88165-8154-4ae5-ba77-f204a464daa4", "leftValue": "={{ $json.source_extension }}", "rightValue": "csv", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [ 1408, 400 ], "id": "5d1963ea-8bd8-44e8-974b-3e4d96f190f7", "name": "Bestand is CSV" }, { "parameters": { "jsCode": "// Detecteert het CSV-scheidingsteken en zet Windows-1252/ANSI om naar UTF-8.\nconst inputItems = $input.all();\nconst output = [];\n\n// Windows-1252 wijkt op deze posities af van ISO-8859-1.\nconst WINDOWS_1252 = {\n 0x80: '€',\n 0x82: '‚',\n 0x83: 'ƒ',\n 0x84: '„',\n 0x85: '…',\n 0x86: '†',\n 0x87: '‡',\n 0x88: 'ˆ',\n 0x89: '‰',\n 0x8A: 'Š',\n 0x8B: '‹',\n 0x8C: 'Œ',\n 0x8E: 'Ž',\n 0x91: '‘',\n 0x92: '’',\n 0x93: '“',\n 0x94: '”',\n 0x95: '•',\n 0x96: '–',\n 0x97: '—',\n 0x98: '˜',\n 0x99: '™',\n 0x9A: 'š',\n 0x9B: '›',\n 0x9C: 'œ',\n 0x9E: 'ž',\n 0x9F: 'Ÿ',\n};\n\nfunction decodeWindows1252(buffer) {\n let result = '';\n\n for (const byte of buffer) {\n if (byte < 0x80 || byte >= 0xA0) {\n result += String.fromCharCode(byte);\n } else {\n result += WINDOWS_1252[byte] ?? String.fromCharCode(byte);\n }\n }\n\n return result;\n}\n\nfunction decodeCsv(buffer) {\n // Een ongeldige UTF-8-byte wordt door Node als � weergegeven.\n const utf8 = buffer.toString('utf8');\n\n if (!utf8.includes('\\uFFFD')) {\n return {\n text: utf8,\n encoding: 'utf-8',\n };\n }\n\n return {\n text: decodeWindows1252(buffer),\n encoding: 'windows-1252',\n };\n}\n\nfunction countOutsideQuotes(line, delimiter) {\n let count = 0;\n let quoted = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n\n if (char === '\"') {\n if (quoted && line[i + 1] === '\"') {\n i++;\n } else {\n quoted = !quoted;\n }\n\n continue;\n }\n\n if (!quoted && char === delimiter) {\n count++;\n }\n }\n\n return count;\n}\n\nfor (let index = 0; index < inputItems.length; index++) {\n const item = inputItems[index];\n const sourceBinary = item.binary?.data;\n\n if (!sourceBinary) {\n throw new Error(`Binary-veld 'data' ontbreekt bij item ${index + 1}.`);\n }\n\n const sourceBuffer =\n await this.helpers.getBinaryDataBuffer(index, 'data');\n\n const decoded = decodeCsv(sourceBuffer);\n const text = decoded.text.replace(/^\\uFEFF/, '');\n\n const firstLine =\n text\n .split(/\\r?\\n/)\n .find(line => line.trim() !== '') ??\n '';\n\n const candidates = [';', ',', '\\t'];\n\n const delimiter = candidates\n .map(value => ({\n value,\n count: countOutsideQuotes(firstLine, value),\n }))\n .sort((a, b) => b.count - a.count)[0];\n\n if (!delimiter || delimiter.count === 0) {\n throw new Error(\n `CSV-scheidingsteken kon niet worden bepaald voor ${\n item.json?.source_file_name ?? sourceBinary.fileName ?? 'het bestand'\n }.`\n );\n }\n\n // De volgende CSV-node ontvangt voortaan altijd echte UTF-8-bytes.\n const utf8Binary = await this.helpers.prepareBinaryData(\n Buffer.from(text, 'utf8'),\n sourceBinary.fileName ?? item.json?.source_file_name ?? 'transportopgaaf.csv',\n 'text/csv',\n );\n\n output.push({\n json: {\n ...(item.json ?? {}),\n csv_delimiter: delimiter.value,\n source_encoding: decoded.encoding,\n normalized_encoding: 'utf-8',\n },\n binary: {\n data: utf8Binary,\n },\n pairedItem: {\n item: index,\n },\n });\n}\n\nreturn output;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1632, 400 ], "id": "f8b5ca41-0887-4ded-a493-49a29a783dba", "name": "Detecteer CSV scheidingsteken", "notesInFlow": true, "onError": "continueErrorOutput", "notes": "Detecteert ; , of tab. Herkent daarnaast ongeldige UTF-8 en zet Windows-1252/ANSI eerst om naar UTF-8. Hierdoor blijven tekens zoals é, è, ç en apostroffen correct in PostgreSQL en XML." }, { "parameters": { "options": { "delimiter": "={{ $json.csv_delimiter }}", "encoding": "utf8", "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 1904, 384 ], "id": "ca5eeac7-94ec-4299-b785-152f774d6dda", "name": "Lees CSV op kolompositie", "notesInFlow": true, "onError": "continueErrorOutput", "notes": "Leest CSV zonder kopregel met het automatisch gedetecteerde scheidingsteken." }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "b4fb3c27-a621-4551-8ea0-cb8cf70817ca", "leftValue": "={{ $json.should_export }}", "rightValue": "", "operator": { "type": "boolean", "operation": "false", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [ 2960, 512 ], "id": "84799366-a4ce-4edd-a5b9-67e3233eae48", "name": "Alleen ongewijzigde revisies", "notesInFlow": true, "notes": "Ook een mail zonder nieuwe Transpas-export is correct verwerkt en krijgt daarom de groene categorie." }, { "parameters": { "rule": { "interval": [ { "field": "minutes" } ] } }, "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ 16, 416 ], "id": "685995b6-e0ed-4949-bbd8-d06eddd2f996", "name": "Controleer FTP elke 5 minuten" }, { "parameters": { "assignments": { "assignments": [ { "id": "66b2d1fd-f300-48be-8521-310ffd0472c2", "name": "ftp_source_folder", "value": "/Flower direct/opdrachten", "type": "string" } ] }, "options": {} }, "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 240, 416 ], "id": "0267471f-1841-49c5-8fdc-c62f28df5de8", "name": "FTP bronmap instellen", "notesInFlow": true, "notes": "Vul hier de FTP-map in waarin Lidl de XLSX-, XLS- of CSV-bestanden plaatst." }, { "parameters": { "operation": "list", "path": "={{ $json.ftp_source_folder }}", "options": {} }, "type": "n8n-nodes-base.ftp", "typeVersion": 1, "position": [ 464, 416 ], "id": "178fe9f0-1c61-4d19-b94b-18c784338601", "name": "Toon bestanden in FTP-map", "credentials": { "ftp": { "id": "oLAZ4OgmkOopMHAq", "name": "FTP De Wit Transport" } } }, { "parameters": { "jsCode": "// Run Once for All Items\n// Houdt alleen echte XLSX-, XLS- en CSV-bestanden over.\n// Verwerkt bewust maximaal één bronbestand per workflow-run.\n// Daardoor kan dat bronbestand na alle zendingregels precies één keer worden verplaatst.\n\nconst items = $input.all();\nconst candidates = [];\n\nfunction joinPath(folder, name) {\n return `${String(folder ?? '').replace(/\\/+$/, '')}/${String(name ?? '').replace(/^\\/+/, '')}`;\n}\n\nfunction timestamp(value) {\n const date = new Date(value ?? 0);\n return Number.isNaN(date.getTime()) ? 0 : date.getTime();\n}\n\nfor (let index = 0; index < items.length; index++) {\n const item = items[index];\n const j = item.json ?? {};\n\n const type = String(j.type ?? j.entryType ?? '').toLowerCase();\n const isDirectory =\n j.isDirectory === true ||\n j.directory === true ||\n ['directory', 'dir', 'd'].includes(type);\n\n if (isDirectory) continue;\n\n const fileName = String(\n j.name ??\n j.fileName ??\n String(j.path ?? '').split('/').pop() ??\n ''\n ).trim();\n\n if (!fileName) continue;\n\n const extension = String(fileName.split('.').pop() ?? '')\n .toLowerCase()\n .trim();\n\n if (!['xlsx', 'xls', 'csv'].includes(extension)) continue;\n\n const folder = String(\n j.ftp_source_folder ??\n $('FTP bronmap instellen').first().json.ftp_source_folder\n );\n\n candidates.push({\n itemIndex: index,\n sortTime: timestamp(\n j.modifiedTime ??\n j.modified_at ??\n j.modified ??\n j.date ??\n j.timestamp\n ),\n fileName,\n json: {\n ...j,\n source_file_name: fileName,\n source_extension: extension,\n source_ftp_path: joinPath(folder, fileName),\n },\n });\n}\n\n// Oudste bestand eerst. Bij ontbrekende datums wordt op bestandsnaam gesorteerd.\ncandidates.sort((a, b) =>\n (a.sortTime - b.sortTime) ||\n a.fileName.localeCompare(b.fileName)\n);\n\nconst selected = candidates[0];\n\nif (!selected) {\n return [];\n}\n\nreturn [{\n json: selected.json,\n pairedItem: { item: selected.itemIndex },\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 688, 416 ], "id": "342f1aa0-48cb-4635-8744-467be0439c1d", "name": "Selecteer FTP-bronbestanden", "notesInFlow": true, "notes": "Selecteert maximaal één CSV/XLS/XLSX per uitvoering. Zo kan één bronbestand met meerdere zendingregels na afloop maar één keer worden verplaatst." }, { "parameters": { "path": "={{ $json.source_ftp_path }}", "options": {} }, "type": "n8n-nodes-base.ftp", "typeVersion": 1, "position": [ 912, 416 ], "id": "007781e2-68d5-4e3e-b991-86fc501d2f25", "name": "Download bestand van FTP", "credentials": { "ftp": { "id": "oLAZ4OgmkOopMHAq", "name": "FTP De Wit Transport" } } }, { "parameters": { "content": "## FTP-bron\n\n1. De workflow leest `.xlsx`, `.xls` en `.csv`.\n2. Per workflow-run wordt bewust **één bronbestand** verwerkt.\n3. Het bestand mag meerdere zendingregels bevatten.\n4. Na succesvolle verwerking wordt het bronbestand **één keer** verplaatst naar `verwerkt`.\n5. Een volgend bestand wordt bij de volgende geplande uitvoering opgepakt.", "height": 260, "width": 500 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 0, 0 ], "id": "99f22c78-9c6f-4853-bb9a-8578f5e32c52", "name": "FTP bron uitleg" }, { "parameters": { "operation": "rename", "oldPath": "={{ $('Selecteer Excel-bijlagen').item.json.source_ftp_path }}", "newPath": "={{ $('FTP bronmap instellen').first().json.ftp_source_folder + '/verwerkt/' + $('Selecteer Excel-bijlagen').item.json.source_file_name }}", "options": {} }, "type": "n8n-nodes-base.ftp", "typeVersion": 1.1, "position": [ 4784, 80 ], "id": "3984f2f8-7ca4-4e5a-abe2-025818327ef9", "name": "Verplaats bronbestand naar verwerkt", "executeOnce": true, "notesInFlow": true, "credentials": { "ftp": { "id": "oLAZ4OgmkOopMHAq", "name": "FTP De Wit Transport" } }, "notes": "Voert de rename maar één keer uit. De bronselectie verwerkt maximaal één bestand per workflow-run." } ], "pinData": {}, "connections": { "Selecteer Excel-bijlagen": { "main": [ [ { "node": "Bestand is CSV", "type": "main", "index": 0 } ] ] }, "Bestand is CSV": { "main": [ [ { "node": "Detecteer CSV scheidingsteken", "type": "main", "index": 0 } ] ] }, "Detecteer CSV scheidingsteken": { "main": [ [ { "node": "Lees CSV op kolompositie", "type": "main", "index": 0 } ] ] }, "Lees CSV op kolompositie": { "main": [ [ { "node": "Normaliseer en valideer orders", "type": "main", "index": 0 } ] ] }, "Normaliseer en valideer orders": { "main": [ [ { "node": "Upsert order status en zoek pakbon", "type": "main", "index": 0 } ] ] }, "Upsert order status en zoek pakbon": { "main": [ [ { "node": "Alleen nieuwe of gewijzigde revisies", "type": "main", "index": 0 }, { "node": "Alleen ongewijzigde revisies", "type": "main", "index": 0 } ] ] }, "Alleen nieuwe of gewijzigde revisies": { "main": [ [ { "node": "Met opgeslagen pakbon", "type": "main", "index": 0 }, { "node": "Zonder pakbonbestand", "type": "main", "index": 0 } ] ] }, "Met opgeslagen pakbon": { "main": [ [ { "node": "Lees pakbon van NAS", "type": "main", "index": 0 } ] ] }, "Lees pakbon van NAS": { "main": [ [ { "node": "Bouw Transpas TPE XML", "type": "main", "index": 0 } ] ] }, "Zonder pakbonbestand": { "main": [ [ { "node": "Bouw Transpas TPE XML", "type": "main", "index": 0 } ] ] }, "Bouw Transpas TPE XML": { "main": [ [ { "node": "Log exportpoging", "type": "main", "index": 0 } ] ] }, "Log exportpoging": { "main": [ [ { "node": "Upload order naar Transpas FTP", "type": "main", "index": 0 } ] ] }, "Upload order naar Transpas FTP": { "main": [ [ { "node": "Markeer export geslaagd", "type": "main", "index": 0 } ], [ { "node": "Maak FTP foutmelding", "type": "main", "index": 0 } ] ] }, "Maak FTP foutmelding": { "main": [ [ { "node": "Markeer exportfout", "type": "main", "index": 0 } ] ] }, "Controleer FTP elke 5 minuten": { "main": [ [ { "node": "FTP bronmap instellen", "type": "main", "index": 0 } ] ] }, "FTP bronmap instellen": { "main": [ [ { "node": "Toon bestanden in FTP-map", "type": "main", "index": 0 } ] ] }, "Toon bestanden in FTP-map": { "main": [ [ { "node": "Selecteer FTP-bronbestanden", "type": "main", "index": 0 } ] ] }, "Selecteer FTP-bronbestanden": { "main": [ [ { "node": "Download bestand van FTP", "type": "main", "index": 0 } ] ] }, "Download bestand van FTP": { "main": [ [ { "node": "Selecteer Excel-bijlagen", "type": "main", "index": 0 } ] ] }, "Markeer export geslaagd": { "main": [ [ { "node": "Verplaats bronbestand naar verwerkt", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1", "binaryMode": "separate", "availableInMCP": false }, "versionId": "4f45ad38-9fb7-4099-be89-17417dcdf73a", "meta": { "instanceId": "bef8d409866a58c0777dfe7cca1b9c2400fd051c056d361501393ab423006b5f" }, "nodeGroups": [], "id": "LmKzYJsq8CIGXvYJ", "tags": [] }