Article

経費精算システムの費用対効果を検証する方法

高田晃太郎
経費精算システムの費用対効果を検証する方法

1件の経費精算に要する平均処理コストは58ドル、さらに19%が何らかの修正を要し、その手戻りに追加で52ドルかかるという調査が知られています(2015年のGBTA Foundation, The True Cost of an Expense Report)¹²。この規模感は企業規模を問わず無視できません³。各種レポートを読み解くと、導入判断は「便利そう」から「再現可能な費用対効果の証明」へ舵を切ることが鍵だと考えられます。意思決定を前に進めるには、体感や主観ではなく、合意可能な定義とデータで語れる設計が有効です。なお、T&E業務の非効率が多くの組織で時間とコストの損失につながっていることも業界レポートで繰り返し指摘されています⁴。そこで本稿では、経費精算システムの費用対効果を、「基準線(ベースライン)の作り方」「TCO(総保有コスト)の全量化」「因果推論による効果測定」「ROI/NPVなど経営指標への翻訳」という流れで、実装可能なコードとともに解説します。実装は自社のデータ基盤と権限設計に合わせて調整してください。

現状把握と効果定義(基準線の作り方)

費用対効果の測定で最初に迷うのは、何を「効果」とみなすかという定義の揺れです。私は直接費と間接費、リスク回避の代理指標を固定するところから始めます。直接費は人件費換算の処理単価、監査・差し戻しの再作業、紙や郵送の物理コストが該当します。間接費は従業員の待ち時間短縮やキャッシュフロー改善、会計締めの前倒しによる生産性向上などで、時間と金額に変換して扱います。リスクは不正検知の網羅率、二重払いの発生率、ポリシー違反率を代理指標に据えると良いでしょう。これらを月次で観測できるテーブルを決め、導入前の12カ月程度の基準線(導入前の平常時の水準)を固めると季節性の影響を抑えられます。

たとえばPostgreSQL上に経費レポートと監査ログがある想定で、処理時間と差し戻し率を抽出して基準線を作ります。以下のクエリは提出から支払までのサイクルタイム、差し戻し率、処理件数を月次で集計します。実行計画の安定化には提出日時と状態カラムの複合インデックスが有効です(基礎となるKPI定義をDWH側でSQL化し、定義の変更履歴をGitで管理すると監査対応や説明責任が明確になります)。

-- 基準線の月次KPI(PostgreSQL)
-- 前提: expense_reports(id, submitter_id, org_unit, submitted_at, approved_at, paid_at, status, amount)
--       audit_logs(report_id, action, created_at)
EXPLAIN ANALYZE
WITH base AS (
  SELECT
    date_trunc('month', submitted_at)::date AS month,
    id,
    status,
    EXTRACT(EPOCH FROM (COALESCE(paid_at, NOW()) - submitted_at))/3600.0 AS hrs_to_paid
  FROM expense_reports
  WHERE submitted_at >= date_trunc('month', NOW()) - INTERVAL '12 months'
), returns AS (
  SELECT DISTINCT report_id
  FROM audit_logs
  WHERE action = 'returned'
)
SELECT
  month,
  COUNT(*) AS reports,
  AVG(hrs_to_paid) AS avg_hrs_to_paid,
  100.0 * SUM(CASE WHEN id IN (SELECT report_id FROM returns) THEN 1 ELSE 0 END)::float / COUNT(*) AS return_rate_pct
FROM base
GROUP BY month
ORDER BY month;

この出力に社内の標準賃率を掛けることで、処理単価を金額化できます。例えば承認者と経理担当の平均時給を6,000円と仮定し、1件あたり平均25分なら直接人件費は2,500円です。差し戻し時は再作業の平均時間を別途乗じます。数値化の前提は関係者に明示し、社内標準に揃えると良いです。

効果カテゴリの固定とトレーサビリティ

