整合性の崩壊を防げ:大規模プロジェクトにおける制約条件変更の監査ログ・アーキテクチャ
プロジェクト管理の現場において、スケジュールの「整合性」は生命線だ。
しかし、エンタープライズレベルの現場でよくある悲劇がある。それは、「いつ、誰が、なぜ、タスクの制約条件(Constraint)をASAPから『開始日は〇月〇日以降』に変更したのかが全く追えない」という事態だ。
「誰かが勝手に制約を変えたせいで、クリティカルパスがズレた」
「遡って調査しようにも、MS Projectの標準機能では『現在の状態』しかわからない」
こうした事態を防ぐには、単なる「ログ出し」では足りない。VBAのイベントハンドリングの特性、オブジェクトのライフサイクル、そしてSQL Serverへの通信コストを完璧に計算に入れた、「堅牢な監査ログシステム」の設計が必要だ。
本記事では、プロフェッショナルが実装すべき、メモリ上の「シャドウ・ステート(影の状態)」を利用した高精度な監査ログシステムの構築手法を伝授する。
1. なぜ、単純な `Project_Change` イベントでは失敗するのか
初心者はこう考える。「`Project_Change` イベントを拾って、変更されたタスクをSQL Serverに書き込めばいい」。
これは致命的な設計ミスだ。
理由は3つある。
1. 「前後の値」が取得できない: MS Projectの `Change` イベントは「何かが変わった」ことしか教えてくれない。「何から何に変わったか」というデルタ(差分)情報は、イベント引数には含まれていない。
2. イベントの嵐(Event Storm): スケジュール計算エンジンが走るたびに、膨大な数のイベントが発火する。そのたびにSQL Serverへ接続・切断(Connect/Disconnect)を繰り返していたら、アプリケーションは即座にハングアップし、ネットワーク帯域は枯渇する。
3. トランザクションの欠如: 変更の記録に失敗したとき、プロジェクトの変更だけが先行してしまう。データの整合性が担保されない。
我々が目指すべきは、「メモリ上に前回状態を保持し、変更を検知した瞬間に差分のみを非同期(あるいはバッチ)で抽出する」アーキテクチャだ。
2. 設計思想:シャドウ・ステート・パターン
本システムでは、以下のコンポーネントを構築する。
- Shadow State (Dictionary Object): メモリ上に `TaskID` をキーとし、`ConstraintType` を値とする辞書を保持する。これが「前回の正しい状態」となる。
- Audit Engine (Class Module): 変更の差分を計算し、SQL Serverへの通信を管理する。
- SQL Server (Relational DB): 監査ログを永続化する。
データベース設計 (SQL Server)
まず、受け皿となるテーブルを定義する。パフォーマンスを考慮し、インデックスは最小限にしつつ、検索性を確保する。
CREATE TABLE ProjectAuditLogs (
LogID BIGINT IDENTITY(1,1) PRIMARY KEY,
ProjectName NVARCHAR(255),
TaskID INT,
TaskName NVARCHAR(255),
ChangedBy NVARCHAR(100),
ChangeTimestamp DATETIME2 DEFAULT SYSDATETIME(),
PropertyName NVARCHAR(50), -- 'ConstraintType' 等
OldValue NVARCHAR(50),
NewValue NVARCHAR(50)
);
CREATE INDEX IX_Audit_TaskID ON ProjectAuditLogs(TaskID);
3. 実装:極限のプロフェッショナル・コード
ここからは、実際にプロダクション環境で使用に耐えうるコードを記述していく。
単なるコピペではなく、「なぜこの構造なのか」を理解しながら読み進めてほしい。
A. 監査エンジン・クラスモジュール (`clsAuditManager`)
このクラスは、SQL接続のライフサイクルと、差分検知のロジックをカプセル化する。
' Class Module: clsAuditManager
Option Explicit
Private pConn As ADODB.Connection
Private pConnectionString As String
Private pShadowState As Object ' Scripting.Dictionary
' 初期化
Private Sub Class_Initialize()
Set pShadowState = CreateObject("Scripting.Dictionary")
End Sub
' 接続設定
Public Sub Initialize(ByVal connStr As String)
pConnectionString = connStr
Set pConn = New ADODB.Connection
pConn.ConnectionString = pConnectionString
pConn.CursorLocation = adUseClient
pConn.Open
End Sub
' 初期状態(スナップショット)の構築
' プロジェクトを開いた直後に呼び出す
Public Sub CaptureInitialState(ByRef proj As Project)
Dim t As Task
pShadowState.RemoveAll
For Each t In proj.Tasks
If Not t Is Nothing Then
' TaskIDをキーに、ConstraintTypeを格納
' ConstraintTypeは列挙型(Long)なので、比較しやすいよう保持
pShadowState(t.ID) = t.ConstraintType
End If
Next t
End Sub
' 変更の差分検知とログ記録
Public Sub AuditConstraintChange(ByRef t As Task, ByVal userName As String, ByVal projName As String)
Dim oldVal As Variant
Dim newVal As Long
Dim oldStr As String, newStr As String
' 変更されたタスクのIDがシャドウステートにあるか確認
If Not pShadowState.Exists(t.ID) Then
' 新規タスクの場合の処理(必要に応じて)
pShadowState(t.ID) = t.ConstraintType
Exit Sub
End If
oldVal = pShadowState(t.ID)
newVal = t.ConstraintType
' 値が変わっている場合のみ処理
If oldVal <> newVal Then
oldStr = GetConstraintName(CLng(oldVal))
newStr = GetConstraintName(newVal)
' SQL Serverへ書き込み
Call WriteToSQL(projName, t.ID, t.Name, userName, "ConstraintType", oldStr, newStr)
' シャドウステートを更新(ここが重要:状態の同期)
pShadowState(t.ID) = newVal
End If
End Sub
' SQLへの書き込み(エラーハンドリングを徹底)
Private Sub WriteToSQL(projName As String, tID As Long, tName As String, user As String, prop As String, oldV As String, newV As String)
On Error GoTo ErrorHandler
Dim cmd As ADODB.Command
Set cmd = New ADODB.Command
With cmd
.ActiveConnection = pConn
.CommandText = "INSERT INTO ProjectAuditLogs (ProjectName, TaskID, TaskName, ChangedBy, PropertyName, OldValue, NewValue) " & _
"VALUES (?, ?, ?, ?, ?, ?, ?)"
.CommandType = adCmdText
' パラメータ化クエリを使用してSQLインジェクションを防止
.Parameters.Append .CreateParameter("@p1", adVarWChar, adParamInput, 255, projName)
.Parameters.Append .CreateParameter("@p2", adInteger, adParamInput, , tID)
.Parameters.Append .CreateParameter("@p3", adVarWChar, adParamInput, 255, tName)
.Parameters.Append .CreateParameter("@p4", adVarWChar, adParamInput, 100, user)
.Parameters.Append .CreateParameter("@p5", adVarWChar, adParamInput, 50, prop)
.Parameters.Append .CreateParameter("@p6", adVarWChar, adParamInput, 50, oldV)
.Parameters.Append .CreateParameter("@p7", adVarWChar, adParamInput, 50, newV)
.Execute
End With
Exit Sub
ErrorHandler:
' 本来はここでエラーログをローカルファイルに書き出す等のリトライ戦略を取る
Debug.Print "SQL Error: " & Err.Description
End Sub
' 列挙型を文字列に変換するヘルパー関数
Private Function GetConstraintName(val As Long) As String
Select Case val
Case pjTaskConstraintAsSoonAsPossible: GetConstraintName = "ASAP"
Case pjTaskConstraintAsLateAsPossible: GetConstraintName = "ALAP"
Case pjTaskConstraintStartNoEarlierThan: GetConstraintName = "StartNoEarlierThan"
Case pjTaskConstraintStartNoLaterThan: GetConstraintName = "StartNoLaterThan"
Case pjTaskConstraintStartOn: GetConstraintName = "StartOn"
Case pjTaskConstraintEndNoEarlierThan: GetConstraintName = "EndNoEarlierThan"
Case pjTaskConstraintEndNoLaterThan: GetConstraintName = "EndNoLaterThan"
Case pjTaskConstraintEndOn: GetConstraintName = "EndOn"
Case Else: GetConstraintName = "Other(" & val & ")"
End Select
End Function
' クラス終了時のクリーンアップ
Private Sub Class_Terminate()
If Not pConn Is Nothing Then
If pConn.State = adStateOpen Then pConn.Close
End If
Set pConn = Nothing
Set pShadowState = Nothing
End Sub
B. プロジェクト・イベント・ハンドラ (`ThisProject`)
ここがシステムの「トリガー」となる。
' ThisProject Module
Option Explicit
Private WithEvents pApp As MSProject.Application
Private mAuditMgr As clsAuditManager
Private mIsInitializing As Boolean
Private Sub Project_Open()
' 初期化フラグを立てる(初期ロード時の大量イベント発火を防ぐ)
mIsInitializing = True
Set mAuditMgr = New clsAuditManager
' SQL Serverの接続文字列(環境に合わせて構成)
Dim connStr As String
connStr = "Provider=SQLOLEDB;Data Source=YOUR_SERVER;Initial Catalog=YOUR_DB;Integrated Security=SSPI;"
On Error Resume Next
mAuditMgr.Initialize connStr
If Err.Number <> 0 Then
MsgBox "監査システムの初期化に失敗しました。オフラインモードで動作します。", vbCritical
Set mAuditMgr = Nothing
Else
' シャドウステートの構築
mAuditMgr.CaptureInitialState(ActiveProject)
End If
On Error GoTo 0
mIsInitializing = False
End Sub
' 変更検知イベント
Private Sub Project_Change(ByVal Name As String)
If mIsInitializing Or mAuditMgr Is Nothing Then Exit Sub
' 変更された対象がタスクであることを確認
' ※Name引数は変更されたオブジェクトの型によって異なるため注意が必要
' ここでは、実務上最も堅牢な「タスクの制約変更」に絞ったアプローチをとる
On Error Resume Next
Dim t As Task
' NameがタスクのIDや名前である場合、対象のTaskオブジェクトを取得
' 注意: Project_ChangeのName引数は、変更されたプロパティによって挙動が変わる
' 実際の運用では、対象を特定するためのロジックを慎重に設計する
' 本実装では、タスクのプロパティ変更を検知するロジックを想定
' (MS Project VBAの仕様上、Changeイベントから直接Taskオブジェクトを特定するのは難しいため、
' 大規模プロジェクトでは、特定の変更範囲をスキャンするか、
' より高度なAPIを用いて変更オブジェクトを特定する)
' --- 実務的な妥協点と堅牢性のバランス ---
' 変更されたタスクを特定するロジック(例:現在選択されているタスク、
' または変更通知から推測)
Set t = ActiveCell.Task
If Not t Is Nothing Then
mAuditMgr.AuditConstraintChange t, Environ("USERNAME"), ActiveProject.Name
End If
On Error GoTo 0
End Sub
4. 伝説のアーキテクトからのアドバイス:運用の極意
このコードを実装しただけで満足してはいけない。エンタープライズ環境でこのシステムを成功させるには、以下の3点を徹底せよ。
① ネットワークの不確実性を許容せよ
SQL Serverへの書き込みは、必ずしも成功するとは限らない。`WriteToSQL` 内でエラーが発生した場合、即座にシステムを止めるのではなく、「ローカルのテキストファイルやCSVに一時退避(バッファリング)し、後でリトライする」仕組みを組み込むのが、真のプロフェッショナルだ。
② 「全件スキャン」のコストを計算せよ
`CaptureInitialState` はプロジェクトを開いた瞬間に全タスクをループする。タスクが5万件あるプロジェクトでは、これだけで数秒のフリーズが発生する。
大規模プロジェクトでは、「開いた直後にすべてを記録する」のではなく、「ユーザーが操作を開始したタイミングで、差分をバックグラウンドで計算する」、あるいは「前回保存時のハッシュ値を保持しておく」といった最適化を検討せよ。
③ セキュリティと権限
VBAからSQL Serverへ接続する場合、`Integrated Security=SSPI`(Windows認証)を使うのが最も安全だ。個別のDBユーザーID/パスワードをVBAコード内にハードコードするような愚行は、絶対に避けなければならない。
まとめ
プロジェクトの制約条件を変更することは、スケジュールの構造を変えることと同義だ。その変更を「記録」することは、単なる管理業務ではなく、プロジェクトの「証跡(Audit Trail)」を守る崇高なエンジニアリングである。
今回紹介した「シャドウ・ステート・パターン」を用いれば、MS Projectの不安定なイベントモデルを制御下に置き、極めて堅牢な監査ログシステムを構築できるはずだ。
「動くコード」を書くだけで満足するな。「壊れない、そして責任を取れるコード」を書け。
