在先前的文章裡,我們成功建立了自動記錄打卡時間到 Google Sheets 的基礎流程。然而,目前的版本有個小缺點:每次觸發都會新增一筆紀錄,這在需要短暫外出又返回公司的情境下(像是買午餐),會造成不必要的重複紀錄
這篇文章將改善這個流程,讓我們的自動化系統更加好用:
上班打卡 (Check-In):只允許每天記錄一次,避免重複。
下班打卡 (Check-Out):自動更新當天的記錄,而非新增一筆。
為了實作更複雜的判斷,我們需要一個統一的入口來處理所有打卡請求
把最後一個節點改為「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}`,
    },
  },
];

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

填入
{
  {
    $json.action;
  }
}
read_first;

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

「URL」填寫如下
https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog
驗證方式選「Predefined Credential Type」
「Credential Type」選擇之前有設定過的「Google Sheets OAuth2 API」
再下個欄位選「Google Sheets account」

讀取到資料後,就來到最關鍵的一步:判斷該執行什麼動作
下個節點再選擇「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,
      },
    },
  ];
}

現在,我們有了 append 和 update 兩種指令,需要讓流程根據指令執行對應的操作
下個節點再兵分兩路,都選「If」
第一條命名為「Determine Final Action」
填入
{
  {
    $json.action;
  }
}
append;

第二條「If」路線命名為「Should Update?」,填入
{
  {
    $json.action;
  }
}
update;

現在的畫布會長這樣

接著在「Determine Final Action」的下個節點選擇「HTTP Request」
設定為「POST」請求
「URL」
https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/Worklog:append?valueInputOption=USER_ENTERED
憑證的選擇跟剛才一樣

接著把「Send Body」開啟,內容填寫
values;
{
  {
    $json.data.values;
  }
}

接著到剛剛的「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;
  }
}

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


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

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

底下附上這次流程的 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": []
}