CHAPTER 10 ・ 総合演習 / Lesson 39

月次レポートを自動で作る

読み込み・整形・集計・前月比・Excel出力を、関数でつないだ1本の流れにします。

目安 40分 ・ インストール不要

このレッスンが終わったら

「月次の作業を、読み込み→整形→集計→出力の関数に分けて、1本の流れにできる。テストで結果を守れる」と言える。

STEP 1

課題とデータを見る

毎月、月が替わると、決まって頼まれる仕事があります。「先月の売上を、得意先別・商品別・地域別にまとめて、前の月と比べてください」。Excelで毎月ピボットテーブルを作り直している人も多いでしょう。同じ手順の繰り返しは、Pythonの得意分野です。

材料は、売上明細(sales_detail.csv)と、得意先・商品の一覧(customers.csv、products.csv)です。まず、明細の中身を確かめます。

売上明細の件数を、月ごとに数えるサンプルファイル付き・書き換えOK
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

2025年4月から9月までの明細が128行あります。1行は「伝票番号・日付・得意先コード・商品コード・数量・単価」で、得意先名や地域は、別のファイルにあります。

今回作るレポートの中身は、次の通りです。指定した月(例:2025-09)について、

  • 得意先別・商品別・地域別の、当月の売上・前月の売上・前月比・構成比
  • 売上の多い上位5社
  • 金額は、数量×単価の合計(税抜)。棒グラフは使わず、Excelの表示形式(桁区切り、マイナスは赤字)で見やすくする
STEP 2

設計:4つの関数と、まとめ役

作業を、4つの関数と、それをつなぐ main に分けます。このような「小さな処理を順につないだ1本の流れ」を、パイプラインと呼ぶこともあります。

  1. load_data3つのCSVを読む
  2. clean売上と年月を作り、名前を付ける
  3. summarize当月・前月・前月比・構成比
  4. check_totals検算:合計が合うか
  5. write_reportExcelに書く
main(month, dry_run) が、この5つを順に呼びます。
関数受け取るもの返すもの
load_data()なし明細・得意先・商品の3つの表
clean(...)3つの表売上と年月の列がある、名前付きの明細
summarize(df, key, month, prev)明細、集計の軸、対象月、前月集計の表
write_report(path, tables)出力先、集計の表なし(Excelを書く)
main(month, dry_run)対象月、書き込むかどうか集計の表(確認用)

決めておくことが、3つあります。

  • 対象月は引数で渡す:来月は、コードではなく、引数の "2025-10" を変えるだけにする
  • 前月が無いとき、前月比は空欄にする:0で割れないので、「比べられない」ものとして扱う
  • dry_run:True のときは、集計と検算だけ行い、ファイルを書かない(Lesson 29)。本番の前に試せる
STEP 3

部品を1つずつ作る

前月の求め方。 「2025-01 の前月」は「2024-12」です。文字の計算では間違えやすいので、pandas の月の型 Period に任せます。

前月を求める(年をまたぐ場合も)pandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

読み込みと整形。 売上を計算し、年月の列を作り、得意先名・地域・商品名を結合します(Lesson 23)。結合の前後で行数が変わっていないかを、必ず確かめます。

clean:売上を計算して、名前を付けるpandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

validate="many_to_one" は、「右の表(得意先・商品)に、同じコードが2行あったらエラー」という安全装置です。重複があると行が増え、売上が二重に数えられてしまうので、行数の確認と合わせて守ります。

集計。 当月と前月を並べて、前月比と構成比を求めます。まず、ごく小さなデータで手計算できる形にして、テストします。

summarize:小さなデータでテストするpandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

ポイントは .where(out["前月"] > 0) です。「前月が0より大きいところだけ、比べる。それ以外は空欄(NaN)にする」という意味で、0で割る問題を避けています。B社のように前月はあったのに当月が0の得意先も、表に残ります。取引が途切れたことに気付けるからです。

Excelの見た目。 数字をそのまま書くと、桁区切りも%も付きません。Excelの表示形式を設定すると、値は変えずに、見た目だけを整えられます(Lesson 27)。

表示形式を設定して、読み直すpandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

0.0%;[Red]-0.0% は、「プラスは普通に、マイナスは赤字で」という書式です。ここで確かめたのは、値が元の数字のままであることと、書式が設定されていることです。

STEP 4

組み合わせて完成させる

部品を main でつなぎます。ログ(Lesson 12)で、途中の様子を残します。最後に、本番の書き込み、書き出したExcelの読み直し、前月が無い月、データの無い月を試します。

