【テクニカル・上級編】【実務中級】Project VBAにおける「イベント駆動」の活用:タスク変更時に自動でログを記録する – Project VBA解析バイブル

スポンサーリンク

【実務中級】Project VBAにおける「イベント駆動」の活用:タスク変更時に自動でログを記録する

プロジェクトマネジメントの現場において、進捗データの改ざん、意図しないスケジュール変更、あるいは「誰がこのマイルストーンを動かしたのか分からない」というガバナンス上の致命傷は、組織規模を問わず頻発する。ExcelであればWorksheet_Changeイベントで容易に追跡できる変更履歴も、Microsoft Project(以下、MS Project)の世界においては、その独特なオブジェクトモデルと非同期のタスクエンジンが災いし、一筋縄ではいかない。

本稿では、MS Projectのイベント駆動モデルの深層に踏り込み、`Project_BeforeTaskChange`イベントをフックして「誰が、いつ、どのタスクのどのプロパティを、どう変更したか」を正確にキャプチャし、外部ログへ永続化するアーキテクチャを解説する。

1. MS Projectイベントモデルの暗部とライフサイクル

Excel VBAやAccess VBAのイベントハンドリングに慣れ親しんだエンジニアほど、MS Projectのイベント設計には度肝を抜かれる。Projectのオブジェクトモデルは、単なる「セルの集まり」ではなく、CPM(クリティカルパス法)を裏で計算し続ける重厚なスケジューリングエンジンと直結している。

イベント捕捉の勘所:`Before`系イベントの優位性

変更後の値を拾う `TaskChange` 系イベントも存在するが、監査ログやバリデーションの観点では `Project_BeforeTaskChange` 一択となる。理由は以下の通りだ:

1. 変更前の状態との比較: 変更がコミットされる直前に発火するため、変更前後の差分(Delta)を厳密に計算できる。
2. 処理のキャンセル: 条件に合致しない不正な変更(例:実績入力済みのタスクの大幅な過去日への逆戻りなど)を、`Cancel = True` によって水際で阻止できる。

しかし、このイベントには「高頻度で多重発火する」「UIスレッドと密結合しているため重い処理を書くとProject全体がフリーズする」というアーキテクチャ上の爆弾が仕掛けられている。これを制御するには、適切なメモリ管理とWindows APIによるコンテキスト情報の補完が不可欠となる。

2. 実装アーキテクチャ:監査ログシステムの全体像

今回の実装では、以下の要件を満たすコードを構築する。

  • 変更検知: タスク名(Name)、開始日(Start)、終了日(Finish)、工数(Work)の変更を監視。
  • オペレーター特定: Windowsのネットワークログオン名(API経由)を取得し、誰が操作したかを特定。
  • ログ出力: 競合(ファイルロック)に強いCSV形式または外部DBへの非同期風書き込み。

実装コード(ThisProject クラスモジュール)

MS Projectのイベントを捕捉するためには、標準モジュールではなく、`ThisProject` モジュール にコードを記述する必要がある。

Option Explicit

‘ =========================================================================
‘ Windows API Declarations (セッション実行ユーザーの特定用)
‘ =========================================================================
If VBA7 Then
Private Declare PtrSafe Function GetUserName Lib “advapi32.dll” Alias “GetUserNameA” ( _
By-Ref lpBuffer As Byte, _
By-Ref nSize As Long) As Long
Else
Private Declare Function GetUserName Lib “advapi32.dll” Alias “GetUserNameA” ( _
By-Ref lpBuffer As Byte, _
By-Ref nSize As Long) As Long
End If

‘ 監査ログの出力先パス(環境に合わせて変更すること)
Private Const LOG_FILE_PATH As String = “C:\ProjectAudit\TaskAudit_Log.csv”

‘ =========================================================================
‘ イベントプロシージャ: タスク変更直前イベント
‘ =========================================================================
Private Sub Project_BeforeTaskChange(ByVal Tsk As Task, ByVal Field As PjField, ByVal NewValue As Variant, Cancel As Boolean)
‘ 1. 異常系・システム内部処理による変更のガード
If Tsk Is Nothing Then Exit Sub
If Tsk.Summary Then Exit Sub ‘ サマリータスクの直接変更は除外する場合

‘ 2. 変更前後の値が同じ場合は処理をスキップ(無駄なイベント発火の抑制)
If IsValueChanged(Tsk, Field, NewValue) = False Then Exit Sub

On Error GoTo ErrorHandler

‘ 3. 監査情報の収集
Dim operatorName As String
Dim changeTimestamp As String
Dim taskID As Long
Dim taskName As String
Dim fieldName As String
Dim oldValue As String

operatorName = GetCurrentWindowsUser()
changeTimestamp = Format$(Now, “yyyy-mm-dd hh:nn:ss”)
taskID = Tsk.ID
taskName = Tsk.Name
fieldName = GetFieldNameString(Field)
oldValue = GetCurrentFieldValue(Tsk, Field)

‘ 4. ログレコードの書き込み(ファイルI/O)
Call WriteAuditLog(changeTimestamp, operatorName, taskID, taskName, fieldName, oldValue, CStr(NewValue))

Exit Sub

ErrorHandler:
‘ 監査ログの書き込み失敗でプロジェクト自体の保存や操作を止めてはならないが、
‘ エンジニア向けにイミディエイトへエラーを吐き出しておく
Debug.Print “[AuditError] Error ” & Err.Number & “: ” & Err.Description
End Sub

‘ =========================================================================
‘ プライベート関数群:メモリ最適化とヘルパーロジック
‘ =========================================================================

Private Function GetCurrentWindowsUser() As String
Dim buffer(255) As Byte
Dim size As Long
size = UBound(buffer)

