🧾事務自動化ラボ

入金消込をスプレッドシートで自動化する無料テンプレート(GAS・全文コピペ配布)

銀行の入出金明細を貼ってメニューを押すと、請求台帳と突き合わせて入金済みの請求を消し込み、未入金だけを一覧にします。振込名義がカナで社名と違っていても対応できます。コードは全文このページに置いています。

請求書を出したあとに残るのが、入金の確認です。ネットバンキングの明細を開いて、請求書の控えと見比べて、入った分に印を付けて、まだ入っていない相手を数える。件数が少ないうちは5分で終わりますが、取引先が10社を超えたあたりから、「先月分、あの会社から入ってたかどうか」が思い出せなくなります。

このページには、その突き合わせ(入金消込)をGoogleスプレッドシートでやるGoogle Apps Script(GAS)のコードを全文置いています。無料です。ダウンロード先もメールアドレスの入力も要りません。下のコードをコピーして貼るだけです。

このテンプレートですること・しないこと

売り込みのために曖昧にしても意味がないので、最初に線を引いておきます。

できること

  • 「請求台帳」と、銀行の明細を貼った「入金明細」を突き合わせて、対応が取れた請求を自動で入金済みにする
  • 振込名義が社名と違っていても照合できる(カ)タナカショウジ と「株式会社タナカ商事」を同じものとして扱う)
  • 振込手数料が引かれた入金を、同じ入金として扱う(許容差額は設定シートで変更可)
  • 複数の請求がまとめて1回で振り込まれた場合に、その組み合わせを見つけて両方を消し込む
  • 一部だけの入金や多すぎる入金は「要確認」で返す。候補が1件なら、その請求Noと、残額か超過額を消込メモに書く。手で確定しておけば、次の入金はその残額と突き合わせる
  • 未入金だけを、期日からの経過日数つきで一覧にする
  • 何度実行しても、同じ入金を二重に消し込まない

しないこと

  • 銀行からの明細の取り込み(明細のダウンロードと貼り付けは手作業です。理由は後述します)
  • 迷ったときに、それらしいほうへ寄せて消し込むこと。候補が複数ある入金は消し込まず「要確認」として残します
  • 督促メールの送信
  • 会計帳簿への転記、仕訳の作成、消費税の処理

最後の1つは重要です。これは会計ソフトの代わりではありません。 「誰からいくら入っていないか」を出すだけの道具です。帳簿は帳簿で別に付けてください。

なぜ銀行との自動連携を入れていないか

入金消込のいちばん面倒な部分は、明細を持ってくるところに見えます。ただ、そこを自動化しようとすると、口座の認証情報をどこかに預けることになります。個人事業や数人の会社で、自分で貼ったスクリプトにそれをやらせる利点は、手作業で明細をコピーする手間と釣り合いません。

明細のダウンロードと貼り付けは月に1回、1分もかかりません。 突き合わせのほうが何十分もかかるので、そちらだけを自動化しています。

導入手順

Googleアカウントがあれば5分ほどで動きます。

  1. Googleスプレッドシートを新規作成する
  2. メニュー「拡張機能 > Apps Script」を開く
  3. エディタに最初から入っている function myFunction() {} をすべて消す(Cmd+A / Ctrl+A → Delete)
  4. このページ下部のコードを全文コピーして貼り付ける
  5. Cmd+S / Ctrl+S で保存する
  6. スプレッドシートのタブに戻ってページを再読み込みする(メニューはここで初めて出ます)
  7. メニューバーに現れた「入金消込」→「初期シートを作成」を押す
  8. 初回だけ承認画面が出ます。「詳細」→「(プロジェクト名)に移動」→「許可」の順に進む
  9. 「請求台帳」に請求を入れる。「入金明細」に銀行の明細を貼る
  10. 「入金消込 > 入金を消し込む」

承認画面で「このアプリは確認されていません」と出る場合

自分で貼ったコードなので、Googleの審査を受けていない状態です。表示されるのは正常です。「詳細」を開いて「安全ではないページに移動」に進むと承認できます。心配なら、コードを読んでから実行してください。 このコードは外部のサーバーへ一切通信していません。読み書きするのは、開いているスプレッドシートと、そのスプレッドシートに付いた保存場所(書き込みの途中で止まったときの控えを置く)だけです。

承認するアカウントを間違えないこと

複数のGoogleアカウントにログインしている場合、スプレッドシートを所有しているアカウントと、承認画面で選ぶアカウントが一致していないと error=access_denied で弾かれます。所有アカウントが分からないときは、いったん myaccount.google.com を開いて確認してください。

「シートが見つかりません」と出る場合

メニューの「初期シートを作成」を先に実行してください。シート名を変えたい場合は、コード先頭の CFG.SHEET を直します。

シートの使い方

請求台帳

「請求No」「取引先」「請求日」「入金期日」「請求額」までを手で入れます。右側の5列(状態・入金日・入金額・差額・消込メモ)はスクリプトが書く欄なので、触らなくて構いません。

列 入れるもの
請求No INV-202608-01 など。空でも動きますが、消込メモが読めなくなります
取引先 社名。表記は台帳の中で統一してください
請求日 / 入金期日 2026/08/31 形式。未入金レポートの経過日数に使います
請求額 税込の請求額

請求書そのものをスプレッドシートで発行しているなら、請求書PDFを1クリックで作る無料テンプレート の発行分をそのままこの台帳に写せます。

入金明細

ネットバンキングからダウンロードした入出金明細を、「入金日」「摘要(振込名義)」「入金額」の3列に貼ります。列の並びが違うCSVなら、貼ったあとに、この3列が1〜3列目に来るよう並べ替えてください。3列の順番だけが違う場合は、コード先頭の CFG.PAY にある DATE・DESC・AMOUNT の数字を1〜3の中で入れ替えても構いません(例: DATE: 3, DESC: 1, AMOUNT: 2)。4以上にはしないでください。入金額などの列が上書きされるか、入金明細への書き込みが行われなくなります。

4〜6列目(消込状態・消込先請求No・消込メモ)は、スクリプトが書き込む場所です。CSVの残高などの列がここに入ったまま実行すると上書きされるので、空けてから実行してください。

出金行が混ざっていても構いません。金額が0以下の行は無視されます。

名義対応 — ここが消込の成否を分けます

銀行の明細に出るのは、たいていカタカナの振込名義です。台帳の「株式会社タナカ商事」と、明細の カ)タナカショウジ は、文字として一致しません。ここを埋めるのが「名義対応」シートです。

取引先 振込名義
株式会社タナカ商事 タナカショウジ
青山デザイン アオヤマデザイン

1つの取引先に複数の名義を登録できます(担当者名義で振り込まれる、旧社名のまま、など)。行を足すだけです。

