Files

135 lines
16 KiB
JSON

{
"name": "Systematic",
"nodes": [
{
"parameters": {
"formTitle": "Systematic File upload",
"formFields": {
"values": [
{
"fieldLabel": "Upload file",
"fieldType": "file",
"fieldName": "uploadFile"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.5,
"position": [
-64,
0
],
"id": "441716c7-ed61-47db-aff7-dd8ed680c747",
"name": "On form submission",
"webhookId": "f6c9a170-d2b5-4d4b-8c44-44bff00a6e27"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "=uploadFile",
"options": {
"headerRow": true,
"range": "1"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1,
"position": [
96,
0
],
"id": "95e801c3-d23f-4bb4-af50-941cdcfbb059",
"name": "Extract from File1"
},
{
"parameters": {
"jsCode": "// n8n Code node (Run once for all items)\n// Input: items (1 per Excel-rij) OF 1 item met array van rijen\n// Output: meerdere items -> { filename, xml, reference }\n\n// ======= vaste Transpas pickup address_id =======\nconst PICKUP_ADDRESS_ID = \"69110\"; // Systematic BV\n\nconst inItems = $input.all();\nlet rows = [];\n\n// 1) input kan zijn: 1 item met array als .json, of meerdere items met .json per rij\nif (inItems.length === 1 && Array.isArray(inItems[0].json)) {\n rows = inItems[0].json;\n} else {\n rows = inItems.map(i => i.json);\n}\n\n// ---------- helpers ----------\nconst AMP_FIX_RE = /&(?!amp;|lt;|gt;|quot;|apos;|#\\d+;|#x[0-9A-Fa-f]+;)/g;\nfunction xmlEscape(str) {\n return String(str ?? \"\")\n .replace(AMP_FIX_RE, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n}\n\nfunction isBlankRow(r) {\n return Object.values(r || {}).every(v => v === null || v === undefined || String(v).trim() === \"\");\n}\n\nfunction isTotalRow(r) {\n const eta = r?.[\"ETD start\"];\n return typeof eta === \"string\" && eta.trim().toLowerCase() === \"totaal\";\n}\n\nfunction isDimsOnlyRow(r) {\n const hasDims = r?.[\"Breedte\"] != null || r?.[\"Hoogte\"] != null || r?.[\"Lengte\"] != null;\n const hasRef = !!getReference(r);\n const hasLos =\n !!(r?.[\"Los naam\"] || r?.[\"Los land\"] || r?.[\"Los postcode\"] || r?.[\"Los plaats\"] || r?.[\"Los adresregel\"]);\n const hasEtaEtd = r?.[\"ETA start\"] != null || r?.[\"ETD start\"] != null;\n return hasDims && !hasRef && !hasLos && !hasEtaEtd;\n}\n\nfunction num(v) {\n const n = parseFloat(String(v ?? \"\").replace(\",\", \".\"));\n return Number.isFinite(n) ? n : null;\n}\n\n// Excel serial (1900 date system) -> YYYY-MM-DD\nfunction excelDateToISO(val) {\n if (typeof val !== \"number\" || !Number.isFinite(val)) return \"\";\n const base = new Date(Date.UTC(1899, 11, 30)); // Excel epoch\n const d = new Date(base.getTime() + val * 86400000);\n return d.toISOString().slice(0, 10);\n}\n\n// Format Date -> YYYY-MM-DD (zonder UTC-dagverschuiving)\nfunction formatDate(d) {\n const yyyy = d.getFullYear();\n const mm = String(d.getMonth() + 1).padStart(2, \"0\");\n const dd = String(d.getDate()).padStart(2, \"0\");\n return `${yyyy}-${mm}-${dd}`;\n}\n\nfunction getNextWorkday(date) {\n const d = new Date(date);\n do {\n d.setDate(d.getDate() + 1);\n } while (d.getDay() === 0 || d.getDay() === 6);\n return d;\n}\n\nfunction pickFirstNonEmpty(...vals) {\n for (const v of vals) {\n if (v !== null && v !== undefined && String(v).trim() !== \"\") return String(v).trim();\n }\n return \"\";\n}\n\nfunction joinAddressLines(...lines) {\n return lines.map(l => String(l ?? \"\").trim()).filter(Boolean).join(\" \");\n}\n\n// >>> BELANGRIJK: jouw input gebruikt \"Nummer\" i.p.v. \"Referentie\"\nfunction getReference(r) {\n return pickFirstNonEmpty(\n r?.[\"Referentie\"],\n r?.[\"Nummer\"], // <-- jouw bestand\n r?.[\"Reference\"],\n r?.[\"Ref\"]\n );\n}\n\n// Pallettype op footprint: Breedte x Lengte\nfunction palletUnitId(width, length) {\n const w = Number(width);\n const l = Number(length);\n if (!Number.isFinite(w) || !Number.isFinite(l)) return \"PW pallet\";\n\n const pair = `${w}x${l}`;\n const pairRev = `${l}x${w}`;\n\n if (pair === \"80x120\" || pairRev === \"80x120\") return \"Euroformaat\";\n if (pair === \"100x120\" || pairRev === \"100x120\") return \"Blokpallet\";\n return \"PW pallet\";\n}\n\nfunction extractDims(r) {\n return {\n width: num(r[\"Breedte\"]),\n height: num(r[\"Hoogte\"]),\n length: num(r[\"Lengte\"]),\n stackable: num(r[\"Stapelbaar\"]) ?? 0\n };\n}\n\n// ---------- 1) schoonmaken + groeperen (dims-only aan vorige koppelen) ----------\nconst shipments = [];\nlet current = null;\n\nfor (const r of rows) {\n if (!r || isBlankRow(r) || isTotalRow(r)) continue;\n\n const ref = getReference(r);\n\n if (ref) {\n current = { main: r, dims: [extractDims(r)], reference: ref };\n shipments.push(current);\n continue;\n }\n\n if (isDimsOnlyRow(r) && current) {\n current.dims.push(extractDims(r));\n continue;\n }\n // overige rommelregels: negeren (zoals \"Aangemaakt op ...\")\n}\n\n// Debug: als er niets overblijft, geef 1 item terug met info ipv \"geen output\"\nif (!shipments.length) {\n return [{\n json: {\n error: \"Geen shipments aangemaakt (geen Referentie/Nummer gevonden).\",\n rowCount: rows.length,\n firstRowKeys: rows[0] ? Object.keys(rows[0]) : []\n }\n }];\n}\n\n// ---------- 2) per shipment -> Transpas XML ----------\nconst CUSTOMER_ID = \"28834\";\nconst DEBTOR_ID = \"28834\";\nconst DEPARTMENT_ID = \"Dep DeWit\";\nconst PLANGROUP_ID = \"2\";\nconst PRODUCT_ID = \"123\";\nconst PRODUCT_DESC = \"General Cargo\";\n\nfunction splitWeight(total, n) {\n const t = Number(total);\n if (!Number.isFinite(t) || n <= 0) return Array(n).fill(\"\");\n if (n === 1) return [String(t)];\n\n const per = Math.round((t / n) * 1000) / 1000;\n const arr = Array(n).fill(per);\n const sum = arr.reduce((a, b) => a + b, 0);\n const diff = Math.round((t - sum) * 1000) / 1000;\n arr[n - 1] = Math.round((arr[n - 1] + diff) * 1000) / 1000;\n return arr.map(x => String(x));\n}\n\nreturn shipments.map(s => {\n const o = s.main;\n\n const reference = s.reference || getReference(o) || \"\";\n\n // ===== DATUMLOGICA AANGEPAST =====\n // Laden = eerstvolgende werkdag (vanaf vandaag)\n // Lossen = werkdag daarna (dus: eerstvolgende werkdag + 1 werkdag)\n const base = new Date();\n base.setHours(12, 0, 0, 0); // voorkomt dagverschuiving door timezone/UTC\n const loadDateObj = getNextWorkday(base);\n const unloadDateObj = getNextWorkday(loadDateObj);\n\n const loadDate = formatDate(loadDateObj);\n const unloadDate = formatDate(unloadDateObj);\n // ================================\n\n // laad info (je input heeft geen laad-adres/plaats/postcode -> blijft leeg behalve naam/land)\n const pickupName = pickFirstNonEmpty(o[\"Laad naam\"]);\n const pickupCountry = pickFirstNonEmpty(o[\"Laad land\"], \"NL\");\n\n // los info\n const deliveryName = pickFirstNonEmpty(o[\"Los naam\"]);\n const deliveryCountry = pickFirstNonEmpty(o[\"Los land\"]);\n const deliveryZip = pickFirstNonEmpty(o[\"Los postcode\"]);\n const deliveryCity = pickFirstNonEmpty(o[\"Los plaats\"]);\n const deliveryAddress1 = joinAddressLines(o[\"Los adresregel\"], o[\"Los adresregel_1\"], o[\"Los adresregel_2\"]);\n\n const deliveryEmail = pickFirstNonEmpty(o[\"Los contact email\"]);\n const deliveryPhone = pickFirstNonEmpty(o[\"Los contact telefoon\"], o[\"Los contact mobiel\"]);\n\n // instructions / book-in (op basis van teksten die er zijn)\n const instructions = [o[\"Los openingstijden\"], o[\"Los opmerking\"]]\n .map(v => String(v ?? \"\").trim())\n .filter(Boolean);\n\n const bookIn = instructions.some(t => /BOOKING|RDV|APPOINTMENT|AFSPRAAK|RENDEZ/i.test(t));\n const instructionsXml = instructions.map(t => ` <driverinfo>${xmlEscape(t)}</driverinfo>`).join(\"\\n\");\n\n // cargo totals\n const totalWeight = num(o[\"Totaal brutogewicht\"]) ?? 0;\n const totalColli = Math.max(1, Math.round(num(o[\"Totaal colli\"]) ?? 1));\n const totalLdm = num(o[\"Totaal laadmeter\"]);\n\n // goodslines (dims) -> exact aantal colli\n const dimsList = (s.dims || []).filter(d => d.width || d.length || d.height);\n\n let dimsForLines = dimsList.slice(0, totalColli);\n while (dimsForLines.length < totalColli) {\n dimsForLines.push(dimsForLines[dimsForLines.length - 1] || extractDims(o));\n }\n\n const weightsPerLine = splitWeight(totalWeight, totalColli);\n\n const unitTypes = dimsForLines.map(d => palletUnitId(d.width, d.length));\n const uniqueUnitTypes = [...new Set(unitTypes)];\n const cargoUnitId = uniqueUnitTypes.length === 1 ? uniqueUnitTypes[0] : \"Pallets\";\n\n const goodsLinesXml = dimsForLines.map((d, idx) => {\n const w = d.width ?? \"\";\n const h = d.height ?? \"\";\n const l = d.length ?? \"\";\n\n const vol =\n (d.width && d.height && d.length)\n ? ((d.width / 100) * (d.height / 100) * (d.length / 100)).toFixed(3)\n : \"\";\n\n const unitId = unitTypes[idx] || \"PW pallet\";\n\n return `\n <goodsline>\n <sequence>${idx + 1}</sequence>\n <edireference>${xmlEscape(reference)}</edireference>\n <unitamount>1</unitamount>\n <unit_id matchmode=\"1\">${xmlEscape(unitId)}</unit_id>\n <product_id matchmode=\"1\">${PRODUCT_ID}</product_id>\n <productdescription>${xmlEscape(PRODUCT_DESC)}</productdescription>\n <weight>${xmlEscape(weightsPerLine[idx])}</weight>\n <loadingmeter/>\n <volume>${xmlEscape(vol)}</volume>\n <colli/>\n <length>${xmlEscape(l)}</length>\n <width>${xmlEscape(w)}</width>\n <height>${xmlEscape(h)}</height>\n <reference>${xmlEscape(reference)}</reference>\n <dangerousgoods></dangerousgoods>\n <qty1/>\n </goodsline>`;\n }).join(\"\");\n\n const cargoXml = `\n <cargo>\n <unitamount>${totalColli}</unitamount>\n <unit_id matchmode=\"1\">${xmlEscape(cargoUnitId)}</unit_id>\n <product_id matchmode=\"1\">${PRODUCT_ID}</product_id>\n <productdescription>${xmlEscape(PRODUCT_DESC)}</productdescription>\n <weight>${xmlEscape(totalWeight)}</weight>\n // <loadingmeter>${totalLdm != null ? xmlEscape(totalLdm) : \"\"}</loadingmeter>\n <bool1>true</bool1>\n <bool2>true</bool2>\n <mintemperature/>\n <maxtemperature/>\n <barcode/>\n <goodslines>\n ${goodsLinesXml}\n </goodslines>\n </cargo>`;\n\n const xml = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<transportbookings>\n <transportbooking>\n <edireference>${xmlEscape(reference)}</edireference>\n <reference>${xmlEscape(reference)}</reference>\n <customer_id matchmode=\"8\">${CUSTOMER_ID}</customer_id>\n <debtor_id matchmode=\"8\">${DEBTOR_ID}</debtor_id>\n <department_id matchmode=\"3\">${xmlEscape(DEPARTMENT_ID)}</department_id>\n <shipments>\n <shipment>\n <edireference>${xmlEscape(reference)}</edireference>\n <reference>${xmlEscape(reference)}</reference>\n <plangroup_id matchmode=\"0\">${PLANGROUP_ID}</plangroup_id>\n <version/>\n <sender>\n <address_id matchmode=\"0\" overwrite=\"false\">${PICKUP_ADDRESS_ID}</address_id>\n <name>${xmlEscape(pickupName)}</name>\n <address1></address1>\n <zipcode></zipcode>\n <city_id matchmode=\"4\"></city_id>\n <country_id matchmode=\"2\">${xmlEscape(pickupCountry)}</country_id>\n </sender>\n <receiver>\n <address_id matchmode=\"5\" overwrite=\"false\"/>\n <name>${xmlEscape(deliveryName)}</name>\n <address1>${xmlEscape(deliveryAddress1)}</address1>\n <zipcode>${xmlEscape(deliveryZip)}</zipcode>\n <city_id matchmode=\"4\">${xmlEscape(deliveryCity)}</city_id>\n <country_id matchmode=\"2\">${xmlEscape(deliveryCountry)}</country_id>\n </receiver>\n <pickupaddress>\n <address_id matchmode=\"0\" overwrite=\"true\">${PICKUP_ADDRESS_ID}</address_id>\n <reference>${xmlEscape(reference)}</reference>\n <date>${xmlEscape(loadDate)}</date>\n <time/>\n <datetill>${xmlEscape(loadDate)}</datetill>\n <timetill/>\n <name>${xmlEscape(pickupName)}</name>\n <address1></address1>\n <zipcode></zipcode>\n <city_id matchmode=\"4\"></city_id>\n <country_id matchmode=\"2\">${xmlEscape(pickupCountry)}</country_id>\n <contact></contact>\n <email></email>\n <phone></phone>\n${instructionsXml ? instructionsXml + \"\\n\" : \"\"} <remarks/>\n <neutraladdress_id matchmode=\"0\"/>\n <capabilities/>\n </pickupaddress>\n <deliveryaddress>\n <address_id matchmode=\"5\" overwrite=\"false\"/>\n <reference>${xmlEscape(reference)}</reference>\n <date>${xmlEscape(unloadDate)}</date>\n <time/>\n <datetill>${xmlEscape(unloadDate)}</datetill>\n <timetill/>\n <name>${xmlEscape(deliveryName)}</name>\n <address1>${xmlEscape(deliveryAddress1)}</address1>\n <zipcode>${xmlEscape(deliveryZip)}</zipcode>\n <city_id matchmode=\"4\">${xmlEscape(deliveryCity)}</city_id>\n <country_id matchmode=\"2\">${xmlEscape(deliveryCountry)}</country_id>\n <contact>${xmlEscape(deliveryName)}</contact>\n <email>${xmlEscape(deliveryEmail)}</email>\n <phone>${xmlEscape(deliveryPhone)}</phone>\n <remarks/>\n${instructionsXml ? instructionsXml + \"\\n\" : \"\"} <neutraladdress_id matchmode=\"0\"/>\n <capabilities/>\n </deliveryaddress>\n${cargoXml}\n </shipment>\n </shipments>\n </transportbooking>\n</transportbookings>`;\n\n return {\n json: {\n reference,\n filename: `sli_${reference}.xml`,\n xml\n }\n };\n});\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
288,
0
],
"id": "1b8bf47f-bc47-4169-b11a-21efc1b99248",
"name": "Build Transpas XML"
},
{
"parameters": {
"operation": "upload",
"path": "=/prod/totp/{{ $json.filename }}",
"binaryData": false,
"fileContent": "={{ $json.xml }}",
"options": {}
},
"type": "n8n-nodes-base.ftp",
"typeVersion": 1,
"position": [
544,
0
],
"id": "179f79c5-8ce3-46e7-802a-ae19b3ea9a64",
"name": "FTP1",
"credentials": {
"ftp": {
"id": "oLAZ4OgmkOopMHAq",
"name": "FTP De Wit Transport"
}
}
}
],
"pinData": {},
"connections": {
"Extract from File1": {
"main": [
[
{
"node": "Build Transpas XML",
"type": "main",
"index": 0
}
]
]
},
"Build Transpas XML": {
"main": [
[
{
"node": "FTP1",
"type": "main",
"index": 0
}
]
]
},
"On form submission": {
"main": [
[
{
"node": "Extract from File1",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false,
"timeSavedMode": "fixed",
"errorWorkflow": "1Ps5lukDf2sgcGL7",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "a5a249df-bb32-4171-afdf-5ae57ecf588e",
"meta": {
"instanceId": "bef8d409866a58c0777dfe7cca1b9c2400fd051c056d361501393ab423006b5f"
},
"nodeGroups": [],
"id": "JapyhfpdzChol1xDx0_eX",
"tags": []
}