【実務・中級編】制約条件の変更履歴をVBAでログ出力し、スケジュール変動の責任所在を明確化する – Project VBA解析バイブル

スポンサーリンク

なぜあなたのプロジェクトのクリティカルパスは「突然死」するのか

Microsoft Project(以下、MS Project)を運用していて、こんな経験はないだろうか。

「先行タスクが2週間前倒しになったのに、後続の重要マイルストーンが1日も動かない」
「意図しないタスクに『指定日以後に開始(SNET)』が設定され、プロジェクト全体のクリティカルパスが分断されている」

誰が、いつ、なぜその制約条件(Constraint)を設定したのか。担当者に問い詰めても返ってくるのは「日付をドラッグしただけです」「勝手に変わっていました」という曖昧な言い訳ばかりだ。

MS Projectの計算エンジン(CPM: クリティカル・パス・メソッド)は極めて精緻だ。しかし「日付の手入力を制約条件へ自動変換する」というお節介な初期挙動のせいで、現場の無自覚な操作がスケジュールを硬直化させ、バッファを静かに食いつぶしていく。

標準機能に変更履歴を追跡する監査ログ(Audit Trail)が存在しないのなら、エンジニアリングで解決するしかない。本稿では、制約条件の変動をミリ秒単位で捕捉し、変更者のOSアカウント・新旧の制約種別・制約日付を永続化するエンタープライズ水準の監査ログシステムの実装アーキテクチャを公開する。

—

1. スケジュール計算エンジンの挙動と制約条件の力学

コードを書く前に、Project VBAが対峙している計算エンジンの内部状態を整理しよう。

制約条件(Constraint Type)の8大分類

タスクの制約条件は `PjConstraint` 列挙体で定義されている。

| 定数名 | 値 | 意味 | エンジンへの影響度 |
| :— | :—: | :— | :— |
| `pjASAP` | 0 | できるだけ早く(初期値) | 柔軟(先行関係に従う) |
| `pjALAP` | 1 | できるだけ遅く | 柔軟(フロートを消費) |
| `pjSNET` | 2 | 指定日以後に開始 | 半固定(最も事故が多発) |
| `pjSNLT` | 3 | 指定日以前に開始 | 半固定 |
| `pjFNET` | 4 | 指定日以後に終了 | 半固定 |
| `pjFNLT` | 5 | 指定日以前に終了 | 半固定 |
| `pjMSO` | 6 | 指定日に開始 | 完全固定(CPM分断) |
| `pjMFO` | 7 | 指定日に終了 | 完全固定(CPM分断) |

実務におけるスケジュールの歪みは、ほぼ9割が「本来 `pjASAP` であるべきタスクが、無自覚に `pjSNET` や `pjMSO` に化けていること」に起因する。ガントチャート上でバーをマウスで1ピクセル動かした瞬間、MS Projectはユーザーに警告することなくタスクを `pjSNET` に書き換えるからだ。

なぜApplicationイベント単体で拾ってはいけないのか

「変更を検知するなら `Project_Change` イベントを使えばいい」と考えたプログラマは、確実に地獄を見る。

`Project_Change` は、セル1つの選択変更や画面の再描画、計算エンジンのイテレーションごとに無数に発火する。このコンテキストで全タスクをループ処理してログを吐き出せば、数百タスクのプロジェクトでさえ操作が完全にフリーズする。

最適解は、「タスクのカスタムフィールド(Enterprise/Local Text)を状態スナップショット領域として確保し、保存前(`ProjectBeforeSave`)またはコミット処理時に一括で差分比較(Diff)を行う」アプローチだ。

[現在のタスク状態] vs [Text30にシリアライズ退避された前回確定状態]
│
├─ 差分あり(ConstraintType or ConstraintDate)
│ ├─ 監査ログ出力(TSV / イベントログ)
│ └─ Text30を最新状態へ更新
│
└─ 差分なし ─ スルー(計算負荷ゼロ)

—

2. 堅牢な監査ログアーキテクチャの設計

本設計では、タスクの `Text30` を「前回確定時のハッシュ署名保持フィールド」として割り当てる。

署名フォーマット(Text30)

`[ConstraintType]:[ConstraintDate yyyyMMddHHmm]`
例: `2:202610250900`(SNET: 2026年10月25日 09:00)

この文字列を比較するだけで、日付形式のローカライズ差異(和暦/西暦、OSのロケール設定)に左右されず、完全な文字列一致(O(1))で変更の有無を判定できる。

ログ出力先

ファイルロック競合を回避するため、監査ログはローカルまたは共有ネットワーク上の 日付別TSV(Tab-Separated Values)ファイル へのアペンド(追記)書き込みを採用する。TSVを採用するのは、タスク名にカンマが含まれるケースを考慮してのエスケープ事故防止だ。

—

3. プロダクションコード:制約条件監査ログエンジン