照合の前に、両方の文字列を次のように揃えています。

  • 半角カナ タナカ を全角カナ「タナカ」へ、全角英数を半角へ(Unicode正規化)
  • ひらがなをカタカナへ(台帳が「さくら」で明細が サクラ でも当たります)
  • 「株式会社」「㈱」「カブシキガイシャ」「カ)」などの法人格表記を落とす(付く・付かないが銀行ごとに違うため)
  • 空白・記号・長音符を落とす

**取引先名がカタカナで台帳に入っているなら、名義対応への登録は要りません。**台帳の取引先名そのものも照合対象に入れています。登録が要るのは、漢字の社名とカナの名義がつながらないときです。

登録する名義は、できるだけ長くしてください。2〜3文字の短い名義は、摘要全体と完全一致する場合だけ使います。たとえば「タナカ」を登録しても、「フリコミ タナカショウジ」へは部分一致させません。

設定

項目 既定値 意味
許容差額 880 請求額との差がこの金額以内なら同じとみなす。振込手数料の差引を吸収するため
合算の最大件数 3 まとめ入金で、何件までの組み合わせを探すか
合算を試す上限件数 12 1取引先の未入金がこれを超えたら合算を探さない

合算の最大件数は4、合算を試す上限件数は20より大きい値を入れても、その値に丸めます。組み合わせの探索は件数が増えると急に重くなるためです。

許容差額を大きくしすぎると、別の請求と取り違えます。手数料の実額(多くは110〜880円)に合わせてください。

消込を判断する順番

自動で消し込む道具でいちばん怖いのは、間違った相手に消し込まれることです。間違って消し込まれた請求は「入金済」として一覧から消えるので、気づく機会がありません。そのため、確実なものから順に確定させ、迷ったら消し込まない作りにしています。

  1. 名義が一致し、金額も一致する組み合わせを、1対1で確定させる
  2. 残った入金について、同じ取引先の複数の請求の合計と一致する組み合わせを探す(まとめ入金)
  3. まだ残った入金が一部入金・過入金の候補でも、請求台帳は自動更新せず「要確認」にする
  4. それ以外は消し込まず、「要確認」として理由を書く

各段階で、同じ請求を2件の入金が取り合っている場合は、どちらも確定させません。合計が一致する組み合わせが2通り見つかった場合も同じです。名義が2社に一致した入金でも、会社をまたいだ請求の合計では消し込みません。

要確認になった入金には、直せる形で理由が入ります。

  • 「振込名義『フリコミ スズキ タロウ』に対応する取引先が不明。名義対応へ登録してください」
  • 「振込名義が複数の取引先に一致(タナカ商事 / 田中工務店)。名義をより長い表記で登録してください」
  • 「取引先『青山デザイン』に未入金の請求がありません(同額の請求は INV-202607-03)」
  • 「候補が複数あり一意に決まりません(INV-001, INV-004)。正しい請求を確かめてから、この行の消込状態を『消込済』にし、消込先請求Noに請求Noを入れ、請求台帳の入金額(これまでの入金の合計)と状態も入れてください」

名義対応を直したら、もう一度「入金を消し込む」を押せば、要確認だった行が当たり直します。

消込済みにした入金を戻したいときは、入金明細のその行の「消込状態」「消込先請求No」「消込メモ」と、請求台帳の対応する行の「状態」「入金日」「入金額」「差額」「消込メモ」を、両方とも空に戻してから実行してください。片方だけ戻すと食い違いが残ります。入金明細だけ戻すと、同じ取引先に同額の未入金があれば、その請求にも同じ入金が付いて二重になり、無ければ要確認になります。請求台帳だけ戻すと、その入金は消込済のままなので二度と当たらず、請求が未入金のまま残ります。手で確定した結果まで消えてしまうので、一括で戻す機能は付けていません。

二重に消し込まれない理由

「入金を消し込む」は何度実行しても構いません。消込状態が「消込済」の入金行はスキップし、状態が「入金済」の請求は候補から外すためです。判断の材料はすべてシート上にあり、スクリプトの実行が終わっても残ります。

これは意識して書いた部分です。実行中の変数にだけ状態を持たせると、実行が終わった時点で消えて、次の実行で同じ入金をもう一度処理してしまいます。

書き込みが途中で止まった場合にも備えています。請求台帳と入金明細は2回に分けて書くため、1回目の後で止まると「台帳だけ入金済み・明細は未消込」の食い違いが残ります。そこで、書く前に「書く予定の値」を控えておき、書き終えたら消します。次に実行したとき控えが残っていれば、同じ値を書き直してから突き合わせに進みます。書く値は足し算の結果そのものなので、何度書き直しても入金額は二重になりません。行の並びが変わっていた行は書き直さず、どの行かを知らせます。知らせた行は、請求台帳と入金明細を見比べて、書かれていない側を手で入れてください。入金明細なら「消込状態」を「消込済」にして「消込先請求No」を、請求台帳なら「状態」「入金日」「入金額」を入れます。

分割で振り込まれた場合

一部入金と過入金は自動では確定しません。明細だけでは、その入金がどの請求の何回目の分なのかを決めきれないためです。入金明細を「要確認」にし、候補が1件なら、その請求Noと、残額か超過額を消込メモに書きます。確かめたら、入金明細のその行は「消込状態」を「消込済」にし、「消込先請求No」に請求Noを入れてください。請求台帳のその請求は、「入金額」にこれまでの入金の合計を入れ、「状態」を一部入金なら「一部入金」、過入金なら「入金済」にします。 過入金の超過分は、請求台帳の消込メモに書いておいてください。消込済にした行は次から判定し直さないので、手で入れた内容が上書きされることはありません。消込メモに残った案内の文は、書き換えても消しても構いません。一部入金のあとの入金は、台帳の入金額を引いた残額と突き合わせます。

動作の確認について

2026年10月2日に、新しいGoogleスプレッドシートへこのページのコードを貼り、実際に動かして確かめました。

実際のスプレッドシートで確かめたこと(2026年10月2日)

確かめた内容 結果
メニュー「入金消込」に「初期シートを作成/入金を消し込む/未入金レポートを作る」の3つが出る 確認
承認画面で求められる権限が「このスプレッドシートだけ」になっている(コード先頭の @OnlyCurrentDoc による) 確認
「初期シートを作成」で、請求台帳・入金明細・名義対応・設定の4枚ができる 確認
請求4件・入金3件で実行すると、名義と金額が合う2件が入金済になり、名義の分からない1件は要確認として理由がメモに入る 確認
請求台帳の「状態・入金日・入金額・差額・消込メモ」と、入金明細の「消込状態・消込先請求No・消込メモ」の列に正しく書き込まれる(振込手数料220円の差は差額 -220) 確認
もう一度実行しても消込は0件で、台帳も明細も変わらない 確認
「未入金レポート」に未入金2件と残額合計 ¥61,000 が、期日超過・期日前の区分つきで出る 確認
請求台帳だけを書いて止めた状態から「入金を消し込む」を押すと、「同じ内容を書き直しました」と出て明細も消込済になり、入金額は二重にならない 確認
同じく止めた状態で入金明細の2行目に1行差し込んでから実行すると、ずれた3行は書き直さずに行番号を知らせ、入金額は二重にならない 確認
時間主導トリガーから実行しても途中で止まらず、結果が実行ログ(Apps Scriptエディタの「実行数」)に出る 確認

