MAS-132: 月次行グルーピングの自動生成(行積み上がり対策D)
概要
| 項目 | 内容 |
|---|---|
| 案件ID | MAS-132 |
| カテゴリ | UX・可視性 |
| Phase | P2 |
| 優先度 | ★★ |
| 対象ファイル(新規) | 300_ui/303_row_grouping.js |
| 対象ファイル(変更) | 000_infra/002_constants.js(MENU_DEFINITION へ 4 項目追加) |
| 既存メニュー定義ローダ | 100_config/101_sys_config.js L323 onOpen()(Constants.MENU_DEFINITION を動的にループ) |
| 対象シート | 32_wrk_invoice(INV)/ 33_wrk_bank(STL) |
| ユーザー操作 | サイドバーメニューから手動実行(初版はトリガー自動実行なし) |
目的
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.js の MENU_DEFINITION)
onOpen() は 100_config/101_sys_config.js L323 に存在するが、メニュー項目の実体は 000_infra/002_constants.js L206 の MENU_DEFINITION 配列で宣言的に定義されており、onOpen() はこれを動的にループしてメニューを生成する(forEach → ui.createMenu → addItem)。つまり本案件でメニューを追加する正しい編集先は 002_constants.js の MENU_DEFINITION である(101_sys_config.js は編集不要)。
MENU_DEFINITION の既存カテゴリに「表示設定」や「表示」に該当するものは存在しない(最も近いのは「📋 サイドバー: ⚙️ メンテナンス」だが、グループ操作は破壊的ではない UI 整形のため用途が異なる)。新規カテゴリ「📋 サイドバー: 📂 表示設定」を追加し、以下 4 項目を登録する。既存の source: 'sidebar' パターンに合わせる。
- 「📂 月次グループ再構築(請求)」 →
funcName: 'buildMonthlyGroupsInv' - 「📂 月次グループ再構築(銀行)」 →
funcName: 'buildMonthlyGroupsStl' - 「🗑️ 月次グループ全解除(請求)」 →
funcName: 'clearMonthlyGroupsInv' - 「🗑️ 月次グループ全解除(銀行)」 →
funcName: 'clearMonthlyGroupsStl'
MENU_DEFINITION の funcName は文字列で解釈されるため実引数を取らないラッパー関数が必要。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 配列の順序でシートに再書き込みするため、後続処理はシート上の既存順序に依存しない)。
注意事項
Range.sort({column, ascending})を使うこと。Sheet.sort()はヘッダー行を含む全行をソートするため使用禁止- グループ深さは 1 のみ作成する(多階層グループは作らない)。クリア時は
shiftRowGroupDepth(-1)で完結する sheet.getRowGroup(rowIndex, depth)はグループが存在しない行に対してnullを返す。collapse()呼び出し前にnullチェック必須LockService.waitLock(0)はロック取得に失敗すると例外をスローする。try-catchで必ず捕捉し、lock.releaseLock()はfinally内で実行することUtils.parseDateToYm()が""を返した行(日付空欄・不正書式)はグループ境界の判定から除外し、直前の年月に属する行として扱う- ソート列インデックスはヘッダー名で動的取得すること(列番号ハードコード禁止。CLAUDE.md 規約)
- メニュー項目の
funcNameは実引数を取れない。buildMonthlyGroups('INV')を直接 funcName にはできないため、buildMonthlyGroupsInv()等のラッパー関数を 4 本用意する shiftRowGroupDepth(1)は連続した行範囲に対してのみグループを作成する。境界検出ループは必ず「年月が変わった瞬間に直前までの範囲を確定させる」形で実装すること- 最終グループのクロージングを忘れない。ループ終端到達後、最後の年月の連続行範囲もグループ化する処理を必ず呼ぶ
- 事前確認ダイアログで 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 | 二重実行による行順序の破壊・グループ操作の競合を防止 |
| 8 | sheet.getRowGroup(startRow, 1) が null を返す(作成直後の API 遅延等) | if (g) チェックにより .collapse() をスキップ。折りたたみは次回再実行で回復可能 | null 参照エラー防止 |
| 9 | 事前確認ダイアログで CANCEL 押下 | finally でロックを解放し、シートには一切変更を加えずに return | Human-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.js の InvoiceDTO 型定義 L40〜L41 |
33_wrk_bank の B 列ヘッダー | 決済ID(STL) | 000_infra/003_contracts.js の BankTxDTO 型定義 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:323 | grep -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) の colIndex | 1 始まり。データなしなら 1 を返す | 000_infra/004_utils.js L320〜L329 |
Utils.toastResult(funcName, message, duration) | 第 1 引数はトーストのタイトル | 000_infra/004_utils.js L520〜L523 |
関連ドキュメント
- MAS-130: フィルタービュープリセット配布(行積み上がり対策A) — 姉妹案件。
300_ui/302_filter_presets.js予定 - MAS-131: 完了行の視認性向上(条件付き書式)(行積み上がり対策B) — 姉妹案件・仕様書作成済
- MAS-133: 年度跨ぎアーカイブ機構(行積み上がり対策E) — 長期運用時の根本対策。本案件は MAS-133 実装前の中期対策として位置付く
- CLAUDE.md — コーディング規約 — 「列参照はヘッダー名ベース」「シートへの書き込み位置は列 B (ID 列) で最終行を判定」の根拠
000_infra/004_utils.js—Utils.getSheetByKey/getTrueLastRow/parseDateToYm/toastResultの定義000_infra/003_contracts.js—InvoiceDTO/BankTxDTOの型定義(ヘッダー名の正本)000_infra/002_constants.js—MENU_DEFINITIONのメニュー宣言(本案件の編集対象)
人間が検討すべき事項
TODO_future.md MAS-132 に記載された以下 3 点を初版スコープで対応した上で、追加の論点を整理する。
① TODO_future.md MAS-132 既出の論点
- 時系列順にソートされていないデータがある場合のグループ境界判定
- 対応方針: 処理冒頭で
Range.sort({column, ascending: true})を必ず実行する設計(エッジケース #1)。未ソートでも結果は同一
- 対応方針: 処理冒頭で
- グループ境界の再計算コスト(数万行で数十秒かかる可能性)
- 対応方針: 初版は手動実行のみ(自動トリガーなし)。数千行までは数秒で完了することを想定。数万行に至る場合は MAS-133(年度跨ぎアーカイブ)の完了後に再評価
- 行追加時のグループ範囲自動拡張(GAS API の制約)
- 対応方針: GAS API では行追加時のグループ自動拡張はサポートされない。ユーザーが新規 INV / STL を追加した翌日以降、再度「月次グループ再構築」を実行する運用で吸収(週次または月次締めの手順書に追記することをオーナー決定事項とする)
② 追加の論点
- 「📋 サイドバー: 📂 表示設定」という新カテゴリを新設するかの判断
- 既存カテゴリ(例: 「📋 サイドバー: ⚙️ メンテナンス」)に統合する案もある。本仕様書では新カテゴリ新設を推奨するが、将来 MAS-130(フィルタープリセット)や MAS-131 の「🎨 書式を再適用」メニューを同カテゴリに集約する運用を見据えてオーナーが決定する必要がある
- ソートによる行順序変更の周知
- 本機能は破壊的変更(行順書き換え)を伴うため、事前確認ダイアログで同意を取る設計にしている。それでも「既存の並び(手動で並び替えた順)を維持したい」ユーザーがいる可能性があるため、運用ドキュメントへの明記が必要
- 自動再構築(マート更新後・月次締め完了後)をどこまで進めるか
- TODO_future.md の「②自動再構築」は本案件スコープから除外(初版は手動のみ)。将来
600_report/系のマート更新完了フックに追加するか、月次締め完了メニューに組み込むかはオーナーが決定
- TODO_future.md の「②自動再構築」は本案件スコープから除外(初版は手動のみ)。将来
- 深さ 2 以上の既存グループへの対応
- 本機能は深さ 1 のみ管理する。手動で深さ 2 以上のグループを張っているユーザーがいる場合は仕様上の制約として割り切る(エッジケース #12)。必要であれば
shiftRowGroupDepth(-N)を N=最大深さ分ループする実装に変更可能
- 本機能は深さ 1 のみ管理する。手動で深さ 2 以上のグループを張っているユーザーがいる場合は仕様上の制約として割り切る(エッジケース #12)。必要であれば
- 折りたたみ解除時のユーザー操作の残存
collapse()は folded 状態にするだけで、ユーザーが Sheets UI の+アイコンで展開した状態は次回再実行まで保持される(再実行すると既存グループを一度解除するため、展開状態はリセットされる)。再実行時に展開状態を覚えておいて復元するか、毎回フルリセットで良いかはオーナー決定
32_wrk_invoice/33_wrk_bank以外の時系列タブへの展開- 例:
31_wrk_order(発注タブ・発注日ベース)、35_wrk_receipt(領収書読取結果)、42_trn_journal(仕訳)等。初版は INV/STL 2 タブに限定。拡張時はbuildMonthlyGroupsのtargetに'ORD'/'RCPT'/'JNL'を追加する設計余地あり
- 例:
- 実行後のアンドゥ可否
- Sheets の Ctrl+Z は GAS 実行後は効かない(ソートを含む一連の変更は 1 トランザクションとして履歴に残るが、複数操作なので完全なロールバックは保証されない)。復旧手段として
clearMonthlyGroupsで深さ 1 グループの解除のみ提供。ソート結果自体の巻き戻しは提供しない
- Sheets の Ctrl+Z は GAS 実行後は効かない(ソートを含む一連の変更は 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 Sonnet | GAS デプロイとブラウザでの 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>