跳到主要內容跳到頁尾

Apps Script 匯入對帳檔:同一份檔案重跑,不會多出重複列

8 分鐘閱讀Lv.3 中級摘要與日期

每天收到的對帳檔有時重複寄、有時改過金額。以交易編號比對舊資料,只新增沒見過的列,內容不同的放進待確認分頁,重跑結果不變。附完整範例與執行結果。

發布

文章目錄

每天會收到一份對帳檔,貼進「匯入」分頁,再由程式併進「對帳明細」。檔案有時重複寄,有時對方改過某一筆的金額。

如果程式每次都把「匯入」整份接到「對帳明細」後面,重複寄一次,明細就多一批重複的列。這篇的做法是:同一份檔案不管跑幾次,結果都一樣。

先看流程

概念示意:匯入檔的每一列先用交易編號和對帳明細比對,分成新增、相同略過、內容不同進待確認、檔內重複或空編號略過四種;只有新增的列會寫進對帳明細,所以重跑結果不變。← 左右滑動看完整的圖,點圖可放大

重點是用「交易編號」認出已經收過的列。認得出來,才知道哪些該新增、哪些該跳過。

先準備

  • 試算表建三個分頁:「匯入」(貼每天的檔案)、「對帳明細」(累積的結果)、「待確認」(放內容對不上的列)。
  • 「匯入」與「對帳明細」第 1 列的表頭,依序是 交易編號、日期、金額、摘要;「待確認」的表頭是 交易編號、既有、匯入。
  • 三個分頁的交易編號欄,都先設成純文字再貼資料。這篇的編號是文字(例如 00123),純文字才不會被轉成數字 123。貼上後如果發現前導 0 不見了(00123 變 123),代表欄格式不是純文字,請清掉重貼,否則程式會把它當成另一個編號。
  • 在「擴充功能 → Apps Script」把四個區塊依序貼進去並儲存,函式選單選 runImport 按「執行」。若 Google 要求授權,先看清楚列出的權限再同意。

區塊一:把編號整理成可比對的樣子

程式碼.gs
Apps Script
/**
 * @OnlyCurrentDoc
 */

const HEADER = ["交易編號", "日期", "金額", "摘要"];

// 鍵:轉成文字、去頭尾空白。數字 123 與文字 "00123" 不是同一個鍵,所以交易編號欄要設成純文字
function normalizeKey(raw) {
  return raw === null || raw === undefined ? "" : String(raw).trim();
}
  • normalizeKey 把編號轉成文字並去掉頭尾空白,A004 與 A004 會被當成同一個。
  • 數字 123 與文字 "00123" 不是同一個鍵,所以才要求編號欄先設純文字。
  • 空白或空儲存格會得到空字串,後面當作「沒有編號」處理。
  • HEADER 是表頭要有的欄名,區塊三用來檢查。

區塊二:先分類,不寫任何東西

程式碼.gs
Apps Script
function isDate(value) {
  return Object.prototype.toString.call(value) === "[object Date]";
}

// 比對其他欄位用:日期比時間,其他比文字
function cellText(value) {
  return isDate(value) ? String(value.getTime()) : normalizeKey(value);
}

// 規劃:只分類,不寫任何東西
function planImport(existing, incoming) {
  const known = new Map();
  existing.forEach((row) => {
    const key = normalizeKey(row[0]);
    if (key !== "" && !known.has(key)) {
      known.set(key, row);
    }
  });
  const seen = new Set();
  const result = { toAdd: [], same: 0, conflicts: [], dupInFile: 0, emptyKey: 0 };
  incoming.forEach((row) => {
    const key = normalizeKey(row[0]);
    if (key === "") {
      result.emptyKey++;
      return;
    }
    if (seen.has(key)) {
      result.dupInFile++;
      return;
    }
    seen.add(key);
    const old = known.get(key);
    if (!old) {
      result.toAdd.push([key].concat(row.slice(1)));
    } else if (old.slice(1).every((value, i) => cellText(value) === cellText(row[i + 1]))) {
      result.same++;
    } else {
      result.conflicts.push({ key: key, existing: old, incoming: row });
    }
  });
  return result;
}
  • planImport 只負責分類,不碰試算表,輸入是兩份二維陣列,輸出是每一類的列與數量。
  • 同一份檔裡編號重複,只取第一筆,其餘算 dupInFile;沒有編號的列算 emptyKey。
  • 編號已存在、其他欄位也一樣,算 same;編號已存在但其他欄位不同,放進 conflicts,不進 toAdd。
  • 新增的列會把編號去掉空白再寫入。日期用時間比,其他欄位比文字。