Node.jsでのテスト(2026年8月30日・10月2日)

消込の判断(名義の正規化・金額や日付の読み取り・請求との突き合わせ)は、Node.js上でもテストしています。

確かめた内容 結果
カ)タナカショウジ / カブシキガイシャ タナカ ショウジ / ㈱ / (有) を同じ表記へ揃える 通過
ひらがな「さくら」と半角カナ サクラ が一致する 通過
"1,234" "¥35,160" "1234" "35160円" を数値として読む 通過
手数料220円が引かれた入金を同じ入金として消し込み、差額 -220 を記録する 通過
2件の請求(35,160 + 22,000)が57,160円でまとめて振り込まれた場合に両方消し込む 通過
同額の請求が2件あるとき、消し込まずに要確認にする 通過
2件の入金が同じ請求に該当するとき、どちらも消し込まない 通過
名義が2社に一致し、両社に同じ金額の未入金があるとき要確認にする 通過
名義が2社に一致しても、会社をまたいだ請求の合計では消し込まない 通過
同じ入力で2回実行しても、入金額も状態も変わらない 通過
一部入金・過入金候補は請求台帳を書き換えず「要確認」にする 通過
一部入金を手で確定した後は、次の入金を残額と突き合わせ、手で入れた行は書き換えない 通過
存在しない日付(例: 2026/02/31)を受け入れない 通過
2〜3文字の短い名義は完全一致のみ許可する 通過
書き込みの途中で止めた状態から、次の実行で書き直し、入金額が二重にならない 通過
止まった後に行が差し込まれても、違う行へ書き直さない 通過
時間主導トリガー(画面なし)で実行しても途中で止まらない 通過

確かめていないこと

  • 実際の銀行のCSVを貼ったときの列のずれ、文字化け、日付の形式(銀行ごとに違います)

明細を貼ったあとに「入金日」「摘要」「入金額」の3列が意図した位置に来ているか、目で確認してから実行してください。

なお、コード中で String.prototype.normalize を使っています。Apps Scriptの新しいランタイム(V8)が前提です。2020年以降に作ったプロジェクトなら既定でV8ですが、古いプロジェクトへ貼る場合は「プロジェクトの設定」でV8が有効か確認してください。

コード

以下を全文コピーして、Apps Scriptエディタに貼り付けてください。

/** @OnlyCurrentDoc  このスプレッドシートだけを読み書きします(承認画面で求める権限をこのファイルに限るための指定) */
/**
 * 請求と入金の突合(入金消込)— Google Apps Script
 * 事務自動化ラボ  https://jimu-jidoka.vercel.app/
 *
 * 「請求台帳」と、銀行からダウンロードした入出金明細を貼った「入金明細」を突き合わせて、
 * 対応が一意に決まる請求だけを入金済みにし、残った未入金を一覧にします。
 *
 * 設計の方針は3つです。
 *   1. 迷ったら消し込まない。候補が複数ある入金は「要確認」として人に返します。
 *   2. 何度実行しても結果が変わらない。消込済みの入金行は次回スキップします。
 *   3. 書き込みの途中で止まっても、次の実行の最初に同じ内容を書き直します。
 */

// ===== 設定 =====
// シート名を変えたい場合はここだけ直してください。列の並びは「初期シートを作成」が作る順です。
const CFG = {
  SHEET: {
    INVOICE: '請求台帳',
    PAYMENT: '入金明細',
    ALIAS: '名義対応',
    SETTINGS: '設定',
    REPORT: '未入金レポート',
  },
  // 請求台帳の列(A=1)
  INV: { NO: 1, PARTNER: 2, ISSUED: 3, DUE: 4, AMOUNT: 5, STATUS: 6, PAID_DATE: 7, PAID_AMOUNT: 8, DIFF: 9, MEMO: 10 },
  // 入金明細の列(A=1)
  PAY: { DATE: 1, DESC: 2, AMOUNT: 3, STATUS: 4, LINKED: 5, MEMO: 6 },
  // 名義対応の列(A=1)
  ALIAS: { PARTNER: 1, NAME: 2 },
  HEADER_ROWS: 1,
};

// 請求台帳の「状態」欄に入る値。ここ以外の文字列は書き込みません。
const STATUS = { UNPAID: '未入金', PARTIAL: '一部入金', PAID: '入金済', CHECK: '要確認' };
// 入金明細の「消込状態」欄に入る値。
const PAY_STATUS = { OPEN: '未消込', DONE: '消込済', CHECK: '要確認' };

// 書き込みが途中で止まったときの書き直し用に、書く予定の値をドキュメントプロパティへ置きます(「書き込み」の節)。
const JOURNAL_KEY = 'reconcile_pending';
const JOURNAL_CHUNK = 8000; // ドキュメントプロパティは1件あたり約9KBまで

// 設定シートの既定値。シート側の値が空・不正なときはこの値を使います。
const DEFAULTS = {
  TOLERANCE: 880,   // 許容差額(円)。振込手数料が引かれた入金を同一とみなす幅
  MAX_COMBINE: 3,   // 合算消込で組み合わせる請求の最大件数
  MAX_INV_PER_PARTNER: 12, // 合算を試す上限。これを超える取引先は合算せず要確認にします
};

// ===== メニュー =====
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('入金消込')
    .addItem('初期シートを作成', 'setupSheets')
    .addSeparator()
    .addItem('入金を消し込む', 'reconcile')
    .addItem('未入金レポートを作る', 'buildUnpaidReport')
    .addToUi();
}

// ===== エントリポイント =====

