概要

項目内容
案件IDMAS-145
カテゴリ外部データ取込
PhaseP1.5
優先度★★★(Phase 1.5 最優先)
所要時間3-4時間
対象ファイル500_import/502_bank_importer.js (新規), 100_config/101_sys_config.js (メニュー追加)
前提案件なし(独立着手可能)

目的

銀行の入出金明細CSVを取り込み、33_wrk_bank の未処理STLと自動マッチングすることで、現在最大のボトルネックである「銀行明細の手入力」を完全排除する。決済日_実績・決済ステータスを自動設定し、月次決算の所要時間を劇的に短縮する。

契約銀行がAPIを提供していないため、CSV取込が恒久的なアプローチとなる(MAS-181 銀行API連携は対象外へ移動済み)。

取込方式のアーキテクチャ決定

比較検討

比較軸案A: ステージングシート方式案B: Google Drive CSV方式
操作の簡便さ◎ スプレッドシートにCSVを貼り付けるだけ。GAS操作に慣れたユーザーに自然△ DriveへのアップロードURL指定が必要。フォルダ構造の設計も必要
エラーハンドリング◎ 貼り付け時にデータがシート上に見えるため、目視確認が容易△ パースエラー時のフィードバックがUI上で見えにくい
501_cc_importerとの一貫性34_wrk_card にCSVを貼り付け→マッチング→消込の同一パターン× 全く異なる取込フロー
既存ワークフローとの親和性◎ クレカ・領収書と同じ「貼り付け→確認→実行」の統一UX△ Drive操作という新しい導線が必要
複数銀行の切替○ シートは1枚で、メニューから銀行を選択○ ファイル名規約で銀行を判別可能

決定: 案A(ステージングシート方式)を採用

理由: 501_cc_importer.js で確立された「ステージングシート + 2段階処理 + 確認FLG」パターンとの一貫性が最も重要。ユーザーの学習コストを最小化し、操作ミスのリスクを低減する。

銀行別CSVフォーマットのアダプター設計

正規化済みレコード型

銀行CSVの列構成差異を吸収する共通の中間レコード型を定義する。

/**
 * 銀行CSV → 正規化済みレコード
 * @typedef {Object} NormalizedBankTx
 * @property {Date}   txDate     - 取引日
 * @property {string} memo       - 摘要(銀行の摘要文をそのまま保持)
 * @property {number} deposit    - 入金額(0以上)
 * @property {number} withdrawal - 出金額(0以上)
 * @property {number} balance    - 残高
 * @property {string} rawRow     - 元CSVの行データ(デバッグ・トレース用)
 */

アダプターインターフェース

/**
 * 銀行別CSVパーサーの共通インターフェース
 * @typedef {Object} BankAdapter
 * @property {string} bankName    - 銀行表示名
 * @property {string} accountKey  - 決済口座キー(例: '口座振込_福井銀行')
 * @property {function(string[][]): NormalizedBankTx[]} parse
 *   — シートの2次元配列(ヘッダー含む)を受け取り、正規化レコード配列を返す
 */

初期対応アダプター

銀行accountKeyCSV列構成(想定)備考
福井銀行口座振込_福井銀行日付, 摘要, お支払金額, お預り金額, 残高メインバンク
SBIネット銀行口座振込_SBI日付, 内容, 出金額, 入金額, 残高, メモサブ口座

新しい銀行の追加方法

BANK_ADAPTERS 配列に新しいアダプターオブジェクトを追加するだけ。parse関数でCSVの列マッピングを定義する。

var BANK_ADAPTERS = [
  {
    bankName: '福井銀行',
    accountKey: '口座振込_福井銀行',
    parse: function(data) { /* 列マッピング */ }
  },
  {
    bankName: 'SBIネット銀行',
    accountKey: '口座振込_SBI',
    parse: function(data) { /* 列マッピング */ }
  }
];

現在のコード

参照パターン: 501_cc_importer.js の2段階処理

Step 1 — マッチング確認(importCcStatement):

  • 34_wrk_card からクレカ明細を読み込み
  • 33_wrk_bank の未処理STLと2段階マッチング(Pass 1: 確実マッチ / Pass 2: 候補提案)
  • マッチ結果を 34_wrk_card のマッチ結果列(確認FLG, 処理結果, マッチ決済ID等)に書き込み
  • 消込は行わない

