iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

在先前的文章裡,我們成功建立了自動記錄打卡時間到 Google Sheets 的基礎流程。然而,目前的版本有個小缺點:每次觸發都會新增一筆紀錄,這在需要短暫外出又返回公司的情境下(像是買午餐),會造成不必要的重複紀錄

這篇文章將改善這個流程,讓我們的自動化系統更加好用:

  • 上班打卡 (Check-In):只允許每天記錄一次,避免重複。

  • 下班打卡 (Check-Out):自動更新當天的記錄,而非新增一筆。

workflow

第一步:統一處理入口

為了實作更複雜的判斷,我們需要一個統一的入口來處理所有打卡請求

  • 把最後一個節點改為「Code」,命名為「Complete Attendance Handler」之後會用到

    // 完整的打卡處理邏輯,統一先讀取資料再決定動作
    const basicData = $("Edit Fields").item.json;
    const todayDate = basicData.todayDate;
    const direction = basicData.direction;
    const currentTime = basicData.currentTime;
    const spreadsheetId = basicData.spreadsheetId;
    
    // 無論是 Check-In 還是 Check-Out,都需要先讀取資料來檢查今天是否已有記錄
    return [
      {
        json: {
          action: "read_first",
          todayDate: todayDate,
          currentTime: currentTime,
          direction: direction,
          spreadsheetId: spreadsheetId,
          message: `Processing ${direction} for ${todayDate}`,
        },
      },
    ];
    

    image.png

第二步:讀取 Google Sheets 現有資料

接下來,我們要根據 read_first 指令來實際讀取試算表

  • 再來接「If」

    image 1.png

  • 填入

    {
      {
        $json.action;
      }
    }
    
    read_first;
    

    image 2.png

  • 接著在「true」的路徑上,選擇「HTTP Request」

    image 3.png

  • 「URL」填寫如下

    • 結尾的「Worklog」是先前設定的工作表名稱,可自行調整
    https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog
    
  • 驗證方式選「Predefined Credential Type」

    • 「Credential Type」選擇之前有設定過的「Google Sheets OAuth2 API」

    • 再下個欄位選「Google Sheets account」

    image 4.png

第三步:核心判斷邏輯

讀取到資料後,就來到最關鍵的一步:判斷該執行什麼動作

  • 下個節點再選擇「Code」,命名為「Process Attendance」

    // 處理所有打卡邏輯(Check-In 和 Check-Out)
    const basicData = $("Complete Attendance Handler").item.json;
    const sheetData = $input.item.json;
    const todayDate = basicData.todayDate;
    const currentTime = basicData.currentTime;
    const direction = basicData.direction;
    const spreadsheetId = basicData.spreadsheetId;
    
    const values = sheetData.values || [];
    let foundRowIndex = -1;
    let foundRow = null;
    
    // 尋找今天的記錄(從第二行開始,跳過標題)
    for (let i = 1; i < values.length; i++) {
      const row = values[i];
      if (row[0] === todayDate) {
        // Date 欄位
        foundRowIndex = i + 1; // Google Sheets 行號從 1 開始
        foundRow = row;
        break;
      }
    }
    
    if (direction === "Check-In") {
      if (foundRowIndex > 0) {
        // 今天已經有 Check-In 記錄,不執行任何動作
        return [
          {
            json: {
              action: "skip",
              message: `Check-In already exists for ${todayDate}. Skipping.`,
              spreadsheetId: spreadsheetId,
            },
          },
        ];
      } else {
        // 今天沒有記錄,新增 Check-In 記錄
        return [
          {
            json: {
              action: "append",
              data: {
                values: [[todayDate, currentTime, ""]],
              },
              spreadsheetId: spreadsheetId,
              message: `Creating new Check-In record for ${todayDate}`,
            },
          },
        ];
      }
    } else if (direction === "Check-Out") {
      if (foundRowIndex > 0) {
        // 找到今天的記錄,直接更新 Check-Out 欄位 (無論有無舊資料都會覆蓋)
        return [
          {
            json: {
              action: "update",
              rowIndex: foundRowIndex,
              range: `Worklog!C${foundRowIndex}`, // C 欄是 Check-Out
              values: [[currentTime]],
              spreadsheetId: spreadsheetId,
              message: `Updating/Overwriting Check-Out for ${todayDate} at row ${foundRowIndex}`,
            },
          },
        ];
      } else {
        // 沒找到今天的記錄,新增一筆只有 Check-Out 的記錄
        return [
          {
            json: {
              action: "append",
              data: {
                values: [[todayDate, "", currentTime]],
              },
              spreadsheetId: spreadsheetId,
              message: `Creating new Check-Out record for ${todayDate}`,
            },
          },
        ];
      }
    } else {
      return [
        {
          json: {
            action: "error",
            message: "Invalid direction. Use Check-In or Check-Out.",
            spreadsheetId: spreadsheetId,
          },
        },
      ];
    }
    

    image 5.png