/** 必要な4枚のシート(請求台帳・入金明細・名義対応・設定)を作ります。既にあるシートには触りません。未入金レポートは「未入金レポートを作る」で作ります。 */
function setupSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const made = [];

  if (ensureSheet_(ss, CFG.SHEET.INVOICE, ['請求No', '取引先', '請求日', '入金期日', '請求額', '状態', '入金日', '入金額', '差額', '消込メモ'])) made.push(CFG.SHEET.INVOICE);
  if (ensureSheet_(ss, CFG.SHEET.PAYMENT, ['入金日', '摘要(振込名義)', '入金額', '消込状態', '消込先請求No', '消込メモ'])) made.push(CFG.SHEET.PAYMENT);
  if (ensureSheet_(ss, CFG.SHEET.ALIAS, ['取引先', '振込名義(通帳・明細に出る表記)'])) made.push(CFG.SHEET.ALIAS);

  if (!ss.getSheetByName(CFG.SHEET.SETTINGS)) {
    const s = ss.insertSheet(CFG.SHEET.SETTINGS);
    s.getRange(1, 1, 4, 3).setValues([
      ['項目', '値', '説明'],
      ['許容差額', DEFAULTS.TOLERANCE, '請求額との差がこの金額以内なら同じとみなします(振込手数料の差引を吸収するため)'],
      ['合算の最大件数', DEFAULTS.MAX_COMBINE, '複数の請求をまとめて1回で振り込まれた場合に、何件までの組み合わせを探すか'],
      ['合算を試す上限件数', DEFAULTS.MAX_INV_PER_PARTNER, '1取引先の未入金がこの件数を超えたら合算を探さず要確認にします'],
    ]);
    s.getRange(1, 1, 1, 3).setFontWeight('bold');
    s.setColumnWidth(1, 160); s.setColumnWidth(2, 90); s.setColumnWidth(3, 520);
    s.setFrozenRows(1);
    made.push(CFG.SHEET.SETTINGS);
  }

  SpreadsheetApp.flush();
  notify_(made.length ? '作成しました:' + made.join(' / ') : 'すべてのシートが既にあります。');
}

/** 入金明細を読み、請求台帳と突き合わせて消し込みます。 */
function reconcile() {
  const lock = LockService.getDocumentLock();
  // 手動実行と時間主導トリガーが重なると、同じ入金を2回書き込む余地が出ます。
  if (!lock.tryLock(20000)) {
    notify_('別の処理が実行中です。少し待ってからもう一度実行してください。');
    return;
  }
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const invSheet = mustGetSheet_(ss, CFG.SHEET.INVOICE);
    const paySheet = mustGetSheet_(ss, CFG.SHEET.PAYMENT);
    if (!invSheet || !paySheet) return;

    // 前回が書き込みの途中で止まっていたら、同じ内容を書き直してから読み直します。
    const recovered = recoverPendingWrite_(invSheet, paySheet);

    const settings = readSettings_(ss);
    const invoices = readInvoices_(invSheet);
    const payments = readPayments_(paySheet);
    const aliases = readAliases_(ss);

    if (invoices.length === 0) { notify_(recovered + '「' + CFG.SHEET.INVOICE + '」に請求データがありません。'); return; }
    if (payments.length === 0) { notify_(recovered + '「' + CFG.SHEET.PAYMENT + '」に入金データがありません。'); return; }

    const result = matchPayments_(invoices, payments, aliases, settings);
    writeBack_(invSheet, paySheet, invoices, payments);

    notify_(
      '消込結果',
      recovered
      + '消込: ' + result.matched + ' 件\n'
      + '要確認: ' + result.unresolved + ' 件\n\n'
      + (result.unresolved
        ? '要確認の行は「' + CFG.SHEET.PAYMENT + '」の消込メモに理由を書いています。\n'
          + '振込名義が漢字の社名と違う場合は「' + CFG.SHEET.ALIAS + '」に対応を登録してから、もう一度実行してください。'
        : 'すべての入金の対応先が決まりました。')
    );
  } finally {
    lock.releaseLock();
  }
}

/** 未入金だけを抜き出したレポートシートを作り直します。 */
function buildUnpaidReport() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const invSheet = mustGetSheet_(ss, CFG.SHEET.INVOICE);
  if (!invSheet) return;

  const tz = ss.getSpreadsheetTimeZone();
  const today = startOfDay_(new Date());
  const invoices = readInvoices_(invSheet).filter((v) => v.status !== STATUS.PAID);

  const rows = invoices.map((v) => {
    const remain = v.amount - v.paidAmount;
    const days = v.due ? Math.floor((today.getTime() - startOfDay_(v.due).getTime()) / 86400000) : null;
    return {
      sortKey: v.due ? v.due.getTime() : Number.MAX_SAFE_INTEGER,
      row: [
        v.no,
        v.partner,
        v.issued ? Utilities.formatDate(v.issued, tz, 'yyyy/MM/dd') : '',
        v.due ? Utilities.formatDate(v.due, tz, 'yyyy/MM/dd') : '',
        v.amount,
        v.paidAmount,
        remain,
        agingLabel_(days),
        v.status || STATUS.UNPAID,
      ],
    };
  }).sort((a, b) => a.sortKey - b.sortKey);

  const header = ['請求No', '取引先', '請求日', '入金期日', '請求額', '入金済額', '残額', '経過', '状態'];
  let sheet = ss.getSheetByName(CFG.SHEET.REPORT);
  if (!sheet) sheet = ss.insertSheet(CFG.SHEET.REPORT);
  sheet.clear();

  const total = rows.reduce((sum, r) => sum + (Number(r.row[6]) || 0), 0);
  const body = rows.map((r) => r.row);
  const values = [header].concat(body.length ? body : [['(未入金はありません)', '', '', '', '', '', '', '', '']]);
  sheet.getRange(1, 1, values.length, header.length).setValues(values);
  sheet.getRange(1, 1, 1, header.length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  if (body.length) {
    sheet.getRange(2, 5, body.length, 3).setNumberFormat('#,##0');
    const last = values.length + 1;
    sheet.getRange(last, 6).setValue('残額合計');
    sheet.getRange(last, 7).setValue(total).setNumberFormat('#,##0');
    sheet.getRange(last, 6, 1, 2).setFontWeight('bold');
  }
  sheet.autoResizeColumns(1, header.length);
  SpreadsheetApp.flush();

  notify_('未入金 ' + body.length + ' 件 / 残額合計 ' + formatYen_(total) + ' を「' + CFG.SHEET.REPORT + '」に書き出しました。');
}

/**
 * 結果を知らせます。メニューから実行したときはダイアログ、時間主導トリガーから実行したときは実行ログへ出します。
 * トリガー実行では SpreadsheetApp.getUi() が使えず、呼ぶとその場で止まるためです。
 */
function notify_(title, message) {
  let ui = null;
  try { ui = SpreadsheetApp.getUi(); } catch (e) { ui = null; }
  if (!ui) { console.log(title + (message ? '\n' + message : '')); return; }
  if (message === undefined) ui.alert(title);
  else ui.alert(title, message, ui.ButtonSet.OK);
}

// ===== 読み取り =====

function mustGetSheet_(ss, name) {
  const s = ss.getSheetByName(name);
  if (!s) notify_('「' + name + '」という名前のシートが見つかりません。メニューの「初期シートを作成」を先に実行してください。');
  return s;
}

/**
 * シートの列数が足りないと getRange が「範囲の座標が...」で落ちます。
 * 自分で作った台帳を貼り込むと列を削っていることがあるので、足りない分だけ足します。
 */