Step 2 — 消込実行(applyCcSettlement):

  • 確認FLG=TRUE かつ 処理結果=MATCHED の行のみ処理
  • 33_wrk_bank決済ステータス消込済, 決済日_実績, 消込手段カード明細 を更新

参照パターン: マッチ結果列の構造

var MATCH_EXTRA_HEADERS = [
  '確認FLG', '処理結果', 'マッチ決済ID(STL)',
  'STL決済日_計画', 'STL決済金額', 'STL取引先名', 'STL摘要'
];

33_wrk_bank(BankTxDTO)の列構成

有効フラグ, 決済ID(STL), 消込対象請求ID(INV), 決済日_計画,
立替日, 決済日_実績, 決済ステータス, 入出金区分, 決済口座,
取引先名, 税込金額_決済, 差額(手数料等), 差額処理科目,
組織名, 摘要, 消込手段, 自動仕訳JNL_ID

修正方針

全体アーキテクチャ

36_wrk_bank_import (ステージングシート)
  ↓ ユーザーが銀行CSVを貼り付け
  ↓
502_bank_importer.js
  ├─ Step 1: importBankStatement()  — パース + マッチング確認
  │    ├─ 銀行アダプターでCSVをパース → NormalizedBankTx[]
  │    ├─ 重複チェック(取引日+金額+摘要のハッシュ)
  │    ├─ 33_wrk_bank の未処理STLと2段階マッチング
  │    └─ マッチ結果を 36_wrk_bank_import のマッチ結果列に表示
  │
  └─ Step 2: applyBankSettlement() — 確認FLG=TRUE で消込実行
       ├─ 33_wrk_bank の決済ステータス→消込済, 決済日_実績, 消込手段→銀行CSV
       └─ マッチなしの明細は新規STLとして33_wrk_bankに追記(任意)

Step 0: DDL — ステージングシート 36_wrk_bank_import の新設

101_sys_config.js の setupAllSchemas に追加。

'WRK_BANK_IMPORT': {
  headers: [
    "取引日", "摘要", "出金額", "入金額", "残高",
    "銀行名", "取込ハッシュ",
    "確認FLG", "処理結果",
    "マッチ決済ID(STL)", "STL決済日_計画", "STL決済金額",
    "STL取引先名", "STL摘要"
  ],
  color: "#38761d"
}

01_sys_config の configDefaults にも追加:

if (!existKeys.includes('WRK_BANK_IMPORT'))
  confSheet.appendRow(['WRK_BANK_IMPORT', '', '36_wrk_bank_import', '銀行CSV取込']);

Step 1: importBankStatement() — パース + マッチング確認

/**
 * メニュー: 🏦 銀行CSV マッチング確認
 * 36_wrk_bank_import のCSVデータをパースし、33_wrk_bank の未処理STLと
 * 自動マッチング。結果をマッチ結果列に表示する。消込は行わない。
 */