第四步:兵分兩路,執行最終動作

現在,我們有了 appendupdate 兩種指令,需要讓流程根據指令執行對應的操作

  • 下個節點再兵分兩路,都選「If」

    • 第一條命名為「Determine Final Action」

    • 填入

      {
        {
          $json.action;
        }
      }
      
      append;
      

    image 6.png

  • 第二條「If」路線命名為「Should Update?」,填入

    {
      {
        $json.action;
      }
    }
    
    update;
    

    image 7.png

  • 現在的畫布會長這樣

    image 8.png

  • 接著在「Determine Final Action」的下個節點選擇「HTTP Request」

    • 設定為「POST」請求

    • 「URL」

      https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog:append?valueInputOption=USER_ENTERED
      
    • 憑證的選擇跟剛才一樣

    image 9.png

  • 接著把「Send Body」開啟,內容填寫

    values;
    
    {
      {
        $json.data.values;
      }
    }
    

    image 10.png

  • 接著到剛剛的「Should Update?」設定下個節點,同樣是「HTTP Request」

    • 節點可以命名為「Update Check-Out via API」

    • 請求設定為「PUT」

    • 「URL」

      https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/{{ $json.range }}?valueInputOption=USER_ENTERED
      
    • 憑證選擇相同的設定

    • 「Send Body」同樣開啟,內容填寫

      values;
      
      {
        {
          $json.values;
        }
      }
      

    image 11.png

