Googleスプレッドシートの売上から月末着地予想を自動計算しSlackに投稿する方法

Googleスプレッドシートの売上から月末着地予想を自動計算しSlackに投稿する方法 経理・数字の自動化

日次で売上を入力しているスプレッドシートがあるなら、月末を待たずに「このままいくとどこに着地するか」を自動で出せます。

この記事では、実際に毎月動かしている仕組みをコード付きで解説します。

  • スプレッドシートは非公開のまま、読み取り専用のサービスアカウントで取得
  • 集計処理と認証情報を分け、秘密鍵をコードに書かない
  • macOSの launchd で毎月15日の朝8時に自動実行
  • 結果はSlackとmacOSの通知に届く

全体の流れ

launchd(毎月15日 8:00)
  ↓
run_prediction.sh
  ├─ ① sync.sh          … スプレッドシートをxlsxでダウンロード
  ├─ ② predict.py       … ランレートで月末着地を計算
  ├─ ③ curl → Slack     … 結果をWebhookで投稿
  ├─ ④ osascript        … Macの通知センターに表示
  └─ ⑤ 実行ログに追記

① スプレッドシートを非公開のまま取得する

以前は、URLの末尾を /export?format=xlsx に変え、認証なしで取得していました。この方法はシートを「リンクを知っている全員」に公開する必要があり、売上や利益を扱うには危険でした。

現在は、Google Cloudでサービスアカウント(自動取得専用の読み取り役)を作り、そのメールアドレスだけをシートの「閲覧者」に追加しています。一般的なアクセスは「制限付き」のままです。

  1. Google Cloudでサービスアカウントを作る
  2. Google Drive APIを有効にする
  3. 対象シートをサービスアカウントへ「閲覧者」で共有する
  4. JSON形式の秘密鍵を、本人だけが読める別ファイルとして保存する

取得処理の核は次の形です。

from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/drive.readonly"]
credentials = service_account.Credentials.from_service_account_file(
    ".google_key.json", scopes=SCOPES
)
drive = build("drive", "v3", credentials=credentials)

data = drive.files().export(
    fileId="【スプレッドシートID】",
    mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
).execute()

with open("利益表.xlsx.tmp", "wb") as file:
    file.write(data)

⚠️ 秘密鍵の中身は、記事・チャット・共有フォルダへ貼らないでください。 また、取得に失敗したときは古いファイルで予測を続けず、処理を中止する設計にします。


② ランレートで月末の着地を計算する

考え方は単純です。

累計 ÷ 入力済み日数 × その月の日数 = 着地予想

15日までに1,500万円あり、15日分入力されているなら、1日100万円のペース。31日の月なら3,100万円で着地する、という計算です。

predict.py

#!/usr/bin/env python3
# 利益表の「月末着地予想」をランレート(日割り)で算出する
# 使い方: python3 predict.py [月]   例: python3 predict.py 6
import sys, calendar, datetime
import openpyxl

LOCS = ["拠点A", "拠点B", "拠点C"]

def first_row(ws, label):
    """A列から指定ラベルの行番号を探す"""
    for r in range(1, ws.max_row + 1):
        if str(ws.cell(r, 1).value).strip() == label:
            return r
    return None

def daily(ws, row):
    """B列〜AF列(1日〜31日)を取得"""
    return [ws.cell(row, c).value for c in range(2, 33)]

def main():
    now = datetime.datetime.now()
    month = int(sys.argv[1]) if len(sys.argv) > 1 else now.month
    year = now.year
    dim = calendar.monthrange(year, month)[1]  # その月の日数

    wb = openpyxl.load_workbook("利益表.xlsx", data_only=True)
    print(f"=== {month}月 着地予想({now:%Y-%m-%d}時点・日割り換算)===")
    print(f"{'拠点':<5}{'入力':>4}{'累計売上':>12}{'→着地売上':>13}{'利益率':>7}")

    tot_cs = tot_ps = tot_pp = 0
    notes = []
    for loc in LOCS:
        name = f"{month}月{loc}"
        if name not in wb.sheetnames:
            # スペース有無などの表記ゆれを吸収する
            cand = [s for s in wb.sheetnames if s.replace(" ", "") == name]
            if not cand:
                notes.append(f"{loc}: シート未作成")
                continue
            name = cand[0]
        ws = wb[name]

        srow = first_row(ws, "売上")
        prow = first_row(ws, "利益")
        svals = daily(ws, srow)
        pvals = daily(ws, prow)

        # 0でない日を「入力済み」とみなす
        elapsed = sum(1 for v in svals if isinstance(v, (int, float)) and v != 0)
        if elapsed == 0:
            notes.append(f"{loc}: 未入力")
            continue

        cum_s = sum(v for v in svals if isinstance(v, (int, float)))
        cum_p = sum(v for v in pvals[:elapsed] if isinstance(v, (int, float)))
        ps = cum_s / elapsed * dim   # 着地売上
        pp = cum_p / elapsed * dim   # 着地利益
        tot_cs += cum_s; tot_ps += ps; tot_pp += pp

        # 入力日数が少ないときは警告を出す
        flag = "  ※日数少なく不確実" if elapsed < 5 else ""
        rate = pp / ps * 100 if ps else 0
        print(f"{loc:<5}{elapsed:>4}{cum_s:>12,.0f}{ps:>13,.0f}{rate:>6.1f}%{flag}")

    print("-" * 60)
    rate = tot_pp / tot_ps * 100 if tot_ps else 0
    print(f"{'合計':<5}{'':>4}{tot_cs:>12,.0f}{tot_ps:>13,.0f}{rate:>6.1f}%")
    print(f"\n月の日数: {dim}日 / 着地 = 累計 ÷ 入力日数 × {dim}")
    for n in notes:
        print("注: " + n)

