概要

項目内容
案件IDMAS-152
カテゴリ自動入力パイプライン(電帳法対応)
PhasePhase 1.5
優先度P1 (★★★)
所要時間1-2時間
実装ステータス📝 仕様書段階・実装未着手 (2026-04-28 監査時点)
対象ファイル500_import/502_receipt_reader.js(既存修正)
前提案件MAS-154(取引先略称自動生成・完了済)
後続案件MAS-153(ファイル名ベースの証憑リンク再構築)

目的

電子帳簿保存法(スキャナ保存)の検索要件 3 項目(日付・取引先・金額)をファイル名のみで充足させ、税務調査時および会計士共有時の証憑検索を即時可能にする。Gemini OCR で抽出済みの accrualDate / vendor / totalAmount を使用し、502_receipt_reader.jsimportReceiptPdfs() 後処理として YYYYMMDD_取引先略称_金額_元ファイル名.ext 形式へ自動リネームする。併せて processed/YYYY-MM/ の月別サブフォルダへ整理し、後続案件 MAS-153(リンク再構築)がファイル名照合のみでリンクを復旧できる前提を作る。

現在のコード

1. 処理済みフォルダ直下への移動 — 502_receipt_reader.js:47-54, 149

// processed サブフォルダの取得or作成
var processedFolder;
var subFolders = folder.getFoldersByName('processed');
if (subFolders.hasNext()) {
  processedFolder = subFolders.next();
} else {
  processedFolder = folder.createFolder('processed');
}
// ...
// 処理済みフォルダに移動 (成功時のみ)
file.moveTo(processedFolder);

processed/ フラット構造で月別分割がなく、リネームも行っていない。元ファイル名(スキャナが付ける連番等)のまま蓄積されるため、電帳法の検索要件を満たせない。

2. receipt タブへの書き込み — 502_receipt_reader.js:62-63, 107, 142-144

var HEADERS = ['管理ID', '処理日時', '証憑種別', '取引先名', '🏢住所', '税込金額_決済', '税抜金額_決済', '消費税額_決済', '源泉税額', 'T番号', '帳票番号', '発行日', '発生日(P/L計上日)', '決済日_実績', '決済手段', '摘要', 'ファイル名', '証跡リンク'];
// ...
var driveLink = 'https://drive.google.com/file/d/' + file.getId() + '/view';
// ...
fileName + (extractedList.length > 1 ? ' (p' + (ei+1) + ')' : ''),  // ファイル名
driveLink                                                            // 証跡リンク

「ファイル名」「証跡リンク」列へはリネーム前のファイル名が書き込まれる。リネーム実施後は新ファイル名で上書きする必要がある。

3. OCR 抽出値 — 502_receipt_reader.js:119-144

extracted.vendor / extracted.accrualDate / extracted.issueDate / extracted.totalAmount は既存の Gemini 応答に存在(callGeminiForReceipt_ のプロンプト参照)。金額は数値で返るが、Gemini が文字列で返す稀ケースも想定し Number() でラップしてから Math.round() する。

4. 既存の略称正規化 — 502_receipt_reader.js:129

Utils.normalizePartnerName(extracted.vendor || ''),  // 取引先名 (I-10: MST_PART略称で正規化)

MAS-154 で導入済の Utils.normalizePartnerName() が MST_PART の「略称」列(101_sys_config.js:645)から取引先略称を返す。この関数を再利用し、ファイル名の取引先部にも同じ略称を使うことで、35_wrk_receipt の「取引先名」列との整合性を担保する。

修正方針

全体像: importReceiptPdfs() 内でのファイル移動フェーズを 「月別サブフォルダの取得/作成 → リネーム → moveTo → 35_wrk_receipt 列更新」 の 4 ステップに再構成する。processed 直下への移動は廃止し、すべて processed/YYYY-MM/ 配下へ集約する。

Step 1: リネーム関数と各種ヘルパーの追加

502_receipt_reader.js 末尾に、以下のプライベート関数を追加する。

1-a. buildEvidenceFileName_(extracted, originalName, now)

電帳法準拠のファイル名を組み立てる純粋関数。

/**
 * 電帳法準拠のファイル名を組み立てる
 * フォーマット: YYYYMMDD_取引先略称_金額_元ファイル名.ext
 * @param {Object} extracted - OCR抽出結果 (vendor/accrualDate/issueDate/totalAmount)
 * @param {string} originalName - 元のファイル名(拡張子含む)
 * @param {Date} now - 処理日時(発生日・発行日が欠落した場合の最終fallback)
 * @returns {string} リネーム後のファイル名
 */