完成:月次レポートを作る main(month, dry_run)pandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます

結果を読みましょう。

  • 9月の売上合計は 1,036,500円で、得意先別・商品別・地域別の3つの合計が、すべて明細と一致しました。一致しなければ、check_totals が止めてくれます
  • 前月(8月)と比べると、約66%の減少です(サンプルデータの都合です)。実際の仕事で、こんなに減っていたら、まずデータの取り込み漏れや、入力の遅れを疑います
  • 得意先では、サクラ印刷が299,000円でトップ、アサヒ建材は前月の約4.6倍(前月比で約+360%)です。8月にあって9月が0円の得意先(スミレ運輸・カエデ設計)も、表に残っています
  • main("2025-04") は、前月(3月)のデータが無いので、前月比が空欄になります。エラーにはならず、レポートは作れます
  • main("2025-12") は、データが無い月なので、分かりやすい日本語のエラーで止まります(Lesson 10)

dry_run=True(既定)なら、集計と検算だけで、ファイルは作りません。main("2025-04") のログに、その表示が出ています。ブラウザではダウンロードできないので、書き出した 月次レポート_2025-09.xlsx を読み直して確かめました。自分のパソコンで動かせば、フォルダにファイルができます。

STEP 5

自分で書く

お題① 月を指定して、その月の売上合計を返す関数 total_of(df, month) を完成させてください。7月・8月の合計と、8月の前月比(小数1桁の%)を表示します。

お題①:月の合計と、前月比pandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

df[df["年月"] == month]["売上"].sum() で合計が出ます。int(...) で整数にして、return で返します。

答えの例を見る
import pandas as pd

sales = pd.read_csv("sales_detail.csv")
df = sales.assign(売上=sales["数量"] * sales["単価"], 年月=sales["日付"].str[:7])


def total_of(df, month):
  return int(df[df["年月"] == month]["売上"].sum())


jul = total_of(df, "2025-07")
aug = total_of(df, "2025-08")
print(jul, aug)
print(f"{(aug / jul - 1):.1%}" if jul else "前月なし")

お題② 2025年8月の売上の上位3社の得意先名を、売上の多い順にリストで表示してください。(customers.csv の結合と、nlargest を使います)

お題②:8月の上位3社pandas・サンプルファイル付き
Ctrl / ⌘ + Enter でも実行

この欄では、サンプルファイルが使えます。実行のたびに、ファイルは最初の状態に戻ります。

結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

df[df["年月"] == "2025-08"].groupby("得意先名")["売上"].sum() のあと、.nlargest(3).index.tolist() で、名前のリストになります。

答えの例を見る
import pandas as pd

sales = pd.read_csv("sales_detail.csv")
customers = pd.read_csv("customers.csv")
df = sales.assign(売上=sales["数量"] * sales["単価"], 年月=sales["日付"].str[:7])
df = df.merge(customers[["得意先コード", "得意先名"]], on="得意先コード", how="left", validate="many_to_one")

by_customer = df[df["年月"] == "2025-08"].groupby("得意先名")["売上"].sum()
print(by_customer.nlargest(3).index.tolist())

お題③ 検算の関数 check_total(detail_total, table_total) を完成させてください。2つの合計が違うときは、ValueError("合計が合いません") を出します。

お題③:検算の関数コードは書き換えられます
Ctrl / ⌘ + Enter でも実行
結果
▶ 実行を押すと、ここに結果が出ます
ヒントを見る

if detail_total != table_total: のあとに、raise ValueError("合計が合いません") と書きます。

答えの例を見る
def check_total(detail_total, table_total):
  if detail_total != table_total:
      raise ValueError("合計が合いません")


for a, b in [(1036500, 1036500), (1036500, 1036000)]:
  try:
      check_total(a, b)
      print("OK")
  except ValueError as e:
      print(e)
STEP 6

振り返りと、実務に持ち帰る

import argparse


def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="月次の売上レポートを作ります")
    parser.add_argument("--month", required=True, help="対象月(例 2025-09)")
    parser.add_argument("--dry-run", action="store_true", help="ファイルを書き込まずに確認だけする")
    return parser.parse_args(argv)


if __name__ == "__main__":
    args = parse_args()
    main(args.month, dry_run=args.dry_run)
python report.py --month 2025-09 --dry-run
python report.py --month 2025-09

理解チェックmain(month, dry_run) のように、対象月と dry_run を「引数」にしておく利点は?

理解チェック前月の売上が0円の商品について、前月比はどう扱うのが適切でしょう?