auto-project-management/gas/Code.gs.backup
柴田貴司 a892a3c87c Initial commit: Claude Code Gantt Chart Generator
- 対話型Ganttチャート自動生成システム
- Claude Code スキル定義 (/gantt, /gantt-update)
- Google Apps Script連携
- Todoist・Discord統合機能
- 完全なセットアップドキュメント

🤖 Generated with Claude Code
2026-01-01 17:24:17 +09:00

3211 lines
107 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Ganttチャート自動生成システム - メインスクリプト
*
* このスクリプトはJSONファイルからGanttチャートを自動生成します
*/
/**
* スプレッドシートを開いたときにカスタムメニューを追加
*/
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('📊 ガントチャート')
.addItem('🔄 すべて更新', 'menuUpdateAll')
.addItem('📂 Driveから再読み込み', 'menuReloadFromDrive')
.addSeparator()
.addItem('📊 ガントチャート更新', 'menuUpdateGantt')
.addItem('📋 全タスク一覧更新', 'menuUpdateAllTasks')
.addItem('⚠️ 期日切れ一覧更新', 'menuUpdateOverdue')
.addToUi();
}
/**
* メニュー: すべて更新
*/
function menuUpdateAll() {
const ui = SpreadsheetApp.getUi();
try {
menuUpdateGantt();
menuUpdateAllTasks();
menuUpdateOverdue();
ui.alert('✅ 更新完了', 'すべてのシートを更新しました。', ui.ButtonSet.OK);
} catch (e) {
ui.alert('❌ エラー', `更新中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK);
}
}
/**
* メニュー: Driveから再読み込み
* Google DriveのJSONファイルを手動で読み込み、スプレッドシートに反映
*/
function menuReloadFromDrive() {
const ui = SpreadsheetApp.getUi();
// 確認ダイアログ
const response = ui.alert(
'📂 Driveから再読み込み',
'Google Drive上のJSONファイルを読み込み、\nスプレッドシートに反映します。\n\n処理を実行しますか',
ui.ButtonSet.YES_NO
);
if (response !== ui.Button.YES) {
return;
}
try {
// checkForNewJsonFiles を手動実行
checkForNewJsonFiles();
ui.alert(
'✅ 読み込み完了',
'JSONファイルの読み込みが完了しました。\n\n詳細はログで確認できます。',
ui.ButtonSet.OK
);
} catch (e) {
ui.alert(
'❌ エラー',
`読み込み中にエラーが発生しました:\n${e.message}`,
ui.ButtonSet.OK
);
}
}
/**
* メニュー: ガントチャート更新
* 現在アクティブなシートがガントチャートシートの場合、そのシートを更新
*/
function menuUpdateGantt() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const activeSheet = ss.getActiveSheet();
const sheetName = activeSheet.getName();
// プロジェクト一覧シートからプロジェクトIDを取得
const projectListSheet = ss.getSheetByName(CONFIG.PROJECT_LIST_SHEET_NAME);
if (!projectListSheet) {
throw new Error('プロジェクト一覧シートが見つかりません。');
}
// ガントチャートシートかどうか判定(プロジェクト一覧からリンクを確認)
const data = projectListSheet.getDataRange().getValues();
let projectId = null;
for (let i = 1; i < data.length; i++) {
const rowProjectId = data[i][0];
const expectedSheetName = `${rowProjectId}_`;
if (sheetName.startsWith(expectedSheetName)) {
projectId = rowProjectId;
break;
}
}
if (!projectId) {
// アクティブシートがガントチャートでない場合、選択ダイアログを表示するか最初のプロジェクトを更新
SpreadsheetApp.getUi().alert('⚠️ 注意', '現在のシートはガントチャートシートではありません。\nガントチャートシートを選択してから実行してください。', SpreadsheetApp.getUi().ButtonSet.OK);
return;
}
// DriveからJSONを再読み込みしてガントチャートを更新
const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
const files = folder.getFiles();
while (files.hasNext()) {
const file = files.next();
const fileName = file.getName();
if (fileName.includes(projectId) && fileName.endsWith('.json')) {
const content = file.getBlob().getDataAsString();
const projectData = JSON.parse(content);
const renderer = new GanttRenderer(activeSheet, projectData);
renderer.render();
Logger.log(`✓ ガントチャート「${sheetName}」を更新しました`);
return;
}
}
throw new Error(`プロジェクトID ${projectId} のJSONファイルが見つかりません。`);
}
/**
* メニュー: 全タスク一覧更新
*/
function menuUpdateAllTasks() {
const sheetManager = new SheetManager();
const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
const files = folder.getFiles();
while (files.hasNext()) {
const file = files.next();
const fileName = file.getName();
if (fileName.endsWith('.json')) {
const content = file.getBlob().getDataAsString();
const projectData = JSON.parse(content);
sheetManager.updateAllTasks(projectData);
}
}
Logger.log('✓ 全タスク一覧を更新しました');
}
/**
* メニュー: 期日切れ一覧更新
*/
function menuUpdateOverdue() {
const sheetManager = new SheetManager();
sheetManager.updateOverdueTasks();
Logger.log('✓ 期日切れ一覧を更新しました');
}
/**
* プロジェクトデータからGanttチャートを生成
*
* @param {Object} projectData - プロジェクトデータJSON
*/
function generateGanttFromData(projectData) {
Logger.log(`プロジェクト「${projectData.project_name}」のGanttチャート生成を開始...`);
const sheetManager = new SheetManager();
// 1. プロジェクトGanttシートを作成レンダリング
Logger.log('Ganttチャートを作成中...');
const ganttSheet = sheetManager.getOrCreateGanttSheet(
String(projectData.project_id),
projectData.project_name
);
const renderer = new GanttRenderer(ganttSheet, projectData);
renderer.render();
// 2. 全タスクリストを更新
Logger.log('全タスクリストを更新中...');
sheetManager.updateAllTasks(projectData);
// 3. プロジェクト一覧を更新(最後に更新してシートリンクを取得)
Logger.log('プロジェクト一覧を更新中...');
sheetManager.updateProjectList(projectData);
Logger.log('✓ すべてのシートを更新完了');
}
/**
* ファイル名からproject_idを抽出
* @param {string} fileName - ファイル名(例: "192_ポートフォリオ.json" or "processed_192_ポートフォリオ.json" or "updated_192_ポートフォリオ.json"
* @return {string|null} - プロジェクトID例: "192"
*/
function extractProjectId(fileName) {
// processed_ または updated_ を除去
const nameWithoutPrefix = fileName.replace(/^(processed_|updated_)/, '');
// 最初の "_" より前を取得
const match = nameWithoutPrefix.match(/^(\d+)_/);
return match ? match[1] : null;
}
/**
* プロジェクト一覧シートに既存プロジェクトがあるか判定
* @param {string} projectId - プロジェクトID
* @return {boolean} - 既存プロジェクトならtrue
*/
function existsInProjectList(projectId) {
const sheetManager = new SheetManager();
const projectListSheet = sheetManager.ss.getSheetByName(CONFIG.PROJECT_LIST_SHEET_NAME);
if (!projectListSheet) {
return false;
}
const data = projectListSheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]) === String(projectId)) {
return true;
}
}
return false;
}
/**
* Google Driveフォルダを監視して未処理のJSONファイルを処理
*
* この関数は時間ベーストリガー1分ごとで自動実行されます。
*
* 処理フロー:
* 1. CONFIG.DRIVE_FOLDER_IDで指定されたフォルダ内のJSONファイルを検索
* 2. 未処理ファイルprocessed_/updated_で始まらないを処理
* 3. 既存プロジェクトの場合は processed_/updated_ 付きでも更新
* 4. 処理後、ファイル名を変更:
* - 新規作成: "processed_XXXX.json"
* - 既存更新: "updated_XXXX.json"
*
* トリガー設定方法:
* 1. Apps Scriptエディタで「トリガー」アイコンをクリック
* 2. 「トリガーを追加」をクリック
* 3. 実行する関数: checkForNewJsonFiles
* 4. イベントのソース: 時間主導型
* 5. 時間ベースのトリガーのタイプ: 分ベースのタイマー
* 6. 時間の間隔: 1分おき
*/
function checkForNewJsonFiles() {
try {
const folderId = CONFIG.DRIVE_FOLDER_ID;
Logger.log(`[DEBUG] フォルダID: ${folderId}`);
if (!folderId) {
Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID が設定されていません。');
return;
}
if (folderId === 'your_folder_id_here') {
Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID がデフォルト値のままです。実際のフォルダIDを設定してください。');
return;
}
const folder = DriveApp.getFolderById(folderId);
Logger.log(`[DEBUG] フォルダ名: ${folder.getName()}`);
const files = folder.getFiles(); // 全ファイルを取得
let fileCount = 0;
let processedCount = 0;
while (files.hasNext()) {
const file = files.next();
const fileName = file.getName();
const mimeType = file.getMimeType();
fileCount++;
Logger.log(`[DEBUG] ファイル${fileCount}: ${fileName}`);
Logger.log(`[DEBUG] - MIMEタイプ: ${mimeType}`);
Logger.log(`[DEBUG] - .jsonで終わる? ${fileName.endsWith('.json')}`);
Logger.log(`[DEBUG] - processed/updated済み? ${fileName.startsWith('processed_') || fileName.startsWith('updated_')}`);
// JSONファイルかチェック
if (!fileName.endsWith('.json')) {
Logger.log(`⏭ スキップJSONではない: ${fileName}`);
continue;
}
// project_idを抽出
const projectId = extractProjectId(fileName);
Logger.log(`[DEBUG] - プロジェクトID: ${projectId}`);
// 処理条件:
// 1. processed_ または updated_ で始まらない(新規ファイル)
// 2. processed_ または updated_ で始まるが既存プロジェクト(更新ファイル)
const isProcessed = fileName.startsWith('processed_') || fileName.startsWith('updated_');
const isNewFile = !isProcessed;
const isExistingProject = projectId && existsInProjectList(projectId);
if (isNewFile || isExistingProject) {
// 既存プロジェクトかどうかで判定(こちらを優先)
if (isExistingProject) {
Logger.log(`✓ 既存プロジェクトの更新を検出: ${fileName}`);
} else {
Logger.log(`✓ 新しいJSONファイルを検出: ${fileName}`);
}
try {
// JSONファイルを処理
processJsonFile(file.getId());
// 処理成功後、ファイル名を変更
if (isExistingProject) {
// 既存プロジェクトの場合は updated_ を付ける
const cleanFileName = fileName.replace(/^(processed_|updated_)/, '');
const newFileName = 'updated_' + cleanFileName;
file.setName(newFileName);
Logger.log(`✓ 更新完了。ファイル名を変更: ${newFileName}`);
} else {
// 新規プロジェクトの場合は processed_ を付ける
const newFileName = 'processed_' + fileName;
file.setName(newFileName);
Logger.log(`✓ 新規作成完了。ファイル名を変更: ${newFileName}`);
}
processedCount++;
} catch (error) {
Logger.log(`✗ ファイル処理エラー (${fileName}): ${error.message}`);
Logger.log(`✗ スタックトレース: ${error.stack}`);
// エラーが発生してもループは続ける
}
} else {
Logger.log(`⏭ スキップ: ${fileName}`);
}
}
Logger.log(`[DEBUG] 検出ファイル数: ${fileCount}, 処理数: ${processedCount}`);
if (processedCount > 0) {
Logger.log(`✓ ${processedCount}個のファイルを処理しました。`);
} else {
Logger.log('未処理のJSONファイルはありません。');
}
} catch (error) {
Logger.log('✗ フォルダ監視エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
sendDiscordNotification(
`Google Driveフォルダの監視中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
}
}
/**
* 手動でJSONファイルを指定して処理
*
* @param {string} fileId - Google DriveのファイルID
*/
function processJsonFile(fileId) {
try {
const file = DriveApp.getFileById(fileId);
const content = file.getBlob().getDataAsString();
const projectData = JSON.parse(content);
Logger.log(`JSONファイル「${file.getName()}」を処理中...`);
generateGanttFromData(projectData);
sendDiscordNotification(
`プロジェクト「${projectData.project_name}」のGanttチャートを生成しました。\n` +
`ファイル: ${file.getName()}\n` +
`スプレッドシート: https://docs.google.com/spreadsheets/d/${CONFIG.SPREADSHEET_ID}`
);
} catch (error) {
Logger.log('✗ エラー: ' + error);
sendDiscordNotification(
`JSONファイル処理中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
/**
* 実際のJSONファイルから生成Google Driveのファイル使用
*
* 【自動実行】
* - npm run gantt:save でアップロード時に自動的にprocessJsonFile()が実行されます
* - Apps Script APIを使用してNode.jsから直接呼び出されます
*
* 【手動実行】
* 1. Google DriveにJSONファイルをアップロード
* 2. ファイルを右クリック → 「リンクを取得」 → ファイルIDをコピー
* 3. 下記の FILE_ID を実際のファイルIDに書き換え
* 4. この関数を実行
*
* ファイルIDの例: "1a2b3c4d5e6f7g8h9i0j" (長い英数字の文字列)
*/
function generateGanttFromFile() {
const FILE_ID = 'your_file_id_here'; // ここに実際のファイルIDを入力
if (FILE_ID === 'your_file_id_here') {
Logger.log('⚠ FILE_ID を実際のファイルIDに書き換えてください。');
return;
}
processJsonFile(FILE_ID);
}
/**
* 全Ganttシートを一括再同期
*
* - 全Ganttシートからプロジェクトデータを読み取り
* - プロジェクト一覧、全タスクリスト、期日切れタスクを更新
*/
function syncAllSheets() {
try {
Logger.log('=== 全Ganttシートの一括同期を開始 ===');
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
// 1. 全Ganttシートを検出
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式
});
Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`);
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
// 2. 各Ganttシートからデータを読み取って更新
let successCount = 0;
let errorCount = 0;
for (const ganttSheet of ganttSheets) {
try {
Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`);
// Ganttシートからプロジェクトデータを読み取り
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) {
Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`);
errorCount++;
continue;
}
// プロジェクト一覧を更新
sheetManager.updateProjectList(projectData);
// 全タスクリストを更新
sheetManager.updateAllTasks(projectData);
// Ganttシートのステータスから進捗率を更新
sheetManager.updateGanttProgressFromStatus(ganttSheet);
Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`);
successCount++;
// API制限・リソース枯渇対策次のシート処理まで1秒待機
Utilities.sleep(1000);
} catch (error) {
Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`);
errorCount++;
}
}
// 3. 期日切れタスクシートを更新
Logger.log('--- 期日切れタスクシートを更新中 ---');
try {
const overdueCount = sheetManager.updateOverdueTasks();
Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`);
} catch (error) {
Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`);
}
Logger.log('=== 同期完了 ===');
Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`);
} catch (error) {
Logger.log('✗ 全体エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
throw error;
}
}
/**
* Ganttシートをバッチ処理で同期
*
* @param {number} startIndex - 開始インデックス0始まり
* @param {number} batchSize - 処理するシート数
* @param {boolean} updateOverdue - 期日切れタスク更新を実行するか(デフォルト: false
*/
function syncBatchSheets(startIndex, batchSize, updateOverdue = false) {
try {
Logger.log(`=== Ganttシートのバッチ同期を開始 (${startIndex}~${startIndex + batchSize - 1}) ===`);
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
// 1. 全Ganttシートを検出
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式
});
Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`);
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
// 2. バッチ範囲を計算
const endIndex = Math.min(startIndex + batchSize, ganttSheets.length);
const batchSheets = ganttSheets.slice(startIndex, endIndex);
Logger.log(`✓ バッチ処理対象: ${batchSheets.length}件 (${startIndex}~${endIndex - 1})`);
if (batchSheets.length === 0) {
Logger.log('⚠ 処理対象のシートがありません。');
return;
}
// 3. 各Ganttシートからデータを読み取って更新
let successCount = 0;
let errorCount = 0;
for (const ganttSheet of batchSheets) {
try {
Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`);
// Ganttシートからプロジェクトデータを読み取り
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) {
Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`);
errorCount++;
continue;
}
// プロジェクト一覧を更新
sheetManager.updateProjectList(projectData);
// 全タスクリストを更新
sheetManager.updateAllTasks(projectData);
// Ganttシートのステータスから進捗率を更新
sheetManager.updateGanttProgressFromStatus(ganttSheet);
Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`);
successCount++;
// API制限・リソース枯渇対策次のシート処理まで1秒待機
Utilities.sleep(1000);
} catch (error) {
Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`);
errorCount++;
}
}
// 4. 期日切れタスクシートを更新(最後のバッチのみ)
if (updateOverdue) {
Logger.log('--- 期日切れタスクシートを更新中 ---');
try {
const overdueCount = sheetManager.updateOverdueTasks();
Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`);
} catch (error) {
Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`);
}
}
Logger.log('=== バッチ同期完了 ===');
Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`);
} catch (error) {
Logger.log('✗ バッチ全体エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
throw error;
}
}
/**
* バッチ1: 0-4番目のシートを同期
*/
function syncBatch1() {
syncBatchSheets(0, 5);
}
/**
* バッチ2: 5-9番目のシートを同期
*/
function syncBatch2() {
syncBatchSheets(5, 5);
}
/**
* バッチ3: 10-14番目のシートを同期
*/
function syncBatch3() {
syncBatchSheets(10, 5);
}
/**
* バッチ4: 15-19番目のシートを同期
*/
function syncBatch4() {
syncBatchSheets(15, 5);
}
/**
* バッチ5: 20番目以降のシートを同期 + 期日切れタスク更新
*/
function syncBatch5() {
syncBatchSheets(20, 5, true); // 最後のバッチのみ期日切れタスク更新
}
/**
* 自動継続方式のバッチ同期
* - プロパティサービスで進捗を保存
* - 前回の続きから5シートずつ処理
* - 全シート完了したら最初に戻る
* - 最後のバッチで期日切れタスク更新
*/
function syncNextBatch() {
try {
const scriptProperties = PropertiesService.getScriptProperties();
const BATCH_SIZE = 5;
// 現在のインデックスを取得初回は0
let currentIndex = parseInt(scriptProperties.getProperty('SYNC_CURRENT_INDEX') || '0');
Logger.log(`=== 自動継続バッチ同期開始 (開始インデックス: ${currentIndex}) ===`);
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
// Ganttシートを検出
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/);
});
const totalSheets = ganttSheets.length;
Logger.log(`✓ 総Ganttシート数: ${totalSheets}件`);
if (totalSheets === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
scriptProperties.setProperty('SYNC_CURRENT_INDEX', '0');
return;
}
// インデックスが範囲外の場合はリセット
if (currentIndex >= totalSheets) {
Logger.log('✓ 全シート処理完了。最初に戻ります。');
currentIndex = 0;
}
// バッチ範囲を計算
const endIndex = Math.min(currentIndex + BATCH_SIZE, totalSheets);
const batchSheets = ganttSheets.slice(currentIndex, endIndex);
const isLastBatch = endIndex >= totalSheets;
Logger.log(`✓ バッチ処理: ${batchSheets.length}件 (${currentIndex}~${endIndex - 1}) ${isLastBatch ? '[最終バッチ]' : ''}`);
// 各シートを同期
let successCount = 0;
let errorCount = 0;
for (const ganttSheet of batchSheets) {
try {
Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`);
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) {
Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`);
errorCount++;
continue;
}
sheetManager.updateProjectList(projectData);
sheetManager.updateAllTasks(projectData);
sheetManager.updateGanttProgressFromStatus(ganttSheet);
Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`);
successCount++;
Utilities.sleep(1000);
} catch (error) {
Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`);
errorCount++;
}
}
// 最終バッチの場合は期日切れタスク更新
if (isLastBatch) {
Logger.log('--- 期日切れタスクシートを更新中 ---');
try {
const overdueCount = sheetManager.updateOverdueTasks();
Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`);
} catch (error) {
Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`);
}
}
// 次回のインデックスを保存
const nextIndex = isLastBatch ? 0 : endIndex;
scriptProperties.setProperty('SYNC_CURRENT_INDEX', nextIndex.toString());
Logger.log('=== バッチ同期完了 ===');
Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`);
Logger.log(`次回開始インデックス: ${nextIndex} ${isLastBatch ? '(最初に戻る)' : ''}`);
} catch (error) {
Logger.log('✗ バッチ全体エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
throw error;
}
}
/**
* 全Ganttシートを強制的に再同期
* 変更検知機能を削除したため、syncAllSheets()と同じ)
*/
function forceResyncAll() {
syncAllSheets();
}
/**
* 全Ganttシートの期日切れタスクに色付け
* - ステータスには一切触らない
* - 終了日セルG列のみ色付け赤背景・黄色文字・太字
* - render()を使わないので超軽量
*/
function updateOverdueColors() {
try {
Logger.log('=== 期日切れタスクの色付け開始 ===');
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
// Ganttシートを検出
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/);
});
Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`);
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
let totalColoredCells = 0;
for (const ganttSheet of ganttSheets) {
try {
// タスクデータの開始行GanttRendererの構造: 2行目がヘッダー、4行目からタスク
const lastRow = ganttSheet.getLastRow();
if (lastRow < 4) continue;
const taskStartRow = 4; // 固定4行目からタスクデータ
// タスクデータの範囲を取得
const taskDataRange = ganttSheet.getRange(taskStartRow, 1, lastRow - taskStartRow + 1, 9);
const taskData = taskDataRange.getValues();
// 全終了日セルG列をリセット
const endDateCol = ganttSheet.getRange(taskStartRow, 7, taskData.length, 1);
endDateCol.setBackground('#FFFFFF');
endDateCol.setFontColor('#000000');
endDateCol.setFontWeight('normal');
let coloredCount = 0;
// 各タスクをチェック
for (let i = 0; i < taskData.length; i++) {
const row = taskData[i];
const endDateValue = row[6]; // G列終了日
const progress = row[7]; // H列進捗率
// 空行はスキップ
if (!row[0]) continue;
// 期日切れ判定進捗率は0.0-1.0の小数で保存されている)
if (endDateValue && progress !== 1) {
const endDate = new Date(endDateValue);
endDate.setHours(0, 0, 0, 0);
if (endDate < today) {
// 期日切れ → 色付け
const endDateCell = ganttSheet.getRange(taskStartRow + i, 7);
endDateCell.setBackground('#FF0000'); // 赤背景
endDateCell.setFontColor('#FFFF00'); // 黄色文字
endDateCell.setFontWeight('bold'); // 太字
coloredCount++;
}
}
}
if (coloredCount > 0) {
Logger.log(`✓ ${ganttSheet.getName()}: ${coloredCount}件の期日切れタスクに色付け`);
}
totalColoredCells += coloredCount;
} catch (error) {
Logger.log(`✗ 色付けエラー (${ganttSheet.getName()}): ${error.message}`);
}
}
Logger.log('=== 色付け完了 ===');
Logger.log(`総計: ${totalColoredCells}件の期日切れタスクに色付け`);
} catch (error) {
Logger.log('✗ 全体エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
throw error;
}
}
/**
* 期日切れタスクをDiscordに通知
*
* - 期日切れタスクシートのデータを読み取り
* - Discord WebhookでEmbed形式の通知を送信
*/
function sendOverdueTasksNotification() {
try {
const sheetManager = new SheetManager();
const overdueSheet = sheetManager.getOrCreateOverdueTasksSheet();
// 期日切れタスクを取得
const lastRow = overdueSheet.getLastRow();
if (lastRow <= 1) {
Logger.log('✓ 期日切れタスクはありません。');
return;
}
// データを読み取り(ヘッダー除く)
const data = overdueSheet.getRange(2, 1, lastRow - 1, 9).getValues();
// プロジェクトごとにグループ化
const projectGroups = {};
for (const row of data) {
const projectId = String(row[0] || '');
const projectName = String(row[1] || '');
const taskId = String(row[2] || '');
const parentTaskName = String(row[3] || '');
const childTaskName = String(row[4] || '');
const dueDate = row[5] || '';
const assignee = String(row[6] || '');
const daysOverdue = row[7] || 0;
const progress = row[8] || 0;
// タスク名を決定
const taskName = childTaskName || parentTaskName || taskId;
// 進捗率を正規化0.0-1.0 → 0-100
let progressPercent = 0;
if (typeof progress === 'number') {
progressPercent = progress <= 1 ? Math.round(progress * 100) : Math.round(progress);
}
if (!projectGroups[projectId]) {
projectGroups[projectId] = {
projectName: projectName,
tasks: []
};
}
projectGroups[projectId].tasks.push({
taskName: taskName,
dueDate: dueDate,
assignee: assignee,
daysOverdue: daysOverdue,
progress: progressPercent
});
}
// Embed形式でDiscord通知を作成
const embeds = [];
for (const projectId in projectGroups) {
const group = projectGroups[projectId];
// タスクリストをMarkdown形式で整形
const taskList = group.tasks
.slice(0, 10) // 最大10件まで
.map(task => {
const dueDateStr = task.dueDate instanceof Date
? `${task.dueDate.getMonth() + 1}/${task.dueDate.getDate()}`
: task.dueDate;
return `• **${task.taskName}** (期日: ${dueDateStr}, 遅延: ${task.daysOverdue}日, 進捗: ${task.progress}%)`;
})
.join('\n');
const moreCount = group.tasks.length > 10 ? `\n... 他${group.tasks.length - 10}件` : '';
embeds.push({
title: `⚠️ 期日切れタスク: ${group.projectName}`,
description: taskList + moreCount,
color: 0xFF6B6B, // 赤色
footer: {
text: `合計 ${group.tasks.length}件の期日切れタスク`
}
});
}
if (embeds.length === 0) {
Logger.log('✓ 期日切れタスクはありません。');
return;
}
// Discord Webhook送信
const payload = { embeds: embeds };
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
Logger.log(`✓ Discord通知送信完了: ${response.getResponseCode()}`);
} catch (error) {
Logger.log(`✗ Discord通知エラー: ${error.message}`);
Logger.log(`✗ スタックトレース: ${error.stack}`);
throw error;
}
}
/**
* TodoistのInboxタスクを同期
*
* - Todoist REST API v2を使用してInboxタスクを取得
* - Todoistタスクシートに書き込み
*/
function syncTodoistTasks() {
try {
Logger.log('=== Todoistタスク同期開始 ===');
// 1. Todoist APIからタスクを取得
const apiKey = CONFIG.TODOIST_API_KEY;
if (!apiKey) {
Logger.log('⚠ TODOIST_API_KEY が設定されていません。');
return 0;
}
const url = 'https://api.todoist.com/rest/v2/tasks';
const options = {
method: 'get',
headers: {
'Authorization': `Bearer ${apiKey}`
},
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const responseCode = response.getResponseCode();
if (responseCode !== 200) {
Logger.log(`✗ Todoist API エラー: ${responseCode}`);
Logger.log(`✗ レスポンス: ${response.getContentText()}`);
return 0;
}
const allTasks = JSON.parse(response.getContentText());
Logger.log(`✓ Todoistタスク取得完了: ${allTasks.length}件`);
// 2. プロジェクト一覧を取得してInboxプロジェクトIDを特定
const projectsUrl = 'https://api.todoist.com/rest/v2/projects';
const projectsResponse = UrlFetchApp.fetch(projectsUrl, options);
const projects = JSON.parse(projectsResponse.getContentText());
let inboxProjectId = null;
for (const project of projects) {
if (project.name === 'Inbox' || project.is_inbox_project) {
inboxProjectId = String(project.id);
Logger.log(`✓ Inboxプロジェクト検出: ${inboxProjectId}`);
break;
}
}
if (!inboxProjectId) {
Logger.log('⚠ InboxプロジェクトIDが見つかりませんでした。');
return 0;
}
// 3. Inboxプロジェクトのタスクのみをフィルタリング
const inboxTasks = allTasks.filter(task => String(task.project_id) === inboxProjectId);
Logger.log(`✓ Inboxタスク抽出完了: ${inboxTasks.length}件`);
// 4. シートマネージャーを使用してタスクを更新
const sheetManager = new SheetManager();
const count = sheetManager.updateTodoistTasks(inboxTasks);
Logger.log(`✓ Todoistタスク同期完了: ${count}件`);
return count;
} catch (error) {
Logger.log(`✗ Todoistタスク同期エラー: ${error.message}`);
Logger.log(`✗ スタックトレース: ${error.stack}`);
throw error;
}
}
/**
* Todoist連携機能のテスト開発用
*
* 使用方法:
* 1. GASエディタでこの関数を選択
* 2. 実行ボタンをクリック
* 3. 以下がテストされます:
* - Todoist APIからInboxタスクを取得
* - Todoistタスクシートへの書き込み
* - Discord通知の送信
*/
/**
* 既存シートに実績工数列を追加するマイグレーション関数
*
* 【重要】この関数は一度だけ実行してください。
*
* 処理内容:
* 1. 全GanttシートX_プロジェクト名形式の12列目L列に「実績工数h」列を挿入
* 2. 全プロジェクトタスクシートの14列目N列に「実績工数h」列を挿入
*
* 使用方法:
* 1. GASエディタでこの関数を選択
* 2. 実行ボタンをクリック
* 3. 実行後、各シートに「実績工数h」列が追加されたことを確認
*/
function migrateExistingSheetsForActualHours() {
try {
Logger.log('=== 実績工数列マイグレーション開始 ===');
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
let ganttCount = 0;
let errorCount = 0;
// 1. 全Ganttシートを処理
Logger.log('--- Ganttシートを処理中 ---');
for (const sheet of allSheets) {
const sheetName = sheet.getName();
// GanttシートX_プロジェクト名 または XXXX_プロジェクト名形式を検出
if (sheetName.match(/^\d{1,4}_.+$/)) {
try {
Logger.log(`処理中: ${sheetName}`);
// 2行目ヘッダー行の12列目L列の値を確認
const currentHeader = sheet.getRange(2, 12).getValue();
// すでに「実績工数h」がある場合はスキップ
if (currentHeader === '実績工数h') {
Logger.log(` ⏭ スキップ(すでに実績工数列あり): ${sheetName}`);
continue;
}
// 12列目L列に新しい列を挿入
sheet.insertColumnAfter(11); // K列の後ろに挿入
// ヘッダー行2行目の12列目に「実績工数h」を設定
sheet.getRange(2, 12).setValue('実績工数h');
// ヘッダーのスタイルを適用(他のヘッダーと同じスタイル)
const headerCell = sheet.getRange(2, 12);
headerCell.setBackground('#E8F4FD');
headerCell.setHorizontalAlignment('center');
headerCell.setVerticalAlignment('middle');
// 1行目の12列目も同じ色で塗る
sheet.getRange(1, 12).setBackground('#E8F4FD');
sheet.getRange(1, 12).setHorizontalAlignment('center');
sheet.getRange(1, 12).setVerticalAlignment('middle');
Logger.log(` ✓ 完了: ${sheetName}`);
ganttCount++;
} catch (error) {
Logger.log(` ✗ エラー (${sheetName}): ${error.message}`);
errorCount++;
}
}
}
Logger.log(`✓ Ganttシート処理完了: ${ganttCount}件`);
// 2. 全プロジェクトタスクシートを処理
Logger.log('--- 全プロジェクトタスクシートを処理中 ---');
const allTasksSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.ALL_TASKS);
if (allTasksSheet) {
try {
// 1行目ヘッダー行の14列目N列の値を確認
const currentHeader = allTasksSheet.getRange(1, 14).getValue();
// すでに「実績工数h」がある場合はスキップ
if (currentHeader === '実績工数h') {
Logger.log(' ⏭ スキップ(すでに実績工数列あり): 全プロジェクトタスク');
} else {
// 14列目N列に新しい列を挿入
allTasksSheet.insertColumnAfter(13); // M列の後ろに挿入
// ヘッダー行1行目の14列目に「実績工数h」を設定
allTasksSheet.getRange(1, 14).setValue('実績工数h');
// ヘッダーのスタイルを適用
const headerCell = allTasksSheet.getRange(1, 14);
headerCell.setBackground('#4A90E2');
headerCell.setFontColor('#FFFFFF');
headerCell.setFontWeight('bold');
headerCell.setHorizontalAlignment('center');
// 列幅を設定
allTasksSheet.setColumnWidth(14, 75);
Logger.log(' ✓ 完了: 全プロジェクトタスク');
}
} catch (error) {
Logger.log(` ✗ エラー (全プロジェクトタスク): ${error.message}`);
errorCount++;
}
} else {
Logger.log(' ⚠ 全プロジェクトタスクシートが見つかりません。');
}
Logger.log('=== マイグレーション完了 ===');
Logger.log(`成功: ${ganttCount}件のGanttシート + 全タスクシート / エラー: ${errorCount}件`);
// Discord通知
sendDiscordNotification(
`✅ **実績工数列マイグレーション完了**\n\n` +
`Ganttシート: ${ganttCount}件\n` +
`全プロジェクトタスクシート: 処理済み\n` +
`エラー: ${errorCount}件`,
false
);
} catch (error) {
Logger.log('✗ マイグレーションエラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
sendDiscordNotification(
`実績工数列マイグレーション中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
/**
* 依存タスクブロッカー検知: 遅延タスクの影響分析
*
* この関数は遅延しているタスクを検出し、それらが他のタスクに与える影響を分析します。
*
* @param {Object} projectData - プロジェクトデータ
* @return {Object} 分析結果
* {
* delayedTasks: [遅延タスクの配列],
* impactedTasks: {
* delayed_task_id: {
* impactedTaskIds: [影響を受けるタスクIDの配列],
* criticalPathLength: クリティカルパスの長さ
* }
* },
* circularDependencies: [[循環依存タスクIDの配列], ...],
* taskMap: { task_id: task_object }
* }
*/
function analyzeDependencyImpact(projectData) {
try {
const today = new Date();
today.setHours(0, 0, 0, 0); // 時刻をリセット
const tasks = projectData.tasks || [];
const delayedTasks = [];
const taskMap = {}; // task_id -> task object
const dependencyGraph = {}; // task_id -> [dependent task_ids] (このタスクに依存しているタスクのID)
// 1. タスクマップを構築 & 遅延タスクを抽出
Logger.log('--- 遅延タスクを検出中 ---');
for (const task of tasks) {
taskMap[task.task_id] = task;
// 遅延タスク判定: 終了日 < 今日 && 進捗率 < 100%
if (task.end_date) {
const endDate = new Date(task.end_date);
endDate.setHours(0, 0, 0, 0);
const progress = task.progress || 0;
const progressPercent = progress <= 1 ? progress * 100 : progress;
if (endDate < today && progressPercent < 100) {
const daysOverdue = Math.floor((today - endDate) / (1000 * 60 * 60 * 24));
delayedTasks.push({
...task,
daysOverdue: daysOverdue
});
Logger.log(` 遅延タスク検出: ${task.task_id} (${task.task_name}) - ${daysOverdue}日遅延`);
}
}
}
Logger.log(`✓ 遅延タスク: ${delayedTasks.length}件`);
// 2. 依存関係グラフを構築(逆方向: どのタスクが自分に依存しているか)
Logger.log('--- 依存関係グラフを構築中 ---');
for (const task of tasks) {
const dependencies = task.dependencies || [];
for (const depTaskId of dependencies) {
if (!dependencyGraph[depTaskId]) {
dependencyGraph[depTaskId] = [];
}
dependencyGraph[depTaskId].push(task.task_id);
}
}
// 3. 各遅延タスクについて影響範囲を分析DFS: 深さ優先探索)
Logger.log('--- 影響範囲を分析中 ---');
const impactedTasks = {}; // delayed_task_id -> { impactedTaskIds: [...], criticalPathLength: N }
for (const delayedTask of delayedTasks) {
const visited = new Set();
const impactedTaskIds = [];
let maxDepth = 0;
// DFSで影響範囲を探索
const dfs = (taskId, depth) => {
if (visited.has(taskId)) {
return; // 既に訪問済み(循環依存の場合)
}
visited.add(taskId);
maxDepth = Math.max(maxDepth, depth);
// 遅延タスク自身は除外
if (taskId !== delayedTask.task_id) {
impactedTaskIds.push(taskId);
}
// このタスクに依存しているタスクを再帰的に探索
const dependentTasks = dependencyGraph[taskId] || [];
for (const dependentTaskId of dependentTasks) {
dfs(dependentTaskId, depth + 1);
}
};
dfs(delayedTask.task_id, 0);
impactedTasks[delayedTask.task_id] = {
impactedTaskIds: impactedTaskIds,
criticalPathLength: maxDepth
};
Logger.log(` ${delayedTask.task_id}: ${impactedTaskIds.length}件のタスクに影響, クリティカルパス長=${maxDepth}`);
}
// 4. 循環依存を検出
Logger.log('--- 循環依存を検出中 ---');
const circularDependencies = detectCircularDependencies(tasks);
if (circularDependencies.length > 0) {
Logger.log(`⚠ 循環依存を検出: ${circularDependencies.length}件`);
for (const cycle of circularDependencies) {
Logger.log(` 循環: ${cycle.join(' -> ')}`);
}
} else {
Logger.log('✓ 循環依存なし');
}
return {
delayedTasks: delayedTasks,
impactedTasks: impactedTasks,
circularDependencies: circularDependencies,
taskMap: taskMap
};
} catch (error) {
Logger.log(`✗ 依存関係分析エラー: ${error.message}`);
Logger.log(`✗ スタックトレース: ${error.stack}`);
throw error;
}
}
/**
* 循環依存を検出
*
* タスク間の依存関係に循環がないかをチェックします。
* 循環依存がある場合、それらのタスクIDのサイクルを返します。
*
* @param {Array} tasks - タスク配列
* @return {Array} 循環依存のリスト [[taskId1, taskId2, ...], ...]
*/
function detectCircularDependencies(tasks) {
const circularDeps = [];
const visited = new Set();
const recursionStack = new Set();
const taskMap = {};
// タスクマップを構築
for (const task of tasks) {
taskMap[task.task_id] = task;
}
// DFSで循環依存を検出
const dfs = (taskId, path) => {
if (recursionStack.has(taskId)) {
// 循環依存を発見
const cycleStart = path.indexOf(taskId);
const cycle = path.slice(cycleStart).concat([taskId]);
circularDeps.push(cycle);
return true;
}
if (visited.has(taskId)) {
return false;
}
visited.add(taskId);
recursionStack.add(taskId);
path.push(taskId);
const task = taskMap[taskId];
if (task && task.dependencies) {
for (const depTaskId of task.dependencies) {
dfs(depTaskId, path.slice());
}
}
recursionStack.delete(taskId);
return false;
};
// 全タスクをチェック
for (const task of tasks) {
if (!visited.has(task.task_id)) {
dfs(task.task_id, []);
}
}
return circularDeps;
}
/**
* 依存タスクブロッカーの通知をDiscordに送信
*
* 遅延タスクとその影響範囲をEmbed形式でDiscordに通知します。
*
* @param {Object} impactReport - analyzeDependencyImpact()の戻り値
* @param {Object} projectData - プロジェクトデータ
* @param {Object} criticalPathReport - calculateCriticalPath()の戻り値(オプション)
*/
function sendDependencyBlockerNotification(impactReport, projectData, criticalPathReport) {
try {
if (!impactReport.delayedTasks || impactReport.delayedTasks.length === 0) {
Logger.log('✓ 遅延タスクがないため通知をスキップ');
return;
}
const embeds = [];
// プロジェクト情報
const projectName = projectData.project_name || 'プロジェクト';
const projectId = projectData.project_id || '';
// 遅延タスク情報を整形最大5件まで表示
const delayedTasksInfo = impactReport.delayedTasks
.slice(0, 5)
.map(task => {
const taskName = task.task_name || task.task_id;
const daysOverdue = task.daysOverdue || 0;
const impact = impactReport.impactedTasks[task.task_id];
const impactCount = impact ? impact.impactedTaskIds.length : 0;
const criticalPathLength = impact ? impact.criticalPathLength : 0;
return `• **${taskName}**\n - 遅延: ${daysOverdue}日\n - 影響タスク数: ${impactCount}件\n - クリティカルパス長: ${criticalPathLength}`;
})
.join('\n\n');
const moreCount = impactReport.delayedTasks.length > 5
? `\n\n... 他${impactReport.delayedTasks.length - 5}件の遅延タスク`
: '';
// 循環依存情報
let circularDepsInfo = '';
if (impactReport.circularDependencies.length > 0) {
circularDepsInfo = '\n\n⚠ **循環依存を検出**\n' +
impactReport.circularDependencies
.slice(0, 3)
.map(cycle => `• ${cycle.join(' → ')}`)
.join('\n');
if (impactReport.circularDependencies.length > 3) {
circularDepsInfo += `\n... 他${impactReport.circularDependencies.length - 3}件`;
}
}
const embed = {
title: `⚠️ 依存タスクブロッカー検知: ${projectName}`,
description: delayedTasksInfo + moreCount + circularDepsInfo,
color: 0xFF6B6B, // 赤色
footer: {
text: `合計 ${impactReport.delayedTasks.length}件の遅延タスク`
},
timestamp: new Date().toISOString()
};
// クリティカルパス情報を追加
if (criticalPathReport && !criticalPathReport.skipped && !criticalPathReport.error) {
embed.fields = [{
name: '🎯 クリティカルパス情報',
value: `プロジェクト所要時間: **${criticalPathReport.projectDuration}日**\n` +
`クリティカルタスク数: **${criticalPathReport.criticalTasks.length}件**\n` +
`Near-Criticalタスク数: **${criticalPathReport.nearCriticalTasks.length}件**`,
inline: false
}];
}
embeds.push(embed);
// Discord Webhook送信
const payload = { embeds: embeds };
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
const responseCode = response.getResponseCode();
if (responseCode === 200 || responseCode === 204) {
Logger.log(`✓ Discord通知送信完了: ${responseCode}`);
} else {
Logger.log(`⚠ Discord通知送信エラー: ${responseCode}`);
Logger.log(`レスポンス: ${response.getContentText()}`);
}
} catch (error) {
Logger.log(`✗ Discord通知エラー: ${error.message}`);
Logger.log(`✗ スタックトレース: ${error.stack}`);
throw error;
}
}
/**
* 依存タスクブロッカーを自動チェック(トリガー用)
*
* すべてのGanttシートで遅延タスクの影響を分析し、Discord通知とハイライトを実行します。
*
* トリガー設定:
* - 頻度: 毎日9:00に実行
* - 設定方法: Apps Scriptエディタの「トリガー」から設定
*/
function checkDependencyBlockers() {
try {
Logger.log('=== 依存タスクブロッカーチェック開始 ===');
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
// Ganttシートを検出
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式
});
Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`);
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
let totalProjects = 0;
let totalDelayed = 0;
let totalImpacted = 0;
let errorCount = 0;
// 各Ganttシートをチェック
for (const ganttSheet of ganttSheets) {
try {
Logger.log(`--- ${ganttSheet.getName()} をチェック中 ---`);
// Ganttシートからプロジェクトデータを読み取り
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) {
Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`);
errorCount++;
continue;
}
// 依存関係の影響を分析
const impactReport = analyzeDependencyImpact(projectData);
// クリティカルパス分析
const criticalPathReport = calculateCriticalPath(projectData);
if (criticalPathReport && !criticalPathReport.skipped) {
Logger.log(`[INFO] Critical path calculated for ${ganttSheet.getName()}: ${criticalPathReport.projectDuration} days`);
}
if (impactReport.delayedTasks.length > 0) {
// Discord通知を送信クリティカルパス情報を含む
sendDependencyBlockerNotification(impactReport, projectData, criticalPathReport);
// Ganttシートでブロックタスクをハイライト
highlightBlockedTasks(ganttSheet, projectData, impactReport);
// クリティカルパスをハイライト
highlightCriticalPath(ganttSheet, projectData, criticalPathReport);
totalDelayed += impactReport.delayedTasks.length;
// 影響を受けるタスク数を集計
for (const delayedTaskId in impactReport.impactedTasks) {
totalImpacted += impactReport.impactedTasks[delayedTaskId].impactedTaskIds.length;
}
Logger.log(`✓ ${ganttSheet.getName()}: ${impactReport.delayedTasks.length}件の遅延タスク検出`);
} else {
Logger.log(`✓ ${ganttSheet.getName()}: 遅延タスクなし`);
}
totalProjects++;
} catch (error) {
Logger.log(`✗ チェックエラー (${ganttSheet.getName()}): ${error.message}`);
errorCount++;
}
}
Logger.log('=== チェック完了 ===');
Logger.log(`プロジェクト: ${totalProjects}件 / 遅延タスク: ${totalDelayed}件 / 影響タスク: ${totalImpacted}件 / エラー: ${errorCount}件`);
// サマリー通知(遅延タスクがある場合のみ)
if (totalDelayed > 0) {
sendDiscordNotification(
`📊 **依存タスクブロッカーチェック完了**\n\n` +
`チェック対象: ${totalProjects}件のプロジェクト\n` +
`遅延タスク: ${totalDelayed}件\n` +
`影響を受けるタスク: ${totalImpacted}件\n` +
`エラー: ${errorCount}件`,
errorCount > 0
);
}
} catch (error) {
Logger.log('✗ 全体エラー: ' + error.message);
Logger.log('✗ スタックトレース: ' + error.stack);
sendDiscordNotification(
`依存タスクブロッカーチェック中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
/**
* クリティカルパスを計算
*
* タスクの依存関係からクリティカルパス(最長経路)を算出します。
* Kahn's Algorithmによるトポロジカルソート + Forward/Backward Passで計算。
*
* @param {Object} projectData - プロジェクトデータtasks配列を含む
* @return {Object} クリティカルパス情報
* {
* criticalTasks: [task_id, ...], // クリティカルパス上のタスクID配列
* taskMetrics: {
* task_id: {
* es: Number, // 最早開始時刻(日数)
* ef: Number, // 最早終了時刻(日数)
* ls: Number, // 最遅開始時刻(日数)
* lf: Number, // 最遅終了時刻(日数)
* slack: Number // スラック(日数)
* }
* },
* nearCriticalTasks: [task_id, ...], // Near-criticalSlack 1-2日
* projectDuration: Number // プロジェクト全体の所要日数
* }
*/
function calculateCriticalPath(projectData) {
const tasks = projectData.tasks;
// パフォーマンス保護: タスク数が多すぎる場合はスキップ
if (tasks.length > 500) {
Logger.log('[WARNING] Too many tasks (' + tasks.length + '), skipping critical path analysis');
return {
criticalTasks: [],
taskMetrics: {},
nearCriticalTasks: [],
projectDuration: 0,
skipped: true
};
}
// 1. タスクマップ構築ID → タスクオブジェクト)
const taskMap = {};
for (const task of tasks) {
taskMap[task.task_id] = task;
}
// 2. グラフ構築(隣接リスト形式)
const adjacencyList = {}; // task_id → [dependent_task_ids]
const inDegree = {}; // task_id → 入次数
for (const task of tasks) {
adjacencyList[task.task_id] = [];
inDegree[task.task_id] = 0;
}
for (const task of tasks) {
if (task.dependencies && task.dependencies.length > 0) {
for (const depId of task.dependencies) {
if (adjacencyList[depId]) {
adjacencyList[depId].push(task.task_id);
inDegree[task.task_id]++;
}
}
}
}
// 3. トポロジカルソートKahn's Algorithm
const queue = [];
const topologicalOrder = [];
for (const taskId in inDegree) {
if (inDegree[taskId] === 0) {
queue.push(taskId);
}
}
while (queue.length > 0) {
const current = queue.shift();
topologicalOrder.push(current);
for (const neighbor of adjacencyList[current]) {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
// 循環依存チェック
if (topologicalOrder.length !== tasks.length) {
Logger.log('[ERROR] Circular dependency detected in critical path analysis');
return {
criticalTasks: [],
taskMetrics: {},
nearCriticalTasks: [],
projectDuration: 0,
error: 'circular_dependency'
};
}
// 4. Forward PassES/EF計算
const taskMetrics = {};
for (const taskId of topologicalOrder) {
const task = taskMap[taskId];
const duration = calculateTaskDuration(task.start_date, task.end_date);
taskMetrics[taskId] = {
es: 0,
ef: 0,
ls: 0,
lf: 0,
slack: 0,
duration: duration
};
// 先行タスクの最大EFを計算
let maxEF = 0;
if (task.dependencies && task.dependencies.length > 0) {
for (const depId of task.dependencies) {
if (taskMetrics[depId]) {
maxEF = Math.max(maxEF, taskMetrics[depId].ef);
}
}
}
taskMetrics[taskId].es = maxEF;
taskMetrics[taskId].ef = maxEF + duration;
}
// 5. プロジェクト全体の所要時間を計算
let projectDuration = 0;
for (const taskId in taskMetrics) {
projectDuration = Math.max(projectDuration, taskMetrics[taskId].ef);
}
// 6. Backward PassLS/LF計算
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
const taskId = topologicalOrder[i];
const task = taskMap[taskId];
// 後続タスクがない場合はLF = EF
if (adjacencyList[taskId].length === 0) {
taskMetrics[taskId].lf = taskMetrics[taskId].ef;
} else {
// 後続タスクの最小LSを計算
let minLS = Infinity;
for (const successorId of adjacencyList[taskId]) {
if (taskMetrics[successorId]) {
minLS = Math.min(minLS, taskMetrics[successorId].ls);
}
}
taskMetrics[taskId].lf = minLS;
}
taskMetrics[taskId].ls = taskMetrics[taskId].lf - taskMetrics[taskId].duration;
}
// 7. Slack計算とクリティカルパス/Near-critical判定
const criticalTasks = [];
const nearCriticalTasks = [];
for (const taskId in taskMetrics) {
const slack = taskMetrics[taskId].ls - taskMetrics[taskId].es;
taskMetrics[taskId].slack = slack;
if (slack === 0) {
criticalTasks.push(taskId);
} else if (slack > 0 && slack <= 2) {
nearCriticalTasks.push(taskId);
}
}
return {
criticalTasks: criticalTasks,
taskMetrics: taskMetrics,
nearCriticalTasks: nearCriticalTasks,
projectDuration: projectDuration
};
}
/**
* 開始日と終了日から所要日数を計算
*
* @param {string} startDate - 開始日 (YYYY-MM-DD)
* @param {string} endDate - 終了日 (YYYY-MM-DD)
* @return {number} 所要日数
*/
function calculateTaskDuration(startDate, endDate) {
if (!startDate || !endDate) return 1; // デフォルト1日
const start = new Date(startDate);
const end = new Date(endDate);
const duration = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
return duration > 0 ? duration : 1;
}
// ============================================================
// バーンダウンチャート機能
// ============================================================
/**
* 日次バーンダウンデータ記録
*
* トリガー経由で毎日自動実行され、全プロジェクトの進捗状況を記録します。
* 同日のデータが既に存在する場合は上書きします。
*/
function recordDailyBurndownData() {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheetManager = new SheetManager();
const burndownSheet = sheetManager.getOrCreateBurndownSheet();
const today = new Date();
today.setHours(0, 0, 0, 0);
const dateStr = Utilities.formatDate(today, 'Asia/Tokyo', 'yyyy-MM-dd');
// 全Ganttシートを取得プロジェクト識別子で判定
const ganttSheets = ss.getSheets().filter(sheet =>
sheet.getName().match(/^\d{1,4}_.+$/)
);
if (ganttSheets.length === 0) {
Logger.log('[INFO] No Gantt sheets found for burndown recording');
return;
}
Logger.log('[INFO] Recording burndown data for ' + ganttSheets.length + ' projects');
for (const ganttSheet of ganttSheets) {
try {
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
const projectName = projectData.project_name || ganttSheet.getName();
// 進捗率計算
const expectedProgress = calculateExpectedProgress(projectData);
const completedTasks = projectData.tasks.filter(t => t.progress === 100).length;
const totalTasks = projectData.tasks.length;
const actualProgress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
const remainingTasks = totalTasks - completedTasks;
// ベロシティ計算過去7日間
const velocity = calculateVelocity(burndownSheet, projectName, 7);
// 完了予測日計算
let predictedDate = '-';
if (velocity > 0 && remainingTasks > 0) {
const daysToCompletion = Math.ceil(remainingTasks / velocity);
const predicted = new Date(today);
predicted.setDate(predicted.getDate() + daysToCompletion);
predictedDate = Utilities.formatDate(predicted, 'Asia/Tokyo', 'yyyy-MM-dd');
}
// 既存行を検索(同日・同プロジェクト)
const existingRow = findBurndownRow(burndownSheet, today, projectName);
const rowData = [
dateStr,
projectName,
expectedProgress,
actualProgress,
remainingTasks,
completedTasks,
totalTasks,
velocity,
predictedDate
];
if (existingRow > 0) {
// 既存データを上書き
burndownSheet.getRange(existingRow, 1, 1, rowData.length).setValues([rowData]);
Logger.log('[INFO] Updated burndown data for ' + projectName);
} else {
// 新規データを追加
burndownSheet.appendRow(rowData);
Logger.log('[INFO] Recorded new burndown data for ' + projectName);
}
} catch (projectError) {
Logger.log('[ERROR] Failed to record burndown for ' + ganttSheet.getName() + ': ' + projectError.message);
}
}
// 古いデータをクリーンアップ90日超過
cleanupOldBurndownData(burndownSheet, 90);
Logger.log('[INFO] Daily burndown recording completed');
} catch (error) {
Logger.log('[ERROR] recordDailyBurndownData failed: ' + error.message);
}
}
/**
* 予定進捗率の計算
*
* 現在日時を基準に、プロジェクト期間における理想的な進捗率を計算します。
* 計算式: (経過日数 / 全体日数) × 100
*
* @param {Object} projectData - プロジェクトデータ
* @return {Number} 予定進捗率(%、小数第1位まで
*/
function calculateExpectedProgress(projectData) {
try {
const tasks = projectData.tasks || [];
if (tasks.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
// プロジェクト全体の開始日・終了日を取得
let projectStart = null;
let projectEnd = null;
for (const task of tasks) {
if (task.start_date) {
if (!projectStart || task.start_date < projectStart) {
projectStart = task.start_date;
}
}
if (task.end_date) {
if (!projectEnd || task.end_date > projectEnd) {
projectEnd = task.end_date;
}
}
}
if (!projectStart || !projectEnd) {
Logger.log('[WARNING] Cannot calculate expected progress: missing start/end dates');
return 0;
}
// 日数計算
const totalDays = Math.max(1, Math.ceil((projectEnd - projectStart) / (1000 * 60 * 60 * 24)));
const elapsedDays = Math.max(0, Math.ceil((today - projectStart) / (1000 * 60 * 60 * 24)));
// 予定進捗率を計算0100%にクリップ)
const expectedProgress = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
return Math.round(expectedProgress * 10) / 10; // 小数第1位まで
} catch (error) {
Logger.log('[ERROR] calculateExpectedProgress failed: ' + error.message);
return 0;
}
}
/**
* ベロシティ計算過去N日間の平均完了タスク数/日)
*
* @param {Sheet} burndownSheet - バーンダウンデータシート
* @param {String} projectName - プロジェクト名
* @param {Number} days - 過去何日間のデータを使用するか(デフォルト: 7
* @return {Number} ベロシティ小数第2位まで
*/
function calculateVelocity(burndownSheet, projectName, days) {
try {
days = days || 7;
const today = new Date();
today.setHours(0, 0, 0, 0);
const dataRange = burndownSheet.getDataRange();
const values = dataRange.getValues();
// ヘッダー行をスキップし、該当プロジェクトのデータを抽出
const projectData = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
const recordDateStr = row[0]; // A列: 記録日
const recordProjectName = row[1]; // B列: プロジェクト名
const completedTasks = row[5]; // F列: 完了タスク数
if (recordProjectName !== projectName) continue;
// 日付解析
let recordDate;
if (recordDateStr instanceof Date) {
recordDate = new Date(recordDateStr);
} else {
recordDate = new Date(recordDateStr);
}
recordDate.setHours(0, 0, 0, 0);
// 過去N日間のデータのみ
const daysDiff = Math.ceil((today - recordDate) / (1000 * 60 * 60 * 24));
if (daysDiff >= 0 && daysDiff < days) {
projectData.push({
date: recordDate,
completedTasks: completedTasks
});
}
}
if (projectData.length < 2) {
// データ不足の場合はベロシティ計算不可
return 0;
}
// 日付でソート(古い順)
projectData.sort((a, b) => a.date - b.date);
// 完了タスク数の差分を計算
const oldest = projectData[0].completedTasks;
const newest = projectData[projectData.length - 1].completedTasks;
const taskDiff = newest - oldest;
const dayDiff = Math.max(1, projectData.length - 1);
const velocity = taskDiff / dayDiff;
return Math.round(velocity * 100) / 100; // 小数第2位まで
} catch (error) {
Logger.log('[ERROR] calculateVelocity failed: ' + error.message);
return 0;
}
}
/**
* バーンダウンシートから指定日・プロジェクトの行番号を検索
*
* @param {Sheet} burndownSheet - バーンダウンデータシート
* @param {Date} date - 記録日
* @param {String} projectName - プロジェクト名
* @return {Number} 行番号見つからない場合は0
*/
function findBurndownRow(burndownSheet, date, projectName) {
try {
const dateStr = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy-MM-dd');
const dataRange = burndownSheet.getDataRange();
const values = dataRange.getValues();
for (let i = 1; i < values.length; i++) { // ヘッダー行をスキップ
const row = values[i];
const recordDateStr = row[0]; // A列: 記録日
const recordProjectName = row[1]; // B列: プロジェクト名
// 日付文字列に変換して比較
let compareDateStr;
if (recordDateStr instanceof Date) {
compareDateStr = Utilities.formatDate(recordDateStr, 'Asia/Tokyo', 'yyyy-MM-dd');
} else {
compareDateStr = recordDateStr;
}
if (compareDateStr === dateStr && recordProjectName === projectName) {
return i + 1; // 行番号1-indexed
}
}
return 0; // 見つからない
} catch (error) {
Logger.log('[ERROR] findBurndownRow failed: ' + error.message);
return 0;
}
}
/**
* 古いバーンダウンデータの削除(指定日数より前のデータ)
*
* @param {Sheet} burndownSheet - バーンダウンデータシート
* @param {Number} days - 保持日数(デフォルト: 90日
*/
function cleanupOldBurndownData(burndownSheet, days) {
try {
days = days || 90;
const today = new Date();
today.setHours(0, 0, 0, 0);
const cutoffDate = new Date(today);
cutoffDate.setDate(cutoffDate.getDate() - days);
const dataRange = burndownSheet.getDataRange();
const values = dataRange.getValues();
// 削除対象行を収集(後ろから削除するため逆順)
const rowsToDelete = [];
for (let i = 1; i < values.length; i++) { // ヘッダー行をスキップ
const row = values[i];
const recordDateStr = row[0]; // A列: 記録日
let recordDate;
if (recordDateStr instanceof Date) {
recordDate = new Date(recordDateStr);
} else {
recordDate = new Date(recordDateStr);
}
recordDate.setHours(0, 0, 0, 0);
if (recordDate < cutoffDate) {
rowsToDelete.push(i + 1); // 行番号1-indexed
}
}
// 後ろから順に削除(行番号がずれないように)
rowsToDelete.reverse();
for (const rowNum of rowsToDelete) {
burndownSheet.deleteRow(rowNum);
}
if (rowsToDelete.length > 0) {
Logger.log('[INFO] Cleaned up ' + rowsToDelete.length + ' old burndown records (older than ' + days + ' days)');
}
} catch (error) {
Logger.log('[ERROR] cleanupOldBurndownData failed: ' + error.message);
}
}
/**
* バーンダウンチャート日次記録トリガーのセットアップ
*
* この関数を実行すると、毎日午前9時に recordDailyBurndownData() を自動実行する
* トリガーが登録されます。既存のトリガーがある場合は重複登録されません。
*
* GASエディタから手動で1回実行してください。
*/
function setupDailyBurndownTrigger() {
try {
const functionName = 'recordDailyBurndownData';
// 既存のトリガーを確認
const triggers = ScriptApp.getProjectTriggers();
const existingTrigger = triggers.find(trigger =>
trigger.getHandlerFunction() === functionName
);
if (existingTrigger) {
Logger.log('[INFO] Daily burndown trigger already exists');
Logger.log('✅ トリガーは既に設定されています毎日午前9時実行');
return;
}
// 新規トリガー作成毎日午前9時
ScriptApp.newTrigger(functionName)
.timeBased()
.atHour(9)
.everyDays(1)
.create();
Logger.log('[INFO] Daily burndown trigger created successfully');
Logger.log('✅ トリガーを作成しました!');
Logger.log('📅 毎日午前9時にバーンダウンデータが自動記録されます');
} catch (error) {
Logger.log('[ERROR] Failed to setup trigger: ' + error.message);
Logger.log('❌ トリガー設定に失敗しました: ' + error.message);
}
}
/**
* バーンダウンチャート日次記録トリガーの削除
*
* 自動記録を停止したい場合にこの関数を実行してください。
*/
function removeDailyBurndownTrigger() {
try {
const functionName = 'recordDailyBurndownData';
const triggers = ScriptApp.getProjectTriggers();
let removedCount = 0;
for (const trigger of triggers) {
if (trigger.getHandlerFunction() === functionName) {
ScriptApp.deleteTrigger(trigger);
removedCount++;
}
}
if (removedCount > 0) {
Logger.log('[INFO] Removed ' + removedCount + ' trigger(s)');
Logger.log('✅ トリガーを削除しました(' + removedCount + '件)');
Logger.log('⏸️ バーンダウンデータの自動記録が停止されました');
} else {
Logger.log('[INFO] No triggers found');
Logger.log(' 削除対象のトリガーはありませんでした');
}
} catch (error) {
Logger.log('[ERROR] Failed to remove trigger: ' + error.message);
Logger.log('❌ トリガー削除に失敗しました: ' + error.message);
}
}
/**
* 全シート同期トリガーのセットアップ
*
* この関数を実行すると、毎日午前10時に syncAllSheets() を自動実行する
* トリガーが登録されます。既存のトリガーがある場合は重複登録されません。
*
* GASエディタから手動で1回実行してください。
*/
function setupSyncAllSheetsTrigger() {
try {
const functionName = 'syncAllSheets';
// 既存のトリガーを確認
const triggers = ScriptApp.getProjectTriggers();
const existingTrigger = triggers.find(trigger =>
trigger.getHandlerFunction() === functionName
);
if (existingTrigger) {
Logger.log('[INFO] Sync all sheets trigger already exists');
Logger.log('✅ トリガーは既に設定されています毎日午前10時実行');
return;
}
// 新規トリガー作成毎日午前10時
ScriptApp.newTrigger(functionName)
.timeBased()
.atHour(10)
.everyDays(1)
.create();
Logger.log('[INFO] Sync all sheets trigger created successfully');
Logger.log('✅ トリガーを作成しました!');
Logger.log('📅 毎日午前10時に全シート同期が自動実行されます');
Logger.log('📊 プロジェクト一覧、全タスク、期日切れタスクが更新されます');
} catch (error) {
Logger.log('[ERROR] Failed to setup trigger: ' + error.message);
Logger.log('❌ トリガー設定に失敗しました: ' + error.message);
}
}
/**
* 全シート同期トリガーの削除
*
* 自動同期を停止したい場合にこの関数を実行してください。
*/
function removeSyncAllSheetsTrigger() {
try {
const functionName = 'syncAllSheets';
const triggers = ScriptApp.getProjectTriggers();
let removedCount = 0;
for (const trigger of triggers) {
if (trigger.getHandlerFunction() === functionName) {
ScriptApp.deleteTrigger(trigger);
removedCount++;
}
}
if (removedCount > 0) {
Logger.log('[INFO] Removed ' + removedCount + ' trigger(s)');
Logger.log('✅ トリガーを削除しました(' + removedCount + '件)');
Logger.log('⏸️ 全シート同期の自動実行が停止されました');
} else {
Logger.log('[INFO] No triggers found');
Logger.log(' 削除対象のトリガーはありませんでした');
}
} catch (error) {
Logger.log('[ERROR] Failed to remove trigger: ' + error.message);
Logger.log('❌ トリガー削除に失敗しました: ' + error.message);
}
}
/**
* プロジェクト健全性レポート生成
*
* @param {Object} projectData - プロジェクトデータ
* @param {Date} today - 基準日
* @return {Object} レポートオブジェクト
*/
function generateProjectHealthReport(projectData, today) {
try {
today = today || new Date();
today.setHours(0, 0, 0, 0);
const tasks = projectData.tasks || [];
const projectName = projectData.project_name || 'プロジェクト';
// 基本統計
const totalTasks = tasks.length;
let completedTasks = 0;
let overdueTasks = 0;
let totalProgress = 0;
// 担当者別ワークロード
const assigneeWorkload = {};
// 今週期限、来週開始のタスク
const thisWeekDueTasks = [];
const nextWeekStartTasks = [];
// 今週の範囲(日曜日~土曜日)
const thisWeekEnd = new Date(today);
thisWeekEnd.setDate(today.getDate() + (6 - today.getDay())); // 次の土曜日
thisWeekEnd.setHours(23, 59, 59, 999);
// 来週の範囲
const nextWeekStart = new Date(thisWeekEnd);
nextWeekStart.setDate(thisWeekEnd.getDate() + 1);
nextWeekStart.setHours(0, 0, 0, 0);
const nextWeekEnd = new Date(nextWeekStart);
nextWeekEnd.setDate(nextWeekStart.getDate() + 6);
nextWeekEnd.setHours(23, 59, 59, 999);
// プロジェクト全体の開始日・終了日を取得
let projectStartDate = null;
let projectEndDate = null;
for (const task of tasks) {
// 進捗率の正規化
const progress = task.progress || 0;
const progressPercent = progress <= 1 ? progress * 100 : progress;
totalProgress += progressPercent;
if (progressPercent >= 100) {
completedTasks++;
}
// 期日切れチェック
if (task.end_date) {
const endDate = new Date(task.end_date);
endDate.setHours(0, 0, 0, 0);
if (endDate < today && progressPercent < 100) {
overdueTasks++;
}
// 今週期限のタスク
if (endDate >= today && endDate <= thisWeekEnd && progressPercent < 100) {
thisWeekDueTasks.push(task);
}
// プロジェクト終了日の更新
if (!projectEndDate || endDate > projectEndDate) {
projectEndDate = endDate;
}
}
// 開始日チェック
if (task.start_date) {
const startDate = new Date(task.start_date);
startDate.setHours(0, 0, 0, 0);
// 来週開始のタスク
if (startDate >= nextWeekStart && startDate <= nextWeekEnd) {
nextWeekStartTasks.push(task);
}
// プロジェクト開始日の更新
if (!projectStartDate || startDate < projectStartDate) {
projectStartDate = startDate;
}
}
// 担当者別ワークロード
const assignee = task.assignee || '未割り当て';
if (!assigneeWorkload[assignee]) {
assigneeWorkload[assignee] = {
totalTasks: 0,
completedTasks: 0,
inProgressTasks: 0,
overdueTasks: 0
};
}
assigneeWorkload[assignee].totalTasks++;
if (progressPercent >= 100) {
assigneeWorkload[assignee].completedTasks++;
} else if (progressPercent > 0) {
assigneeWorkload[assignee].inProgressTasks++;
}
if (task.end_date) {
const endDate = new Date(task.end_date);
endDate.setHours(0, 0, 0, 0);
if (endDate < today && progressPercent < 100) {
assigneeWorkload[assignee].overdueTasks++;
}
}
}
// 平均進捗率
const avgProgress = totalTasks > 0 ? totalProgress / totalTasks : 0;
// 予定進捗率の計算
let expectedProgress = 0;
let riskScore = 0;
let riskLevel = '🟢低';
if (projectStartDate && projectEndDate) {
const totalDuration = projectEndDate - projectStartDate;
const elapsedDuration = today - projectStartDate;
if (totalDuration > 0 && elapsedDuration >= 0) {
expectedProgress = (elapsedDuration / totalDuration) * 100;
expectedProgress = Math.min(100, Math.max(0, expectedProgress)); // 0-100%に制限
// リスクスコア計算
riskScore = expectedProgress - avgProgress;
// リスクレベル判定
if (riskScore > 20) {
riskLevel = '🔴高';
} else if (riskScore > 10) {
riskLevel = '🟡中';
} else {
riskLevel = '🟢低';
}
}
}
return {
projectName: projectName,
totalTasks: totalTasks,
completedTasks: completedTasks,
overdueTasks: overdueTasks,
avgProgress: Math.round(avgProgress * 10) / 10, // 小数点1桁
expectedProgress: Math.round(expectedProgress * 10) / 10,
riskScore: Math.round(riskScore * 10) / 10,
riskLevel: riskLevel,
assigneeWorkload: assigneeWorkload,
thisWeekDueTasks: thisWeekDueTasks,
nextWeekStartTasks: nextWeekStartTasks
};
} catch (error) {
Logger.log(`✗ プロジェクト健全性レポート生成エラー: ${error.message}`);
throw error;
}
}
/**
* 週次プロジェクトレポート送信
*/
function sendWeeklyReport() {
try {
Logger.log('=== 週次レポート生成開始 ===');
const today = new Date();
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/);
});
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
const reports = [];
let totalProjects = 0;
let totalTasks = 0;
let totalCompleted = 0;
let totalOverdue = 0;
let highRiskProjects = 0;
let mediumRiskProjects = 0;
let lowRiskProjects = 0;
// 各プロジェクトのレポート生成
for (const ganttSheet of ganttSheets) {
try {
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) continue;
const report = generateProjectHealthReport(projectData, today);
reports.push(report);
totalProjects++;
totalTasks += report.totalTasks;
totalCompleted += report.completedTasks;
totalOverdue += report.overdueTasks;
if (report.riskLevel === '🔴高') {
highRiskProjects++;
} else if (report.riskLevel === '🟡中') {
mediumRiskProjects++;
} else {
lowRiskProjects++;
}
} catch (error) {
Logger.log(`✗ レポート生成エラー (${ganttSheet.getName()}): ${error.message}`);
}
}
// 週次サマリー
const overallProgress = totalTasks > 0
? Math.round((totalCompleted / totalTasks) * 100)
: 0;
let summaryText = `**📊 週次プロジェクトレポート**\n\n`;
summaryText += `**全体サマリー**\n`;
summaryText += `• プロジェクト数: ${totalProjects}件\n`;
summaryText += `• 全タスク数: ${totalTasks}件\n`;
summaryText += `• 完了タスク: ${totalCompleted}件 (${overallProgress}%)\n`;
summaryText += `• 期日切れ: ${totalOverdue}件\n`;
summaryText += `• リスク状況: 🔴${highRiskProjects}件 / 🟡${mediumRiskProjects}件 / 🟢${lowRiskProjects}件\n\n`;
// プロジェクト別詳細(リスク高い順)
const sortedReports = reports.sort((a, b) => {
const riskOrder = { '🔴高': 3, '🟡中': 2, '🟢低': 1 };
return riskOrder[b.riskLevel] - riskOrder[a.riskLevel];
});
summaryText += `**プロジェクト別状況**\n`;
for (const report of sortedReports.slice(0, 10)) {
const progressPercent = report.totalTasks > 0
? Math.round((report.completedTasks / report.totalTasks) * 100)
: 0;
summaryText += `\n${report.riskLevel} **${report.projectName}**\n`;
summaryText += ` 進捗: ${progressPercent}% (${report.completedTasks}/${report.totalTasks}件)`;
if (report.overdueTasks > 0) {
summaryText += ` | 期日切れ: ${report.overdueTasks}件`;
}
if (report.thisWeekDueTasks.length > 0) {
summaryText += ` | 今週期限: ${report.thisWeekDueTasks.length}件`;
}
summaryText += `\n`;
}
if (sortedReports.length > 10) {
summaryText += `\n... 他${sortedReports.length - 10}件のプロジェクト\n`;
}
// 今週期限・来週開始のタスク集計
const allThisWeekDueTasks = [];
const allNextWeekStartTasks = [];
for (const report of reports) {
for (const task of report.thisWeekDueTasks) {
allThisWeekDueTasks.push({
projectName: report.projectName,
...task
});
}
for (const task of report.nextWeekStartTasks) {
allNextWeekStartTasks.push({
projectName: report.projectName,
...task
});
}
}
// 今週期限タスク
if (allThisWeekDueTasks.length > 0) {
summaryText += `\n**📅 今週完了予定タスク (${allThisWeekDueTasks.length}件)**\n`;
for (const task of allThisWeekDueTasks.slice(0, 10)) {
const taskName = task.task_name || task.task_id;
const endDate = new Date(task.end_date);
const dateStr = `${endDate.getMonth() + 1}/${endDate.getDate()}`;
summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`;
}
if (allThisWeekDueTasks.length > 10) {
summaryText += `... 他${allThisWeekDueTasks.length - 10}件\n`;
}
}
// 来週開始タスク
if (allNextWeekStartTasks.length > 0) {
summaryText += `\n**🚀 来週開始予定タスク (${allNextWeekStartTasks.length}件)**\n`;
for (const task of allNextWeekStartTasks.slice(0, 10)) {
const taskName = task.task_name || task.task_id;
const startDate = new Date(task.start_date);
const dateStr = `${startDate.getMonth() + 1}/${startDate.getDate()}`;
summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`;
}
if (allNextWeekStartTasks.length > 10) {
summaryText += `... 他${allNextWeekStartTasks.length - 10}件\n`;
}
}
// Discord通知送信
const payload = {
embeds: [{
title: '📊 週次プロジェクトレポート',
description: summaryText,
color: highRiskProjects > 0 ? 0xFF6B6B : (mediumRiskProjects > 0 ? 0xFFA726 : 0x66BB6A),
footer: { text: `生成日時: ${today.toLocaleDateString('ja-JP')}` },
timestamp: new Date().toISOString()
}]
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
Logger.log(`✓ 週次レポート送信完了: ${response.getResponseCode()}`);
Logger.log(`✓ ${totalProjects}件のプロジェクトをレポート`);
} catch (error) {
Logger.log('✗ 週次レポート生成エラー: ' + error.message);
sendDiscordNotification(
`週次レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
/**
* 月次プロジェクトレポート送信
*/
function sendMonthlyReport() {
try {
Logger.log('=== 月次レポート生成開始 ===');
const today = new Date();
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/);
});
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
const reports = [];
let totalProjects = 0;
let totalTasks = 0;
let totalCompleted = 0;
let totalOverdue = 0;
let highRiskProjects = 0;
let mediumRiskProjects = 0;
let lowRiskProjects = 0;
// 先月・今月の範囲を計算
const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1);
thisMonthStart.setHours(0, 0, 0, 0);
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
thisMonthEnd.setHours(23, 59, 59, 999);
const lastMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
lastMonthStart.setHours(0, 0, 0, 0);
const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0);
lastMonthEnd.setHours(23, 59, 59, 999);
let lastMonthCompletedCount = 0;
let thisMonthTargetCount = 0;
// 各プロジェクトのレポート生成
for (const ganttSheet of ganttSheets) {
try {
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) continue;
const report = generateProjectHealthReport(projectData, today);
reports.push(report);
totalProjects++;
totalTasks += report.totalTasks;
totalCompleted += report.completedTasks;
totalOverdue += report.overdueTasks;
if (report.riskLevel === '🔴高') {
highRiskProjects++;
} else if (report.riskLevel === '🟡中') {
mediumRiskProjects++;
} else {
lowRiskProjects++;
}
// 先月完了タスクと今月目標タスクのカウント
for (const task of projectData.tasks) {
const progress = task.progress || 0;
const progressPercent = progress <= 1 ? progress * 100 : progress;
// 先月完了したタスク先月中に100%になったもの)
if (progressPercent >= 100 && task.end_date) {
const endDate = new Date(task.end_date);
if (endDate >= lastMonthStart && endDate <= lastMonthEnd) {
lastMonthCompletedCount++;
}
}
// 今月期限のタスク(未完了)
if (progressPercent < 100 && task.end_date) {
const endDate = new Date(task.end_date);
if (endDate >= thisMonthStart && endDate <= thisMonthEnd) {
thisMonthTargetCount++;
}
}
}
} catch (error) {
Logger.log(`✗ レポート生成エラー (${ganttSheet.getName()}): ${error.message}`);
}
}
// 月次サマリー
const overallProgress = totalTasks > 0
? Math.round((totalCompleted / totalTasks) * 100)
: 0;
let summaryText = `**📊 月次プロジェクトレポート**\n\n`;
summaryText += `**全体サマリー**\n`;
summaryText += `• プロジェクト数: ${totalProjects}件\n`;
summaryText += `• 全タスク数: ${totalTasks}件\n`;
summaryText += `• 完了タスク: ${totalCompleted}件 (${overallProgress}%)\n`;
summaryText += `• 期日切れ: ${totalOverdue}件\n`;
summaryText += `• リスク状況: 🔴${highRiskProjects}件 / 🟡${mediumRiskProjects}件 / 🟢${lowRiskProjects}件\n\n`;
// 月次固有情報
summaryText += `**月次統計**\n`;
summaryText += `• 先月完了タスク: ${lastMonthCompletedCount}件\n`;
summaryText += `• 今月目標タスク: ${thisMonthTargetCount}件\n\n`;
// プロジェクト別詳細(リスク高い順)
const sortedReports = reports.sort((a, b) => {
const riskOrder = { '🔴高': 3, '🟡中': 2, '🟢低': 1 };
return riskOrder[b.riskLevel] - riskOrder[a.riskLevel];
});
summaryText += `**プロジェクト別状況**\n`;
for (const report of sortedReports.slice(0, 10)) {
const progressPercent = report.totalTasks > 0
? Math.round((report.completedTasks / report.totalTasks) * 100)
: 0;
summaryText += `\n${report.riskLevel} **${report.projectName}**\n`;
summaryText += ` 進捗: ${progressPercent}% (${report.completedTasks}/${report.totalTasks}件)`;
if (report.overdueTasks > 0) {
summaryText += ` | 期日切れ: ${report.overdueTasks}件`;
}
summaryText += `\n`;
}
if (sortedReports.length > 10) {
summaryText += `\n... 他${sortedReports.length - 10}件のプロジェクト\n`;
}
// 今週期限・来週開始のタスク集計
const allThisWeekDueTasks = [];
const allNextWeekStartTasks = [];
for (const report of reports) {
for (const task of report.thisWeekDueTasks) {
allThisWeekDueTasks.push({
projectName: report.projectName,
...task
});
}
for (const task of report.nextWeekStartTasks) {
allNextWeekStartTasks.push({
projectName: report.projectName,
...task
});
}
}
// 今週期限タスク
if (allThisWeekDueTasks.length > 0) {
summaryText += `\n**📅 今週完了予定タスク (${allThisWeekDueTasks.length}件)**\n`;
for (const task of allThisWeekDueTasks.slice(0, 10)) {
const taskName = task.task_name || task.task_id;
const endDate = new Date(task.end_date);
const dateStr = `${endDate.getMonth() + 1}/${endDate.getDate()}`;
summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`;
}
if (allThisWeekDueTasks.length > 10) {
summaryText += `... 他${allThisWeekDueTasks.length - 10}件\n`;
}
}
// 来週開始タスク
if (allNextWeekStartTasks.length > 0) {
summaryText += `\n**🚀 来週開始予定タスク (${allNextWeekStartTasks.length}件)**\n`;
for (const task of allNextWeekStartTasks.slice(0, 10)) {
const taskName = task.task_name || task.task_id;
const startDate = new Date(task.start_date);
const dateStr = `${startDate.getMonth() + 1}/${startDate.getDate()}`;
summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`;
}
if (allNextWeekStartTasks.length > 10) {
summaryText += `... 他${allNextWeekStartTasks.length - 10}件\n`;
}
}
// Discord通知送信
const payload = {
embeds: [{
title: '📊 月次プロジェクトレポート',
description: summaryText,
color: highRiskProjects > 0 ? 0xFF6B6B : (mediumRiskProjects > 0 ? 0xFFA726 : 0x66BB6A),
footer: { text: `生成日時: ${today.toLocaleDateString('ja-JP')}` },
timestamp: new Date().toISOString()
}]
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
Logger.log(`✓ 月次レポート送信完了: ${response.getResponseCode()}`);
Logger.log(`✓ ${totalProjects}件のプロジェクトをレポート`);
} catch (error) {
Logger.log('✗ 月次レポート生成エラー: ' + error.message);
sendDiscordNotification(
`月次レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
/**
* 見積精度計算
*
* @param {Object} projectData - プロジェクトデータ
* @return {Object} 精度分析レポート
*/
function calculateEstimationAccuracy(projectData) {
try {
const projectName = projectData.project_name || 'プロジェクト';
const tasks = projectData.tasks || [];
// 実績工数が記録されている完了タスクを抽出
const completedTasksWithActual = [];
const assigneeData = {};
for (const task of tasks) {
const progress = task.progress || 0;
const progressPercent = progress <= 1 ? progress * 100 : progress;
// 完了タスクかつ見積・実績の両方がある
if (progressPercent >= 100 && task.estimated_hours && task.actual_hours) {
const estimated = parseFloat(task.estimated_hours);
const actual = parseFloat(task.actual_hours);
if (estimated > 0 && actual > 0) {
// 誤差率計算: (実績 - 見積) / 見積 × 100
const errorRate = ((actual - estimated) / estimated) * 100;
const absoluteError = Math.abs(errorRate);
const taskAccuracy = {
task_id: task.task_id,
task_name: task.task_name || task.task_id,
assignee: task.assignee || '未割り当て',
estimated: estimated,
actual: actual,
errorRate: Math.round(errorRate * 10) / 10, // 小数点1桁
absoluteError: Math.round(absoluteError * 10) / 10
};
completedTasksWithActual.push(taskAccuracy);
// 担当者別データの集計
const assignee = task.assignee || '未割り当て';
if (!assigneeData[assignee]) {
assigneeData[assignee] = {
totalTasks: 0,
totalEstimated: 0,
totalActual: 0,
totalErrorRate: 0
};
}
assigneeData[assignee].totalTasks++;
assigneeData[assignee].totalEstimated += estimated;
assigneeData[assignee].totalActual += actual;
assigneeData[assignee].totalErrorRate += errorRate;
}
}
}
// プロジェクト全体の精度
let overallAccuracy = 0;
let totalEstimated = 0;
let totalActual = 0;
if (completedTasksWithActual.length > 0) {
for (const taskAcc of completedTasksWithActual) {
totalEstimated += taskAcc.estimated;
totalActual += taskAcc.actual;
}
if (totalEstimated > 0) {
overallAccuracy = ((totalActual - totalEstimated) / totalEstimated) * 100;
overallAccuracy = Math.round(overallAccuracy * 10) / 10;
}
}
// 担当者別精度
const assigneeAccuracy = [];
for (const assignee in assigneeData) {
const data = assigneeData[assignee];
const avgErrorRate = data.totalTasks > 0
? data.totalErrorRate / data.totalTasks
: 0;
assigneeAccuracy.push({
assignee: assignee,
totalTasks: data.totalTasks,
totalEstimated: Math.round(data.totalEstimated * 10) / 10,
totalActual: Math.round(data.totalActual * 10) / 10,
avgErrorRate: Math.round(avgErrorRate * 10) / 10
});
}
// 誤差率順にソート
assigneeAccuracy.sort((a, b) => Math.abs(b.avgErrorRate) - Math.abs(a.avgErrorRate));
// 最も誤差が大きいタスク上位5件
const worstTasks = completedTasksWithActual
.sort((a, b) => b.absoluteError - a.absoluteError)
.slice(0, 5);
return {
projectName: projectName,
overallAccuracy: overallAccuracy,
completedTasksWithActual: completedTasksWithActual.length,
totalEstimated: Math.round(totalEstimated * 10) / 10,
totalActual: Math.round(totalActual * 10) / 10,
assigneeAccuracy: assigneeAccuracy,
worstTasks: worstTasks
};
} catch (error) {
Logger.log(`✗ 見積精度計算エラー: ${error.message}`);
throw error;
}
}
/**
* 見積精度レポート送信
*/
function sendEstimationAccuracyReport() {
try {
Logger.log('=== 見積精度レポート生成開始 ===');
const sheetManager = new SheetManager();
const ss = sheetManager.ss;
const allSheets = ss.getSheets();
const ganttSheets = allSheets.filter(sheet => {
const name = sheet.getName();
return name.match(/^\d{1,4}_.+$/);
});
if (ganttSheets.length === 0) {
Logger.log('⚠ Ganttシートが見つかりませんでした。');
return;
}
const accuracyReports = [];
let totalProjects = 0;
let totalTasksWithActual = 0;
let overallTotalEstimated = 0;
let overallTotalActual = 0;
const allAssigneeData = {};
const allWorstTasks = [];
// 各プロジェクトの精度分析
for (const ganttSheet of ganttSheets) {
try {
const projectData = sheetManager.readProjectDataFromSheet(ganttSheet);
if (!projectData) continue;
const accuracy = calculateEstimationAccuracy(projectData);
if (accuracy.completedTasksWithActual > 0) {
accuracyReports.push(accuracy);
totalProjects++;
totalTasksWithActual += accuracy.completedTasksWithActual;
overallTotalEstimated += accuracy.totalEstimated;
overallTotalActual += accuracy.totalActual;
// 担当者別データの統合
for (const assigneeAcc of accuracy.assigneeAccuracy) {
if (!allAssigneeData[assigneeAcc.assignee]) {
allAssigneeData[assigneeAcc.assignee] = {
totalTasks: 0,
totalEstimated: 0,
totalActual: 0
};
}
allAssigneeData[assigneeAcc.assignee].totalTasks += assigneeAcc.totalTasks;
allAssigneeData[assigneeAcc.assignee].totalEstimated += assigneeAcc.totalEstimated;
allAssigneeData[assigneeAcc.assignee].totalActual += assigneeAcc.totalActual;
}
// ワーストタスク収集
for (const worstTask of accuracy.worstTasks) {
allWorstTasks.push({
projectName: accuracy.projectName,
...worstTask
});
}
}
} catch (error) {
Logger.log(`✗ 精度計算エラー (${ganttSheet.getName()}): ${error.message}`);
}
}
if (totalProjects === 0) {
Logger.log('⚠ 見積・実績の両方が記録されているタスクがありません。');
sendDiscordNotification('見積精度レポート: 分析対象のタスクがありません。', false);
return;
}
// 全体精度
const overallAccuracy = overallTotalEstimated > 0
? ((overallTotalActual - overallTotalEstimated) / overallTotalEstimated) * 100
: 0;
// 担当者別精度の計算
const assigneeAccuracyList = [];
for (const assignee in allAssigneeData) {
const data = allAssigneeData[assignee];
const errorRate = data.totalEstimated > 0
? ((data.totalActual - data.totalEstimated) / data.totalEstimated) * 100
: 0;
assigneeAccuracyList.push({
assignee: assignee,
totalTasks: data.totalTasks,
totalEstimated: Math.round(data.totalEstimated * 10) / 10,
totalActual: Math.round(data.totalActual * 10) / 10,
errorRate: Math.round(errorRate * 10) / 10
});
}
// 誤差率順にソート(絶対値)
assigneeAccuracyList.sort((a, b) => Math.abs(b.errorRate) - Math.abs(a.errorRate));
// ワーストタスク上位10件
const topWorstTasks = allWorstTasks
.sort((a, b) => b.absoluteError - a.absoluteError)
.slice(0, 10);
// Discord通知作成
let summaryText = `**📊 見積精度レポート**\n\n`;
summaryText += `**全体サマリー**\n`;
summaryText += `• 分析プロジェクト数: ${totalProjects}件\n`;
summaryText += `• 分析タスク数: ${totalTasksWithActual}件\n`;
summaryText += `• 総見積工数: ${Math.round(overallTotalEstimated * 10) / 10}h\n`;
summaryText += `• 総実績工数: ${Math.round(overallTotalActual * 10) / 10}h\n`;
summaryText += `• 全体精度: ${Math.round(overallAccuracy * 10) / 10}% ${overallAccuracy > 0 ? '(超過)' : overallAccuracy < 0 ? '(余裕)' : ''}\n\n`;
// プロジェクト別精度(誤差大きい順)
const sortedProjects = accuracyReports.sort((a, b) =>
Math.abs(b.overallAccuracy) - Math.abs(a.overallAccuracy)
);
summaryText += `**プロジェクト別精度**\n`;
for (const report of sortedProjects.slice(0, 10)) {
summaryText += `\n**${report.projectName}**\n`;
summaryText += ` 見積: ${report.totalEstimated}h / 実績: ${report.totalActual}h\n`;
summaryText += ` 精度: ${report.overallAccuracy}% (${report.completedTasksWithActual}タスク)\n`;
}
if (sortedProjects.length > 10) {
summaryText += `\n... 他${sortedProjects.length - 10}件のプロジェクト\n`;
}
// 担当者別精度
if (assigneeAccuracyList.length > 0) {
summaryText += `\n**担当者別精度**\n`;
for (const assigneeAcc of assigneeAccuracyList.slice(0, 10)) {
summaryText += `\n**${assigneeAcc.assignee}**\n`;
summaryText += ` 見積: ${assigneeAcc.totalEstimated}h / 実績: ${assigneeAcc.totalActual}h\n`;
summaryText += ` 精度: ${assigneeAcc.errorRate}% (${assigneeAcc.totalTasks}タスク)\n`;
}
if (assigneeAccuracyList.length > 10) {
summaryText += `\n... 他${assigneeAccuracyList.length - 10}人の担当者\n`;
}
}
// 見積誤差が大きいタスク
if (topWorstTasks.length > 0) {
summaryText += `\n**⚠️ 見積誤差の大きいタスク**\n`;
for (const worstTask of topWorstTasks.slice(0, 5)) {
const sign = worstTask.errorRate > 0 ? '+' : '';
summaryText += `\n• **${worstTask.task_name}** (${worstTask.projectName})\n`;
summaryText += ` 見積: ${worstTask.estimated}h / 実績: ${worstTask.actual}h / 誤差: ${sign}${worstTask.errorRate}%\n`;
}
if (topWorstTasks.length > 5) {
summaryText += `\n... 他${topWorstTasks.length - 5}件のタスク\n`;
}
}
// Discord通知送信
const payload = {
embeds: [{
title: '📊 見積精度レポート',
description: summaryText,
color: Math.abs(overallAccuracy) > 20 ? 0xFF6B6B : (Math.abs(overallAccuracy) > 10 ? 0xFFA726 : 0x66BB6A),
footer: { text: `分析対象: ${totalTasksWithActual}件のタスク` },
timestamp: new Date().toISOString()
}]
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
Logger.log(`✓ 見積精度レポート送信完了: ${response.getResponseCode()}`);
Logger.log(`✓ ${totalProjects}件のプロジェクト、${totalTasksWithActual}件のタスクを分析`);
} catch (error) {
Logger.log('✗ 見積精度レポート生成エラー: ' + error.message);
sendDiscordNotification(
`見積精度レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`,
true
);
throw error;
}
}
// ============================================
// テスト関数
// ============================================
/**
* 依存タスクブロッカー検知機能のテスト
*
* 実行方法: GASエディタでこの関数を選択して実行
* 結果確認: ログビューアとDiscordチャンネルで確認
*/
/**
* 日付フォーマット用ヘルパー関数
* @param {Date} date - フォーマット対象の日付
* @return {string} YYYY-MM-DD形式の文字列
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// ============================================
// 変更検知機能(パフォーマンス最適化)
// ============================================
/**
* Ganttシートの最終同期日時を取得
* @param {Sheet} sheet - Ganttシート
* @returns {Date|null} - 最終同期日時未同期の場合はnull
*/
function getLastSyncTime(sheet) {
try {
const props = PropertiesService.getDocumentProperties();
const key = `lastSync_${sheet.getName()}`;
const value = props.getProperty(key);
return value ? new Date(value) : null;
} catch (error) {
return null;
}
}
/**
* Ganttシートの最終同期日時を記録
* @param {Sheet} sheet - Ganttシート
*/
function setLastSyncTime(sheet) {
try {
const props = PropertiesService.getDocumentProperties();
const key = `lastSync_${sheet.getName()}`;
const now = new Date().toISOString();
props.setProperty(key, now);
} catch (error) {
Logger.log(`⚠ 最終同期日時の記録失敗: ${sheet.getName()} - ${error.message}`);
}
}
/**
* Ganttシートが同期が必要かどうか判定
* @param {Sheet} sheet - Ganttシート
* @param {number} minIntervalMinutes - 最小同期間隔デフォルト5分
* @returns {boolean} - 同期が必要ならtrue
*/
function shouldSyncSheet(sheet, minIntervalMinutes = 5) {
const lastSyncTime = getLastSyncTime(sheet);
// 初回同期(最終同期日時が未設定)
if (!lastSyncTime) {
return true;
}
// 現在時刻との差分を計算(分単位)
const now = new Date();
const diffMinutes = (now - lastSyncTime) / (1000 * 60);
// 最小間隔を経過していれば同期が必要
return diffMinutes >= minIntervalMinutes;
}