設計書 v2 2026-04-06 CEO決定済み

MNML 契約管理パイプライン(ops/contract/)

クライアント(売上先)とサプライヤー(委託先)を統合管理する汎用契約管理基盤

背景と目的

CONTRACT MANAGEMENT PIPELINE

売上も仕入も
1つのJSONで一元管理

VDUで契約先が増えても contracts.json に1行追加するだけ。全パイプラインから金額・期間を参照できる基盤を作る

v1(前回設計)からの変更
  • サプライヤー(委託先)中心 → クライアント+サプライヤー統合
  • clientフィールド → vduフィールドに変更(VDU横断管理)
  • typeフィールド追加: sales / purchase
  • SharePoint構成: 案A(10_Corporate/contracts/)に決定
  • データソース: ローカルJSON管理に決定(MFクラウドAPIなし)
解決する課題
  • 契約金額・期間がデータとして保持されていない
  • クライアント契約(売上)の情報が散在している
  • VDU横断で「今どの契約が有効か」が把握できない
  • 委託先追加のたびにコード変更が必要

CEO決定事項

項目決定内容理由
決定 SharePoint構成 案A: 10_Corporate/contracts/ 配下に clients / suppliers 全契約書を1箇所に集約し、ContractScannerが1パスで走査可能
決定 データ管理方式 統合管理(案1): 1つの contracts.json に全契約 クライアント・サプライヤーの区別はtypeフィールドで。ファイル分割は不要
決定 データソース ローカルJSON管理で開始 MFクラウドに契約管理APIがないため

データモデル

1ファイル(contracts.json)で全契約を管理する。新規契約先の追加はJSONへの追記だけで完結する。

Contract モデル

