概要

項目内容
案件IDMAS-132
カテゴリUX・可視性
PhaseP2
優先度★★
対象ファイル(新規)300_ui/303_row_grouping.js
対象ファイル(変更)000_infra/002_constants.jsMENU_DEFINITION へ 4 項目追加)
既存メニュー定義ローダ100_config/101_sys_config.js L323 onOpen()Constants.MENU_DEFINITION を動的にループ)
対象シート32_wrk_invoiceINV)/ 33_wrk_bankSTL
ユーザー操作サイドバーメニューから手動実行(初版はトリガー自動実行なし)

目的

32_wrk_invoice および 33_wrk_bank は時系列で月ごとに INV / STL が積み上がるため、数千行規模になると画面スクロール・目視確認のコストが急増する。Google Sheets のネイティブ行グルーピング機能(折りたたみ可能な行グループ)を使って、発生日/決済日_実績 の年月単位で行を自動的にグループ化し、過去月はデフォルトで折りたたむことで、視界を「当月+必要に応じて展開した過去月」に絞り込めるようにする。

MAS-130(フィルタービュープリセット配布・対策A)と MAS-131(完了行の条件付き書式・対策B)が数百〜千行規模向けのファーストライン対策であるのに対し、本案件(対策D)は数千行〜規模で真価を発揮する中期対策として位置付ける。

ユーザーが Sheets の UI で手動グループを張る作業を排し、メニュー 1 つで月境界を自動検出→グループ化→過去月折りたたみまでを完結させる。

現在のコード

対象機能は現時点で未実装(新規追加案件)。現状のユーザー体験は以下のとおり。

  • 32_wrk_invoice / 33_wrk_bank は時系列で INV / STL が追記され続け、同一年月の行が連続する構造になっている
  • 行数が数千件に達するとスクロール量が膨大になり、「当月の未処理分だけを確認したい」というユースケースで視認性が大きく損なわれる
  • Sheets の行グループ機能は手動でも設定可能だが、毎月新規行が追加されるたびにユーザーが手作業で張り替えるのは非現実的

修正方針

アーキテクチャ決定事項

① 新規ファイル 300_ui/303_row_grouping.js の新設

300_ui/ 配下には現在 301_ui_assist.js のみ存在する。302_*.js は未使用。CLAUDE.md の採番規約は「十・一の位はディレクトリ内の順序」であるが、MAS-130(フィルタープリセット・TODO_future で 302_filter_presets.js を予定)が本案件の姉妹案件として先に番号確保されているため、本案件は 303_row_grouping.js として新設する。

同ファイルに以下 2 つの公開関数を実装する。

  • buildMonthlyGroups(target)target'INV' または 'STL'。対象シートを日付順にソート→既存グループを解除→年月境界でグループ化→過去月を折りたたむ、を一連で実施
  • clearMonthlyGroups(target) — 対象シートの深さ 1 行グループを全解除する

