祝日 API を"消費"して稼働報告書を自動生成する ― Excel の数式を殺さずに祝日対応を差し込む
前回、「消費者が LLM や表計算である前提で祝日 API を設計する」という producer 側の話を書きました。今回はその対になる consumer 側、実際にその API を業務ツールから叩く実装記録です。
題材は、月次の稼働時間報告書(Excel)をクライアントごとに一括生成する社内ツールです。ここに前回作った /calendar を消費する処理を足して、土日だけでなく祝日も除いた稼働日数を出せるようにしました。ポイントは「既存の Excel テンプレートの数式を殺さずに、外部データを差し込む」ところです。
きっかけ:予定稼働日数が祝日を数えていた
このツール、従来は「予定稼働日数」をテンプレートの数式で出していました。中身はこんな COUNTIFS です。
=COUNTIFS(C8:C38,"<>土",C8:C38,"<>日")
C 列に入っている曜日から土日だけを除外して数える、というものです。素朴で分かりやすいのですが、これだと祝日が稼働日にカウントされてしまう。月に祝日が 2 日あれば、予定稼働日数が実態より 2 日多く出てしまいます。
ここに祝日対応を入れたいわけですが、稼働日の判定ロジックはもう前回の API 側(isBusinessDay)に持っています。なので、このツールは「API を叩いて、返ってきた稼働日フラグを Excel に流し込むだけ」の薄い消費者に徹します。
従来構造の把握:ロジックは Python になかった
面白かったのは、着手する前に構造を確認したら、稼働日判定のロジックが Python 側に一切なかったことです。土日除外は上の COUNTIFS、つまりテンプレートの数式に埋まっていて、Python は「テンプレをコピーして企業名や対象月を書くだけ」でした。
ということは、Python 側に祝日判定を書いて固定値で埋めるのは筋が悪い。集計ロジックが「Python の中」と「Excel の数式」に二重に散ってしまいます。集計は Excel ネイティブの数式のままにしておきたい ── これが差し込み設計の出発点でした。
差し込み設計:非表示の作業列 + SUM
そこで、こういう形にしました。
/calendar/:monthを叩いて、月の全日をisBusinessDay付きで取得する- 非表示の作業列 K 列に、稼働日フラグ(土日祝を除く平日 = 1、それ以外 = 0)を書く
- 予定稼働日数のセル
G40を=SUM(K8:K38)に置き換える
稼働日数を Python で計算して固定値で埋める"のではなく"、フラグだけ書いて集計は Excel の SUM に任せる、というのが肝です。こうすると、ファイルを開いた人が G40 をクリックすれば「K 列の合計なんだな」と中身を追えるし、あとから手で修正することもできます。Python は判定結果を置くだけ、集計は Excel ネイティブのままです。
def write_calendar(ws, calendar_days):
ws["K7"] = "稼働日"
ws.column_dimensions["K"].hidden = True # 集計用の作業列なので隠す
by_day = {d.day: d for d in calendar_days}
for day in range(1, 32):
row = 8 + day - 1 # 8行目 = 1日
info = by_day.get(day)
if info is None:
# 月末を超える行(30日月の31日など)はクリア
ws[f"B{row}"] = None
ws[f"C{row}"] = None
ws[f"I{row}"] = None
ws[f"K{row}"] = None
else:
ws[f"B{row}"] = info.day
ws[f"C{row}"] = info.weekday
ws[f"I{row}"] = info.name if info.is_holiday else None # 祝日名をメモ欄に
ws[f"K{row}"] = 1 if info.is_business_day else 0
ws["G40"] = "=SUM(K8:K38)"
祝日名は I 列(メモ欄)に出しているので、報告書を見た人が「あ、この日は海の日か」と分かるようにもなっています。
罠1:Python-urllib/* の User-Agent が 403 で弾かれる
ここで人柱ポイントです。ローカルで curl すると普通に返ってくる API が、Python から叩くと 403 になるという現象にハマりました。
原因は User-Agent でした。Python の urllib は既定で Python-urllib/3.x という UA を送るのですが、これが弾かれていました。カスタム UA を明示して解決です。
req = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "working-uptime-generator/0.1", # 既定 UA だと 403
},
)
「ローカルの curl では動くのに、スクリプトからだと落ちる」系の地味な罠でした。同じところで詰まる人がいそうなので残しておきます。
参考: MDN - User-Agent
罠2:取得に失敗したら"黙って進めない"
もう一つ意識したのは、API 取得に失敗したときにフォールバックで適当な稼働日数を作らないことです。稼働報告書は請求にも関わる書類なので、「祝日 API が落ちてたので土日だけ除いた数字で作りました」と黙って進むのがいちばん怖い。なので取得失敗時は、フォールバックせずに明示的にエラー終了させています。
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError) as e:
sys.stderr.write(
f"Error: Failed to fetch holiday calendar from '{url}': {e}\n"
"Check network connectivity or HOLIDAY_API_BASE in config.yml.\n"
)
sys.exit(1)
「誤った稼働日数の報告書を作る」より「作れませんでしたと止まる」ほうがずっと安全、という判断です。
ついでの一元化:作業者名を config へ
作業のついでに、作業者名の二重管理も解消しました。もともと作業者名は「生成するファイル名」と「テンプレートの氏名セル」の両方に別々に埋まっていて、変えるときに直し忘れそうな状態でした。これを config.yml に WORKER_NAME として一元化し、起動時にセルもファイル名もそこから埋めるようにしています。
これは前回の「API をデータの単一情報源にする」話の、ごく小さな Excel 版とも言えます。値の出どころを 1 か所に決めておくと、テンプレ直書きに勝てます。
まとめ
既存の Excel 資産(数式・書式)を活かしたまま、外部データ(祝日 API)を差し込むときの勘どころは、こんな感じでした。
- 集計は数式のままにして、Python は判定結果(フラグ)を置くだけにする
- 非表示の作業列 +
SUMで、開いた人が中身を追える形を保つ - UA を明示して 403 を避ける
- 失敗時はフォールバックせず止める(誤った数字を作らない)
- テンプレ本体は触らず、コピー後の各ファイルにだけ書く
前回の producer 記事で「isBusinessDay を供給側で確定させた」おかげで、消費側のこのツールは本当に「叩いて詰めるだけ」で済みました。次回は、同じ API を今度は手元用の薄い Rust CLI から消費する話を書きます。