區塊三:讀資料、檢查分頁與表頭

程式碼.gs
Apps Script
function getSheet(book, name) {
  const sheet = book.getSheetByName(name);
  if (!sheet) {
    throw new Error("找不到分頁「" + name + "」");
  }
  return sheet;
}

function checkHeader(sheet, name) {
  const actual = sheet.getRange(1, 1, 1, HEADER.length).getValues()[0];
  if (HEADER.some((title, i) => normalizeKey(actual[i]) !== title)) {
    throw new Error("「" + name + "」的表頭要依序是:" + HEADER.join("、"));
  }
}

function readRows(sheet, cols) {
  const last = sheet.getLastRow();
  return last < 2 ? [] : sheet.getRange(2, 1, last - 1, cols).getValues();
}

function describeRow(row, timeZone) {
  return row
    .slice(1)
    .map((value) => (isDate(value) ? Utilities.formatDate(value, timeZone, "yyyy-MM-dd") : String(value)))
    .join(" / ");
}
  • getSheet 找不到分頁就丟錯,不會默默跳過。
  • checkHeader 表頭不對就丟錯。兩個分頁欄位順序錯了,資料會寫到錯的欄,所以寧可停下來。
  • readRows 從第 2 列讀到最後一列,一次 getValues,沒有資料就回傳空陣列。
  • describeRow 把一列整理成「日期 / 金額 / 摘要」的文字,放進「待確認」給人看。

區塊四:只寫新增的,並記下待確認

程式碼.gs
Apps Script
// 接在最後一列之後;第 1 欄先設成純文字,編號才不會被轉成數字
function appendRows(sheet, rows, cols) {
  if (rows.length === 0) {
    return;
  }
  const start = sheet.getLastRow() + 1;
  sheet.getRange(start, 1, rows.length, 1).setNumberFormat("@");
  sheet.getRange(start, 1, rows.length, cols).setValues(rows);
}

function runImport() {
  const book = SpreadsheetApp.getActiveSpreadsheet();
  const source = getSheet(book, "匯入");
  const detail = getSheet(book, "對帳明細");
  const pending = getSheet(book, "待確認");
  checkHeader(source, "匯入");
  checkHeader(detail, "對帳明細");

  const plan = planImport(readRows(detail, 4), readRows(source, 4));
  const timeZone = book.getSpreadsheetTimeZone();
  const listed = new Set(readRows(pending, 3).map((row) => JSON.stringify(row)));
  const fresh = plan.conflicts
    .map((c) => [c.key, describeRow(c.existing, timeZone), describeRow(c.incoming, timeZone)])
    .filter((row) => !listed.has(JSON.stringify(row)));

  appendRows(detail, plan.toAdd, 4);
  appendRows(pending, fresh, 3);
  Logger.log(
    "新增 " + plan.toAdd.length +
      "、相同 " + plan.same +
      "、待確認 " + plan.conflicts.length + "(新列入 " + fresh.length + ")" +
      "、檔內重複 " + plan.dupInFile +
      "、空鍵 " + plan.emptyKey
  );
}
  • appendRows 接在最後一列之後,一次 setValues。寫入前先把第 1 欄設成純文字,編號才不會被轉成數字。
  • runImport 全程只讀「匯入」,不改它。
  • 「待確認」會先讀出已經列過的內容,同一筆衝突不會因為重跑而一直增加。
  • 執行記錄會顯示這次新增、相同、待確認、檔內重複、空編號各幾筆。

