請求書PDF SharePoint自動配置

Issue #123 | 2026-03-17 | architect

1. やりたいこと

MFクラウドから取得した請求書PDFを、SharePointの所定フォルダに正しいファイル名で自動配置する。現在は手動でリネーム&アップロードしているが、これをパイプラインの1ステップとして自動化する。

項目現状(手動)自動化後
PDFダウンロード自動(download ステップ)変更なし
ファイル名変換手動リネーム設定ファイルに基づき自動変換
SharePoint配置手動アップロードGraph API で自動アップロード
共有リンク手動でURL取得自動生成してコンソール出力

2. 設計方針

2.1 請求書番号 → 担当者名のマッピング

請求書番号(billing_number)と担当者名の対応は、JSONの設定ファイルで管理する。

なぜJSON設定ファイルか?
MFクラウドAPIの memo フィールドに担当者名が入っている保証がない。APIの入力に依存すると、入力漏れ時にファイル名が不正になる。JSONなら確実に制御できる。担当者は年単位で変わる程度のため、変更頻度も低い。

設定ファイルの場所: ops/invoice/data/billing_map.json

{
  "partner_display": "TFHD",
  "company_name": "合同会社MNML",
  "sp_base_path": "10_Corporate/accounting/30_売上請求/TFHD",
  "billings": {
    "11": "(内山・根崎)",
    "12": "(小川)"
  }
}
フィールド用途
partner_displaySharePointファイル名の取引先略称TFHD
company_nameSharePointファイル名の自社名合同会社MNML
sp_base_pathSharePoint上の配置先ベースパス10_Corporate/accounting/30_売上請求/TFHD
billings請求書番号 → 担当者サフィックスのマップ{"11": "(内山・根崎)", "12": "(小川)"}
将来の拡張: 取引先が増えた場合、billings にキーを追加するだけ。ファイルを1箇所編集するだけで対応できる。

2.2 アップロード方式: Graph API 直接アップロード

経費精算パイプライン(accounting/export_excel.py)と同じ方式を採用する。

方式メリットデメリット採用
Graph API PUT 確実。同期遅延なし。共有リンクも即時取得 OAuthトークン管理が必要(既存基盤あり) 採用
OneDrive同期フォルダ コードがシンプル(ファイルコピーだけ) 同期遅延あり。Mac miniで同期が動いている前提。同期エラー検知不可 不採用

2.3 冪等性

Graph API の PUT は上書き動作(同名ファイルが存在すれば更新される)。これは冪等。

2.4 BKディレクトリへのバックアップ

自動化しない。理由:

3. ファイル構成

ファイル操作内容
ops/invoice/data/billing_map.json 新規 請求書番号→担当者名のマッピング設定
ops/invoice/pipeline/steps/upload.py 新規 リネーム+SharePointアップロードのステップ
ops/invoice/pipeline/cli.py 変更 upload サブコマンド追加
ops/invoice/pipeline/scheduler/run.py 変更 パイプライン末尾に upload ステップ追加
ops/accounting/pipeline/steps/export_excel.py 参考 SharePointアップロードの実装パターン
ops/onedrive/app/auth.py 参考 OneDriveAuth(OAuthトークン管理)を再利用

4. 処理フロー

4.1 全体パイプライン(変更後)

1
confirm — MFクラウドの請求書を確定する
2
download — PDFをダウンロードしてローカルに保存する
ops/invoice/data/invoices/{YYYYMM}/{billing_number}_{partner_name}.pdf
3
upload 新規 — ローカルPDFをリネームしてSharePointにアップロード
SharePoint/.../TFHD/{YYYY}/請求書{YYYYMM}_TFHD_合同会社MNML({担当者名}).pdf

4.2 upload ステップの内部処理

1
設定読み込みbilling_map.json をロード
2
PDFスキャンcfg.invoice_dirdata/invoices/{YYYYMM}/)内の全PDFを列挙
3
ファイル名変換 — 各PDFに対し billing_number を抽出 → billing_map.json で担当者名を解決 → SharePoint用ファイル名を組み立てる
4
アップロード — Graph API PUT で SharePoint に配置。1件ずつ順次実行(レート制限対策)
5
結果出力 — 各ファイルのアップロード結果(成功/失敗)とSharePoint上のURLをコンソールに表示

5. インターフェース定義

5.1 データモデル: BillingMap

# ops/invoice/pipeline/steps/upload.py

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
_BILLING_MAP_PATH = _DATA_DIR / "billing_map.json"

# SharePoint Drive ID(経費精算と同じ Communication site のドキュメント)
_SP_DRIVE_ID = "b!tNcjqNNMOkCi_YGmMqT7kS1WUEYULklGuffT9GtBXJBc4QvplsmnSJQywtJi22rt"


