{
  "name": "24-hvac-field-service-orchestration",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "hvac-service-request",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "d4e5f6g7-1111-4444-8888-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        240,
        500
      ]
    },
    {
      "parameters": {
        "model": "gpt-4o",
        "prompt": {
          "messages": [
            {
              "role": "system",
              "content": "You are an expert HVAC dispatch AI. Analyze the service request. Extract: 1. device_type (e.g., AC, Heater, Refrigerator), 2. issue_summary (1 sentence), 3. required_parts (array of strings, e.g., ['compressor', 'freon']), 4. urgency (critical, high, normal). Respond ONLY in valid JSON format with these exact keys."
            },
            {
              "role": "user",
              "content": "Customer: {{ $json.customer_name }}\nAddress: {{ $json.customer_address }}\nIssue Description: {{ $json.issue_description }}"
            }
          ]
        },
        "options": {
          "responseFormat": "json_object"
        }
      },
      "id": "d4e5f6g7-2222-4444-8888-000000000002",
      "name": "OpenAI",
      "type": "n8n-nodes-base.openAi",
      "typeVersion": 1.2,
      "position": [
        460,
        500
      ],
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT id, name, lat, lon, skills, current_load, van_inventory FROM technicians WHERE status = 'active';",
        "options": {}
      },
      "id": "d4e5f6g7-3333-4444-8888-000000000003",
      "name": "PostgreSQL",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        680,
        500
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Score technicians returned by PostgreSQL against the original request and AI analysis.\nconst webhookItem = $('Webhook').first().json;\nconst request = webhookItem.body || webhookItem;\nconst customerLat = Number(request.customer_lat);\nconst customerLon = Number(request.customer_lon);\n\nif (!Number.isFinite(customerLat) || !Number.isFinite(customerLon)) {\n  throw new Error('customer_lat and customer_lon must be valid numbers');\n}\n\nconst aiResponse = $('OpenAI').first().json;\nconst rawContent = aiResponse.content\n  ?? aiResponse.message?.content\n  ?? aiResponse.choices?.[0]?.message?.content\n  ?? '{}';\n\nlet aiAnalysis;\ntry {\n  aiAnalysis = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent;\n} catch (error) {\n  throw new Error('AI analysis is not valid JSON');\n}\n\nconst normalizeList = (value) => {\n  if (Array.isArray(value)) return value.map((item) => String(item).trim().toLowerCase());\n  if (typeof value === 'string') {\n    return value.replace(/[{}]/g, '').split(',').map((item) => item.trim().replace(/^\"|\"$/g, '').toLowerCase()).filter(Boolean);\n  }\n  return [];\n};\n\nconst technicians = $input.all().map(({ json }) => ({\n  id: String(json.id ?? ''),\n  name: String(json.name ?? 'Technician'),\n  lat: Number(json.lat),\n  lon: Number(json.lon),\n  skills: normalizeList(json.skills),\n  currentLoad: Math.max(0, Number(json.current_load) || 0),\n  inventory: normalizeList(json.van_inventory),\n})).filter((tech) => tech.id && Number.isFinite(tech.lat) && Number.isFinite(tech.lon));\n\nconst requiredSkill = String(aiAnalysis.device_type ?? '').trim().toLowerCase();\nconst requiredParts = normalizeList(aiAnalysis.required_parts);\n\nfunction distanceKm(lat1, lon1, lat2, lon2) {\n  const radius = 6371;\n  const dLat = (lat2 - lat1) * Math.PI / 180;\n  const dLon = (lon2 - lon1) * Math.PI / 180;\n  const a = Math.sin(dLat / 2) ** 2\n    + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;\n  return radius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n}\n\nconst candidates = technicians\n  .filter((tech) => tech.skills.includes(requiredSkill))\n  .map((tech) => {\n    const distance = distanceKm(customerLat, customerLon, tech.lat, tech.lon);\n    const proximityScore = Math.max(0, 100 - distance * 5);\n    const partsAvailable = requiredParts.every((part) => tech.inventory.includes(part));\n    const partsScore = partsAvailable ? 100 : 0;\n    const loadScore = Math.max(0, 100 - tech.currentLoad * 15);\n    const score = proximityScore * 0.4 + partsScore * 0.4 + loadScore * 0.2;\n    return { ...tech, distanceKm: Number(distance.toFixed(2)), partsAvailable, score: Number(score.toFixed(2)) };\n  })\n  .sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm || a.id.localeCompare(b.id));\n\nconst bestTech = candidates[0] || null;\nconst basePrice = aiAnalysis.urgency === 'critical' ? 150 : 100;\nconst travelFee = bestTech ? bestTech.distanceKm * 1.5 : 0;\nconst partsFee = requiredParts.length * 25;\n\nreturn [{ json: {\n  request_id: request.request_id || null,\n  customer_name: request.customer_name || null,\n  customer_address: request.customer_address || null,\n  ai_analysis: aiAnalysis,\n  assigned_technician: bestTech,\n  final_price_usd: Number((basePrice + travelFee + partsFee).toFixed(2)),\n  parts_available: bestTech?.partsAvailable === true,\n  dispatch_status: bestTech?.partsAvailable === true ? 'AUTO_DISPATCH_READY' : 'MANUAL_REVIEW_REQUIRED',\n  candidate_count: candidates.length,\n} }];"
      },
      "id": "d4e5f6g7-4444-4444-8888-000000000004",
      "name": "Code",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        500
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "cond1",
                    "leftValue": "={{ $json.parts_available }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equal"
                    }
                  },
                  {
                    "id": "cond2",
                    "leftValue": "={{ $json.assigned_technician }}",
                    "rightValue": "",
                    "operator": {
                      "type": "object",
                      "operation": "notEmpty"
                    }
                  }
                ],
                "combinator": "and"
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "default"
        }
      },
      "id": "d4e5f6g7-5555-4444-8888-000000000005",
      "name": "Switch",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1120,
        500
      ]
    },
    {
      "parameters": {
        "calendar": {
          "__rl": true,
          "value": "primary",
          "mode": "list"
        },
        "start": "={{ new Date(Date.now() + 86400000).toISOString() }}",
        "end": "={{ new Date(Date.now() + 90000000).toISOString() }}",
        "summary": "Service: {{ $json.ai_analysis.device_type }} for {{ $json.customer_name }}",
        "description": "Tech: {{ $json.assigned_technician.name }}\nAddress: {{ $json.customer_address }}\nIssue: {{ $json.ai_analysis.issue_summary }}\nEstimated Price: ${{ $json.final_price_usd }}",
        "additionalFields": {
          "attendees": [
            "tech@example.com"
          ]
        }
      },
      "id": "d4e5f6g7-6666-4444-8888-000000000006",
      "name": "Google Calendar",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.1,
      "position": [
        1340,
        420
      ],
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "fromNumber": "+1234567890",
        "toNumber": "+989123456789",
        "message": "Hello {{ $json.customer_name }}. Your {{ $json.ai_analysis.device_type }} service is confirmed! Tech: {{ $json.assigned_technician.name }} will arrive tomorrow. Est. Price: ${{ $json.final_price_usd }}. Reply STOP to opt out.",
        "options": {}
      },
      "id": "d4e5f6g7-7777-4444-8888-000000000007",
      "name": "Twilio",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        1560,
        420
      ],
      "credentials": {
        "twilioApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "C9988776655",
          "mode": "list",
          "cachedResultName": "dispatch-manager-alerts"
        },
        "text": "\u26a0\ufe0f *SERVICE REQUEST UNASSIGNED* \u26a0\ufe0f\n\nCustomer: {{ $json.customer_name }}\nIssue: {{ $json.ai_analysis.issue_summary }}\nReason: No technician with required skills/parts available.\n\nAction: Manual dispatch required or order parts.",
        "otherOptions": {}
      },
      "id": "d4e5f6g7-8888-4444-8888-000000000008",
      "name": "Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1340,
        580
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "OpenAI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI": {
      "main": [
        [
          {
            "node": "PostgreSQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PostgreSQL": {
      "main": [
        [
          {
            "node": "Code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code": {
      "main": [
        [
          {
            "node": "Switch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch": {
      "main": [
        [
          {
            "node": "Google Calendar",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Calendar": {
      "main": [
        [
          {
            "node": "Twilio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "id": "24-hvac-field-service-orchestration",
  "tags": []
}