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)可能需要您自行付费。
相关工作流推荐
面向财务专业人士的自动化每日现金流与费用报告
使用Google表格、Slack和邮件为财务团队生成每日现金流报告
Code
Merge
Slack
+7
25 节点Oneclick AI Squad
文档提取
提取PDF的模板
用于Google Sheets跟踪的Llama AI模型
Set
Code
Html
+11
24 节点Cristian Baño Belchí
文档提取
实时银行交易监控和会计团队警报系统
监控银行交易并为会计团队提供多通道告警
If
Code
Merge
+6
32 节点Oneclick AI Squad
文档提取
AI驱动的税务截止日期提醒与合规警报(会计用)
使用GPT-4、Google Sheets和Slack的税务截止日期管理与合规警报
If
Code
Slack
+5
19 节点Oneclick AI Squad
文档提取
智能库存补货与自动采购订单
基于OpenAI预测与ERP集成的AI驱动库存管理
Code
Filter
Postgres
+5
24 节点Oneclick AI Squad
文档提取
使用Google套件、PDF和邮件自动化商业保险提交
通过Google套件、PDF和邮件自动化商业保险提交流程
If
Set
Code
+10
37 节点David Olusola
文档提取