if __name__ == "__main__":
    main()

必要なライブラリは openpyxl だけです。

pip3 install openpyxl

実装のポイント3つ

1. 「入力済み日数」を0以外のセル数で判定する

月の途中では、まだ入力されていない日が空欄か0になっています。elapsed を「値が入っていて0でない日数」として数えることで、未入力の日を分母から除外しています。ここを月の経過日数で計算してしまうと、入力が遅れている拠点の予想が過小になります。

2. 表記ゆれを吸収する

実務のシートは名前が揺れます。"6月拠点A" と "6月 拠点A" は別物として扱われるため、スペースを除去して比較する処理を入れています。

cand = [s for s in wb.sheetnames if s.replace(" ", "") == name]

データを綺麗にしてから自動化しようとすると、いつまでも始まりません。汚いまま動かすほうが早いです。

3. 不確実なときは、そう表示する

月初の数日しか入力がないと、予想は簡単に壊れます。1日分の売上を31倍すれば、当然おかしな数字になります。

flag = "  ※日数少なく不確実" if elapsed < 5 else ""

自動で出てきた数字は信じられやすいため、数字の隣に注意書きを出す設計にしています。これは運用してから足した機能ですが、入れてよかったものの筆頭です。


③ Slackに投稿する(エスケープの罠に注意)

Slack Appの Incoming Webhook URLを取得し、テキストファイルに保存しておきます。

# .slack_webhook というファイルにURLを1行だけ書いておく

投稿部分のコードがこれです。

if [ -f ".slack_webhook" ]; then
  HOOK=$(head -1 .slack_webhook | tr -d '[:space:]')
  REPORT=$(cat "$OUT")
  # JSON用に \ と " をエスケープし、改行を \n に変換
  BODY=$(printf '%s' "$REPORT" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS="\\n"}{print}')
  PAYLOAD="{\"text\":\"📊 *月末着地予想* ($(date '+%-m')月 / $(date '+%-m月%-d日')時点)\n\`\`\`${BODY}\`\`\`\"}"
  curl -s -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$HOOK" > /dev/null 2>&1
fi

ここでハマります

レポートの中身をそのままJSONに入れると、投稿が失敗するか表示が壊れます。原因は改行とダブルクォートです。

  • sed 's/\\/\\\\/g; s/"/\\"/g' … バックスラッシュとダブルクォートをエスケープ
  • awk 'BEGIN{ORS="\\n"}{print}' … 改行を \n という2文字に変換

さらに、表形式のレポートは **` で囲って等幅フォントにしないと桁が崩れます**。上のコードでは “` “ で囲っています。


④ macOSの通知に出す

Slackを見ていなくても気づけるよう、Macの通知センターにも出します。

SUMMARY=$(grep '合計' "$OUT" | tail -1 | tr -s ' ')
/usr/bin/osascript -e "display notification \"${SUMMARY}\" \
  with title \"月末着地予想 ($(date '+%-m')月)\" \
  subtitle \"詳細: ${OUT}\" sound name \"Glass\""

通知は文字数が限られるため、レポート全文ではなく「合計」の行だけを抜き出して表示しています。tr -s ' ' で連続する空白を1つに詰めているのは、表の桁揃え用スペースをそのまま出すと読めなくなるためです。


⑤ launchdで毎月15日に自動実行する

macOSで定期実行するなら cron ではなく launchd を使います。

~/Library/LaunchAgents/com.keiri.prediction.plist を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.keiri.prediction</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/zsh</string>
        <string>/Users/【ユーザー名】/経理/run_prediction.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Day</key>
        <integer>15</integer>
        <key>Hour</key>
        <integer>8</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/【ユーザー名】/経理/launchd_out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/【ユーザー名】/経理/launchd_err.log</string>
</dict>
</plist>

登録します。

launchctl load ~/Library/LaunchAgents/com.keiri.prediction.plist