function importBankStatement() {
  var FUNC = 'importBankStatement';
  try {
    var ss = getWebSpreadsheet_();
    var ui = SpreadsheetApp.getUi();

    // --- 銀行選択ダイアログ ---
    var bankChoice = ui.prompt(
      '🏦 銀行CSV取込',
      '銀行を選択してください:\n1: 福井銀行\n2: SBIネット銀行',
      ui.ButtonSet.OK_CANCEL
    );
    if (bankChoice.getSelectedButton() !== ui.Button.OK) return;
    var adapterIdx = parseInt(bankChoice.getResponseText()) - 1;
    if (adapterIdx < 0 || adapterIdx >= BANK_ADAPTERS.length) {
      return ui.alert('🚨 無効な選択です。');
    }
    var adapter = BANK_ADAPTERS[adapterIdx];

    // --- 36_wrk_bank_import ---
    var importSheet = Utils.getSheetByKey('WRK_BANK_IMPORT', '36_wrk_bank_import');
    if (!importSheet) return ui.alert('🚨 36_wrk_bank_import が見つかりません。');
    var importData = importSheet.getDataRange().getValues();
    if (importData.length < 2) return ui.alert('🚨 CSVデータがありません。');
    var importIdx = buildHeaderIndex_(importData[0]);

    // --- CSVパース ---
    var txns = adapter.parse(importData);
    if (txns.length === 0) return ui.alert('🚨 有効な明細が見つかりません。');

    // --- 重複チェック(取込ハッシュ) ---
    var hashCol = importIdx['取込ハッシュ'];
    var existingHashes = {};
    // 33_wrk_bank の摘要+日付+金額から既存ハッシュを構築
    // ... 省略(後述のエッジケース参照)

    // --- 正規化データを36タブに書き戻し ---
    for (var t = 0; t < txns.length; t++) {
      var row = t + 2; // ヘッダー行 + 1
      var tx = txns[t];
      importSheet.getRange(row, importIdx['取引日'] + 1).setValue(tx.txDate);
      importSheet.getRange(row, importIdx['摘要'] + 1).setValue(tx.memo);
      importSheet.getRange(row, importIdx['出金額'] + 1).setValue(tx.withdrawal);
      importSheet.getRange(row, importIdx['入金額'] + 1).setValue(tx.deposit);
      importSheet.getRange(row, importIdx['残高'] + 1).setValue(tx.balance);
      importSheet.getRange(row, importIdx['銀行名'] + 1).setValue(adapter.bankName);
      importSheet.getRange(row, importIdx['取込ハッシュ'] + 1).setValue(
        computeTxHash_(tx.txDate, tx.memo, tx.deposit, tx.withdrawal)
      );
    }

    // --- 33_wrk_bank の候補STL抽出 ---
    var bankSheet = Utils.getSheetByKey('WRK_BANK', '33_wrk_bank');
    var bankData = bankSheet.getDataRange().getValues();
    var bankIdx = buildHeaderIndex_(bankData[0]);
    var candidates = extractBankCandidateStls_(bankData, bankIdx, adapter.accountKey);

    // --- 2段階マッチング ---
    var matchCount = 0, suggestCount = 0, unmatchCount = 0;

    for (var t = 0; t < txns.length; t++) {
      var row = t + 2;
      var tx = txns[t];
      var txAmt = tx.withdrawal > 0 ? tx.withdrawal : tx.deposit;
      var txType = tx.withdrawal > 0 ? '出金' : '入金';
      if (txAmt === 0) {
        importSheet.getRange(row, importIdx['処理結果'] + 1).setValue('SKIP:金額ゼロ');
        continue;
      }

      // Pass 1: 金額完全一致 + 日付±3日 + 入出金区分一致
      var bestIdx = -1;
      var bestDateDiff = Infinity;
      for (var s = 0; s < candidates.length; s++) {
        if (candidates[s].matched) continue;
        if (candidates[s].ioType !== txType) continue;
        if (Math.abs(candidates[s].amount - txAmt) >= 1) continue; // 金額完全一致(1円未満の誤差許容)
        var dateDiff = Math.abs(
          (new Date(tx.txDate) - new Date(candidates[s].dueDate)) / 86400000
        );
        if (dateDiff > 3) continue;

        // 同一金額・日付範囲内で最も日付が近いSTLを選択
        if (dateDiff < bestDateDiff) { bestDateDiff = dateDiff; bestIdx = s; }
      }

      if (bestIdx >= 0) {
        // 確実マッチ
        var m = candidates[bestIdx];
        m.matched = true;
        writeMatchResult_(importSheet, row, importIdx, 'MATCHED', m);
        matchCount++;
      } else {
        // Pass 2: 金額±10% + 摘要キーワード一致で候補提案
        var suggestIdx = -1;
        var suggestBest = -1;
        for (var s2 = 0; s2 < candidates.length; s2++) {
          if (candidates[s2].matched) continue;
          if (candidates[s2].ioType !== txType) continue;
          var amtDiffRate = Math.abs(txAmt - candidates[s2].amount) / Math.abs(candidates[s2].amount || 1);
          var amtScore = amtDiffRate <= 0.10 ? 40 : 0;
          var nameScore = isMemoFuzzyMatch_(tx.memo, candidates[s2].vendor, candidates[s2].memo) ? 30 : 0;
          var dateScore = 0;
          var d2 = Math.abs((new Date(tx.txDate) - new Date(candidates[s2].dueDate)) / 86400000);
          if (d2 <= 7) dateScore = 30;
          else if (d2 <= 14) dateScore = 15;
          var score = amtScore + nameScore + dateScore;
          if (score > suggestBest) { suggestBest = score; suggestIdx = s2; }
        }

        if (suggestIdx >= 0 && suggestBest >= 40) {
          writeMatchResult_(importSheet, row, importIdx, 'SUGGEST', candidates[suggestIdx]);
          importSheet.getRange(row, 1, 1, importSheet.getMaxColumns()).setBackground('#FCE5CD');
          suggestCount++;
        } else {
          importSheet.getRange(row, importIdx['処理結果'] + 1).setValue('UNMATCHED');
          importSheet.getRange(row, 1, 1, importSheet.getMaxColumns()).setBackground('#FCE5CD');
          unmatchCount++;
        }
      }
    }

    // 結果ダイアログ
    ui.alert('🏦 マッチング確認 結果',
      '✅ 確実マッチ: ' + matchCount + '件\n' +
      '💡 候補提案: ' + suggestCount + '件\n' +
      '⚠️ アンマッチ: ' + unmatchCount + '件\n\n' +
      '確認後、「確認FLG」にチェックを入れて「🏦 銀行CSV消込実行」を実行してください。',
      ui.ButtonSet.OK);

  } catch (e) {
    console.error(FUNC, e.message, e.stack);
    SpreadsheetApp.getUi().alert('🚨 ' + FUNC + ' エラー', e.message, SpreadsheetApp.getUi().ButtonSet.OK);
  }
}