測定対象を固定したら、計測の出所を一本化します。経費SaaSのダッシュボードだけに頼ると定義がブラックボックスになりがちです。DWH側でKPI定義をSQL化しGitで管理すると、定義変更の差分が追え、監査時の説明責任も果たせます。dbtやLookMLといったモデリングレイヤーに「submitted_to_paid_hours」「return_rate」「policy_violation_rate」を標準メジャーとして実装し、導入前後の差分を同一定義で比較できるようにしておくと再現性が高まります。

コストの全量化とTCOモデル化

次にコスト側を全量化します。サブスクリプション費用だけでは不十分で、実装・統合・SSO、運用・監査フロー設計、トレーニング、ロールアウトのチャンピオンFTE、旧システムの廃止まで含めて**TCO(Total Cost of Ownership: 総保有コスト)**に落とし込みます。初期費用は償却期間を決めて月次に按分し、ユーザー増減と機能追加に合わせて将来費用もシナリオ化します。以下はPythonによる簡易TCOモデルです。入力検証と例外処理を備え、初期費用を36カ月で按分し、席数の成長と従量課金を織り込みます(割引率はWACCなど組織の資本コストに合わせて設定)。

from dataclasses import dataclass
from typing import List, Dict
import math

@dataclass
class CostInput:
  months: int
  seats_start: int
  monthly_sub_per_seat: float
  variable_cost_per_report: float
  reports_per_month_start: int
  growth_rate_seat_pct: float  # e.g., 0.02 = 2%/mo
  growth_rate_reports_pct: float
  one_off_impl: float
  training_one_off: float
  integration_one_off: float
  discount_rate_annual: float  # WACCなど

class TCOModelError(Exception):
  pass

def monthly_discount_rate(annual: float) -> float:
  if annual < 0:
    raise TCOModelError("discount rate must be >= 0")
  return (1 + annual) ** (1/12) - 1

def project_tco(ci: CostInput) -> Dict[str, List[float]]:
  if ci.months <= 0 or ci.seats_start <= 0:
    raise TCOModelError("months and seats must be positive")
  md = monthly_discount_rate(ci.discount_rate_annual)
  costs, discounted = [], []
  seats, reports = ci.seats_start, ci.reports_per_month_start
  amort = (ci.one_off_impl + ci.training_one_off + ci.integration_one_off) / 36.0
  for m in range(1, ci.months + 1):
    sub = seats * ci.monthly_sub_per_seat
    usage = reports * ci.variable_cost_per_report
    total = sub + usage + amort
    costs.append(total)
    discounted.append(total / ((1 + md) ** m))
    seats = math.ceil(seats * (1 + ci.growth_rate_seat_pct))
    reports = math.ceil(reports * (1 + ci.growth_rate_reports_pct))
  return {"nominal": costs, "discounted": discounted}

# 使用例
try:
  ci = CostInput(
    months=36, seats_start=800, monthly_sub_per_seat=900,
    variable_cost_per_report=15, reports_per_month_start=5000,
    growth_rate_seat_pct=0.01, growth_rate_reports_pct=0.02,
    one_off_impl=1200000, training_one_off=300000, integration_one_off=500000,
    discount_rate_annual=0.08
  )
  tco = project_tco(ci)
except TCOModelError as e:
  print(f"TCO input error: {e}")

ライセンスの「実際の利用」に近い値を得るため、ベンダーAPIから月次のアクティブ利用者やOCR処理件数などを取得しDWHに蓄えます。下のNode.js例は指数バックオフと必須フィールド検証、トランザクション管理を備えた取り込み処理です(baseURL指定に注意)。

import axios from 'axios';
import { Client } from 'pg';

const api = axios.create({ baseURL: process.env.EXP_API, timeout: 15000 });

async function fetchWithRetry(path, params, retries = 5) {
  let attempt = 0;
  while (attempt < retries) {
    try {
      const res = await api.get(path, { params });
      if (!Array.isArray(res.data)) throw new Error('unexpected payload');
      return res.data;
    } catch (e) {
      attempt++;
      const backoff = Math.min(2000 * 2 ** attempt, 30000);
      if (attempt >= retries) throw e;
      await new Promise(r => setTimeout(r, backoff));
    }
  }
}

