概要

項目内容
案件IDMAS-014
カテゴリUX / FP&A
PhaseP2
優先度★★
所要時間3〜4時間
対象ファイル200_data/202_repository.js(BudgetRepository / ProjectRepository 追加)
400_domain/420_project_profitability.jsupdatePjVarianceDashboard_() 追加)
100_config/101_sys_config.js(シートキー登録)
000_infra/002_constants.jsMENU_DEFINITION にエントリ追加)
前提案件なし(420_project_profitability.js の既存 loadPjMaster_ / loadAcctMaster_ を再利用するのみ。既存動作の改変は伴わない)

目的

各 PM(プロジェクトマネージャー)が自身の担当プロジェクトの「予算消化状況」「売上達成率」「利益率」をリアルタイムで一覧確認できるダッシュボードシート(79_pj_variance_dashboard)を新設する。

既存の 78_pj_plPJ 横並び P/L)と 79_pj_monthly(PJ 別月次採算表)は 実績中心 で、共通費配賦後の限界利益や科目別明細の表示を主眼としている。一方、本 F-14 は 予実差異(Budget vs Actual)そのものを PJ 別に可視化 することを目的とする。41_trn_budget(予算)と 42_trn_journal(実績)を突き合わせ、売上・費用・利益の各カテゴリで差異額・差異率・予算消化率を 1 行 / 1 PJ の横断ビューで表示する。

現場 PM が自分で数字を叩きに来ることを想定し、シート上部にプルダウン(対象 PJ 選択・単月/累計(YTD) 切替)を設けて絞り込めるようにする。

現在のコード

400_domain/420_project_profitability.js(既存 PJ 集計の中核)

関数 / シンボル行番号役割
buildProjectProfitability()L7メイン関数。79_pj_monthly78_pj_pl77_pj_raw を生成
loadPjMaster_(ss)L80314_mst_project{ pjName: { code, allocType, pm } } 形式で返す
loadAcctMaster_(ss)L82211_mst_account{ acctName: { stmt, cat, disp, dispAcct } } 形式で返す
loadAllocRules_(ss)L84228_bud_allocation の配賦ルール読込
既存シート出力L18 / L701 / L750既存は 79_pj_monthly78_pj_pl77_pj_raw の 3 マートのみ

本 F-14 で追加する updatePjVarianceDashboard_() は、loadPjMaster_ / loadAcctMaster_ をそのまま再利用する(車輪の再発明を避ける)。

200_data/202_repository.js(Repository 層)

既存 Repository行番号本件での位置づけ
readSheetAsDtos_(sheet)L19-L29全 Repository 共通の DTO 化プライベートヘルパー
JournalRepositoryL259-L298findAll() / save() / append() の 3 メソッド構成。本件で findAll() を利用
AccountRepositoryL304-L350読み取り専用マスタ Repo の最小構成テンプレート_getSheet + findAll + findAsMap)。BudgetRepository / ProjectRepository はこのパターンに準拠する
BudgetRepository未実装。本 F-14 で新設する
ProjectRepository未実装。本 F-14 で新設する

000_infra/003_contracts.js(DTO 型定義)

DTO行番号本件で参照する主要フィールド
BudgetDTOL132-L149有効フラグ対象年月("YYYY-MM")・PJ名収支区分("収入" / "支出")・科目名予算金額
JournalEntryDTOL95-L129発生日(P/L計上日)PJ名収支区分科目名税抜金額_実績

100_config/101_sys_config.js(システム設定)

該当箇所行番号役割
onOpen() ディスパッチャL323-L350Constants.MENU_DEFINITION を走査してメニューを構築(直接 addItem しない宣言的方式)
シートキー登録ブロックL770-L823if (!existKeys.includes('KEY')) confSheet.appendRow(...) パターンで 01_sys_config にエントリ追加
MST_PROJ DDL スキーマL83614_mst_project のヘッダーは 有効フラグ, PJコード, プロジェクト名, …, 配賦区分
TRN_BUDG DDL スキーマL86141_trn_budget のヘッダーは 有効フラグ, 予算ID, 対象年月, 決済予定年月, 予算バージョン, 収支区分, 組織名, PJ名, 科目名, 予算金額, …

000_infra/002_constants.js(メニュー定義)

該当箇所行番号役割
MENU_DEFINITIONL206-L324宣言的メニュー定義配列
📋 サイドバー: 📊 マート更新 カテゴリL229-L239buildProjectProfitability(PJ 別採算)が所属。本 F-14 のメニュー項目もこのカテゴリに追加する

プルダウン設置の既存実装

600_report/608_datamart_render.js L114-L115 に明確な参考例がある:

const snapChoices = ctx.targetMonthsWithActBgt.map(v => String(v).replace(/\n/g, ' '));
const snapRule = SpreadsheetApp.newDataValidation().requireValueInList(snapChoices).setAllowInvalid(true).build();
sheetSnap.getRange('B1').clearDataValidations().setNumberFormat('@').setDataValidation(snapRule);

選択値の読み取りは sheet.getRange('A1').getValue() で行う。

修正方針

Step 1: 202_repository.js に読み取り専用 Repository を 2 つ追加

AccountRepository(L304-L350)のパターンに完全準拠。_getSheet + findAll の最小構成とし、内部で既存プライベートヘルパー readSheetAsDtos_ を直接呼び出す。

  • BudgetRepository
    • シートキー: TRN_BUDG(L786 で既に登録済み)、フォールバック名: '41_trn_budget'
    • findAll(): { headers: string[], dtos: BudgetDTO[] }
  • ProjectRepository
    • シートキー: MST_PROJ(L836 の DDL で登録済み。Utils.getSheetByKey で解決可能)、フォールバック名: '14_mst_project'
    • findAll(): { headers: string[], dtos: Object[] }

いずれも save / append実装しない(本ダッシュボードは読み取り専用のため不要)。

Step 2: ダッシュボード更新関数の実装

配置場所: 400_domain/420_project_profitability.js の末尾(loadAllocRules_ の下)に updatePjVarianceDashboard_() を追加する。既存の loadPjMaster_ / loadAcctMaster_ を再利用できるため、新規ファイルには分離しない。

