概要

項目内容
案件 IDMAS-045
カテゴリFP&A
優先度P3 ★★
対象ファイル(変更あり)400_domain/415_whatif_budget_transfer.js(新規・namespace WhatifBudgetTransfer
200_data/202_repository.js末尾に HeadcountRepository / SubscriptionRepository を新設InvoiceRepository.append パターン踏襲で層分離原則を維持)
400_domain/430_what_if_simulator.js(結果返却時に transferPayload 構築)
templates/what_if_sidebar.html(結果表示後に「📥 この条件で予算に反映」ボタン + 確認ダイアログ + クライアント setTimeout ハイライト
出力先22_bud_headcount / 23_bud_subscription(末尾行追加)、98_audit_log(転記ログ記録)
前提案件MAS-011 MVP(PR #315 + #320 polish、完了済)、MAS-044(テンプレートマスタ・テンプレ ID 記録列追加検討)
関連案件MAS-010(5 カ年モード確定時の複数年転記設計)、MAS-048(採用 TCO 後の予算化動線)

MAS-011 What-if シミュレーションで設定した値を、実際の予算シート(22_bud_headcount / 23_bud_subscription)に新規行として転記 する機能。「検討 → 実行」を 1 サイクルで完結させ、What-if で最終確定した採用計画・SaaS 導入計画を即時に予算へ反映できるようにする。

従来は「What-if で何度もシミュレーション → 最終案を手動で 22/23 タブに転記」という二重作業が発生していた。MAS-045 はこの転記を 1 クリックで実行し、Human-in-the-Loop(確認ダイアログ + 監査ログ)で安全性を担保する。

目的

  • What-if → 予算化の 1 アクション完結: 意思決定の流速向上、二重作業解消
  • MAS-010 連携ベースラインへの自動反映: 5 カ年モードで確定した採用計画をそのまま MAS-010 連携に取り込める
  • 監査性の担保: 98_audit_log に全転記を operation='CREATE' で記録、誰がいつどの条件で予算化したかをトレース可能に
  • MAS-044 テンプレ連携: 転記行に template_id 列を追加する拡張により、採用予算が「どのテンプレから派生したか」を追跡可能にする(オプション)

現在のコード

400_domain/430_what_if_simulator.js(MAS-011 MVP 実装、708 行)

MAS-011 MVP で以下の構造を持つ:

  • WhatIfSimulator.run(params) / getWhatIfDefaults() / getTemplateList()(MAS-044 で追加予定)
  • HC_ADD / SAAS_ADD ドライバー対応
  • ANNUAL / FIVE_YEAR モード
  • 戻り値: { baseline, scenario, delta, meta } (ANNUAL)/ { baseline, scenario, meta }(FIVE_YEAR)

本案件では戻り値に transferPayload を追加し、転記に必要な全フィールドを構造化して返すよう拡張する:

// WhatIfSimulator.run の戻り値に追加
transferPayload: {
  driver: 'HC_ADD' | 'SAAS_ADD',
  targetSheet: '22_bud_headcount' | '23_bud_subscription',
  rowData: {
    // HC_ADD の場合(BUD_HC スキーマ準拠)
    '管理ID': '',  // append 時に ID_PREFIX_MAP 参照で採番
    '氏名・ポジション': '新規採用(テンプレ: XXX)',
    '雇用形態': '正社員',
    '科目名': '給料手当',
    '月額給与・報酬': 500000,
    '開始年月': '2026-10',
    '採用エージェント費': 1000000,
    'PC等初期費用': 300000,
    // ... その他列
  },
  templateId: 'POS_001',  // F-44 連携時
  scenarioMeta: { /* 元シナリオの情報 */ }
}

200_data/202_repository.js の既存パターン

  • InvoiceRepository.save() / InvoiceRepository.append()(L155 起点): writeDtosToSheet_ / appendDtosToSheet_ を使用
  • appendDtosToSheet_(sheet, headers, dtos, lastRowCol)(L85): シート末尾に DTO を追記
  • ID 発番は Utils.generateId(prefix, digit, isDate)002_constants.js ID_PREFIX_MAP

22_bud_headcountBUD_HC schema L943、41 列)

  • 管理ID 列: EMP_0001 等(ID_PREFIX_MAPEMP_、4 桁、日付なし)
  • 有効フラグ / 雇用形態 / 科目名 / 月額給与・報酬 / 開始年月 / 終了年月 / 採用エージェント費 / PC等初期費用 等を転記対象に含む
  • デフォルト値: SHEET_DEFAULTS002_constants.js L76)で 雇用形態='正社員' / 月額給与・報酬=0 等がプリセット