async function ingestMonthly(year, month) {
  const client = new Client({ connectionString: process.env.PG_URL });
  await client.connect();
  try {
    const users = await fetchWithRetry('/usage/active_users', { year, month });
    const ocr = await fetchWithRetry('/usage/ocr', { year, month });
    await client.query('BEGIN');
    for (const u of users) {
      if (!u.userId || !u.activeDays) continue;
      await client.query(
        'INSERT INTO em_usage(month, user_id, active_days) VALUES($1,$2,$3) ON CONFLICT DO NOTHING',
        [`${year}-${String(month).padStart(2,'0')}-01`, u.userId, u.activeDays]
      );
    }
    for (const x of ocr) {
      if (!x.count) continue;
      await client.query(
        'INSERT INTO em_ocr(month, count) VALUES($1,$2) ON CONFLICT DO NOTHING',
        [`${year}-${String(month).padStart(2,'0')}-01`, x.count]
      );
    }
    await client.query('COMMIT');
  } catch (e) {
    await client.query('ROLLBACK');
    console.error('ingest error', e);
    throw e;
  } finally {
    await client.end();
  }
}

こうして得た実利用データで、見積もりではなく現実のTCOを月次に積み上げます。購買の意思決定で重要なのは、SaaSの名目単価ではなく、ユースケース1件あたりの総コストがどう下がったかです⁵。あわせて、チェック自動化の網羅率など品質KPIも並走させてください。

効果の実測設計と因果推論

導入前後の単純比較はしばしば錯覚を生みます。季節要因や他施策の同時実行を除外するために、対象部門だけを段階導入して**差の差分(Difference-in-Differences)**で推定すると頑健です。これは「導入対象×導入前後」の二軸で平均値の差分を二回取り、外生的な揺らぎを打ち消す方法です。統制群は同等のプロファイルを持つ部門や子会社から選びます。以下はpandasでDiDを実装し、95%信頼区間をブートストラップで求める例です。KPIは提出から支払までの時間を短縮したいので負の効果が望ましい設定とします。

import pandas as pd
import numpy as np
from typing import Tuple

class DIDError(Exception):
  pass

def did_effect(df: pd.DataFrame) -> Tuple[float, Tuple[float, float]]:
  # df columns: ['month','org_unit','treated','post','kpi']
  if df.empty or not set(['treated','post','kpi']).issubset(df.columns):
    raise DIDError('invalid dataframe')
  grp = df.groupby(['treated','post'])['kpi'].mean().unstack()
  if grp.shape != (2,2):
    raise DIDError('need both treated/control and pre/post')
  effect = (grp.loc[1,1] - grp.loc[1,0]) - (grp.loc[0,1] - grp.loc[0,0])
  boot = []
  rng = np.random.default_rng(42)
  for _ in range(2000):
    sample = df.sample(frac=1.0, replace=True, random_state=rng)
    g = sample.groupby(['treated','post'])['kpi'].mean().unstack()
    boot.append((g.loc[1,1] - g.loc[1,0]) - (g.loc[0,1] - g.loc[0,0]))
  low, high = np.percentile(boot, [2.5, 97.5])
  return float(effect), (float(low), float(high))

# 使い方: DWHから抽出した月次KPIにtreated/postを付与して渡す

統制群の選定はSQLで半自動化できます。以下は同一親組織下で従業員数と費用構成が近い部門を近傍探索する例です。