@dataclass
class BillingMap:
    """請求書番号と担当者名の対応表。"""
    partner_display: str          # "TFHD"
    company_name: str             # "合同会社MNML"
    sp_base_path: str             # "10_Corporate/accounting/30_売上請求/TFHD"
    billings: dict[str, str]      # {"11": "(内山・根崎)", "12": "(小川)"}

    @classmethod
    def load(cls) -> BillingMap:
        """billing_map.json を読み込む。"""
        raw = json.loads(_BILLING_MAP_PATH.read_text(encoding="utf-8"))
        return cls(
            partner_display=raw["partner_display"],
            company_name=raw["company_name"],
            sp_base_path=raw["sp_base_path"],
            billings=raw["billings"],
        )

    def resolve_filename(self, billing_number: str, yyyymm: str) -> str | None:
        """請求書番号からSharePoint用のファイル名を生成する。

        対応するエントリがなければ None を返す。
        """
        suffix = self.billings.get(billing_number)
        if suffix is None:
            return None
        return f"請求書{yyyymm}_{self.partner_display}_{self.company_name}{suffix}.pdf"

    def resolve_remote_path(self, billing_number: str, yyyymm: str, year: int) -> str | None:
        """SharePoint上のフルパス(Drive root からの相対パス)を返す。"""
        filename = self.resolve_filename(billing_number, yyyymm)
        if filename is None:
            return None
        return f"{self.sp_base_path}/{year}/{filename}"

5.2 メイン関数: run()

async def run(cfg: PipelineConfig) -> None:
    """ローカルの請求書PDFをリネームしてSharePointにアップロードする。"""
    # 1. billing_map.json 読み込み
    bmap = BillingMap.load()

    # 2. ダウンロード済みPDF一覧
    pdf_files = sorted(cfg.invoice_dir.glob("*.pdf"))
    if not pdf_files:
        console.print(f"[yellow]アップロード対象なし: {cfg.invoice_dir}[/]")
        return

    # 3. 認証準備
    from onedrive.app.auth import OneDriveAuth
    auth = OneDriveAuth()

    uploaded = 0
    skipped = 0
    for pdf_path in pdf_files:
        # 4. ファイル名から billing_number を抽出(先頭の"_"区切り)
        billing_number = pdf_path.stem.split("_")[0]

        # 5. SharePoint用パスを解決
        remote_path = bmap.resolve_remote_path(billing_number, cfg.yyyymm, cfg.year)
        if remote_path is None:
            console.print(f"[yellow]スキップ(マッピングなし): {pdf_path.name} "
                          f"(billing_number={billing_number})[/]")
            skipped += 1
            continue

        sp_filename = remote_path.rsplit("/", 1)[-1]

        if cfg.dry_run:
            console.print(f"[dim][DRY-RUN] {pdf_path.name} → {sp_filename}[/]")
            continue

        # 6. Graph API PUT でアップロード
        try:
            link = await _upload_pdf(auth, pdf_path, remote_path)
            console.print(f"[green]アップロード:[/] {pdf_path.name} → {sp_filename}")
            console.print(f"  [dim]{link}[/]")
            uploaded += 1
        except Exception as e:
            console.print(f"[red]失敗:[/] {pdf_path.name} — {e}")

    console.print(f"\nアップロード完了: {uploaded} 件, スキップ: {skipped} 件")
    if cfg.dry_run:
        console.print("[yellow]--dry-run のため実際のアップロードは行われていません。[/]")

5.3 アップロード関数: _upload_pdf()

async def _upload_pdf(auth, pdf_path: Path, remote_path: str) -> str:
    """PDFをSharePointにアップロードし、共有リンクを返す。

    Graph API: PUT /drives/{driveId}/root:/{path}:/content
    冪等: 同名ファイルがあれば上書き(バージョン履歴は自動保存)。
    """
    headers = await auth.get_auth_header()
    headers["Content-Type"] = "application/octet-stream"

    url = (f"https://graph.microsoft.com/v1.0/drives/{_SP_DRIVE_ID}"
           f"/root:/{remote_path}:/content")

    data = pdf_path.read_bytes()

    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.put(url, headers=headers, content=data)
        resp.raise_for_status()
        item_id = resp.json()["id"]

    # 共有リンク作成
    link_headers = await auth.get_auth_header()
    link_headers["Content-Type"] = "application/json"
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(
            f"https://graph.microsoft.com/v1.0/drives/{_SP_DRIVE_ID}/items/{item_id}/createLink",
            headers=link_headers,
            json={"type": "view", "scope": "organization"},
        )
        resp.raise_for_status()
        return resp.json().get("link", {}).get("webUrl", "")

