プロジェクト管理 2026年10月31日

工数の実績が無いチームでEVMを回してみた。5営業日遅れて終わってもSPIは1.00に戻り、時間で測るSPI(t)だけが0.80で残った

XECIN PMBOKPMOWBS技術検証

先日、社内で過去の改修の振り返りをしていて、「あの遅れは、どの時点で数字に出ていたのか」という話になりました。

EVM(アーンドバリュー・マネジメント)で見直せば分かるはず、と言いかけて、手が止まりました。EVM の説明では、たいてい PV(計画値)・EV(出来高)・AC(実際にかかった工数)の3つが並び、そこから CPI と EAC で「最終的にどこまでかかるか」を出す流れになっています。PMBOK でも、進捗とコストを同じ物差しで測る代表的な道具です。

ところが、うちの Redmine で作業時間が記録されているチケットを数えてみると、API キーで見える 3,815 件のうち 3 件でした。見積もり工数が入っているのも 17 件だけです。

AC が無ければ、CPI も EAC も計算できません。教科書どおりに AC の入力から始めようとすると、まず入力の手間で止まります。小さなチームで工数の実績入力が続かないのは、うちに限った話ではないと思います。

一方で、予定の終了日(期日)が入っているチケットは 712 件、開始日と両方そろっているものでも 659 件ありました。予定日と「閉じたかどうか」なら、ほぼ毎日そろっている。だったら、EVM のうちスケジュールの側だけでも回せるのではないか。今回はそれを確かめた記録です。

削ったのはコストの側、残したのはスケジュールの側

フレームワークは目的ではなく道具です。今回のテーラリング(何を削り、何を残すか)は次のように決めました。

  • 削る: AC・CPI・EAC。入力が続かない数字を前提にした指標は、計算できても信用できない
  • 残す: PV・EV・SPI。予定日と状態があれば計算できる
  • 足す: SPI(t)(後で説明する、時間で測る SPI)と、完了日の予測

もう1つ、工数の代わりになる「重み」が要ります。チケットの大きさは予定日数で代用しました。3日の予定なら重み3、1日なら重み1です。PV は、予定の期間を日割りにして積み上げます。

ここは好みが分かれるところですが、チケット1本を一律に重み1とする数え方もあります。ただ、1日で終わる公開作業と4日かかるコーディングを同じ1と数えると、PV の線が実態から離れすぎます。今回は予定日数にしました。

12本・20営業日の計画が25日で終わった表

実案件の数字はそのまま出せないので、過去の改修の形を借りて、計画と実際を表に起こしました。小さなコーポレートサイトの改修で、デザインの確認で先方の戻りを待ち、その遅れが後ろへ押し出されていく、よくある形です。日付は営業日の通し番号です。

チケット内容予定実際
T01要件の整理1〜2日目1〜2日目
T02サイトマップとワイヤー3〜4日目3〜4日目
T03トップのデザイン5〜7日目5〜7日目
T04下層のデザイン6〜8日目6〜8日目
T05デザインの確認と修正9〜10日目9〜13日目(先方の確認待ち)
T06トップのコーディング11〜13日目14〜17日目
T07下層のコーディング11〜14日目14〜18日目
T08CMS への組み込み14〜16日目18〜21日目
T09フォームと計測タグ15〜16日目19〜20日目
T10結合テスト17〜18日目22〜23日目
T11修正19日目24日目
T12公開作業20日目25日目

計画は20営業日、実際は25営業日で終わりました。遅れの始まりは T05 で、10日目に閉じるはずが、確認待ちで13日目までずれ込んでいます。重みの合計(全体の量)は28です。

出来高の数え方を3通り用意した

EV の数え方は3つ試しました。

  • 0/100: 閉じたら全部、それまではゼロ
  • 50/50: 着手したら半分、閉じたら全部
  • 進捗率: Redmine の進捗率の欄をそのまま使う

進捗率は、正直なところ最初から怪しいと思っていました。念のため、社内のある施策の親チケットにぶら下がる子チケット15本を、後で載せるスクリプトで書き出してみました。15本とも閉じているのに、進捗率が100%になっているのは2本だけです。残りの13本は0%・60%・80%・90%のまま止まっていました。閉じたあとに進捗率まで直す人はいない、ということなんですよね。

そこで表の上では、進捗率を「経過日数 ÷ 予定日数」を10%単位で切り捨てて入れ、閉じるまでは90%で頭打ちにする人を想定しました。予定の日数を過ぎても90%のまま、という入れ方です。確認待ちの T05 は、10日目から12日目まで3日間、90%と申告され続けます。