以下のコードを、対象のプロジェクトファイル(`.mpp`)または全社配布用のテンプレート(`Global.mpt`)に実装する。

クラスモジュール: `cConstraintAuditor`

まずはイベントをフックし、計算エンジンの暴走を抑えながら差分を抽出するコアエンジンをクラスとしてカプセル化する。

Option Explicit

‘ =========================================================================
‘ クラス名: cConstraintAuditor
‘ 責 務 : タスク制約条件の変更検知、および監査ログファイルへの追記
‘ =========================================================================

Private Const SNAPSHOT_FIELD As PjField = pjTaskText30
Private Const LOG_DIR_PATH As String = “C:\MSProject_AuditLogs\” ‘ 運用環境のUNCパス等に変更

‘ 変更追跡を実行するメインルーチン
Public Sub AuditConstraints(ByVal prj As Project)
On Error GoTo ErrHandler

‘ 変更不要な状態(プロジェクトが空、または読み取り専用)は即時終了
If prj.Tasks.Count = 0 Then Exit Sub

Dim originalCalculation As PjCalculation
originalCalculation = Application.Calculation

‘ パフォーマンス担保のため一時的に手動計算&画面描画停止
Application.ScreenUpdating = False
Application.Calculation = pjManual

Dim currentUserName As String
currentUserName = Environ$(“USERNAME”)
If Len(currentUserName) = 0 Then currentUserName = “UNKNOWN”

Dim logLines As Collection
Set logLines = New Collection

Dim t As Task
Dim currentSignature As String
Dim previousSignature As String
Dim logEntry As String

For Each t In prj.Tasks
‘ 空白行(非表示タスク等)はスキップ
If Not (t Is Nothing) Then
‘ 外部タスク参照(他プロジェクト連携タスク)を除外
If Not t.ExternalTask Then
currentSignature = GenerateSignature(t)
previousSignature = t.GetField(SNAPSHOT_FIELD)

‘ 初期状態(Text30が空)の場合は、現況をシリアライズして終了
If Len(previousSignature) = 0 Then
t.SetField SNAPSHOT_FIELD, currentSignature
ElseIf currentSignature <> previousSignature Then
‘ 差分発生: ログ用エントリを構築
logEntry = FormatLogEntry(prj.Name, currentUserName, t, previousSignature, currentSignature)
logLines.Add logEntry

‘ スナップショットを更新
t.SetField SNAPSHOT_FIELD, currentSignature
End If
End If
End If
Next t

‘ 差分が存在した場合のみディスクI/Oを実行
If logLines.Count > 0 Then
FlushLogToFile logLines
End If

CleanExit:
‘ エンジン状態の確実な復帰
Application.Calculation = originalCalculation
Application.ScreenUpdating = True
Exit Sub

ErrHandler:
‘ 失敗してもユーザーの保存アクションそのものを殺さないようトラップ
Debug.Print “Audit failed: ” & Err.Number & ” – ” & Err.Description
Resume CleanExit
End Sub

‘ タスクの制約から一意の署名文字列を生成
Private Function GenerateSignature(ByVal t As Task) As String
Dim cType As Long
cType = t.ConstraintType

‘ ASAP / ALAP は制約日付を持たない(持っていたとしても無意味な値)
If cType = pjASAP Or cType = pjALAP Then
GenerateSignature = CStr(cType) & “:NONE”
Else
Dim cDate As String
If IsDate(t.ConstraintDate) Then
cDate = Format$(t.ConstraintDate, “yyyymmddhhnn”)
Else
cDate = “NODATE”
End If
GenerateSignature = CStr(cType) & “:” & cDate
End If
End Function

‘ 監査ログ行のフォーマット
Private Function FormatLogEntry(ByVal prjName As String, _
ByVal user As String, _
ByVal t As Task, _
ByVal oldSig As String, _
ByVal newSig As String) As String
Dim timestamp As String
timestamp = Format$(Now(), “yyyy-mm-dd hh:nn:ss”)

‘ カラム: タイムスタンプ, 実行ユーザー, プロジェクト名, タスクUID, タスク名, 旧署名, 新署名
‘ ※タスク名に含まれるタブ文字はスペースに置換
Dim safeTaskName As String
safeTaskName = Replace(t.Name, vbTab, ” “)

FormatLogEntry = timestamp & vbTab & _
user & vbTab & _
prjName & vbTab & _
t.UniqueID & vbTab & _
safeTaskName & vbTab & _
GetConstraintLabel(oldSig) & vbTab & _
GetConstraintLabel(newSig)
End Function

‘ 署名から可読性の高い文字列表現にデコード
Private Function GetConstraintLabel(ByVal signature As String) As String
Dim parts() As String
parts = Split(signature, “:”)

