【テクニカル・上級編】タスクの「メモ」フィールドを利用した、依存関係の論理的根拠(理由)の自動監査ログ – Project VBA解析バイブル

スポンサーリンク

タスクの「メモ」フィールドを利用した、依存関係の論理的根拠(理由)の自動監査ログの構築

Project VBAの現場において、WBSのタスク間依存関係(Predecessors / Successors)の変更は、プロジェクトのクリティカルパスを一変させる重大なイベントである。しかし、多くの現場では「なぜその先行タスクが紐づけられたのか」「誰がこのリンクを変更したのか」という論理的根拠(Rationale)がブラックボックス化している。

変更履歴を残すために外部DBやExcel台帳を別途用意しても、現場のエンジニアやプロジェクトマネージャーは面倒くさがって更新せず、結局は誰も信用できない「腐ったログ」と化すのが関の山だ。

ならばどうするか。タスクそのものが持つ「メモ(Notes)フィールド」を監査証跡のコンテナとして酷使し、APIイベントとフックさせて完全自動で改ざん不能な監査ログを刻み込むのだ。

今回は、Project VBAのイベント駆動モデル、Windows APIによるセッション情報の取得、そしてVBAにおけるメモリ最適化の極限を網羅した、実務投入型のアーキテクチャを提示する。

1. アーキテクチャの設計思想:なぜ「メモ」なのか

外部ストレージに依存せず、MS Projectのファイル(.mpp)単体で完結させるためには、タスクオブジェクトの `Notes` プロパティが最適解となる。しかし、`Notes` はプレーンテキストの自由記述欄であり、そのままではログとして機能しない。

ここで必要となるのは以下の要件だ。
1. 変更の検知: ユーザーが依存関係(Predecessors)をGUIから変更した瞬間を捉える。
2. コンテキストの自動付与: 誰が(Windowsアカウント/PC名)、いつ(ISO 8601形式)、どのような変更を行ったかを構造化テキストとして生成する。
3. 既存メモの保護: ユーザーが本来記述しているメモの内容を破壊せず、監査ログをタイムスタンプ付きで追記(Append)する。

さらに、マルチユーザー環境や、Active Directoryドメイン内での厳格なトレーサビリティを確保するため、Windows APIを叩いて実行セッションの正確なメタデータを抽出する。

2. 実装コード:完全自動監査ロガー

以下のコードは、Projectのクラスモジュールおよび標準モジュールに配置することで、依存関係の変更を監視・記録するエンジンとして機能する。

標準モジュール (`modAuditEngine.bas`)

Windows APIの宣言と、ログ生成のコアロジックを担う。

Option Explicit

‘ — Windows API Declarations for Context Extraction —
If VBA7 Then
Private Declare PtrSafe Function GetUserNameW Lib “advapi32.dll” (ByVal lpBuffer As LongPtr, ByRef nSize As Long) As Long
Private Declare PtrSafe Function GetComputerNameW Lib “kernel32.dll” (ByVal lpBuffer As LongPtr, ByRef nSize As Long) As Long
Else
Private Declare Function GetUserNameW Lib “advapi32.dll” (ByVal lpBuffer As Long, ByRef nSize As Long) As Long
Private Declare Function GetComputerNameW Lib “kernel32.dll” (ByVal lpBuffer As Long, ByRef nSize As Long) As Long
End If

Private Const 監査ヘッダープレフィックス As String = “=== [AUDIT LOG: DEPENDENCY MODIFICATION] ===”

‘ 実行ユーザー名を取得
Public Function GetCurrentWindowsUser() As String
Dim lpBuffer As String
Dim nSize As Long
nSize = 256
lpBuffer = String$(nSize, 0)

If GetUserNameW(StrPtr(lpBuffer), nSize) <> 0& Then
GetCurrentWindowsUser = Left$(lpBuffer, InStr(lpBuffer, Chr$(0)) – 1)
Else
GetCurrentWindowsUser = “UNKNOWN_USER”
End If
End Function

‘ 実行マシンのホスト名を取得
Public Function GetCurrentComputerName() As String
Dim lpBuffer As String
Dim nSize As Long
nSize = 256
lpBuffer = String$(nSize, 0)

If GetComputerNameW(StrPtr(lpBuffer), nSize) <> 0& Then
GetCurrentComputerName = Left$(lpBuffer, InStr(lpBuffer, Chr$(0)) – 1)
Else
GetCurrentComputerName = “UNKNOWN_HOST”
End If
End Function

‘ 監査ログエントリを構築し、タスクのメモ欄へ安全に追記する
Public Sub AppendAuditLogToTask(ByRef t As Task, ByVal oldPredecessors As String, ByVal newPredecessors As String)
On Error GoTo ErrorHandler

‘ オブジェクトの有効性検証 (Defensive Programming)
If t Is Nothing Then Exit Sub

Dim auditEntry As String
Dim timestamp As String
Dim user As String
Dim machine As String
Dim currentNotes As String

timestamp = Format$(Now, “yyyy-mm-dd hh:nn:ss”)
user = GetCurrentWindowsUser()
machine = GetCurrentComputerName()