function buildEvidenceFileName_(extracted, originalName, now) {
  // --- 日付部 (accrualDate → issueDate → now の順で fallback) ---
  var ymd = Utils.parseDateToYmd(extracted.accrualDate || '')
         || Utils.parseDateToYmd(extracted.issueDate || '')
         || Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  var dateToken = ymd.replace(/-/g, '');  // ハイフン除去 → YYYYMMDD

  // --- 取引先略称部 (I-10 の normalizePartnerName を再利用) ---
  var partnerToken = Utils.normalizePartnerName(extracted.vendor || '') || 'UNKNOWN';
  partnerToken = sanitizeFileNamePart_(partnerToken);

  // --- 金額部 (数値/文字列/null/NaN 全てを 0 以上の整数へ) ---
  var amountNum = Number(extracted.totalAmount);
  var amountToken = (isFinite(amountNum) && amountNum > 0) ? String(Math.round(amountNum)) : '0';

  // --- 元ファイル名部(拡張子分離→本体切り詰め→再結合) ---
  var extMatch = String(originalName).match(/^(.+?)(\.[^.]+)?$/);
  var baseName = extMatch ? extMatch[1] : String(originalName);
  var ext = (extMatch && extMatch[2]) ? extMatch[2] : '';
  baseName = sanitizeFileNamePart_(baseName);

  // プレフィックス(日付_略称_金額_)を先に組んで残余文字数を算出
  var prefix = dateToken + '_' + partnerToken + '_' + amountToken + '_';
  var MAX_TOTAL = 200;  // Drive 上限 255 に対し安全マージン
  var budget = MAX_TOTAL - prefix.length - ext.length;
  if (budget < 1) budget = 1;  // 極端に長いプレフィックスでも最低1文字は残す
  if (baseName.length > budget) baseName = baseName.substring(0, budget);

  return prefix + baseName + ext;
}

1-b. sanitizeFileNamePart_(s)

Drive の禁止文字と OCR 由来の改行を除去する。

/**
 * ファイル名パーツから Drive 禁止文字と制御文字を除去
 * @param {string} s
 * @returns {string}
 */