-- 統制群候補の抽出(近傍探索)
-- org_units(id, parent_id, headcount, travel_share, expense_share)
WITH base AS (
  SELECT id, parent_id, headcount, travel_share, expense_share
  FROM org_units WHERE id = $treated_org
), candidates AS (
  SELECT c.id, c.parent_id,
         ABS(c.headcount - b.headcount) / NULLIF(b.headcount,0) AS hc_gap,
         ABS(c.travel_share - b.travel_share) AS ts_gap,
         ABS(c.expense_share - b.expense_share) AS es_gap
  FROM org_units c CROSS JOIN base b
  WHERE c.parent_id = (SELECT parent_id FROM base) AND c.id <> (SELECT id FROM base)
)
SELECT id FROM candidates
ORDER BY (hc_gap + ts_gap + es_gap) ASC
LIMIT 3;

効果の統計的有意性は万能ではありませんが、意思決定のノイズを減らします。業務では「十分に大きい差が実務的に意味を持つか」を重視します。たとえば、差し戻し率が3ポイント下がり、再作業の平均時間が20分なら、仮に月5,000件の処理量とすると毎月5,000×0.03×20分=3,000分の削減、すなわち人件費ベースで約30〜40万円に相当する、といった翻訳が必要です。

経営指標への翻訳と意思決定

観測された便益を、TCOと組み合わせて経営が読める指標に変換します。私はROI、NPV、回収期間、IRRのセットで示すことを推奨します⁵。ROIは(総便益−総コスト)/総コスト、NPV(正味現在価値)は将来のキャッシュフローを割引いて現在価値に換算した合計です。以下はNPV、IRR、回収期間を計算するPythonです。入力の健全性検証を入れ、ゼロ除算や収束失敗を例外で扱います(IRRはnumpy-financialを利用)。

import numpy as np
from typing import List

try:
  import numpy_financial as npf
except ImportError:
  npf = None

class FinanceError(Exception):
  pass

def npv(rate_monthly: float, cashflows: List[float]) -> float:
  if rate_monthly < 0:
    raise FinanceError('rate must be non-negative')
  # cashflowsはt=1以降のフロー(初期投資は別変数で扱う想定)
  return float(sum(cf / ((1 + rate_monthly) ** t) for t, cf in enumerate(cashflows, start=1)))

def payback_period(cashflows: List[float]) -> float:
  # cashflowsはt=0を含む(初期投資が負の値)
  cum = 0.0
  for i, cf in enumerate(cashflows):
    cum += cf
    if cum >= 0:
      return float(i)
  raise FinanceError('not paid back within horizon')

def irr(cashflows: List[float]) -> float:
  # cashflowsはt=0を含む系列(例: [-初期投資, +, +, ...])
  if npf is None:
    raise FinanceError('numpy-financial is required for IRR')
  r = npf.irr(cashflows)
  if r is None or np.isnan(r):
    raise FinanceError('IRR did not converge')
  return float(r)

# 例: 初期投資はマイナス、以降は月次ネットキャッシュフロー
discount_rate_annual = 0.08
rate_m = (1 + discount_rate_annual) ** (1/12) - 1
cashflows_monthly = [180000] * 36
initial_invest = -2500000

try:
  v = initial_invest + npv(rate_m, cashflows_monthly)  # NPV
  pb = payback_period([initial_invest] + cashflows_monthly)
  r = irr([initial_invest] + cashflows_monthly)
except FinanceError as e:
  print('finance error', e)

感度分析も重要です。OCR精度や自動監査ルールのヒット率によって便益がぶれるため、モンテカルロで分布を把握します。下は便益とコストの揺らぎを仮定してROIと回収期間の分布を出す例です(中央値とパーセンタイルで意思決定時のレンジを提示)。

import numpy as np

np.random.seed(7)
trials = 2000
benefit_mean, benefit_sd = 180000, 40000
cost_mean, cost_sd = 90000, 15000
months = 36

roi_samples, pb_samples = [], []
for _ in range(trials):
  monthly_b = np.random.normal(benefit_mean, benefit_sd, months)
  monthly_c = np.random.normal(cost_mean, cost_sd, months)
  net = monthly_b - monthly_c
  invested = 2500000
  cum = -invested
  pb = None
  for i, cf in enumerate(net, start=1):
    cum += cf
    if cum >= 0 and pb is None:
      pb = i
  if pb is None:
    pb = float('inf')
  roi = (net.sum() - invested) / invested
  roi_samples.append(roi)
  pb_samples.append(pb)

