{
  "name": "Synthetic RFQ file review",
  "nodes": [
    {
      "id": "node-0",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        250,
        300
      ],
      "parameters": {}
    },
    {
      "id": "node-1",
      "name": "Read RFQ PDF",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        470,
        300
      ],
      "parameters": {
        "operation": "read",
        "fileSelector": "/files/sample-rfq.pdf",
        "options": {
          "dataPropertyName": "data"
        }
      }
    },
    {
      "id": "node-2",
      "name": "Extract RFQ PDF",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.1,
      "position": [
        690,
        300
      ],
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "data",
        "options": {}
      }
    },
    {
      "id": "node-3",
      "name": "Read catalogue CSV",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        910,
        300
      ],
      "parameters": {
        "operation": "read",
        "fileSelector": "/files/catalogue.csv",
        "options": {
          "dataPropertyName": "data"
        }
      }
    },
    {
      "id": "node-4",
      "name": "Extract catalogue CSV",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.1,
      "position": [
        1130,
        300
      ],
      "parameters": {
        "operation": "csv",
        "binaryPropertyName": "data",
        "options": {
          "headerRow": true
        }
      }
    },
    {
      "id": "node-5",
      "name": "Collect catalogue rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1350,
        300
      ],
      "parameters": {
        "jsCode": "return [{json:{catalogue:$input.all().map(item=>item.json)}}];"
      }
    },
    {
      "id": "node-6",
      "name": "Read approved prices CSV",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        1570,
        300
      ],
      "parameters": {
        "operation": "read",
        "fileSelector": "/files/approved-prices.csv",
        "options": {
          "dataPropertyName": "data"
        }
      }
    },
    {
      "id": "node-7",
      "name": "Extract approved prices CSV",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.1,
      "position": [
        1790,
        300
      ],
      "parameters": {
        "operation": "csv",
        "binaryPropertyName": "data",
        "options": {
          "headerRow": true
        }
      }
    },
    {
      "id": "node-8",
      "name": "Match RFQ against approved sources",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2010,
        300
      ],
      "parameters": {
        "jsCode": "const pdf = $('Extract RFQ PDF').first().json;\nconst text = String(pdf.text || '').trim();\nif (!text) throw new Error('The PDF contains no extractable text. Supply the agreed text PDF layout.');\nif (Number(pdf.numpages || pdf.numPages || 1) > 2) throw new Error('The agreed PDF limit is two pages.');\nconst lines = text.split(/\\r?\\n/).map(line => line.trim()).filter(Boolean);\nif (!/^RFQ synthetic fixture v\\d+(?:.*)?$/.test(lines[0])) throw new Error('Unsupported PDF layout. This demonstration accepts the numbered, six-column synthetic RFQ layout.');\nconst sourceLines = lines.slice(1);\nif (!sourceLines.length || sourceLines.length > 50) throw new Error('Supply between one and 50 RFQ lines.');\nconst catalogue = $('Collect catalogue rows').first().json.catalogue;\nconst prices = $input.all().map(item => item.json);\nif (!catalogue.length || !prices.length) throw new Error('The catalogue and approved price list must contain rows.');\nif (catalogue.length > 200 || prices.length > 200) throw new Error('The catalogue and approved price list are limited to 200 rows each.');\nconst string = value => String(value ?? '').trim();\nfunction index(rows, fields) {\n  const result = new Map();\n  rows.forEach((row, i) => {\n    if (fields.some(field => !string(row[field]))) throw new Error(`Reference row ${i + 2} has a blank required key.`);\n    const key = fields.map(field => string(row[field])).join('|');\n    const matches = result.get(key) || [];\n    matches.push({ ...row, source_row: i + 2 }); result.set(key, matches);\n  });\n  return result;\n}\nconst catalogByCode = index(catalogue, ['code']);\nconst priceByCodeUnit = index(prices, ['code', 'unit']);\nconst numberPattern = /^-?(?:\\d+)(?:\\.\\d+)?$/;\nconst output = sourceLines.map((line, offset) => {\n  const cells = line.split('|').map(string);\n  const row = { source_pdf_line: offset + 2, line_reference: cells[0] || '', code: cells[1] || '', description: cells[2] || '', quantity_text: cells[3] || '', quantity: '', unit: cells[4] || '', requested_price: cells[5] || '', approved_price: '', currency: '', line_total: '', status: 'exception', reason: '', catalogue_row: '', approved_price_row: '', raw_line: line };\n  const reject = reason => ({ ...row, reason });\n  if (![5,6].includes(cells.length) || !/^\\d+$/.test(cells[0])) return reject('malformed_line');\n  if (!row.code) return reject('missing_code');\n  if (!numberPattern.test(cells[3]) || !Number.isFinite(Number(cells[3])) || Number(cells[3]) <= 0) return reject('invalid_quantity');\n  row.quantity = Number(cells[3]);\n  if (!row.unit) return reject('missing_unit');\n  const candidates = catalogByCode.get(row.code) || [];\n  if (!candidates.length) return reject('missing_catalogue');\n  if (candidates.length !== 1) return reject('ambiguous_catalogue');\n  row.catalogue_row = candidates[0].source_row;\n  if (string(candidates[0].unit) !== row.unit) return reject('unit_mismatch');\n  const priceCandidates = priceByCodeUnit.get(`${row.code}|${row.unit}`) || [];\n  if (!priceCandidates.length) return reject('missing_price');\n  if (priceCandidates.length !== 1) return reject('ambiguous_price');\n  const price = priceCandidates[0];\n  const approved = string(price.approved_price);\n  if (!numberPattern.test(approved) || !Number.isFinite(Number(approved)) || Number(approved) < 0 || !string(price.currency)) return reject('invalid_approved_price');\n  row.approved_price = Number(approved); row.currency = string(price.currency); row.approved_price_row = price.source_row;\n  if (row.requested_price && !numberPattern.test(row.requested_price)) return reject('invalid_requested_price');\n  if (row.requested_price && Number(row.requested_price) !== row.approved_price) return reject('price_mismatch');\n  if (!Number.isSafeInteger(Math.round(row.quantity * row.approved_price * 100))) return reject('amount_out_of_range');\n  row.status = 'accepted'; row.reason = 'Exact code and unit; price comes from the approved list.';\n  row.line_total = Math.round((row.quantity * row.approved_price + Number.EPSILON) * 100) / 100;\n  return row;\n});\nconst summary = { input_line_count: sourceLines.length, output_line_count: output.length, accepted_line_count: output.filter(row => row.status === 'accepted').length, exception_count: output.filter(row => row.status === 'exception').length, ambiguous_catalogue_count: output.filter(row => row.reason === 'ambiguous_catalogue').length, missing_price_count: output.filter(row => row.reason === 'missing_price').length, run_failed: false, quote_total: 'Withheld until review is complete' };\nreturn output.map(row => ({ json: { ...row, ...summary } }));\n"
      }
    },
    {
      "id": "node-9",
      "name": "Convert review to XLSX",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        2230,
        300
      ],
      "parameters": {
        "operation": "xlsx",
        "binaryPropertyName": "data",
        "options": {
          "fileName": "acceptance-review.xlsx",
          "sheetName": "Review"
        }
      }
    },
    {
      "id": "node-10",
      "name": "Write XLSX report",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        2450,
        300
      ],
      "parameters": {
        "operation": "write",
        "fileName": "/files/sample-review.xlsx",
        "dataPropertyName": "data"
      }
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Read RFQ PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read RFQ PDF": {
      "main": [
        [
          {
            "node": "Extract RFQ PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract RFQ PDF": {
      "main": [
        [
          {
            "node": "Read catalogue CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read catalogue CSV": {
      "main": [
        [
          {
            "node": "Extract catalogue CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract catalogue CSV": {
      "main": [
        [
          {
            "node": "Collect catalogue rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect catalogue rows": {
      "main": [
        [
          {
            "node": "Read approved prices CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read approved prices CSV": {
      "main": [
        [
          {
            "node": "Extract approved prices CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract approved prices CSV": {
      "main": [
        [
          {
            "node": "Match RFQ against approved sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match RFQ against approved sources": {
      "main": [
        [
          {
            "node": "Convert review to XLSX",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert review to XLSX": {
      "main": [
        [
          {
            "node": "Write XLSX report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  }
}