function sanitizeFileNamePart_(s) {
  if (!s) return '';
  var t = String(s);
  t = t.replace(/[\r\n\t]+/g, ' ');        // OCR 由来の改行・タブ → 空白
  t = t.replace(/[\/\\:\*\?"<>\|]/g, '_'); // Drive 禁止文字 → アンダースコア
  t = t.replace(/\s+/g, ' ').trim();       // 連続空白を1つに
  return t;
}

1-c. isAlreadyRenamed_(fileName)

冪等性担保のための判定。新形式(先頭 8 桁数字 + _)を正規表現で厳密に判定する。

/**
 * 既に電帳法準拠形式でリネーム済みか判定(冪等性)
 * @param {string} fileName
 * @returns {boolean}
 */
function isAlreadyRenamed_(fileName) {
  return /^\d{8}_/.test(String(fileName || ''));
}

1-d. resolveUniqueFileName_(folder, desiredName)

同名ファイルが存在する場合に拡張子直前へ _2, _3 を付与する。Folder.getFilesByName()hasNext() 判定ループで探索する。

/**
 * 指定フォルダ内で重複しないファイル名を決定する
 * @param {GoogleAppsScript.Drive.Folder} folder
 * @param {string} desiredName
 * @returns {string}
 */
function resolveUniqueFileName_(folder, desiredName) {
  if (!folder.getFilesByName(desiredName).hasNext()) return desiredName;
  var extMatch = desiredName.match(/^(.+?)(\.[^.]+)?$/);
  var base = extMatch ? extMatch[1] : desiredName;
  var ext = (extMatch && extMatch[2]) ? extMatch[2] : '';
  for (var n = 2; n < 1000; n++) {
    var candidate = base + '_' + n + ext;
    if (!folder.getFilesByName(candidate).hasNext()) return candidate;
  }
  // 1000 件以上の衝突はシステム異常 → 末尾に timestamp を付与して回避
  return base + '_' + new Date().getTime() + ext;
}

Step 2: 月別サブフォルダロジック

processed 直下ではなく processed/YYYY-MM/ を動的に取得・作成する。DriveAppgetFoldersByName()FolderIterator を返すためオブジェクトの truthy チェックでは空有無を判定できない。必ず .hasNext() で判定する。

/**
 * processed/YYYY-MM/ サブフォルダを取得(無ければ作成)
 * @param {GoogleAppsScript.Drive.Folder} processedFolder - processed 親フォルダ
 * @param {Object} extracted - OCR抽出結果
 * @param {Date} now - 処理日時
 * @returns {GoogleAppsScript.Drive.Folder}
 */
function getOrCreateMonthFolder_(processedFolder, extracted, now) {
  var ymd = Utils.parseDateToYmd(extracted.accrualDate || '')
         || Utils.parseDateToYmd(extracted.issueDate || '')
         || Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  var ym = ymd.substring(0, 7);  // 'YYYY-MM'
  var iter = processedFolder.getFoldersByName(ym);
  if (iter.hasNext()) return iter.next();          // ← hasNext() 必須
  return processedFolder.createFolder(ym);
}

Step 3: 既存 importReceiptPdfs() の呼び出し箇所の改修と 35_wrk_receipt 列更新

502_receipt_reader.js:148-150 周辺(file.moveTo(processedFolder) ブロック)を以下のように書き換える。

3-a. ファイル名インデックスの事前取得(ハードコード禁止)

HEADERS 配列の宣言(L63)の直下で、書き込み対象列のインデックスを動的に取得する。

var iFileName = HEADERS.indexOf('ファイル名');
var iDriveLink = HEADERS.indexOf('証跡リンク');
if (iFileName === -1 || iDriveLink === -1) {
  throw new Error('receipt タブの HEADERS にファイル名/証跡リンク列が定義されていません');
}

3-b. リネーム + 月別フォルダ移動 + 列更新

既存 file.moveTo(processedFolder);(L149)の行を以下に置換する。複数ページ PDF の場合は 1 枚目の extracted(既存の for ループ外で一度だけ計算)を代表値として使用する。1 ファイル 1 リネーム原則。

// --- 電帳法準拠リネーム (I-08) ---
// 代表 extracted を決定(複数ページの場合は合計金額・最新日付を採用)
var rep = buildRepresentativeExtracted_(extractedList);

// 既に新形式ならリネームスキップ
var newName = isAlreadyRenamed_(fileName)
  ? fileName
  : buildEvidenceFileName_(rep, fileName, new Date(now));

// 月別サブフォルダの取得/作成
var monthFolder = getOrCreateMonthFolder_(processedFolder, rep, new Date(now));

// 同月フォルダ内で重複しない名前へ
newName = resolveUniqueFileName_(monthFolder, newName);

// リネーム → 移動 (失敗時は元名のまま processed/YYYY-MM/ へ移動)
var finalName = fileName;
try {
  if (newName !== fileName) file.setName(newName);
  finalName = file.getName();
} catch (rnErr) {
  Utils.logInfo(FUNC, '⚠️ リネーム失敗 (' + fileName + '): ' + rnErr.message);
  finalName = fileName;
}
file.moveTo(monthFolder);

// Drive リンクは ID ベースなので不変だが、念のため再生成
var newDriveLink = 'https://drive.google.com/file/d/' + file.getId() + '/view';

// 35_wrk_receipt の該当行群(当該ファイル由来)のファイル名・証跡リンク列を更新
updateReceiptRowsForFile_(rcpSheet, HEADERS, iFileName, iDriveLink, fileName, extractedList.length, finalName, newDriveLink);

3-c. 代表 extracted 生成ヘルパー

1 ファイル複数ページの場合、日付は最新ページ、金額は全ページの合計を採用する(1 ファイル 1 リネーム原則)。

/**
 * 1ファイル1リネーム原則: 複数ページ extracted を集約
 * - vendor: 最初の非空値
 * - accrualDate / issueDate: 最大値(最新)
 * - totalAmount: 合計(数値化できるもののみ)
 * @param {Array<Object>} list
 * @returns {Object}
 */
function buildRepresentativeExtracted_(list) {
  var rep = { vendor: '', accrualDate: '', issueDate: '', totalAmount: 0 };
  if (!list || !list.length) return rep;
  var sum = 0;
  for (var i = 0; i < list.length; i++) {
    var e = list[i] || {};
    if (!rep.vendor && e.vendor) rep.vendor = e.vendor;
    if (e.accrualDate && e.accrualDate > rep.accrualDate) rep.accrualDate = e.accrualDate;
    if (e.issueDate && e.issueDate > rep.issueDate) rep.issueDate = e.issueDate;
    var n = Number(e.totalAmount);
    if (isFinite(n)) sum += n;
  }
  rep.totalAmount = sum;
  return rep;
}

3-d. 35_wrk_receipt 行更新ヘルパー

ハードコード禁止: 列インデックスは呼び出し元で HEADERS.indexOf() 済みの値を受け取る。B 列(管理ID)起点で最終行から遡り、直前に書き込んだ extractedList.length 件分の行を特定して更新する。

/**
 * 直前に書き込んだ receipt 行群のファイル名・証跡リンクを更新
 * @param {GoogleAppsScript.Spreadsheet.Sheet} rcpSheet
 * @param {Array<string>} HEADERS
 * @param {number} iFileName - 「ファイル名」列の0始まりインデックス
 * @param {number} iDriveLink - 「証跡リンク」列の0始まりインデックス
 * @param {string} origFileName - リネーム前のファイル名(既存行特定用)
 * @param {number} pageCount - 今回書き込んだページ数
 * @param {string} newFileName - 新ファイル名
 * @param {string} newDriveLink - 新 Drive リンク
 */
function updateReceiptRowsForFile_(rcpSheet, HEADERS, iFileName, iDriveLink, origFileName, pageCount, newFileName, newDriveLink) {
  if (pageCount <= 0) return;
  var lastRow = rcpSheet.getLastRow();
  var startRow = lastRow - pageCount + 1;
  if (startRow < 2) return;
  for (var r = startRow; r <= lastRow; r++) {
    // 複数ページなら ' (p1)' 等を付与して新ファイル名に反映
    var pageSuffix = (pageCount > 1) ? ' (p' + (r - startRow + 1) + ')' : '';
    var extMatch = newFileName.match(/^(.+?)(\.[^.]+)?$/);
    var withPage = extMatch ? extMatch[1] + pageSuffix + (extMatch[2] || '') : newFileName + pageSuffix;
    rcpSheet.getRange(r, iFileName + 1).setValue(withPage);
    rcpSheet.getRange(r, iDriveLink + 1).setValue(newDriveLink);
  }
}

3-e. 旧 processed 直下移動ロジックの削除

既存の L47-54processedFolder 取得/作成)は親フォルダ取得としてそのまま残すprocessed 自体は引き続き必要)。削除するのは file.moveTo(processedFolder); の直接呼び出しのみ。

