概要

項目内容
案件IDMAS-085
カテゴリPJ別損益
PhaseP1
優先度★★★
所要時間1時間
対象ファイル400_domain/402_project_profitability.js
参照ファイル600_report/607_datamart_fs.js(読み取りのみ)

目的

78_pj_pl(PJ別損益)の全社合計営業利益と、92_fs_pl(決算書P/L)の営業利益が一致することを自動検証し、不一致時にユーザーへ警告する。両タブは独立した計算ロジックで生成されるため、乖離はデータ不整合やロジックバグの兆候を示す。

背景

2つのタブの生成フロー

buildBudgetTrendDataMart()          buildProjectProfitability()
  ├→ dmCalcPl_(ctx)                   ├→ 32_wrk_invoice 独自読み込み
  ├→ sectionTotalsPl['op_profit']     ├→ PJ別集計 + 共通費配賦
  ├→ dmBuildFsPl_(ctx, sheet)         ├→ 全社合計 = Σ PJ営業利益
  └→ 92_fs_pl に出力                  └→ 78_pj_pl に出力
  • 92_fs_pl: sectionTotalsPl['op_profit'][0](通期合計)を直接出力
  • 78_pj_pl: 各PJの営業利益を合算して全社合計を算出(最終列)
  • 独立実行: 92はマート更新時、78はメニューから手動実行。同じctxを共有しない

不一致が発生し得る原因

原因説明
INVステータスフィルタの差異78は「承認済/部分決済/決済完了」、92はctxの前処理に依存
PJ未割当の費用78でPJ割当されていないINVが集計漏れする可能性
配賦元合計の扱い78の全社合計はPJ列のみ合算し、B列(配賦元合計)を含めない
丸め誤差配賦計算で端数が発生し、PJ合計と元の合計が微小に乖離
タイミング差92と78を別タイミングで実行した場合、元データが変わっている

現在のコード

78タブ: 全社合計列の算出(402_project_profitability.js L691-698)

// 全社合計列を再計算 (B列=配賦元合計は表示のみ、PJ列のみ合算)
for (var pr = 2; pr < plOut.length; pr++) {
  var rowSum = 0;
  for (var pc = 2; pc < plOut[pr].length - 1; pc++) {
    rowSum += (Number(plOut[pr][pc]) || 0);
  }
  plOut[pr][plOut[pr].length - 1] = rowSum;
}

78タブ: 営業利益行のラベル(L548)

{ id: 'op_profit', name: '✨ PJ営業利益', type: 'profit', ... }

92タブ: 営業利益の出力(607_datamart_fs.js L44-46)

} else if (sec.type === 'profit') {
  var profitTotal = sectionTotalsPl[sec.id] ? sectionTotalsPl[sec.id][0] : 0;
  out.push([sec.name, profitTotal]);
}

営業利益のラベルは '✨ 営業利益'PL_SECTIONSop_profit セクション名)。

78タブ: 完了通知(L761)

ss.toast('💰 プロジェクト別 採算(限界利益) + 共通費配賦を生成しました!', '完了', 8);

修正方針

buildProjectProfitability() の完了通知(L761)の直前に、92タブとの整合性チェックを追加する。

実装ロジック

// S-13: 78タブと92タブの営業利益整合性チェック
var sheet92 = ss.getSheetByName('92_fs_pl');
if (sheet92) {
  // 78タブ: plOut から営業利益行の全社合計を取得
  var opProfit78 = 0;
  for (var cr = 0; cr < plOut.length; cr++) {
    if (String(plOut[cr][0]).includes('PJ営業利益')) {
      opProfit78 = Number(plOut[cr][plOut[cr].length - 1]) || 0;
      break;
    }
  }
  // 92タブ: シートから営業利益行を検索
  var data92 = sheet92.getDataRange().getValues();
  var opProfit92 = 0;
  for (var cr2 = 0; cr2 < data92.length; cr2++) {
    if (String(data92[cr2][0]).includes('営業利益') && !String(data92[cr2][0]).includes('経常')) {
      opProfit92 = Number(data92[cr2][1]) || 0;
      break;
    }
  }
  // 差額チェック(丸め誤差を許容: ±1円)
  var diff = Math.abs(opProfit78 - opProfit92);
  if (diff > 1) {
    var msg = '⚠️ 78タブと92タブの営業利益に差異があります\n'
      + '78_pj_pl 全社合計: ¥' + opProfit78.toLocaleString() + '\n'
      + '92_fs_pl 営業利益: ¥' + opProfit92.toLocaleString() + '\n'
      + '差額: ¥' + diff.toLocaleString();
    SpreadsheetApp.getUi().alert('整合性チェック (S-13)', msg,
      SpreadsheetApp.getUi().ButtonSet.OK);
    Utils.logInfo(FUNC, msg.replace(/\n/g, ' | '));
  }
}

警告表示の方式

方式採用理由
alert(モーダルダイアログ)差異は深刻な問題の兆候。ユーザーが見逃さないよう確認を強制
toast(画面下部)数秒で消えるため、見逃すリスクがある

誤差閾値

閾値理由
±1円配賦計算の丸め誤差を許容。2円以上の差は実質的なデータ不整合

92タブが存在しない場合

buildProjectProfitability()buildBudgetTrendDataMart() とは独立して実行可能。92タブが未生成の場合はチェックをスキップする(sheet92がnullならif文で素通り)。