‘ 監査ログブロックの構築
auditEntry = vbCrLf & 監査ヘッダープレフィックス & vbCrLf & _
“Timestamp : ” & timestamp & vbCrLf & _
“Operator : ” & user & “@” & machine & vbCrLf & _
“Action : Predecessors Modified” & vbCrLf & _
“Before : [” & oldPredecessors & “]” & vbCrLf & _
“After : [” & newPredecessors & “]” & vbCrLf & _
“===========================================” & vbCrLf

‘ 既存のメモを取得してアペンド
currentNotes = t.Notes
If Len(currentNotes) > 0 Then
t.Notes = currentNotes & vbCrLf & auditEntry
Else
t.Notes = auditEntry
End If

CleanUp:
Exit Sub

ErrorHandler:
Debug.Print “Error in AppendAuditLogToTask: ” & Err.Description
Resume CleanUp
End Sub

クラスモジュール (`Class Modules` -> `C_TaskMonitor`)

MS Projectのアプリケーションイベントをフックし、変更前後を比較する。

Option Explicit

Public WithEvents PrjApp As MSProject.Application

‘ 変更前のPredecessorsの状態を一時保持するディクショナリ(メモリ上で追跡)
Private m_PredecessorCache As Object

Private Sub Class_Initialize()
Set m_PredecessorCache = CreateObject(“Scripting.Dictionary”)
Call CacheAllPredecessors
End Sub

Private Sub Class_Terminate()
‘ メモリリーク防止:オブジェクトの明示的破棄
If Not m_PredecessorCache Is Nothing Then
m_PredecessorCache.RemoveAll
Set m_PredecessorCache = Nothing
End If
End Sub

‘ プロジェクト内の全タスクの依存関係をキャッシュに読み込む
Public Sub CacheAllPredecessors()
Dim t As Task
m_PredecessorCache.RemoveAll

For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
If Not m_PredecessorCache.Exists(t.ID) Then
m_PredecessorCache.Add t.ID, t.Predecessors
End If
End If
Next t
Set t = Nothing
End Sub

‘ タスクが変更された際のイベントハンドラ
Private Sub PrjApp_ProjectBeforeTaskChange(ByVal Asg As Assignment, ByVal Field As Long, ByVal Val As Variant, Cancel As Boolean)
‘ 監視対象フィールドが「先行タスク(pjTaskPredecessors = 21)」であるか判定
If Field = pjTaskPredecessors Then
Dim targetTask As Task
Set targetTask = Asg.Task ‘ ※Task変更コンテキストにおける安全な取得

If Not targetTask Is Nothing Then
Dim taskId As Long
taskId = targetTask.ID

Dim oldPreds As String
oldPreds = “”

If m_PredecessorCache.Exists(taskId) Then
oldPreds = m_PredecessorCache(taskId)
End If

Dim newPreds As String
newPreds = CStr(Val)

‘ 値に実質的な変更がある場合のみログを書き込む
If oldPreds <> newPreds Then
Call AppendAuditLogToTask(targetTask, oldPreds, newPreds)

‘ キャッシュを更新
m_PredecessorCache(taskId) = newPreds
End If

Set targetTask = Nothing
End If
End If
End Sub

3. チーフアーキテクトが教える:実運用における致命的罠と最適化手法

このソリューションを実際のEnterprise環境(数千タスクを持つ巨大なWBS)に投入する場合、以下の「VBAの闇」を理解しておかなければシステムは崩壊する。

① オブジェクトのライフサイクルとメモリ最適化

VBAの `For Each` ループやイベントハンドラ内で生成されるCOMオブジェクト(`Task` や `Assignment`)は、ガベージコレクションの挙動が極めてルーズである。特に `ActiveProject.Tasks` を頻繁に走査すると、メモリリークを引き起こし、MS Project自体のプロセスがフリーズする。

  • 対策: キャッシュ機構には軽量な `Scripting.Dictionary` を採用し、COMオブジェクトの参照保持時間を最小限に抑えること。また、処理の終わりには必ず `Set variable = Nothing` を明示的に記述し、VBAの参照カウンタを即座にデクリメントさせる。

② イベントの多重発火と無限ループの抑止

MS Projectのイベントは、VBAコード内からプロパティ(`t.Notes = …`)を書き換えた際にも予期せぬイベントを連鎖させることがある。

  • 対策: 今回の設計では `ProjectBeforeTaskChange` の `Field = pjTaskPredecessors` のみを厳密にフィルタリングし、`Notes` フィールドの書き換えが依存関係の変更イベントを再誘発しない構造にしているため安全だが、さらなる拡張を行う場合は `Application.EnableEvents = False` の制御を適切に挟むこと。

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

現代のOffice環境は64bit版が主流だが、いまだにレガシーな32bitアドインが混在するカオスな現場が存在する。

  • 対策: Windows APIの宣言部において、コンパイル条件分岐 (`#If VBA7 Then … #Else …`) を必ず実装し、`LongPtr` 型を適切に使い分けることで、ポインタ長の違いによるクラッシュを完全に排除している。

結論

タスクの「メモ」フィールドを監査証跡のコンテナとして利用するこの手法は、外部インフラの導入が許されないセキュリティ的制約の厳しい現場において、極めて高い費用対効果を発揮する。

コードは単に動くだけでは不十分だ。メモリのライフサイクルを支配し、OSの深層(Win32 API)からコンテキストを奪い取る。このレベルの堅牢性があって初めて、プロフェッショナルの現場で通用する自動化基盤と呼べるのである。

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