function ensureColumns_(sheet, need) {
  const have = sheet.getMaxColumns();
  if (have < need) sheet.insertColumnsAfter(have, need - have);
}

function ensureSheet_(ss, name, header) {
  if (ss.getSheetByName(name)) return false;
  const s = ss.insertSheet(name);
  s.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold');
  s.setFrozenRows(1);
  s.autoResizeColumns(1, header.length);
  return true;
}

function readSettings_(ss) {
  const out = {
    tolerance: DEFAULTS.TOLERANCE,
    maxCombine: DEFAULTS.MAX_COMBINE,
    maxInvPerPartner: DEFAULTS.MAX_INV_PER_PARTNER,
  };
  const sheet = ss.getSheetByName(CFG.SHEET.SETTINGS);
  if (!sheet || sheet.getLastRow() < 2) return out;
  const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
  const map = { '許容差額': 'tolerance', '合算の最大件数': 'maxCombine', '合算を試す上限件数': 'maxInvPerPartner' };
  rows.forEach((r) => {
    const key = map[String(r[0]).trim()];
    const num = parseAmount_(r[1]);
    if (key && num !== null && num >= 0) out[key] = num;
  });
  // 組み合わせ探索は件数に対して急激に重くなります。上限を超える指定は黙って丸めます。
  out.maxCombine = Math.min(Math.max(out.maxCombine, 1), 4);
  out.maxInvPerPartner = Math.min(Math.max(out.maxInvPerPartner, 1), 20);
  return out;
}

function readInvoices_(sheet) {
  const last = sheet.getLastRow();
  if (last <= CFG.HEADER_ROWS) return [];
  ensureColumns_(sheet, CFG.INV.MEMO);
  const width = Math.max(sheet.getLastColumn(), CFG.INV.MEMO);
  const values = sheet.getRange(CFG.HEADER_ROWS + 1, 1, last - CFG.HEADER_ROWS, width).getValues();
  const out = [];
  values.forEach((r, i) => {
    const no = String(r[CFG.INV.NO - 1] || '').trim();
    const partner = String(r[CFG.INV.PARTNER - 1] || '').trim();
    const amount = parseAmount_(r[CFG.INV.AMOUNT - 1]);
    // 請求Noも取引先も金額も無い行は、末尾の空行とみなして無視します。
    if (!no && !partner && amount === null) return;
    out.push({
      row: CFG.HEADER_ROWS + 1 + i,
      no: no,
      partner: partner,
      partnerKey: normalizeName_(partner),
      issued: toDate_(r[CFG.INV.ISSUED - 1]),
      due: toDate_(r[CFG.INV.DUE - 1]),
      amount: amount === null ? 0 : amount,
      status: String(r[CFG.INV.STATUS - 1] || '').trim(),
      paidDate: toDate_(r[CFG.INV.PAID_DATE - 1]),
      paidAmount: parseAmount_(r[CFG.INV.PAID_AMOUNT - 1]) || 0,
      memo: String(r[CFG.INV.MEMO - 1] || '').trim(),
      dirty: false,
    });
  });
  // 残額 = 請求額 - 既に記録済みの入金額。分割で振り込まれた2回目以降は、
  // 請求額そのものではなく残額と突き合わせないと永久に一致しません。
  out.forEach((v) => { v.remain = v.amount - v.paidAmount; });
  return out;
}

function readPayments_(sheet) {
  const last = sheet.getLastRow();
  if (last <= CFG.HEADER_ROWS) return [];
  ensureColumns_(sheet, CFG.PAY.MEMO);
  const width = Math.max(sheet.getLastColumn(), CFG.PAY.MEMO);
  const values = sheet.getRange(CFG.HEADER_ROWS + 1, 1, last - CFG.HEADER_ROWS, width).getValues();
  const out = [];
  values.forEach((r, i) => {
    const desc = String(r[CFG.PAY.DESC - 1] || '').trim();
    const amount = parseAmount_(r[CFG.PAY.AMOUNT - 1]);
    if (!desc && amount === null) return;
    out.push({
      row: CFG.HEADER_ROWS + 1 + i,
      date: toDate_(r[CFG.PAY.DATE - 1]),
      desc: desc,
      descKey: normalizeName_(desc),
      amount: amount === null ? 0 : amount,
      status: String(r[CFG.PAY.STATUS - 1] || '').trim(),
      linked: String(r[CFG.PAY.LINKED - 1] || '').trim(),
      memo: String(r[CFG.PAY.MEMO - 1] || '').trim(),
      dirty: false,
    });
  });
  return out;
}

/** 名義対応シートを「正規化した名義 → 取引先名」の索引にします。取引先名そのものも索引へ入れます。 */
function readAliases_(ss) {
  const index = [];
  const push = (key, partner) => {
    // 1文字の名義は他社の名義に紛れて誤爆します(「ア」が「アオヤマ」に一致してしまう)。
    if (key && key.length >= 2 && partner) index.push({ key: key, partner: partner });
  };
  const invSheet = ss.getSheetByName(CFG.SHEET.INVOICE);
  if (invSheet && invSheet.getLastRow() > CFG.HEADER_ROWS) {
    invSheet.getRange(CFG.HEADER_ROWS + 1, CFG.INV.PARTNER, invSheet.getLastRow() - CFG.HEADER_ROWS, 1)
      .getValues().forEach((r) => {
        const p = String(r[0] || '').trim();
        push(normalizeName_(p), p);
      });
  }
  const aliasSheet = ss.getSheetByName(CFG.SHEET.ALIAS);
  if (aliasSheet && aliasSheet.getLastRow() > CFG.HEADER_ROWS) {
    aliasSheet.getRange(CFG.HEADER_ROWS + 1, 1, aliasSheet.getLastRow() - CFG.HEADER_ROWS, 2)
      .getValues().forEach((r) => {
        push(normalizeName_(String(r[CFG.ALIAS.NAME - 1] || '')), String(r[CFG.ALIAS.PARTNER - 1] || '').trim());
      });
  }
  // 長い名義から先に判定します。「アオヤマ」と「アオヤマシヨウジ」が両方登録されている場合、
  // 短いほうで先に当たると常に取り違えます。
  index.sort((a, b) => b.key.length - a.key.length);
  return index;
}

// ===== 突合(ここがこのツールの本体。シートに触らない純粋な処理にしてあります) =====

/**
 * @param {Array} invoices readInvoices_ の結果
 * @param {Array} payments readPayments_ の結果
 * @param {Array} aliases  readAliases_ の結果
 * @param {Object} settings readSettings_ の結果
 * @return {{matched:number, partial:number, unresolved:number}}
 */