launchdで押さえるべき点

  • StartCalendarInterval で Day だけ指定すると、毎月その日に実行されます。 曜日で回したいなら Weekday、毎日なら Hour と Minute だけ指定します
  • パスは絶対パスで書きます。 launchdから起動されるとカレントディレクトリも環境変数も普段と異なるため、python3 ではなく /usr/bin/python3 のようにフルパス指定が安全です
  • StandardOutPath と StandardErrorPath は必ず設定します。 これがないと、失敗したときに何が起きたか分かりません
  • Macがスリープしていると実行されません。 指定時刻に電源が入っている必要があります

実運用でハマったこと

最大の罠:デスクトップに置いたスクリプトは自動実行できない

これで実際に数ヶ月間、自動実行が動いていませんでした。

症状はこうです。ターミナルから手で実行すると正常に動く。しかしlaunchd経由だとエラーログにこう出る。

/bin/zsh: can't open input file: /Users/【ユーザー名】/Desktop/経理/run_prediction.sh

ファイルは確実に存在し、実行権限もあります。それでも「開けない」と言われます。

状態を確認するとこうなります。

launchctl list | grep keiri
# -    127    com.keiri.prediction

終了コード127。これは「コマンドまたはファイルが見つからない」を意味します。

原因:macOSのプライバシー保護(TCC)

macOSは デスクトップ・書類・ダウンロード の各フォルダを保護しており、バックグラウンドで起動されたプロセスからは読み取れません。手動実行が通るのは、ターミナル自体にアクセス許可が与えられているからです。

launchdから起動された /bin/zsh にはその許可がないため、ファイルの存在すら確認できずに終了します。

対処:保護対象外の場所に置く

最も確実なのは、スクリプトを保護されていないディレクトリへ移すことです。

mkdir -p ~/scripts/keiri
mv ~/Desktop/経理/*.sh ~/Desktop/経理/*.py ~/scripts/keiri/

plistのパスも合わせて修正し、読み込み直します。

launchctl unload ~/Library/LaunchAgents/com.keiri.prediction.plist
launchctl load ~/Library/LaunchAgents/com.keiri.prediction.plist

/bin/zsh にフルディスクアクセスを与える方法もありますが、すべてのシェルスクリプトに保護フォルダへの権限を与えることになるため推奨しません。スクリプトを移動するほうが安全です。

教訓:自動化は「作った時点」ではなく「自動で1回動いた時点」で完成です。
手動実行が成功しても、それは動作確認になっていません。

スクリプトが動かない原因の大半はパス

手元のターミナルで動くのに launchd 経由だと動かない、というのが最頻出です。カレントディレクトリが違うことが原因なので、スクリプトの冒頭に必ずこれを入れます。

cd "$(dirname "$0")" || exit 1

実行ログを残す

最後に1行追記するだけで、運用がまったく変わります。

echo "[$(date '+%Y-%m-%d %H:%M')] 着地予想を ${OUT} に保存・Slack投稿しました" >> 実行ログ.txt

自動化は「動かなくなったこと」に気づけないのが最大のリスクです。ログがあれば、いつから止まっているかが分かります。


そもそも会計ソフトを使うべきか

ここまで自作の話をしてきましたが、目的によっては会計ソフトを入れたほうが早いです。

自作 会計ソフト
集計・帳簿 自分で作る必要がある 標準機能
仕訳・申告 対応できない 対応
自社独自の予測・指標 自由に作れる 決まった形式のみ
費用 ほぼ無料 月額

「集計」が目的なら会計ソフト、「自社独自の見方をしたい」なら自作、というのが実感です。

今回作ったランレート予想は、拠点別・入力日数ベースという自社固有の見方なので自作しました。一方で、仕訳や決算を自作でやろうとは思いません。

会計ソフトと自作、どこで分けるべきか

仕訳・決算は会計ソフト、自社独自の予測は自作。実務で迷った境界を1枚の表にまとめました。

自作と会計ソフトの比較を見る →


まとめ

  • 売上・利益を含むスプレッドシートは公開URLを使わず、サービスアカウントへ閲覧権限を渡して非公開のまま取得する
  • 着地予想は 累計 ÷ 入力済み日数 × 月の日数 で出せる。難しい統計は不要
  • 入力済み日数は「0でないセルの数」で判定する。経過日数で割ると入力遅れの拠点が過小評価される
  • データの表記ゆれはプログラム側で吸収する。綺麗にしてから始めようとすると永久に始まらない
  • 予想が不確実なときは、そう表示させる。自動で出た数字は信じられやすい
  • macOSの定期実行は launchd。ログ出力の設定を必ず入れる

全部で100行に満たないコードですが、月末を待たずに数字が見えるようになるという点で、費用対効果は高いと感じています。

あわせて読みたい

コメント

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