執行結果

下面是一組範例資料:「對帳明細」原本有 A001、A002(金額 250)、A003 三列,「匯入」有 12 列,最右欄的比對結果是方便閱讀加上去的:

列 / 欄ABCDE
1交易編號日期金額摘要比對結果
2A0012026-10-01100文具相同,略過
3A0022026-10-02280運費金額不同,進待確認
4A0032026-10-0380郵資相同,略過
5 A004 2026-10-04500海報新增(編號去空白)
6A0052026-10-0560貼紙新增
7A0052026-10-0560貼紙檔內重複,略過
82026-10-0610沒有編號空編號,略過
9001232026-10-07330包材新增
10A0062026-10-0845膠帶新增
11A0072026-10-0990紙箱新增
12A0082026-10-10120標籤新增
13A0012026-10-01999改過的重複列檔內重複,略過

示範試算表

第一次執行,執行記錄是「新增 6、相同 2、待確認 1(新列入 1)、檔內重複 2、空鍵 1」。「對帳明細」變成:

列 / 欄ABCD
1交易編號日期金額摘要
2A0012026-10-01100文具
3A0022026-10-02250運費
4A0032026-10-0380郵資
5A0042026-10-04500海報
6A0052026-10-0560貼紙
7001232026-10-07330包材
8A0062026-10-0845膠帶
9A0072026-10-0990紙箱
10A0082026-10-10120標籤

示範試算表

A002 還是 250,沒有被改成 280。「待確認」多了一列:

列 / 欄ABC
1交易編號既有匯入
2A0022026-10-02 / 250 / 運費2026-10-02 / 280 / 運費

示範試算表

立刻再執行一次:新增 0、相同 8、待確認 1(新列入 0)。兩個分頁的列數都沒變。把「匯入」清成空表再執行,全部是 0,也沒有寫入。這幾個表是預先寫好的結果,不會自己執行程式。

為什麼衝突不覆蓋

編號相同、金額不同的列,程式不替你決定哪邊對,而是放進「待確認」,由人看過再處理。

拒絕重複(這篇的做法)覆蓋更新
同編號、內容不同保留舊的,列出來給人看用新的蓋掉舊的
對方誤改金額舊資料不受影響已核對的數字被悄悄改掉
對方更正金額要人手動改一次自動更新
適合對帳、財務、已經核過的資料商品價格這類永遠以最新為準的資料

兩種都對,差別在「錯誤蓋掉舊資料」的代價有多大。對帳資料通常寧可多一道人工確認。

換成你的情境

  • 鍵欄:這篇用第 1 欄的交易編號;你的資料可以是訂單編號、Email,或兩欄合起來。
  • 比對欄:planImport 裡比對其他欄位的範圍(old.slice(1)),可以改成只比金額。
  • 衝突處理:放進待確認、直接略過、或覆蓋更新,依上表的取捨決定。
  • 分頁名稱與欄數:HEADER、各分頁名稱、4 與 3 這兩個欄數。
1

金額變了就自動更新,要改哪裡?

如果規則改成「同一個交易編號的金額變了,就用匯入的新金額更新舊的那一列」,要改哪個區塊?這個改法有什麼風險?

延伸閱讀

說明

多人同時執行可能重複新增,需要另外加鎖。單次執行有 6 分鐘的時間上限,資料量大到超過上限時會被中斷。編號欄請先設成純文字,否則 00123 這類編號會被轉成數字。

分享:
#Apps Script#匯入#去重複#冪等
喜特先生 Mr. Sheet

喜特先生 Mr. Sheet

數據分析師/商業分析師

八年工作經驗,跨不同業界解決數據需求。做過的案例都整理在作品集。

接下來可以

相關文章

查看所有文章

留言討論

共 0 則留言

訂閱電子報,新文章上線就通知你

免費,隨時可取消。

送出即表示同意服務條款與隱私權政策。