最後的調整與測試

  • 記得到右上角的設定裡面,把時區改為台灣時間

    image 12.png

    image 13.png

  • 最後讓初始節點留下「Webhook」,就可以拿手機起來做測試惹

    image 14.png

  • 看到試算表都有寫入時間,代表成功囉

    2025-08-19 09.44.08 docs.google.com 7c5ece5f76c2.png

  • 底下附上這次流程的 JSON 檔案

    {
      "name": "自動記錄打卡時間",
      "nodes": [
        {
          "parameters": {
            "jsCode": "return [\n  {\n    json: {\n      empty: items.length == 1 && Object.keys(items[0].json).length == 0,\n    },\n  },\n];\n"
          },
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [448, -144],
          "id": "217d2210-7364-4873-b1e0-08247718b9fb",
          "name": "Return if Null"
        },
        {
          "parameters": {
            "resource": "spreadsheet",
            "title": "WorkTimeTracking",
            "sheetsUi": {
              "sheetValues": [
                {
                  "title": "Worklog"
                }
              ]
            },
            "options": {}
          },
          "type": "n8n-nodes-base.googleSheets",
          "typeVersion": 4.6,
          "position": [880, -272],
          "id": "08e69bb8-71c4-410f-9ef5-459e08533322",
          "name": "Create spreadsheet",
          "credentials": {
            "googleSheetsOAuth2Api": {
              "id": "YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
              "name": "Your Google Sheets Account"
            }
          }
        },
        {
          "parameters": {
            "jsCode": "return [{\n  json: {\n    Date: \"\",\n    \"Check-In\": \"\",\n    \"Check-Out\": \"\"\n  }\n}];"
          },
          "id": "generate-header-data-fixed",
          "name": "Generate Header Data",
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [1088, -272]
        },
        {
          "parameters": {
            "operation": "append",
            "documentId": {
              "__rl": true,
              "value": "={{ $('Create spreadsheet').item.json.spreadsheetId }}",
              "mode": "id"
            },
            "sheetName": {
              "__rl": true,
              "value": "Worklog",
              "mode": "name"
            },
            "columns": {
              "mappingMode": "autoMapInputData",
              "value": {},
              "matchingColumns": [],
              "schema": [
                {
                  "id": "Date",
                  "displayName": "Date",
                  "required": false,
                  "defaultMatch": false,
                  "display": true,
                  "type": "string",
                  "canBeUsedToMatch": true,
                  "removed": false
                },
                {
                  "id": "Check-In",
                  "displayName": "Check-In",
                  "required": false,
                  "defaultMatch": false,
                  "display": true,
                  "type": "string",
                  "canBeUsedToMatch": true,
                  "removed": false
                },
                {
                  "id": "Check-Out",
                  "displayName": "Check-Out",
                  "required": false,
                  "defaultMatch": false,
                  "display": true,
                  "type": "string",
                  "canBeUsedToMatch": true,
                  "removed": false
                }
              ],
              "attemptToConvertTypes": false,
              "convertFieldsToString": false
            },
            "options": {
              "cellFormat": "USER_ENTERED"
            }
          },
          "id": "add-headers-simple",
          "name": "Add Headers",
          "type": "n8n-nodes-base.googleSheets",
          "typeVersion": 4.6,
          "position": [1312, -272],
          "credentials": {
            "googleSheetsOAuth2Api": {
              "id": "YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
              "name": "Your Google Sheets Account"
            }
          }
        },
        {
          "parameters": {
            "path": "your-webhook-path",
            "options": {}
          },
          "type": "n8n-nodes-base.webhook",
          "typeVersion": 2.1,
          "position": [0, -64],
          "id": "9d0c71fa-9cc9-4335-897d-8e6e9b82731d",
          "name": "Webhook",
          "webhookId": "YOUR_WEBHOOK_ID"
        },
        {
          "parameters": {
            "conditions": {
              "options": {
                "caseSensitive": true,
                "leftValue": "",
                "typeValidation": "strict",
                "version": 2
              },
              "conditions": [
                {
                  "id": "5860c006-af45-4884-af63-c7f1afa2447b",
                  "leftValue": "={{$json[\"empty\"]}}",
                  "rightValue": "",
                  "operator": {
                    "type": "boolean",
                    "operation": "true",
                    "singleValue": true
                  }
                }
              ],
              "combinator": "and"
            },
            "options": {}
          },
          "type": "n8n-nodes-base.if",
          "typeVersion": 2.2,
          "position": [672, -144],
          "id": "8ff4da35-0f2b-4d6b-ac7f-9af62a7e4b3f",
          "name": "Doesn't exist?"
        },
        {
          "parameters": {
            "conditions": {
              "options": {
                "caseSensitive": true,
                "leftValue": "",
                "typeValidation": "strict",
                "version": 2
              },
              "conditions": [
                {
                  "id": "ac6f1aa1-7a34-4a33-a7ca-22c2c7ba6366",
                  "leftValue": "={{$json.action}}",
                  "rightValue": "read_first",
                  "operator": {
                    "type": "string",
                    "operation": "equals",
                    "name": "filter.operator.equals"
                  }
                }
              ],
              "combinator": "and"
            },
            "options": {}
          },
          "type": "n8n-nodes-base.if",
          "typeVersion": 2.2,
          "position": [432, 192],
          "id": "6989cc48-2514-452f-9c05-7f76e1aa8240",
          "name": "Should Read Data?"
        },
        {
          "parameters": {
            "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleSheetsOAuth2Api",
            "options": {}
          },
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [656, 160],
          "id": "a5923022-eb71-43c9-9eb7-679849c2ca0b",
          "name": "Read Sheet Data",
          "credentials": {
            "googleSheetsOAuth2Api": {
              "id": "YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
              "name": "Your Google Sheets Account"
            }
          }
        },
        {
          "parameters": {
            "jsCode": "// 完整的打卡處理邏輯,統一先讀取資料再決定動作\nconst basicData = $('Set Basic Data').item.json;\nconst todayDate = basicData.todayDate;\nconst direction = basicData.direction;\nconst currentTime = basicData.currentTime;\nconst spreadsheetId = basicData.spreadsheetId;\n\n// 無論是 Check-In 還是 Check-Out,都需要先讀取資料來檢查今天是否已有記錄\nreturn [{\n  json: {\n    action: 'read_first',\n    todayDate: todayDate,\n    currentTime: currentTime,\n    direction: direction,\n    spreadsheetId: spreadsheetId,\n    message: `Processing ${direction} for ${todayDate}`\n  }\n}];"
          },
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [224, 192],
          "id": "236340ba-f466-424b-a0ef-be7e45a52fb6",
          "name": "Complete Attendance Handler"
        },
        {
          "parameters": {
            "jsCode": "// 處理所有打卡邏輯(Check-In 和 Check-Out)\nconst basicData = $('Complete Attendance Handler').item.json;\nconst sheetData = $input.item.json;\nconst todayDate = basicData.todayDate;\nconst currentTime = basicData.currentTime;\nconst direction = basicData.direction;\nconst spreadsheetId = basicData.spreadsheetId;\n\nconst values = sheetData.values || [];\nlet foundRowIndex = -1;\nlet foundRow = null;\n\n// 尋找今天的記錄(從第二行開始,跳過標題)\nfor (let i = 1; i < values.length; i++) {\n  const row = values[i];\n  if (row[0] === todayDate) { // Date 欄位\n    foundRowIndex = i + 1; // Google Sheets 行號從 1 開始\n    foundRow = row;\n    break;\n  }\n}\n\nif (direction === 'Check-In') {\n  if (foundRowIndex > 0) {\n    // 今天已經有 Check-In 記錄,不執行任何動作\n    return [{\n      json: {\n        action: 'skip',\n        message: `Check-In already exists for ${todayDate}. Skipping.`,\n        spreadsheetId: spreadsheetId\n      }\n    }];\n  } else {\n    // 今天沒有記錄,新增 Check-In 記錄\n    return [{\n      json: {\n        action: 'append',\n        data: {\n          values: [[todayDate, currentTime, '']]\n        },\n        spreadsheetId: spreadsheetId,\n        message: `Creating new Check-In record for ${todayDate}`\n      }\n    }];\n  }\n} else if (direction === 'Check-Out') {\n  if (foundRowIndex > 0) {\n    // 找到今天的記錄,直接更新 Check-Out 欄位 (無論有無舊資料都會覆蓋)\n    return [{\n      json: {\n        action: 'update',\n        rowIndex: foundRowIndex,\n        range: `Worklog!C${foundRowIndex}`, // C 欄是 Check-Out\n        values: [[currentTime]],\n        spreadsheetId: spreadsheetId,\n        message: `Updating/Overwriting Check-Out for ${todayDate} at row ${foundRowIndex}`\n      }\n    }];\n  } else {\n    // 沒找到今天的記錄,新增一筆只有 Check-Out 的記錄\n    return [{\n      json: {\n        action: 'append',\n        data: {\n          values: [[todayDate, '', currentTime]]\n        },\n        spreadsheetId: spreadsheetId,\n        message: `Creating new Check-Out record for ${todayDate}`\n      }\n    }];\n  }\n} else {\n  return [{\n    json: {\n      action: 'error',\n      message: 'Invalid direction. Use Check-In or Check-Out.',\n      spreadsheetId: spreadsheetId\n    }\n  }];\n}"
          },
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [864, 160],
          "id": "250d32a9-07c9-4cb9-bd36-b51ff4983a4f",
          "name": "Process Attendance"
        },
        {
          "parameters": {
            "conditions": {
              "options": {
                "caseSensitive": true,
                "leftValue": "",
                "typeValidation": "strict",
                "version": 2
              },
              "conditions": [
                {
                  "id": "6784a186-e2b8-4d8b-9c03-45b4223bf1f2",
                  "leftValue": "={{$json.action}}",
                  "rightValue": "append",
                  "operator": {
                    "type": "string",
                    "operation": "equals",
                    "name": "filter.operator.equals"
                  }
                }
              ],
              "combinator": "and"
            },
            "options": {}
          },
          "type": "n8n-nodes-base.if",
          "typeVersion": 2.2,
          "position": [1072, 144],
          "id": "d6158010-19e8-421c-9386-a1ed7bd32b63",
          "name": "Determine Final Action"
        },
        {
          "parameters": {
            "conditions": {
              "options": {
                "caseSensitive": true,
                "leftValue": "",
                "typeValidation": "strict",
                "version": 2
              },
              "conditions": [
                {
                  "id": "e281ee76-ad12-4602-9dc9-8e12afc21b3c",
                  "leftValue": "={{$json.action}}",
                  "rightValue": "update",
                  "operator": {
                    "type": "string",
                    "operation": "equals",
                    "name": "filter.operator.equals"
                  }
                }
              ],
              "combinator": "and"
            },
            "options": {}
          },
          "type": "n8n-nodes-base.if",
          "typeVersion": 2.2,
          "position": [1072, 304],
          "id": "721fb5fe-5297-4c1f-8351-ffc93b2c166a",
          "name": "Should Update?"
        },
        {
          "parameters": {
            "method": "POST",
            "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog:append?valueInputOption=USER_ENTERED",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleSheetsOAuth2Api",
            "sendBody": true,
            "bodyParameters": {
              "parameters": [
                {
                  "name": "values",
                  "value": "={{ $json.data.values }}"
                }
              ]
            },
            "options": {}
          },
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [1296, 64],
          "id": "90c769c0-61f3-4254-8993-685dafaae302",
          "name": "Append Record via API",
          "credentials": {
            "googleSheetsOAuth2Api": {
              "id": "YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
              "name": "Your Google Sheets Account"
            }
          }
        },
        {
          "parameters": {
            "method": "PUT",
            "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/{{ $json.range }}?valueInputOption=USER_ENTERED",
            "authentication": "predefinedCredentialType",
            "nodeCredentialType": "googleSheetsOAuth2Api",
            "sendBody": true,
            "bodyParameters": {
              "parameters": [
                {
                  "name": "values",
                  "value": "={{ $json.values }}"
                }
              ]
            },
            "options": {}
          },
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [1296, 224],
          "id": "c32e3058-7852-4828-9f16-1edad3ab6199",
          "name": "Update Check-Out via API",
          "credentials": {
            "googleSheetsOAuth2Api": {
              "id": "YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
              "name": "Your Google Sheets Account"
            }
          }
        },
        {
          "parameters": {
            "mode": "raw",
            "jsonOutput": "={\n  \"todayDate\": \"{{ $now.format('yyyy-MM-dd') }}\",\n  \"currentTime\": \"{{ $now.format('HH:mm:ss') }}\",\n  \"direction\": \"{{ $('Webhook').item.json.headers.direction ? $('Webhook').item.json.headers.direction : 'Check-In'}}\",\n  \"spreadsheetId\": \"{{ $('Search files and folders').item.json.id }}\"\n}\n",
            "options": {}
          },
          "type": "n8n-nodes-base.set",
          "typeVersion": 3.4,
          "position": [1088, -64],
          "id": "5b2a7c91-0734-4239-9eda-42f8bcfc9444",
          "name": "Set Basic Data"
        },
        {
          "parameters": {
            "resource": "fileFolder",
            "queryString": "WorkTimeTracking",
            "returnAll": true,
            "filter": {},
            "options": {}
          },
          "type": "n8n-nodes-base.googleDrive",
          "typeVersion": 3,
          "position": [224, -144],
          "id": "874f71f2-35ab-466b-adc4-c5ce69471ebe",
          "name": "Search files and folders",
          "alwaysOutputData": true,
          "credentials": {
            "googleDriveOAuth2Api": {
              "id": "YOUR_GOOGLE_DRIVE_CREDENTIAL_ID",
              "name": "Your Google Drive Account"
            }
          }
        }
      ],
      "pinData": {},
      "connections": {
        "Return if Null": {
          "main": [
            [
              {
                "node": "Doesn't exist?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Create spreadsheet": {
          "main": [
            [
              {
                "node": "Generate Header Data",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Generate Header Data": {
          "main": [
            [
              {
                "node": "Add Headers",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Webhook": {
          "main": [
            [
              {
                "node": "Search files and folders",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Add Headers": {
          "main": [
            [
              {
                "node": "Set Basic Data",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Doesn't exist?": {
          "main": [
            [
              {
                "node": "Create spreadsheet",
                "type": "main",
                "index": 0
              }
            ],
            [
              {
                "node": "Set Basic Data",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Should Read Data?": {
          "main": [
            [
              {
                "node": "Read Sheet Data",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Read Sheet Data": {
          "main": [
            [
              {
                "node": "Process Attendance",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Complete Attendance Handler": {
          "main": [
            [
              {
                "node": "Should Read Data?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Process Attendance": {
          "main": [
            [
              {
                "node": "Determine Final Action",
                "type": "main",
                "index": 0
              },
              {
                "node": "Should Update?",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Determine Final Action": {
          "main": [
            [
              {
                "node": "Append Record via API",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Should Update?": {
          "main": [
            [
              {
                "node": "Update Check-Out via API",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Set Basic Data": {
          "main": [
            [
              {
                "node": "Complete Attendance Handler",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Search files and folders": {
          "main": [
            [
              {
                "node": "Return if Null",
                "type": "main",
                "index": 0
              }
            ]
          ]
        }
      },
      "active": true,
      "settings": {
        "executionOrder": "v1",
        "callerPolicy": "workflowsFromSameOwner"
      },
      "versionId": "YOUR_WORKFLOW_VERSION_ID",
      "meta": {
        "templateCredsSetupCompleted": true,
        "instanceId": "YOUR_INSTANCE_ID"
      },
      "id": "YOUR_WORKFLOW_ID",
      "tags": []
    }
    

上一篇
[Day06]_自動記錄打卡時間-#3:串接 iPhone 捷徑
下一篇
[Day08]_午餐吃什麼
系列文
告別重複瑣事: n8n workflow 自動化工作實踐8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言