Dim typeStr As String
Select Case CLng(parts(0))
Case pjASAP: typeStr = “できるだけ早く (ASAP)”
Case pjALAP: typeStr = “できるだけ遅く (ALAP)”
Case pjSNET: typeStr = “指定日以後に開始 (SNET)”
Case pjSNLT: typeStr = “指定日以前に開始 (SNLT)”
Case pjFNET: typeStr = “指定日以後に終了 (FNET)”
Case pjFNLT: typeStr = “指定日以前に終了 (FNLT)”
Case pjMSO: typeStr = “指定日に開始 (MSO)”
Case pjMFO: typeStr = “指定日に終了 (MFO)”
Case Else: typeStr = “不明 (” & parts(0) & “)”
End Select

GetConstraintLabel = “[” & typeStr & ” 日付=” & parts(1) & “]”
End Function

‘ ディスクへの高速フラッシュ
Private Sub FlushLogToFile(ByVal lines As Collection)
On Error GoTo FileErr

‘ ディレクトリ存在チェックと生成
If Dir(LOG_DIR_PATH, vbDirectory) = “” Then
MkDir LOG_DIR_PATH
End If

Dim filePath As String
filePath = LOG_DIR_PATH & “ConstraintAudit_” & Format$(Date, “yyyymm”) & “.log”

Dim fileNum As Integer
fileNum = FreeFile

‘ 共有モードでアペンドオープン(マルチプロセスセーフ化)
Open filePath For Append Shared As #fileNum

Dim item As Variant
For Each item In lines
Print #fileNum, CStr(item)
Next item

Close #fileNum
Exit Sub

FileErr:
If fileNum > 0 Then Close #fileNum
Debug.Print “Log File I/O Error: ” & Err.Description
End Sub

ThisProjectへの配線(イベントハンドラ)

作成したクラスを保存トリガーで駆動させる。`ThisProject` モジュールに以下を記述する。

Option Explicit

Private mAuditor As cConstraintAuditor

Private Sub Project_Open(ByVal pj As Project)
Set mAuditor = New cConstraintAuditor
End Sub

‘ プロジェクト保存直前に自動的に差分検証を実行
Private Sub Project_BeforeSave(ByVal pj As Project, ByVal Response As PjSaveResponse)
If mAuditor Is Nothing Then
Set mAuditor = New cConstraintAuditor
End If

mAuditor.AuditConstraints pj
End Sub

—

4. チーフアーキテクトが教える「運用の急所」

このシステムをエンタープライズの本番運用に載せる際、絶対に外してはならないポイントが3つある。

① 日付比較に `IsDate` や `DateDiff` を直接使ってはならない

VBAの `Date` 型比較は、秒未満のミリ秒データやタイムゾーン情報によって「画面上は全く同じ日付なのに等価判定が `False` になる」という誤検知(False Positive)を誘発しやすい。

そのため、署名ロジックでは必ず `Format$(t.ConstraintDate, “yyyymmddhhnn”)` のように分単位で切り捨てた決定論的文字列へ落とし込んでから比較すること。これでOS間の日付シリアライゼーション差異は完全に無力化できる。

② ファイルI/Oのデッドロック対策

共有サーバ上にログを集約する場合、複数人が同時に保存(Save)を押下すると `Permission Denied (Err 70)` が発生するリスクがある。

本コードで `Append Shared` モードを指定しているのはそのためだ。排他ロックではなく共有追記モードで開き、必要最小限のミリ秒でディスクへフラッシュして即座にハンドルを閉じる設計にしている。さらにトラフィックが集中する数千人規模の組織なら、ログの受け口をローカルバッファにしておき、バックグラウンドのバッチで吸い上げる設計へ拡張するのが筋だ。

③ なぜEnterpriseカスタムフィールドではなくLocalを使うのか

Project Server / Project Online(Project Plan 3/5)と同期する場合、Enterpriseカスタムフィールドの更新にはチェックアウト権限とセッションのオーバーヘッドが付きまとう。

監査ログのキャッシュはあくまで「そのマシン・そのファイル内での前回状態との差分」を割り出すための過渡的データにすぎない。`pjTaskText30` のようなローカルフィールドに閉じ込めておくことで、サーバ側のスキーマ競合や発行(Publish)トランザクションを汚染しないというメリットが生まれる。

—

5. まとめ:ツールに使われるな、エンジンを掌握せよ

制約条件の変更を野放しにする行為は、データベースで言えば「誰でも主キーや外部キー制約を警告なしにドロップできる」状態に等しい。そんな杜撰なアーキテクチャで開発プロジェクトの品質が保てるはずがないのだ。

1. 制約条件はCPMエンジンの骨格であり、安易な編集は遅延の温床となる
2. イベント駆動を避け、保存時の確定スナップショット(Text30)差分比較でCPU負荷を極小化する
3. OSユーザーと変更内容を日時付きでTSVに刻み、変更のトレーサビリティを担保する

この監査ロジックが組み込まれた瞬間、プロジェクトから「いつの間にか日付が変わっていました」という不毛な言い訳は消滅する。エンジニアリングの厳密さをもって、プロジェクトマネジメントを支配下に置こう。

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