影響範囲

変更対象変更内容変更量
500_import/502_receipt_reader.jsヘルパー関数 6 個追加 + importReceiptPdfs() の移動フェーズ改修~150行追加 / ~5行置換
  • 既存動作への影響: processed/ 直下へのフラット配置が processed/YYYY-MM/ へ変わる。既存の processed 配下ファイル(リネーム前)は放置(マイグレーションは本仕様の範囲外)
  • 列構造・DDL への影響なし: 35_wrk_receipt のヘッダー構成は変更なし(列値のみ書き換え)
  • postProcessReceiptData_() への影響なし: 取引先名・T番号・住所の補正ロジックとは独立
  • callGeminiForReceipt_() への影響なし: OCR 応答の解釈は不変

注意事項

  1. DriveApp.getFoldersByName() / getFilesByName() は FolderIterator / FileIterator を返す。返り値オブジェクトの truthy チェックで有無を判定してはならず、必ず .hasNext() を使うこと(失敗パターン #19 系の罠)
  2. ファイル名の拡張子分離は必ず先に実施。切り詰め→再結合の順を守らないと拡張子が消失する(本仕様 Step 1-a の buildEvidenceFileName_ 参照)
  3. 列インデックスのハードコード禁止。「ファイル名」「証跡リンク」の列位置は HEADERS.indexOf() で動的取得し、-1 の場合は即エラー(失敗パターン #18 系)
  4. リネームと moveTo の順序: リネーム成功後に移動。移動先フォルダ内で重複判定するため、resolveUniqueFileName_ は必ず移動先フォルダ(monthFolder)に対して呼ぶ
  5. リネーム失敗は処理を止めない。権限エラー等で setName が失敗しても moveTo は実施し、元ファイル名のまま processed/YYYY-MM/ へ配置する(try/catch で吸収・Utils.logInfo で記録)
  6. 冪等性の担保: isAlreadyRenamed_^\d{8}_ を厳密マッチ。「ファイル名に 8 桁数字が含まれる」だけで判定すると、元ファイル名先頭に偶然日付が入っているケースを誤スキップする
  7. 金額部の , 禁止: 電帳法の検索要件で金額の照合を容易にするため、3,500 ではなく 3500 と記録する。Math.round() 後に String() 変換で十分(toLocaleString 等は使わない)
  8. OCR 由来の改行・Drive 禁止文字の除去タイミング: sanitizeFileNamePart_ を「取引先略称部」と「元ファイル名部」の両方に適用する。プレフィックス結合後に適用すると _ 区切りまで壊れる
  9. Gemini 応答の totalAmount の型: 通常は数値だが稀に文字列・null・欠落あり。Number() でラップし isFinite() で判定してから Math.round() する
  10. file.getName() はリネーム反映後の新名を返すsetName 後は内部バッファが更新されているため、新名記録用に file.getName() の戻り値を使って差し支えない

エッジケース

条件ファイル名処理理由
accrualDate 欠落issueDate → 処理日時(now)の順でフォールバック電帳法はベスト努力で日付を持つ。処理日時 fallback は最終手段
totalAmount = 0 / null / NaN金額部を 0 で固定、ファイル名化は実行スキップすると電帳法の日付・取引先要件を満たさないため
vendor 欠落UNKNOWN で代替リネーム実行を止めないことを優先
MST_PART 未登録の vendorgenerateLogicalAbbr の fallback 結果を使用既存 normalizePartnerName の挙動(法人格除去+カッコ除去)
OCR 由来の改行(\n, \r)・タブ空白 1 文字に置換後 trimDrive ファイル名エラー防止
Drive 禁止文字(/ \ : * ? " < > |_ に置換Drive の物理制約
同名ファイル既存拡張子直前に _2, _3, ... を付与(getFilesByName().hasNext() ループ)重複回避・可読性
複数ページ PDF(extractedList.length > 1代表 extracted(合計金額+最新日付)で 1 ファイル 1 リネーム1 ファイル 1 リネーム原則
リネーム失敗(権限不足等)元ファイル名のまま processed/YYYY-MM/ へ移動、Utils.logInfo で記録処理継続を優先(エラーで全体停止を避ける)
既に新形式(/^\d{8}_/ に合致)リネームスキップ、そのまま processed/YYYY-MM/ へ移動冪等性・二重リネーム防止

実データ検証(事前確認項目)

確認項目確認方法理由
MST_PART に「略称」列が存在101_sys_config.js:645 の headers 配列を Read 済 ✅Utils.normalizePartnerName が依存
既存 processed/ フォルダ内のファイル件数GAS エディタで DriveApp.getFolderById(Env.receiptFolderId()).getFoldersByName('processed').next().getFiles() 件数を確認マイグレーション対象の規模感把握(本仕様では移行しないが、影響範囲の参考)
extracted.totalAmount の型既存 35_wrk_receipt の「税込金額_決済」列を 10 件ほど確認数値/文字列の混在有無。Number() フォールバックの必要性確認
getFilesByName の大文字小文字判定Drive は大文字小文字を区別する → 同名チェックはそのままで OK重複判定ロジックの前提

プロダクトポリシー

Human-in-the-Loop の取り扱い: 本案件では「OCR に成功した全行をリネーム対象(人手チェック不要)」とする。理由は以下の 2 点。

  1. ファイル名そのものには会計仕訳として確定する情報は含まれない(日付・取引先・金額の検索性のみ)
  2. 後段の Action B 消込時点で確認 FLG によるレビュー機構が動作しているため、誤抽出があってもここで検出される

ただし Utils.logInfo でリネーム失敗・スキップ(isAlreadyRenamed_)は全件ログに残し、後から追跡可能にする。

関連ドキュメント

仕様書関連箇所
dev_mas-154_partner_logical_abbr.mdUtils.normalizePartnerName() の実装。本案件で再利用
dev_mas-157_photo_ocr.md同一ファイル 502_receipt_reader.js を改修する仕様書(MIME 拡張・画像品質チェック)
spec/spec_receipt_import.md領収書取込フローの業務仕様
TODO_future.mdMAS-152 の案件定義・MAS-153 との依存関係
CLAUDE.mdコーディング規約(列参照はヘッダー名ベース・有効フラグ判定等)

人間が検討すべき事項

#項目詳細
1ファイル名最大長の設定値本仕様では 200 文字(Drive 上限 255 に対し 55 文字のマージン)。日本語を多く含む場合 UTF-8 で byte 数が増えるが、Drive 内部は Unicode 文字数で判定のため問題なし。運用で調整
2既存 processed/ 直下ファイルの扱い本仕様では移行しない。MAS-153 実装時に「ファイル名が新形式でない → 旧ファイル」と検出できるため、MAS-153 で一括リネーム&月別振り分けを行う前提
3金額 0 円のファイル名化可否本仕様では 0 で記録してリネーム続行。税務調査上「金額 0」は稀だが、OCR 失敗の指標として視認可能にするメリットあり。運用で見直し
4複数ページ PDF の代表値取り方本仕様では「金額=合計・日付=最新」。請求書(単一金額・単一日付)と領収書(複数枚バラ)の混在で意味が変わるが、1 ファイル 1 リネーム原則のもと妥協
5EVIDENCE_FOLDER_ID 等のプロパティ分離現状 Env.receiptFolderId()processed/YYYY-MM/ に入る。MAS-153 で会計士共有フォルダを別途指定する場合は別プロパティ化が必要
6リネーム失敗時のアラート強度本仕様では Utils.logInfo のみ。件数が多ければダイアログでサマリ表示を追加する選択肢あり

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

あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者です。
案件 MAS-152「証憑ファイル名の電帳法準拠リネーム」を実装してください。

## 実行前タスク
以下のファイルを読み込んでください:
1. `500_import/502_receipt_reader.js` — 既存の領収書OCR実装。特に以下を確認:
   - `importReceiptPdfs()` (L12-175): メイン関数
   - L47-54: `processed` サブフォルダ取得
   - L62-63: `HEADERS` 配列(「ファイル名」「証跡リンク」列の位置確認用)
   - L107: `driveLink` 生成
   - L119-144: receipt タブへの書き込みループ
   - L149: `file.moveTo(processedFolder)` — 本案件で差し替える中心箇所
2. `000_infra/004_utils.js` — 以下のヘルパーを確認(本案件で再利用):
   - `parseDateToYmd()` (L108): 日付文字列の正規化
   - `normalizePartnerName()` (L343): 取引先略称の取得
   - `generateLogicalAbbr()` (L319): 法人格除去ロジック
3. `100_config/101_sys_config.js:645` — `MST_PART` の「略称」列の存在確認
4. `docs/dev/dev_mas-152_evidence_filename_rename.md` — 本仕様書
5. `CLAUDE.md` — 列参照のヘッダー名ベース規約・有効フラグ判定等

## 修正対象ファイル
- `500_import/502_receipt_reader.js` — **既存ファイルの修正のみ**(新規ファイル作成なし)

## 実装内容

### A. 末尾に以下のプライベート関数 6 個を追加

1. `buildEvidenceFileName_(extracted, originalName, now)`:
   - フォーマット `YYYYMMDD_取引先略称_金額_元ファイル名.ext`
   - 日付: `accrualDate` → `issueDate` → `now` の順で fallback、`parseDateToYmd` でYYYY-MM-DD化してハイフン除去
   - 取引先: `Utils.normalizePartnerName()`、空なら `UNKNOWN`、`sanitizeFileNamePart_` を通す
   - 金額: `Number(extracted.totalAmount)` を `isFinite && >0` 判定、OKなら `Math.round`、そうでなければ `0`
   - 元ファイル名: 拡張子分離 → 本体切り詰め(プレフィックス+拡張子を引いた残余 budget) → 再結合
   - 最大全長 200 文字(Drive 上限 255 に対する安全マージン)

2. `sanitizeFileNamePart_(s)`:
   - `\r\n\t` → 空白 / Drive 禁止文字 `/\:*?"<>|` → `_` / 連続空白圧縮

3. `isAlreadyRenamed_(fileName)`:
   - `/^\d{8}_/.test(fileName)` で厳密判定

4. `resolveUniqueFileName_(folder, desiredName)`:
   - `folder.getFilesByName(name).hasNext()` が true の間、拡張子直前に `_2`, `_3`, ... を付与
   - 1000 回以上衝突したら timestamp を末尾に

5. `getOrCreateMonthFolder_(processedFolder, extracted, now)`:
   - `YYYY-MM` を `parseDateToYmd` から取得 → `processedFolder.getFoldersByName(ym).hasNext()` で既存確認 → 無ければ `createFolder(ym)`
   - **※必ず `.hasNext()` を使うこと(FolderIterator の罠)**

6. `buildRepresentativeExtracted_(list)`:
   - 複数ページ用: vendor は最初の非空、日付は最新(文字列比較で `>`)、金額は数値化できるものの合計

7. `updateReceiptRowsForFile_(rcpSheet, HEADERS, iFileName, iDriveLink, origFileName, pageCount, newFileName, newDriveLink)`:
   - 直前に書き込んだ `pageCount` 行分について、`rcpSheet.getLastRow()` から遡って行範囲を決定
   - 複数ページ時は拡張子直前に ` (p1)` 等を維持して書き戻す

### B. `importReceiptPdfs()` の改修

1. **`HEADERS` 宣言の直下**(L63 付近)に列インデックス取得を追加:
   ```js
   var iFileName = HEADERS.indexOf('ファイル名');
   var iDriveLink = HEADERS.indexOf('証跡リンク');
   if (iFileName === -1 || iDriveLink === -1) {
     throw new Error('receipt タブの HEADERS にファイル名/証跡リンク列が定義されていません');
   }
   ```

2. **L149 付近 `file.moveTo(processedFolder);` を差し替え**:
   - 代表 extracted 生成 → 新ファイル名組み立て → 月別フォルダ取得 → 重複回避リネーム → `setName` (try/catch)→ 月別フォルダへ `moveTo` → 35_wrk_receipt 行更新
   - **`processed` 直下への move は廃止**、全て `processed/YYYY-MM/` へ

3. **`processedFolder` 取得ロジック(L47-54)はそのまま残す** — 親フォルダとして引き続き必要

## 制約
- `postProcessReceiptData_()` は一切変更しない
- `callGeminiForReceipt_()` は一切変更しない
- `HEADERS` 配列の構造は変更しない
- 列インデックスは**必ず `HEADERS.indexOf()` 経由で取得**し、固定数値でハードコードしない
- `DriveApp` の `getFoldersByName` / `getFilesByName` の戻り値は必ず `.hasNext()` で判定
- 既に `/^\d{8}_/` にマッチするファイル名は**リネームスキップ**(冪等性)

## エッジケース
- `accrualDate` 欠落 → `issueDate` → `now`
- `totalAmount` が `0 / null / NaN / 文字列` → `Number()` でラップ、`isFinite && >0` で判定、それ以外は `0`
- `vendor` 欠落 → `UNKNOWN`
- OCR 由来の改行・Drive 禁止文字 → `sanitizeFileNamePart_` で除去
- 同名ファイル既存 → 拡張子直前に `_2`, `_3`, ...
- 複数ページ PDF → `buildRepresentativeExtracted_` で代表値(金額=合計・日付=最新)
- リネーム失敗 → 元ファイル名のまま `processed/YYYY-MM/` へ移動、`Utils.logInfo` で記録
- 既に新形式 → リネームスキップ

## 実データ検証
- MST_PART に「略称」列が存在することを `101_sys_config.js:645` で確認済
- `extracted.totalAmount` が数値で返ることを `callGeminiForReceipt_` のプロンプト(L187-191)で確認

## 動作確認
`npm run push:dev` 後:
1. Drive 領収書フォルダにテスト PDF(vendor/accrualDate/totalAmount が抽出できるもの)を配置
2. メニュー「📄 領収書PDFの読み込み (Drive)」を実行
3. **検証**: `processed/YYYY-MM/` サブフォルダが作成され、`YYYYMMDD_取引先略称_金額_元ファイル名.pdf` 形式でファイルが配置されている
4. **検証**: 35_wrk_receipt の該当行の「ファイル名」列が新名、「証跡リンク」列が新 URL に更新されている
5. **検証**: 同じファイルを再投入すると、重複回避で `_2` が付与される
6. **検証**: `accrualDate` が空の PDF で `issueDate` → `now` へのフォールバックが動作
7. **検証**: vendor 欠落時に `UNKNOWN` となる
8. **検証**: 既に `/^\d{8}_/` の形式のファイルを投入するとリネームスキップされる(ログ `Utils.logInfo` で確認)

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

| フェーズ | 拡張思考 | 備考 |
|---------|:--------:|------|
| ヘルパー関数の実装 | あり | 拡張子分離・切り詰め・重複回避ループの正確性確認 |
| `importReceiptPdfs()` の差し替え位置特定 | あり | L149 前後の変数スコープ(`fileName`, `extractedList`, `now`, `rcpSheet`, `HEADERS`)の把握 |
| サニタイズ・冪等性判定 | なし | 仕様書で完全定義済み |

推奨実行モデル

工程推奨モデル理由
仕様書作成(本ドキュメント)Claude Opus 4.6Drive API の罠(FolderIterator)、拡張子分離順序、ハードコード禁止等の複数観点を同時設計
実装Claude Sonnet 4.6ヘルパー関数 6 個の追加と importReceiptPdfs() 内 1 ブロックの差し替えで中程度の判断
動作確認ユーザー手動Drive フォルダへの PDF 配置・実行・結果確認が必要

変更履歴

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

仕様書作成プロンプト(再現性・監査性のため記録)

展開して表示
<instruction>
あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者兼仕様書ライターです。
CLIエージェントである「Claude Code」として、以下の指示とフェーズに従い、案件 I-08「証憑ファイル名の電帳法準拠リネーム」の開発仕様書を作成してください。
また、作成完了後は `docs/_config.json` の §E.6(パイプライン・RPA・外部連携)に情報を追記してください。

## Phase 1: 実行前タスク(必読・必ずツールを使用して順次実行すること)

### A. 案件定義の把握
1. `docs/_internal/TODO_future.md` で **I-08** の行を特定(L393付近)。
   - 「概要」「期待される効果」「人間が検討すべき事項」を全文読んでください。
   - 後続案件 I-09(リンク再構築)の前提となることに注意してください。

### B. プロジェクト規約の把握
2. `CLAUDE.md` を読み込み、コーディング規約・命名規則・テスト手順・GASファイル番号体系・ドキュメントサイト登録ルールを把握してください。

### C. 類似仕様書の参照(フォーマット踏襲)
3. `docs/dev/dev_mas-157_photo_ocr.md` を Read — 同じ `502_receipt_reader.js` を改修する仕様書(最も近いパターン)。
4. `docs/dev/dev_mas-154_partner_logical_abbr.md` を Read — I-08 が依拠する取引先略称機能の仕様書。

### D. 修正対象コードの確認(Grep だけで判断せず、必ず Read 等でファイル全体・周辺を読むこと)
5. `500_import/502_receipt_reader.js` 全体
   - 特に `importReceiptPdfs()`(L12付近)、`file.moveTo(processedFolder)`(L149付近)、ヘッダー定義(L62-63付近)、driveLink生成(L107付近)。※行番号は目安です。
6. `000_infra/004_utils.js` の以下関数を Read:
   - `normalizePartnerName()` (L343付近) — 取引先名→略称の変換
   - `generateLogicalAbbr()` (検索) — 略称生成ロジック
   - `parseDateToYmd()` — 日付正規化
7. `100_config/101_sys_config.js` で `MST_PART` ヘッダー定義(L477付近)を読み、「略称」列の存在を確認してください。
8. `docs/_internal/dev_spec_prompt_template.md` の「Phase 2: 仕様書の作成」セクション構成と「実装プロンプトのフォーマット」を読んでください。

### E. 失敗パターンの把握
9. `docs/_internal/failure_patterns.md` を Read — 過去の失敗事例(特に Drive 操作・ファイル名処理・ハードコード関連)。

## Phase 2: 仕様書の作成
収集した情報を元に仕様書を作成し、`docs/dev/dev_mas-152_evidence_filename_rename.md` に出力(保存)してください。

### 既存実装の前提知識(車輪の再発明を防ぐ)
- `Utils.normalizePartnerName(rawName)` で取引先名→略称変換が既に提供されています。再実装禁止。
- `extracted.accrualDate / extracted.issueDate / extracted.totalAmount` は既存 OCR 結果に存在。
- `processed/` サブフォルダへの移動は既に L48-54 付近で実装済み。本案件は **rename + 月別サブフォルダ作成とそこへの移動** に切り替えます。
- ファイル名と Drive リンクは 35_wrk_receipt の「ファイル名」「証跡リンク」列に既に書き込まれています。リネーム後は両列を更新する必要があります。
- 後続案件 I-09(リンク再構築)の前提となるため、ファイル名形式の規定は厳密に統一すること。

### 固有の設計要件(必ず仕様書に含める)

#### アーキテクチャ決定事項
- **リネーム実行タイミング**: 月別フォルダを作成/取得し、そこに `file.moveTo()` する **直前** に rename。失敗時はリネームせず元ファイル名のまま processed フォルダへ移動する fallback を持つこと。
- **月別サブフォルダ構造**: `processed/YYYY-MM/` を発生日(`accrualDate`)から自動取得・作成。`accrualDate` がない場合は `issueDate`、それもなければ `処理日時` の年月で fallback。
  - **※GASの罠への対応**: `DriveApp` の `getFoldersByName` 等はイテレータを返すため、オブジェクトの存在チェックだけでなく、必ず `.hasNext()` で有無を判定するロジックを仕様に明記すること。
- **ファイル名フォーマット(厳密規定)**: `YYYYMMDD_取引先略称_金額_元ファイル名.pdf`
  - YYYYMMDD: `accrualDate` を `parseDateToYmd` で正規化、ハイフン除去。
  - 取引先略称: `Utils.normalizePartnerName(extracted.vendor)` の結果。
  - 金額: `Math.round(extracted.totalAmount)` を `,` なしで(電帳法検索要件のため)。
  - 元ファイル名: **※重要※ 拡張子を一度分離してから** 長すぎる場合は切り詰め、最後に拡張子を再結合する設計にすること(拡張子消失バグの防止)。
  - 拡張子: 元のまま(.pdf / .jpg / .png 等)。
- **ファイル名長制限**: Drive は 255 文字制限。プレフィックス(日付_略称_金額_)の長さを考慮し、全体が 200 文字以下になるよう「元ファイル名」の長さを動的に切り詰めること。
- **重複ファイル名対応**: processed/YYYY-MM/ 内に同名ファイルが既存なら拡張子の直前に `_N` を付与(N=2,3,...)。※ `getFilesByName` での存在チェックループ方針に言及すること。
- **35_wrk_receipt 列更新**: rename 後の新ファイル名を「ファイル名」列、driveLink を「証跡リンク」列に書き戻す。
  - **※ハードコード禁止**: 列インデックスは固定数値(`[14]`など)で書かず、必ずヘッダー行配列から `indexOf` 等で動的に取得する設計にすること。

#### エッジケース(テーブルで網羅)
(省略 - 本文中テーブル参照。10ケース)

#### プロダクトポリシー
- **Human-in-the-Loop**: 本案件では「OCR成功した全行をリネーム対象(人手チェック不要)」とする(後段の Action B 消込時に確認FLG が機能するため)。

#### 実データ検証(Claude Codeが事前確認すべき項目)
- `MST_PART` シート(または config 定義)で「略称」列が存在するか。
- 既存の processed/ フォルダ内にファイルが何件あるか(マイグレーション対象の規模感の把握)。
- OCR で取得される `extracted.totalAmount` の型(数値 or 文字列)。

#### 動作確認(仕様書末尾に記載)
1. `npm run push:dev` で dev 反映
2. 領収書フォルダにテストPDF(vendor/date/amount が抽出できるもの)を配置
3. メニュー「📄 領収書PDF読み込み」実行
4. processed/YYYY-MM/ 配下に `YYYYMMDD_Amazon_3500_元ファイル名.pdf` 形式で配置されているか
5. 35_wrk_receipt の該当行のファイル名列・証跡リンク列が更新されているか
6. 同名ファイル再投入で `_2` 付きになるか
7. 日付欠落・金額欠落・vendor欠落の各ケースで挙動確認

### 出力品質の基準とフォーマット
- `docs/_internal/dev_spec_prompt_template.md` の「Phase 2: 仕様書の作成」セクション構成に**完全準拠**すること。
- 「現在のコード」セクションは `502_receipt_reader.js:149` 周辺のスニペットを引用。
- 「修正方針」は **Step 1(リネーム関数追加) / Step 2(月別フォルダロジック) / Step 3(35_wrk_receipt 列更新)** の3段階に分割。
- エッジケーステーブルは上記 10 ケースすべて含める。
- **実装プロンプトの出力形式**: バッククォート(```)で囲まず、全行を行頭4スペースインデントで出力すること。(※Markdownパーサーの都合上、前後に空行を入れること)
- **仕様書作成プロンプトの記録**: この指示テキスト全体(`<instruction>` から `</instruction>` まで)を一言一句変えずに、仕様書末尾の `<details><summary>展開して表示</summary>` の中に記録すること(v1.6 ルール)。
- 「変更履歴」テーブルに当日の日付で `初版作成` を記載。

## Phase 3: `_config.json` への追記と構文チェック(極めて重要)
仕様書の作成が完了したら、以下の手順で設定ファイルを更新してください。
1. `docs/_config.json` を読み込み、`§E.6` の項目に今回の仕様書へのリンクと簡単な説明を追記して保存。
2. **【重要】** 保存後、ターミナルで `node -e "require('./docs/_config.json')"` などを実行し、**絶対に JSON の構文エラー(カンマの抜け漏れや括弧の不整合など)が起きていないことを自己確認**すること。エラーがあれば修正すること。
</instruction>