{
  "name": "RomeoApps Swiss HVAC exact-match synthetic proof",
  "nodes": [
    {
      "parameters": {},
      "id": "manual-trigger",
      "name": "Run synthetic acceptance fixture",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const catalog = [\n  { ref: 'DEMO-GM-014', supplier: 'Getaz Miauton / synthetic', label: 'mitigeur thermostatique mural', aliases: ['mitigeur thermostatique mural', 'mitigeur thermostatique'], price: 180 },\n  { ref: 'DEMO-ST-008', supplier: 'Sanitas Troesch / synthetic', label: 'raccord multicouche 16 mm', aliases: ['raccord multicouche 16 mm', 'raccord multicouche 16', 'raccords multicouche 16', 'raccord multicouche', 'raccords multicouche'], price: 12.5 },\n  { ref: 'DEMO-NB-003', supplier: 'Nussbaum / synthetic', label: \"vanne d'arrêt 1/2 pouce\", aliases: [\"vanne d'arrêt\", 'vanne arrêt', 'vanne 1/2'], price: 24.5 }\n];\nreturn [{ json: {\n  provenance: { synthetic_only: true, client_data: false, supplier_data: false, credentials: false },\n  rules: { material_margin: 0.15, labor_rate_chf: 95, travel_chf: 45, vat_rate: 0.081 },\n  labor_hours: 2,\n  travel: true,\n  catalog,\n  requests: [\n    { case_id: 'known-only', text: 'Remplacer un mitigeur thermostatique mural. Ajouter 2 raccords multicouche 16 mm.', expected: 'MATCHED / READY' },\n    { case_id: 'known-plus-missing', text: 'Remplacer un mitigeur thermostatique mural. Ajouter une pièce non référencée à confirmer.', expected: 'MATCHED + REVIEW' },\n    { case_id: 'missing-only', text: 'Remplacer la pièce non référencée à confirmer. Ajouter un composant hors catalogue.', expected: 'REVIEW REQUIRED' }\n  ]\n} }];"
      },
      "id": "fixture",
      "name": "Load synthetic catalog and requests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [480, 300]
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst clean = (value) => String(value).toLocaleLowerCase('fr').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[’']/g, \"'\").trim();\nconst quantityFor = (line) => {\n  const match = line.match(/(?:^|\\s)(\\d+(?:[.,]\\d+)?)\\s*(?:x|×|pieces?|pcs?|unit(?:e|és?)?)?/i);\n  return match ? Math.max(1, Number(match[1].replace(',', '.'))) : 1;\n};\nconst money = (value) => Math.round((value + Number.EPSILON) * 100) / 100;\nconst runs = input.requests.map((request) => {\n  const rows = request.text.split(/[\\n.;]+/).map((line) => line.trim()).filter(Boolean).map((line) => {\n    const normalized = clean(line);\n    const item = input.catalog.find((candidate) => candidate.aliases.some((alias) => normalized.includes(clean(alias))));\n    return item ? { kind: 'match', ref: item.ref, supplier: item.supplier, label: item.label, quantity: quantityFor(line), unit_price_chf: item.price, price_source: 'catalog_row' } : { kind: 'exception', line, price_source: null, reason: 'no_exact_reference' };\n  });\n  const matched = rows.filter((row) => row.kind === 'match');\n  const held = rows.filter((row) => row.kind === 'exception');\n  const materials = matched.reduce((sum, row) => sum + row.unit_price_chf * row.quantity, 0);\n  const margin = materials * input.rules.material_margin;\n  const labor = input.labor_hours * input.rules.labor_rate_chf;\n  const travel = input.travel ? input.rules.travel_chf : 0;\n  const subtotal = materials + margin + labor + travel;\n  const vat = subtotal * input.rules.vat_rate;\n  return { case_id: request.case_id, expected: request.expected, actual: matched.length && !held.length ? 'MATCHED / READY' : matched.length ? 'MATCHED + REVIEW' : 'REVIEW REQUIRED', priced_lines: matched, held_lines: held, totals_chf: { materials: money(materials), margin: money(margin), labor: money(labor), travel: money(travel), vat: money(vat), total: money(subtotal + vat) } };\n});\nreturn [{ json: { ...input, runs, boundary: 'Synthetic-only fixture. No supplier data, customer data, credentials, network calls, or external writes.' } }];"
      },
      "id": "matcher",
      "name": "Match exact references and calculate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [740, 300]
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst failures = [];\nfor (const run of input.runs) {\n  if (run.actual !== run.expected) failures.push(`${run.case_id}: decision mismatch`);\n  if (run.held_lines.some((line) => line.price_source !== null || Object.hasOwn(line, 'unit_price_chf'))) failures.push(`${run.case_id}: held line received a price`);\n  if (run.priced_lines.some((line) => line.price_source !== 'catalog_row')) failures.push(`${run.case_id}: price provenance missing`);\n}\nconst knownOnly = input.runs.find((run) => run.case_id === 'known-only');\nconst missingOnly = input.runs.find((run) => run.case_id === 'missing-only');\nif (!knownOnly || !missingOnly || knownOnly.priced_lines.length !== 2 || missingOnly.priced_lines.length !== 0) failures.push('scenario coverage mismatch');\nreturn [{ json: {\n  status: failures.length ? 'fail' : 'pass',\n  cases: input.runs.length,\n  failures,\n  synthetic_only: input.provenance.synthetic_only,\n  external_side_effects: 'none',\n  priced_line_rule: 'Every price must carry price_source=catalog_row.',\n  missing_line_rule: 'Every unmatched line remains unpriced and held for review.',\n  runs: input.runs\n} }];"
      },
      "id": "evaluator",
      "name": "Evaluate fail-closed acceptance cases",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1000, 300]
    }
  ],
  "connections": {
    "Run synthetic acceptance fixture": { "main": [[{ "node": "Load synthetic catalog and requests", "type": "main", "index": 0 }]] },
    "Load synthetic catalog and requests": { "main": [[{ "node": "Match exact references and calculate", "type": "main", "index": 0 }]] },
    "Match exact references and calculate": { "main": [[{ "node": "Evaluate fail-closed acceptance cases", "type": "main", "index": 0 }]] }
  },
  "active": false,
  "settings": { "executionOrder": "v1" },
  "versionId": "romeoapps-swiss-hvac-exact-match-synthetic-v1",
  "meta": { "templateCredsSetupCompleted": true },
  "tags": []
}