影響範囲

  • 変更ファイル: 402_project_profitability.jsbuildProjectProfitability() 末尾に約20行追加
  • 既存ロジックへの影響: なし(チェックは出力完了後に実行。plOutの値を読むのみ)
  • 実行タイミング: メニュー「プロジェクト別損益」実行時、78タブ出力直後

注意事項

  1. 92タブの営業利益行は '✨ 営業利益'、78タブは '✨ PJ営業利益' とラベルが異なる。検索条件は「営業利益」を含み「経常」を含まない行とする
  2. 92タブの金額列はB列(index 1)。フォーマット #,##0;[Red]△ #,##0;"-" が適用されているが、getValues() は数値型で取得するため Number() 変換で問題ない
  3. 78タブの全社合計は plOut 配列の最終列(index = plOut[r].length - 1)。シート読み込みではなくメモリ上の配列から直接取得する(78は同じ関数内で生成済み)
  4. 差異が検出された場合でも78タブの出力は正常に完了する(チェックは出力後に実行)

関連ドキュメント

仕様書関連箇所
CLAUDE.md変更時の動作確認テスト: 400_domain/ 変更 → 関連テスト
B.3 統合テスト手順マート更新テスト

人間が検討すべき事項

なし(検証ロジックのみ。即実装可)


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

あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者です。
CLIエージェントである「Claude Code」として、以下の指示に従い、案件 S-13「78タブと92タブの営業利益整合性チェック」を実装してください。

## 実行前タスク(コンテキストの読み込み)

実装前に**必ず**以下のファイルを読み込んで、現在の実装を正確に把握してください。

1. 修正対象: `400_domain/402_project_profitability.js` — `buildProjectProfitability()` の全体構造。特に以下を確認:
   - L548: 営業利益のセクション定義ラベル `'✨ PJ営業利益'`
   - L691-698: 全社合計列の再計算ロジック(`plOut` 配列の最終列)
   - L761: 完了toast(チェックはこの直前に挿入)
   - L763-766: try-catch の閉じ括弧の位置
2. 参照(読み取りのみ): `600_report/607_datamart_fs.js` — `dmBuildFsPl_()` の出力フォーマット。営業利益行のラベル `'✨ 営業利益'` と金額列(B列, index 1)
3. プロジェクト規約: `CLAUDE.md`
4. 開発仕様書: `docs/dev/dev_mas-085_consistency_check.md`

## 修正対象ファイル

`400_domain/402_project_profitability.js` の `buildProjectProfitability()` のみ。他ファイルの変更は不要。

## 実装内容

### buildProjectProfitability() への追加(1箇所のみ)

L761の完了toast `ss.toast('💰 プロジェクト別...')` の**直前**に、以下のロジックを追加:

1. `ss.getSheetByName('92_fs_pl')` で92タブを取得。存在しなければスキップ
2. `plOut` 配列から `'PJ営業利益'` を含む行を検索し、最終列の値を取得(= 78の全社合計営業利益)
3. 92タブの `getDataRange().getValues()` から `'営業利益'` を含み `'経常'` を含まない行を検索し、B列(index 1)の値を取得
4. 差額の絶対値が1円を超える場合、`SpreadsheetApp.getUi().alert()` でモーダルダイアログを表示
5. `Utils.logInfo()` でログにも記録

### 重要な制約

- 78タブの営業利益は**シートからではなく `plOut` 配列**から直接取得する(同じ関数内で生成済み)
- 92タブの営業利益は**シートから読み取る**(別タイミングで生成されたデータ)
- チェックは78タブ出力完了後に実行する。チェック結果に関わらず78の出力は正常に行われる
- try-catch の閉じ括弧(L763)の**内側**に挿入すること(例外発生時もエラーハンドリングされるように)
- 92タブの`'営業利益'`検索で`'経常利益'`や`'税引前利益'`にマッチしないよう、`'経常'` を含まない条件を付けること

## 動作確認

実装後、`npm run push:dev` で開発環境にデプロイし、以下を確認:
1. GASエディタで `buildBudgetTrendDataMart()` を実行(92タブを生成)
2. GASエディタで `buildProjectProfitability()` を実行
3. 78タブと92タブの営業利益が一致 → 完了toastのみ表示(alertなし)
4. 92タブが存在しない状態で78を実行 → エラーなく完了

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

| フェーズ | 拡張思考 | 備考 |
|---------|:--------:|------|
| ファイル読み込み・構造理解 | なし | plOut配列の構造確認のみ |
| 営業利益行の検索ロジック | あり | 78と92でラベルが異なる(PJ営業利益 vs 営業利益)。経常利益との誤マッチ回避条件の設計 |
| 挿入位置の判定 | あり | try-catch内部、toast直前の正確な挿入位置の特定 |
| 閾値・警告方式の判断 | なし | 仕様書で定義済み(±1円、alert方式) |

推奨実行モデル

工程推奨モデル理由
仕様書作成(本ドキュメント)Claude Opus 4.678と92の独立した計算ロジックの差異分析、不一致原因の体系的な整理に高い推論力が必要
実装Claude Sonnet 4.6仕様書でコードがほぼ完全に定義済み。挿入位置の特定(try-catch内、toast直前)に中程度の判断力が必要
動作確認ユーザー手動GASエディタでの2関数の順次実行とダイアログ確認が必要

変更履歴

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