function matchPayments_(invoices, payments, aliases, settings) {
  const tol = settings.tolerance;

  // 消込済みの入金は触りません。ここが「何度実行しても二重にならない」根拠です。
  const targets = payments.filter((p) => p.status !== PAY_STATUS.DONE && p.amount > 0);
  // 既に入金済みの請求と、残額が残っていない請求は候補から外します。
  const open = invoices.filter((v) => v.status !== STATUS.PAID && v.remain > 0);

  // 入金1件ごとに、名義から取引先を推定します。
  targets.forEach((p) => {
    p._partners = partnersFor_(p.descKey, aliases);
    p._cands = open.filter((v) => p._partners.indexOf(v.partner) >= 0);
  });

  const usedInv = {};   // 請求No → 使用済み
  const done = {};      // 入金の行番号 → true

  // --- 第1段階: 名義が一致し、金額も一致するものだけを、1対1で確定させる ---
  const claim = {};
  targets.forEach((p) => {
    if (done[p.row]) return;
    // 請求額以下の入金のみ、手数料差引として許容します。
    // 過入金は許容差額内でも自動確定しません。
    p._exact = p._cands.filter((v) => p.amount <= v.remain && v.remain - p.amount <= tol);
    p._exact.forEach((v) => { (claim[v.row] = claim[v.row] || []).push(p.row); });
  });
  targets.forEach((p) => {
    if (done[p.row] || p._exact.length !== 1) return;
    const v = p._exact[0];
    // 同じ請求を2件の入金が取り合っている場合は、どちらも確定させません。
    if (claim[v.row].length !== 1 || usedInv[v.row]) return;
    settle_(p, [v], tol, '名義と金額が一致');
    usedInv[v.row] = true;
    done[p.row] = true;
  });

  // --- 第2段階: 複数の請求がまとめて1回で振り込まれた場合(合算) ---
  const comboClaim = {};
  targets.forEach((p) => {
    if (done[p.row]) return;
    p._combo = null;
    // 合算は同じ取引先の請求どうしに限ります。名義が2社に一致しても、会社をまたいでは合算しません。
    let subsets = [];
    p._partners.forEach((name) => {
      if (subsets.length > 1) return;
      const pool = p._cands.filter((v) => v.partner === name && !usedInv[v.row]);
      if (pool.length < 2 || pool.length > settings.maxInvPerPartner) return;
      subsets = subsets.concat(findSubsets_(pool, p.amount, tol, settings.maxCombine));
    });
    if (subsets.length === 1) {
      p._combo = subsets[0];
      p._combo.forEach((v) => { (comboClaim[v.row] = comboClaim[v.row] || []).push(p.row); });
    }
  });
  targets.forEach((p) => {
    if (done[p.row] || !p._combo) return;
    if (p._combo.some((v) => usedInv[v.row] || comboClaim[v.row].length !== 1)) return;
    settle_(p, p._combo, tol, p._combo.length + '件をまとめて入金');
    p._combo.forEach((v) => { usedInv[v.row] = true; });
    done[p.row] = true;
  });

  // --- 第3段階: 一部入金・過入金は自動書込みせず、要確認で返す ---
  const soleClaim = {};
  targets.forEach((p) => {
    if (done[p.row]) return;
    const pool = p._cands.filter((v) => !usedInv[v.row]);
    p._sole = pool.length === 1 ? pool[0] : null;
    if (p._sole) { (soleClaim[p._sole.row] = soleClaim[p._sole.row] || []).push(p.row); }
  });
  targets.forEach((p) => {
    if (done[p.row] || !p._sole) return;
    const v = p._sole;
    // 同じ請求に2件の入金が該当している状態で片方を消すと、もう片方が宙に浮きます。
    if (usedInv[v.row] || soleClaim[v.row].length !== 1) return;
    p._reasonOverride = p.amount < v.remain
      ? '請求 ' + v.no + ' の一部入金候補(残 ' + formatYen_(v.remain - p.amount) + ')。確定するときは、この行の消込状態を「消込済」にし、消込先請求Noに請求Noを入れ、請求台帳の入金額(これまでの入金の合計)と状態(一部入金)を入れてください'
      : '請求 ' + v.no + ' の過入金候補(超過 ' + formatYen_(p.amount - v.remain) + ')。確定するときは、この行の消込状態を「消込済」にし、消込先請求Noに請求Noを入れ、請求台帳の入金額(これまでの入金の合計)と状態(入金済)を入れ、超過分を請求台帳の消込メモに書いてください';
  });

  // --- 残り: 消し込まずに理由を書いて返す ---
  let unresolved = 0;
  targets.forEach((p) => {
    if (done[p.row]) return;
    unresolved++;
    p.status = PAY_STATUS.CHECK;
    p.linked = '';
    p.memo = p._reasonOverride || reasonFor_(p, invoices, tol);
    p.dirty = true;
  });

  // 集計。matched は金額が合って確定したもの、partial は差額が残ったもの。
  let matched = 0, partial = 0;
  targets.forEach((p) => {
    if (!done[p.row]) return;
    if (p._diff === 0) matched++; else partial++;
  });

  // 入金が1円も付かなかった請求は「未入金」に戻します(状態欄が空のままだと見落とすため)。
  invoices.forEach((v) => {
    if (v.status === STATUS.PAID) return;
    if (!v.status) { v.status = STATUS.UNPAID; v.dirty = true; }
  });

  return { matched: matched, partial: partial, unresolved: unresolved };
}

/** 入金1件を請求1件以上へ割り当て、両方のオブジェクトを更新します(書き込みはしません)。 */
function settle_(payment, invs, tol, note) {
  const totalRemain = invs.reduce((s, v) => s + v.remain, 0);
  const diff = payment.amount - totalRemain;
  payment.status = PAY_STATUS.DONE;
  payment.linked = invs.map((v) => v.no || '(No未設定)').join(', ');
  payment.memo = note + (Math.abs(diff) <= tol && diff !== 0 ? '/差額 ' + formatYen_(diff) + ' は許容内' : '');
  payment.dirty = true;
  payment._diff = Math.abs(diff) <= tol ? 0 : diff;

  // 合算のときは、端数を最後の1件へ寄せます。請求ごとに按分すると1円単位が合わなくなるためです。
  let allocated = 0;
  invs.forEach((v, i) => {
    const share = (i === invs.length - 1)
      ? payment.amount - allocated
      : Math.min(v.remain, payment.amount - allocated);
    allocated += share;
    v.paidDate = payment.date;
    v.paidAmount = v.paidAmount + share;   // 上書きではなく加算。分割入金の2回目で1回目が消えないように
    v.remain = v.amount - v.paidAmount;
    v.diff = v.paidAmount - v.amount;
    v.status = Math.abs(v.diff) <= tol ? STATUS.PAID : (v.diff < 0 ? STATUS.PARTIAL : STATUS.CHECK);
    v.memo = note;
    v.dirty = true;
  });
}

