クライアント(売上先)とサプライヤー(委託先)を統合管理する汎用契約管理基盤
VDUで契約先が増えても contracts.json に1行追加するだけ。全パイプラインから金額・期間を参照できる基盤を作る
clientフィールド → vduフィールドに変更(VDU横断管理)typeフィールド追加: sales / purchase| 項目 | 決定内容 | 理由 |
|---|---|---|
| 決定 SharePoint構成 | 案A: 10_Corporate/contracts/ 配下に clients / suppliers |
全契約書を1箇所に集約し、ContractScannerが1パスで走査可能 |
| 決定 データ管理方式 | 統合管理(案1): 1つの contracts.json に全契約 | クライアント・サプライヤーの区別はtypeフィールドで。ファイル分割は不要 |
| 決定 データソース | ローカルJSON管理で開始 | MFクラウドに契約管理APIがないため |
1ファイル(contracts.json)で全契約を管理する。新規契約先の追加はJSONへの追記だけで完結する。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
id | str | ○ | 一意なID(例: C001, S001)。Cはクライアント、Sはサプライヤー |
type | Literal["sales", "purchase"] | ○ | sales = クライアント(売上先)、purchase = サプライヤー(委託先) |
counterparty | str | ○ | 取引先名(表示名) |
counterparty_full_name | str | None | 取引先フルネーム(個人名等) | |
counterparty_email | str | None | 取引先メールアドレス | |
vdu | list[str] | ○ | 紐づくVDU案件(例: ["TFHD"])。複数VDUにまたがる場合はリスト |
contract_type | str | ○ | 契約種別(業務委託 / 準委任 / 請負 / コンサルティング 等) |
amount | int | ○ | 月額金額(円・税抜)。整数のみ |
currency | str | 通貨コード(デフォルト: JPY) | |
period_start | str (YYYY-MM-DD) | ○ | 契約開始日 |
period_end | str (YYYY-MM-DD) | None | 契約終了日(無期限の場合はnull) | |
auto_renew | bool | 自動更新かどうか(デフォルト: false) | |
renewal_notice_days | int | 更新通知を何日前に出すか(デフォルト: 30) | |
status | Literal["active", "expired", "terminated"] | ○ | 契約ステータス |
payment_cycle | str | None | 支払サイクル(monthly / quarterly / yearly) | |
report_due_day | int | None | 月次報告の締め日(例: 25 = 毎月25日) | |
sharepoint_contract_path | str | None | SharePoint上の契約書フォルダパス | |
sharepoint_deliverable_path | str | None | SharePoint上の成果物フォルダパス | |
invoice_folder | str | None | 請求書保管フォルダパス | |
notes | str | None | 備考 |
expiringは廃止。期限切れ警告はcheckコマンドの出力で表現し、データには持たない| 現在 | 遷移先 | トリガー |
|---|---|---|
active | expired | checkコマンド実行時、period_endを過ぎている場合に自動遷移 |
active | terminated | 手動で中途解約を記録 |
expired | active | 再契約(period_endを延長して手動更新) |
terminated | active | 再契約(新規エントリ推奨だが、復活も可) |
※ auto_renew: true の契約は check 時に expired にせず、Slack通知で「自動更新されるが確認してください」と表示する。
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()使用) |
# [tool.setuptools.packages.find] の include に追加
include = [..., "contract*"]
# 依存は基本パッケージのみ(pydantic, rich)で追加不要
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
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()
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)
| コマンド | 説明 | オプション |
|---|---|---|
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: 警告日数を指定 |
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) |
経費の仕訳時に委託先契約を参照 |
# ops/tfhd/ から
from contract.app.repository import get_monthly_amount
amount = get_monthly_amount("根崎")
# → 500000(円・税抜)
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
# 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
| フィールド | 用途 | contracts.json に移行? |
|---|---|---|
name | 委託先表示名 | ○ → counterparty |
full_name | フルネーム | ○ → counterparty_full_name |
email | メールアドレス | ○ → counterparty_email |
client | VDU名 | ○ → vdu(リスト化) |
bundle_with_sender | TFHDメール添付方式 | × — TFHDフロー固有。targets.jsonに残す |
sharepoint_folder | 成果物フォルダ | ○ → sharepoint_deliverable_path |
sharepoint_dir_name | SPディレクトリ名 | × — targets.jsonに残す |
role | メール宛先の種別(to/cc) | × — 送付先情報。targets.jsonに残す |
invoice_folder | 請求書フォルダ | ○ → invoice_folder |
contract_id フィールドを追加(contracts.jsonへの紐付け)contract_id + TFHDフロー固有フィールドのみに縮小[
{"name": "根崎", "email": "...", "client": "TFHD", "contract_id": "S001", ...},
{"name": "小川", "email": "...", "client": "TFHD", "contract_id": "S002", ...},
...
]
contract_id があれば、targets.json → contracts.json を辿って金額・期間を取得できる。既存のTFHDフローは targets.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型で強制) |
amount | gt=0(0以下は拒否)。int型強制 |
period_start / period_end | period_end が指定された場合、period_start < period_end をmodel_validatorで検証 |
status | "active" | "expired" | "terminated" のみ |
report_due_day | ge=1, le=31 |
vdu | 空リスト不可(min_length=1) |
| 観点 | 方針 |
|---|---|
| データ量 | 現在6件、将来的にも数十件規模。JSONファイル全量読み込みで問題なし |
| キャッシュ | 不要。ファイルI/Oのコストはミリ秒単位。毎回読み込みで鮮度を保つ |
| 同時書き込み | CLIは単一プロセス前提。ロック不要。将来的に並行書き込みが必要になった場合はファイルロック(fcntl)を追加 |
[tool.setuptools.packages.find] に "contract*" を追加する必要があるcheck コマンドでバリデーションも行う| 選択 | メリット | デメリット |
|---|---|---|
| 1ファイルに全契約(統合管理) | シンプル。検索・フィルタが容易。バックアップが1ファイル | 件数が増えると視認性が下がる(数十件なら問題なし) |
| statusは3値(expiring廃止) | データの状態がシンプル。「もうすぐ切れる」は状態ではなく表示の問題 | checkコマンドを定期実行しないと期限切れに気づかない → 対策: Slackスケジューラ連携 |
| vduをリスト型に | 1人が複数VDUにまたがるケースに対応可能 | 現状は全員1つのVDU。過剰設計のリスク → ただし将来の拡張コストはほぼゼロ |