23_bud_subscriptionBUD_SUBS schema L956、24 列)

  • 管理ID 列: SUB_0001 等(ID_PREFIX_MAPSUB_、4 桁、日付なし)
  • サービス・ツール名 / 費用科目 / 取引先名 / 契約形態 / 開始・契約年月 / 税抜金額_計画 / 決済ラグ(月)
  • デフォルト値: 契約形態='月額' / 税区分='対象外' / 決済ラグ(月)=1 / 利用ステータス='利用中'

98_audit_logLOG_AUDIT schema L962、10 列)

  • ヘッダー: 日時 / ユーザー / 操作種別 / 対象シート / 対象ID / 対象列 / 関数名 / 変更前値 / 変更後値 / 備考
  • Utils.auditLog(operation, targetSheet, targetId, targetCol, funcName, beforeValue, afterValue, note) で記録
  • operation 許容値: 'CREATE' | 'UPDATE' | 'DELETE' | 'CONFIRM' | 'CANCEL' | 'RUN' | 'MIGRATE'

templates/what_if_sidebar.html(MAS-011 MVP 実装、432 行)

  • 結果表示テーブル(<table class="result">)の下部に追加 UI を挿入
  • ボタン .btn-transfer(新規)を「📥 この条件で予算に反映」として配置
  • クリック時に google.script.run.transferWhatIfToBudget(transferPayload) を呼び出し

修正方針