# 計画: (チケット, 予定の開始日, 予定の終了日)。日は営業日の通し番号
PLAN = [
    ("T01", 1, 2), ("T02", 3, 4), ("T03", 5, 7), ("T04", 6, 8),
    ("T05", 9, 10), ("T06", 11, 13), ("T07", 11, 14), ("T08", 14, 16),
    ("T09", 15, 16), ("T10", 17, 18), ("T11", 19, 19), ("T12", 20, 20),
]
# 実際: チケット -> (着手した日, 閉じた日)
ACTUAL = {
    "T01": (1, 2), "T02": (3, 4), "T03": (5, 7), "T04": (6, 8),
    "T05": (9, 13), "T06": (14, 17), "T07": (14, 18), "T08": (18, 21),
    "T09": (19, 20), "T10": (22, 23), "T11": (24, 24), "T12": (25, 25),
}
PLAN_DAYS = 20
WEIGHT = {t: end - start + 1 for t, start, end in PLAN}  # 工数の代わりに予定日数で重み付け
TOTAL = sum(WEIGHT.values())  # 28


def planned_value(day):
    # 予定の期間を日割りにして、その日までに終わっているはずの量を足す
    return sum(min(max(day - s + 1, 0), WEIGHT[t]) for t, s, _ in PLAN)


