From 6cf66d2806974fbf7a02f389382bf5e0d7a16e65 Mon Sep 17 00:00:00 2001 From: Akira Date: Sat, 21 Feb 2026 15:29:27 +0900 Subject: [PATCH] =?UTF-8?q?=E7=99=BD=E7=9A=87=E9=9B=86=E8=90=BD=E5=96=B6?= =?UTF-8?q?=E8=BE=B2=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 10 + .../19_windmill_通知ワークフロー連携仕様.md | 309 ++++++++++++++++++ flows/shiraou_notification.flow.json | 27 ++ wm-api.sh | 24 ++ 4 files changed, 370 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 docs/shiraou/19_windmill_通知ワークフロー連携仕様.md create mode 100644 flows/shiraou_notification.flow.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d8dcf76 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(./wm-api.sh:*)", + "Bash(python3:*)", + "Bash(curl:*)", + "Bash(python3.exe -m json.tool:*)" + ] + } +} diff --git a/docs/shiraou/19_windmill_通知ワークフロー連携仕様.md b/docs/shiraou/19_windmill_通知ワークフロー連携仕様.md new file mode 100644 index 0000000..0166cc0 --- /dev/null +++ b/docs/shiraou/19_windmill_通知ワークフロー連携仕様.md @@ -0,0 +1,309 @@ +# Windmill 通知ワークフロー連携仕様 + +> **作成日**: 2026-02-21 +> **対象システム**: 白皇集落営農組合 統合システム (`shiraou.keinafarm.net`) +> **目的**: 予約・実績の変更をLINEで管理者に通知するWindmillワークフローの実装仕様 + +--- + +## 1. 概要 + +統合システム側が「変更履歴取得API」を提供する。 +Windmillは定期的にこのAPIをポーリングし、変更があればLINEへ通知する。 + +``` +Windmill(定期実行) + └→ GET https://shiraou.keinafarm.net/reservations/api/changes/?since=<前回実行時刻> + └→ 変更一覧(予約・実績)を取得 + └→ 変更があればLINE Messaging APIへ通知 + └→ 前回実行時刻を更新 +``` + +--- + +## 2. 変更履歴取得API + +### エンドポイント + +``` +GET https://shiraou.keinafarm.net/reservations/api/changes/ +``` + +### 認証 + +`X-API-Key` ヘッダーにAPIキーを指定する(統合システム管理者から取得)。 + +``` +X-API-Key: +``` + +APIキーが不正な場合は `401 Unauthorized` が返る。 + +### クエリパラメータ + +| パラメータ | 型 | 必須 | 説明 | +|-----------|-----|------|------| +| `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する | + +**`since` の形式例**: +- `2026-02-21T10:00:00` (ナイーブ、JSTとして扱われる) +- `2026-02-21T10:00:00+09:00` (タイムゾーン付き、推奨) + +### レスポンス(200 OK) + +```json +{ + "checked_at": "2026-02-21T12:00:00+09:00", + "since": "2026-02-21T10:00:00+09:00", + "reservations": [ + { + "operation": "create", + "reservation_id": 123, + "user_name": "田中太郎", + "machine_name": "トラクター", + "start_at": "2026-02-25T09:00:00+09:00", + "end_at": "2026-02-25T12:00:00+09:00", + "operated_at": "2026-02-21T11:30:00+09:00", + "operator_name": "田中太郎", + "reason": "" + }, + { + "operation": "cancel", + "reservation_id": 120, + "user_name": "佐藤花子", + "machine_name": "田植機", + "start_at": "2026-02-22T08:00:00+09:00", + "end_at": "2026-02-22T17:00:00+09:00", + "operated_at": "2026-02-21T11:45:00+09:00", + "operator_name": "佐藤花子", + "reason": "" + } + ], + "usages": [ + { + "operation": "update", + "usage_id": 456, + "user_name": "山田次郎", + "machine_name": "コンバイン", + "amount": 4.0, + "unit": "時間", + "start_at": "2026-02-20T08:00:00+09:00", + "end_at": "2026-02-20T12:00:00+09:00", + "operated_at": "2026-02-21T11:55:00+09:00", + "operator_name": "管理者A", + "reason": "記録ミスのため修正" + } + ] +} +``` + +### operation の値一覧 + +**予約(reservations)**: + +| 値 | 意味 | +|----|------| +| `create` | 予約が作成された | +| `update` | 予約の日時・機械が変更された | +| `cancel` | 予約がキャンセルされた | + +**実績(usages)**: + +| 値 | 意味 | +|----|------| +| `create` | 実績が登録された | +| `update` | 実績が修正された | +| `delete` | 実績が削除された | + +### 変更なしの場合 + +`reservations` と `usages` が両方空配列になる。通知は不要。 + +```json +{ + "checked_at": "2026-02-21T12:05:00+09:00", + "since": "2026-02-21T12:00:00+09:00", + "reservations": [], + "usages": [] +} +``` + +### エラーレスポンス + +| ステータス | 原因 | +|-----------|------| +| `401 Unauthorized` | APIキーが不正または未設定 | +| `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 | + +--- + +## 3. Windmillワークフロー設計 + +### 3.1 スケジュール + +- **実行間隔**: 5分毎(`*/5 * * * *`) +- 農業機械の予約という用途上、数分の遅延は許容範囲 + +### 3.2 状態管理(前回実行時刻) + +Windmillの **Resource** または **State Variable** に前回実行時刻を保存する。 + +``` +変数名: last_checked_at +初期値: (初回実行時は現在時刻 - 10分 を使用) +``` + +### 3.3 ワークフロー全体フロー(擬似コード) + +```python +# 1. 前回実行時刻を取得 +last_checked = get_state("last_checked_at") or (now() - 10 minutes) + +# 2. 変更履歴を取得 +response = GET "https://shiraou.keinafarm.net/reservations/api/changes/" + params: { since: last_checked.isoformat() } + headers: { "X-API-Key": NOTIFICATION_API_KEY } + +# 3. 変更があればLINEに通知 +if response.reservations or response.usages: + message = format_line_message(response) + send_line_message(LINE_CHANNEL_ACCESS_TOKEN, LINE_USER_ID, message) + +# 4. 前回実行時刻を更新 +set_state("last_checked_at", response.checked_at) +``` + +### 3.4 LINEメッセージのフォーマット例 + +```python +def format_line_message(data): + lines = ["📋 営農システム 変更通知\n"] + + for r in data["reservations"]: + start = r["start_at"][:16].replace("T", " ") + end = r["end_at"][:16].replace("T", " ") + + if r["operation"] == "create": + icon = "🟢" + label = "予約作成" + elif r["operation"] == "update": + icon = "🔵" + label = "予約変更" + elif r["operation"] == "cancel": + icon = "🔴" + label = "予約キャンセル" + + lines.append(f"{icon} {label}") + lines.append(f" 機械: {r['machine_name']}") + lines.append(f" 利用者: {r['user_name']}") + lines.append(f" 日時: {start} 〜 {end}") + if r["reason"]: + lines.append(f" 理由: {r['reason']}") + lines.append("") + + for u in data["usages"]: + start = u["start_at"][:16].replace("T", " ") + + if u["operation"] == "create": + icon = "🟢" + label = "実績登録" + elif u["operation"] == "update": + icon = "🔵" + label = "実績修正" + elif u["operation"] == "delete": + icon = "🔴" + label = "実績削除" + + lines.append(f"{icon} {label}") + lines.append(f" 機械: {u['machine_name']}") + lines.append(f" 利用者: {u['user_name']}") + lines.append(f" 利用量: {u['amount']}{u['unit']}") + lines.append(f" 日: {start[:10]}") + if u["reason"]: + lines.append(f" 理由: {u['reason']}") + lines.append("") + + return "\n".join(lines).strip() +``` + +**出力例**: +``` +📋 営農システム 変更通知 + +🟢 予約作成 + 機械: トラクター + 利用者: 田中太郎 + 日時: 2026-02-25 09:00 〜 2026-02-25 12:00 + +🔴 予約キャンセル + 機械: 田植機 + 利用者: 佐藤花子 + 日時: 2026-02-22 08:00 〜 2026-02-22 17:00 + +🔵 実績修正 + 機械: コンバイン + 利用者: 山田次郎 + 利用量: 4.0時間 + 日: 2026-02-20 + 理由: 記録ミスのため修正 +``` + +--- + +## 4. Windmill側の環境変数(シークレット) + +| 変数名 | 説明 | 設定場所 | +|--------|------|---------| +| `NOTIFICATION_API_KEY` | 統合システムのAPIキー | Windmill Secret | +| `LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging API チャネルアクセストークン | Windmill Secret | +| `LINE_USER_ID` / `LINE_GROUP_ID` | 通知先のユーザーIDまたはグループID | Windmill Secret | + +--- + +## 5. 統合システム側の設定(django側の作業) + +本番サーバーの `docker-compose.yml` に以下の環境変数を追加し、デプロイする。 + +```yaml +environment: + - NOTIFICATION_API_KEY=<任意の強いランダム文字列> +``` + +**APIキーの生成例**: +```bash +openssl rand -hex 32 +``` + +--- + +## 6. 動作確認方法 + +### curlで直接テスト + +```bash +# 変更なし(直近1分) +curl -H "X-API-Key: <キー>" \ + "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00" + +# 広い範囲で変更を取得(初期確認用) +curl -H "X-API-Key: <キー>" \ + "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00" + +# APIキーなし → 401が返ることを確認 +curl "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00" +``` + +### ダッシュボードで変更確認 + +管理者アカウントで `https://shiraou.keinafarm.net/reservations/dashboard/` にアクセスすると、 +直近30件の予約操作履歴・実績操作ログを確認できる。 +Windmillワークフローのデバッグ時に「APIが何を返すべきか」の確認に使える。 + +--- + +## 7. 注意事項 + +- `since` に渡す時刻は **前回の `checked_at`** を使う(`since` ではなく `checked_at` を保存すること) +- 同一の変更が2回通知されないよう、状態管理を確実に行う +- ワークフローがエラーで終了した場合、`last_checked_at` を更新しないようにすること(次回実行時に再取得される) +- APIキーは定期的にローテーションすること(変更時は統合システム側の環境変数も同時に更新) diff --git a/flows/shiraou_notification.flow.json b/flows/shiraou_notification.flow.json new file mode 100644 index 0000000..672e7db --- /dev/null +++ b/flows/shiraou_notification.flow.json @@ -0,0 +1,27 @@ +{ + "path": "f/shiraou/shiraou_notification", + "summary": "白皇集落営農 変更通知", + "description": "shiraou.keinafarm.net の予約・実績変更をポーリングし、変更があればLINEで管理者に通知する。5分毎に実行。", + "value": { + "modules": [ + { + "id": "a", + "summary": "変更確認・LINE通知", + "value": { + "type": "rawscript", + "language": "python3", + "content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分)\n state = wmill.get_state() or {}\n last_checked = state.get(\"last_checked_at\")\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知(エラー時は状態を更新しない)\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_state({\"last_checked_at\": checked_at})\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n", + "input_transforms": {}, + "lock": "" + } + } + ] + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "order": [], + "properties": {}, + "required": [] + } +} diff --git a/wm-api.sh b/wm-api.sh index b81b334..028b2ec 100755 --- a/wm-api.sh +++ b/wm-api.sh @@ -61,6 +61,27 @@ case "${1:-help}" in fi api_post "/scripts/create" "$(cat "$2")" ;; + create-flow) + if [ -z "${2:-}" ]; then + echo "Usage: $0 create-flow " + exit 1 + fi + api_post "/flows/create" "$(cat "$2")" + ;; + update-flow) + if [ -z "${2:-}" ] || [ -z "${3:-}" ]; then + echo "Usage: $0 update-flow " + exit 1 + fi + api_put "/flows/update/$2" "$(cat "$3")" + ;; + create-schedule) + if [ -z "${2:-}" ]; then + echo "Usage: $0 create-schedule " + exit 1 + fi + api_post "/schedules/create" "$(cat "$2")" + ;; run-script) if [ -z "${2:-}" ]; then echo "Usage: $0 run-script [json-args]" @@ -112,6 +133,9 @@ Windmill REST API ヘルパー get-script - スクリプトの詳細を取得 get-flow - フローの詳細を取得 create-script - JSONファイルからスクリプトを作成 + create-flow - JSONファイルからフローを作成 + update-flow - フローを更新 + create-schedule - JSONファイルからスケジュールを作成 run-script [args] - スクリプトを実行 run-flow [args] - フローを実行 job-status - ジョブのステータスを確認