【テクニカル・上級編】初心者向け:特定のタスクタイプに定型制約を適用する「プリセット」機能 – Project VBA解析バイブル

スポンサーリンク
スポンサーリンク

スケジュール計算エンジンの決定論的制御:タスク制約プリセットの実装における極限の設計論

プロジェクト管理の現場において、MS Projectは単なる「ガントチャートを描画するツール」ではない。それは、膨大な依存関係とリソース制約が絡み合う、動的なスケジュール計算エンジンである。

多くの開発者が「タスクタイプに応じて制約(Constraint)を自動設定する」という機能を実装しようとする際、彼らは単なる `If…Then` 文の羅列、あるいは使い捨てのプロシージャを作成して終わる。しかし、数千タスクを超える大規模プロジェクト、あるいはERPシステムと連携するエンタープライズ・スケジューラーを設計するシニアエンジニアにとって、それは「動かないコード」ではなく「制御不能なコード」に等しい。

本稿では、タスクタイプ(調査、開発、承認等)に基づき、ASAP(可能な限り早く)やALAP(可能な限り遅く)といった制約を、計算エンジンの負荷を最小限に抑えつつ、メモリ管理を徹底して適用するためのアーキテクチャを解説する。

1. 陥りやすい罠:再計算の嵐(Recalculation Storm)

初心者が書くコードの致命的な欠陥は、オブジェクトのプロパティを一つ書き換えるたびに、MS Projectの計算エンジンを叩き起こしてしまうことにある。

`Task.ConstraintType` をループ内で一つずつ変更すると、MS Projectはその都度、プロジェクト全体のクリティカルパスを再計算しようとする。タスク数が1,000を超えたあたりで、アプリケーションは「応答なし」の状態に陥る。これは、計算量 $O(N)$ の操作が、再計算のコストによって指数関数的に増大する、アルゴリズム設計上の敗北である。

我々が目指すべきは、「状態の確定」と「計算の実行」を分離する設計である。

2. 高度なプリセット・アーキテクチャの実装

定型制約を適用するためのロジックは、ハードコーディングしてはならない。タスクタイプと制約の対応関係を「データ」として定義し、それをエンジンが読み解く構造にする。

以下に、`Scripting.Dictionary` を用いたマッピング戦略と、メモリリークを回避するためのオブジェクト制御を組み込んだプロフェッショナルな実装例を示す。

実装コード:ConstraintPresetEngine

' << Module: ScheduleEngineCore >>
Option Explicit

' Windows API: UIの更新を抑制し、計算中のフリーズ(描画負荷)を最小限に抑える
' ただし、MS Projectでは Application.ScreenUpdating が基本。
' 極限の制御が必要な場合は、SendMessageによるウィンドウ状態の制御を検討する。
Private Declare PtrSafe Function LockWindowUpdate Lib "user32" (ByVal hwndLock As LongPtr) As Long