Step 2: applyBankSettlement() — 消込実行

/**
 * メニュー: 🏦 銀行CSV消込実行
 * 36_wrk_bank_import の確認FLG=TRUE 行について、33_wrk_bank のSTLを消込済にする。
 */
function applyBankSettlement() {
  var FUNC = 'applyBankSettlement';
  try {
    var ss = getWebSpreadsheet_();
    var ui = SpreadsheetApp.getUi();

    var importSheet = Utils.getSheetByKey('WRK_BANK_IMPORT', '36_wrk_bank_import');
    if (!importSheet) return ui.alert('🚨 36_wrk_bank_import が見つかりません。');
    var importData = importSheet.getDataRange().getValues();
    var importIdx = buildHeaderIndex_(importData[0]);

    var bankSheet = Utils.getSheetByKey('WRK_BANK', '33_wrk_bank');
    var bankData = bankSheet.getDataRange().getValues();
    var bankIdx = buildHeaderIndex_(bankData[0]);
    var bankMaxCol = bankData[0].length;

    // STL_ID → 行番号マップ
    var stlRowMap = {};
    for (var b = 1; b < bankData.length; b++) {
      var stlId = String(bankData[b][bankIdx['決済ID(STL)']]).trim();
      if (stlId) stlRowMap[stlId] = b + 1;
    }

    var processCount = 0;

    for (var i = 1; i < importData.length; i++) {
      var row = importData[i];
      var rowNum = i + 1;

      // 確認FLG=TRUE かつ 処理結果=MATCHED or SUGGEST のみ処理
      var confirmVal = row[importIdx['確認FLG']];
      if (confirmVal !== true) continue;
      var result = String(row[importIdx['処理結果']] || '').trim();
      if (result === '消込済') continue;
      if (result !== 'MATCHED' && result !== 'SUGGEST') continue;

      var matchedStlId = String(row[importIdx['マッチ決済ID(STL)']] || '').trim();
      if (!matchedStlId || !stlRowMap[matchedStlId]) continue;

      var stlRow = stlRowMap[matchedStlId];
      var txDate = row[importIdx['取引日']];

      // 33_wrk_bank を更新
      bankSheet.getRange(stlRow, bankIdx['決済ステータス'] + 1).setValue('消込済');
      bankSheet.getRange(stlRow, bankIdx['決済日_実績'] + 1).setValue(txDate);
      if (bankIdx['消込手段'] !== undefined) {
        bankSheet.getRange(stlRow, bankIdx['消込手段'] + 1).setValue('銀行CSV');
      }

      // 金額差異がある場合(SUGGEST時)、差額を記録
      var stlDataRow = bankData[stlRow - 1];
      var stlAmt = Utils.parseAmt(stlDataRow[bankIdx['税込金額_決済']]);
      var txAmt = (Number(row[importIdx['出金額']]) || 0) + (Number(row[importIdx['入金額']]) || 0);
      var diff = txAmt - stlAmt;
      if (Math.abs(diff) >= 1) {
        bankSheet.getRange(stlRow, bankIdx['差額(手数料等)'] + 1).setValue(diff);
      }

      // ハイライト (薄緑)
      bankSheet.getRange(stlRow, 1, 1, bankMaxCol).setBackground('#D9EAD3');

      // 36タブの処理結果を更新
      importSheet.getRange(rowNum, importIdx['処理結果'] + 1).setValue('消込済');
      importSheet.getRange(rowNum, 1, 1, importSheet.getMaxColumns()).setBackground('#D9EAD3');

      processCount++;
    }

    if (processCount === 0) {
      ui.alert('🏦', '消込対象がありません。\n確認FLGにチェックが入っているMATCHED/SUGGEST行がありません。', ui.ButtonSet.OK);
    } else {
      ui.alert('🏦 消込実行 結果',
        '🎉 ' + processCount + '件のSTLを消込済にしました。\n\nAction B を実行して仕訳を作成してください。',
        ui.ButtonSet.OK);
    }

  } catch (e) {
    console.error(FUNC, e.message, e.stack);
    SpreadsheetApp.getUi().alert('🚨 ' + FUNC + ' エラー', e.message, SpreadsheetApp.getUi().ButtonSet.OK);
  }
}