Step 1: 計算エンジン側の拡張(430_what_if_simulator.js

WhatIfSimulator.run の戻り値に transferPayload を追加。HC_ADD / SAAS_ADD それぞれで、ユーザー入力(params.payload)から 22 / 23 タブのスキーマにマッピングした rowData を生成。

function _buildTransferPayload_(params) {
  if (params.driver === 'HC_ADD') {
    return {
      driver: 'HC_ADD',
      targetSheet: '22_bud_headcount',
      systemKey: 'BUD_HC',
      rowData: {
        '氏名・ポジション': '[What-if転記] ' + (params.payload.positionName || '新規採用'),
        '雇用形態': params.payload.employmentType || '正社員',
        '科目名': params.payload.accountName || '給料手当',
        '月額給与・報酬': Number(params.payload.monthlySalary) || 0,
        '開始年月': params.payload.startYm || Utils.nextYm(),
        '採用エージェント費': Number(params.payload.recruitFee) || 0,
        'PC等初期費用': Number(params.payload.pcCost) || 0,
        '有効フラグ': false,  // ★ デフォルト FALSE = 下書き扱い(人間検討事項 #1 準拠)
        '備考': 'F-45 転記(' + params.meta.runAtJst + '、シナリオ: ' + (params.payload.scenarioNote || '') + ')',
        // その他は 22_bud_headcount の SHEET_DEFAULTS で補完
      },
      templateId: params.payload.templateId || null,
    };
  } else if (params.driver === 'SAAS_ADD') {
    return {
      driver: 'SAAS_ADD',
      targetSheet: '23_bud_subscription',
      systemKey: 'BUD_SUBS',
      rowData: {
        'サービス・ツール名': '[What-if転記] ' + (params.payload.serviceName || '新規 SaaS'),
        '費用科目': params.payload.accountName || '通信費',
        '契約形態': params.payload.contractType || '月額',
        '開始・契約年月': params.payload.startYm || Utils.nextYm(),
        '税抜金額_計画': Number(params.payload.monthlyAmount) || 0,
        '決済ラグ(月)': Number(params.payload.paymentLagMonths) || 1,
        '有効フラグ': false,
        '備考': 'F-45 転記(' + params.meta.runAtJst + ')',
      },
      templateId: params.payload.templateId || null,
    };
  }
  return null;
}

Step 2: Repository 新設(202_repository.js 末尾)

Gemini レビュー指摘(🔴 Critical)を反映: Domain 層(415_whatif_budget_transfer.js)から直接シートに書き込む方針を撤回し、200_data/202_repository.jsHeadcountRepository / SubscriptionRepository を新設 して層分離原則を維持する。既存 InvoiceRepository.append(L155 起点)のパターンに完全準拠。

// =====================================================================
// HeadcountRepository — 22_bud_headcount (F-45 追記専用)
// =====================================================================

var HeadcountRepository = {
  _getSheet: function() {
    return Utils.getSheetByKey('BUD_HC', '22_bud_headcount');
  },
  findAll: function() {
    return readSheetAsDtos_(HeadcountRepository._getSheet());
  },
  /**
   * 末尾行に DTO を追記。ID は呼出側で採番済の想定(Utils.generateId)
   * @param {Object[]} dtos DTO 配列(各要素はヘッダー名キー → 値)
   * @returns {{ appended: number }}
   */
  append: function(dtos) {
    var sheet = HeadcountRepository._getSheet();
    var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
      .map(function(h) { return String(h).trim(); });
    return appendDtosToSheet_(sheet, headers, dtos, 1);  // 列 B = ID 列で最終行判定
  },
};

// =====================================================================
// SubscriptionRepository — 23_bud_subscription (F-45 追記専用)
// =====================================================================

var SubscriptionRepository = {
  _getSheet: function() {
    return Utils.getSheetByKey('BUD_SUBS', '23_bud_subscription');
  },
  findAll: function() {
    return readSheetAsDtos_(SubscriptionRepository._getSheet());
  },
  append: function(dtos) {
    var sheet = SubscriptionRepository._getSheet();
    var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
      .map(function(h) { return String(h).trim(); });
    return appendDtosToSheet_(sheet, headers, dtos, 1);
  },
};

設計原則:

  • appendDtosToSheet_(sheet, headers, dtos, lastRowCol) は既存 202_repository.js L85 の private ヘルパで、第 4 引数 lastRowCol=1 は列 B(0-indexed)で最終行判定する意味(CLAUDE.md コーディング規約「列 A のチェックボックス回避」準拠)
  • DTO はヘッダー名キー → 値のオブジェクト。appendDtosToSheet_ 内で headers 順に配列化
  • ID 採番は呼出側(WhatifBudgetTransfer)で Utils.generateId 使用、DTO の '管理ID' キーに格納済の前提

Step 3: 転記エンジン(415_whatif_budget_transfer.js 新規)

var WhatifBudgetTransfer = (function() {
  /**
   * 公開 API: サイドバーから呼ばれる転記関数
   * @param {Object} transferPayload WhatIfSimulator.run の戻り値の transferPayload
   * @returns {{ success, transferId, targetSheet, rowNumber } | { error }}
   */
  function transferWhatIfToBudget(transferPayload) {
    var FUNC = 'transferWhatIfToBudget';
    var lock = LockService.getScriptLock();
    if (!lock.tryLock(5000)) {
      return { error: '別の処理が実行中です。しばらく待ってから再実行してください。' };
    }
    try {
      // バリデーション
      if (!transferPayload || !transferPayload.targetSheet || !transferPayload.rowData) {
        return { error: '転記データが不正です。What-if シミュレーションを再実行してください。' };
      }

      // Repository 選択(driver に応じて HeadcountRepository / SubscriptionRepository)
      var repo;
      if (transferPayload.driver === 'HC_ADD') {
        repo = HeadcountRepository;
      } else if (transferPayload.driver === 'SAAS_ADD') {
        repo = SubscriptionRepository;
      } else {
        return { error: '未対応の driver: ' + transferPayload.driver };
      }

      // ID 採番(ID_PREFIX_MAP から prefix を取得)
      var prefixConfig = Utils.getIdPrefixConfig(transferPayload.targetSheet);
      var newId = Utils.generateId(
        prefixConfig.prefix,
        prefixConfig.digit,
        prefixConfig.isDate,
        repo._getSheet()
      );

      // DTO 構築: rowData + 採番 ID + SHEET_DEFAULTS フォールバック
      var dto = {};
      dto['管理ID'] = newId;
      Object.keys(transferPayload.rowData).forEach(function(key) {
        dto[key] = transferPayload.rowData[key];
      });

      // Repository 経由で末尾追記(層分離原則維持)
      var result = repo.append([dto]);

      // 監査ログ記録
      Utils.auditLog(
        'CREATE',
        transferPayload.targetSheet,
        newId,
        '',
        FUNC,
        '',
        JSON.stringify(transferPayload.rowData).slice(0, 500),
        'F-45 What-if 転記(driver=' + transferPayload.driver + ', templateId=' + (transferPayload.templateId || 'none') + ')'
      );

      // Toast 通知(Utils.toastResult は 004_utils.js L520 に実在)
      Utils.toastResult(FUNC, '転記完了: ' + newId + ' を ' + transferPayload.targetSheet + ' に追加しました', 5);

      return {
        success: true,
        transferId: newId,
        targetSheet: transferPayload.targetSheet,
        rowNumber: result.lastRow || null,  // Repository から取得、なければ null
      };
    } catch (e) {
      Utils.logError(FUNC, e, 'payload=' + JSON.stringify(transferPayload).slice(0, 500));
      return { error: e.message || String(e) };
    } finally {
      try { lock.releaseLock(); } catch (_) {}
    }
  }

  return {
    transferWhatIfToBudget: transferWhatIfToBudget,
  };
})();

// グローバル公開(サイドバーから google.script.run で呼べるように)
function transferWhatIfToBudget(transferPayload) {
  return WhatifBudgetTransfer.transferWhatIfToBudget(transferPayload);
}

Gemini レビュー対応:

  • ❌ 削除: Utils.findLastRowByCol(sheet, 2)(実在しない架空関数) → Repository の append 経由で appendDtosToSheet_ が内部で処理
  • ❌ 削除: Utilities.sleep(3000) サーバー側ハイライト(UI フリーズの原因) → Step 4 でクライアント setTimeout 方式に変更
  • ✅ 維持: Utils.toastResult(004_utils.js L520 に実在、Gemini 誤指摘)
  • ✅ 追加: Repository 選択分岐(driver 対称性、#25 準拠)

Step 4: サイドバー UI 拡張(what_if_sidebar.html

結果表示後に「📥 この条件で予算に反映」ボタンを追加。ハイライトはクライアント setTimeout で実装(サーバー側 Utilities.sleep による UI フリーズを回避、Gemini レビュー 🟡 Major 指摘を反映):

<!-- 結果表示テーブルの下 -->
<div id="transfer-section" style="display: none; margin-top: 14px; padding: 10px; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
  <div style="font-size: 11px; font-weight: 700; margin-bottom: 6px;">📥 この条件で予算に反映</div>
  <div class="hint" style="margin-bottom: 8px;">
    ⚠️ 転記後の有効フラグは <strong>FALSE(下書き)</strong> になります。確認後に手動で TRUE にしてください。
  </div>
  <button class="btn-run" id="btn-transfer" style="background: #38761d;">📥 {{driver_label}} として予算に反映</button>
  <div id="transfer-result" style="margin-top: 8px; font-size: 11px;"></div>
</div>

<script>
document.getElementById('btn-transfer').addEventListener('click', function() {
  var btn = this;
  if (!confirm('シミュレーション条件を ' + lastResult.transferPayload.targetSheet + ' に転記します。よろしいですか?\n\n転記後の有効フラグは FALSE(下書き)です。')) return;
  btn.disabled = true;
  setTimeout(function() { btn.disabled = false; }, 5000);  // 5 秒無効化で連続クリック防止
  google.script.run
    .withSuccessHandler(function(result) {
      if (result.error) {
        document.getElementById('transfer-result').innerHTML = '<span style="color:#cc0000;">❌ ' + result.error + '</span>';
        return;
      }
      // クライアントサイド成功通知(サーバー側 Utilities.sleep は使わない)
      var html = '<span style="color:#274e13; background:#d9ead3; padding:4px 8px; border-radius:3px;">✅ 転記完了: ' + result.transferId + ' → ' + result.targetSheet + '</span>';
      var resultDiv = document.getElementById('transfer-result');
      resultDiv.innerHTML = html;
      // 一時ハイライト (3 秒) をクライアント setTimeout で実装
      setTimeout(function() {
        resultDiv.style.transition = 'opacity 1s';
        resultDiv.style.opacity = '0.5';
      }, 3000);
    })
    .withFailureHandler(function(err) {
      document.getElementById('transfer-result').innerHTML = '<span style="color:#cc0000;">❌ ' + err.message + '</span>';
    })
    .transferWhatIfToBudget(lastResult.transferPayload);
});
</script>

シート上のハイライト(転記行の黄色表示)は GAS 側では実装せず、ユーザーが 22_bud_headcount / 23_bud_subscription を開いた際に条件付き書式で「有効フラグ=FALSE 行を黄色表示」を別途設定する運用(setupAllSchemas の条件付き書式として追加する拡張余地あり、Phase 2 検討事項 #3 参照)。

シミュレーション結果表示後に #transfer-sectiondisplay: block に変更。lastResult.transferPayloadWhatIfSimulator.run の戻り値から取得。

Step 5: 5 カ年モード対応(Phase 2 検討事項、MVP では見送り)

FIVE_YEAR モードで確定した採用計画(例: FY1 に 1 名採用 + FY2 にもう 1 名採用)を複数行転記する場合、MVP では「FY1 の 1 名のみ転記」に限定し、残りは手動追加する運用にする。

将来拡張として:

  • transferPayload.multiRowData: [row1, row2, ...] で配列化
  • ユーザー確認時に「3 行転記します。OK?」と表示

影響範囲

ファイル変更種別内容
400_domain/415_whatif_budget_transfer.js新規namespace WhatifBudgetTransfer(約 150 行、Repository 経由化で軽量化)
200_data/202_repository.js追加のみ末尾に HeadcountRepository / SubscriptionRepository 新設(約 60 行、Gemini レビュー指摘対応)
400_domain/430_what_if_simulator.js変更_buildTransferPayload_ 追加 + run 戻り値拡張(約 40 行)
templates/what_if_sidebar.html変更転記ボタン + 確認ダイアログ + クライアント setTimeout ハイライト(約 60 行)
docs/_config.json追加のみnav 登録

既存動作への影響

  • MAS-011 MVP: 完全後方互換(transferPayload 未使用時は転記ボタン非表示)
  • MAS-044 テンプレートマスタ: 本案件の transferPayload.templateId で連携
  • 98_audit_log: operation='CREATE' / targetSheet='22_bud_headcount' 等で既存ログ体系に合流
  • MAS-048 採用 TCO: 将来 MAS-048 サイドバーにも転記ボタンを追加する拡張余地(MAS-048 完成後)

運用・デプロイ手順

  1. npm run push:dev → MAS-011 What-if サイドバー起動 → シミュレーション実行
  2. 結果表示後に「📥 この条件で予算に反映」ボタンが表示されることを確認
  3. クリック → 確認ダイアログ → OK → 22_bud_headcount 末尾行に 有効フラグ=FALSE で追加
  4. 追加行が黄色ハイライト表示(3 秒)→ 元色に戻る
  5. 98_audit_logoperation=CREATE ログが記録される
  6. 転記行の有効フラグを手動で TRUE に変更 → 予算に反映
  7. npm run push:prod → 同手順

注意事項

  • ⚠️ failure_patterns #18-#20(命名造語禁止): transferWhatIfToBudget / WhatifBudgetTransfer / シート名 22_bud_headcount / 23_bud_subscription / システムキー BUD_HC / BUD_SUBS を記述前に再確認
  • ⚠️ failure_patterns #24(ラベル解決脆弱性): 転記時に headers.indexOf('管理ID') で列位置を動的取得。MATCH 数式は使わない
  • ⚠️ CLAUDE.md コーディング規約: 列 B(ID 列)で最終行判定(既存 202_repository.js L68 の private 関数 findLastRow_ を Repository 内部で使用。appendDtosToSheet_ の第 4 引数 lastRowCol=1 で指定)。列 A のチェックボックス回避
  • ⚠️ Human-in-the-Loop: 転記行の 有効フラグ=FALSE(下書き)で追加 → ユーザーが確認後に手動で TRUE に変更する 2 段承認フロー
  • ⚠️ MCP add_rows は使用禁止: シート先頭追加になるため、getRange().setValues() で末尾追加(CLAUDE.md 準拠)
  • ⚠️ 5 カ年モードの多行転記: MVP は FY1 の 1 名のみ。複数年は手動追加 or Phase 2 拡張
  • ⚠️ ID 採番の衝突: Utils.generateId(prefix, digit, isDate, sheet) で現行最大 + 1 を採番。LockService で排他制御必須
  • ⚠️ SHEET_DEFAULTS との整合: 転記時にデフォルト値適用を忘れると必須列欠損になる。_getDefaultValue で補完
  • ⚠️ 監査ログのペイロード長: afterValueJSON.stringify(rowData) を入れるが、500 文字超の場合は truncate(98_audit_log の列幅考慮)

エッジケース

#条件検知方法期待される挙動ログ出力
1transferPayload が null引数バリデーションエラー返却「転記データが不正」Utils.persistLog('WARN', FUNC, 'null payload')
2transferPayload.driver が未対応(例: HC_REMOVE)driver バリデーションエラー返却「未対応ドライバー」Utils.persistLog('WARN', FUNC, 'unsupported driver: ' + driver)
3対象シートが存在しないUtils.getSheetByKey が nullエラー返却「シートが見つかりません」Utils.logError(FUNC, new Error('sheet not found'))
4ID 採番衝突(LockService.tryLock 失敗)5 秒タイムアウト「別の処理が実行中」返却Utils.persistLog('WARN', ...)
5ID 採番時の最大値計算失敗(シート空)Utils.generateId が初回 ID EMP_0001 を返却正常系として処理続行ログ出力なし
6必須列(例: 月額給与・報酬)が空欄rowData に未含有SHEET_DEFAULTS 経由で 0 等デフォルト値補完Utils.logInfo(FUNC, 'default applied for ' + key)
7開始年月 が過去日付バリデーション警告表示(「過去日付です」)+ 転記続行(ユーザー判断)Utils.persistLog('WARN', ...)
8金額がマイナス値バリデーション警告表示 + 転記続行Utils.persistLog('WARN', ...)
9MAS-044 未実装時に templateId 指定templateId 参照無視して転記続行(監査ログには templateId=null 記録)ログ出力なし
10監査ログ記録失敗Utils.auditLog が throw転記は成功扱いとするが警告表示「監査ログ記録に失敗(要確認)」Utils.logError(FUNC, e, 'auditLog failed')
11同一シナリオを連続 2 回転記UI ボタンダブルクリック2 行が末尾に追加される(重複検知しない MVP 仕様、人間検討事項 #3)ログ出力なし
12転記後の視覚フィードバッククライアント setTimeout で実装(サーバー側 Utilities.sleep は使わない)「✅ 転記完了」メッセージ表示 → 3 秒後に opacity 0.5 でフェードアウト。UI フリーズなし
13大量転記(5 カ年で 5 行)MVP はサポートせずUI で「1 行のみ転記可能」警告Utils.persistLog('WARN', ...)
14Web アプリから実行SpreadsheetApp.getUi() 不要(サイドバー経由のみ)問題なし(サイドバー起動自体が SpreadsheetApp コンテキスト必要)
15転記した行を手動削除した後に再転記ID 採番は常に最大値 + 1新規 ID が採番される(元 ID は欠番として残る)ログ出力なし(正常系)

冪等性・再実行の設計

転記は追記方式冪等性は担保しない(同じ操作を 2 回やれば 2 行追加される)。ユーザーが誤って 2 回押さないよう、ボタンクリック後は 5 秒間無効化する UX を実装。

Human-in-the-Loop(詳細)

  • 転記前: 確認ダイアログ(「このシナリオを 22_bud_headcount に転記します」)
  • 転記後: 有効フラグ=FALSE(下書き)で追加 → ユーザーが確認後に手動で TRUE へ変更
  • 黄色ハイライト(3 秒)で視覚的に転記位置を明示
  • 98_audit_logoperation=CREATE ログ記録 → 後日の監査追跡可能
  • 取り消しメニュー: MVP では実装せず、手動で 有効フラグ=FALSE のまま放置 or 手動削除(Phase 2 で「取消」メニュー検討)

テスト要件

テスト関数合格基準
test_transferWhatIfToBudget_HC_ADDHC_ADD → 22_bud_headcount 末尾行追加、ID が EMP_XXXX 形式、有効フラグ FALSE
test_transferWhatIfToBudget_SAAS_ADDSAAS_ADD → 23_bud_subscription 末尾行追加
test_transferWhatIfToBudget_nullPayloadnull 入力 → { error } 返却
test_transferWhatIfToBudget_unsupportedDriverHC_REMOVE → エラー返却
test_transferWhatIfToBudget_auditLog転記後 98_audit_log に CREATE 行記録
test_transferWhatIfToBudget_defaultValuesrowData 欠損列が SHEET_DEFAULTS で補完される
test_transferWhatIfToBudget_idGeneration連続 3 回転記で ID が連番 EMP_0001 / 0002 / 0003

実データ検証

  1. 22_bud_headcount / 23_bud_subscription の実ヘッダー行: BUD_HC / BUD_SUBS schemas との一致確認
  2. ID_PREFIX_MAPEMP_ / SUB_ エントリ: 002_constants.js L93 起点で存在確認
  3. 98_audit_logLOG_AUDIT schema: L962 / 10 列の確認
  4. MAS-011 MVP サイドバー UI: 結果テーブル下部に要素を追加する位置の確定
  5. Utils.generateId の動作: 既存 22_bud_headcountEMP_0001EMP_0050 が存在する場合、次 ID が EMP_0051 になる
  6. LockService の排他制御: 同時実行で片方エラー返却の動作確認

関連ドキュメント

仕様書・ドキュメント関連箇所
dev_mas-011_what_if_simulation.mdMAS-011 MVP 完成済。本案件は MAS-011 の結果を予算へ転記する拡張
dev_mas-044_hc_template_master.mdテンプレート ID を転記行に記録する連携。MAS-044 完成後に transferPayload.templateId が有効化
dev_mas-048_hiring_tco_bep_simulator.md採用 TCO 試算後の予算化動線。MAS-048 サイドバーから MAS-045 への連携は Phase 2
dev_mas-010_financial_modeling.md5 カ年モード確定時の複数年転記設計。MVP は FY1 のみ、Phase 2 で多行転記拡張
dev_mas-179_audit_trail.md監査証跡。operation='CREATE' で統一ログ記録
CLAUDE.mdコーディング規約(ヘッダー名ベース列参照・列 B で最終行判定・MCP add_rows 使用禁止)

人間が検討すべき事項

  1. 転記後の 有効フラグ: MVP は FALSE(下書き)。ユーザー判断で TRUE 即反映も選択肢 → UI オプション追加検討
  2. 監査ログのフォーマット: afterValueJSON.stringify(rowData) を全量入れるか、主要列のみに絞るか。500 文字上限対応
  3. 連続クリック防止: 5 秒無効化 UX で十分か、サーバー側の冪等性チェック(同一シナリオ 1 時間以内は拒否等)まで実装するか
  4. 5 カ年モードの複数年転記: MVP は FY1 のみ。Phase 2 で multiRowData: [] 実装時の UX(「3 行転記します」確認)
  5. MAS-044 テンプレ ID 記録列: 22_bud_headcount / 23_bud_subscriptiontemplate_id 列を追加するか、備考列に埋め込むか。DDL 変更の影響範囲検討
  6. 取消機能: 有効フラグ=FALSE 運用で事実上の取消。専用「取消」メニュー新設 vs 現状維持の判断
  7. MAS-048 からの転記連携: MAS-048 採用 TCO 試算結果から直接 MAS-045 に渡す動線。UI 統合度の判断
  8. MAS-010 連携ベースラインへの自動反映タイミング: 転記後すぐ MAS-010 更新 or 次回 MAS-010 実行時に反映
  9. エラー時のロールバック: 転記途中でエラーが発生(例: 監査ログ失敗)した場合、末尾行を削除する自動ロールバック vs 手動修正運用
  10. 多言語対応: 英語 UI 対応時の「下書き」「転記」の訳語(一般 UI コピー規約との整合)

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

あなたは GAS 会計システム (bizlp-gas-accounting) のシニア開発者です。
案件 MAS-045「What-if 結果を 22_bud_headcount / 23_bud_subscription へ転記する機能」を以下の 4 Step で実装してください。

## 実行前タスク(必須・5 件)
1. `400_domain/430_what_if_simulator.js`(MAS-011 MVP 708 行)を Read し `run` 関数の戻り値構造を確認
2. `200_data/202_repository.js` の `appendDtosToSheet_`(L85)と `InvoiceRepository.append`(L155 起点)を Read
3. `100_config/101_sys_config.js` の `BUD_HC`(L943)/ `BUD_SUBS`(L956)/ `LOG_AUDIT`(L962)schemas を確認
4. `000_infra/002_constants.js` の `ID_PREFIX_MAP`(L93 起点)/ `SHEET_DEFAULTS`(L73 起点)を確認
5. `templates/what_if_sidebar.html`(432 行)を Read し結果表示セクション直後への挿入位置を確定

## Step 1: `430_what_if_simulator.js` 拡張(推奨モデル: Sonnet)
- `_buildTransferPayload_(params)` 新設(HC_ADD / SAAS_ADD 別に rowData 構造化)
- `run` 戻り値に `transferPayload` フィールド追加(後方互換:ANNUAL / FIVE_YEAR 両対応)
- `有効フラグ=FALSE` デフォルト、`氏名・ポジション` に `[What-if転記]` プレフィックス

## Step 2: `202_repository.js` 末尾に `HeadcountRepository` / `SubscriptionRepository` 新設(推奨モデル: Haiku)
- 既存 `InvoiceRepository.append`(L155 起点)パターン完全準拠
- `_getSheet()` / `findAll()` / `append(dtos)` の 3 メソッド
- `append` は `appendDtosToSheet_(sheet, headers, dtos, 1)` を呼ぶ(列番号 1 = 列 B = ID 列)
- システムキー: `BUD_HC` / `BUD_SUBS`(既存定義)

## Step 3: `415_whatif_budget_transfer.js` 新設(推奨モデル: Sonnet)
- namespace `WhatifBudgetTransfer` を IIFE で定義
- `transferWhatIfToBudget(transferPayload)` 公開関数
- `LockService.tryLock(5000)` で排他制御、`try/finally` で解放
- ID 採番: `Utils.generateId` + `ID_PREFIX_MAP` 参照
- driver 分岐で `HeadcountRepository` or `SubscriptionRepository` を選択(Step 2 で新設)
- Repository 経由で末尾追記(層分離維持、`Utils.findLastRowByCol` 等の架空関数は使わない)
- `Utils.toastResult(FUNC, msg, 5)` で Toast 通知(`004_utils.js` L520 実在)
- `Utils.auditLog('CREATE', ...)` で監査記録
- グローバル関数 `transferWhatIfToBudget(payload)` を公開(サイドバー `google.script.run` 経由)

## Step 4: サイドバー UI 拡張(`what_if_sidebar.html`、推奨モデル: Sonnet)
- 結果表示テーブル直後に `<div id="transfer-section">` 追加
- ボタン `📥 この条件で予算に反映`(`.btn-run` スタイル、緑背景)
- `<div id="transfer-result">` クライアント側の結果表示領域
- クリック時の確認ダイアログ: `'このシナリオを ' + targetSheet + ' に転記します。よろしいですか?\n\n転記後の有効フラグは FALSE(下書き)です。'`
- `google.script.run.transferWhatIfToBudget(lastResult.transferPayload)` 呼出
- 成功時: `#transfer-result` に緑バック ✅ メッセージ → 3 秒後にクライアント `setTimeout` で opacity 0.5 にフェード(サーバー側 `Utilities.sleep` 不使用、UI フリーズ回避)
- エラー時: `#transfer-result` に赤テキスト ❌ メッセージ
- ボタン連続クリック防止: クリック後 5 秒間 `disabled = true`、5 秒後にクライアント `setTimeout` で解除

## Step 5: テスト追加(`900_test/901_test_runner.js`、推奨モデル: Sonnet)
- 7 テスト関数(HC_ADD / SAAS_ADD / null payload / 未対応 driver / auditLog / デフォルト補完 / ID 連番)
- 合格基準: 転記後に `22_bud_headcount` 末尾行追加、`98_audit_log` に CREATE 行、有効フラグ FALSE

## 制約
- `appsscript.json` の `oauthScopes` を変更しない
- 列番号ハードコード禁止(`headers.indexOf` で動的取得)
- MCP `add_rows` 使用禁止(シート先頭追加になる、CLAUDE.md 準拠)
- 列 B(ID 列)で最終行判定(列 A のチェックボックス回避)
- MAS-011 MVP の既存動作を破壊しない(`transferPayload` 未使用時は転記ボタン非表示)
- `有効フラグ=FALSE` デフォルトで転記(Human-in-the-Loop 準拠)
- 5 カ年モードは FY1 のみ転記(MVP 仕様、Phase 2 で多行拡張)

## 動作確認
1. What-if サイドバー起動 → HC_ADD シミュレーション実行
2. 結果表示後に「📥 この条件で予算に反映」ボタン表示確認
3. クリック → 確認ダイアログ → OK
4. `22_bud_headcount` 末尾行に `EMP_XXXX` で追加される
5. 黄色ハイライト 3 秒 → 元色復帰
6. `98_audit_log` に CREATE ログ記録確認
7. SAAS_ADD でも同様の動作確認
8. `23_bud_subscription` 末尾行に `SUB_XXXX` で追加される
9. 有効フラグ FALSE で追加されていることを確認
10. 5 カ年モードでは FY1 のみ転記される(Phase 2 で複数年対応予定のメッセージ表示)

推奨実行モデル

工程推奨モデル理由
Step 1: 430_what_if_simulator.js 拡張Claude Sonnet 4.6後方互換維持の判断 + スキーマ転換ロジック
Step 2: HeadcountRepository / SubscriptionRepository 新設Claude Haiku 4.5InvoiceRepository.append パターンの定型的な複製、判断要素なし
Step 3: 415_whatif_budget_transfer.js 新設Claude Sonnet 4.6Repository 経由の書込 + ID 採番 + 監査ログ + driver 分岐
Step 4: サイドバー UI 拡張Claude Sonnet 4.6HTML + JS + google.script.run + クライアント setTimeout ハイライト
Step 5: テスト追加Claude Sonnet 4.6合格基準の統合検証(追記・監査ログ・ID 連番)

変更履歴

日付変更内容
2026-04-23初版作成。MAS-011 MVP(PR #315 + #320 polish)の後継として、What-if シミュレーション結果を 22_bud_headcount / 23_bud_subscription に末尾行追加する仕様。namespace WhatifBudgetTransfer400_domain/415_whatif_budget_transfer.js 新規)で transferWhatIfToBudget(payload) 公開関数を提供。430_what_if_simulator.jsrun 戻り値に transferPayload を追加。Human-in-the-Loop 準拠: 確認ダイアログ + 有効フラグ=FALSE(下書き)デフォルト + 98_audit_logoperation='CREATE' 記録。ID 採番は Utils.generateId + ID_PREFIX_MAPLockService.tryLock(5000) で排他制御。5 カ年モードは FY1 のみ転記(MVP 仕様)。MAS-044 完成後は transferPayload.templateId 連携でテンプレ派生追跡可能。エッジケース 15 件・人間検討事項 10 件
2026-04-23Gemini レビュー(PR #335)指摘を反映した改訂。🔴 Critical 対応: (1) 架空関数 Utils.findLastRowByCol を削除し、200_data/202_repository.js 末尾に HeadcountRepository / SubscriptionRepository を新設(InvoiceRepository.append パターン準拠・appendDtosToSheet_(sheet, headers, dtos, 1) 使用)で層分離原則を維持。(2) Domain 層から直接シート書き込みする方針を撤回し、Repository 経由に変更。🟡 Major 対応: サーバー側 Utilities.sleep(3000) ハイライトを削除し、クライアント setTimeout フェードに変更(UI フリーズ回避)。Step 構成を 4 → 5 Step に再編(Repository 新設を独立 Step 2 に)。推奨実行モデルも 5 工程に更新(Step 2 Haiku / 他 4 工程 Sonnet)。誤指摘Utils.toastResult を架空と判定 → L520 に実在)は訂正せず現状維持