フィールド必須説明
idstr一意なID(例: C001, S001)。Cはクライアント、Sはサプライヤー
typeLiteral["sales", "purchase"]sales = クライアント(売上先)、purchase = サプライヤー(委託先)
counterpartystr取引先名(表示名)
counterparty_full_namestr | None取引先フルネーム(個人名等)
counterparty_emailstr | None取引先メールアドレス
vdulist[str]紐づくVDU案件(例: ["TFHD"])。複数VDUにまたがる場合はリスト
contract_typestr契約種別(業務委託 / 準委任 / 請負 / コンサルティング 等)
amountint月額金額(円・税抜)。整数のみ
currencystr通貨コード(デフォルト: JPY
period_startstr (YYYY-MM-DD)契約開始日
period_endstr (YYYY-MM-DD) | None契約終了日(無期限の場合はnull)
auto_renewbool自動更新かどうか(デフォルト: false)
renewal_notice_daysint更新通知を何日前に出すか(デフォルト: 30)
statusLiteral["active", "expired", "terminated"]契約ステータス
payment_cyclestr | None支払サイクル(monthly / quarterly / yearly
report_due_dayint | None月次報告の締め日(例: 25 = 毎月25日)
sharepoint_contract_pathstr | NoneSharePoint上の契約書フォルダパス
sharepoint_deliverable_pathstr | NoneSharePoint上の成果物フォルダパス
invoice_folderstr | None請求書保管フォルダパス
notesstr | None備考
設計ポイント
  • 金額はint型: 円単位・税抜。消費税計算は参照側が行う。Float/Decimalは使わない
  • vduはリスト型: 1つの委託先が複数VDUに関わるケースに対応(例: TFHDとMNML両方に提供)
  • counterparty: supplier/client を統一した呼称。typeフィールドで売上/仕入を区別
  • statusは3値: v1のexpiringは廃止。期限切れ警告はcheckコマンドの出力で表現し、データには持たない

ステータス遷移

現在遷移先トリガー
activeexpiredcheckコマンド実行時、period_endを過ぎている場合に自動遷移
activeterminated手動で中途解約を記録
expiredactive再契約(period_endを延長して手動更新)
terminatedactive再契約(新規エントリ推奨だが、復活も可)

auto_renew: true の契約は check 時に expired にせず、Slack通知で「自動更新されるが確認してください」と表示する。

パッケージ構成

既存opsパイプラインと同じ構造パターン
ops/contract/ を新設。app/ にデータ層、pipeline/ にCLIエントリポイントを配置する。accounting/ や invoice/ と同じレイアウト。

ディレクトリ構成

ops/contract/
├── __init__.py
├── app/
│   ├── __init__.py
│   ├── config.py              # pydantic-settings: SharePointパス、通知設定
│   ├── models.py              # Contract Pydanticモデル
│   ├── repository.py          # contracts.json の読み書き + 公開クエリ関数
│   └── data/
│       └── contracts.json     # 契約マスタデータ
└── pipeline/
    ├── __init__.py
    ├── __main__.py            # python -m contract.pipeline
    ├── cli.py                 # argparse サブコマンド定義
    └── steps/
        ├── __init__.py
        ├── list_contracts.py  # 一覧表示(richテーブル)
        ├── show_contract.py   # 契約詳細表示
        ├── add_contract.py    # 対話的に契約追加
        └── check_expiry.py    # 期限チェック + ステータス更新 + Slack通知

各モジュールの役割

モジュール役割
config.py pydantic-settings で .env から読み込み。SharePoint契約書ベースパス(10_Corporate/contracts/)、Slack通知先チャンネル、更新警告の日数デフォルト値
models.py Contractモデル(pydantic BaseModel)。バリデーション(amount > 0、period_start < period_end等)、シリアライズ、ヘルパープロパティ(is_expiring_soon, days_until_expiry
repository.py contracts.jsonの読み書き。他パイプラインが呼ぶ公開クエリ関数を提供(次セクション参照)
list_contracts.py richテーブルで一覧表示。--type, --vdu, --status フィルタ対応
show_contract.py IDを指定して1件の契約詳細をrichパネルで表示
add_contract.py 対話的プロンプト(rich.prompt)で契約情報を入力し、contracts.jsonに追記
check_expiry.py 全契約の期限をチェック。expired自動遷移 + 期限30日以内をSlack通知(shared.notify()使用)

pyproject.toml への追加

# [tool.setuptools.packages.find] の include に追加
include = [..., "contract*"]

# 依存は基本パッケージのみ(pydantic, rich)で追加不要

関数シグネチャ・インターフェース定義

models.py

from __future__ import annotations

from datetime import date
from typing import Literal

from pydantic import BaseModel, Field


class Contract(BaseModel):
    """契約データモデル。"""

    id: str
    type: Literal["sales", "purchase"]
    counterparty: str
    counterparty_full_name: str | None = None
    counterparty_email: str | None = None
    vdu: list[str]
    contract_type: str
    amount: int = Field(gt=0, description="月額金額(円・税抜)")
    currency: str = "JPY"
    period_start: date
    period_end: date | None = None
    auto_renew: bool = False
    renewal_notice_days: int = 30
    status: Literal["active", "expired", "terminated"]
    payment_cycle: str | None = None
    report_due_day: int | None = Field(default=None, ge=1, le=31)
    sharepoint_contract_path: str | None = None
    sharepoint_deliverable_path: str | None = None
    invoice_folder: str | None = None
    notes: str | None = None

    @property
    def is_expiring_soon(self) -> bool:
        """更新通知期間内かどうか。"""
        if self.period_end is None or self.status != "active":
            return False
        return (self.period_end - date.today()).days <= self.renewal_notice_days

    @property
    def days_until_expiry(self) -> int | None:
        """期限切れまでの日数。無期限ならNone。"""
        if self.period_end is None:
            return None
        return (self.period_end - date.today()).days

    @property
    def is_expired(self) -> bool:
        """期限を過ぎているか。"""
        if self.period_end is None:
            return False
        return date.today() > self.period_end

config.py

from __future__ import annotations

from pathlib import Path

from pydantic_settings import BaseSettings


class ContractSettings(BaseSettings):
    """契約管理パイプラインの設定。"""

    # SharePoint
    contract_sharepoint_base: str = "/10_Corporate/contracts"

    # Slack通知
    slack_bot_token: str = ""
    contract_notify_channel: str = ""  # 通知先チャンネルID

    # デフォルト値
    default_renewal_notice_days: int = 30

    model_config = {"env_file": str(Path(__file__).resolve().parents[3] / ".env")}


# データファイルパス
DATA_DIR: Path = Path(__file__).resolve().parent / "data"
CONTRACTS_JSON: Path = DATA_DIR / "contracts.json"

settings = ContractSettings()

repository.py — 公開クエリ関数

from __future__ import annotations

import json
from pathlib import Path

from contract.app.config import CONTRACTS_JSON
from contract.app.models import Contract


def _load_contracts(path: Path = CONTRACTS_JSON) -> list[Contract]:
    """contracts.json を読み込み、Contractリストを返す。"""
    if not path.exists():
        return []
    with path.open(encoding="utf-8") as f:
        data = json.load(f)
    return [Contract.model_validate(item) for item in data]


def _save_contracts(contracts: list[Contract], path: Path = CONTRACTS_JSON) -> None:
    """Contractリストを contracts.json に書き込む。"""
    data = [c.model_dump(mode="json") for c in contracts]
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


# === 公開クエリ関数(他パイプラインから呼ばれる) ===

def get_all_contracts() -> list[Contract]:
    """全契約を返す。"""
    return _load_contracts()


def get_active_contracts(
    *,
    type: str | None = None,
    vdu: str | None = None,
) -> list[Contract]:
    """有効な契約のみ返す。type / vdu でフィルタ可能。"""
    contracts = [c for c in _load_contracts() if c.status == "active"]
    if type is not None:
        contracts = [c for c in contracts if c.type == type]
    if vdu is not None:
        contracts = [c for c in contracts if vdu in c.vdu]
    return contracts


def get_contract_by_id(contract_id: str) -> Contract | None:
    """IDで契約を検索。"""
    for c in _load_contracts():
        if c.id == contract_id:
            return c
    return None


def get_contract_by_counterparty(name: str) -> Contract | None:
    """取引先名で契約を検索(部分一致)。"""
    for c in _load_contracts():
        if name in c.counterparty or (c.counterparty_full_name and name in c.counterparty_full_name):
            return c
    return None


def get_contracts_by_vdu(vdu: str) -> list[Contract]:
    """VDU名で契約を絞り込む。"""
    return [c for c in _load_contracts() if vdu in c.vdu]


def get_monthly_amount(counterparty_name: str) -> int | None:
    """取引先の月額金額を返す。見つからなければNone。"""
    contract = get_contract_by_counterparty(counterparty_name)
    if contract is None or contract.status != "active":
        return None
    return contract.amount


def add_contract(contract: Contract) -> None:
    """契約を追加する。ID重複時はValueError。"""
    contracts = _load_contracts()
    if any(c.id == contract.id for c in contracts):
        msg = f"契約ID {contract.id} は既に存在します"
        raise ValueError(msg)
    contracts.append(contract)
    _save_contracts(contracts)


def update_contract(contract: Contract) -> None:
    """契約を更新する。ID不存在時はValueError。"""
    contracts = _load_contracts()
    for i, c in enumerate(contracts):
        if c.id == contract.id:
            contracts[i] = contract
            _save_contracts(contracts)
            return
    msg = f"契約ID {contract.id} が見つかりません"
    raise ValueError(msg)

CLIコマンド設計

コマンド一覧

コマンド説明オプション
python -m contract.pipeline list 全契約一覧を表示 --type sales|purchase
--vdu TFHD
--status active|expired|terminated
python -m contract.pipeline show C001 契約詳細を表示 位置引数: 契約ID
python -m contract.pipeline add 対話的に契約を追加 なし(対話プロンプトで入力)
python -m contract.pipeline check 期限切れ・更新時期チェック --notify: Slack通知も送信
--days 60: 警告日数を指定

cli.py シグネチャ

from __future__ import annotations

import argparse
import sys


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="contract.pipeline",
        description="MNML 契約管理パイプライン",
    )
    sub = parser.add_subparsers(dest="command", help="実行するコマンド")

    # list
    p_list = sub.add_parser("list", help="契約一覧を表示")
    p_list.add_argument("--type", choices=["sales", "purchase"], help="契約タイプで絞り込み")
    p_list.add_argument("--vdu", help="VDU名で絞り込み")
    p_list.add_argument("--status", choices=["active", "expired", "terminated"], help="ステータスで絞り込み")

    # show
    p_show = sub.add_parser("show", help="契約詳細を表示")
    p_show.add_argument("contract_id", help="契約ID")

    # add
    sub.add_parser("add", help="対話的に契約を追加")

    # check
    p_check = sub.add_parser("check", help="期限チェック・ステータス更新")
    p_check.add_argument("--notify", action="store_true", help="Slack通知を送信")
    p_check.add_argument("--days", type=int, default=30, help="警告日数(デフォルト: 30)")

    return parser


def main(argv: list[str] | None = None) -> None:
    parser = build_parser()
    args = parser.parse_args(argv)

    if not args.command:
        parser.print_help()
        sys.exit(1)

    if args.command == "list":
        from contract.pipeline.steps.list_contracts import run
        run(type_filter=args.type, vdu_filter=args.vdu, status_filter=args.status)
    elif args.command == "show":
        from contract.pipeline.steps.show_contract import run
        run(contract_id=args.contract_id)
    elif args.command == "add":
        from contract.pipeline.steps.add_contract import run
        run()
    elif args.command == "check":
        from contract.pipeline.steps.check_expiry import run
        run(notify=args.notify, warning_days=args.days)

出力例: list

$ python -m contract.pipeline list
┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ ID    ┃ タイプ   ┃ 取引先     ┃ VDU    ┃ 月額(税抜) ┃ 期間                    ┃ ステータス ┃
┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ C001  │ 売上     │ TFHD       │ TFHD   │ 1,500,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
│ S001  │ 仕入     │ 根崎       │ TFHD   │   500,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
│ S002  │ 仕入     │ 小川哲央   │ TFHD   │   300,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
│ S003  │ 仕入     │ 森屋穣     │ MNML   │   200,000 │ 2026-04-01 ~            │ active  │
│ S004  │ 仕入     │ 小川さとこ │ MNML   │   150,000 │ 2026-04-01 ~ 2026-09-30 │ active  │
│ S005  │ 仕入     │ Josh       │ MNML   │   100,000 │ 2026-04-01 ~            │ active  │
└───────┴──────────┴────────────┴────────┴───────────┴─────────────────────────┴─────────┘
合計: 6件(売上1件 / 仕入5件)

出力例: list --vdu TFHD

$ python -m contract.pipeline list --vdu TFHD
┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ ID    ┃ タイプ   ┃ 取引先     ┃ VDU    ┃ 月額(税抜) ┃ 期間                    ┃ ステータス ┃
┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ C001  │ 売上     │ TFHD       │ TFHD   │ 1,500,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
│ S001  │ 仕入     │ 根崎       │ TFHD   │   500,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
│ S002  │ 仕入     │ 小川哲央   │ TFHD   │   300,000 │ 2026-04-01 ~ 2027-03-31 │ active  │
└───────┴──────────┴────────────┴────────┴───────────┴─────────────────────────┴─────────┘
TFHD VDU: 3件(売上 1,500,000円 / 仕入 800,000円 / 差引 700,000円)

出力例: show C001

$ python -m contract.pipeline show C001
╭──────────────────────────────────────────────╮
│ 契約詳細: C001                               │
├──────────────────────────────────────────────┤
│ タイプ:       売上(クライアント)             │
│ 取引先:       TFHD                            │
│ VDU:          TFHD                            │
│ 契約種別:     コンサルティング                 │
│ 月額:         1,500,000円(税抜)              │
│ 期間:         2026-04-01 ~ 2027-03-31         │
│ 残日数:       360日                            │
│ 自動更新:     なし                             │
│ 支払サイクル: monthly                          │
│ ステータス:   active                           │
│ SharePoint:   /10_Corporate/contracts/clients/ │
│               TFHD/                            │
╰──────────────────────────────────────────────╯

出力例: check

$ python -m contract.pipeline check --days 60
契約期限チェック(警告: 60日以内)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

⚠ S004 小川さとこ: 残り178日(2026-09-30まで)
  → 更新通知が30日前(2026-09-01)に発行されます

✓ 期限切れの契約はありません
✓ ステータス更新: 0件

他パイプラインとの連携

連携マップ

パイプライン参照する関数用途
ops/tfhd/ get_active_contracts(type="purchase", vdu="TFHD") TFHD委託先の月額金額を取得し、支払い通知に使用
ops/tfhd/ get_monthly_amount("根崎") 個別の委託先の金額取得(報告書作成時)
ops/mnml/ get_active_contracts(type="purchase", vdu="MNML") MNML委託先一覧と金額の取得
ops/invoice/(将来) get_active_contracts(type="sales") クライアントへの請求金額を取得
ops/accounting/(将来) get_contract_by_counterparty(name) 経費の仕訳時に委託先契約を参照

利用例

TFHD: 委託先の月額金額を取得
# ops/tfhd/ から
from contract.app.repository import get_monthly_amount

amount = get_monthly_amount("根崎")
# → 500000(円・税抜)
VDU別の売上・仕入サマリ
from contract.app.repository import get_active_contracts

tfhd_sales = get_active_contracts(type="sales", vdu="TFHD")
tfhd_purchases = get_active_contracts(type="purchase", vdu="TFHD")

sales_total = sum(c.amount for c in tfhd_sales)
purchase_total = sum(c.amount for c in tfhd_purchases)
margin = sales_total - purchase_total
# → 売上 1,500,000 - 仕入 800,000 = 差引 700,000
targets.json からの移行アダプタ
# ops/tfhd/app/ に移行期間中のアダプタを提供
from contract.app.repository import get_monthly_amount as _get_amount

def get_target_amount(target_name: str) -> int:
    """targets.json の代わりに contracts.json から金額を取得する。"""
    amount = _get_amount(target_name)
    if amount is None:
        msg = f"契約が見つかりません: {target_name}"
        raise ValueError(msg)
    return amount

SharePointフォルダ構成

案A(統一構成)に決定済み
10_Corporate/contracts/ 配下に clients(クライアント)と suppliers(委託先)を配置する。

フォルダ構成

10_Corporate/
└── contracts/
    ├── clients/                    ← 売上先の契約書
    │   └── TFHD/
    │       └── 2026_コンサルティング契約書.pdf
    └── suppliers/                  ← 委託先の契約書
        ├── 根崎/
        │   └── 2026_業務委託契約書.pdf
        ├── 小川哲央/
        │   └── 2026_業務委託契約書.pdf
        ├── 森屋/
        │   └── 2026_業務委託契約書.pdf
        ├── 小川さとこ/
        │   └── 2026_業務委託契約書.pdf
        └── Josh/
            └── 2026_業務委託契約書.pdf

既存フォルダとの関係

フォルダ扱い
30_VDU/consulting/20_suppliers/{人名}/10_契約書/ 既存TFHD案件の契約書。新構成に移動またはシンボリックリンク。移行後は10_Corporate/contracts/suppliers/が正とする
30_VDU/consulting/20_suppliers/{人名}/20_成果物/ そのまま残す。成果物はVDU別管理が自然。contracts.jsonのsharepoint_deliverable_pathで参照
10_Corporate/accounting/40_仕入れ請求/ そのまま残す。請求書は会計パイプラインの管轄。contracts.jsonのinvoice_folderで参照
移行時の注意
  • 既存のSharePointパス参照(targets.jsonのsharepoint_folder等)は、contracts.jsonのsharepoint_contract_pathに新パスを設定する
  • TFHD成果物フォルダ(sharepoint_deliverable_path)は既存パスをそのまま使用する
  • クライアントフォルダ(clients/)はVDU名をフォルダ名にする(将来VDUが増えてもフォルダ追加だけ)

targets.json 移行計画

現在の targets.json の役割

フィールド用途contracts.json に移行?
name委託先表示名○ → counterparty
full_nameフルネーム○ → counterparty_full_name
emailメールアドレス○ → counterparty_email
clientVDU名○ → vdu(リスト化)
bundle_with_senderTFHDメール添付方式× — TFHDフロー固有。targets.jsonに残す
sharepoint_folder成果物フォルダ○ → sharepoint_deliverable_path
sharepoint_dir_nameSPディレクトリ名× — targets.jsonに残す
roleメール宛先の種別(to/cc)× — 送付先情報。targets.jsonに残す
invoice_folder請求書フォルダ○ → invoice_folder

移行フェーズ

Phase 1: 共存(今回実装)
  • contracts.json を新設。全契約データを投入
  • targets.json はそのまま残す(TFHDフロー固有の情報を保持)
  • 金額・期間の参照は contracts.json から取得するように変更
  • targets.json のエントリに contract_id フィールドを追加(contracts.jsonへの紐付け)
Phase 2: 重複解消(将来)
  • targets.json から名前・メール・SharePointパスの重複を削除
  • targets.json は contract_id + TFHDフロー固有フィールドのみに縮小
  • ops/tfhd/ が contracts.json 経由で名前・メールを取得するように変更
Phase 3: 統合完了(将来)
  • targets.json は送付先(recipient)情報のみに特化
  • 契約関連フィールドは全て contracts.json に一本化
  • targets.json を recipients.json にリネーム(役割の明確化)
Phase 1 で targets.json に追加するフィールド
[
  {"name": "根崎", "email": "...", "client": "TFHD", "contract_id": "S001", ...},
  {"name": "小川", "email": "...", "client": "TFHD", "contract_id": "S002", ...},
  ...
]

contract_id があれば、targets.json → contracts.json を辿って金額・期間を取得できる。既存のTFHDフローは targets.json の他のフィールドを引き続き使う。

サンプルデータ(contracts.json)

TFHD(クライアント1件+委託先2名)+ MNML(委託先3名)の計6件。

[
  {
    "id": "C001",
    "type": "sales",
    "counterparty": "TFHD",
    "counterparty_full_name": "東急不動産ホールディングス株式会社",
    "counterparty_email": null,
    "vdu": ["TFHD"],
    "contract_type": "コンサルティング",
    "amount": 1500000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": "2027-03-31",
    "auto_renew": false,
    "renewal_notice_days": 60,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": 25,
    "sharepoint_contract_path": "/10_Corporate/contracts/clients/TFHD",
    "sharepoint_deliverable_path": null,
    "invoice_folder": null,
    "notes": null
  },
  {
    "id": "S001",
    "type": "purchase",
    "counterparty": "根崎",
    "counterparty_full_name": null,
    "counterparty_email": "r.nesaki@gmail.com",
    "vdu": ["TFHD"],
    "contract_type": "業務委託",
    "amount": 500000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": "2027-03-31",
    "auto_renew": false,
    "renewal_notice_days": 30,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": null,
    "sharepoint_contract_path": "/10_Corporate/contracts/suppliers/根崎",
    "sharepoint_deliverable_path": "/30_VDU/consulting/20_suppliers/10_根崎さん/20_成果物",
    "invoice_folder": null,
    "notes": null
  },
  {
    "id": "S002",
    "type": "purchase",
    "counterparty": "小川哲央",
    "counterparty_full_name": null,
    "counterparty_email": "tetsu0o0726@gmail.com",
    "vdu": ["TFHD"],
    "contract_type": "業務委託",
    "amount": 300000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": "2027-03-31",
    "auto_renew": false,
    "renewal_notice_days": 30,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": null,
    "sharepoint_contract_path": "/10_Corporate/contracts/suppliers/小川哲央",
    "sharepoint_deliverable_path": "/30_VDU/consulting/20_suppliers/40_小川哲央さん/20_成果物",
    "invoice_folder": null,
    "notes": null
  },
  {
    "id": "S003",
    "type": "purchase",
    "counterparty": "森屋",
    "counterparty_full_name": "森屋穣",
    "counterparty_email": "m.moriya.0804@gmail.com",
    "vdu": ["MNML"],
    "contract_type": "業務委託",
    "amount": 200000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": null,
    "auto_renew": false,
    "renewal_notice_days": 30,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": null,
    "sharepoint_contract_path": "/10_Corporate/contracts/suppliers/森屋",
    "sharepoint_deliverable_path": null,
    "invoice_folder": "/10_Corporate/accounting/40_仕入れ請求",
    "notes": null
  },
  {
    "id": "S004",
    "type": "purchase",
    "counterparty": "小川さとこ",
    "counterparty_full_name": "小川さとこ",
    "counterparty_email": "ogawasatoko.wedding@gmail.com",
    "vdu": ["MNML"],
    "contract_type": "業務委託",
    "amount": 150000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": "2026-09-30",
    "auto_renew": false,
    "renewal_notice_days": 30,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": null,
    "sharepoint_contract_path": "/10_Corporate/contracts/suppliers/小川さとこ",
    "sharepoint_deliverable_path": null,
    "invoice_folder": "/10_Corporate/accounting/40_仕入れ請求/小川さん",
    "notes": null
  },
  {
    "id": "S005",
    "type": "purchase",
    "counterparty": "Josh",
    "counterparty_full_name": "Joshua Leaf",
    "counterparty_email": "jleaf415@gmail.com",
    "vdu": ["MNML"],
    "contract_type": "業務委託",
    "amount": 100000,
    "currency": "JPY",
    "period_start": "2026-04-01",
    "period_end": null,
    "auto_renew": false,
    "renewal_notice_days": 30,
    "status": "active",
    "payment_cycle": "monthly",
    "report_due_day": null,
    "sharepoint_contract_path": "/10_Corporate/contracts/suppliers/Josh",
    "sharepoint_deliverable_path": null,
    "invoice_folder": "/10_Corporate/accounting/40_仕入れ請求/Josh",
    "notes": null
  }
]

※ 金額は仮の値。実際の契約書から転記する。

セキュリティ・パフォーマンス

入力バリデーション

フィールドバリデーション
id一意性チェック(add時にrepository.pyで検証)
type"sales" | "purchase" のみ(Literal型で強制)
amountgt=0(0以下は拒否)。int型強制
period_start / period_endperiod_end が指定された場合、period_start < period_end をmodel_validatorで検証
status"active" | "expired" | "terminated" のみ
report_due_dayge=1, le=31
vdu空リスト不可(min_length=1)

パフォーマンス

観点方針
データ量現在6件、将来的にも数十件規模。JSONファイル全量読み込みで問題なし
キャッシュ不要。ファイルI/Oのコストはミリ秒単位。毎回読み込みで鮮度を保つ
同時書き込みCLIは単一プロセス前提。ロック不要。将来的に並行書き込みが必要になった場合はファイルロック(fcntl)を追加

前提条件・制約事項

前提条件

  • MFクラウドに契約管理APIは存在しない。ローカルJSON管理で開始する
  • 契約データの初期投入はCEOが手動で行う(実際の契約書を見て金額・期間を入力)
  • pyproject.toml の [tool.setuptools.packages.find]"contract*" を追加する必要がある
  • 追加の外部依存は不要(pydantic, rich は既存依存に含まれる)

制約事項

  • contracts.json の手動編集ミスを検出する仕組みがない → check コマンドでバリデーションも行う
  • 契約書PDFの自動解析(OCR等)は対象外。手動でJSONに転記する
  • MFクラウドとの同期機能は将来拡張(API提供開始を待つ)

トレードオフ

選択メリットデメリット
1ファイルに全契約(統合管理) シンプル。検索・フィルタが容易。バックアップが1ファイル 件数が増えると視認性が下がる(数十件なら問題なし)
statusは3値(expiring廃止) データの状態がシンプル。「もうすぐ切れる」は状態ではなく表示の問題 checkコマンドを定期実行しないと期限切れに気づかない → 対策: Slackスケジューラ連携
vduをリスト型に 1人が複数VDUにまたがるケースに対応可能 現状は全員1つのVDU。過剰設計のリスク → ただし将来の拡張コストはほぼゼロ