Step 3: ヘルパー関数

/** 取込ハッシュの生成(重複取込防止用) */
function computeTxHash_(txDate, memo, deposit, withdrawal) {
  var d = txDate instanceof Date ? Utilities.formatDate(txDate, Session.getScriptTimeZone(), 'yyyyMMdd') : String(txDate);
  return d + '|' + String(memo).trim().substring(0, 30) + '|' + deposit + '|' + withdrawal;
}

/**
 * 33_wrk_bank から口座一致の未処理STLを抽出
 * extractCandidateStls_ (501_cc_importer.js) の銀行版
 */
function extractBankCandidateStls_(bankData, bankIdx, accountKey) {
  var candidates = [];
  for (var b = 1; b < bankData.length; b++) {
    var flag = bankData[b][bankIdx['有効フラグ']];
    if (flag === false || String(flag).toUpperCase() === 'FALSE') continue;
    var stlStatus = String(bankData[b][bankIdx['決済ステータス']]).trim();
    if (stlStatus !== '未処理') continue;
    var acct = String(bankData[b][bankIdx['決済口座']]).trim();
    if (acct !== accountKey) continue;
    candidates.push({
      rowIndex: b + 1,
      stlId: String(bankData[b][bankIdx['決済ID(STL)']]).trim(),
      dueDate: bankData[b][bankIdx['決済日_計画']],
      vendor: String(bankData[b][bankIdx['取引先名']] || '').trim(),
      amount: Math.abs(Utils.parseAmt(bankData[b][bankIdx['税込金額_決済']])),
      memo: String(bankData[b][bankIdx['摘要']] || '').trim(),
      ioType: String(bankData[b][bankIdx['入出金区分']] || '').trim(),
      matched: false
    });
  }
  return candidates;
}

/** 摘要のファジーマッチ(銀行摘要 ↔ STL取引先名・摘要) */
function isMemoFuzzyMatch_(bankMemo, stlVendor, stlMemo) {
  var bm = normalizeMerchant_(bankMemo);
  var sv = normalizeMerchant_(stlVendor);
  var sm = normalizeMerchant_(stlMemo);
  if (!bm) return false;

  // 取引先名がCSV摘要に含まれるか
  if (sv && sv.length >= 3 && bm.indexOf(sv) !== -1) return true;
  if (sv && sv.length >= 3 && sv.indexOf(bm) !== -1) return true;

  // STL摘要のキーワードがCSV摘要に含まれるか
  if (sm) {
    var tokens = sm.replace(/[_\-\/()()\[\]【】.,:;]/g, ' ')
      .split(/\s+/).filter(function(t) { return t.length >= 2; });
    for (var i = 0; i < tokens.length; i++) {
      if (bm.indexOf(tokens[i]) !== -1) return true;
    }
  }
  return false;
}