/** 消し込めなかった理由を、直せる形で書きます。「一致しません」だけでは直しようがないので。 */
function reasonFor_(p, invoices, tol) {
  if (p._partners.length === 0) {
    return '振込名義「' + p.desc + '」に対応する取引先が不明。「' + CFG.SHEET.ALIAS + '」へ登録してください';
  }
  if (p._partners.length > 1) {
    return '振込名義が複数の取引先に一致(' + p._partners.join(' / ') + ')。名義をより長い表記で登録してください';
  }
  const sameAmount = invoices.filter((v) => v.status !== STATUS.PAID && Math.abs(v.remain - p.amount) <= tol);
  if (p._cands.length === 0) {
    return sameAmount.length
      ? '取引先「' + p._partners[0] + '」に未入金の請求がありません(同額の請求は ' + sameAmount.map((v) => v.no).join(', ') + ')'
      : '取引先「' + p._partners[0] + '」に未入金の請求がありません';
  }
  if (p._cands.length === 1) {
    return '請求 ' + p._cands[0].no + ' に該当する入金が複数あります。どれが該当するか手で決めてください';
  }
  return '候補が複数あり一意に決まりません(' + p._cands.map((v) => v.no).join(', ') + ')。正しい請求を確かめてから、この行の消込状態を「消込済」にし、消込先請求Noに請求Noを入れ、請求台帳の入金額(これまでの入金の合計)と状態も入れてください';
}

/** 合計が target に一致する部分集合を探します。見つかった組み合わせを全部返します。 */
function findSubsets_(pool, target, tol, maxSize) {
  const found = [];
  const pick = [];
  const walk = (start, sum) => {
    if (found.length > 1) return; // 一意でないと分かった時点で打ち切ります
    // 入金が請求合計以下の場合だけ、手数料差引として候補にします。
    // 一度一致しても、さらに請求を足した別の許容範囲候補がないか探し続けます。
    if (pick.length >= 2 && sum >= target && sum - target <= tol) found.push(pick.slice());
    if (pick.length >= maxSize || sum > target + tol) return;
    for (let i = start; i < pool.length; i++) {
      pick.push(pool[i]);
      walk(i + 1, sum + pool[i].remain);
      pick.pop();
      if (found.length > 1) return;
    }
  };
  walk(0, 0);
  return found;
}

/** 正規化した摘要から、一致する取引先名を返します(重複は除きます)。 */
function partnersFor_(descKey, aliases) {
  const hit = [];
  if (!descKey) return hit;
  aliases.forEach((a) => {
    // 短い名義は完全一致のみ許可します。2〜3文字の部分一致は
    // 無関係な会社名に含まれるリスクが高いためです。
    const matches = descKey === a.key || (a.key.length >= 4 && descKey.indexOf(a.key) >= 0);
    if (matches && hit.indexOf(a.partner) < 0) hit.push(a.partner);
  });
  return hit;
}

// ===== 書き込み =====
// 行ごとに setValue すると、請求が数百件ある月に実行時間の上限(6分)へ当たります。
// 更新する列の範囲をまとめて1回読み、変わった行だけ差し替えて、1回で書き戻します。
//
// 請求台帳と入金明細は2回に分けて書きます。1回目の後で止まると、台帳だけが入金済みになり、
// 次の実行で同じ入金を二重に数えるおそれがあります。そこで、書く前に「書く予定の値」を
// ドキュメントプロパティへ保存し、書き終えたら消します。次の実行の最初に残っていれば、
// 同じ値を書き直してから進みます。書く値は確定値(足し算の結果)なので、何度書き直しても同じです。

function writeBack_(invSheet, paySheet, invoices, payments) {
  const plan = {
    inv: planRows_(invoices, invRow_, invKeyOf_),
    pay: planRows_(payments, payRow_, payKeyOf_),
  };
  if (plan.inv.length === 0 && plan.pay.length === 0) return;
  const props = PropertiesService.getDocumentProperties();
  saveJournal_(props, plan);
  applyPlan_(invSheet, paySheet, plan);
  SpreadsheetApp.flush();
  clearJournal_(props);
}

/** 前回の書き込みが途中で止まっていたら書き直します。結果の説明文(無ければ空文字)を返します。 */
function recoverPendingWrite_(invSheet, paySheet) {
  const props = PropertiesService.getDocumentProperties();
  const plan = loadJournal_(props);
  if (!plan) return '';
  const skipped = applyPlan_(invSheet, paySheet, plan);
  SpreadsheetApp.flush();
  clearJournal_(props);
  return '前回の実行が書き込みの途中で止まっていたため、同じ内容を書き直しました。\n'
    + (skipped.length
      ? '行の並びが変わっていて書き直さなかった行があります(' + skipped.join('、') + ')。この行は手で確かめてください。\n'
      : '')
    + '\n';
}

const invRow_ = (v) => [v.status, v.paidDate || '', v.paidAmount || '', v.diff === undefined ? '' : v.diff, v.memo || ''];
const payRow_ = (p) => [p.status, p.linked, p.memo];
// 書き直す前に、その行が同じ請求・同じ入金のままかを確かめるための目印です。
const invKeyOf_ = (v) => v.no + '|' + v.amount;
const payKeyOf_ = (p) => (p.date ? p.date.getTime() : '') + '|' + p.desc + '|' + p.amount;

function planRows_(items, toRow, keyOf) {
  return items.filter((x) => x.dirty).map((x) => ({ row: x.row, key: keyOf(x), vals: toRow(x) }));
}

function applyPlan_(invSheet, paySheet, plan) {
  const invKeyAt = (r) => String(r[CFG.INV.NO - 1] || '').trim() + '|' + (parseAmount_(r[CFG.INV.AMOUNT - 1]) || 0);
  const payKeyAt = (r) => {
    const d = toDate_(r[CFG.PAY.DATE - 1]);
    return (d ? d.getTime() : '') + '|' + String(r[CFG.PAY.DESC - 1] || '').trim() + '|' + (parseAmount_(r[CFG.PAY.AMOUNT - 1]) || 0);
  };
  const skipped = [];
  applyRows_(invSheet, CFG.INV.STATUS, 5, plan.inv, invKeyAt).forEach((row) => skipped.push(CFG.SHEET.INVOICE + ' ' + row + '行目'));
  applyRows_(paySheet, CFG.PAY.STATUS, 3, plan.pay, payKeyAt).forEach((row) => skipped.push(CFG.SHEET.PAYMENT + ' ' + row + '行目'));
  return skipped;
}

