Assistant intelligent pour les rapports de vente Loyverse

Avancé

Ceci est unDocument Extractionworkflow d'automatisation du domainecontenant 21 nœuds.Utilise principalement des nœuds comme Code, EmailSend, HttpRequest, GoogleSheets, ManualTrigger. Analyse financière quotidienne automatisée à partir de Loyverse POS via Google Sheets et e-mail

Prérequis
  • Peut nécessiter les informations d'identification d'authentification de l'API cible
  • Informations d'identification Google Sheets API
Aperçu du workflow
Visualisation des connexions entre les nœuds, avec support du zoom et du déplacement
Exporter le workflow
Copiez la configuration JSON suivante dans n8n pour importer et utiliser ce workflow
{
  "id": "LtlVKYumh3ovfRvp",
  "meta": {
    "instanceId": "e9910e69e07bf6e9ce2cd1f3b96d2f64b6b6d14f6f393c47edf2af31f3104eca",
    "templateCredsSetupCompleted": true
  },
  "name": "Loyverse Sales Report Agent",
  "tags": [
    {
      "id": "aoGcelnN4BThaYFi",
      "name": "template",
      "createdAt": "2025-11-01T10:13:47.960Z",
      "updatedAt": "2025-11-01T10:13:47.960Z"
    }
  ],
  "nodes": [
    {
      "id": "8ccf3480-8a71-41af-bef2-2a3e8e84778a",
      "name": "Lors du clic sur 'Exécuter le workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -240,
        -400
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "b1406a9b-d6e9-4a0d-8f6d-0f7d3ee7170a",
      "name": "Lire les Données Historiques",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        976,
        -176
      ],
      "parameters": {
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": ""
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "O4vZa1eFgJYJCpqg",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6,
      "alwaysOutputData": true
    },
    {
      "id": "909cfa2e-7e99-44e7-ab44-60d461f11754",
      "name": "Calculer Tous les Indicateurs",
      "type": "n8n-nodes-base.code",
      "position": [
        1232,
        -176
      ],
      "parameters": {
        "jsCode": "// --- Read Config ---\n// Read the configuration from the MASTER CONFIG node\nconst CONFIG = $node[\"MASTER CONFIG\"].json.loyverse_ids;\n\n// --- Main Logic ---\nconst shiftsData = $items(\"Get Yesterday's Shifts From Loyverse\");\nconst receiptsData = $items(\"Get Yesterday's Receipts From Loyverse\");\nconst itemMasterData = $items(\"Format Product Data\");\nconst historicalData = $items(\"Read Historical Data\").map(item => item.json);\nconst shifts = (shiftsData.length > 0 && shiftsData[0].json) ? shiftsData[0].json.shifts || [] : [];\nconst receipts = (receiptsData.length > 0 && receiptsData[0].json) ? receiptsData[0].json.receipts || [] : [];\nconst allItems = itemMasterData.map(item => item.json);\n\nif (shifts.length === 0 && receipts.length === 0) {\n  return [{ json: { \"message\": \"No shifts or receipts found to process.\" } }];\n}\n\n// Build a map of all items for fast lookup\nconst itemMap = {};\nfor (const item of allItems) {\n  itemMap[item.variant_id] = item;\n}\n\nconst businessDateString = $items(\"Calculate Shift Time\")[0].json.businessDate;\nconst businessDate = new Date(businessDateString + 'T12:00:00Z');\n\n// --- Initialize Metrics ---\nconst metrics = {\n  date: businessDateString,\n  weekday: businessDate.toLocaleString('en-US', { timeZone: 'UTC', weekday: 'short' }),\n  gross_profit: 0,\n  net_operating_profit: 0,\n  best_seller_by_quantity_name: 'N/A',\n  best_seller_by_quantity_count: 0,\n  best_seller_by_profit_name: 'N/A',\n  best_seller_by_profit_amount: 0,\n  weekday_performance_vs_avg: 'N/A',\n  weekday_rank: 'N/A',\n  month_performance_vs_avg: 'N/A',\n  ATV: 0,\n  IPT: 0,\n  total_discounts_amount: 0,\n  total_discounts_count: 0,\n  rolling_30_day_nop: 0,\n  wtd_net_operating_profit: 0,\n  profit_tendency: '➡️',\n  // New generic totals\n  total_cash_net: 0,\n  total_qr_payments: 0,\n  total_cash_difference: 0,\n  totalGrossRevenue: 0 // Will be calculated from receipts\n};\n\n// Dynamically create category metrics\nfor (const categoryName of Object.values(CONFIG.CATEGORIES)) {\n  const safeCategoryName = categoryName.replace(/ /g, '_').replace(/\\./g, '');\n  metrics[`${safeCategoryName}_items_sold`] = 0;\n  metrics[`${safeCategoryName}_revenue`] = 0;\n  metrics[`${safeCategoryName}_profit`] = 0;\n}\n\n// --- Process Shifts (Flexible) ---\nlet total_paid_out = 0;\nconst relevantShifts = shifts.filter(s => CONFIG.pos_device_ids.includes(s.pos_device_id));\n\nfor (const shift of relevantShifts) {\n  metrics.total_cash_net += (shift.actual_cash - shift.starting_cash);\n  metrics.total_discounts_amount += shift.discounts || 0;\n  metrics.total_cash_difference += (shift.actual_cash - shift.expected_cash);\n  total_paid_out += shift.paid_out || 0;\n\n  // Find all QR payments for this shift\n  for (const payment of shift.payments) {\n    if (CONFIG.qr_payment_type_ids.includes(payment.payment_type_id)) {\n      metrics.total_qr_payments += payment.money_amount;\n    }\n  }\n}\n\n// --- Process Receipts ---\nconst itemCounts = {};\nconst itemProfits = {};\nlet totalItemsSold = 0;\n\nfor (const receipt of receipts) {\n  if (receipt.total_discounts > 0) {\n    metrics.total_discounts_count++;\n  }\n\n  for (const lineItem of receipt.line_items) {\n    totalItemsSold += lineItem.quantity;\n    const masterItem = itemMap[lineItem.variant_id];\n    if (!masterItem) continue;\n\n    const cost = masterItem.cost || 0;\n    const grossRevenue = lineItem.total_money || 0;\n    metrics.totalGrossRevenue += grossRevenue; // Use this as the main revenue metric\n    const revenueBeforeVat = (grossRevenue / 107) * 100;\n    const profit = revenueBeforeVat - (cost * lineItem.quantity);\n    \n    metrics.gross_profit += profit;\n\n    const itemName = masterItem.variant_name === 'Default' ? masterItem.item_name : `${masterItem.item_name} (${masterItem.variant_name})`;\n    itemCounts[itemName] = (itemCounts[itemName] || 0) + lineItem.quantity;\n    itemProfits[itemName] = (itemProfits[itemName] || 0) + profit;\n\n    // Add to category totals\n    const categoryName = CONFIG.CATEGORIES[masterItem.category_id];\n    if (categoryName) {\n      const safeCategoryName = categoryName.replace(/ /g, '_').replace(/\\./g, '');\n      metrics[`${safeCategoryName}_revenue`] += grossRevenue;\n      metrics[`${safeCategoryName}_profit`] += profit;\n    }\n  }\n}\n\n// --- Final Calculations ---\nmetrics.net_operating_profit = metrics.gross_profit - total_paid_out;\n\nif (receipts.length > 0) {\n  metrics.ATV = metrics.totalGrossRevenue / receipts.length;\n  metrics.IPT = totalItemsSold / receipts.length;\n}\n\n// Best Seller (Quantity)\nlet maxQuantity = 0;\nfor (const itemName in itemCounts) {\n  if (itemCounts[itemName] > maxQuantity) {\n    maxQuantity = itemCounts[itemName];\n    metrics.best_seller_by_quantity_name = itemName;\n    metrics.best_seller_by_quantity_count = maxQuantity;\n  }\n}\n\n// Best Seller (Profit)\nlet maxProfit = -Infinity;\nfor (const itemName in itemProfits) {\n  if (itemProfits[itemName] > maxProfit) {\n    maxProfit = itemProfits[itemName];\n    metrics.best_seller_by_profit_name = itemName;\n    metrics.best_seller_by_profit_amount = maxProfit;\n  }\n}\n\n// --- Historical Calculations ---\n// Note: This uses totalGrossRevenue (from receipts) as the \"Total Revenue\"\nconst todayRevenue = metrics.totalGrossRevenue; \nconst todayWeekday = metrics.weekday;\nconst todayMonth = businessDate.getMonth();\nconst todayYear = businessDate.getFullYear();\nconst oneYearAgo = new Date();\noneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);\n\n// Weekday Performance\nconst sameWeekdayHistory = historicalData.filter(row => {\n  const rowDate = new Date(row.Date);\n  const rowWeekday = rowDate.toLocaleString('en-US', { timeZone: 'UTC', weekday: 'short' });\n  return rowWeekday === todayWeekday && rowDate >= oneYearAgo;\n});\nif (sameWeekdayHistory.length > 0) {\n  const totalRevenue = sameWeekdayHistory.reduce((sum, row) => sum + (parseFloat(row['Total Revenue']) || 0), 0);\n  const avgRevenue = totalRevenue / sameWeekdayHistory.length;\n  metrics.weekday_performance_vs_avg = (((todayRevenue / avgRevenue) - 1) * 100).toFixed(2) + '%';\n  const allRevenues = sameWeekdayHistory.map(row => parseFloat(row['Total Revenue']) || 0);\n  allRevenues.push(todayRevenue);\n  allRevenues.sort((a, b) => b - a);\n  const rank = allRevenues.indexOf(todayRevenue) + 1;\n  metrics.weekday_rank = `${rank} of ${allRevenues.length}`;\n}\n\n// Month Performance\nconst sameMonthHistory = historicalData.filter(row => {\n  const rowDate = new Date(row.Date);\n  return rowDate.getMonth() === todayMonth && rowDate.getFullYear() === todayYear;\n});\nif (sameMonthHistory.length > 0) {\n  const totalRevenue = sameMonthHistory.reduce((sum, row) => sum + (parseFloat(row['Total Revenue']) || 0), 0);\n  const avgRevenue = totalRevenue / sameMonthHistory.length;\n  metrics.month_performance_vs_avg = (((todayRevenue / avgRevenue) - 1) * 100).toFixed(2) + '%';\n}\n\n// --- Rolling 30-Day & WTD Calculations ---\nfunction getStartOfWeek(date) {\n  const d = new Date(date);\n  const day = d.getDay();\n  const diff = d.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is sunday\n  d.setHours(0, 0, 0, 0); // Normalize time\n  return new Date(d.setDate(diff));\n}\n\nconst today = businessDate; \nconst startOfCurrentWeek = getStartOfWeek(today);\n\n// Rolling 30-Day\nconst endDate = new Date(today);\nconst startDate = new Date(today);\nstartDate.setDate(startDate.getDate() - 29); // 30-day window\nconst rolling30DayData = historicalData.filter(row => {\n  const rowDate = new Date(row.Date + 'T12:00:00Z');\n  return rowDate >= startDate && rowDate < endDate;\n});\nconst historical30DayProfit = rolling30DayData.reduce((sum, row) => {\n  return sum + (parseFloat(row['Net Operating Profit']) || 0);\n}, 0);\nmetrics.rolling_30_day_nop = historical30DayProfit + metrics.net_operating_profit;\n\n// Week-to-Date\nconst sameWeekData = historicalData.filter(row => {\n  const rowDate = new Date(row.Date + 'T12:00:00Z');\n  return rowDate >= startOfCurrentWeek && rowDate < today;\n});\nconst historicalWtdProfit = sameWeekData.reduce((sum, row) => {\n  return sum + (parseFloat(row['Net Operating Profit']) || 0);\n}, 0);\nmetrics.wtd_net_operating_profit = historicalWtdProfit + metrics.net_operating_profit;\n\n// Profit Tendency\nconst fourWeeksAgo = new Date(startOfCurrentWeek);\nfourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28);\nconst last4WeeksData = historicalData.filter(row => {\n  const rowDate = new Date(row.Date + 'T12:00:00Z');\n  return rowDate >= fourWeeksAgo && rowDate < startOfCurrentWeek;\n});\nif (last4WeeksData.length > 0) {\n  const last4WeeksTotalProfit = last4WeeksData.reduce((sum, row) => {\n    return sum + (parseFloat(row['Net Operating Profit']) || 0);\n  }, 0);\n  const avgWeeklyProfit = last4WeeksTotalProfit / 4;\n  if (metrics.wtd_net_operating_profit > avgWeeklyProfit) {\n    metrics.profit_tendency = '⬆️';\n  } else if (metrics.wtd_net_operating_profit < avgWeeklyProfit) {\n    metrics.profit_tendency = '⬇️';\n  }\n}\n\nreturn [{ json: metrics }];"
      },
      "typeVersion": 2
    },
    {
      "id": "9a04bbb6-1e5b-4c23-a7f6-810cfda646a9",
      "name": "Obtenir tous les produits de Loyverse",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        16,
        -176
      ],
      "parameters": {
        "url": "https://api.loyverse.com/v1.0/items",
        "options": {},
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "limit",
              "value": "250"
            }
          ]
        }
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "iud3HyaSMK1hjAab",
          "name": "Loyverse Token Auth"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "01f719aa-f043-4db0-866c-d8125802f0e3",
      "name": "Formater les Données Produit",
      "type": "n8n-nodes-base.code",
      "position": [
        240,
        -176
      ],
      "parameters": {
        "jsCode": "\nconst allItems = $items(\"Get all products from Loyverse\")[0].json.items;\nconst flattenedItems = [];\n\nfor (const item of allItems) {\n  if (item.variants && item.variants.length > 0) {\n    for (const variant of item.variants) {\n      const variantStoreInfo = variant.stores && variant.stores.length > 0 ? variant.stores[0] : {};\n      const itemStoreInfo = item.stores && item.stores.length > 0 ? item.stores[0] : {};\n\n      flattenedItems.push({\n        json: {\n          variant_id: variant.variant_id,\n          barcode: variant.barcode || item.barcode,\n          cost: variant.cost,\n          price: variantStoreInfo.price !== undefined ? variantStoreInfo.price : itemStoreInfo.price,\n          sku: variant.sku,\n          available_for_sale: variant.available_for_sale,\n          option1_value: variant.option1_value,\n          option2_value: variant.option2_value,\n          option3_value: variant.option3_value,\n          item_id: item.id,\n          handle: item.handle,\n          item_name: item.item_name,\n          is_composite: item.is_composite,\n          category_id: item.category_id,\n          modifier_ids: item.modifier_ids ? item.modifier_ids.join(', ') : null,\n          option1_name: item.option1_name,\n          option2_name: item.option2_name,\n          option3_name: item.option3_name,\n          created_at: item.created_at,\n          updated_at: item.updated_at,\n          deleted_at: item.deleted_at,\n          // **NEW:** Add the components array for this variant\n          components: variant.components || null\n        }\n      });\n    }\n  } else {\n    console.log(`Warning: Item '${item.item_name}' (ID: ${item.id}) has no variants array and will be skipped.`);\n  }\n}\n\nreturn flattenedItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "31c1d0ca-bca2-44c8-ae22-2915fcef54d0",
      "name": "Calculer l'Horaire de Poste",
      "type": "n8n-nodes-base.code",
      "position": [
        672,
        -656
      ],
      "parameters": {
        "jsCode": "// --- CONFIGURATION ---\n// Get config from the MASTER CONFIG node\nconst config = $node[\"MASTER CONFIG\"].json.business_settings;\n\n// Get the shift end time (e.g., \"02:00\") from the config\nconst shiftEndTime = config.shiftEnd; \n\n// Get the timezone (e.g., \"Asia/Bangkok\")\nconst TIMEZONE = config.timezone;\n\n\n// 1. Calculate the new \"Changeover\" time\n// Parse the \"HH:mm\" string into numbers\nconst [endHour, endMinute] = shiftEndTime.split(':').map(Number);\n\n// Calculate the changeover time (2 hours after shift end)\n// setHours() is smart and handles hour numbers > 23 automatically\nconst changeoverHour = endHour + 2; \nconst changeoverMinute = endMinute;\n\n// 2. Get the current time in your specified timezone\nconst nowInUserTz = new Date(new Date().toLocaleString('en-US', { timeZone: TIMEZONE }));\n\n// 3. Determine the end of the report window\n// First, set a potential end date to *today's* changeover time\nlet reportEndDate = new Date(nowInUserTz);\nreportEndDate.setHours(changeoverHour, changeoverMinute, 0, 0);\n\n// 4. Check if the current time is *before* today's changeover\n// We must create a separate date object for this comparison\nlet changeoverToday = new Date(nowInUserTz);\nchangeoverToday.setHours(changeoverHour, changeoverMinute, 0, 0);\n\n// If \"now\" is before the changeover, the business day hasn't ended.\n// We must report on the *previous* day's window.\nif (nowInUserTz < changeoverToday) {\n  // Wind the report end date back to *yesterday's* changeover time\n  reportEndDate.setDate(reportEndDate.getDate() - 1);\n}\n\n// 5. Calculate the start of the report window\n// The API query window starts exactly 24 hours before the end time.\nconst reportStartDate = new Date(reportEndDate.getTime() - (24 * 60 * 60 * 1000));\n\n// 6. Determine the \"business date\" (the calendar date the shift started)\nconst year = reportStartDate.getFullYear();\nconst month = String(reportStartDate.getMonth() + 1).padStart(2, '0');\nconst day = String(reportStartDate.getDate()).padStart(2, '0');\nconst businessDateString = `${year}-${month}-${day}`;\n\n// 7. Return the dates as UTC ISO strings for the Loyverse API\nreturn [{ \n  json: {\n    start_of_day: reportStartDate.toISOString(), \n    end_of_day: reportEndDate.toISOString(),\n    businessDate: businessDateString // Pass the correct date to the next nodes\n  }\n}];"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "90a4e282-7492-4149-b663-123e7ece77c6",
      "name": "Obtenir les Postes d'Hier de Loyverse",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        912,
        -656
      ],
      "parameters": {
        "url": "https://api.loyverse.com/v1.0/shifts",
        "options": {},
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "created_at_min",
              "value": "={{ $('Calculate Shift Time').item.json.start_of_day }}"
            },
            {
              "name": "created_at_max",
              "value": "={{ $('Calculate Shift Time').item.json.end_of_day }}"
            }
          ]
        }
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "iud3HyaSMK1hjAab",
          "name": "Loyverse Token Auth"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "5b7c50f6-6ff7-4e45-968e-33019fcd028c",
      "name": "Obtenir les Reçus d'Hier de Loyverse",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1120,
        -656
      ],
      "parameters": {
        "url": "https://api.loyverse.com/v1.0/receipts\n",
        "options": {},
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "created_at_min",
              "value": "={{ $('Calculate Shift Time').item.json.start_of_day }}"
            },
            {
              "name": "created_at_max",
              "value": "={{ $('Calculate Shift Time').item.json.end_of_day }}"
            },
            {
              "name": "limit",
              "value": "250"
            }
          ]
        }
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "iud3HyaSMK1hjAab",
          "name": "Loyverse Token Auth"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "111f10b7-9c40-443e-9538-4e53e45cc66f",
      "name": "Envoyer un email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        1616,
        -672
      ],
      "webhookId": "484945f4-c7f6-40ad-9b50-cd2cee2c3ef1",
      "parameters": {
        "text": "==📊 REPORT 📊\n👇👇👇👇👇👇👇\n {{ $items(\"Calculate All Metrics\")[0].json.weekday }} , {{ $items(\"Calculate All Metrics\")[0].json.date }}\n\n💰 Total income: {{ $items(\"Calculate All Metrics\")[0].json.totalGrossRevenue.toFixed(2)}} THB\n🤑 Final Net Profit: {{ $items(\"Calculate All Metrics\")[0].json.net_operating_profit.toFixed(2) }} THB\n\n⎯⎯⎯⎯⎯⎯⎯\n\n🚨 CASH HANDLING 🚨\n\nTotal Cash Difference: {{ $items(\"Calculate All Metrics\")[0].json.total_cash_difference.toFixed(2) }} THB\n\n⎯⎯⎯⎯⎯⎯⎯\n\n📈 PERFORMANCE 📉\n\n...compared with same day average: \n{{ $items(\"Calculate All Metrics\")[0].json.weekday_performance_vs_avg }}\n\n📊 CUMULATIVE NET OPERATING PROFIT 📊\n\n👉 This Week: {{ $items(\"Calculate All Metrics\")[0].json.wtd_net_operating_profit.toFixed(2) }} THB\n👉 30 days rolling: {{ $items(\"Calculate All Metrics\")[0].json.rolling_30_day_nop.toFixed(2) }} THB\n👉 Trend (vs. last 4wks): {{ $items(\"Calculate All Metrics\")[0].json.profit_tendency }}",
        "options": {},
        "subject": "=Daily Report for {{ $items(\"Calculate All Metrics\")[0].json.date }}",
        "toEmail": "={{ $node[\"MASTER CONFIG\"].json.business_settings.reportEmailReceiver }}",
        "fromEmail": "report@yourbusiness.com",
        "emailFormat": "text"
      },
      "credentials": {
        "smtp": {
          "id": "RxMHZcojPtoJVqF7",
          "name": "SMTP account"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "14920d77-308a-47d6-b4f9-655aa481e463",
      "name": "Exécuter Quotidiennement à 8h15 (modifiable)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -256,
        -176
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 8,
              "triggerAtMinute": 15
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "9392d0e5-7026-4fbf-87b5-f5dcdea522ce",
      "name": "Note Adhésive",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -96,
        -768
      ],
      "parameters": {
        "color": 3,
        "width": 672,
        "height": 448,
        "content": "# SETUP STEP 2/4\n## 1. Make a copy of this Spreadsheet: https://docs.google.com/spreadsheets/d/1DlEUo3mQUaxn2HEp34m7VAath8L3RDuPy5zFCljSZHE/edit?usp=sharing\n## 2. Open the MASTER CONFIG node below and edit the necessary variables:"
      },
      "typeVersion": 1
    },
    {
      "id": "c0ab3a86-0c8b-4eba-a61d-a0d3259b031d",
      "name": "Note Adhésive1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -32,
        -288
      ],
      "parameters": {
        "color": 5,
        "width": 432,
        "height": 352,
        "content": "## Get Product Data from Loyverse\nNo Changes required here!"
      },
      "typeVersion": 1
    },
    {
      "id": "68cf2659-c189-4f66-9a41-5a67bba0c9ad",
      "name": "Note Adhésive2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -768
      ],
      "parameters": {
        "color": 5,
        "width": 720,
        "height": 336,
        "content": "## Get Sales Data From Last Shift\nNo Changes required here! (Change the Shift Start and End in Config Node!)\n"
      },
      "typeVersion": 1
    },
    {
      "id": "4b58e0e9-28e6-4f25-9965-dbd38ba55b5c",
      "name": "Note Adhésive3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1152,
        -288
      ],
      "parameters": {
        "color": 5,
        "width": 336,
        "height": 288,
        "content": "## Calculate Metrics"
      },
      "typeVersion": 1
    },
    {
      "id": "254bcd80-2c52-423f-af74-fa3b775c213a",
      "name": "Note Adhésive4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1552,
        -768
      ],
      "parameters": {
        "color": 4,
        "width": 320,
        "height": 288,
        "content": "## Send Report"
      },
      "typeVersion": 1
    },
    {
      "id": "100afc44-ecaa-4991-a7e7-24f3b562b64e",
      "name": "MASTER CONFIG",
      "type": "n8n-nodes-base.code",
      "position": [
        -48,
        -496
      ],
      "parameters": {
        "jsCode": "/*\n==============================================\nUSER CONFIGURATION\n==============================================\nThis is the ONLY node you need to edit.\n*/\n\nconst config = {\n  // --- 1. Google Sheet Settings ---\n  google_sheet_settings: {\n    SpreadsheetID: \"PASTE_SPREADSHEET_ID_HERE\", // ---(String after 'https://docs.google.com/spreadsheets/d/' in your browser adress bar when accessing the Spreadsheet; Spreadsheet must be shared via link.) ---\n    ProductListSheet: \"ProductList\",\n    SalesDataSheet: \"SalesData\"\n  },\n\n  // --- 2. Business & Report Settings ---\n  business_settings: {\n    shiftStart: \"PASTE_SHIFT_START_HERE\", // e.g., \"08:00\"\n    shiftEnd: \"PASTE_SHIFT_END_HERE\",     // e.g., \"02:00\"\n    timezone: \"ENTER_SAME_TIMEZONE_AS_SET_IN_WORKFLOW_SETTINGS\", // e.g., \"Asia/Bangkok\"\n    reportEmailReceiver: \"PASTE_EMAIL_RECEIVER_HERE\"\n  },\n\n  // --- 4. Loyverse IDs & Config ---\n  loyverse_ids: { // <--- **FIX 2: MOVED THIS TO BE A SEPARATE KEY**\n    pos_device_ids: [\n      'PASTE_POS_ID_1_HERE',\n      'PASTE_POS_ID_2_HERE'\n    ],\n    qr_payment_type_ids: [\n      'PASTE_QR_PAYMENT_ID_1_HERE'\n    ],\n    CATEGORIES: {\n      '5f22222235-713f-4ca6-9b22-dab34sab77db': 'SampleCategory1',\n      '1f2229dab-e88e-4d64-bbbd-862141295f4fb': 'SampleCategory2',\n      'da2222-b1b6-4bce-aecb-12444490ac554': 'SampleCategory3',\n    }\n  }\n}; // <--- config object closes here\n\nreturn config;"
      },
      "typeVersion": 2
    },
    {
      "id": "7ff826f4-67b5-49e8-8d23-afb50f3e334a",
      "name": "Note Adhésive5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1552,
        -288
      ],
      "parameters": {
        "color": 3,
        "width": 704,
        "height": 640,
        "content": "# SETUP STEP 4/4\n\n## Open the \"Save latest sales data\" node below and select the \"LoyverseDataTool\" Spreadsheet from the list in document section.\n\n## In the sheet section, paste the following code:\n### Sheet: {{ $node[\"MASTER CONFIG\"].json.google_sheet_settings.SalesDataSheet }}\n\n## 3. The column list will now appear and you can map your columns to the data from the Calculate All Metrics node."
      },
      "typeVersion": 1
    },
    {
      "id": "3886f3ba-fc8d-46f8-88f0-34008afbb2fa",
      "name": "Note Adhésive6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -512,
        -768
      ],
      "parameters": {
        "width": 384,
        "height": 320,
        "content": "# SETUP STEP 1/4\n\n## Create credentials for…\n\n1. Loyverse: Create an API token in Loyverse under “Integrations” - “Access Tokens”. Then create Loyverse credentials in N8N: (“Generic / “Bearer YOUR_TOKEN_HERE”, save Loyverse API token in “Bearer YOUR_TOKEN_HERE” field).\n2. Google Sheets API \n3. Email Sender (SMTP credentials or a mail service like GMAIL.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "34351ea1-a172-458a-9baf-7b7ace54ad93",
      "name": "Note Adhésive7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        -288
      ],
      "parameters": {
        "color": 3,
        "width": 688,
        "height": 688,
        "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n# Setup Step 3/4\n### After the MASTER CONFIG is filled out (!) open the \"Save Product List\" node and select \"From List\" in the \"Document\" section. You can then select the \"LoyverseDataTool\" Spreadsheet that you just copied from the list. \n\nAlternative, if you have problems with setting up the Google Sheet Credentials: share the Spreadsheet via link, copy the ID and save it in MASTER CONFIG, then select \"ID\" in the document section, switch to \"expression\" and paste this code:\n{{ $node[\"MASTER CONFIG\"].json.google_sheet_settings.SpreadsheetID }} \n\n\n### Next, in the Sheet section, paste this code (switch to \"expression\" first): {{ $node[\"MASTER CONFIG\"].json.google_sheet_settings.ProductListSheet }}\n\n### The \"Map Each Column Manually\" section will load your sheet's columns and you can now map your columns to the data from \"Format Product Data\" node.\n\n### Next, open \"Read Historical Data\" node, select the document form the list or paste the same code as above. In Sheet section paste the following code (different from the one above!)\nSheet: {{ $node[\"MASTER CONFIG\"].json.google_sheet_settings.SalesDataSheet }}\n(no mapping required here).\n"
      },
      "typeVersion": 1
    },
    {
      "id": "966a6c35-a12c-4b1b-acd4-2da989107732",
      "name": "Sauvegarder la Liste de Produits",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        528,
        -176
      ],
      "parameters": {
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": ""
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "O4vZa1eFgJYJCpqg",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6
    },
    {
      "id": "cef55f5a-579d-4d39-9141-f130f751e64d",
      "name": "Sauvegarder les Dernières Données de Ventes",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1616,
        176
      ],
      "parameters": {
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": ""
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "O4vZa1eFgJYJCpqg",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6,
      "alwaysOutputData": true
    }
  ],
  "active": false,
  "pinData": {},
  "settings": {
    "timezone": "Asia/Bangkok",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "4e1a4aaf-81ad-4899-bdee-33082fdc10e0",
  "connections": {
    "111f10b7-9c40-443e-9538-4e53e45cc66f": {
      "main": [
        []
      ]
    },
    "100afc44-ecaa-4991-a7e7-24f3b562b64e": {
      "main": [
        [
          {
            "node": "9a04bbb6-1e5b-4c23-a7f6-810cfda646a9",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "966a6c35-a12c-4b1b-acd4-2da989107732": {
      "main": [
        [
          {
            "node": "31c1d0ca-bca2-44c8-ae22-2915fcef54d0",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "01f719aa-f043-4db0-866c-d8125802f0e3": {
      "main": [
        [
          {
            "node": "966a6c35-a12c-4b1b-acd4-2da989107732",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "31c1d0ca-bca2-44c8-ae22-2915fcef54d0": {
      "main": [
        [
          {
            "node": "90a4e282-7492-4149-b663-123e7ece77c6",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "b1406a9b-d6e9-4a0d-8f6d-0f7d3ee7170a": {
      "main": [
        [
          {
            "node": "909cfa2e-7e99-44e7-ab44-60d461f11754",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "909cfa2e-7e99-44e7-ab44-60d461f11754": {
      "main": [
        [
          {
            "node": "111f10b7-9c40-443e-9538-4e53e45cc66f",
            "type": "main",
            "index": 0
          },
          {
            "node": "cef55f5a-579d-4d39-9141-f130f751e64d",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "cef55f5a-579d-4d39-9141-f130f751e64d": {
      "main": [
        []
      ]
    },
    "9a04bbb6-1e5b-4c23-a7f6-810cfda646a9": {
      "main": [
        [
          {
            "node": "01f719aa-f043-4db0-866c-d8125802f0e3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "90a4e282-7492-4149-b663-123e7ece77c6": {
      "main": [
        [
          {
            "node": "5b7c50f6-6ff7-4e45-968e-33019fcd028c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "14920d77-308a-47d6-b4f9-655aa481e463": {
      "main": [
        [
          {
            "node": "9a04bbb6-1e5b-4c23-a7f6-810cfda646a9",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8ccf3480-8a71-41af-bef2-2a3e8e84778a": {
      "main": [
        [
          {
            "node": "100afc44-ecaa-4991-a7e7-24f3b562b64e",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5b7c50f6-6ff7-4e45-968e-33019fcd028c": {
      "main": [
        [
          {
            "node": "b1406a9b-d6e9-4a0d-8f6d-0f7d3ee7170a",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Foire aux questions

Comment utiliser ce workflow ?

Copiez le code de configuration JSON ci-dessus, créez un nouveau workflow dans votre instance n8n et sélectionnez "Importer depuis le JSON", collez la configuration et modifiez les paramètres d'authentification selon vos besoins.

Dans quelles scénarios ce workflow est-il adapté ?

Avancé - Extraction de documents

Est-ce payant ?

Ce workflow est entièrement gratuit et peut être utilisé directement. Veuillez noter que les services tiers utilisés dans le workflow (comme l'API OpenAI) peuvent nécessiter un paiement de votre part.

Workflows recommandés

Rapports quotidiens automatisés de flux de trésorerie et de dépenses pour professionnels de la finance
Générer un rapport de flux de trésorerie quotidien pour l'équipe financière avec Google Sheets, Slack et e-mail
Code
Merge
Slack
+
Code
Merge
Slack
25 NœudsOneclick AI Squad
Extraction de documents
Modèle pour extraire des PDF
Modèle d'IA Llama pour le suivi dans Google Sheets
Set
Code
Html
+
Set
Code
Html
24 NœudsCristian Baño Belchí
Extraction de documents
Réapprovisionnement intelligent des stocks et commandes d'achat automatiques
Gestion des stocks pilotée par l'IA, basée sur les prévisions d'OpenAI et l'intégration ERP
Code
Filter
Postgres
+
Code
Filter
Postgres
24 NœudsOneclick AI Squad
Extraction de documents
Automatisation des soumissions d'assurance commerciale avec Google Workspace, PDF et e-mail
Automatisation du processus de soumission d'assurance commerciale avec la suite Google, les PDF et l'e-mail
If
Set
Code
+
If
Set
Code
37 NœudsDavid Olusola
Extraction de documents
AI-Deepseek-R1t Approbation des déplacements pour réunions et demande d'autorisation de dépenses
Automatisation de l'approbation des voyages et des réunions avec Deepseek AI, Gmail et Google Sheets
If
Set
Code
+
If
Set
Code
24 NœudsCheng Siong Chin
Extraction de documents
Évaluation des travaux par IA Sonar Pro et rappels pour plusieurs dates d'échéance
Automatiser l'attribution des relecture par les pairs via Sonar Pro AI et des rappels de dates limites multi-canaux
Set
Filter
Discord
+
Set
Filter
Discord
23 NœudsCheng Siong Chin
Extraction de documents
Informations sur le workflow
Niveau de difficulté
Avancé
Nombre de nœuds21
Catégorie1
Types de nœuds7
Description de la difficulté

Adapté aux utilisateurs avancés, avec des workflows complexes contenant 16+ nœuds

Liens externes
Voir sur n8n.io

Partager ce workflow

Catégories

Catégories: 34