② メニュー追加(000_infra/002_constants.jsMENU_DEFINITION

onOpen()100_config/101_sys_config.js L323 に存在するが、メニュー項目の実体は 000_infra/002_constants.js L206 の MENU_DEFINITION 配列で宣言的に定義されており、onOpen() はこれを動的にループしてメニューを生成する(forEachui.createMenuaddItem)。つまり本案件でメニューを追加する正しい編集先は 002_constants.jsMENU_DEFINITION である(101_sys_config.js は編集不要)。

MENU_DEFINITION の既存カテゴリに「表示設定」や「表示」に該当するものは存在しない(最も近いのは「📋 サイドバー: ⚙️ メンテナンス」だが、グループ操作は破壊的ではない UI 整形のため用途が異なる)。新規カテゴリ「📋 サイドバー: 📂 表示設定」を追加し、以下 4 項目を登録する。既存の source: 'sidebar' パターンに合わせる。

  • 「📂 月次グループ再構築(請求)」 → funcName: 'buildMonthlyGroupsInv'
  • 「📂 月次グループ再構築(銀行)」 → funcName: 'buildMonthlyGroupsStl'
  • 「🗑️ 月次グループ全解除(請求)」 → funcName: 'clearMonthlyGroupsInv'
  • 「🗑️ 月次グループ全解除(銀行)」 → funcName: 'clearMonthlyGroupsStl'

MENU_DEFINITIONfuncName は文字列で解釈されるため実引数を取らないラッパー関数が必要303_row_grouping.js 内に buildMonthlyGroupsInv() / buildMonthlyGroupsStl() / clearMonthlyGroupsInv() / clearMonthlyGroupsStl() の 4 本のラッパーを定義し、それぞれ buildMonthlyGroups('INV') / buildMonthlyGroups('STL') / clearMonthlyGroups('INV') / clearMonthlyGroups('STL') に委譲する。

③ 排他制御(LockService

  • var lock = LockService.getScriptLock(); でスクリプトロックを取得
  • lock.waitLock(0)try-catch で囲む(即時タイムアウト。取得失敗時は例外をスロー)
  • catch 内で Utils.toastResult('月次グループ再構築', '別の処理が実行中です。しばらく後に再試行してください。', 5) を呼んで return
  • lock.releaseLock()finally ブロックで必ず実行

④ 事前確認ダイアログ(Human-in-the-Loop)

ソート処理は破壊的変更(行順序の書き換え)であるため、実行前にユーザーの明示的な同意を取る。

var resp = SpreadsheetApp.getUi().alert(
  '月次グループ再構築',
  'この操作はシートを日付順に並び替えます。よろしいですか?',
  SpreadsheetApp.getUi().ButtonSet.OK_CANCEL
);
if (resp !== SpreadsheetApp.getUi().Button.OK) {
  lock.releaseLock();
  return;
}

clearMonthlyGroups 側はソートを伴わない解除のみなので事前確認ダイアログは不要(ただし完了トーストは必須)。

⑤ シート取得(Utils.getSheetByKey

Phase 1 で確認した InvoiceRepository._getSheet() / BankTxRepository._getSheet() と同じキーを使う。

  • INV: Utils.getSheetByKey('WRK_INVC', '32_wrk_invoice')
  • STL: Utils.getSheetByKey('WRK_BANK', '33_wrk_bank')

⑥ 最終行取得(Utils.getTrueLastRow

CLAUDE.md 規約「シートへの書き込み位置は列B (ID列) で最終行を判定」に従い、B 列(1-based 2)で判定する。Utils.getTrueLastRow(sheet, 2) を使用する(colIndex は 1 始まり)。戻り値が 1 の場合はデータなしとして Utils.toastResult('月次グループ再構築', '対象データがありません', 5) で通知し、ロック解放後に return する。

⑦ ソート処理(Range.sort() のみ使用。Sheet.sort() は使用禁止)

Sheet.sort() はヘッダー行(1 行目)を巻き込んでソートする可能性があるため使用禁止。データ行のみを対象に Range.sort({column, ascending}) を使う。

var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
  .map(function(h) { return String(h).trim(); });
var dateColName = (target === 'INV') ? '発生日(P/L計上日)' : '決済日_実績';
var dateColIndex = headers.indexOf(dateColName) + 1; // 1-based
if (dateColIndex === 0) {
  // ヘッダー不在 → エラー通知
}
sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn())
  .sort({column: dateColIndex, ascending: true});

列インデックスは必ずヘッダー名から動的取得する(CLAUDE.md 規約「列参照はヘッダー名ベース」)。

⑧ 既存グループのクリア(shiftRowGroupDepth(-1)

本機能は深さ 1 のグループのみを扱う設計のため、既存の深さ 1 グループを全解除してからグループ化に入る(多階層グループの混在を防ぐ)。

sheet.getRange(2, 1, lastRow - 1, 1).shiftRowGroupDepth(-1);

⑨ グループ化ループ

データ行を 2 行目から走査し、Utils.parseDateToYm(row[dateColIdx])dateColIdx は 0 始まり)で各行の "YYYY-MM" を取得する。年月が変化する境界を検出したら、直前の年月に属する連続行範囲に対して shiftRowGroupDepth(1) を呼んでグループを張る。

擬似コード:

var data = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).getValues();
var dateColIdx = dateColIndex - 1; // 0-based
var boundaries = []; // [{ startRow, length, ym }, ...]
var currentYm = '';
var groupStart = 2; // 絶対行番号(sheet 上の row index)
for (var i = 0; i < data.length; i++) {
  var ym = Utils.parseDateToYm(data[i][dateColIdx]);
  if (!ym) continue; // 空欄・不正書式は境界判定から除外(直前の年月に属する扱い)
  if (currentYm && ym !== currentYm) {
    // 境界検出
    boundaries.push({
      startRow: groupStart,
      length: (2 + i) - groupStart, // 新年月の直前行までの長さ
      ym: currentYm
    });
    groupStart = 2 + i;
  }
  currentYm = ym;
}
// 最後のグループを忘れずクロージング
if (currentYm) {
  boundaries.push({
    startRow: groupStart,
    length: lastRow - groupStart + 1,
    ym: currentYm
  });
}

boundaries.forEach(function(b) {
  if (b.length >= 1) {
    sheet.getRange(b.startRow, 1, b.length, 1).shiftRowGroupDepth(1);
  }
});

⑩ 過去月の折りたたみ

Utils.parseDateToYm(new Date()) で当月の "YYYY-MM" を取得し、グループ年月がそれより古いグループの開始行に対して sheet.getRowGroup(startRow, 1) を呼ぶ。戻り値が null でない場合のみ .collapse() を実行する(null チェック必須)。

var nowYm = Utils.parseDateToYm(new Date());
boundaries.forEach(function(b) {
  if (b.ym < nowYm) {
    var g = sheet.getRowGroup(b.startRow, 1);
    if (g) g.collapse();
  }
});

⑪ 完了通知(Utils.toastResult

  • 成功: Utils.toastResult('月次グループ再構築', 'X月分のグループを作成しました', 5)
  • 解除: Utils.toastResult('月次グループ全解除', '完了しました', 5)

影響範囲

ファイル変更種別規模
300_ui/303_row_grouping.js新規作成約 150 行(公開 API 2 本 + 内部ラッパー 4 本 + 定数定義)
000_infra/002_constants.js編集(MENU_DEFINITION に 1 カテゴリ追加、4 項目登録)約 10 行

RPA仕訳・集計・レポートロジックへの影響は一切ない。シート表示(行グルーピング)のみを操作するため、データ内容・ヘッダー構造・DDL スキーマに変更はない。

強いて挙げれば、ソートは行順序を書き換えるため物理的にデータの並びが変わる。ただしヘッダー名ベースのアクセスを徹底している本プロダクトでは、行順序はどのモジュールのビジネスロジックにも影響しないOrderRepository.save()writeDtosToSheet_ も DTO 配列の順序でシートに再書き込みするため、後続処理はシート上の既存順序に依存しない)。

注意事項

  1. Range.sort({column, ascending}) を使うことSheet.sort() はヘッダー行を含む全行をソートするため使用禁止
  2. グループ深さは 1 のみ作成する(多階層グループは作らない)。クリア時は shiftRowGroupDepth(-1) で完結する
  3. sheet.getRowGroup(rowIndex, depth) はグループが存在しない行に対して null を返すcollapse() 呼び出し前に null チェック必須
  4. LockService.waitLock(0) はロック取得に失敗すると例外をスローするtry-catch で必ず捕捉し、lock.releaseLock()finally 内で実行すること
  5. Utils.parseDateToYm()"" を返した行(日付空欄・不正書式)はグループ境界の判定から除外し、直前の年月に属する行として扱う
  6. ソート列インデックスはヘッダー名で動的取得すること(列番号ハードコード禁止。CLAUDE.md 規約)
  7. メニュー項目の funcName は実引数を取れないbuildMonthlyGroups('INV') を直接 funcName にはできないため、buildMonthlyGroupsInv() 等のラッパー関数を 4 本用意する
  8. shiftRowGroupDepth(1)連続した行範囲に対してのみグループを作成する。境界検出ループは必ず「年月が変わった瞬間に直前までの範囲を確定させる」形で実装すること
  9. 最終グループのクロージングを忘れない。ループ終端到達後、最後の年月の連続行範囲もグループ化する処理を必ず呼ぶ
  10. 事前確認ダイアログで CANCEL された場合は finally でロック解放後に return する(リーク防止)

エッジケース

#条件動作理由
1データが未ソートの状態で実行処理冒頭の Range.sort({column, ascending: true}) で強制的に昇順ソートするため、境界検出の前提条件が担保されるグループ境界検出の前提としてソートが必須。未ソートのまま検出するとグループ数が爆発する
2日付列(発生日(P/L計上日) / 決済日_実績)が空欄の行Utils.parseDateToYm()"" を返す → その行は境界判定から除外し、直前の年月グループに属する扱いで処理を継続する1 行の空欄で全体処理を止めない。実データ上、起票途中で日付未入力の行が混ざる可能性がある
3日付列が不正書式("TBD" / "未定" / 数値型でない任意文字列)同上(parseDateToYm"" を返す → 直前グループに属する扱い)パース不可値で処理停止しない
4ヘッダー行のみでデータ行が 1 行もないUtils.getTrueLastRow(sheet, 2) が 1 を返す → データ行数 = 0 と判定し、Utils.toastResult('月次グループ再構築', '対象データがありません', 5) で通知して安全終了(ロック解放済)空配列への shiftRowGroupDepth 呼び出しで Range 構築が失敗するのを回避
5全データが同一年月(例: 当月 INV のみ 10 件)グループが 1 つ作成される。b.ym < nowYm が false のため折りたたみはスキップ正常終了
6全データが過去月(当月 INV が 0 件)全グループが b.ym < nowYm を満たし折りたたまれる。ユーザーが任意に展開する運用正常終了
7別ユーザーが同一シートに対して同時実行LockService.waitLock(0) が即時例外 → catch で Utils.toastResult('月次グループ再構築', '別の処理が実行中です。しばらく後に再試行してください。', 5) を発行し return二重実行による行順序の破壊・グループ操作の競合を防止
8sheet.getRowGroup(startRow, 1)null を返す(作成直後の API 遅延等)if (g) チェックにより .collapse() をスキップ。折りたたみは次回再実行で回復可能null 参照エラー防止
9事前確認ダイアログで CANCEL 押下finally でロックを解放し、シートには一切変更を加えずに returnHuman-in-the-Loop の担保。破壊的変更の取り消し可能性を保証
10ソート列ヘッダー(「発生日(P/L計上日)」/「決済日_実績」)がシートに存在しないheaders.indexOf(name)-1 を返し dateColIndex === 0 になる → Utils.toastResult('月次グループ再構築', 'ソート列が見つかりません: ' + dateColName, 5) で通知して中断DDL 変更・手動リネーム時のセーフティネット
11対象シート自体が存在しない(getSheetByKey が null 返却)シート null チェックで Utils.toastResult('月次グループ再構築', '対象シートが見つかりません', 5) を発行して中断別環境(dev/prod 切替後など)での安全動作
12既存グループが深さ 2 以上で張られている場合初期化処理 shiftRowGroupDepth(-1) は深さ 1 ずつ解除するため、深さ 2 グループは深さ 1 に降格するのみ。以降の新規グループ作成は深さ 1 で上書きされる本機能は深さ 1 のみを管理する設計のため、手動で深さ 2 以上を張られた場合は完全に元へ戻らない可能性がある。人間が検討すべき事項に挙げる
13同じ年月の行が数千行連続(極端な例)shiftRowGroupDepth(1) を 1 回呼ぶだけで完結。API 呼び出し回数は「グループ数」に比例し、行数には比例しない大量データへの耐性あり。ただし Range.sort() 自体の所要時間は行数に線形
14数万行規模での実行GAS の 6 分実行制限に抵触する可能性あり(ソート + 境界検出 + グループ API コール合計)。目安として 1 万行以下なら数十秒以内、3 万行を超えると実行時間超過のリスク人間が検討すべき事項に「将来 MAS-133(年度跨ぎアーカイブ)が実装されれば現行タブの行数は数千に収まる」の前提で初版設計している旨を記載

実データ検証

本機能はシート表示変更のみでマスタデータの読み書きを伴わないが、実装前に以下の対応を必ず確認する。

確認項目正解確認方法
32_wrk_invoice の B 列ヘッダー請求ID(INV)000_infra/003_contracts.jsInvoiceDTO 型定義 L40〜L41
33_wrk_bank の B 列ヘッダー決済ID(STL)000_infra/003_contracts.jsBankTxDTO 型定義 L71〜L73
32_wrk_invoice のソート対象列発生日(P/L計上日)InvoiceDTO L47
33_wrk_bank のソート対象列決済日_実績BankTxDTO L77
Utils.getSheetByKey のシステムキー(INV)'WRK_INVC'200_data/202_repository.js InvoiceRepository._getSheet() L155〜156
Utils.getSheetByKey のシステムキー(STL)'WRK_BANK'200_data/202_repository.js BankTxRepository._getSheet() L217〜218
onOpen の所在100_config/101_sys_config.js:323grep -rn "^function onOpen\\(" 100_config/ 300_ui/
メニュー定義の実体000_infra/002_constants.js MENU_DEFINITION L206〜同上 grep + 002_constants.js Read
Utils.parseDateToYm の戻り値規約"YYYY-MM" 形式 / パース不可なら ""000_infra/004_utils.js L354〜L361
Utils.getTrueLastRow(sheet, colIndex)colIndex1 始まり。データなしなら 1 を返す000_infra/004_utils.js L320〜L329
Utils.toastResult(funcName, message, duration)第 1 引数はトーストのタイトル000_infra/004_utils.js L520〜L523

関連ドキュメント

人間が検討すべき事項

TODO_future.md MAS-132 に記載された以下 3 点を初版スコープで対応した上で、追加の論点を整理する。

① TODO_future.md MAS-132 既出の論点

  1. 時系列順にソートされていないデータがある場合のグループ境界判定
    • 対応方針: 処理冒頭で Range.sort({column, ascending: true}) を必ず実行する設計(エッジケース #1)。未ソートでも結果は同一
  2. グループ境界の再計算コスト(数万行で数十秒かかる可能性)
    • 対応方針: 初版は手動実行のみ(自動トリガーなし)。数千行までは数秒で完了することを想定。数万行に至る場合は MAS-133(年度跨ぎアーカイブ)の完了後に再評価
  3. 行追加時のグループ範囲自動拡張(GAS API の制約)
    • 対応方針: GAS API では行追加時のグループ自動拡張はサポートされない。ユーザーが新規 INV / STL を追加した翌日以降、再度「月次グループ再構築」を実行する運用で吸収(週次または月次締めの手順書に追記することをオーナー決定事項とする)

② 追加の論点

  1. 「📋 サイドバー: 📂 表示設定」という新カテゴリを新設するかの判断
    • 既存カテゴリ(例: 「📋 サイドバー: ⚙️ メンテナンス」)に統合する案もある。本仕様書では新カテゴリ新設を推奨するが、将来 MAS-130(フィルタープリセット)や MAS-131 の「🎨 書式を再適用」メニューを同カテゴリに集約する運用を見据えてオーナーが決定する必要がある
  2. ソートによる行順序変更の周知
    • 本機能は破壊的変更(行順書き換え)を伴うため、事前確認ダイアログで同意を取る設計にしている。それでも「既存の並び(手動で並び替えた順)を維持したい」ユーザーがいる可能性があるため、運用ドキュメントへの明記が必要
  3. 自動再構築(マート更新後・月次締め完了後)をどこまで進めるか
    • TODO_future.md の「②自動再構築」は本案件スコープから除外(初版は手動のみ)。将来 600_report/ 系のマート更新完了フックに追加するか、月次締め完了メニューに組み込むかはオーナーが決定
  4. 深さ 2 以上の既存グループへの対応
    • 本機能は深さ 1 のみ管理する。手動で深さ 2 以上のグループを張っているユーザーがいる場合は仕様上の制約として割り切る(エッジケース #12)。必要であれば shiftRowGroupDepth(-N) を N=最大深さ分ループする実装に変更可能
  5. 折りたたみ解除時のユーザー操作の残存
    • collapse() は folded 状態にするだけで、ユーザーが Sheets UI の + アイコンで展開した状態は次回再実行まで保持される(再実行すると既存グループを一度解除するため、展開状態はリセットされる)。再実行時に展開状態を覚えておいて復元するか、毎回フルリセットで良いかはオーナー決定
  6. 32_wrk_invoice / 33_wrk_bank 以外の時系列タブへの展開
    • 例: 31_wrk_order(発注タブ・発注日ベース)、35_wrk_receipt(領収書読取結果)、42_trn_journal(仕訳)等。初版は INV/STL 2 タブに限定。拡張時は buildMonthlyGroupstarget'ORD' / 'RCPT' / 'JNL' を追加する設計余地あり
  7. 実行後のアンドゥ可否
    • Sheets の Ctrl+Z は GAS 実行後は効かない(ソートを含む一連の変更は 1 トランザクションとして履歴に残るが、複数操作なので完全なロールバックは保証されない)。復旧手段として clearMonthlyGroups で深さ 1 グループの解除のみ提供。ソート結果自体の巻き戻しは提供しない

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

あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者です。
案件 MAS-132「月次行グルーピングの自動生成(行積み上がり対策D)」を実装してください。

## 実行前タスク(推測で書かない。必ず Read で裏取りする)
1. `grep -rn "onOpen" 100_config/ 300_ui/` を実行し、onOpen() の所在を確認する
   (想定: 100_config/101_sys_config.js:323)
2. `000_infra/002_constants.js` の MENU_DEFINITION(L206〜)を Read し、既存カテゴリの
   構造(`source: 'sidebar'` の扱い、`addItem` のラベル・funcName 記述法)を確認する
3. `000_infra/004_utils.js` を Read し、以下 4 関数のシグネチャを確認する
   - Utils.getSheetByKey(key, fallbackName) — 引数順: key が第1引数
   - Utils.getTrueLastRow(sheet, colIndex) — colIndex は 1 始まり、データなしは 1 を返す
   - Utils.parseDateToYm(val) — "YYYY-MM" 形式を返す。パース不可なら ""
   - Utils.toastResult(funcName, message, duration) — 第1引数はタイトル
4. `200_data/202_repository.js` を Read し、以下のシステムキーを確認する
   - InvoiceRepository._getSheet(): Utils.getSheetByKey('WRK_INVC', '32_wrk_invoice')
   - BankTxRepository._getSheet(): Utils.getSheetByKey('WRK_BANK', '33_wrk_bank')
5. `000_infra/003_contracts.js` を Read し、以下のヘッダー名を確認する
   - InvoiceDTO: 請求ID(INV) / 発生日(P/L計上日)
   - BankTxDTO: 決済ID(STL) / 決済日_実績
6. `ls 300_ui/` を実行し、新規ファイル番号を確定する(現状 301_ui_assist.js のみ。
   302 は MAS-130(フィルタープリセット)で予約済みのため **303_row_grouping.js** を採用)

## 修正対象ファイル
- `300_ui/303_row_grouping.js`(新規作成・約 150 行)
- `000_infra/002_constants.js`(MENU_DEFINITION に 1 カテゴリ追加・4 項目登録)

## 実装内容

### 300_ui/303_row_grouping.js(新規作成)

以下の関数を実装する。

**公開 API(メニュー呼び出し用ラッパー・引数なし):**
- `buildMonthlyGroupsInv()` → `buildMonthlyGroups('INV')` に委譲
- `buildMonthlyGroupsStl()` → `buildMonthlyGroups('STL')` に委譲
- `clearMonthlyGroupsInv()` → `clearMonthlyGroups('INV')` に委譲
- `clearMonthlyGroupsStl()` → `clearMonthlyGroups('STL')` に委譲

**コア関数 `buildMonthlyGroups(target)`:**
1. `var FUNC = 'buildMonthlyGroups';` と `var TITLE = '月次グループ再構築';` を定義
2. LockService.getScriptLock() を取得し、try { lock.waitLock(0) } で即時取得を試みる
3. waitLock 失敗時は catch 内で Utils.toastResult(TITLE, '別の処理が実行中です。しばらく後に再試行してください。', 5) して return
4. try ブロック内で以下を実行
   - SpreadsheetApp.getUi().alert(TITLE, 'この操作はシートを日付順に並び替えます。よろしいですか?', ButtonSet.OK_CANCEL)
     で事前確認。CANCEL なら return(finally で releaseLock)
   - target === 'INV' なら Utils.getSheetByKey('WRK_INVC', '32_wrk_invoice')
     target === 'STL' なら Utils.getSheetByKey('WRK_BANK', '33_wrk_bank')
     どちらにも該当しない or sheet が null なら Utils.toastResult で通知して return
   - var lastRow = Utils.getTrueLastRow(sheet, 2);
     lastRow === 1 なら「対象データがありません」を toastResult して return
   - var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
       .map(function(h) { return String(h).trim(); });
   - var dateColName = (target === 'INV') ? '発生日(P/L計上日)' : '決済日_実績';
   - var dateColIndex = headers.indexOf(dateColName) + 1;
   - dateColIndex === 0(indexOf -1)なら「ソート列が見つかりません」を toastResult して return
   - sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn())
       .sort({column: dateColIndex, ascending: true}); でデータ行のみソート
   - sheet.getRange(2, 1, lastRow - 1, 1).shiftRowGroupDepth(-1); で既存グループ全解除
   - var data = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).getValues();
     var dateColIdx = dateColIndex - 1; // 0-based
     で再取得(ソート後の状態)
   - 境界検出ループ(詳細は仕様書本文「⑨ グループ化ループ」参照)で
     boundaries = [{ startRow, length, ym }, ...] を構築
   - boundaries.forEach で各 { startRow, length } に対して
     sheet.getRange(startRow, 1, length, 1).shiftRowGroupDepth(1); を呼ぶ
     (length >= 1 のガード付き)
   - var nowYm = Utils.parseDateToYm(new Date());
     boundaries.forEach で b.ym < nowYm を満たすグループに対し
     var g = sheet.getRowGroup(b.startRow, 1); if (g) g.collapse();
   - Utils.toastResult(TITLE, boundaries.length + '月分のグループを作成しました', 5);
5. finally ブロックで lock.releaseLock() を実行(try の直後に return したケースでも必ず解放)
6. catch (e) で Utils.logError(FUNC, e) して Utils.toastResult(TITLE, 'エラー: ' + e.message, 5)

**コア関数 `clearMonthlyGroups(target)`:**
1. target に応じてシート取得(buildMonthlyGroups と同じキー)
2. Utils.getTrueLastRow(sheet, 2) でデータなしチェック
3. sheet.getRange(2, 1, lastRow - 1, 1).shiftRowGroupDepth(-1) で全グループ解除
4. Utils.toastResult('月次グループ全解除', '完了しました', 5)
5. (ロック取得は省略可。解除のみで破壊的変更ではないため)

### 000_infra/002_constants.js への追記

MENU_DEFINITION 配列(L206〜L324)の末尾(「📋 サイドバー: 🧪 テスト」カテゴリの後)に、
以下の新カテゴリを追加する:

{
  category: '📋 サイドバー: 📂 表示設定',
  source: 'sidebar',
  items: [
    { label: '📂 月次グループ再構築(請求)', funcName: 'buildMonthlyGroupsInv',
      description: '32_wrk_invoice の発生日(P/L計上日)で月単位に行グループ化(過去月は折りたたみ)' },
    { label: '📂 月次グループ再構築(銀行)', funcName: 'buildMonthlyGroupsStl',
      description: '33_wrk_bank の決済日_実績で月単位に行グループ化(過去月は折りたたみ)' },
    { label: '🗑️ 月次グループ全解除(請求)', funcName: 'clearMonthlyGroupsInv',
      description: '32_wrk_invoice の月次行グループを全解除' },
    { label: '🗑️ 月次グループ全解除(銀行)', funcName: 'clearMonthlyGroupsStl',
      description: '33_wrk_bank の月次行グループを全解除' },
  ]
},

## 制約(絶対遵守)

- `Sheet.sort()` は使用禁止。必ず `Range.sort({column, ascending})` を使う
- グループ深さは 1 のみ作成する。多階層グループ作成禁止
- `sheet.getRowGroup(row, depth)` の戻り値は必ず `if (g)` で null チェックしてから `.collapse()` を呼ぶ
- 列インデックスのハードコード禁止。`headers.indexOf(headerName) + 1` で動的に取得する(CLAUDE.md 規約)
- 既存の RPA・仕訳・集計・レポートロジックファイルは一切変更禁止
- `lock.releaseLock()` は `finally` ブロック内で必ず実行する
- `onOpen()` 本体(101_sys_config.js)は編集しない。メニュー追加は 002_constants.js の MENU_DEFINITION のみ
- ラッパー関数(`buildMonthlyGroupsInv` 等)は **引数を取らない**(MENU_DEFINITION の funcName 制約)

## エッジケース

| 条件 | 動作 |
|------|------|
| 日付列が空欄または不正書式 | `parseDateToYm` が "" を返す → 直前の年月グループに属する扱いで処理継続 |
| データ行なし(ヘッダーのみ) | `getTrueLastRow` が 1 を返す → トースト通知して処理スキップ |
| 全データが同一年月 | グループ 1 つ作成。当月なら折りたたみなし |
| 同時実行 | `waitLock(0)` 例外 → catch でトースト通知して中断 |
| `getRowGroup` が null | `if (g)` チェックにより `collapse()` をスキップ |
| ソート列ヘッダー不在 | `indexOf === -1` → トースト通知して中断 |
| シート自体が取得不可 | sheet null チェック → トースト通知して中断 |
| 事前確認で CANCEL | `finally` でロック解放後 return(シート変更なし) |

## 動作確認

1. `npm run push:dev` でデプロイする
2. 開発用スプレッドシートを開き、「📋 サイドバー: 📂 表示設定」カテゴリに 4 項目が
   表示されることを確認する
3. `32_wrk_invoice` に複数月(当月 + 過去月 2-3 ヶ月分)の INV がある状態で
   「📂 月次グループ再構築(請求)」を実行する
4. 事前確認ダイアログが表示されることを確認する
5. OK を選択し、(a) データが発生日昇順にソートされる (b) 月ごとに行グループが作成される
   (c) 過去月のグループが折りたたまれる (d) 当月グループは展開状態のまま
   (e) 「X月分のグループを作成しました」トーストが出る、をすべて確認
6. 「🗑️ 月次グループ全解除(請求)」を実行し、グループが全解除されることを確認する
   (行順はソート済みのまま戻らない点も確認)
7. `33_wrk_bank` で同様の確認を実施する(ソート列は「決済日_実績」)
8. データなし状態(ヘッダーのみのシート)でのトースト通知・安全スキップを確認
9. 別ブラウザ / 別タブで同時実行を試み、排他制御(トースト通知で中断)が効くことを確認
10. git commit → git push → PR を作成する

推奨実行モデル

工程推奨モデル理由
実装(新規ファイル 303_row_grouping.js 作成 + 002_constants.js MENU_DEFINITION 追記)Claude Sonnet仕様書でコードはほぼ完全に定義されているが、MENU_DEFINITION 末尾への挿入位置特定と既存パターンへの追従(source: 'sidebar' / label / description の書式)に中程度の判断が必要
動作確認・PR 作成Claude SonnetGAS デプロイとブラウザでの UI 確認を含む。複数手順の実行判断が必要

変更履歴

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

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

展開して表示
<instruction>
【タイムアウト回避・実行原則(v1.7・必ず遵守すること)】
1. **拡張思考の使い分け**: Phase 1(設計)では拡張思考をフル活用し、ファイル名・エッジケース一覧・Step分割粒度・固有名詞(関数名/シートキー名/列名/行番号)を完全に確定させる。Phase 2(清書)の各Step内では拡張思考を最小限に抑え、Phase 1で確定済みの内容の書き下しに徹する。出力途中で再考しない。
2. **テキスト報告の禁止**: 「〜を作成します」等のtextのみでtool_useなしにturnを終了しない。説明は1文以内。直ちにtoolを呼ぶ。
3. **4-5分割のWrite/Edit実行**: Step 2-1(骨格)/2-2(前半)/2-3a(エッジケース〜人間検討事項)/2-3b(実装プロンプト〜変更履歴)/2-4(`<details>`プロンプト記録)の5Stepに分割して実行する。1回のWrite/Editは約300行以内を目安にする。
4. **各Stepで何を書くかを具体指示**: 各Step内で設計判断を再考しない。Phase 1で確定した内容をそのまま書き出す。

======================================================================
あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者兼仕様書ライターです。
CLIエージェント「Claude Code」として、案件 S-60「月次行グルーピングの自動生成(行積み上がり対策D)」の開発仕様書を作成してください。
開発仕様書を新規作成した場合は、`docs/_config.json` の `nav` 配列の適切なセクションにも必ず追記してください。

---

## Phase 1: 実行前タスク(テキスト報告禁止。即座にツール実行)

以下をすべてReadまたはGrepで調査し、Phase 2で設計判断を再考しないよう、固有名詞・構造を完全に確定させてから仕様書作成に着手すること。「名前から推測」した瞬間に手を止めてReadする(失敗パターン #18-#20 の直接対策)。

### 1-A: 案件定義の読み込み
- `docs/_internal/TODO_future.md` を Read し、S-60 の案件名・概要・人間が検討すべき事項を取得する

### 1-B: プロジェクト規約の読み込み
- `CLAUDE.md` を Read し、以下を把握する:
  - ファイル番号体系(300_ui/レイヤーの採番ルール)
  - シートへの書き込み位置ルール(「列B (ID列) で最終行を判定」等のコーディング規約)
  - マイグレーションスクリプト運用ガイドライン(参考:今回は非マイグレーションだが規約確認)

### 1-C: onOpen() の定義場所と既存メニュー構造の特定(必須・推測禁止)
- `grep -rn "onOpen" 100_config/ 300_ui/` を実行し、onOpen()が定義されているファイルと行番号を特定する
- 特定したファイルを Read し、既存のメニュー名(`addMenu`/`addSubMenu`/`addItem` の第1引数)を**そのままの文字列**で引用する。「表示設定」というメニューが既に存在するか、新規作成が必要かを確認する

### 1-D: 共通ユーティリティのシグネチャ確認
- `000_infra/004_utils.js` を Read し、以下の関数シグネチャを確認して引用する(記憶で書かない):
  - `Utils.getSheetByKey(key, fallbackName)` — 引数名と順序
  - `Utils.getTrueLastRow(sheet, colIndex)` — `colIndex`が1始まりであることを確認
  - `Utils.parseDateToYm(val)` — 戻り値が `""` となる条件
  - `Utils.toastResult(funcName, message, duration)` — 第1引数がタイトル(funcName)であることを確認

### 1-E: シートシステムキーの確認(推測禁止・Readで裏取り)
- `200_data/202_repository.js` を Read し、以下を確認する:
  - `InvoiceRepository._getSheet()` が呼ぶ `Utils.getSheetByKey()` の第1引数(システムキー文字列)
  - `BankTxRepository._getSheet()` が呼ぶ `Utils.getSheetByKey()` の第1引数(システムキー文字列)

### 1-F: DTOによる列名の確認
- `000_infra/003_contracts.js` を Read し、以下のヘッダー名が実在することを確認する:
  - `InvoiceDTO` におけるソート対象列「発生日(P/L計上日)」とID列(B列)のヘッダー名
  - `BankTxDTO` におけるソート対象列「決済日_実績」とID列(B列)のヘッダー名

### 1-G: 新規ファイル番号の確認
- `ls 300_ui/` を実行し、`302_*.js` が存在するかを確認する。存在しなければ `303_row_grouping.js`、存在すれば次の空き番号を採番する。

---

## Phase 2: 仕様書の分割作成

出力先: `docs/dev/dev_mas-132_monthly_row_grouping.md`
**【重要】絶対に1回のツール呼び出しで全内容を出力しない。以下の5 Stepに分割して実行すること。**

### Step 2-1: 骨格の作成 (File Write, ~20行)
見出しのみ。本文空で可。以下のセクション構成で骨格を作成する:
```
# MAS-132: 月次行グルーピングの自動生成(行積み上がり対策D)
## 概要 / ## 目的 / ## 現在のコード / ## 修正方針 / ## 影響範囲
## 注意事項 / ## エッジケース / ## 実データ検証 / ## 関連ドキュメント
## 人間が検討すべき事項 / ## 実装プロンプト(Claude Code 用)
## 推奨実行モデル / ## 変更履歴 / ## 仕様書作成プロンプト
```

### Step 2-2: 前半セクションの追記 (File Edit または Bash, ~300行)
Phase 1で確定した固有名詞をそのまま使用し、以下を記述する(再考しない):

**概要テーブル**: 案件ID=S-60、カテゴリ=UI/UX改善、対象ファイル=`300_ui/30X_row_grouping.js`(新設・番号はPhase 1で確定)+ onOpen()定義ファイル(Phase 1で特定したファイルパスをそのまま記載)

**目的**: `32_wrk_invoice` および `33_wrk_bank` の行が月単位で積み上がった際の視認性低下を、Sheetsのネイティブ行グルーピング機能で解決する。ユーザーが手動でグループ設定する手間を省き、月境界を自動検出してグループ化する。

**現在のコード**: 対象機能は現時点で存在しない(新規追加)。現状の課題(行積み上がりによる視認性低下)を簡潔に記述する。

**修正方針**(以下のアーキテクチャ決定事項を全て具体的に記述する):

- **新規ファイル**: `300_ui/30X_row_grouping.js`(番号はPhase 1で確定した値)を新設し、`buildMonthlyGroups(target)` と `clearMonthlyGroups(target)` の2関数を実装する
- **UIメニュー追加**: Phase 1で特定した onOpen() 定義ファイルの該当箇所に、以下のメニュー項目を追加する(Phase 1で確認した実在するメニュー名の構造に合わせて記述する。「表示設定」が未存在なら新規サブメニューとして追加):
  - 「月次グループ再構築(請求)」→ `buildMonthlyGroups('INV')` を呼び出す
  - 「月次グループ再構築(銀行)」→ `buildMonthlyGroups('STL')` を呼び出す
  - 「月次グループ全解除(請求)」→ `clearMonthlyGroups('INV')` を呼び出す
  - 「月次グループ全解除(銀行)」→ `clearMonthlyGroups('STL')` を呼び出す
- **排他制御**: `LockService.getScriptLock()` を取得し `lock.waitLock(0)` を呼ぶ(即時タイムアウト)。取得失敗時は catch 内で `Utils.toastResult('月次グループ再構築', '別の処理が実行中です。しばらく後に再試行してください。', 5)` を呼んで return する。`lock.releaseLock()` は finally ブロック内で必ず実行する
- **事前確認ダイアログ**: `SpreadsheetApp.getUi().alert('この操作はシートを日付順に並び替えます。よろしいですか?', SpreadsheetApp.getUi().ButtonSet.OK_CANCEL)` でユーザーの明示的な同意を取る。CANCEL 選択時は処理を中断して return する
- **シート取得**: Phase 1で確認したシステムキーを使い `Utils.getSheetByKey(KEY, 'XX_wrk_xxxxxx')` で取得する
- **最終行取得**: `Utils.getTrueLastRow(sheet, 2)`(B列=ID列を使用。CLAUDE.md 規約)。戻り値が 1 の場合はデータなしとして `Utils.toastResult` で通知して return する
- **ソート処理**: ヘッダー行を含まないデータ行のみを対象に `sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).sort({column: dateColIndex, ascending: true})` を実行する。`Sheet.sort()` は使用しない(ヘッダー行を巻き込む可能性があるため禁止)。`dateColIndex` はヘッダー配列の `indexOf('発生日(P/L計上日)')` 等で動的に取得する(ハードコード禁止)
- **既存グループのクリア**: 本機能は深さ1のグループのみ作成する設計のため、`sheet.getRange(2, 1, lastRow - 1, 1).shiftRowGroupDepth(-1)` で既存の深さ1グループを全解除する
- **グループ化ループ**: データ行を2行目から走査し、`Utils.parseDateToYm(row[dateColIdx])` で各行の年月文字列を取得する。年月が変化する境界を検出し、同一年月の行範囲 `sheet.getRange(startRow, 1, groupLen, 1).shiftRowGroupDepth(1)` でグループを作成する
- **過去月の折りたたみ**: グループ作成後、現在年月(`Utils.parseDateToYm(new Date())`)より前の月のグループ開始行に対して `sheet.getRowGroup(groupStartRow, 1)` を呼び、戻り値が null でない場合のみ `.collapse()` を実行する(nullチェック必須)
- **完了通知**: `Utils.toastResult('月次グループ再構築', 'X月分のグループを作成しました', 5)`

**影響範囲**: 変更ファイル=`30X_row_grouping.js`(新設・約100行)+ onOpen()定義ファイル(+約8行)。既存のRPA・仕訳・集計・レポートロジックへの影響なし。シート表示のみ変更。

**注意事項**(番号付きリスト):
1. `Range.sort({column, ascending})` を使うこと。`Sheet.sort()` はヘッダー行を含む全行をソートするため使用禁止
2. グループ深さは1のみ(多階層グループは作らない)。クリア時は `shiftRowGroupDepth(-1)` で完結する
3. `sheet.getRowGroup(rowIndex, depth)` はグループが存在しない行に対して `null` を返す。`collapse()` 呼び出し前に `null` チェック必須
4. `LockService.waitLock(0)` はロック取得に失敗すると例外をスローする。`try-catch` で必ず補足し、`lock.releaseLock()` は `finally` 内で実行すること
5. `Utils.parseDateToYm()` が `""` を返した行(日付空欄・不正書式)はグループ境界の判定から除外し、直前の年月に属する行として扱う
6. ソート列インデックスはヘッダー名で動的取得すること(列番号ハードコード禁止。CLAUDE.md 規約)

### Step 2-3a: エッジケース〜人間が検討すべき事項の追記 (File Edit または Bash, ~200行)

(以下、Step 2-3a / Step 2-3b / Step 2-4 の本文指示は省略。実際には `tasks/prompts/task_S-60.md` の `<instruction>` タグ全文を保持)

---

## Phase 3: 保存と記録

### 3-A: _config.json への追記(必須)
`docs/_config.json` の `nav` 配列の §E.2(バグ修正・バリデーション)セクションに以下を追記する。追記後は `cat docs/_config.json | python3 -m json.tool > /dev/null` 等で JSON 構文エラーがないことを確認する:
```json
{ "file": "dev/dev_mas-132_monthly_row_grouping.md", "title": "E.2.XX MAS-132 月次行グルーピングの自動生成" }
```

### 3-B: changelog 追記
`docs/_internal/changelog.md` のヘッダー直後(先頭行)に追記する:
```
| YYYY-MM-DD | [dev_mas-132_monthly_row_grouping.md](dev_mas-132_monthly_row_grouping.md) | 初版作成。月次行グルーピングの自動生成(行積み上がり対策D)の仕様書 |
```

### 3-C: コミット&プッシュ
```bash
git add docs/dev/dev_mas-132_monthly_row_grouping.md docs/_config.json docs/_internal/changelog.md
git commit -m "docs: MAS-132 月次行グルーピングの自動生成の開発仕様書を作成

32_wrk_invoiceおよび33_wrk_bankを対象とした月次行グルーピング自動生成の
開発仕様書を新規作成。新規ファイル303_row_grouping.jsの設計を含む。"
git push -u origin {現在のブランチ}
```
</instruction>