If GetUserName(buffer(0), size) <> 0 Then
‘ ヌル文字の手前までを文字列化
GetCurrentWindowsUser = Left$(StrConv(buffer, vbUnicode), size – 1)
Else
GetCurrentWindowsUser = “UNKNOWN_USER”
End If
End Function

Private Function GetFieldNameString(ByVal Field As PjField) As String
‘ 監視対象のフィールドIDに応じて文字列を返す
Select Case Field
Case pjTaskName: GetFieldNameString = “タスク名”
Case pjTaskStart: GetFieldNameString = “開始日”
Case pjTaskFinish: GetFieldNameString = “終了日”
Case pjTaskWork: GetFieldNameString = “工数”
Case Else: GetFieldNameString = “Field_ID_” & CStr(Field)
End Select
End Function

Private Function GetCurrentFieldValue(ByVal Tsk As Task, ByVal Field As PjField) As String
On Error Resume Next
Select Case Field
Case pjTaskName: GetCurrentFieldValue = Tsk.Name
Case pjTaskStart: GetCurrentFieldValue = CStr(Tsk.Start)
Case pjTaskFinish: GetCurrentFieldValue = CStr(Tsk.Finish)
Case pjTaskWork: GetCurrentFieldValue = CStr(Tsk.Work / 60) ‘ ProjectのWorkは分単位のため時間に変換
Case Else: GetCurrentFieldValue = “[N/A]”
End Select
If Err.Number <> 0 Then GetCurrentFieldValue = “[Error]”
On Error GoTo 0
End Function

Private Function IsValueChanged(ByVal Tsk As Task, ByVal Field As PjField, ByVal NewValue As Variant) As Boolean
Dim currentVal As String
currentVal = GetCurrentFieldValue(Tsk, Field)
If currentVal <> CStr(NewValue) Then
IsValueChanged = True
Else
IsValueChanged = False
End If
End Function

Private Sub WriteAuditLog(ByVal TimeStamp As String, ByVal User As String, ByVal TskID As Long, ByVal TskName As String, ByVal FldName As String, ByVal OldVal As String, ByVal NewVal As String)
Dim fileNum As Integer
fileNum = FreeFile

‘ CSV形式で追記モード出力 (UTF-8等での出力が必要な場合はADODB.Streamを使用すること)
Open LOG_FILE_PATH For Append As #fileNum
Print #fileNum, “””” & TimeStamp & “””,””” & User & “””,” & TskID & “,” & _
“””” & Replace(TskName, “”””, “”””””) & “””,” & _
“””” & FldName & “””,” & _
“””” & OldVal & “””,” & _
“””” & NewVal & “”””
Close #fileNum
End Sub

3. チーフアーキテクトが指摘する「実務上の罠」と極限の最適化

上記のコードは美しく機能するが、エンタープライズの現場(数百〜数千タスクを持つ巨大なEnterprise Projectなど)に投入した場合、以下のボトルネックに直面する。シニアエンジニアとして、これらをどう回避すべきかを解説する。

A. オブジェクトのライフサイクルとメモリリークの防止

VBAにおける `Task` オブジェクトや `Project` オブジェクトは、背後でCOM(Component Object Model)の参照カウンタを保持している。イベントハンドラの引数として渡される `Tsk As Task` は自動的に管理されるが、マクロ内で独自に `ActiveProject.Tasks.Item(…)` などをインスタンス化するコードを乱発すると、ガベージコレクションが追いつかず、Projectプロセスのメモリフットプリントが肥大化する

  • 対策: オブジェクト変数を取得した場合は、不要になった時点で明示的に `Set variable = Nothing` を行い、COM参照を即座に解放する癖をつけよ。

B. ファイルI/Oのボトルネックと排他制御

MS Projectで一括タスク更新(例: Excelからのインポートや、ガントチャート上での複数行ドラッグ操作)が行われた場合、`Project_BeforeTaskChange` はミリ秒単位の短時間に何十回も連続発火する。
上記のサンプルコードの通り、都度 `Open … For Append` を行っていると、ディスクのI/O待ちが発生し、最悪の場合はProject自体が「応答なし」に陥るか、ファイルロック競合(Error 70: 書き込み権限がありません)を引き起こす。

  • 高度な対策: 本番環境においては、ファイルへの直接書き込みを避け、一度グローバル配列や専用のコレクションクラスにログをバッファリングし、プロジェクト閉時(`Project_BeforeClose`)または一定時間ごとにまとめてバルク書き込み(Bulk Write)するアーキテクチャに変更すべきである。

C. レガシー環境(32bit/64bit混在)への配慮

現代のクライアントPCは64bit版 Office が主流だが、古いアドインやサードパーティ製ツールとの兼ね合いでいまだに32bit版が強制されている組織も少なくない。
前述のコードに記述した `#If VBA7 Then` 条件付きコンパイルによるAPI宣言の切り分けは、クロスプラットフォーム(32/64bit)で完全に動作を保証するための必須要件である。これを怠ると、レガシー環境に展開した瞬間にコンパイルエラーで全システムが沈黙する。

総括

MS Projectにおけるイベント駆動型の監査ログ実装は、単に「コードを動かす」ことの何倍も、「システムの振る舞いとパフォーマンス特性を理解しているか」が問われる領域である。

タスク変更イベントの挙動、ファイルI/Oの競合、そしてメモリのライフサイクル。これらを完全に掌握した上でコードを組み上げることで初めて、企業のPMO(プロジェクトマネジメントオフィス)が信頼しうる、堅牢な基盤が完成する。

退屈なリファレンスをなぞるフェーズはもう終わりにしよう。あなたの手で、現場のガバナンスをコードの力で担保せよ。

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