Files
n8n-workflows/Flower_direct/Lidl Frankrijk - Opdracht naar Postgres en Transpas.json
T
2026-08-06 20:19:41 +02:00

890 lines
60 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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,
576
],
"id": "798a0f29-9cfd-4e9e-951b-ef9ab1252c2e",
"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": [
2032,
576
],
"id": "d28f2932-ef00-494c-b882-8797e5823abf",
"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)\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 LATERAL (\n SELECT doc.*\n FROM public.lidl_fr_documents doc\n WHERE doc.transport_number = c.transport_number\n AND doc.document_kind = 'PAKBON'\n AND doc.active = true\n AND doc.storage_status = 'stored'\n AND doc.file_path IS NOT NULL\n ORDER BY doc.received_at DESC, doc.id DESC\n LIMIT 1\n) d ON true;",
"options": {
"queryReplacement": "={{ [JSON.stringify($json)] }}"
}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
2256,
576
],
"id": "65f00226-090b-45a2-a401-493fd409474c",
"name": "Upsert order status en zoek pakbon",
"notesInFlow": true,
"credentials": {
"postgres": {
"id": "m4DQjg3b1iDYGlBp",
"name": "Postgres Flower direct"
}
},
"onError": "continueErrorOutput",
"notes": "Documentselectie is per transportnummer gecorreleerd met LEFT JOIN LATERAL. Hierdoor kan een document van een andere order niet meer als nieuwste document voor alle orders worden hergebruikt."
},
{
"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": [
2704,
240
],
"id": "fc63eb5b-448a-4d40-a6c4-9b21a516c261",
"name": "Alleen nieuwe of gewijzigde revisies"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Per item uitvoeren.\n// Deze node ontvangt binnen de loop exact één order.\n// Bij een order met pakbon is de order-JSON door de Merge-node gecombineerd\n// met binary.pakbon. Bij een order zonder pakbon komt de order direct binnen.\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 inputItem = $input.item;\nconst j = inputItem.json ?? {};\n\nif (!j.order_id) {\n throw new Error('Order-ID ontbreekt in de huidige loop-iteratie.');\n}\n\nif (!j.transport_number) {\n throw new Error(`Transportnummer ontbreekt voor order ${j.order_id}.`);\n}\n\nif (\n j.pakbon_id &&\n !j.is_cancelled &&\n !String(j.pakbon_file_path ?? '').trim()\n) {\n throw new Error(\n `Pakbonpad ontbreekt voor transportnummer ${j.transport_number}.`,\n );\n}\n\nfunction xmlEsc(value) {\n return String(value ?? '')\n .replace(\n /&(?!amp;|lt;|gt;|quot;|apos;|#\\d+;|#x[0-9A-Fa-f]+;)/g,\n '&amp;',\n )\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\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)}</${name}>`;\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}</${name}>`,\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 ' <documents>',\n ' <document>',\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 ' </document>',\n ' </documents>',\n ].join('\\n');\n}\n\nconst xmlLines = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n '<transportbookings>',\n ' <transportbooking>',\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 ' <shipments>',\n ' <shipment>',\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 ' </shipment>',\n ' </shipments>',\n ' </transportbooking>',\n '</transportbookings>',\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": [
3600,
240
],
"id": "35b1334e-b9fc-47d0-8e0d-d0b15006ac43",
"name": "Bouw Transpas TPE XML",
"notesInFlow": true,
"onError": "continueErrorOutput",
"notes": "Ontvangt per iteratie één order. Bij een pakbon bevat hetzelfde item zowel de juiste order-JSON als binary.pakbon."
},
{
"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": [
3824,
240
],
"id": "9fe032cb-b9c3-47c1-ba49-50b8f74e7e76",
"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": [
4048,
240
],
"id": "6c961ced-bbed-4366-b056-25f798d608da",
"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": [
4272,
240
],
"id": "67539e73-d05a-4ed6-8a95-1f9362c30f63",
"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": [
4272,
672
],
"id": "36e474cd-f4aa-4e10-bce1-803f8bb3cf23",
"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": [
4496,
672
],
"id": "7ac9974c-3c70-47c8-a5f1-89b5de72cf1a",
"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": [
2928,
144
],
"id": "a2edfac1-53f7-48db-909f-b1ff0eee2ea8",
"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": [
3376,
432
],
"id": "7e99ea48-ec49-45ad-b118-ac09f750a2f8",
"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": [
3184,
-32
],
"id": "bbcf61d6-f82c-463e-970f-f01eb7ee6612",
"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": [
1360,
576
],
"id": "d723f334-765a-46fd-8832-9b7a9fb3c45f",
"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": [
1584,
576
],
"id": "7d11ffdd-b21f-4e02-b52f-9b3833ac43fc",
"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": [
1808,
576
],
"id": "26c9ac30-a1c9-483d-b9cf-9df73dbd7f3c",
"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": [
2704,
432
],
"id": "4d3d3c22-20fe-4704-ba01-8ca02895a694",
"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,
576
],
"id": "73618086-2fda-4f64-8f61-6b8bd103b559",
"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,
576
],
"id": "ba1e6552-09b5-4892-a405-f4ab64ec8add",
"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,
576
],
"id": "16229f15-ce52-42e8-b458-76aff2d5646c",
"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,
576
],
"id": "f08457e0-77bf-44a3-8325-960526ce4c7e",
"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,
576
],
"id": "fb43da1c-4b18-4a6f-9087-a761df10ed72",
"name": "Download bestand van FTP",
"credentials": {
"ftp": {
"id": "oLAZ4OgmkOopMHAq",
"name": "FTP De Wit Transport"
}
}
},
{
"parameters": {
"content": "## Verwerking per order\n\n1. Per uitvoering wordt één CSV/XLS/XLSX opgehaald.\n2. De orders uit het bestand worden met batchgrootte **1** verwerkt.\n3. Per order wordt apart gecontroleerd of een pakbon aanwezig is.\n4. Bij een pakbon worden order-JSON en binary bestand binnen dezelfde iteratie samengevoegd.\n5. Pas als de loop volledig klaar is, wordt het bronbestand één keer naar `verwerkt` verplaatst.\n6. Bij een exportfout wordt niet naar de loop teruggekeerd en blijft het bronbestand staan.",
"height": 260,
"width": 500
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
0
],
"id": "150cb6c4-801b-4240-b008-4e2c874cd728",
"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": {
"createDirectories": false
}
},
"type": "n8n-nodes-base.ftp",
"typeVersion": 1.1,
"position": [
2704,
624
],
"id": "4ce36db8-27c5-47de-a94e-d985bb22d376",
"name": "Verplaats bronbestand naar verwerkt",
"executeOnce": true,
"notesInFlow": true,
"credentials": {
"ftp": {
"id": "oLAZ4OgmkOopMHAq",
"name": "FTP De Wit Transport"
}
},
"notes": "Wordt alleen vanaf de 'done'-uitgang van de orderloop uitgevoerd. Het CSV-bestand wordt dus pas na alle succesvol verwerkte/ongwijzigde orders één keer verplaatst."
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
2480,
576
],
"id": "b24f676a-73c6-49ec-9b5a-4f47706d041c",
"name": "Verwerk orders één voor één",
"notesInFlow": true,
"notes": "Verwerkt steeds precies één order. Pas nadat deze order volledig klaar is, wordt de volgende order uit het CSV-bestand gestart."
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3376,
144
],
"id": "64f5e12b-2d12-41fa-ba03-86056c187fe3",
"name": "Combineer order met gelezen pakbon",
"notesInFlow": true,
"notes": "Combineert binnen de loop de JSON van de huidige order met het binary pakbonbestand. Omdat de loop batchgrootte 1 is, kunnen order en document niet met een andere order mengen."
}
],
"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": "Verwerk orders één voor één",
"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": "Combineer order met gelezen pakbon",
"type": "main",
"index": 0
},
{
"node": "Lees pakbon van NAS",
"type": "main",
"index": 0
}
]
]
},
"Lees pakbon van NAS": {
"main": [
[
{
"node": "Combineer order met gelezen pakbon",
"type": "main",
"index": 1
}
]
]
},
"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": "Verwerk orders één voor één",
"type": "main",
"index": 0
}
]
]
},
"Verwerk orders één voor één": {
"main": [
[
{
"node": "Verplaats bronbestand naar verwerkt",
"type": "main",
"index": 0
}
],
[
{
"node": "Alleen nieuwe of gewijzigde revisies",
"type": "main",
"index": 0
},
{
"node": "Alleen ongewijzigde revisies",
"type": "main",
"index": 0
}
]
]
},
"Combineer order met gelezen pakbon": {
"main": [
[
{
"node": "Bouw Transpas TPE XML",
"type": "main",
"index": 0
}
]
]
},
"Alleen ongewijzigde revisies": {
"main": [
[
{
"node": "Verwerk orders één voor één",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "98e992f4-4db0-441d-87a8-e42df73acb21",
"meta": {
"instanceId": "bef8d409866a58c0777dfe7cca1b9c2400fd051c056d361501393ab423006b5f"
},
"nodeGroups": [],
"id": "bA4pz9VBLu3t8tX0",
"tags": []
}