''' <summary>
''' タスクタイプに基づき、制約条件をバッチ適用するメインエンジン
''' </summary>
Public Sub ApplyTaskTypePresets()
    Dim pjApp As MSProject.Application
    Set pjApp = MSProject.Application
    
    ' 1. プリセット定義の構築 (Dictionaryによる高速ルックアップ)
    ' 本来は外部設定ファイルやDBからロードすべきだが、ここではメモリ内で定義
    Dim presetMap As Object
    Set presetMap = CreateObject("Scripting.Dictionary")
    
    ' Key: TaskType (String), Value: ConstraintType (MSProject.TaskConstraintType)
    ' 調査 -> ASAP (pjConstraintAsSoonAsPossible)
    ' 開発 -> ASAP (pjConstraintAsSoonAsPossible)
    ' 承認 -> ALAP (pjConstraintAsLateAsPossible)
    presetMap.Add "調査", pjConstraintAsSoonAsPossible
    presetMap.Add "開発", pjConstraintAsSoonAsPossible
    presetMap.Add "承認", pjConstraintAsLateAsPossible
    presetMap.Add "レビュー", pjConstraintAsLateAsPossible

    ' 2. パフォーマンス最適化の開始
    On Error GoTo ErrorHandler
    pjApp.ScreenUpdating = False
    ' 計算モードを「手動」に切り替えることで、ループ内の再計算嵐を防ぐ
    pjApp.Calculation = pjManual 

    Dim t As MSProject.Task
    Dim currentType As String
    Dim targetConstraint As MSProject.TaskConstraintType
    
    ' 3. オブジェクトのライフサイクルを意識したイテレーション
    ' Project.Tasks コレクションは巨大なため、ポインタの操作に近い意識で回す
    For Each t In pjApp.ActiveProject.Tasks
        ' 空白行(サマリータスク以外や削除済みタスク)のスキップ
        If Not t Is Nothing Then
            If Not t.Summary Then
                ' タスク名またはカスタムプロパティからタイプを判定
                ' ここでは簡易的にタスク名の一部にタイプが含まれると仮定
                currentType = GetTaskTypeFromContext(t)
                
                If presetMap.Exists(currentType) Then
                    targetConstraint = presetMap(currentType)
                    
                    ' 現在の制約と異なる場合のみ書き換え(無駄なプロパティ操作の回避)
                    If t.ConstraintType <> targetConstraint Then
                        t.ConstraintType = targetConstraint
                    End If
                End If
            End If
        End If
    Next t

    ' 4. 変更の確定と再計算の単発実行
    pjApp.Calculation = pjAutomatic
    pjApp.ScreenUpdating = True
    
    MsgBox "Preset application completed successfully.", vbInformation
    
CleanUp:
    ' メモリの明示的解放:COMオブジェクトの参照カウントを確実にゼロにする
    Set presetMap = Nothing
    Set t = Nothing
    Exit Sub

ErrorHandler:
    pjApp.Calculation = pjAutomatic
    pjApp.ScreenUpdating = True
    MsgBox "Critical Error: " & Err.Description, vbCritical
    Resume CleanUp
End Sub

''' <summary>
''' コンテキストからタスクタイプを抽出するロジック
''' </summary>
Private Function GetTaskTypeFromContext(ByRef t As MSProject.Task) As String
    ' 実際の実装では、Text1 などのカスタムプロパティを参照するのが最も堅牢
    ' ここではデモンストレーションとしてタスク名から判定
    If InStr(t.Name, "調査") > 0 Then
        GetTaskTypeFromContext = "調査"
    ElseIf InStr(t.Name, "開発") > 0 Then
        GetTaskTypeFromContext = "開発"
    ElseIf InStr(t.Name, "承認") > 0 Then
        GetTaskTypeFromContext = "承認"
    Else
        GetTaskTypeFromContext = "その他"
    End If
End Function

3. アーキテクトの視点:なぜこの設計なのか

3.1. 計算モードの制御(The Calculation Switch)

コード内で `pjApp.Calculation = pjManual` を実行している点に注目してほしい。これは単なるテクニックではない。大規模なスケジュールにおいて、プロパティの変更は「トリガー」であり、計算は「副作用」である。副作用をループ内で発生させず、最後に一括して発生させることで、計算コストを $O(N \times \text{RecalcCost})$ から $O(N + \text{RecalcCost})$ へと劇的に削減している。

3.2. メモリ管理とCOMオブジェクトのライフサイクル

VBAはガベージコレクションが弱いため、`Set t = Nothing` や `Set presetMap = Nothing` による明示的な解放は、長期間稼働するシステムにおいて不可欠である。特に、このマクロを外部の .NET アプリケーションや Excel から COM 参照経由で呼び出す場合、参照カウントが残っていると MS Project のプロセスがバックグラウンドでゾンビ化し、メモリを食いつぶす原因となる。

3.3. 拡張性と保守性(Decoupling)

`GetTaskTypeFromContext` 関数を分離している点も重要だ。タスクタイプの判定ロジックが「名前」から「カスタムプロパティ」や「外部DB」に変更されたとしても、メインのエンジン(`ApplyTaskTypePresets`)を修正する必要はない。これは、変更の波及範囲を最小限に抑える、保守性のための鉄則である。

4. 結論

「特定のタスクに制約を当てる」という単純な要件の裏には、計算エンジンの挙動、メモリの挙動、そしてユーザー体験(レスポンス)という三位一体の課題が潜んでいる。

シニアエンジニアとして、我々に求められるのは、単に「動く」コードを書くことではない。大規模なデータセットに対しても、決定論的(Deterministic)かつ、予測可能なパフォーマンスを発揮するアーキテクチャを設計することである。

この知見を、君たちの実装に組み込んでほしい。

タイトルとURLをコピーしました