Files
n8n-workflows/Flower_direct/Lidl Frankrijk - Opdracht naar Postgres en Transpas.json
T

808 lines
57 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,
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 '&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": [
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": []
}