/** マッチ結果の書き込み(36タブ) */
function writeMatchResult_(sheet, rowNum, idx, resultLabel, stlCandidate) {
  sheet.getRange(rowNum, idx['確認FLG'] + 1).setValue(resultLabel === 'MATCHED');
  sheet.getRange(rowNum, idx['処理結果'] + 1).setValue(resultLabel);
  sheet.getRange(rowNum, idx['マッチ決済ID(STL)'] + 1).setValue(stlCandidate.stlId);
  sheet.getRange(rowNum, idx['STL決済日_計画'] + 1).setValue(stlCandidate.dueDate);
  sheet.getRange(rowNum, idx['STL決済金額'] + 1).setValue(stlCandidate.amount);
  sheet.getRange(rowNum, idx['STL取引先名'] + 1).setValue(stlCandidate.vendor);
  sheet.getRange(rowNum, idx['STL摘要'] + 1).setValue(stlCandidate.memo);
  if (resultLabel === 'MATCHED') {
    sheet.getRange(rowNum, 1, 1, sheet.getMaxColumns()).setBackground('#FFFFFF');
  }
}

Step 4: メニュー登録(101_sys_config.js

onOpen_() の「🔍 消込・マッチング」メニューにセパレータ + 2項目を追加。

ui.createMenu('🔍 消込・マッチング')
  .addItem('💳 クレカ明細マッチング確認', 'importCcStatement')
  .addItem('💳 クレカ消込実行 (確認FLG=TRUE)', 'applyCcSettlement')
  .addSeparator()
  .addItem('📄 領収書PDFの読み込み (Drive)', 'importReceiptPdfs')
  .addItem('🧾 領収書マッチング確認', 'importReceiptStatement')
  .addItem('🧾 領収書消込実行 (確認FLG=TRUE)', 'applyReceiptSettlement')
  .addSeparator()
  .addItem('🏦 銀行CSV マッチング確認', 'importBankStatement')        // ← 追加
  .addItem('🏦 銀行CSV消込実行 (確認FLG=TRUE)', 'applyBankSettlement') // ← 追加
  .addToUi();

影響範囲

変更対象変更内容変更量
500_import/502_bank_importer.js新規作成~250行
100_config/101_sys_config.jsDDL追加 + メニュー追加~10行
  • 既存ファイルへの変更は 101_sys_config.js のみ
  • 501_cc_importer.js は変更しない(共通化リファクタリングは別案件)
  • 33_wrk_bank のデータ構造は変更なし(既存列を更新するのみ)

注意事項

  1. normalizeMerchant_()501_cc_importer.js で定義済み。GASのグローバルスコープで参照可能なため、502_bank_importer.js から直接呼び出せる。再定義不要
  2. CSVの文字コード: 銀行CSVはShift-JIS (CP932) が多い。スプレッドシートに貼り付ける時点でUTF-8に変換されるため、GAS側での文字コード処理は不要
  3. 確認FLG の自動TRUE設定: Pass 1 (MATCHED) では 確認FLG=TRUE を自動セット。Pass 2 (SUGGEST) では 確認FLG=FALSE でユーザー判断を要求
  4. getWebSpreadsheet_() の使用: 501_cc_importer.js と同じく getWebSpreadsheet_() を使用してスプレッドシートを取得する(Env経由のID参照)
  5. STL金額の絶対値比較: STLの 税込金額_決済 は正の数で格納されている。入金(売上回収)・出金(経費支払)の区分は 入出金区分 列で判定する
  6. 銀行アダプターのCSV列マッピングは仮定: 実際のCSVフォーマットが異なる場合、アダプターの parse 関数を調整する必要がある。初回実装時に実際のCSVファイルを確認すること

エッジケース

条件処理理由
1つの銀行明細に複数STLがマッチ可能金額完全一致 + 日付最近のSTLを1つだけマッチ。残りはSUGGEST1:1マッチを原則とし、誤マッチを防止
1つのSTLに複数の銀行明細がマッチ(分割入金)初回マッチ時に matched=true でロック。2件目以降はSUGGESTまたはUNMATCHED分割入金は手動対応。自動マッチの信頼性を優先
同じCSVの重複取込取込ハッシュ(日付+摘要先頭30文字+入出金額)で既存データと照合。重複行は SKIP:重複同一CSVの再貼り付けによるデータ重複を防止
金額ゼロの銀行明細SKIP:金額ゼロ利息計算の端数や残高照会など、実質的な入出金がない行
マッチ候補なしの銀行明細UNMATCHED(オレンジ背景)。STLとして新規追加はしない不明な入出金を自動でSTL化するとデータ品質が低下する。手動対応を促す
入金明細(売上回収)STLの 入出金区分=入金 とマッチ出金と同じマッチングロジック。入出金区分の一致で絞り込み
銀行の振込手数料が差し引かれた入金金額不一致でPass 1不成立 → Pass 2で候補提案(金額±10%以内)差額は applyBankSettlement差額(手数料等) に記録

実データ検証(MCP でのデータ確認が必要な場合)

確認項目確認方法理由
33_wrk_bank の 決済口座 の実際の値MCP で33タブの決済口座列のユニーク値を取得アダプターの accountKey を実データに合わせる必要がある
福井銀行CSVの実際の列構成実際のCSVファイルを確認アダプターのparse関数の列マッピングを確定する
SBIネット銀行CSVの実際の列構成実際のCSVファイルを確認同上
33_wrk_bank の未処理STLの件数と金額分布MCP で 決済ステータス=未処理 のSTLを集計マッチング精度の事前評価

関連ドキュメント

仕様書関連箇所
CLAUDE.md33_wrk_bank の集計条件: 消込済 かつ 自動仕訳JNL_ID あり
CLAUDE.mdMCP add_rows は使わない。update_cells で末尾行に書き込む
501_cc_importer.js2段階マッチ + 確認FLGパターンの実装参照
dev_mas-077_settlement_date_sync.mdSTL→INVの決済日転記(Action B連動)
dev_mas-158_hc_saas_import.mdSaaSデータ取込の仕様書(類似アーキテクチャ)

人間が検討すべき事項

#項目詳細
1CSVフォーマットの銀行別差異福井銀行・SBIネット銀行の実際のCSV列構成を確認し、アダプターの parse 関数を確定する必要がある。TODO_future.md から転記
2ステージングシートの新設36_wrk_bank_import をDDLに追加。番号36は使用可能か確認(既存は31-35)。TODO_future.md から転記
3マッチ候補なし明細のSTL自動追加本仕様ではUNMATCHED明細のSTL自動追加は行わない(データ品質優先)。運用でニーズがあれば追加検討
4501_cc_importer.js との共通化リファクタリングextractCandidateStls_, ensureMatchCols_, normalizeMerchant_ 等の共通ヘルパーを共有モジュールに抽出する案件。MAS-145のスコープ外として、別途 MAS-146 またはリファクタリング案件で対応
5日付許容誤差の調整Pass 1 で ±3日、Pass 2 で ±14日を設定。運用実績に基づいて調整が必要になる可能性がある
6振込手数料の自動判定入金時に振込手数料が差し引かれるケース。差額を自動で「支払手数料」科目に割り当てるかは別案件(MAS-122 銀行残高照合と連動)

実装プロンプト(Claude Code 用)

あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者です。
案件 MAS-145「法人口座CSV取込・自動マッチング」を実装してください。

## 実行前タスク
以下のファイルを読み込んでください:
1. `500_import/501_cc_importer.js` — 2段階マッチ + 確認FLGパターンの参照実装。以下を重点確認:
   - `importCcStatement()`: Pass 1/Pass 2 のマッチングロジック
   - `applyCcSettlement()`: 確認FLG=TRUE での消込実行パターン
   - `MATCH_EXTRA_HEADERS`: マッチ結果列のヘッダー構成
   - `extractCandidateStls_()`: 候補STL抽出のフィルタ条件
   - `normalizeMerchant_()`: 全角→半角正規化(502からも呼び出す)
2. `000_infra/003_contracts.js` — BankTxDTO の列構成
3. `000_infra/002_constants.js` — ID発番ルール、RPA_DEFAULTS の決済口座名
4. `100_config/101_sys_config.js` — DDL定義(WRK_BANK の headers)とメニュー登録箇所
5. `200_data/202_repository.js` — BankTxRepository の append パターン
6. `CLAUDE.md` — コーディング規約
7. `docs/dev/dev_mas-145_bank_csv_import.md` — 本仕様書

## 修正対象ファイル
- `500_import/502_bank_importer.js` — **新規作成**
- `100_config/101_sys_config.js` — DDL追加 + メニュー追加

## 実装内容

### A: `502_bank_importer.js` の新規作成
仕様書の「修正方針」セクションに記載されたコードを基に実装。
主要関数:
- `importBankStatement()` — Step 1: CSVパース + 2段階マッチング確認
- `applyBankSettlement()` — Step 2: 確認FLG=TRUE の行を消込実行
- `computeTxHash_()` — 重複取込防止用ハッシュ生成
- `extractBankCandidateStls_()` — 33_wrk_bankから口座一致の未処理STLを抽出
- `isMemoFuzzyMatch_()` — 銀行摘要のファジーマッチ
- `writeMatchResult_()` — マッチ結果の書き込み
- `BANK_ADAPTERS` — 銀行別CSVパーサー配列

### B: `101_sys_config.js` の修正
1. setupAllSchemas の SCHEMAS に `WRK_BANK_IMPORT` を追加
2. configDefaults に `WRK_BANK_IMPORT` 行を追加
3. 「🔍 消込・マッチング」メニューに銀行CSV用の2項目を追加

## 制約
- `501_cc_importer.js` は変更しない
- `33_wrk_bank` のスキーマは変更しない(既存列を更新するのみ)
- `normalizeMerchant_()` は `501_cc_importer.js` で定義済みのため再定義しない
- 銀行アダプターのCSV列マッピングは暫定。実際のCSVで調整が必要

## 実データ検証
- 33_wrk_bank の `決済口座` 列のユニーク値を確認し、アダプターの `accountKey` を合わせる
- 実際の銀行CSVの列構成が仕様と異なる場合、アダプターの `parse` を調整

## 動作確認
`npm run push:dev` 後:
1. setupAllSchemas を実行 → `36_wrk_bank_import` タブが作成されること
2. 36タブに銀行CSVデータを貼り付け
3. メニュー「🔍 消込・マッチング」→「🏦 銀行CSV マッチング確認」を実行
4. 銀行選択ダイアログで「1: 福井銀行」を選択
5. **検証**: 36タブにマッチ結果列が表示されること(MATCHED/SUGGEST/UNMATCHED)
6. MATCHED行の確認FLGがTRUEであること。SUGGEST行はFALSEであること
7. 確認FLG=TRUEの行を確認後、「🏦 銀行CSV消込実行」を実行
8. **検証**: 33_wrk_bank の対象STLが `消込済` + `決済日_実績` + `消込手段=銀行CSV` になること
9. Action B を実行 → 仕訳が正常に生成されること

### 拡張思考の使用状況

| フェーズ | 拡張思考 | 備考 |
|---------|:--------:|------|
| ファイル読み込み・構造理解 | あり | 501パターンの把握、BankTxDTOの列構成確認 |
| 銀行アダプター実装 | なし | 仕様書で列マッピング定義済み(暫定) |
| マッチングロジック | あり | Pass1/Pass2の条件調整、エッジケース処理 |
| 消込実行 | なし | 501のapplyCcSettlementパターンの踏襲 |
| DDL・メニュー追加 | なし | 定型作業 |

推奨実行モデル

工程推奨モデル理由
仕様書作成(本ドキュメント)Claude Opus 4.6501パターンの分析、アーキテクチャ決定(ステージング vs Drive)、エッジケース網羅に高い推論力が必要
実装Claude Sonnet 4.6仕様書でコード構造は定義済みだが、銀行アダプターの実CSV対応とマッチングの微調整に中程度の判断力が必要
動作確認ユーザー手動銀行CSVの貼り付け → マッチング確認 → 消込実行の一連の手動操作が必要

変更履歴

日付変更内容
2026-04-16初版作成