6. ファイル名変換ロジック

6.1 変換ルール

入力(ローカル)出力(SharePoint)
11_東急不動産ホールディングス株式会社.pdf 請求書202602_TFHD_合同会社MNML(内山・根崎).pdf
12_東急不動産ホールディングス株式会社.pdf 請求書202602_TFHD_合同会社MNML(小川).pdf

6.2 変換の仕組み

  1. ローカルファイル名 {billing_number}_{partner_name}.pdf から billing_number を抽出("_" で分割した先頭要素)
  2. billing_map.jsonbillings から billing_number をキーに担当者サフィックスを取得
  3. テンプレート: 請求書{YYYYMM}_{partner_display}_{company_name}{suffix}.pdf

6.3 マッピングに存在しない請求書番号の場合

アップロードをスキップし、警告をコンソールに出力する。無理にアップロードしない(ファイル名が不正になるため)。

運用時の対応: 新しい請求書番号が増えた場合は billing_map.json にエントリを追加してパイプラインを再実行する。

7. SharePoint配置先

項目
Drive IDb!tNcjqNNMOkCi_YGmMqT7kS1WUEYULklGuffT9GtBXJBc4QvplsmnSJQywtJi22rt
ベースパス10_Corporate/accounting/30_売上請求/TFHD
年別サブフォルダ{YYYY}/(例: 2026/
フルパス例10_Corporate/accounting/30_売上請求/TFHD/2026/請求書202602_TFHD_合同会社MNML(内山・根崎).pdf
年フォルダの自動作成: Graph APIの PUT は中間ディレクトリが存在しない場合でも自動的に作成する。明示的な mkdir 操作は不要。

8. CLI変更

8.1 cli.pyupload サブコマンドを追加

# build_parser() 内に追加
sub.add_parser("upload", help="SharePointにPDFを配置")

# main() 内に追加
elif args.step == "upload":
    from invoice.pipeline.steps import upload
    asyncio.run(upload.run(cfg))

使い方:

# 手動実行
python -m invoice.pipeline upload
python -m invoice.pipeline upload --dry-run
python -m invoice.pipeline --year 2026 --month 2 upload

8.2 scheduler/run.py に upload を追加

async def _run_pipeline(log: logging.Logger) -> None:
    from invoice.pipeline.steps import confirm, download, upload

    cfg = PipelineConfig()

    log.info("confirm 開始")
    await confirm.run(cfg)
    log.info("confirm 完了")

    log.info("download 開始")
    await download.run(cfg)
    log.info("download 完了")

    # 新規追加
    log.info("upload 開始")
    await upload.run(cfg)
    log.info("upload 完了")

    log.info("パイプライン完了")

9. 冪等性

操作2回目以降の挙動安全性
download ステップ ローカルに同名ファイルが存在すればスキップ 安全(既存のまま)
upload ステップ(Graph API PUT) 同名ファイルを上書き。SharePointがバージョン履歴を保持 安全(内容が同じなら実質変化なし)
共有リンク作成 既存リンクがあれば再取得されるだけ 安全

10. エラーハンドリング

エラー対応
billing_map.json が存在しない ステップ冒頭で FileNotFoundError → コンソールにメッセージ出力して終了
billing_number がマップにない 該当ファイルをスキップ。警告出力。他のファイルは継続
OAuthトークン期限切れ OneDriveAuth 基盤が自動リフレッシュ(3回リトライ)
Graph API エラー(4xx/5xx) 該当ファイルのみ失敗表示。他のファイルは継続
ダウンロード済みPDFが0件 「アップロード対象なし」と出力して正常終了

11. 認証

既存の OneDriveAuthops/onedrive/app/auth.py)をそのまま使う。

新規のOAuth設定や追加スコープは不要。

12. 前提条件・制約

13. 設計のまとめ

設計判断決定理由
担当者マッピング JSONファイル API依存を避け確実に制御。変更頻度が低いため設定ファイルで十分
アップロード方式 Graph API PUT 経費精算と同じ方式。同期遅延なし。実績あり
冪等性 PUTの上書き動作 Graph APIの仕様上、自然に冪等。バージョン履歴も自動保存
BKバックアップ 自動化しない SharePointのバージョン管理で代替可能。二重管理を避ける
マッピング不一致時 スキップ+警告 不正なファイル名で配置するより安全
<> {"type": "cf_deploy", "path": "agents/workers/architect/output/DESIGN_invoice_sharepoint_upload.html", "issue": 123} <>