AutomationFlowsAI & RAG › Estimate Construction Costs From Text with Telegram, Openai and Ddc Cwicr

Estimate Construction Costs From Text with Telegram, Openai and Ddc Cwicr

ByArtem Boiko @datadrivenconstruction on n8n.io

A Telegram bot that converts natural-language work descriptions into detailed cost estimates using AI parsing, vector search, and the open-source DDC CWICR database with 55,000+ construction work items. Contractors & Estimators who need quick ballpark figures from verbal/text…

Event trigger★★★★★ complexityAI-powered88 nodesHTTP RequestTelegramTelegram TriggerChain LlmOpenAI ChatGoogle Gemini ChatAnthropic ChatOpenRouter Chat
AI & RAG Trigger: Event Nodes: 88 Complexity: ★★★★★ AI nodes: yes Added:
Estimate Construction Costs From Text with Telegram, Openai and Ddc Cwicr — n8n workflow card showing HTTP Request, Telegram, Telegram Trigger integration

This workflow corresponds to n8n.io template #12174 — we link there as the canonical source.

This workflow follows the Chainllm → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "Y4vZ0z2b1xAcFQxy",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "DDC CWICR - Text Estimator v11 (AI Nodes)",
  "tags": [],
  "nodes": [
    {
      "id": "5c6b56be-8107-4d2a-99c6-fe01de0dcd7b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5600,
        -2176
      ],
      "parameters": {
        "width": 404,
        "height": 132,
        "content": "\u2b50 **Star us on GitHub!**\n\n[github.com/datadrivenconstruction/DDC-CWICR](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR)\n\n**DDC CWICR** \u2014 Open Source Construction Cost Database\n- 55,000+ work items\n- 9 languages\n- Free forever"
      },
      "typeVersion": 1
    },
    {
      "id": "00370c25-67e4-4789-9abf-e994473af278",
      "name": "\ud83d\udd10 Credentials Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5600,
        -2032
      ],
      "parameters": {
        "color": 5,
        "width": 412,
        "height": 936,
        "content": "## \ud83d\udd10 Credentials Setup\n\n### \ud83d\udd11 TOKEN node:\n- `bot_token` - Telegram Bot API\n- `QDRANT_URL` - Qdrant server\n- `QDRANT_API_KEY` - Qdrant auth\n\n### n8n Credentials (Settings \u2192 Credentials):\n- **OpenAI API** - for LLM + Embeddings\n- **Anthropic API** - (optional) for Claude\n- **Google Gemini API** - (optional) for Gemini\n\n### To switch AI models:\n1. Disable current model node\n2. Enable alternative model\n3. Models auto-connect to chain"
      },
      "typeVersion": 1
    },
    {
      "id": "828e259a-6e85-479c-a60e-76931f116b73",
      "name": "Checklist",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5936,
        -1712
      ],
      "parameters": {
        "width": 324,
        "height": 460,
        "content": "## \u2705 SETUP CHECKLIST\n\n**1. Telegram Bot**\n- [ ] Create via @BotFather\n- [ ] Token in \ud83d\udd11 TOKEN\n\n**2. n8n Credentials**\n- [ ] Add OpenAI credential\n- [ ] Link to Model nodes\n\n**3. Qdrant**\n- [ ] Install/connect Qdrant\n- [ ] Load DDC collections\n- [ ] Set QDRANT_URL\n\n**4. Test**\n- [ ] /start \u2192 language\n- [ ] Enter works\n- [ ] Get estimate"
      },
      "typeVersion": 1
    },
    {
      "id": "1044a0e4-96af-46fb-87e0-f5b7011b82ad",
      "name": "Intro",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5936,
        -2176
      ],
      "parameters": {
        "width": 318,
        "height": 440,
        "content": "## \ud83d\ude80 DDC CWICR Text Estimator\n### Construction Cost Estimation Bot\n\n**Version:** 11.0 AI Nodes\n**Author:** DataDrivenConstruction.io\n\n**All AI via n8n Credentials:**\n- \ud83e\udd16 Parse Text (OpenAI/Claude/Gemini)\n- \ud83e\udd16 Transform Query\n- \ud83e\udd16 Rerank Results\n- \ud83d\udcca Embeddings (OpenAI)\n\n**Features:**\n- \ud83d\udcac Text input\n- \ud83c\udf0d 9 languages\n- \ud83d\udd0d Vector search\n- \ud83d\udcca HTML/Excel/PDF\n\n**No API keys in code!**\nUse n8n Credentials only."
      },
      "typeVersion": 1
    },
    {
      "id": "349fcf16-f55a-4300-aa2a-2bb9ed806716",
      "name": "\ud83d\udd11 TOKEN",
      "type": "n8n-nodes-base.set",
      "position": [
        -5344,
        -1568
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e7b8f8af-ca88-4515-a8ce-3d2c50b815b6",
              "name": "bot_token",
              "type": "string",
              "value": "YOUR_TELEGRAM_BOT_TOKEN"
            },
            {
              "id": "bd298b9a-45b6-4b99-9e50-0346cc402a30",
              "name": "OPENAI_API_KEY",
              "type": "string",
              "value": "YOUR_OPENAI_API_KEY"
            },
            {
              "id": "9c4cfdd2-d46e-465b-ac08-cea275128c20",
              "name": "QDRANT_URL",
              "type": "string",
              "value": "http://localhost:6333"
            },
            {
              "id": "835c6846-26c6-4a0f-ad75-f352010fe4a8",
              "name": "QDRANT_API_KEY",
              "type": "string",
              "value": ""
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "c576122d-0243-4772-99d0-01cf54898118",
      "name": "UI Messages",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4304,
        -2784
      ],
      "parameters": {
        "color": 6,
        "width": 464,
        "height": 804,
        "content": "## \ud83c\udf0d UI Messages\n\nTelegram interface elements:\n- Language selection menu\n- Text input prompts\n- Edit options\n- Help text\n- Error messages\n- Progress indicators\n\nAll localized in Config node.\n\n**Customization:**\nEdit LANG object in Config\nto modify any UI text."
      },
      "typeVersion": 1
    },
    {
      "id": "3385b0fc-beba-4867-8cd8-e1f5c9c210ea",
      "name": "Route Switch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4640,
        -2176
      ],
      "parameters": {
        "color": 4,
        "width": 308,
        "height": 1072,
        "content": "## \ud83d\udd00 Route Switch\n\n**11 Actions:**\n\n| # | Action | Description |\n|---|--------|-------------|\n| 0 | show_lang | Language menu |\n| 1 | lang_selected | Confirm & prompt |\n| 2 | works_updated | After edit |\n| 3 | calculate | Start calculation |\n| 4 | edit | Show edit menu |\n| 5 | export_excel | CSV download |\n| 6 | export_pdf | PDF download |\n| 7 | view_details | Resource details |\n| 8 | help | Help message |\n| 9 | restart | New session |\n| 10 | fallback | Error handler |"
      },
      "typeVersion": 1
    },
    {
      "id": "c095dbb9-7ce0-4173-8c45-d6de272c43b9",
      "name": "Config & Localization",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4880,
        -2176
      ],
      "parameters": {
        "color": 5,
        "width": 220,
        "height": 808,
        "content": "## \ud83c\udf10 Config & Localization\n\n**9 Languages:**\nDE, EN, RU, ES, FR, PT, ZH, AR, HI\n\n**Contains:**\n- UI messages (buttons, prompts)\n- Currency symbols (\u20ac/$\u20bd\u00a5)\n- Database mapping\n- Search language names\n\n**Auto-selects:**\n- Qdrant collection by language\n- Currency by region\n- UI text localization\n\n**Customizable:**\n- Add new languages in LANG object\n- Modify button labels\n- Change currency defaults"
      },
      "typeVersion": 1
    },
    {
      "id": "4fe52156-022b-49e3-97f6-2da1f363a635",
      "name": "Main Router",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5168,
        -2176
      ],
      "parameters": {
        "color": 5,
        "width": 268,
        "height": 808,
        "content": "## \ud83e\udde0 Main Router\n\nCentral message handler.\n\n**Input:** Telegram Update\n\n**Processing:**\n1. Parse message/callback\n2. Manage user sessions\n3. Detect content type\n4. Route to action\n\n**Output:** `action` code (0-10)\n\n**Session Storage:**\n- User language\n- Work items list\n- Calculation results\n- State machine"
      },
      "typeVersion": 1
    },
    {
      "id": "a5e21659-7430-412a-82a5-24b3f153118b",
      "name": "Agg",
      "type": "n8n-nodes-base.code",
      "position": [
        -1920,
        -784
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// AGG - Final aggregation of calculation results + DDC CWICR info\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\n\nconst L = cfg.L || {};\nlet total = 0, workers_sum = 0, materials_sum = 0, machines_sum = 0, labor_hours_sum = 0;\nlet found_count = 0;\n\n// Get accumulated results\nconst storedResults = sd.res?.[cid] || [];\n\nconsole.log('=== AGG ===');\nconsole.log('Results count:', storedResults.length);\n\n// Process each result\nconst works = storedResults.map(w => {\n  const uc = w.uc || 0;\n  const tc = w.tc || 0;\n\n  total += tc;\n  workers_sum += (w.workers_total || 0);\n  materials_sum += (w.materials_total || 0);\n  machines_sum += (w.machines_total || 0);\n  labor_hours_sum += (w.labor_hours || 0);\n\n  if (w._found) found_count++;\n\n  return {\n    id: w.id,\n    name: w.name || w.sq,\n    query: w.sq || w.query,\n    qty: w.qty,\n    unit: w.unit,\n    room: w.room,\n    uc: uc,\n    tc: tc,\n    rate_code: w.rate_code || '',\n    rate_name: w.rate_name || '',\n    resources: w.resources || [],\n    workers_total: w.workers_total || 0,\n    materials_total: w.materials_total || 0,\n    machines_total: w.machines_total || 0,\n    labor_hours: w.labor_hours || 0,\n    found: w._found || false,\n    scope_of_work: w.scope_of_work || [],\n    original_query: w.original_query || w.name\n  };\n});\n\nconst pct = works.length > 0 ? Math.round(found_count / works.length * 100) : 0;\n\n// DDC CWICR info\nconst isLimited = false; // No limit\nconst originalTotal = session.totalWorks || works.length;\nconst skippedWorks = originalTotal - works.length;\n\nconsole.log('Calculated:', works.length, '/', originalTotal);\nconsole.log('Found:', found_count, '/', works.length);\nconsole.log('Total cost:', total.toFixed(2));\n// No limit mode\n\n// Cleanup staticData\nif (sd.res?.[cid]) delete sd.res[cid];\nif (sd.calcProgress?.[cid]) delete sd.calcProgress[cid];\nif (sd.progress?.[cid]) delete sd.progress[cid];\n\n// Save for report generation\nsd.lastResults = { works, total, workers_sum, materials_sum, machines_sum, labor_hours_sum, found_count, pct, L };\n\nreturn {\n  json: {\n    chatId: cid,\n    bot_token: cfg.bot_token,\n    works: works,\n    total: Math.round(total * 100) / 100,\n    workers_sum: Math.round(workers_sum * 100) / 100,\n    materials_sum: Math.round(materials_sum * 100) / 100,\n    machines_sum: Math.round(machines_sum * 100) / 100,\n    labor_hours_sum: Math.round(labor_hours_sum * 100) / 100,\n    found_count: found_count,\n    total_count: works.length,\n    pct: pct,\n    L: L,\n    currency: L.sym || '\u20ac',\n    description: session.description || '',\n    // DDC CWICR\n    _is_limited: isLimited,\n    _total_works: originalTotal,\n    _skipped_works: skippedWorks\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "f6f6aa53-b07e-4a09-b740-7db971f3f7c9",
      "name": "\ud83d\uddd1\ufe0f Delete Progress Msg",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2096,
        -784
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\n  \"chat_id\": {{ $('\ud83e\uddf9 Prep Cleanup').first().json.chatId }},\n  \"message_id\": {{ $('\ud83e\uddf9 Prep Cleanup').first().json._delete_progress_msg || 0 }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "602ec477-8f11-47ec-8cfc-aaf8c9183a09",
      "name": "\ud83d\uddd1\ufe0f Delete Work Msg",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2288,
        -784
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\n  \"chat_id\": {{ $('\ud83e\uddf9 Prep Cleanup').first().json.chatId }},\n  \"message_id\": {{ $('\ud83e\uddf9 Prep Cleanup').first().json._delete_work_msg || 0 }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "7a275264-fb92-4cf2-8d35-dbbbbdf5d2a3",
      "name": "\ud83e\uddf9 Prep Cleanup",
      "type": "n8n-nodes-base.code",
      "position": [
        -2464,
        -784
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREP CLEANUP - Prepare message IDs for deletion after loop completes\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfg = $('Config').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\n\n// Get message IDs to delete\nconst lastMsgId = sd.calcProgress?.[cid]?.lastMsgId || null;\nconst progressMsgId = sd.progress?.[cid]?.message_id || null;\n\nconsole.log('=== PREP CLEANUP ===');\nconsole.log('ChatId:', cid);\nconsole.log('Work msg:', lastMsgId);\nconsole.log('Progress msg:', progressMsgId);\n\nreturn {\n  json: {\n    chatId: cfg.chatId,\n    bot_token: cfg.bot_token,\n    L: cfg.L,\n    _delete_work_msg: lastMsgId,\n    _delete_progress_msg: progressMsgId\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "db70fc98-d9ab-4c81-8992-9e4b844c705a",
      "name": "Acc",
      "type": "n8n-nodes-base.code",
      "position": [
        -1920,
        0
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// ACC - Accumulate calculation results for final aggregation\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst sd = $getWorkflowStaticData('global');\nconst w = $('\ud83d\udcca Update Result').first().json;\nconst cid = String(w.chatId);\n\nif (!sd.res) sd.res = {};\nif (!sd.res[cid]) sd.res[cid] = [];\nsd.res[cid].push(w);\n\nconsole.log('Accumulated:', sd.res[cid].length, '/', w.total_works);\n\nreturn { json: w };"
      },
      "typeVersion": 2
    },
    {
      "id": "e6214da3-91f1-4065-8f60-a73a8fcd8609",
      "name": "\ud83d\udce4 Edit Result",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2096,
        0
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/editMessageText",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"message_id\": {{ $json._edit_msg_id || 0 }}, \"text\": {{ JSON.stringify($json._result_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "f78f0fc1-011c-46c4-a4ae-92088c88589c",
      "name": "\ud83d\udcca Update Result",
      "type": "n8n-nodes-base.code",
      "position": [
        -2256,
        0
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// UPDATE RESULT - Format result message (\u2713 Found / Not found)\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst calcData = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(calcData.chatId);\nconst L = calcData.L || {};\n\nconst current = calcData.work_index || 1;\nconst total = calcData.total_works || 1;\nconst name = calcData.name || calcData.sq || 'Work';\n\nlet shortName = name.length > 22 ? name.substring(0, 19) + '...' : name;\n\nconst tc = parseFloat(calcData.tc) || 0;\nconst uc = parseFloat(calcData.uc) || 0;\nconst rateCode = String(calcData.rate_code || '');\nconst rateName = String(calcData.rate_name || '');\nconst currency = calcData.currency || L.sym || '\u20ac';\n\nconst hasValidRate = rateCode && \n  !rateCode.includes('NOT_FOUND') && \n  !rateCode.includes('PAYLOAD_NOT_FOUND');\n\nconst found = tc > 0 || uc > 0 || hasValidRate || (rateName && rateName.length > 0);\n\nconsole.log('Result:', shortName, found ? '\u2713' : '\u2717', tc.toFixed(0), currency);\n\nlet text = '';\nif (found) {\n  text = current + '/' + total + ' ' + shortName + ' \u2713\\n';\n  text += tc.toFixed(0) + ' ' + currency;\n  if (rateCode && hasValidRate) text += ' \u00b7 ' + rateCode.substring(0, 20);\n} else {\n  text = current + '/' + total + ' ' + shortName + '\\n';\n  text += (L.not_found || '\u041d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e');\n}\n\nconst msgId = sd.calcProgress?.[cid]?.lastMsgId || null;\n\nreturn { json: { ...calcData, _result_text: text, _edit_msg_id: msgId, _found: found } };"
      },
      "typeVersion": 2
    },
    {
      "id": "86c8dc46-d8e3-4aaa-9aff-986535232720",
      "name": "1\ufe0f\u20e3 Prep Query",
      "type": "n8n-nodes-base.code",
      "position": [
        -1920,
        -400
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREP QUERY - Prepare search query with Qdrant credentials from TOKEN\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst loopItem = $input.first().json;\nconst tokenData = $('\ud83d\udd11 TOKEN').first().json;\n\nconst originalQuery = loopItem.sq || loopItem.query || loopItem.name || '';\nconst collectionName = loopItem.db || '';\nconst L = loopItem.L || {};\nconst workUnit = loopItem.unit || 'm\u00b2';\nconst workQty = loopItem.qty || 1;\n\n// Get Qdrant credentials from TOKEN node\nconst QDRANT_URL = tokenData.QDRANT_URL || 'http://localhost:6333';\nconst QDRANT_API_KEY = tokenData.QDRANT_API_KEY || '';\n\nconsole.log('=== PREP QUERY ===');\nconsole.log('Query:', originalQuery);\nconsole.log('Collection:', collectionName);\nconsole.log('Qdrant URL:', QDRANT_URL);\n\nif (!originalQuery || !collectionName) {\n  console.log('ERROR: Missing query or collection');\n  return [{ json: { ...loopItem, _error: 'Missing data', _skip: true } }];\n}\n\nconst searchLang = L.search_lang || 'Russian';\n\n// Detect database language from collection name\nconst isRussianDB = collectionName.includes('RU_') || collectionName.includes('_RU');\nconst isGermanDB = collectionName.includes('DE_');\nconst dbLang = isRussianDB ? 'Russian' : (isGermanDB ? 'German' : 'English');\n\nconst transformPrompt = `You are a construction cost database search expert for ${dbLang} construction rates database.\n\nTASK: Transform user query into optimal SEARCH KEYWORDS that will match entries in a vector database of construction work rates.\n\nDATABASE CONTEXT:\n- Contains standardized construction work rates with codes, names, units, resources\n- Each rate has: rate_code, rate_name, rate_unit, scope_of_work, resources\n- Language: ${dbLang}\n\nTRANSFORMATION RULES:\n1. EXPAND abbreviations to full professional terms\n2. ADD synonyms and related construction terms\n3. INCLUDE work action verbs (install, apply, lay, mount)\n4. Keep original query terms + expanded terms\n5. Output in ${dbLang} language only\n\nUSER QUERY: ${originalQuery}\nUNIT: ${workUnit}\n\nReply with ONLY the optimized search keywords (one line, no explanations):`;\n\nreturn [{ json: {\n  ...loopItem,\n  _original_query: originalQuery,\n  _transform_prompt: transformPrompt,\n  _collection: collectionName,\n  _db_lang: dbLang,\n  _qdrant_url: QDRANT_URL,\n  _qdrant_key: QDRANT_API_KEY\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "38cc18f0-26f4-4fbf-a12d-13eb6154bb94",
      "name": "\ud83d\udcbe Save Work Msg",
      "type": "n8n-nodes-base.code",
      "position": [
        -2096,
        -400
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// SAVE WORK MSG - Store message ID for later editing/deletion\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst loopItem = $('\ud83d\udcdd Prep Work Msg').first().json;\nconst tgResp = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(loopItem.chatId);\n\nif (!sd.calcProgress) sd.calcProgress = {};\nif (!sd.calcProgress[cid]) sd.calcProgress[cid] = {};\nsd.calcProgress[cid].lastMsgId = tgResp.result?.message_id || null;\n\nconsole.log('Saved msg ID:', sd.calcProgress[cid].lastMsgId);\n\nreturn { json: loopItem };"
      },
      "typeVersion": 2
    },
    {
      "id": "78681325-44aa-43d9-960d-276444c26b1e",
      "name": "\ud83d\udce4 Send Work",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2272,
        -400
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $(\"\ud83d\udcdd Prep Work Msg\").item.json.chatId }}, \"text\": {{ JSON.stringify($(\"\ud83d\udcdd Prep Work Msg\").item.json._work_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "ac19ffbe-1ef7-4730-bc8b-5e6fa86b36d2",
      "name": "\ud83d\uddd1\ufe0f Delete Prev",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2464,
        -400
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"message_id\": {{ $json._prev_msg_id || 0 }}}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2601173f-9e00-41d0-b242-5e21532e2ac1",
      "name": "\ud83d\udcdd Prep Work Msg",
      "type": "n8n-nodes-base.code",
      "position": [
        -2640,
        -400
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREP WORK MSG - Create localized \"Searching...\" message for current work\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst loopItem = $('Loop').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(loopItem.chatId);\nconst L = loopItem.L || {};\nconst lang = L.search_lang || 'English';\n\nconst current = loopItem.work_index || 1;\nconst total = loopItem.total_works || 1;\nconst name = loopItem.name || loopItem.sq || 'Work';\nconst qty = loopItem.qty || 1;\nconst unit = loopItem.unit || 'm\u00b2';\n\nlet shortName = name.length > 30 ? name.substring(0, 27) + '...' : name;\n\n// Localized search messages\nconst SEARCH_WORD = {\n  'German': '\ud83d\udd0d Suche',\n  'English': '\ud83d\udd0d Searching',\n  'Russian': '\ud83d\udd0d \u041f\u043e\u0438\u0441\u043a',\n  'Spanish': '\ud83d\udd0d Buscando',\n  'French': '\ud83d\udd0d Recherche',\n  'Portuguese': '\ud83d\udd0d Buscando',\n  'Chinese': '\ud83d\udd0d \u641c\u7d22\u4e2d',\n  'Arabic': '\ud83d\udd0d \u0628\u062d\u062b',\n  'Hindi': '\ud83d\udd0d \u0916\u094b\u091c'\n};\n\nconst searchWord = SEARCH_WORD[lang] || '\ud83d\udd0d Searching';\n\n// Format: \"\ud83d\udd0d \u041f\u043e\u0438\u0441\u043a 3/26\\n\u0413\u0438\u043f\u0441\u043e\u043a\u0430\u0440\u0442\u043e\u043d 25m\u00b2\"\nlet text = `${searchWord} ${current}/${total}\\n`;\ntext += `*${shortName}*\\n`;\ntext += `${qty} ${unit}`;\n\nconst prevMsgId = sd.calcProgress?.[cid]?.lastMsgId || null;\n\nconsole.log('Work', current, '/', total, '-', shortName);\n\nreturn { json: { ...loopItem, _work_text: text, _prev_msg_id: prevMsgId } };"
      },
      "typeVersion": 2
    },
    {
      "id": "d676b99b-6ed0-4f16-b00a-a9182175d754",
      "name": "Loop",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -2816,
        -768
      ],
      "parameters": {
        "options": {
          "reset": false
        }
      },
      "typeVersion": 3
    },
    {
      "id": "b7c5ad07-b6f8-488b-8a1a-b398336545b5",
      "name": "Prep Works",
      "type": "n8n-nodes-base.code",
      "position": [
        -3104,
        -1024
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREP WORKS - Prepare work items for calculation loop (: 5 items)\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfg = $input.first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\n\n// Get all works (no limit)\nconst works = session.works || [];\nconst db = session.db || cfg.db;\nconst L = session.L || cfg.L;\n\n// Initialize results accumulator\nif (!sd.res) sd.res = {};\nsd.res[cid] = [];\n\nconst totalWorks = works.length;\n\nconsole.log('=== PREP WORKS ===');\nconsole.log('Processing:', totalWorks, 'items');\n\n// Return array for loop processing\nreturn works.map((w, idx) => ({ \n  json: { \n    ...w, \n    db, \n    L, \n    currency: L?.sym || '\u20ac',\n    bot_token: cfg.bot_token, \n    chatId: cid, \n    sq: w.query || w.name,\n    original_query: w.query || w.name,\n    work_index: idx + 1, \n    total_works: totalWorks,\n    _is_limited: cfg._is_limited,\n    _original_total: cfg._total_works\n  } \n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "0ffb3b95-d9e3-4c3d-83e6-1f909019b6a1",
      "name": "Save Progress ID",
      "type": "n8n-nodes-base.code",
      "position": [
        -3280,
        -1024
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// SAVE PROGRESS ID - Store initial progress message ID for cleanup\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfg = $('Config').first().json;\nconst prepData = $('\ud83d\udcdd Prep Progress').first().json;\nconst telegramResponse = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\n\n// Store progress message info\nif (!sd.progress) sd.progress = {};\nsd.progress[cid] = {\n  message_id: telegramResponse.result?.message_id || 0,\n  chat_id: cfg.chatId,\n  bot_token: cfg.bot_token\n};\n\n// Initialize calculation progress tracker\nif (!sd.calcProgress) sd.calcProgress = {};\nsd.calcProgress[cid] = { lastMsgId: null };\n\nconsole.log('Progress msg ID:', sd.progress[cid].message_id);\n\nreturn { \n  json: { \n    ...cfg,\n    _total_works: prepData._total_works\n  } \n};"
      },
      "typeVersion": 2
    },
    {
      "id": "76047230-a1cf-46b9-8eb4-9c85e1982969",
      "name": "\ud83d\udce4 Send Progress",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3488,
        -1024
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json._progress_chat_id }}, \"text\": {{ JSON.stringify($json._progress_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "d1cec9fe-6d9a-468d-8ba1-1f8b4112de53",
      "name": "\ud83d\udcdd Prep Progress",
      "type": "n8n-nodes-base.code",
      "position": [
        -3728,
        -1024
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREP PROGRESS - Show calculation progress message (localized)\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfg = $('Config').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\nconst session = sd.sess?.[cid] || {};\n\nconst allWorks = session.works || [];\nconst L = cfg.L || session.L || {};\nconst lang = cfg.lang || 'EN';\n\nconsole.log('=== PREP PROGRESS ===');\nconsole.log('Works:', allWorks.length);\nconsole.log('Language:', lang);\n\nconst totalWorks = allWorks.length;\nconst estimatedMinutes = Math.max(1, Math.ceil(totalWorks * 8 / 60));\n\n// Localized messages for \"Searching prices...\"\nconst SEARCH_MESSAGES = {\n  DE: `\ud83d\udd0d *Preissuche l\u00e4uft...*\\n\\n${totalWorks} Positionen\\n\u23f1 ~${estimatedMinutes} Min`,\n  EN: `\ud83d\udd0d *Searching prices...*\\n\\n${totalWorks} items\\n\u23f1 ~${estimatedMinutes} min`,\n  RU: `\ud83d\udd0d *\u041f\u043e\u0438\u0441\u043a \u0440\u0430\u0441\u0446\u0435\u043d\u043e\u043a...*\\n\\n${totalWorks} \u043f\u043e\u0437\u0438\u0446\u0438\u0439\\n\u23f1 ~${estimatedMinutes} \u043c\u0438\u043d`,\n  ES: `\ud83d\udd0d *Buscando precios...*\\n\\n${totalWorks} elementos\\n\u23f1 ~${estimatedMinutes} min`,\n  FR: `\ud83d\udd0d *Recherche des prix...*\\n\\n${totalWorks} \u00e9l\u00e9ments\\n\u23f1 ~${estimatedMinutes} min`,\n  PT: `\ud83d\udd0d *Buscando pre\u00e7os...*\\n\\n${totalWorks} itens\\n\u23f1 ~${estimatedMinutes} min`,\n  ZH: `\ud83d\udd0d *\u6b63\u5728\u641c\u7d22\u4ef7\u683c...*\\n\\n${totalWorks} \u9879\u76ee\\n\u23f1 ~${estimatedMinutes} \u5206\u949f`,\n  AR: `\ud83d\udd0d *\u062c\u0627\u0631\u064a \u0627\u0644\u0628\u062d\u062b \u0639\u0646 \u0627\u0644\u0623\u0633\u0639\u0627\u0631...*\\n\\n${totalWorks} \u0639\u0646\u0627\u0635\u0631\\n\u23f1 ~${estimatedMinutes} \u062f\u0642\u064a\u0642\u0629`,\n  HI: `\ud83d\udd0d *\u0915\u0940\u092e\u0924\u0947\u0902 \u0916\u094b\u091c \u0930\u0939\u0947 \u0939\u0948\u0902...*\\n\\n${totalWorks} \u0906\u0907\u091f\u092e\\n\u23f1 ~${estimatedMinutes} \u092e\u093f\u0928\u091f`\n};\n\nconst text = SEARCH_MESSAGES[lang] || SEARCH_MESSAGES['EN'];\n\n// Save to session\nsession.totalWorks = totalWorks;\n\nreturn {\n  json: {\n    ...cfg,\n    _progress_chat_id: cfg.chatId,\n    _progress_text: text,\n    _total_works: totalWorks\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "067f5f6a-7a4a-43fc-90ca-46e7976d0f1e",
      "name": "\ud83d\udce4 Send Works",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3328,
        -1696
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('\ud83d\udd11 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"text\": {{ JSON.stringify($json.msg) }}, \"parse_mode\": \"Markdown\", \"reply_markup\": {\"inline_keyboard\": {{ JSON.stringify($json.keyboard) }}}}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "d0f90abb-6cb7-4180-ba96-9a2e94b46062",
      "name": "\ud83d\udcca Show Works",
      "type": "n8n-nodes-base.code",
      "position": [
        -3472,
        -1696
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// SHOW WORKS - Display extracted work items with edit buttons\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst cfgNode = $('Config').first().json;\nconst inputData = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\n\nconst chatId = inputData.chatId || cfgNode.chatId;\nconst bot_token = inputData.bot_token || cfgNode.bot_token;\nconst cid = String(chatId);\n\n// Get L from multiple sources (session is most reliable)\nconst session = sd.sess?.[cid] || {};\nlet L = cfgNode.L || session.L || inputData.L || {};\n\n// Get works from input (from Deduplicate) or session\nconst works = inputData.works || session.works || [];\nconst rooms = inputData.rooms || session.rooms || [];\n\nconsole.log('=== SHOW WORKS ===');\nconsole.log('Works:', works.length);\nconsole.log('L.search_lang:', L.search_lang);\n\n// Save to session\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = {};\nif (works.length > 0) {\n  sd.sess[cid].works = works;\n  sd.sess[cid].rooms = rooms;\n  sd.sess[cid].L = L;\n  sd.sess[cid].db = cfgNode.db;\n  sd.sess[cid].state = 'wait_edit';\n}\n\nfunction short(str, len) {\n  str = String(str || '');\n  return str.length > len ? str.substring(0, len - 1) + '\u2026' : str;\n}\n\nconst totalArea = rooms.reduce((sum, r) => sum + (r.area_m2 || 0), 0);\n\n// Use localized labels with fallback\nconst roomWord = L.rooms || '\u043a\u043e\u043c\u043d\u0430\u0442';\nconst workWord = L.works_identified || '\u043f\u043e\u0437\u0438\u0446\u0438\u0439';\nconst generalWord = L.general || '\u041e\u0431\u0449\u0435\u0435';\n\nlet msg = '*' + rooms.length + ' ' + roomWord;\nif (totalArea > 0) msg += ' \u00b7 ' + (Math.round(totalArea * 10) / 10) + ' m\u00b2';\nmsg += '*\\n';\nmsg += '_' + works.length + ' ' + workWord + '_\\n\\n';\n\nif (works.length === 0) {\n  msg += '_' + (L.no_works || '\u0420\u0430\u0431\u043e\u0442\u044b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b') + '_\\n';\n} else {\n  const worksByRoom = new Map();\n  const noRoom = [];\n\n  for (const w of works) {\n    if (w.room && w.room.length > 0) {\n      if (!worksByRoom.has(w.room)) worksByRoom.set(w.room, []);\n      worksByRoom.get(w.room).push(w);\n    } else {\n      noRoom.push(w);\n    }\n  }\n\n  let workNum = 1;\n\n  for (const [roomName, roomWorks] of worksByRoom) {\n    const room = rooms.find(r => r.name === roomName);\n    const areaStr = room?.area_m2 ? ' \u00b7 ' + room.area_m2 + ' m\u00b2' : '';\n    msg += '*' + short(roomName, 20) + '*' + areaStr + '\\n';\n\n    for (const w of roomWorks) {\n      const wname = short(w.name || 'Work', 25);\n      const qty = typeof w.qty === 'number' ? Math.round(w.qty * 100) / 100 : w.qty;\n      msg += workNum + '. ' + wname + ' \u2014 ' + qty + ' ' + (w.unit || '') + '\\n';\n      workNum++;\n    }\n    msg += '\\n';\n  }\n\n  if (noRoom.length > 0) {\n    msg += '*' + generalWord + '*\\n';\n    for (const w of noRoom) {\n      const wname = short(w.name || 'Work', 25);\n      const qty = typeof w.qty === 'number' ? Math.round(w.qty * 100) / 100 : w.qty;\n      msg += workNum + '. ' + wname + ' \u2014 ' + qty + ' ' + (w.unit || '') + '\\n';\n      workNum++;\n    }\n  }\n}\n\n// Build keyboard\nconst keyboard = [];\nconst maxBtns = works.length;\nif (maxBtns > 0) {\n  for (let i = 0; i < maxBtns; i += 5) {\n    const row = [];\n    for (let j = 0; j < 5 && i + j < maxBtns; j++) {\n      row.push({ text: '\u270f' + (i + j + 1), callback_data: 'edit_work_' + (i + j) });\n    }\n    keyboard.push(row);\n  }\n}\n\nkeyboard.push([\n  { text: L.btn_add_work || '+ \u041f\u043e\u0437\u0438\u0446\u0438\u044f', callback_data: 'add_work' },\n  { text: L.btn_calc || '\u25b6 \u0420\u0430\u0441\u0447\u0451\u0442', callback_data: 'calculate' }\n]);\nkeyboard.push([\n  { text: L.btn_new || '\ud83d\udd04 \u0417\u0430\u043d\u043e\u0432\u043e', callback_data: 'restart' }\n]);\n\nreturn { json: { chatId, bot_token, L, msg, keyboard, works, rooms } };"
      },
      "typeVersion": 2
    },
    {
      "id": "420db4a8-19cf-43db-8289-bc932bee2a54",
      "name": "9\ufe0f\u20e3 Calculate",
      "type": "n8n-nodes-base.code",
      "position": [
        -2464,
        0
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// STEP 8: Calculate - FIXED VERSION v3\n// \u0423\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0438\u0441\u043a \u0434\u0430\u043d\u043d\u044b\u0445 \u0432 \u0440\u0430\u0437\u043d\u044b\u0445 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430\u0445\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst inputData = $input.first().json;\n\nconsole.log('\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');\nconsole.log('STEP 8: CALCULATE v3');\nconsole.log('\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u0423\u041c\u041d\u042b\u0419 \u041f\u041e\u0418\u0421\u041a PAYLOAD - \u0438\u0449\u0435\u043c \u0432\u043e \u0432\u0441\u0435\u0445 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u044b\u0445 \u043c\u0435\u0441\u0442\u0430\u0445\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nlet payload = null;\nlet payloadSource = 'not_found';\n\n// \u0412\u0430\u0440\u0438\u0430\u043d\u0442 1: _best_result.payload\nif (inputData._best_result?.payload?.rate_code) {\n  payload = inputData._best_result.payload;\n  payloadSource = '_best_result.payload';\n}\n// \u0412\u0430\u0440\u0438\u0430\u043d\u0442 2: _best_payload \u043d\u0430\u043f\u0440\u044f\u043c\u0443\u044e\nelse if (inputData._best_payload?.rate_code) {\n  payload = inputData._best_payload;\n  payloadSource = '_best_payload';\n}\n// \u0412\u0430\u0440\u0438\u0430\u043d\u0442 3: \u0434\u0430\u043d\u043d\u044b\u0435 \u043b\u0435\u0436\u0430\u0442 \u043f\u0440\u044f\u043c\u043e \u0432 _best_result (\u0431\u0435\u0437 \u0432\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0433\u043e payload)\nelse if (inputData._best_result?.rate_code) {\n  payload = inputData._best_result;\n  payloadSource = '_best_result (direct)';\n}\n// \u0412\u0430\u0440\u0438\u0430\u043d\u0442 4: payload \u0432\u043d\u0443\u0442\u0440\u0438 payload (\u0434\u0432\u043e\u0439\u043d\u0430\u044f \u0432\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c)\nelse if (inputData._best_result?.payload?.payload?.rate_code) {\n  payload = inputData._best_result.payload.payload;\n  payloadSource = '_best_result.payload.payload';\n}\n// \u0412\u0430\u0440\u0438\u0430\u043d\u0442 5: \u0438\u0449\u0435\u043c rate_code \u0433\u0434\u0435 \u0443\u0433\u043e\u0434\u043d\u043e \u0432 _best_result\nelse if (inputData._best_result) {\n  const br = inputData._best_result;\n  // \u0420\u0435\u043a\u0443\u0440\u0441\u0438\u0432\u043d\u044b\u0439 \u043f\u043e\u0438\u0441\u043a\n  const findPayload = (obj, depth = 0) => {\n    if (!obj || depth > 3) return null;\n    if (obj.rate_code && obj.resources) return obj;\n    for (const key of Object.keys(obj)) {\n      if (typeof obj[key] === 'object' && obj[key] !== null) {\n        const found = findPayload(obj[key], depth + 1);\n        if (found) return found;\n      }\n    }\n    return null;\n  };\n  payload = findPayload(br);\n  if (payload) payloadSource = '_best_result (deep search)';\n}\n\nconsole.log('Payload source:', payloadSource);\nconsole.log('Payload found:', !!payload);\n\nif (payload) {\n  console.log('Payload keys:', Object.keys(payload));\n  console.log('rate_code:', payload.rate_code);\n  console.log('rate_name:', payload.rate_name?.substring(0, 50));\n  console.log('resources count:', (payload.resources || []).length);\n  console.log('cost_summary:', JSON.stringify(payload.cost_summary || {}).substring(0, 200));\n}\n\n// \u0415\u0441\u043b\u0438 payload \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d - \u0432\u044b\u0432\u043e\u0434\u0438\u043c \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443 \u0434\u043b\u044f \u0434\u0438\u0430\u0433\u043d\u043e\u0441\u0442\u0438\u043a\u0438\nif (!payload) {\n  console.log('');\n  console.log('\u274c PAYLOAD NOT FOUND! Debug info:');\n  console.log('inputData keys:', Object.keys(inputData));\n  if (inputData._best_result) {\n    console.log('_best_result keys:', Object.keys(inputData._best_result));\n    console.log('_best_result.payload:', typeof inputData._best_result.payload);\n    if (inputData._best_result.payload) {\n      console.log('_best_result.payload keys:', Object.keys(inputData._best_result.payload));\n    }\n  }\n  \n  // \u0412\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c \u043e\u0448\u0438\u0431\u043a\u0443 \u0441 \u0434\u0438\u0430\u0433\u043d\u043e\u0441\u0442\u0438\u043a\u043e\u0439\n  return [{ json: { \n    ...inputData,\n    rate_code: 'PAYLOAD_NOT_FOUND',\n    rate_name: inputData.name || inputData.sq || 'Unknown',\n    uc: 0, tc: 0,\n    resources: [],\n    _debug_keys: Object.keys(inputData),\n    _debug_best_result_keys: Object.keys(inputData._best_result || {}),\n    _debug_payload_source: payloadSource\n  }}];\n}\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u0422\u0435\u043f\u0435\u0440\u044c payload \u043d\u0430\u0439\u0434\u0435\u043d - \u0438\u0437\u0432\u043b\u0435\u043a\u0430\u0435\u043c \u0434\u0430\u043d\u043d\u044b\u0435\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n// \u0411\u0430\u0437\u043e\u0432\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0440\u0430\u0431\u043e\u0442\u044b\nconst workName = inputData.name || inputData.sq || '';\nconst workQty = inputData.qty || 1;\nconst workUnit = inputData.unit || 'm\u00b2';\n\nconsole.log('');\nconsole.log('Work:', workName);\nconsole.log('Qty:', workQty, workUnit);\n\n// \u0414\u0430\u043d\u043d\u044b\u0435 \u0440\u0430\u0441\u0446\u0435\u043d\u043a\u0438\nconst rateCode = payload.rate_code || 'NOT_FOUND';\nconst rateName = payload.rate_name || workName;\nconst rateUnit = payload.rate_unit || '';\n\nconsole.log('Rate code:', rateCode);\nconsole.log('Rate name:', rateName?.substring(0, 50));\nconsole.log('Rate unit:', rateUnit);\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u041f\u043e\u043b\u0443\u0447\u0430\u0435\u043c \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst costSummary = payload.cost_summary || {};\nlet totalCost = parseFloat(costSummary.total_cost_position || costSummary.total_cost || 0);\n\n// \u0415\u0441\u043b\u0438 cost_summary \u043f\u0443\u0441\u0442\u043e\u0439, \u0432\u044b\u0447\u0438\u0441\u043b\u044f\u0435\u043c \u0438\u0437 \u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\nconst rawResources = payload.resources || [];\nif (totalCost === 0 && rawResources.length > 0) {\n  totalCost = rawResources.reduce((sum, r) => {\n    return sum + parseFloat(r.resource_cost_eur || 0);\n  }, 0);\n  console.log('Total cost calculated from resources:', totalCost);\n}\n\nconsole.log('Total cost:', totalCost);\nconsole.log('Resources count:', rawResources.length);\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u041e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u043c \u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0435\u0434\u0438\u043d\u0438\u0446\u044b \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet unitDivisor = 1;\nconst rateUnitLower = (rateUnit || '').toLowerCase();\n\nif (rateUnitLower.includes('100 ') || rateUnitLower === '100 \u043c' || rateUnitLower === '100 \u043c\u00b2' || rateUnitLower === '100 \u043c2') {\n  unitDivisor = 100;\n} else if (rateUnitLower.match(/^10\\s/) || rateUnitLower.includes('10 \u043c')) {\n  unitDivisor = 10;\n}\n\nconsole.log('Unit divisor:', unitDivisor);\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// Price calculation\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst uc = unitDivisor > 0 ? totalCost / unitDivisor : 0;\nconst tc = workQty * uc;\nconst scaleFactor = unitDivisor > 0 ? workQty / unitDivisor : workQty;\n\nconsole.log('');\nconsole.log('=== PRICE CALCULATION ===');\nconsole.log('UC (price per unit):', uc.toFixed(2), 'EUR');\nconsole.log('TC (total cost):', tc.toFixed(2), 'EUR');\nconsole.log('Scale factor:', scaleFactor);\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet workersTotal = 0;\nlet materialsTotal = 0;\nlet machinesTotal = 0;\nlet laborHoursTotal = 0;\n\nconst resources = rawResources.map(r => {\n  const code = r.resource_code || '';\n  const name = r.resource_name || '';\n  const unit = r.resource_unit || '';\n  const rowType = r.row_type || '';\n  \n  const originalQty = r.resource_quantity !== null && r.resource_quantity !== undefined \n    ? parseFloat(r.resource_quantity) \n    : null;\n  const pricePerUnit = parseFloat(r.resource_price_per_unit_eur_current || 0);\n  const originalCost = parseFloat(r.resource_cost_eur || 0);\n  \n  // \u041e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u043c \u0442\u0438\u043f \u0440\u0435\u0441\u0443\u0440\u0441\u0430\n  let resourceType = 'material';\n  const rowTypeLower = (rowType || '').toLowerCase();\n  const codeUpper = (code || '').toUpperCase();\n  \n  if (rowTypeLower === '\u043c\u0430\u0448\u0438\u043d\u0438\u0441\u0442' || rowTypeLower.includes('\u043c\u0430\u0448\u0438\u043d\u0438\u0441\u0442')) {\n    resourceType = 'labor';\n  } else if (rowTypeLower === '\u044d\u043b\u0435\u043a\u0442\u0440\u0438\u0447\u0435\u0441\u0442\u0432\u043e' || rowTypeLower.includes('\u044d\u043b\u0435\u043a\u0442\u0440\u0438\u0447\u0435\u0441\u0442\u0432')) {\n    resourceType = 'machine';\n  } else if (codeUpper.startsWith('DXME') || codeUpper.startsWith('DX')) {\n    resourceType = 'machine';\n  } else if (codeUpper.startsWith('ME_') || codeUpper.startsWith('PU_') || codeUpper.startsWith('RI_')) {\n    resourceType = 'labor';\n  }\n  \n  // \u041c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\n  let scaledQty = null;\n  let scaledCost = 0;\n  \n  if (originalQty !== null) {\n    scaledQty = originalQty * scaleFactor;\n    scaledCost = scaledQty * pricePerUnit;\n  } else {\n    scaledCost = originalCost * scaleFactor;\n  }\n 

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

This Telegram bot turns natural language descriptions of construction work into reliable cost estimates by parsing the request with OpenAI, running vector search across the DDC CWICR database of over 55,000 work items, and returning structured figures. Contractors and estimators use it when they need fast ballpark numbers from verbal or texted briefs without opening spreadsheets. The key step is the AI-driven lookup that matches plain-English scope to the correct line items and unit rates.

Use it for preliminary pricing on small to medium jobs where speed matters more than formal tender accuracy. Avoid it when clients demand fully itemised, contract-grade breakdowns or when local rates and regulations require manual verification. A common variation replaces OpenAI with Google Gemini for cost-sensitive or region-specific deployments.

About this workflow

A Telegram bot that converts natural-language work descriptions into detailed cost estimates using AI parsing, vector search, and the open-source DDC CWICR database with 55,000+ construction work items. Contractors & Estimators who need quick ballpark figures from verbal/text…

Source: https://n8n.io/workflows/12174/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.

Form Trigger, HTTP Request, OpenAI Chat +15
AI & RAG

Episode 23: UGC with nanobanana. Uses lmChatOpenAi, lmChatOllama, lmChatDeepSeek, lmChatOpenRouter. Event-driven trigger; 74 nodes.

OpenAI Chat, Ollama Chat, Lm Chat Deep Seek +12
AI & RAG

This workflow is designed for marketers, content creators, agencies, and solo founders who want to publish long‑form posts with visuals on autopilot using n8n and AI agents. ​

Tool Http Request, Agent, HTTP Request +27
AI & RAG

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

Output Parser Structured, Telegram, N8N Nodes Tesseractjs +14
AI & RAG

Ultimate Blogblizt is a powerhouse workflow that solves the tedious task of crafting and publishing SEO-optimized tech blog posts. It integrates AI models (OpenAI, Google Gemini), WordPress, and multi

Chain Llm, Telegram Trigger, OpenAI Chat +10