処理ステップ:

  1. シート準備(冪等性 = 複数回実行しても結果が同じになる性質、を担保)

    • ss.getSheetByName('79_pj_variance_dashboard') を取得。無ければ insertSheet()
    • 先頭で sheet.clear()sheet.getRange(1, 1, sheet.getMaxRows(), sheet.getMaxColumns()).clearDataValidations() を実行。
  2. データ取得

    • ProjectRepository.findAll()14_mst_project の全 DTO を取得。有効フラグ === FALSE(または 'FALSE')をスキップ。プロジェクト名 列(= PJ 名として扱う実体)・PJコードPM・責任者名 を保持。
    • BudgetRepository.findAll()41_trn_budget を取得。有効フラグ === FALSE スキップ。
    • JournalRepository.findAll()42_trn_journal を取得。
  3. 日付正規化(Date 型直接比較・文字列ソート崩れの防止)

    • 予算: 対象年月Utils.parseDateToYm(dto['対象年月'])"YYYY-MM" に統一。
    • 実績: 発生日(P/L計上日)Utils.parseDateToYm(dto['発生日(P/L計上日)'])"YYYY-MM" に統一。
    • 既存 420_project_profitability.js L27-L28 と同じ Utils.parseDateToYm / Utils.addMonths を使う。
  4. 対象期間の決定

    • 基準年月(今期 8 月〜翌 7 月)は buildProjectProfitability L22 と同じロジックで導出。
    • シート上部プルダウンの選択値に従い、単月(対象年月 1 ヶ月分)か 累計(YTD)(期首 8 月〜対象年月までの累計)かを切替。
  5. 科目フィルタ(収支区分 による分類)

    • 予算側: 収支区分 === '収入' → 売上予算、=== '支出' → 費用予算。
    • 実績側: 同上。ただし B/S 科目は除外loadAcctMaster_stmt === 'BS' を弾く)し、実績の金額は 税抜金額_実績 を使用。
    • 既存 buildProjectProfitability L130-L140 と整合する分類を採る。科目マスタ未登録の科目名は集計にカウントせず、監査ログ Utils.logInfo で警告のみ記録する(キーワード推測による自動分類は禁止。CLAUDE.md 規約 / 失敗パターン #1)。
  6. PJ 別に予算・実績を集計して { pjName: { salesBud, salesAct, costBud, costAct } } を構築

    • 予算データに存在するが 14_mst_project に登録がない PJ 名、および実績データに存在するが未登録の PJ 名は、集約して (PJマスタ未登録) という 1 行にまとめる(失敗パターン #1: データ品質問題を隠さず可視化)。
    • 有効フラグ === FALSE の PJ マスタ行は集計対象外。
  7. シート上部のパラメータセル

    • A1: 「🔍 対象PJを選択 ➡️」ラベルを A1 に置き、B1 セルに PJ 選択プルダウンProjectRepository.findAll() から有効な PJ 名を動的生成。先頭に (すべて) 項目を含める)。
    • C1: 「期間粒度 ➡️」ラベル。D1 セルに期間粒度プルダウン(固定リスト: 単月 / 累計(YTD))。
    • E1: 「基準年月 ➡️」ラベル。F1 セルに対象年月プルダウン(対象 12 ヶ月の YYYY-MM)。
    • setDataValidation パターンは 608_datamart_render.js L114-115 に準拠。
  8. 出力列(ヘッダーは以下の順序で固定)

    | PJ名 | PM | 売上_予算 | 売上_実績 | 売上_差異額 | 売上_差異率% | 費用_予算 | 費用_実績 | 費用_差異額 | 費用_差異率% | 費用_予算消化率% | 利益_予算 | 利益_実績 | 利益_差異額 | 利益率%_予算 | 利益率%_実績 |

    • 利益 = 売上 − 費用
    • 差異額_売上 = 売上_実績 − 売上_予算(実績が予算を上回る=プラス)
    • 差異率%_売上 = 差異額_売上 / 売上_予算
    • 差異額_費用 = 費用_実績 − 費用_予算(実績が予算を上回る=プラスだが、費用なので赤字方向)
    • 差異率%_費用 = 差異額_費用 / 費用_予算
    • 費用_予算消化率% = 費用_実績 / 費用_予算
    • 利益率%_予算 = 利益_予算 / 売上_予算
    • 利益率%_実績 = 利益_実績 / 売上_実績
  9. 全社合計行を最終行に追加

    • 利益率% は 各 PJ の率を合算しない。全社合計の売上・費用から再計算する(非加算指標を単純合算してはならない。失敗パターン #1)。
  10. 書式整備

    • ヘッダー行: ダーク背景 + 白文字 + 太字(既存 608_datamart_render.js 流儀に合わせる)。
    • 金額列: '#,##0;[Red]△ #,##0;"-"'
    • 率(%) 列: '0.0%;[Red]△ 0.0%;"-"'
    • 全社合計行・(PJマスタ未登録) 行は薄灰色ハイライト + 太字。
    • 赤字(利益_実績 < 0)の行はアラート色(#f4cccc + #cc0000)で強調。
    • setFrozenRows(2)(1 行目パラメータ、2 行目ヘッダー)、setFrozenColumns(2)(PJ 名 + PM 列)。
    • 既存マート同様、setFontFamily('BIZ UDGothic') を全域に適用し、余剰列・行を deleteColumns / deleteRows で物理削除。
  11. 公開関数 updatePjVarianceDashboard()(末尾アンダースコアなし)を定義し、内部で updatePjVarianceDashboard_() を try/catch で呼ぶ(既存 buildProjectProfitability と同じエラー処理パターン)。

Step 3: シートキー登録とメニュー登録

  • シートキー登録: 100_config/101_sys_config.js の L815-L817 付近(PJ_RAW / PJ_PL / PJ_MONTHLY の直後)に 1 行追加:

    if (!existKeys.includes('PJ_VAR_DASH')) confSheet.appendRow(['PJ_VAR_DASH', '', '79_pj_variance_dashboard', 'PJ別予実ダッシュボード']);
    

    Utils.getSheetByKey('PJ_VAR_DASH', '79_pj_variance_dashboard') で解決できるようになる。

  • メニュー登録: 000_infra/002_constants.jsMENU_DEFINITION L230-L239 の 📋 サイドバー: 📊 マート更新 カテゴリ内、buildProjectProfitability の直後に 1 行追加:

    { label: '📋 プロジェクト別予実ダッシュボードを更新', funcName: 'updatePjVarianceDashboard', description: 'PJ 別の予算 vs 実績マート (79_pj_variance_dashboard) を再構築' },
    
  • DDL 追加の要否: 79_pj_variance_dashboard は動的生成のマートシートで、既存の 77_pj_raw / 78_pj_pl / 79_pj_monthly と同様に DDL(setupAllSchemasschemas オブジェクト)には登録しない(既存 3 マートも登録されていない)。

影響範囲

区分対象影響内容
新規追加のみ200_data/202_repository.jsBudgetRepository / ProjectRepository 追加。既存 Repository 未変更
新規追加のみ400_domain/420_project_profitability.jsupdatePjVarianceDashboard() / updatePjVarianceDashboard_() 追加。既存 buildProjectProfitability 未変更
設定追加のみ100_config/101_sys_config.jsPJ_VAR_DASH シートキー登録 1 行追加
設定追加のみ000_infra/002_constants.jsMENU_DEFINITION に 1 項目追加
新規シート79_pj_variance_dashboard起動時は未作成。メニュー初回実行時に自動生成
影響なし既存マート 77_pj_raw / 78_pj_pl / 79_pj_monthly本件は別シート(79_pj_variance_dashboard)に独立出力するため影響なし
影響なし財務諸表(61/62/71/81 等)読み取りのみで一切改変しない

注意事項

  1. 既存ロジック再利用: 420_project_profitability.jsloadPjMaster_ / loadAcctMaster_ を再利用すること。PJ マスタ読込・科目分類を別関数で再実装しない(失敗パターン #1: 重複実装)。
  2. 造語禁止: メニュー名 / シートキー文字列 / 定数名は Phase 1 で確認した実在文字列のみ使用する。例として、メニューカテゴリは 📋 サイドバー: 📊 マート更新(002_constants.js L230)であり、「📊 FP&Aレポート」等の存在しないカテゴリを作ってはならない(失敗パターン #18-#20)。
  3. 列参照はヘッダー名ベース: indexOf('PJ名') 等でヘッダー位置を取得し、列番号ハードコードを避ける(CLAUDE.md 規約)。
  4. 科目マスタ (11_mst_account) 未登録の科目名: キーワード推測で 売上 / 費用 を自動分類しない。未登録は Utils.logInfo で警告のみ記録し、集計から除外する(CLAUDE.md 規約)。
  5. 有効フラグ = FALSE の行: PJ マスタ・予算・実績のすべてで、有効フラグfalse(bool)または 'FALSE'(文字列)なら集計対象から除外する。
  6. PJ マスタ未登録 PJ: 予算・実績データに存在するが 14_mst_project に未登録の PJ 名は、削除せず (PJマスタ未登録) 行に集約表示する(データ品質問題の可視化)。
  7. 非加算指標の扱い: 利益率% を単純合算しない。全社合計行は合算後の売上・費用から率を再計算する(失敗パターン #1)。
  8. 冪等性: 処理冒頭で sheet.clear() + clearDataValidations() を実行し、何度実行しても同じ結果に収束すること。

エッジケース

計算式を含むため、ゼロ除算やデータ欠落のケースを明示する。

#条件表示値理由
1売上実績 = 0 のときの 利益率%_実績"-"ゼロ除算防止
2売上予算 = 0 のときの 利益率%_予算"-"ゼロ除算防止
3差異率% の分母(予算)= 0"-"ゼロ除算防止
4費用予算 = 0 かつ 費用実績 > 0 のときの 予算消化率%"予算外支出"予算未計上の支出を明示(管理会計上の異常値)
5費用予算 = 0 かつ 費用実績 = 0 のときの 予算消化率%"0%"正常ケース
6実績・予算データに存在するが 14_mst_project に未登録の PJ 名(PJマスタ未登録) 行に集約(最終行の 1 行上、全社合計の直前)データ品質問題を隠さず可視化
7予算のみ存在する PJ(実績なし)実績 = 0、差異 = 0 − 予算金額(マイナス表示)未消化予算の可視化
8実績のみ存在する PJ(予算なし)予算 = 0、予算消化率% = "予算外支出"予算外支出の検出
9全社合計行の 利益率%_予算 / 利益率%_実績全社合計の売上・費用から再計算非加算指標を単純合算してはならない(失敗パターン #1)
10有効フラグ = FALSE の PJ マスタ行集計対象から除外(プルダウンにも非表示)CLAUDE.md: 有効フラグ=FALSE の行は全処理でスキップ
11科目マスタ未登録の科目名集計からスキップ。Utils.logInfo[F-14] 未登録科目: ${acct} を記録キーワード推測による自動分類を避ける(CLAUDE.md 規約)
12累計(YTD) モードで対象年月が期首(8月)の場合当月 1 ヶ月分のみ集計(8 月〜8 月)YTD の境界条件を明示
13プルダウンで (すべて) 選択全 PJ を表示(個別 PJ 絞り込みは無効化)初期値。フィルタ未適用状態
14利益_実績 < 0 の行行全体を薄赤(#f4cccc)+赤字文字(#cc0000)で強調PM への視覚的警告

実データ検証

MCP ツールによる live データ検証は本ワークスペース(Codespaces sub 環境)では未実施。Phase 1 では DDL スキーマ(101_sys_config.js L826-L861)および既存コード参照(420_project_profitability.js L809-L819)から確定した。実装後に dev 環境で下記を再検証すること。

14_mst_project 列構造(DDL 定義より)

有効フラグ, PJコード, プロジェクト名, PJ小区分, PJ大区分, 社内外, 資産化,
顧客・取引先名, PJ区分, 契約形態, ステータス, 資産化対象, PM・責任者名, 配賦区分
  • PJ 名として参照する列: プロジェクト名(既存 loadPjMaster_ L811 で確定)
  • 有効フラグ列: 有効フラグ(bool / 'FALSE' 文字列の両方を判定)
  • PM 表示用列: PM・責任者名

41_trn_budget 列構造と 収支区分 実データ値(DDL + DTO 定義より)

有効フラグ, 予算ID, 対象年月, 決済予定年月, 予算バージョン, 収支区分,
組織名, PJ名, 科目名, 予算金額, 摘要, 収支区分コード, 組織コード, PJコード, 主科目コード
  • 収支区分 格納値: "収入" / "支出"BudgetDTO 定義 L139 / 既存 RPA コード '収支区分': '支出' L208 等より確認)
  • 対象年月 フォーマット: "YYYY-MM"(文字列)

42_trn_journal収支区分PJ名(DTO 定義より)

  • 収支区分: "収入" / "支出"JournalEntryDTO 定義 L102)
  • PJ名: 14_mst_projectプロジェクト名 と一致する自由文字列。未指定時は RPA 起票側で '指定なし_共通費など' が入る(202_repository.js L202)
  • 税抜金額_実績 を実績金額として採用(BudgetDTO.予算金額 と同じ税抜ベースで比較するため)

実装後の dev 検証ステップ(npm run push:dev 後)

  1. メニュー「📋 プロジェクト別予実ダッシュボードを更新」を実行し、79_pj_variance_dashboard シートが生成されることを確認。
  2. シート上部のプルダウン 3 つ(PJ 選択 / 期間粒度 / 基準年月)が操作可能であること。
  3. 既存 14_mst_project の有効 PJ がすべて表示されること。
  4. (PJマスタ未登録) 行が必要に応じて出現すること(テストデータで意図的に未登録 PJ 名を含める)。
  5. 全社合計行の利益率 % が、売上_実績 合計 / 費用_実績 合計 から正しく再計算されていること。

関連ドキュメント

人間が検討すべき事項

1. PM 向け公開情報の範囲(TODO_future.md F-14 より)

PM 向けに公開する情報の範囲(原価のみ or 利益率含む)

初期実装では売上・費用・利益・利益率の全列を表示する。ただし、PM ロールに応じて利益率や売上予算を非表示にしたいケースが想定される(例: 外注 PM に対する内部原価非開示)。

推奨対応: 将来の N-11(ロールベースアクセス制御)導入を見据え、表示項目 ON/OFF のフィーチャーフラグを 03_sys_params に設計段階で用意しておく。

推奨パラメータキーデフォルト用途
PJ_DASH_SHOW_REVENUEbooleantrue売上_予算 / 売上_実績 / 売上_差異列を表示するか
PJ_DASH_SHOW_MARGINbooleantrue利益率%_予算 / 利益率%_実績 列を表示するか
PJ_DASH_SHOW_COST_BUDGETbooleantrue費用_予算 / 費用_差異列を表示するか(FALSE なら予算消化率% のみ残す)

読み取りは既存の Constants.getParam('PJ_DASH_SHOW_REVENUE', 'true') を流用可能。最終的な要否と各デフォルト値はプロダクトオーナー判断を仰ぐこと

2. プルダウンの初期値と永続化

セルプルダウンの選択値はシート再描画(sheet.clear())でリセットされる。608_datamart_render.js L116 のように、直前の選択値を保持する仕組みを入れるかどうかは UX 検討事項。初期実装は毎回リセットで可

3. 79_pj_variance_dashboard のタブ並び順

既存タブ 79_pj_monthly との並び順はアルファベット順で 79_pj_monthly79_pj_variance_dashboard となる(sortSheetsByName 実行後も同様)。混乱を避けるため、タブ背景色を既存 79 系(PJ 管理会計のオレンジ系)と揃えるかは UI レビュー時に検討。

4. 実績金額の採用基準

本仕様は 税抜金額_実績42_trn_journal)を採用しているが、予算が税抜で運用されているか税込で運用されているか、現場ルールを確認すること。41_trn_budget.予算金額 の運用が税込であれば、実績も 税込金額_実績 に揃える必要がある(F-14 では税抜採用を前提としたが、要確認)。

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

あなたは GAS 会計システム (bizlp-gas-accounting) のシニア開発者です。
F-14「プロジェクト別予実ダッシュボード」を実装してください。

## 実行前タスク(必須・順序厳守)

下記ファイルをこの順序で Read して、固有名詞・行番号・既存パターンを確定させること。造語・推測禁止。

1. `400_domain/420_project_profitability.js` — 既存 `buildProjectProfitability` L7 / `loadPjMaster_` L803 / `loadAcctMaster_` L822 の構造と呼び出し方。再利用必須。
2. `200_data/202_repository.js` — `AccountRepository` L304-L350(最小 Repository テンプレート)と `readSheetAsDtos_` L19-L29 を確認。
3. `000_infra/003_contracts.js` — `BudgetDTO` L132-L149 と `JournalEntryDTO` L95-L129 のフィールド名を確定。
4. `100_config/101_sys_config.js` L770-L823 — シートキー登録パターン(`if (!existKeys.includes('KEY')) confSheet.appendRow(...)`)。
5. `000_infra/002_constants.js` L206-L324 — `MENU_DEFINITION` と `📋 サイドバー: 📊 マート更新` カテゴリ L229-L239 の位置。
6. `600_report/608_datamart_render.js` L114-L115 — `setDataValidation` の実装パターン。

## 修正対象ファイルと変更量

| ファイル | 変更内容 | 概算行数 |
|---------|---------|---------|
| `200_data/202_repository.js` | `BudgetRepository` + `ProjectRepository` 追加 | +40 行 |
| `400_domain/420_project_profitability.js` | `updatePjVarianceDashboard()` + `updatePjVarianceDashboard_()` 追加 | +180 行 |
| `100_config/101_sys_config.js` | シートキー登録 1 行追加(L815-L817 付近) | +1 行 |
| `000_infra/002_constants.js` | `MENU_DEFINITION` の「📋 サイドバー: 📊 マート更新」カテゴリに 1 項目追加 | +1 行 |

## Step 1: Repository 追加(`200_data/202_repository.js`)

`AccountRepository` L304-L350 の直下に 2 つの Repository を同じパターンで追加:

```js
// =====================================================================
// BudgetRepository — 41_trn_budget (読み取り専用)
// =====================================================================
var BudgetRepository = {
  /** @private */
  _getSheet: function() {
    return Utils.getSheetByKey('TRN_BUDG', '41_trn_budget');
  },
  /**
   * 全予算レコードを DTO 配列で取得する。
   * @returns {{ headers: string[], dtos: BudgetDTO[] }}
   */
  findAll: function() {
    return readSheetAsDtos_(BudgetRepository._getSheet());
  },
};

// =====================================================================
// ProjectRepository — 14_mst_project (読み取り専用マスタ)
// =====================================================================
var ProjectRepository = {
  /** @private */
  _getSheet: function() {
    return Utils.getSheetByKey('MST_PROJ', '14_mst_project');
  },
  /**
   * 全 PJ マスタレコードを DTO 配列で取得する。
   * @returns {{ headers: string[], dtos: Object[] }}
   */
  findAll: function() {
    return readSheetAsDtos_(ProjectRepository._getSheet());
  },
};
```

## Step 2: ダッシュボード更新関数(`400_domain/420_project_profitability.js` 末尾に追加)

`loadAllocRules_` L842-L869 の直下に以下を追加する。

```js
/**
 * F-14: PJ 別予実ダッシュボード (79_pj_variance_dashboard) を再構築する公開関数。
 * メニューから呼ばれる。
 */
function updatePjVarianceDashboard() {
  var FUNC = 'updatePjVarianceDashboard';
  try {
    Utils.logInfo(FUNC, '処理開始');
    updatePjVarianceDashboard_();
    Utils.logInfo(FUNC, '処理完了');
  } catch (e) {
    Utils.logError(FUNC, e);
    SpreadsheetApp.getUi().alert('🚨 ' + FUNC + ' でエラーが発生しました', e.message, SpreadsheetApp.getUi().ButtonSet.OK);
  }
}

/** @private */
function updatePjVarianceDashboard_() {
  var ss = getWebSpreadsheet_();
  var sheetName = Utils.getSheetNameByKey('PJ_VAR_DASH') || '79_pj_variance_dashboard';
  var sheet = ss.getSheetByName(sheetName) || ss.insertSheet(sheetName);

  // 冪等性: 先頭で全クリア
  sheet.clear();
  sheet.getRange(1, 1, sheet.getMaxRows(), sheet.getMaxColumns()).clearDataValidations();

  var projMaster = loadPjMaster_(ss);
  var acctMaster = loadAcctMaster_(ss);

  // 対象 12 ヶ月 (8月〜翌7月、既存 buildProjectProfitability と同じロジック)
  var today = new Date();
  var startYear = (today.getMonth() + 1 >= 8) ? today.getFullYear() : today.getFullYear() - 1;
  var targetMonths = [];
  for (var i = 0; i < 12; i++) {
    var d = new Date(startYear, 7 + i, 1);
    targetMonths.push(d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'));
  }

  // パラメータセル (B1: PJ選択, D1: 期間粒度, F1: 基準年月)
  // 初期値は (すべて) / 単月 / 今月
  var currentYm = (function() {
    var t = new Date();
    return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0');
  })();
  var defaultYm = targetMonths.indexOf(currentYm) >= 0 ? currentYm : targetMonths[0];
  var selectedPj = '(すべて)';
  var selectedGran = '単月';
  var selectedYm = defaultYm;

  // 予算・実績集計
  var aggMap = {};  // { pjName: { salesBud, salesAct, costBud, costAct } }
  function ensureAgg_(pn) {
    if (!aggMap[pn]) aggMap[pn] = { salesBud: 0, salesAct: 0, costBud: 0, costAct: 0 };
    return aggMap[pn];
  }
  function inRange_(ym) {
    if (!ym) return false;
    if (selectedGran === '単月') return ym === selectedYm;
    // 累計(YTD): 期首(targetMonths[0]) 〜 selectedYm
    return ym >= targetMonths[0] && ym <= selectedYm;
  }

  // 予算 (41_trn_budget)
  var bud = BudgetRepository.findAll();
  for (var bi = 0; bi < bud.dtos.length; bi++) {
    var bd = bud.dtos[bi];
    var bFlag = bd['有効フラグ'];
    if (bFlag === false || String(bFlag).toUpperCase() === 'FALSE') continue;
    var bYm = Utils.parseDateToYm(bd['対象年月']);
    if (!inRange_(bYm)) continue;
    var bPj = String(bd['PJ名'] || '').trim() || '指定なし_共通費など';
    if (projMaster[bPj] && projMaster[bPj] /* always registered */) {
      // 登録済み
    } else if (!projMaster[bPj]) {
      bPj = '(PJマスタ未登録)';
    }
    var bAcct = String(bd['科目名'] || '').trim();
    var bAcctInfo = acctMaster[bAcct];
    if (!bAcctInfo) { Utils.logInfo('F-14', '未登録科目(予算): ' + bAcct); continue; }
    if (bAcctInfo.stmt === 'BS') continue;
    var bShushi = String(bd['収支区分'] || '').trim();
    var bAmt = Number(bd['予算金額']) || 0;
    var aggB = ensureAgg_(bPj);
    if (bShushi === '収入') aggB.salesBud += bAmt;
    else if (bShushi === '支出') aggB.costBud += Math.abs(bAmt);
  }

  // 実績 (42_trn_journal)
  var jrn = JournalRepository.findAll();
  for (var ji = 0; ji < jrn.dtos.length; ji++) {
    var jd = jrn.dtos[ji];
    var jYm = Utils.parseDateToYm(jd['発生日(P/L計上日)']);
    if (!inRange_(jYm)) continue;
    var jPj = String(jd['PJ名'] || '').trim() || '指定なし_共通費など';
    if (!projMaster[jPj]) jPj = '(PJマスタ未登録)';
    var jAcct = String(jd['科目名'] || '').trim();
    var jAcctInfo = acctMaster[jAcct];
    if (!jAcctInfo) { Utils.logInfo('F-14', '未登録科目(実績): ' + jAcct); continue; }
    if (jAcctInfo.stmt === 'BS') continue;
    var jShushi = String(jd['収支区分'] || '').trim();
    var jAmt = Number(jd['税抜金額_実績']) || 0;
    var aggA = ensureAgg_(jPj);
    if (jShushi === '収入') aggA.salesAct += jAmt;
    else if (jShushi === '支出') aggA.costAct += Math.abs(jAmt);
  }

  // 出力行構築: 有効 PJ (プロジェクトマスタ登録順) → (PJマスタ未登録) → 全社合計
  var out = [];
  // 1 行目: パラメータセル
  out.push(['🔍 対象PJを選択 ➡️', selectedPj, '期間粒度 ➡️', selectedGran, '基準年月 ➡️', selectedYm, '', '', '', '', '', '', '', '', '', '']);
  // 2 行目: ヘッダー
  out.push(['PJ名', 'PM', '売上_予算', '売上_実績', '売上_差異額', '売上_差異率%', '費用_予算', '費用_実績', '費用_差異額', '費用_差異率%', '費用_予算消化率%', '利益_予算', '利益_実績', '利益_差異額', '利益率%_予算', '利益率%_実績']);

  function zeroSafe_(num, den) { return den === 0 ? '-' : (num / den); }
  function consumeRate_(act, bud) {
    if (bud === 0 && act > 0) return '予算外支出';
    if (bud === 0 && act === 0) return 0;
    return act / bud;
  }

  var pjList = Object.keys(projMaster).filter(function(p) {
    return selectedPj === '(すべて)' || p === selectedPj;
  });
  // 実績・予算にあるが未登録 PJ を集約した (PJマスタ未登録) も対象に含める
  if (aggMap['(PJマスタ未登録)'] && (selectedPj === '(すべて)' || selectedPj === '(PJマスタ未登録)')) {
    pjList.push('(PJマスタ未登録)');
  }

  var grandSalesBud = 0, grandSalesAct = 0, grandCostBud = 0, grandCostAct = 0;
  for (var pi = 0; pi < pjList.length; pi++) {
    var pn = pjList[pi];
    var a = aggMap[pn] || { salesBud: 0, salesAct: 0, costBud: 0, costAct: 0 };
    var pm = (projMaster[pn] && projMaster[pn].pm) || '';
    var sDiff = a.salesAct - a.salesBud;
    var cDiff = a.costAct - a.costBud;
    var profBud = a.salesBud - a.costBud;
    var profAct = a.salesAct - a.costAct;
    var pDiff = profAct - profBud;
    out.push([
      pn, pm,
      a.salesBud, a.salesAct, sDiff, zeroSafe_(sDiff, a.salesBud),
      a.costBud, a.costAct, cDiff, zeroSafe_(cDiff, a.costBud), consumeRate_(a.costAct, a.costBud),
      profBud, profAct, pDiff, zeroSafe_(profBud, a.salesBud), zeroSafe_(profAct, a.salesAct)
    ]);
    grandSalesBud += a.salesBud; grandSalesAct += a.salesAct;
    grandCostBud += a.costBud; grandCostAct += a.costAct;
  }

  // 全社合計行 (利益率は合計から再計算)
  var gSDiff = grandSalesAct - grandSalesBud;
  var gCDiff = grandCostAct - grandCostBud;
  var gProfBud = grandSalesBud - grandCostBud;
  var gProfAct = grandSalesAct - grandCostAct;
  var gPDiff = gProfAct - gProfBud;
  out.push([
    '🏢 全社合計', '',
    grandSalesBud, grandSalesAct, gSDiff, zeroSafe_(gSDiff, grandSalesBud),
    grandCostBud, grandCostAct, gCDiff, zeroSafe_(gCDiff, grandCostBud), consumeRate_(grandCostAct, grandCostBud),
    gProfBud, gProfAct, gPDiff, zeroSafe_(gProfBud, grandSalesBud), zeroSafe_(gProfAct, grandSalesAct)
  ]);

  // シート書き込み
  var rLen = out.length, cLen = out[0].length;
  if (sheet.getMaxRows() < rLen) sheet.insertRowsAfter(sheet.getMaxRows(), rLen - sheet.getMaxRows());
  if (sheet.getMaxColumns() < cLen) sheet.insertColumnsAfter(sheet.getMaxColumns(), cLen - sheet.getMaxColumns());
  sheet.getRange(1, 1, rLen, cLen).setValues(out);
  sheet.setFrozenRows(2);
  sheet.setFrozenColumns(2);

  // プルダウン設置
  var pjChoices = ['(すべて)'].concat(Object.keys(projMaster).sort());
  if (aggMap['(PJマスタ未登録)']) pjChoices.push('(PJマスタ未登録)');
  var ruleB1 = SpreadsheetApp.newDataValidation().requireValueInList(pjChoices).setAllowInvalid(true).build();
  sheet.getRange('B1').clearDataValidations().setDataValidation(ruleB1);
  var ruleD1 = SpreadsheetApp.newDataValidation().requireValueInList(['単月', '累計(YTD)']).setAllowInvalid(true).build();
  sheet.getRange('D1').clearDataValidations().setDataValidation(ruleD1);
  var ruleF1 = SpreadsheetApp.newDataValidation().requireValueInList(targetMonths).setAllowInvalid(true).build();
  sheet.getRange('F1').clearDataValidations().setDataValidation(ruleF1);

  // 書式: ヘッダー行
  sheet.getRange(2, 1, 1, cLen).setBackground('#434343').setFontColor('#FFFFFF').setFontWeight('bold');
  sheet.getRange(1, 1, 1, cLen).setBackground('#EAEAEA').setFontWeight('bold');
  sheet.getRange(1, 2).setBackground('#FFF2CC').setFontColor('#B45F06');
  sheet.getRange(1, 4).setBackground('#FFF2CC').setFontColor('#B45F06');
  sheet.getRange(1, 6).setBackground('#FFF2CC').setFontColor('#B45F06');
  // 全社合計行
  sheet.getRange(rLen, 1, 1, cLen).setBackground('#D9EAD3').setFontWeight('bold').setFontColor('#274E13');
  // 金額列フォーマット (C〜E, G〜I, L〜N)
  sheet.getRange(3, 3, rLen - 2, 3).setNumberFormat('#,##0;[Red]△ #,##0;"-"');
  sheet.getRange(3, 7, rLen - 2, 3).setNumberFormat('#,##0;[Red]△ #,##0;"-"');
  sheet.getRange(3, 12, rLen - 2, 3).setNumberFormat('#,##0;[Red]△ #,##0;"-"');
  // 率 (F, J, K, O, P)
  sheet.getRange(3, 6, rLen - 2, 1).setNumberFormat('0.0%;[Red]△ 0.0%;"-"');
  sheet.getRange(3, 10, rLen - 2, 2).setNumberFormat('0.0%;[Red]△ 0.0%;"-"');
  sheet.getRange(3, 15, rLen - 2, 2).setNumberFormat('0.0%;[Red]△ 0.0%;"-"');
  // 赤字強調 (利益_実績 < 0)
  var colProfAct = 13;  // M 列
  for (var r = 3; r < rLen; r++) {
    var pv = Number(out[r - 1][colProfAct - 1]);
    if (!isNaN(pv) && pv < 0) {
      sheet.getRange(r, 1, 1, cLen).setBackground('#F4CCCC').setFontColor('#CC0000');
    }
  }
  // 列幅
  sheet.setColumnWidth(1, 200);
  sheet.setColumnWidth(2, 120);
  for (var c = 3; c <= cLen; c++) sheet.setColumnWidth(c, 105);
  sheet.getDataRange().setFontFamily('BIZ UDGothic');
  // 余剰列・行削除
  if (sheet.getMaxColumns() > cLen) sheet.deleteColumns(cLen + 1, sheet.getMaxColumns() - cLen);
  if (sheet.getMaxRows() > rLen) sheet.deleteRows(rLen + 1, sheet.getMaxRows() - rLen);

  ss.toast('📋 PJ別予実ダッシュボードを更新しました', '完了', 5);
}
```

## Step 3: シートキー登録とメニュー登録

**`100_config/101_sys_config.js` L815-L817 付近** (`PJ_RAW` / `PJ_PL` / `PJ_MONTHLY` 直後) に 1 行追加:

```js
if (!existKeys.includes('PJ_VAR_DASH')) confSheet.appendRow(['PJ_VAR_DASH', '', '79_pj_variance_dashboard', 'PJ別予実ダッシュボード']);
```

**`000_infra/002_constants.js` L230-L239**(`📋 サイドバー: 📊 マート更新` カテゴリ)の `'プロジェクト別 採算'` 項目の直後に 1 行追加:

```js
{ label: '📋 プロジェクト別予実ダッシュボードを更新', funcName: 'updatePjVarianceDashboard', description: 'PJ 別の予算 vs 実績マート (79_pj_variance_dashboard) を再構築' },
```

## 制約(必ず遵守)

1. **列番号ハードコード禁止**: ヘッダー名ベースの `indexOf` / プロパティアクセスのみ使用(CLAUDE.md 規約)。
2. **科目マスタ未登録の自動推測禁止**: キーワード判定で `売上` / `費用` を推測しない。未登録は `Utils.logInfo` で警告のみ記録し集計から除外(CLAUDE.md 規約)。
3. **メニュー名造語禁止**: 実在するカテゴリ `📋 サイドバー: 📊 マート更新` 内に追加する(002_constants.js L230 を必ず実参照)。新規カテゴリを作らない(失敗パターン #18-#20)。
4. **既存ロジック重複実装禁止**: `loadPjMaster_` / `loadAcctMaster_`(420_project_profitability.js)を再利用。コピーして再実装しない(失敗パターン #1)。
5. **非加算指標の単純合算禁止**: 全社合計の利益率% は合算後の売上・費用から再計算(失敗パターン #1)。
6. **冪等性**: 先頭で `sheet.clear()` + `clearDataValidations()` を必ず実行。

## エッジケース(実装時に必ず網羅)

| # | 条件 | 表示値 |
|---|------|--------|
| 1 | 売上実績 = 0 のときの利益率%(実績) | `"-"` |
| 2 | 売上予算 = 0 のときの利益率%(予算) | `"-"` |
| 3 | 差異率% の分母(予算)= 0 | `"-"` |
| 4 | 費用予算 = 0 かつ費用実績 > 0 の予算消化率% | `"予算外支出"` |
| 5 | 費用予算 = 0 かつ費用実績 = 0 の予算消化率% | `"0%"` |
| 6 | PJ マスタ未登録の PJ 名 | `(PJマスタ未登録)` 行に集約 |
| 7 | 予算のみ存在する PJ | 実績 = 0、差異 = マイナス表示 |
| 8 | 実績のみ存在する PJ | 予算 = 0、予算消化率 = `"予算外支出"` |
| 9 | 全社合計行の利益率% | 全社売上・全社費用から再計算(単純合算不可) |
| 10 | 有効フラグ = FALSE の行 | 全処理でスキップ |
| 11 | 科目マスタ未登録の科目名 | `Utils.logInfo` に記録のみ、集計除外 |
| 14 | 利益_実績 < 0 の行 | 薄赤(`#f4cccc`) + 赤字(`#cc0000`)強調 |

## 動作確認手順

`npm run push:dev` でデプロイ後、以下を dev 環境で確認:

1. メニュー「📋 サイドバー: 📊 マート更新」配下に「📋 プロジェクト別予実ダッシュボードを更新」が表示されること。
2. 実行後に `79_pj_variance_dashboard` シートが新規生成(または再生成)されること。
3. シート B1・D1・F1 のプルダウンで PJ・期間粒度・基準年月が切り替わること(ただし現時点では選択値は次回実行時に参照されるだけで、選択直後には再計算しない。UX 的に再実行が必要なことを 1 行目ラベルで示唆する)。
4. `14_mst_project` に登録された有効 PJ すべてが表示されること。
5. `(PJマスタ未登録)` 行・`🏢 全社合計` 行が最終行に正しく表示されること。
6. 売上実績 = 0 の月を選んだ PJ の利益率%_実績 が `"-"` になること。
7. 費用予算 = 0 だが実績がある PJ の予算消化率% が `"予算外支出"` になること。
8. 利益_実績 < 0 の行が薄赤+赤字強調されること。
9. 既存 `buildProjectProfitability` メニュー実行が壊れていないこと(回帰確認)。

推奨実行モデル

工程推奨モデル理由
Step 1: Repository 追加(BudgetRepository / ProjectRepositoryHaiku既存 AccountRepository L304-L350 のパターン完全踏襲。判断要素なし
Step 2: ダッシュボード更新関数(updatePjVarianceDashboard_()Sonnet既存 loadPjMaster_ / loadAcctMaster_ との統合、収支区分stmt='BS' を組み合わせた集計ロジック、書式整備など中程度の判断が必要
Step 3: シートキー・メニュー登録Haiku既存パターンへの 1 行追加のみ

変更履歴

日付変更者変更内容
2026-04-20Claude Opus 4.7初版作成

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

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

======================================================================
あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者兼仕様書ライターです。
案件 F-14「プロジェクト別予実ダッシュボード」の開発仕様書を作成してください。
作成後は `docs/_config.json` の `nav` 配列の §E.5(FP&A・レポーティング)セクションにも必ず追記してください。

---

## Phase 1: 実行前調査(テキスト報告禁止・即座にツール実行)

以下のファイルを**この順序で**Readし、Phase 2の設計判断に必要な情報をすべて確定させること。
**Grepは「どこにあるか」の発見まで。「どう書くか」の判断は必ずReadで行う。** 関数名・シート名・メニュー名・定数名は実在する文字列のみ引用し、推測・造語は禁止(失敗パターン #18-#20)。

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

### 1-B: 既存PJ関連実装の確認(最重要・車輪の再発明防止)
- `400_domain/420_project_profitability.js` — PJ別収益性計算の既存ロジックを確認する
  - 確認ポイント: 予算・実績の集計方法、科目フィルタ(収支区分の判定値)、PJ集計キーの形式、公開関数名と行番号
  - **このファイルの実装を最大限再利用すること。重複するロジックを別関数として書かない**
  - 確認結果により「420に関数を追加する」か「新規ファイルに分離する」かを Phase 1 で決定する
- `600_report/601_datamart_ingest.js`(またはファイル名をGrepで特定した上でRead)
  - 確認ポイント: マートシート全クリア→再描画のパターン(`sheet.clear()` の呼び出し箇所と行番号)、ヘッダー書き込みと数値フォーマット適用のパターン

### 1-C: データ取得層の確認
- `200_data/202_repository.js`
  - 確認ポイント:
    - `JournalRepository` の `_getSheet`, `findAll`, `save`, `append` の4メソッド構成と行番号(新設Repositoryの雛形として使う)
    - `readSheetAsDtos_` は同ファイル冒頭の `@private` ヘルパーであることを確認。新設Repositoryの `findAll` はこれを内部で呼び出す形で実装する
    - `BudgetRepository` および `ProjectRepository` がすでに存在するか確認する(存在すれば新設不要)
- `000_infra/003_contracts.js`
  - 確認ポイント:
    - `BudgetDTO` の全フィールド(特に `対象年月`, `PJ名`, `予算金額`, `科目名`, `収支区分` の正確なフィールド名)
    - `JournalEntryDTO` の全フィールド(特に `発生日(P/L計上日)`, `PJ名`, `税抜金額_実績`, `科目名`, `収支区分` の正確なフィールド名)

### 1-D: システム設定・メニュー構造の確認(固有名詞の裏取り)
- `100_config/101_sys_config.js`
  - 確認ポイント:
    - `onOpen()` 内のメニュー定義を全文Read。「📊 FP&Aレポート」メニューの正確な文字列と `.addItem` の一覧を確認する(文字列が異なれば実在する正確な名称に差し替える)
    - `77_pj_raw` と `78_pj_pl` のシートキー登録パターン(`appendRow` の行形式)を確認し、`79_pj_variance_dashboard` の登録手順を確定する
    - DDL(`setupAllSchemas` 等)で `79_pj_variance_dashboard` のスキーマを追加する箇所があるか確認する
- `000_infra/002_constants.js`
  - 確認ポイント: `SHEET_DEFAULTS` に `79_pj_variance_dashboard` 相当のエントリが必要か確認する(マートシートは通常不要だが念のため)

### 1-E: プルダウン実装パターンの確認
- `docs/dev/dev_S-22_boundary_month_selector.md`
  - 確認ポイント: セルへのプルダウン設置方法(`setDataValidation` の引数形式)、選択値の読み取り方、実際の実装コード例

### 1-F: 実データ検証(MCPツールで確認)
以下をMCPツールで確認し、DDL定義と実データの乖離がないことを検証する:
- `14_mst_project` の列構造(PJ名列・有効フラグ列の正確なヘッダー名)
- `41_trn_budget` の `収支区分` 実データ値(「収入」「支出」等、実際に格納されている文字列)
- `42_trn_journal` の `収支区分` 実データ値と `PJ名` の代表的なフォーマット

---

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

**【絶対厳守】1回のツール呼び出しで全内容を出力しない。以下5つのStepに分割して実行する。**

出力先: `docs/dev/dev_F-14_pj_variance_dashboard.md`

セクション構成(必須・この順序で全セクションを含めること):
(省略・本仕様書の見出しと一致)

### Step 2-1: 骨格の作成(File Write, 〜20行)
上記セクション見出しのみ。本文は空で可。

### Step 2-2: 概要〜注意事項の追記(File Edit または Bash, 〜300行)
(省略・本仕様書の該当セクションを参照)

### Step 2-3a: エッジケース〜人間検討事項の追記(File Edit または Bash, 〜200行)
(省略・本仕様書の該当セクションを参照)

### Step 2-3b: 実装プロンプト〜変更履歴の追記(File Edit または Bash, 〜250行)
(省略・本仕様書の該当セクションを参照)

### Step 2-4: 仕様書作成プロンプトの記録(File Edit または Bash)
末尾に `<details>` で本プロンプトを記録。

---

## Phase 3: `_config.json` への追記・changelog更新・コミット

### 3-A: `docs/_config.json` への追記(必須)
`nav` 配列の **§E.5(FP&A・レポーティング)** セクションに追加:

{ "file": "dev/dev_F-14_pj_variance_dashboard.md", "title": "E.5.X F-14 プロジェクト別予実ダッシュボード" }

追記後、JSON構文が壊れていないことを確認する(`python3 -m json.tool docs/_config.json` 等)。

### 3-B: `docs/_internal/changelog.md` への追記
先頭行(ヘッダー直後)に追記:

| 2026-04-20 | dev_F-14_pj_variance_dashboard.md | 初版作成。PJ別予実ダッシュボード仕様書 |


### 3-C: コミット&プッシュ
git add → git commit → git push -u origin docs/dev-F-14

📌 取り込み時の注記 (2026-06-02 sub 復元)

本仕様書は旧 F-番号体系で作成され PR 未マージのまま孤立していたドラフトを、origin/docs/dev-* ブランチから内容無改変で復元し、案件ID のみ MAS 体系へ正規化したもの。status: Open(未実装)

⚠️ ファイル番号ドリフト: 本文「対象ファイル」が指す 600_report/610〜612_*.js は現行 main で 別機能に使用済み(610=投資分析/MAS-013・611=財務モデリング/MAS-010・612=採用sim/MAS-012)。 実装時にファイル番号の再割当が必要。

⚠️ 部分実装あり: 400_domain/420_project_profitability.js79_pj_monthly を出力済み。 本仕様の updatePjVarianceDashboard_() と機能重複の可能性 — 実装フェーズで突合・統合判断すること。