p50_roi = float(np.percentile(roi_samples, 50))
p10_roi = float(np.percentile(roi_samples, 10))
p90_roi = float(np.percentile(roi_samples, 90))

合議の場では、中央値と悲観・楽観シナリオの三点を提示し、資本配分の優先順位付けに使います。監査網羅率や違反率の改善は法令遵守の観点で金額換算が難しい領域ですが、罰金や社内調査コストの回避額レンジを示すことで、純粋な時間短縮に偏らない評価が可能になります。付随して、VAT・インボイスの還付機会を取りこぼしていないか、仕訳の粒度が還付要件を満たしているかもチェックします。次のSQLは税区分の不足で還付漏れが疑われるトランザクションを抽出する例です。

-- 還付漏れ疑いのトランザクション抽出
-- expenses(id, posted_at, amount, tax_code, merchant_category, country)
SELECT id, posted_at, amount, merchant_category
FROM expenses
WHERE country = 'JP'
  AND posted_at >= date_trunc('year', NOW())
  AND tax_code IS NULL
  AND merchant_category IN ('HOTEL','AIRLINE','RESTAURANT');

最後に、計測自体のパフォーマンスも品質です。1,000万行規模でも月次KPIの再計算が数分で収まるよう、日別集計の増分更新や適切なインデックスで支えます。PostgreSQLならEXPLAIN ANALYZEでホットパスを特定し、ボトルネックを絞ります。CPU・I/O・メモリのいずれが律速かを見極め、クエリとストレージの二面から最適化してください。

判断を前に進めるための提示方法

経営会議には、KPIの差分、TCO、NPV、回収期間を一枚にまとめたファクトシートを持ち込みます。導入の是非はもちろん、段階導入の順序やトレーニング投入量の最適化にも役立ちます。

まとめ:測り方を決めれば、答えは出せる

経費精算システムの費用対効果は、基準線を整え、コストを全量化し、因果を丁寧に推定し、経営指標に翻訳することで、クリアに示せます。データさえ整えば、便益は「感じ」から「示す」へと変わります。あなたの組織では、何を最初の効果として確定しますか。提出から支払までの時間を半減させるのか、差し戻し率を単月で3ポイント落とすのか、それとも監査の自動化網羅率を70%まで引き上げるのか。今日から基準線の抽出とTCOのモデル化を始め、来期の投資計画に間に合う形でROI・NPV・回収期間の三点セットを準備しましょう。コードはすでにあります。あとは実データを流し込み、意思決定のスイッチを押すだけです。

参考として、GBTA Foundationの調査「The True Cost of an Expense Report」は処理コストとエラー率の現実的な水準を把握するのに有用です¹²。必要に応じて自社データと突き合わせ、前提の妥当性を常に検証してください。

参考文献

  1. PR Newswire. Hidden costs — 5 numbers that show expense reports drain company resources. https://www.prnewswire.com/news-releases/hidden-costs----5-numbers-that-show-expense-reports-drain-company-resources-300175763.html
  2. Hospitality Net. Hidden costs — 5 numbers that show expense reports drain company resources. https://www.hospitalitynet.org/news/4072636.html
  3. SAP Community. Cut your corporate travel and expense processing costs in half with IT. https://community.sap.com/t5/technology-blog-posts-by-sap/cut-your-corporate-travel-and-expense-processing-costs-in-half-with-it/ba-p/12953473
  4. PairSoft. 2018 Travel and Expense Management Insight Report. https://www.pairsoft.com/blog/2018-travel-and-expense-management-insight-report/
  5. Concur. Expense spreadsheet reporting cost calculator. https://www.concur.com/en-us/expense-spreadsheet-reporting-cost-calculator