8
n8n 中文网amn8n.com

Loyverse 销售报告智能助手

高级

这是一个Document Extraction领域的自动化工作流,包含 21 个节点。主要使用 Code, EmailSend, HttpRequest, GoogleSheets, ManualTrigger 等节点。 从 Loyverse POS 系统通过 Google Sheets 和邮件的自动化每日财务分析

前置要求
  • 可能需要目标 API 的认证凭证
  • Google Sheets API 凭证
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
  "id": "LtlVKYumh3ovfRvp",
  "meta": {
    "instanceId": "e9910e69e07bf6e9ce2cd1f3b96d2f64b6b6d14f6f393c47edf2af31f3104eca",
    "templateCredsSetupCompleted": true
  },
  "name": "Loyverse 销售报告智能助手",
  "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": "当点击\"执行工作流\"时",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -240,
        -400
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "b1406a9b-d6e9-4a0d-8f6d-0f7d3ee7170a",
      "name": "读取历史数据",
      "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": "计算所有指标",
      "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": "从 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": "格式化产品数据",
      "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": "计算班次时间",
      "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": "从 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": "从 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": "发送邮件",
      "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": "每日早上 8:15 运行(可调整)",
      "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": "便签",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -96,
        -768
      ],
      "parameters": {
        "color": 3,
        "width": 672,
        "height": 448,
        "content": "# 设置步骤 2/4"
      },
      "typeVersion": 1
    },
    {
      "id": "c0ab3a86-0c8b-4eba-a61d-a0d3259b031d",
      "name": "便签1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -32,
        -288
      ],
      "parameters": {
        "color": 5,
        "width": 432,
        "height": 352,
        "content": "## 从 Loyverse 获取产品数据"
      },
      "typeVersion": 1
    },
    {
      "id": "68cf2659-c189-4f66-9a41-5a67bba0c9ad",
      "name": "便签2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -768
      ],
      "parameters": {
        "color": 5,
        "width": 720,
        "height": 336,
        "content": "## 获取最近班次的销售数据"
      },
      "typeVersion": 1
    },
    {
      "id": "4b58e0e9-28e6-4f25-9965-dbd38ba55b5c",
      "name": "便签3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1152,
        -288
      ],
      "parameters": {
        "color": 5,
        "width": 336,
        "height": 288,
        "content": "## 计算指标"
      },
      "typeVersion": 1
    },
    {
      "id": "254bcd80-2c52-423f-af74-fa3b775c213a",
      "name": "便签4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1552,
        -768
      ],
      "parameters": {
        "color": 4,
        "width": 320,
        "height": 288,
        "content": "## 发送报告"
      },
      "typeVersion": 1
    },
    {
      "id": "100afc44-ecaa-4991-a7e7-24f3b562b64e",
      "name": "主配置",
      "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": "便利贴5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1552,
        -288
      ],
      "parameters": {
        "color": 3,
        "width": 704,
        "height": 640,
        "content": "# 设置步骤 4/4"
      },
      "typeVersion": 1
    },
    {
      "id": "3886f3ba-fc8d-46f8-88f0-34008afbb2fa",
      "name": "便签 6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -512,
        -768
      ],
      "parameters": {
        "width": 384,
        "height": 320,
        "content": "# 设置步骤 1/4"
      },
      "typeVersion": 1
    },
    {
      "id": "34351ea1-a172-458a-9baf-7b7ace54ad93",
      "name": "便签 7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        -288
      ],
      "parameters": {
        "color": 3,
        "width": 688,
        "height": 688,
        "content": ""
      },
      "typeVersion": 1
    },
    {
      "id": "966a6c35-a12c-4b1b-acd4-2da989107732",
      "name": "保存产品列表",
      "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": "保存最新销售数据",
      "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": {
    "Send email": {
      "main": [
        []
      ]
    },
    "MASTER CONFIG": {
      "main": [
        [
          {
            "node": "Get all products from Loyverse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Product List": {
      "main": [
        [
          {
            "node": "Calculate Shift Time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Product Data": {
      "main": [
        [
          {
            "node": "Save Product List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Shift Time": {
      "main": [
        [
          {
            "node": "Get Yesterday's Shifts From Loyverse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Historical Data": {
      "main": [
        [
          {
            "node": "Calculate All Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate All Metrics": {
      "main": [
        [
          {
            "node": "Send email",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save Latest Sales Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Latest Sales Data": {
      "main": [
        []
      ]
    },
    "Get all products from Loyverse": {
      "main": [
        [
          {
            "node": "Format Product Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Yesterday's Shifts From Loyverse": {
      "main": [
        [
          {
            "node": "Get Yesterday's Receipts From Loyverse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Daily at 8:15AM (open to change)": {
      "main": [
        [
          {
            "node": "Get all products from Loyverse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking ‘Execute workflow’": {
      "main": [
        [
          {
            "node": "MASTER CONFIG",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Yesterday's Receipts From Loyverse": {
      "main": [
        [
          {
            "node": "Read Historical Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
常见问题

如何使用这个工作流?

复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。

这个工作流适合什么场景?

高级 - 文档提取

需要付费吗?

本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。

工作流信息
难度等级
高级
节点数量21
分类1
节点类型7
难度说明

适合高级用户,包含 16+ 个节点的复杂工作流

外部链接
在 n8n.io 查看

分享此工作流