/** 書く予定の行の状態欄だけを上書きします。他の行は今シートにある値のまま残します。目印が合わない行は飛ばして返します。 */
function applyRows_(sheet, startCol, width, rows, keyAt) {
  if (rows.length === 0) return [];
  const first = CFG.HEADER_ROWS + 1;
  const last = Math.max.apply(null, rows.map((r) => r.row));
  const lastCol = startCol + width - 1;
  if (sheet.getMaxRows() < last) return rows.map((r) => r.row);
  ensureColumns_(sheet, lastCol);
  const block = sheet.getRange(first, 1, last - first + 1, lastCol).getValues();
  const skipped = [];
  rows.forEach((r) => {
    const cur = block[r.row - first];
    if (keyAt(cur) !== r.key) { skipped.push(r.row); return; }
    for (let i = 0; i < width; i++) cur[startCol - 1 + i] = r.vals[i];
  });
  if (skipped.length === rows.length) return skipped;
  // 状態の列だけを書き戻します(請求No・金額など、元データの列には触りません)。
  sheet.getRange(first, startCol, block.length, width).setValues(block.map((row) => row.slice(startCol - 1, lastCol)));
  return skipped;
}

// 日付は JSON にすると文字列になるので、時刻の数値で持ち、読むときに日付へ戻します。
const encodeVal_ = (v) => (v instanceof Date ? { d: v.getTime() } : v);
const decodeVal_ = (v) => (v && typeof v === 'object' && typeof v.d === 'number' ? new Date(v.d) : v);

function saveJournal_(props, plan) {
  const enc = (rows) => rows.map((r) => ({ row: r.row, key: r.key, vals: r.vals.map(encodeVal_) }));
  const text = JSON.stringify({ inv: enc(plan.inv), pay: enc(plan.pay) });
  const map = {};
  let n = 0;
  for (let i = 0; i < text.length; i += JOURNAL_CHUNK) map[JOURNAL_KEY + '_' + (n++)] = text.slice(i, i + JOURNAL_CHUNK);
  map[JOURNAL_KEY] = String(n);
  props.setProperties(map);
}

function loadJournal_(props) {
  const n = Number(props.getProperty(JOURNAL_KEY) || 0);
  if (!n) return null;
  let text = '';
  for (let i = 0; i < n; i++) text += props.getProperty(JOURNAL_KEY + '_' + i) || '';
  const raw = JSON.parse(text);
  const dec = (rows) => rows.map((r) => ({ row: r.row, key: r.key, vals: r.vals.map(decodeVal_) }));
  return { inv: dec(raw.inv), pay: dec(raw.pay) };
}

function clearJournal_(props) {
  const n = Number(props.getProperty(JOURNAL_KEY) || 0);
  // 個数の目印を先に消します。途中で止まっても、読みかけの断片を「残っている」と誤読しないためです。
  props.deleteProperty(JOURNAL_KEY);
  for (let i = 0; i < n; i++) props.deleteProperty(JOURNAL_KEY + '_' + i);
}

// ===== 値の整形 =====

/**
 * 銀行の明細を貼ると、金額が「1,234」「¥1,234」「1234 」のような文字列で入ることがあります。
 * そのままでは足し算も比較もできないので、数値へ直します。読めない値は null を返します。
 */
function parseAmount_(v) {
  if (v === null || v === undefined || v === '') return null;
  if (typeof v === 'number') return isFinite(v) ? Math.round(v) : null;
  const s = String(v).normalize('NFKC').replace(/[¥\\,\s]/g, '').replace(/円$/, '');
  if (s === '' || !/^-?\d+(\.\d+)?$/.test(s)) return null;
  return Math.round(Number(s));
}

/** 日付欄が Date でも「2026/08/31」でも「2026-08-31」でも受け取れるようにします。 */
function toDate_(v) {
  if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
  if (v === null || v === undefined || v === '') return null;
  const s = String(v).normalize('NFKC').trim().replace(/[.\-]/g, '/');
  const m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
  if (!m) return null;
  const year = Number(m[1]);
  const month = Number(m[2]);
  const day = Number(m[3]);
  const d = new Date(year, month - 1, day);
  if (isNaN(d.getTime()) || d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null;
  return d;
}

/**
 * 会社名と振込名義を突き合わせるための正規化。ここが甘いと消込は当たりません。
 *  - NFKC で半角カナ「タナカ」を全角「タナカ」へ、全角英数を半角へ揃える
 *  - ひらがなをカタカナへ寄せる(台帳が「さくら」、明細が「サクラ」でも当たるように)
 *  - 「株式会社」「カ)」などの法人格表記を落とす(付く・付かないが銀行ごとに違うため)
 *  - 記号・空白・長音符を落とす
 */
function normalizeName_(s) {
  if (!s) return '';
  let t = String(s).normalize('NFKC').toUpperCase();
  t = t.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60));
  // ㈱ は NFKC で「(株)」へ分解されます。先に括弧付きの略記を落とさないと「株」だけが残ります。
  t = t.replace(/[((]\s*(株|有|合|同|名|資)\s*[))]/g, '');
  t = t.replace(/株式会社|有限会社|合同会社|合資会社|合名会社|一般社団法人|一般財団法人/g, '');
  t = t.replace(/カブシキガイシャ|カブシキカイシャ|ユウゲンガイシャ|ユウゲンカイシャ|ゴウドウガイシャ|ゴウドウカイシャ/g, '');
  t = t.replace(/[((]\s*[カユドシ]\s*[))]?|[カユドシ]\s*[))]/g, '');
  t = t.replace(/[\s \-‐‑–—ー・,.、。「」『』()()\[\]{}::;;\/\\*+_"'`~^|]/g, '');
  return t;
}

function startOfDay_(d) {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}

function agingLabel_(days) {
  if (days === null) return '期日未設定';
  if (days < 0) return '期日前(あと' + (-days) + '日)';
  if (days === 0) return '本日が期日';
  if (days <= 30) return '超過 1〜30日';
  if (days <= 60) return '超過 31〜60日';
  if (days <= 90) return '超過 61〜90日';
  return '超過 91日以上';
}

function formatYen_(n) {
  const sign = n < 0 ? '-' : '';
  return sign + '¥' + String(Math.abs(Math.round(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

毎月まわすなら

「入金を消し込む」を月末に手で押すだけでも足ります。定期実行にする場合は、時間主導トリガーで reconcile を呼びます。トリガーから実行したときは画面が無いので、結果はダイアログの代わりに実行ログへ出ます(Apps Scriptエディタの「実行数」で見られます)。

トリガーが黙って止まっていることに気づけない問題そのものについては、GASのトリガーが止まったことに気づく仕組み に別のコードを置いています。

請求のほうも仕組みにしたい場合

この消込ツールは、請求台帳が埋まっている前提で動きます。その請求書の発行そのものが手作業なら、先にそちらを片付けたほうが効きます。

複数社の一括発行・月別の自動採番・発行履歴・二重発行の警告まで組んだ版は有料で配布しています。この消込ツールは無料版のままでも使えますので、必要になったときだけ検討してください。

← 記事一覧へ