def progress_ratio(t, day):
    # 経過日数 / 予定日数 を10%単位で切り捨てて入れる人を想定。閉じるまでは90%で頭打ち
    s, e = ACTUAL[t]
    if day >= e:
        return 1.0
    if day < s:
        return 0.0
    return min(90, 100 * (day - s + 1) // WEIGHT[t] // 10 * 10) / 100


def earned_value(day, rule):
    total = 0.0
    for t in WEIGHT:
        s, e = ACTUAL[t]
        if day >= e:
            total += WEIGHT[t]
        elif rule == "50/50" and day >= s:
            total += WEIGHT[t] / 2
        elif rule == "進捗率":
            total += WEIGHT[t] * progress_ratio(t, day)
    return total

同じ日のSPIが0.83から0.98まで割れた

SPI は EV ÷ PV で、1を下回ると遅れていることになります。T05 が閉じているはずの10日目で比べると、こうなりました。

  • 0/100: 0.83
  • 50/50: 0.92
  • 進捗率: 0.98

同じ案件の、同じ日です。進捗率で数えると、確認待ちで止まっている T05 が90%と申告されるので、ほぼ予定どおりに見えてしまいます。

0/100 は逆に敏感すぎました。遅れが始まる前の1〜9日目のうち、5日(1・3・5・6・7日目)で SPI が0.9を割っています。予定の日割りは毎日増えるのに、出来高は閉じた日にしか増えないからです。3日のチケットなら、予定どおり進んでいても途中の2日間は、そのチケットの分だけ遅れに数えられます。

50/50 は、遅れの前に0.9を割った日がゼロでした。10日目に0.92、11日目に0.79と、遅れが始まったところで素直に下がっています。今回の表では、一番使いやすかったのは 50/50 です。

最初はSPIだけを報告に載せるつもりだった

ここからが想定外でした。

最初は、週次の報告に 50/50 の SPI を1つ載せれば足りると考えていました。ところが日を追って並べてみると、終盤で SPI がどんどん1に寄っていきます。

営業日SPI(0/100)SPI(50/50)SPI(進捗率)SPI(t)(50/50)完了の予測(50/50)
10日目0.830.920.980.9022.2日目
12日目0.620.690.740.7526.7日目
20日目(計画の最終日)0.750.800.850.7626.2日目
23日目0.930.930.930.7825.6日目
24日目0.960.960.960.7925.3日目
25日目(実際の完了)1.001.001.000.8025.0日目

23日目で0.93、24日目で0.96、25日目で1.00。5営業日遅れて終わった案件が、報告の上では「ほぼ予定どおり」に戻っていきます。

理屈は単純で、20日目を過ぎると PV は28で止まり、EV も最後には28に届くからです。SPI は「量の比」なので、どれだけ遅れていても最後は必ず1.00になります。ドキュメントにはこう書いてあるけど(SPI が1未満なら遅れ)、終盤の SPI にはその読み方が効きません。今思えば、計画の終盤ほど SPI を信じてはいけないという話は聞いたことがありました。自分で表を作るまで、ここまで露骨に戻るとは思っていなかったんですよね。

23日目以降は、3つの数え方の値もそろっています。仕掛かり中のチケットが無い日は、数え方の違いが消えるからです。

時間で測るSPI(t)を足した

そこで、量ではなく時間で測る Earned Schedule(アーンド・スケジュール)を足しました。「今の出来高に、計画では何日目に届く予定だったか」を逆引きし、それを今日の日付で割ります。これが SPI(t) です。完了日の予測は、計画の20日を SPI(t) で割って出します。

def earned_schedule(ev):
    # 今の出来高に、計画では何日目に届く予定だったかを日割りで逆引きする
    if ev >= TOTAL:
        return float(PLAN_DAYS)
    day = 0
    while planned_value(day + 1) <= ev:
        day += 1
    pv_now, pv_next = planned_value(day), planned_value(day + 1)
    return day + (ev - pv_now) / (pv_next - pv_now)


for day in (10, 12, 20, 23, 24, 25):
    cells = []
    for rule in ("0/100", "50/50", "進捗率"):
        ev = earned_value(day, rule)
        spi = ev / planned_value(day)
        spi_t = earned_schedule(ev) / day
        cells.append(f"{rule} {spi:.2f} {spi_t:.2f} {PLAN_DAYS / spi_t:.1f}")
    print(f"{day}日目  " + " | ".join(cells))

# 実行結果(数え方ごとに SPI / SPI(t) / 完了の予測の日):
# 10日目  0/100 0.83 0.80 25.0 | 50/50 0.92 0.90 22.2 | 進捗率 0.98 0.98 20.4
# 12日目  0/100 0.62 0.67 30.0 | 50/50 0.69 0.75 26.7 | 進捗率 0.74 0.82 24.5
# 20日目  0/100 0.75 0.72 27.6 | 50/50 0.80 0.76 26.2 | 進捗率 0.85 0.79 25.2
# 23日目  0/100 0.93 0.78 25.6 | 50/50 0.93 0.78 25.6 | 進捗率 0.93 0.78 25.6
# 24日目  0/100 0.96 0.79 25.3 | 50/50 0.96 0.79 25.3 | 進捗率 0.96 0.79 25.3
# 25日目  0/100 1.00 0.80 25.0 | 50/50 1.00 0.80 25.0 | 進捗率 1.00 0.80 25.0

25日目の SPI(t) は0.80で、計画の20日 ÷ 実際の25日とぴったり一致します。50/50 の SPI(t) は、遅れが始まった11日目から最後まで0.73〜0.84の帯から出ませんでした。同じ期間の SPI は0.65〜1.00の間を動いています。

完了日の予測は、思ったほど早くは当たりませんでした。50/50 で、実際の25日目から前後1日以内に収まり続けたのは22日目からです。12日目の時点では26.7日目、16〜17日目には27.2日目と、2日ほど悲観側に振れていました。0/100 は、2〜20日目の間に20.0日目から32.0日目までを行き来しています。

私は、予測の日付そのものより「SPI(t) が0.8前後に張り付いた」という事実のほうが、11日目の時点で使えたと思います。0.8なら、20日の計画は25日前後になる。日付を1つ約束するより、この読み方を報告に書くほうが、外れたときに説明が残ります。

毎朝のスナップショットを取るスクリプト

ここまでは表の上の計算です。実データに当てるには、日ごとの状態が要ります。Redmine の API は「今の」状態しか返さないので、毎朝1回、親チケット配下の予定日・状態・進捗率を CSV に足していくことにしました。先ほどの進捗率の数字(15本中13本が100%未満)も、このスクリプトで書き出したものです。

# 親チケット配下の予定日・状態・進捗率を、実行した日の行として CSV に足していく
import csv, datetime, json, os, sys, urllib.request

base = os.environ["REDMINE_URL"].rstrip("/")
key = os.environ["REDMINE_API_KEY"]
parent = sys.argv[1]

url = f"{base}/issues.json?parent_id={parent}&status_id=*&limit=100"
req = urllib.request.Request(url, headers={"X-Redmine-API-Key": key})
issues = json.load(urllib.request.urlopen(req))["issues"]

today = datetime.date.today().isoformat()
with open(f"snapshot-{parent}.csv", "a", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    for i in issues:
        w.writerow([today, i["id"], i.get("start_date"), i.get("due_date"),
                    i["status"]["is_closed"], i["done_ratio"]])
print(today, len(issues), "rows")

正直に書くと、このスクリプトには改善の余地があります。子チケットが100本を超える親ではページングしていないので取りこぼしますし、同じ日に2回動かすと行が重複します。予定日は暦日なので、営業日の通し番号に直す処理(土日と祝日を除く)も別に要ります。今は、毎朝の記録が途切れないことだけを優先しました。

運用のルールにしたこと

表の結果から、次の4つを決めました。

  • 重みは予定日数。開始日と期日の両方が入っていないチケットは数えない
  • EV は 50/50 で数える。進捗率の欄は使わない
  • 週次の報告には SPI と SPI(t) を並べて載せ、遅れの判断は SPI(t) で読む(終盤の SPI は1に寄っていく)
  • 完了日の予測は1つの日付で書かず、「SPI(t) が0.8なら20日の計画は25日前後」のように、読み方とセットで書く

おわりに

今回の数字は、過去の改修の形を借りた表の上のものです。動いている案件で、毎朝のスナップショットから SPI(t) を1案件分取り切ったわけではありません。

次は、実際の案件で1か月分のスナップショットをためて、50/50 の SPI(t) が表の上と同じように帯に張り付くのかを確かめたいと思っています。チケットを2〜3日の粒度に割ったときに、0/100 の誤報がどこまで減るのかも合わせて見てみるつもりです。

CPI と EAC を捨てたのは、入力が続かないという現場の事情に合わせた判断です。工数の記録が回り始めたら、そのときにコストの側を戻すかどうかを、もう一度考えます。