MFクラウドから取得した請求書PDFを、SharePointの所定フォルダに正しいファイル名で自動配置する。現在は手動でリネーム&アップロードしているが、これをパイプラインの1ステップとして自動化する。
| 項目 | 現状(手動) | 自動化後 |
|---|---|---|
| PDFダウンロード | 自動(download ステップ) | 変更なし |
| ファイル名変換 | 手動リネーム | 設定ファイルに基づき自動変換 |
| SharePoint配置 | 手動アップロード | Graph API で自動アップロード |
| 共有リンク | 手動でURL取得 | 自動生成してコンソール出力 |
請求書番号(billing_number)と担当者名の対応は、JSONの設定ファイルで管理する。
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_display | SharePointファイル名の取引先略称 | TFHD |
company_name | SharePointファイル名の自社名 | 合同会社MNML |
sp_base_path | SharePoint上の配置先ベースパス | 10_Corporate/accounting/30_売上請求/TFHD |
billings | 請求書番号 → 担当者サフィックスのマップ | {"11": "(内山・根崎)", "12": "(小川)"} |
billings にキーを追加するだけ。ファイルを1箇所編集するだけで対応できる。
経費精算パイプライン(accounting/export_excel.py)と同じ方式を採用する。
| 方式 | メリット | デメリット | 採用 |
|---|---|---|---|
| Graph API PUT | 確実。同期遅延なし。共有リンクも即時取得 | OAuthトークン管理が必要(既存基盤あり) | 採用 |
| OneDrive同期フォルダ | コードがシンプル(ファイルコピーだけ) | 同期遅延あり。Mac miniで同期が動いている前提。同期エラー検知不可 | 不採用 |
Graph API の PUT は上書き動作(同名ファイルが存在すれば更新される)。これは冪等。
自動化しない。理由:
| ファイル | 操作 | 内容 |
|---|---|---|
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トークン管理)を再利用 |
ops/invoice/data/invoices/{YYYYMM}/{billing_number}_{partner_name}.pdfSharePoint/.../TFHD/{YYYY}/請求書{YYYYMM}_TFHD_合同会社MNML({担当者名}).pdfbilling_map.json をロードcfg.invoice_dir(data/invoices/{YYYYMM}/)内の全PDFを列挙billing_map.json で担当者名を解決 → SharePoint用ファイル名を組み立てる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}"
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 のため実際のアップロードは行われていません。[/]")
_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", "")
| 入力(ローカル) | 出力(SharePoint) |
|---|---|
11_東急不動産ホールディングス株式会社.pdf |
請求書202602_TFHD_合同会社MNML(内山・根崎).pdf |
12_東急不動産ホールディングス株式会社.pdf |
請求書202602_TFHD_合同会社MNML(小川).pdf |
{billing_number}_{partner_name}.pdf から billing_number を抽出("_" で分割した先頭要素)billing_map.json の billings から billing_number をキーに担当者サフィックスを取得請求書{YYYYMM}_{partner_display}_{company_name}{suffix}.pdfアップロードをスキップし、警告をコンソールに出力する。無理にアップロードしない(ファイル名が不正になるため)。
billing_map.json にエントリを追加してパイプラインを再実行する。
| 項目 | 値 |
|---|---|
| Drive ID | b!tNcjqNNMOkCi_YGmMqT7kS1WUEYULklGuffT9GtBXJBc4QvplsmnSJQywtJi22rt |
| ベースパス | 10_Corporate/accounting/30_売上請求/TFHD |
| 年別サブフォルダ | {YYYY}/(例: 2026/) |
| フルパス例 | 10_Corporate/accounting/30_売上請求/TFHD/2026/請求書202602_TFHD_合同会社MNML(内山・根崎).pdf |
cli.py に upload サブコマンドを追加# 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
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("パイプライン完了")
| 操作 | 2回目以降の挙動 | 安全性 |
|---|---|---|
| download ステップ | ローカルに同名ファイルが存在すればスキップ | 安全(既存のまま) |
| upload ステップ(Graph API PUT) | 同名ファイルを上書き。SharePointがバージョン履歴を保持 | 安全(内容が同じなら実質変化なし) |
| 共有リンク作成 | 既存リンクがあれば再取得されるだけ | 安全 |
| エラー | 対応 |
|---|---|
billing_map.json が存在しない |
ステップ冒頭で FileNotFoundError → コンソールにメッセージ出力して終了 |
| billing_number がマップにない | 該当ファイルをスキップ。警告出力。他のファイルは継続 |
| OAuthトークン期限切れ | OneDriveAuth 基盤が自動リフレッシュ(3回リトライ) |
| Graph API エラー(4xx/5xx) | 該当ファイルのみ失敗表示。他のファイルは継続 |
| ダウンロード済みPDFが0件 | 「アップロード対象なし」と出力して正常終了 |
既存の OneDriveAuth(ops/onedrive/app/auth.py)をそのまま使う。
Files.ReadWrite.All, Sites.Read.All(SharePointファイル操作に必要)ops/onedrive/.tokens.json(既存)BaseOAuth が期限切れ時にリフレッシュトークンで更新新規のOAuth設定や追加スコープは不要。
ops/onedrive/.tokens.json に有効なトークンが存在すること(初回は setup_oauth.py で取得済み)billing_map.json は手動メンテナンス。請求書番号の追加・変更時に更新する| 設計判断 | 決定 | 理由 |
|---|---|---|
| 担当者マッピング | JSONファイル | API依存を避け確実に制御。変更頻度が低いため設定ファイルで十分 |
| アップロード方式 | Graph API PUT | 経費精算と同じ方式。同期遅延なし。実績あり |
| 冪等性 | PUTの上書き動作 | Graph APIの仕様上、自然に冪等。バージョン履歴も自動保存 |
| BKバックアップ | 自動化しない | SharePointのバージョン管理で代替可能。二重管理を避ける |
| マッピング不一致時 | スキップ+警告 | 不正なファイル名で配置するより安全 |