# 健康診断管理 Botトークフロー再設計 実装計画 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **2026-09-11更新**: 着手前に本番HC-WAへ2件の緊急バグ修正が入った(下記Global Constraints・Task0参照)。全ワークフローは現在誤送信対策で`inactive`化されている。本計画のPUT作業自体はactive/inactiveに関係なく実施できるが、Task2/3/4の「実機トークで確認」ステップの直前に対象ワークフローを一時activateする判断をユーザーに仰ぐこと。 **Goal:** 健康診断管理フェーズ2のLINEWORKS Bot対話フローを、承認済み仕様書(`2026-09-10-bot-flow-redesign.md`)通りに再構築する。「健康診断メニュー」の呼称統一、パターンA(受診日時)の分岐変更、パターンB(受診結果確認)の新規実装が中心。 **Architecture:** n8n上で本番稼働中の4ワークフロー(HC-WA/HC-WP/HC-REMINDER/HC-DOC-TIMEOUT)のCodeノードを、Public API(`PUT /api/v1/workflows/{id}`)経由で直接更新する運用。508971(社員別健康診断管理)側は新規Process「自分手配」を追加。DescriptionBの「日時:」行書き換えはn8n(HC-WA)側で行う(Pleasanter Processの値式では動的な文字列組み立てが難しいため)。 **Tech Stack:** n8n Code node(JavaScript、Task Runner環境。`require`不可、グローバル`crypto`不可)、Pleasanter API(items/get, items/update, getsite)、LINEWORKS Bot API。 ## Global Constraints - **【2026-09-11反映済み・消さないこと】HC-WA「署名検証・対象レコード特定」ノード174行目付近**: `resultId: record.ResultId` は `resultId: String(record.ResultId)` に修正済み。resultIdがNumber型のまま渡ると、後段「IF: resultIdあり」ノード(`typeValidation:strict`のstring.notEmpty演算子)が型エラーでワークフロー全体を異常終了させ、Webhookレスポンス未返却→LINEWORKS側リトライ→同一メッセージ重複送信を引き起こすバグの修正。Task2〜4はこのノードを触らないので通常は影響しないが、万一このノードのコードを再取得・再構築する場合は必ずString化を維持すること。 - **【2026-09-11反映済み・消さないこと】HC-WA「分岐処理・実行・送信」ノード冒頭、`// ==== -1. LINEWORKS自動送信イベント(トーク開始時の"利用開始"等) → 無反応 ====`ブロック(現状142-145行目)**: LINEWORKSがBotとのトーク開始・グループ招待時に自動送信する定型テキスト`"利用開始"`を、ユーザーの実発言と誤認識して無条件応答(「対象の健診情報がありません」「待機状態が不正です」等)を返してしまうバグの修正。対応する「署名検証・対象レコード特定」側の`content.text === "利用開始"`検知ブロックとセット。Task2〜4で「分岐処理・実行・送信」ノードのjsCodeを書き換える際、必ずGET直後の最新コードをベースにし(このブロックを含んだ状態から差分編集する)、コミット済みの古いコードスニペットで丸ごと上書きしないこと。 - n8n CodeノードはTask Runner環境。`require(...)`でNode.js組み込みモジュールは読めない。グローバルの`crypto`も存在しない(暗号処理が要る場合はpure JS実装で対応、`identity.js`の署名検証で実績あり)。 - Pleasanter `View.ColumnFilterHash`は列の型で書式が変わる。**リンク型(選択肢が`[[SiteId]]`や`[[Users*]]`)の列は値をJSON配列文字列化(`JSON.stringify([value])`)、通常の自由入力Class列は素の文字列**を渡す。508971の`ClassC`はリンク型(配列形式)、504412の`ClassA`/`Class011`は自由入力(文字列形式)。書式を誤るとフィルタが機能せず全件返る(サイレント障害になるので要注意)。 - 508971のProcess定義をAPI経由で**ゼロから構築してPUTすると、個別レコード編集画面が全件開けなくなる重大障害**が過去に発生している。新規Process追加時は、Pleasanter画面上で管理者に空のProcessを1件手動登録してもらい、それに対してAPI経由でプロパティを追記する方式のみ許可する。既存Processesを配列ごと構築し直すことは絶対にしない。 - n8nワークフローPUT時のbodyは`{name, nodes, connections, settings}`のみ。既存の`nodes`配列を`GET`で取得し、対象ノードの`parameters.jsCode`だけ書き換えてそのままPUTする(他ノードは触らない)。 - 動作確認は野木健一郎さん(508971 ResultId=513409、LINEWORKS本人アカウント)の実機トークで行う。テスト用ダミーレコードは作らない(過去にClassC重複で「複数の対象レコードが見つかりました」バグを誘発した経緯があるため)。 --- ## 事前テスト項目(実装着手前に確認すべきこと) 以下は「わかっていないと手戻りが起きる」項目。Task 0として最初に潰す。 1. **DescriptionB書き換え正規表現の妥当性**: 実際のDescriptionB文字列(`医療機関:...\n日時:2026/09/08 10:11\n...`)に対し、「日時:」行だけを確実に置換できるか。複数行文字列・改行コードの実機値をPleasanterから取得して検証する。 2. **Process「自分手配」の起点Status**: 「最初から自分手配とわかっている社員」がクリックするタイミングは、CurrentStatus=250(申込中)想定で問題ないか。250以外(200/210等)からでも押せる必要があるか、現場運用を踏まえて確認する。 3. **再手配時のDate002更新でStatus遷移が要るか**: 「未受診→再手配」で新日程が決まった際、ProcessId指定なしの`items/update`でDateHash.Date002だけを更新する設計にする(Status310のまま変更なしなのでProcess実行は不要という想定)。この想定が正しいか、Pleasanter側のバリデーション(Process未経由でのDate列更新が許可されているか)を実機で確認する。 4. **「受診結果について」ボタンの二重定義**: 現行実装では「受診・再検査報告」ボタンがStatus310と800の両方を1つのボタンで処理している。新設計ではStatus310は「受診結果について」、Status800は「再検査について」に分離する。この分離後、Status=310かつ800という重複状態は存在しない(Statusは単一値)ので設計上の矛盾はないが、念のため508971のStatus列が複数値を取らないこと(単一選択)を再確認する。 --- ## File Structure n8n側は全てPublic API経由のPUT更新。ローカルには一時作業ファイルとして以下を置く(作業完了後は破棄してよい、正はn8n上)。 - ローカル一時ファイル置き場: リポジトリ直下`_wf_*.json`(取得したワークフロー定義)、`_branch_next.js`等(編集中のノードコード)。**git管理対象外**、作業終了時に削除する。 - 恒久ドキュメント: - `Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign.md`(承認済み仕様書、変更しない) - `Pleasanter/健康診断管理/docs/n8n/design-fixed-menu.md`(実装ログ、Task 6で追記) - 508971変更用スクリプト: `Pleasanter/.claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js`(新規Process追加、既存の`add-*-508971.js`群と同じ安全策パターンを踏襲: 対象ProcessのOnClickがundefinedであることを確認してから処理する) **変更対象ノード一覧**: | ワークフロー | workflowId | ノード名 | 変更内容 | |---|---|---|---| | HC-WA | `0i0Ze3Gq0Waof3Fa` | 分岐処理・実行・送信 | 全面改修(Task 2〜4) | | HC-REMINDER | `kyyd1BHpUJkJSN3W` | 対象レコード取得・分類 | ボタンラベル変更(Task 5) | --- ## Task 0: 事前テスト項目の検証 **Files:** - Create: なし(調査のみ、リポジトリ直下に一時ファイル`_test_descb.js`等を作って検証、完了後削除) **Interfaces:** - Produces: DescriptionBの「日時:」行置換関数`replaceDateLineInDescription(description, newDateText)`の実装(Task 2以降で使う) - [ ] **Step 0: 2026-09-11緊急修正がHC-WA本番に反映済みであることを確認する** ```bash node -e " const KEY = ''; (async () => { const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows/0i0Ze3Gq0Waof3Fa', { headers: { 'X-N8N-API-KEY': KEY } }); const j = await res.json(); const seimei = j.nodes.find(n => n.name === '署名検証・対象レコード特定').parameters.jsCode; const bunki = j.nodes.find(n => n.name === '分岐処理・実行・送信').parameters.jsCode; console.log('active:', j.active); console.log('resultId String化:', seimei.includes('resultId: String(record.ResultId)')); console.log('利用開始イベント無視:', bunki.includes('identity.resolveError === \"system_event\"')); })(); " ``` Expected: `active: false`(HC-WAはユーザー指示で誤送信対策のため停止中)、両方の判定行が`true`。`false`が出た場合は他セッションで別のPUTが入って巻き戻った可能性があるため、作業前に必ずユーザーへ確認する。 - [ ] **Step 1: 513409のDescriptionBを取得し、実際の改行・文字コードを確認する** ```bash cd Pleasanter && node -e " const fs = require('fs'); const server = JSON.parse(fs.readFileSync('./config_production.json', 'utf-8')); (async () => { const url = server.BaseUrl + 'api/items/508971/get'; const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ApiVersion:1.1, ApiKey: server.ApiKey, View:{ColumnFilterHash:{ResultId:'513409'}}})}); const j = await res.json(); console.log(JSON.stringify(j.Response.Data[0].DescriptionHash.DescriptionB)); })(); " ``` Expected: `"医療機関:...\n日時:2026/09/08 10:11\n..."`のような、`\n`区切りの文字列が返る。 - [ ] **Step 2: 置換関数を書いてローカルで検証する** ```javascript function replaceDateLineInDescription(description, newDateText) { return description.replace(/^日時:.*$/m, `日時:${newDateText}`); } function formatDateTimeSlash(isoDateTime) { const m = String(isoDateTime).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); if (m) return `${m[1]}/${m[2]}/${m[3]} ${m[4]}:${m[5]}`; const d = String(isoDateTime).match(/^(\d{4})-(\d{2})-(\d{2})/); if (d) return `${d[1]}/${d[2]}/${d[3]}`; return String(isoDateTime); } // テスト const sample = "医療機関:日の出ヶ丘病院\n日時:2026/09/08 10:11\nコース:その他:そのた1\n"; console.log(replaceDateLineInDescription(sample, formatDateTimeSlash("2026-09-15T09:00:00"))); ``` Expected: 出力が`医療機関:日の出ヶ丘病院\n日時:2026/09/15 09:00\nコース:その他:そのた1\n`になっている(「日時:」行だけが書き変わり、他の行は無傷)。 - [ ] **Step 3: Step1で取得した実際のDescriptionBを使って同じ関数を通し、目視で確認する** Step1の出力をコピーして`replaceDateLineInDescription`に通し、正しく置換されるか確認する。想定外の改行コード(`\r\n`)が混ざっていないか、複数行のうち「日時:」が2箇所以上出現しないか(出現していたら`/gm`ではなく`/m`のままでよいか要検討)を目視確認する。 - [ ] **Step 4: 508971でProcess「自分手配」を手動登録できる下地を確認する** 現在のCurrentStatus=250のレコードが実在するか、または申込中の状態を作れる社員が居るかを508971の一覧で確認する(実データで確認、テストレコードは作らない)。管理者(ユーザー)に、Pleasanter画面から空のProcessを1件手動登録してもらう依頼をこのタイミングで行う(Task 2の前提)。 - [ ] **Step 5: 513409の状態スナップショット取得・復元スクリプトを用意する** 実機トークテスト(Task3 Step9、Task4 Step8)は野木さん(513409)1人の実データで全分岐を順番に検証する。1シナリオ試すたびに元の状態へ巻き戻す必要があるため、まず現状(Status=310、Date002確定済み、DescriptionB確定済み)をスナップショットとして保存し、任意のタイミングで復元できるスクリプトを用意する。 ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && node -e " const fs = require('fs'); const server = JSON.parse(fs.readFileSync('./Pleasanter/config_production.json', 'utf-8')); (async () => { const url = server.BaseUrl + 'api/items/508971/get'; const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ApiVersion:1.1, ApiKey: server.ApiKey, View:{ColumnFilterHash:{ResultId:'513409'}}})}); const j = await res.json(); fs.writeFileSync('./_snapshot_513409.json', JSON.stringify(j.Response.Data[0], null, 2)); console.log('保存完了。Status:', j.Response.Data[0].Status); })(); " ``` 復元スクリプト(`_restore_513409.js`、Task3/Task4の各シナリオ間で繰り返し使う)。**Statusは通常Process経由でしか変更できないため、`items/update`にStatusを直接指定して巻き戻せるかをこのタスクで実機確認する**(できない場合は、巻き戻し専用の一時的なProcessをPleasanter画面で用意する代替案を検討する): ```javascript // _restore_513409.js const fs = require('fs'); const server = JSON.parse(fs.readFileSync('./Pleasanter/config_production.json', 'utf-8')); const snapshot = JSON.parse(fs.readFileSync('./_snapshot_513409.json', 'utf-8')); (async () => { const url = server.BaseUrl + 'api/items/513409/update'; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ApiVersion: 1.1, ApiKey: server.ApiKey, Status: snapshot.Status, ClassHash: snapshot.ClassHash, DateHash: snapshot.DateHash, DescriptionHash: snapshot.DescriptionHash, }), }); console.log(res.status, JSON.stringify(await res.json()).slice(0, 300)); })(); ``` Expected: 実行後、`api/items/508971/get`で513409を取得し直し、Status・ClassHash.Class003・DateHash.Date002・DescriptionHash.DescriptionBがスナップショット取得時点と一致することを確認する。一致しなければ(特にStatusが変わらない場合)、Task3/Task4のテスト時にPleasanter画面から手動でStatusを戻してもらう運用に切り替える。 --- ## Task 1: 508971へ新規Process「自分手配」を追加する **Files:** - Create: `Pleasanter/.claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js` **Interfaces:** - Produces: ProcessId(新規採番される値、既存17件の最大Idの次)。Task 4でHC-WA branch.jsから`PROCESS_ID.SELF_ARRANGE`として参照する。 - [ ] **Step 1: ユーザーに508971でProcessを1件、画面から手動登録してもらう** 名前「自分手配」、CurrentStatus=250、ChangedStatus=400。DataChangesは無し(OnClick等の複雑な設定も画面上ではデフォルトのままでよい)。登録後、Idをユーザーに確認してもらう。 - [ ] **Step 2: 手動登録済みProcessにDataChanges(Class003を「自分手配」にセット)をAPI経由で追記するスクリプトを書く** ```javascript // Pleasanter/.claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js // 健康診断管理 SiteId 508971: 手動登録済みProcess「自分手配」に対し、 // Class003(検査機関)を「自分手配」(511924 ResultId 513480)にセットするDataChangesを追記する。 // OnClickが存在すれば中断する安全策付き(既存の同種スクリプトと同じパターン)。 "use strict"; const fs = require("fs"); const path = require("path"); const { findSiteDir, newModifyDir } = require("../../site-paths.js"); const PROJECT_NAME = "健康診断管理"; const ENV = "production"; const SITE_ID = 508971; const REQUEST_LABEL = "add-process-jibunanpai"; const PROCESS_NAME = "自分手配"; // ユーザーがStep1で登録した名前と一致させる const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); function loadServerConfig() { return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, `config_${ENV}.json`), "utf-8")); } function loadLatest() { const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); const raw = JSON.parse(fs.readFileSync(p, "utf-8")); return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; } const execute = process.argv.includes("--execute"); const server = loadServerConfig(); (async () => { const { current, siteDir, rawText } = loadLatest(); const settings = JSON.parse(JSON.stringify(current.SiteSettings)); settings.Links = current.SiteSettings.Links || []; const proc = settings.Processes.find((p) => p.Name === PROCESS_NAME); if (!proc) { console.error(`[エラー] Process「${PROCESS_NAME}」が見つかりません。先にPleasanter画面で手動登録してください。`); process.exit(1); } if (proc.OnClick !== undefined) { console.error(`[エラー] Process「${PROCESS_NAME}」(Id:${proc.Id})にOnClickフィールドが存在します。中断します。`); process.exit(1); } if (proc.CurrentStatus !== 250 || proc.ChangedStatus !== 400) { console.error(`[エラー] Status遷移が想定外です(CurrentStatus:${proc.CurrentStatus}, ChangedStatus:${proc.ChangedStatus})。250→400になっているか確認してください。`); process.exit(1); } proc.DataChanges = [ { Id: 1, Type: "InputValue", ColumnName: "Class003", Value: "513480", ValueFormulaNotUseDisplayName: false, ValueFormulaIsDisplayError: false }, ]; const body = { ApiVersion: 1.1, ApiKey: server.ApiKey, SiteId: SITE_ID, Title: current.Title, ReferenceType: current.ReferenceType, ParentId: current.ParentId, InheritPermission: current.InheritPermission, SiteSettings: settings, }; const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); console.log(`Process「${PROCESS_NAME}」(Id:${proc.Id}) に Class003=513480(自分手配) のDataChangesを追加します。`); if (!execute) { console.log("(--execute 未指定のためプレビューのみ)"); return; } const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json())); })(); ``` - [ ] **Step 2.5: 508971の最新設定をローカルへ再取得してから実行する** ```bash cd Pleasanter && node .claude/js/get-site-config.js --project="健康診断管理" --env=production ``` (ユーザーがStep1で画面登録した直後の最新状態をローカルキャッシュに反映させるため。省略するとローカルの古いProcesses配列で上書きしてしまう) - [ ] **Step 3: プレビュー実行して内容を確認する** ```bash cd Pleasanter && node ".claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js" ``` Expected: `Process「自分手配」(Id:18) に Class003=513480(自分手配) のDataChangesを追加します。` `(--execute 未指定のためプレビューのみ)` のような出力(Idは実際に採番された値)。 - [ ] **Step 4: ユーザーに実行可否を確認してから本番反映する** ```bash cd Pleasanter && node ".claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js" --execute ``` Expected: `[結果] HTTP 200: {"Id":508971,"StatusCode":200,"Message":"..."}` - [ ] **Step 5: Seleniumで508971個別レコード編集画面が正常に開くか確認する** ```bash python .claude/skills/pleasanter-selenium-html-scrape/scripts/scrape_rendered_html.py \ "https://nextoffice.next-hd.co.jp/pleasanter/items/513409" --headless --wait-selector "body" \ -o /tmp/check_after_process18.html --timeout 30 grep -c "controller=errors\|見つかりません" /tmp/check_after_process18.html ``` Expected: `grep`のカウントが0(エラー文言が含まれない)。 - [ ] **Step 6: Commit** ```bash git add "Pleasanter/.claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js" git commit -m "feat(healthcheck): 508971へ自分手配Process用の反映スクリプトを追加" ``` --- ## Task 2: HC-WA branch.js — 健康診断メニューのボタン構成・ラベル変更 現行の「健康診断について」タップ時の3択(健診日程/受診・再検査報告/診断書提出)を、仕様書通りの3択(受診日時について/受診結果について/再検査について)に変更する。**この段階では各ボタンを押した後の中身はまだ変更しない**(Task 3〜5で順次実装)。 **Files:** - Modify: n8n `0i0Ze3Gq0Waof3Fa`(HC-WA) ノード「分岐処理・実行・送信」の`parameters.jsCode`、180-191行目相当 **Interfaces:** - Consumes: `currentStatus`(識別済み、`identity.currentStatus`由来) - Produces: テキスト値`"受診日時について"` `"受診結果について"` `"再検査について"`(Task 3〜5がこれをトリガー文字列として使う) - [ ] **Step 1: 現在のbranch.jsをn8nから取得してローカルへ保存する** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && node -e " const KEY = ''; const fs = require('fs'); (async () => { const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows/0i0Ze3Gq0Waof3Fa', { headers: { 'X-N8N-API-KEY': KEY } }); const j = await res.json(); fs.writeFileSync('./_wf_hcwa.json', JSON.stringify(j)); const n = j.nodes.find(nn => nn.name === '分岐処理・実行・送信'); fs.writeFileSync('./_branch_next.js', n.parameters.jsCode); })(); " ``` - [ ] **Step 2: `_branch_next.js`の該当箇所を書き換える** **注意**: 下記の行番号(185-196行目)は2026-09-11時点の本番コード基準。Step1で取得した`_branch_next.js`は先頭に2026-09-11追加の「-1. LINEWORKS自動送信イベント」ブロック(9行)を含むため、行番号でなく下記コード文字列で該当箇所を検索して置換すること。このブロック自体(`identity.resolveError === "system_event"`の判定)と、その前の「0. 対象者解決エラー」ブロックは一切変更しない。 185-196行目相当(旧): ```javascript if (text === "健康診断について") { const categoryActions = []; if (currentStatus === 300 || currentStatus === 310) categoryActions.push({ type: "message", label: "健診日程", text: "健診日程" }); if (currentStatus === 310 || currentStatus === 800) categoryActions.push({ type: "message", label: "受診・再検査報告", text: "受診・再検査報告" }); if (currentStatus === 400) categoryActions.push({ type: "message", label: "診断書提出", text: "診断書提出" }); if (categoryActions.length === 0) { await sendText.call(this, "現在ご案内できる項目はありません"); return buildResult("sent", RESET_STATE); } await sendButtons.call(this, "ご用件を選んでください。", withContact(categoryActions)); return buildResult("sent", RESET_STATE); } ``` 新: ```javascript if (text === "健康診断について") { const categoryActions = []; if (currentStatus === 300 || currentStatus === 310) categoryActions.push({ type: "message", label: "受診日時について", text: "受診日時について" }); if (currentStatus === 310 || currentStatus === 400) categoryActions.push({ type: "message", label: "受診結果について", text: "受診結果について" }); if (currentStatus === 800) categoryActions.push({ type: "message", label: "再検査について", text: "再検査について" }); if (categoryActions.length === 0) { await sendText.call(this, "現在ご案内できる項目はありません"); return buildResult("sent", RESET_STATE); } await sendButtons.call(this, "ご用件を選んでください。", withContact(categoryActions)); return buildResult("sent", RESET_STATE); } ``` 193-239行目の3分岐(`健診日程`/`受診・再検査報告`/`診断書提出`)本体は、このタスクでは一切触らない(テキストが一致しなくなるので押しても反応しなくなるだけで、実害はない)。Task 3〜5で新ラベルの分岐として書き直す。 - [ ] **Step 3: ローカルの`_branch_next.js`をPUTで反映する** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && node -e " const KEY = ''; const fs = require('fs'); (async () => { const j = JSON.parse(fs.readFileSync('./_wf_hcwa.json', 'utf-8')); const n = j.nodes.find(nn => nn.name === '分岐処理・実行・送信'); n.parameters.jsCode = fs.readFileSync('./_branch_next.js', 'utf-8'); const putBody = { name: j.name, nodes: j.nodes, connections: j.connections, settings: j.settings }; const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows/0i0Ze3Gq0Waof3Fa', { method: 'PUT', headers: { 'X-N8N-API-KEY': KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(putBody), }); console.log(res.status, (await res.json()).updatedAt); })(); " ``` Expected: `200` とタイムスタンプ。 - [ ] **Step 3.5: HC-WAを一時activateする(ユーザー確認必須)** 現在HC-WAは誤送信対策で`inactive`。実機トーク確認にはWebhookが動く必要があるため、activateしてよいかユーザーに確認してから実施する: ```bash node -e " const KEY = ''; (async () => { const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows/0i0Ze3Gq0Waof3Fa/activate', { method: 'POST', headers: { 'X-N8N-API-KEY': KEY } }); console.log((await res.json()).active); })(); " ``` Expected: `true`。以降のタスクでも実機確認の間はactiveなままでよい(Task6完了まで継続)。 - [ ] **Step 4: 野木さん(513409、現在Status=310)の実機トークで確認する** 固定メニュー「健康診断について」をタップし、「受診日時について」「受診結果について」の2択(Status=310なので両方出る)が表示されることを確認する。まだ中身は未実装なので、押しても`if`に一致せず`buildResult("noop")`扱いになる(何も返らない)想定。ここでは**ボタンの見た目だけ**確認する。 --- ## Task 3: HC-WA branch.js — 受診日時について(パターンA) **Files:** - Modify: 同上ノード。旧`健診日程`ブロック(Task2時点で198-219行目相当)と`schedule_confirm`状態(249-285行目相当)を全面改修。**行番号は毎回Step1で取得する最新コードに合わせて実装者が特定すること**(2026-09-11追加の冒頭ブロック分だけ既存計画記載よりずれている)。 **Interfaces:** - Consumes: Task1で追加したProcessId(`自分手配`、`PROCESS_ID.SELF_ARRANGE`として定義)、Task0で検証した`replaceDateLineInDescription`/`formatDateTimeSlash` - Produces: `awaitInput: "schedule_confirm"`(仕様変更後の内容で継続使用)、`formDraft`に`{date001}`(既存のまま踏襲) - [ ] **Step 1: PROCESS_IDへ自分手配を追加する** 先頭の`PROCESS_ID`オブジェクトに追記: ```javascript const PROCESS_ID = { SCHEDULE_CONFIRM: 6, REPORT_VISITED: 7, DOCUMENT_SAVE: 8, REPORT_RETEST: 12, SELF_ARRANGE: 18, // 自分手配 250→400(Task1で採番された実際のIdに置き換える) }; ``` - [ ] **Step 2: DescriptionB書き換えヘルパーを追加する(Task0で検証済みの関数をそのまま貼る)** `formatDateJp`関数の直後に追記: ```javascript function formatDateTimeSlash(isoDateTime) { const m = String(isoDateTime).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); if (m) return `${m[1]}/${m[2]}/${m[3]} ${m[4]}:${m[5]}`; const d = String(isoDateTime).match(/^(\d{4})-(\d{2})-(\d{2})/); if (d) return `${d[1]}/${d[2]}/${d[3]}`; return String(isoDateTime); } function replaceDateLineInDescription(description, newDateText) { return String(description || "").replace(/^日時:.*$/m, `日時:${newDateText}`); } ``` - [ ] **Step 3: 検査機関情報を組み立てるヘルパーを追加する** 511924(検査機関マスタ)を`ResultId`で1件取得し、病院名・電話番号・担当者を組み立てる: ```javascript async function buildHospitalContactMessage(orgResultId) { const res = await pleasanterPost.call(this, `api/items/511924/get`, { View: { ColumnFilterHash: { ResultId: JSON.stringify([String(orgResultId)]) }, ColumnFilterSearchTypes: { ResultId: "ExactMatch" } }, }); const rows = res.Response.Data || []; if (rows.length !== 1) return "検査機関へお問い合わせください。"; const org = rows[0].ClassHash; const lines = [ "検査機関へ直接ご連絡の上、日程調整をお願いいたします。", "", `病院名:${org.ClassB || ""}`, `電話番号:${org.ClassD || ""}`, ]; if (org.ClassF) lines.push(`担当者:${org.ClassF}`); lines.push("", "変更後の受診日時が決まりましたら、このトークにそのままご返信ください。"); return lines.join("\n"); } ``` `ResultId`列は数値型の内部識別子であり`View.ColumnFilterHash`でのフィルタ可否・書式(配列/文字列)が508971の`ClassC`や504412の`ClassA`とは異なる可能性があるため、**このステップの直後に必ずTask0と同様の実機検証を行う**(下記Step 3.5)。 - [ ] **Step 3.5: ResultId指定フィルタの書式を実機で確認する** ```bash cd Pleasanter && node -e " const fs = require('fs'); const server = JSON.parse(fs.readFileSync('./config_production.json', 'utf-8')); (async () => { const url = server.BaseUrl + 'api/items/511924/get'; // パターン1: 文字列指定 const r1 = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ApiVersion:1.1, ApiKey: server.ApiKey, View:{ColumnFilterHash:{ResultId:'511926'}}})}); console.log('文字列指定:', (await r1.json()).Response.TotalCount); // パターン2: 配列文字列指定 const r2 = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ApiVersion:1.1, ApiKey: server.ApiKey, View:{ColumnFilterHash:{ResultId: JSON.stringify(['511926'])}}})}); console.log('配列文字列指定:', (await r2.json()).Response.TotalCount); })(); " ``` Expected: どちらか一方が`1`件、もう一方が総件数(2件、511924の全件)を返す。**`1`件になった方の書式をStep3のヘルパー関数に採用する**(このプランのStep3コードは配列文字列指定を仮定して書いているが、実機確認の結果、文字列指定が正しければそちらへ修正する)。 - [ ] **Step 4: 「受診日時について」ボタンの中身をStatus300/310で分岐実装する** 193-214行目を置き換え: ```javascript if (text === "受診日時について") { if (currentStatus === 300) { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const date001 = recData.DateHash.Date001; const jpDate = formatDateJp(date001); await sendButtons.call(this, `候補日は${jpDate}です。この日程でよろしければ「承諾」、変更したい場合は「受診日時の変更」を選んでください。`, withContact([ { type: "message", label: "承諾", text: "承諾" }, { type: "message", label: "受診日時の変更", text: "受診日時の変更" }, { type: "message", label: "自分手配", text: "自分手配" }, ])); return buildResult("sent", { awaitInput: "schedule_confirm", awaitProcessId: "", awaitColumn: "", formDraft: JSON.stringify({ date001 }), failCount: 0 }); } if (currentStatus === 310) { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const descriptionB = recData.DescriptionHash.DescriptionB || "確定日の情報がありません。"; await sendButtons.call(this, descriptionB, withContact([ { type: "message", label: "受診日時の変更", text: "受診日時の変更" }, ])); return buildResult("sent", { awaitInput: "schedule_confirm", awaitProcessId: "", awaitColumn: "", formDraft: JSON.stringify({}), failCount: 0 }); } await sendText.call(this, "現在対象外です"); return buildResult("sent", RESET_STATE); } ``` - [ ] **Step 5: `schedule_confirm`状態の分岐を全面改修する** 244-280行目を置き換え: ```javascript // ==== 4. awaitInput = schedule_confirm ==== if (state.awaitInput === "schedule_confirm") { if (contentType !== "text") { await sendText.call(this, "日程についてはテキストでお答えください"); return buildResult("sent"); } const draft = JSON.parse(state.formDraft || "{}"); async function confirmSchedule(newDateIso) { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const newDescriptionB = replaceDateLineInDescription(recData.DescriptionHash.DescriptionB, formatDateTimeSlash(newDateIso)); await pleasanterPost.call(this, `api/items/${resultId}/update`, { ProcessId: PROCESS_ID.SCHEDULE_CONFIRM, DateHash: { Date002: newDateIso }, DescriptionHash: { DescriptionB: newDescriptionB }, }); await sendText.call(this, "日程を確定しました。"); } if (text === "承諾") { await confirmSchedule.call(this, draft.date001); return buildResult("sent", RESET_STATE); } if (text === "自分手配") { await pleasanterPost.call(this, `api/items/${resultId}/update`, { ProcessId: PROCESS_ID.SELF_ARRANGE }); await sendText.call(this, "ご自分で検査機関を手配して受診されるとのことで承知しました。診断結果を受け取りましたら、こちらのトーク画面へ送信してください。"); return buildResult("sent", RESET_STATE); } if (text === "受診日時の変更") { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const message = await buildHospitalContactMessage.call(this, recData.ClassHash.Class003); await sendText.call(this, message); return buildResult("sent", { awaitInput: "schedule_confirm", awaitProcessId: "", awaitColumn: "", formDraft: state.formDraft, failCount: 0 }); } // 自由入力パース(検査機関から連絡を受けた新日時のテキスト送信) try { const parsedDate = parseDateInput(text); await sendButtons.call(this, `${parsedDate}でよろしいですか?`, withContact([ { type: "message", label: "承諾", text: "承諾" }, { type: "message", label: "再入力", text: "再入力" }, ])); return buildResult("sent", { awaitInput: "yesno_confirm", awaitProcessId: "", awaitColumn: "", formDraft: JSON.stringify({ purpose: "schedule", date: parsedDate }), failCount: 0 }); } catch (err) { const failCount = (state.failCount || 0) + 1; if (failCount >= 3) { await sendText.call(this, "日付を認識できないため、いったん受付を中断します。固定メニューから「受診日時について」を選び直してください"); return buildResult("sent", RESET_STATE); } await sendText.call(this, err.message); return buildResult("sent", { awaitInput: "schedule_confirm", awaitProcessId: "", awaitColumn: "", formDraft: state.formDraft, failCount }); } } ``` - [ ] **Step 6: `yesno_confirm`のscheduleケース(292-300行目)をボタンラベル「承諾/再入力」対応に書き換える** ```javascript if (draft.purpose === "schedule") { if (text === "承諾") { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const newDescriptionB = replaceDateLineInDescription(recData.DescriptionHash.DescriptionB, formatDateTimeSlash(draft.date)); await pleasanterPost.call(this, `api/items/${resultId}/update`, { ProcessId: PROCESS_ID.SCHEDULE_CONFIRM, DateHash: { Date002: draft.date }, DescriptionHash: { DescriptionB: newDescriptionB }, }); await sendText.call(this, "日程を確定しました。"); return buildResult("sent", RESET_STATE); } await sendText.call(this, "もう一度、受診日時を入力してください。"); return buildResult("sent", { awaitInput: "schedule_confirm", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount: 0 }); } ``` 285-291行目の「はい/いいえ」でお答えください、というガード文もscheduleケースでは「承諾/再入力」に対応させる必要がある。285行目の条件分岐を`purpose`で先に判定するよう並べ替える(詳細はStep7)。 - [ ] **Step 7: yesno_confirmの入口ガード(285-291行目)をpurpose別に分岐させる** ```javascript // ==== 5. awaitInput = yesno_confirm ==== if (state.awaitInput === "yesno_confirm") { const draft = JSON.parse(state.formDraft || "{}"); const isScheduleFlow = draft.purpose === "schedule"; const validAnswers = isScheduleFlow ? ["承諾", "再入力"] : ["はい", "いいえ"]; if (contentType !== "text" || !validAnswers.includes(text)) { await sendButtons.call(this, isScheduleFlow ? "「承諾」または「再入力」でお答えください。" : "「はい」または「いいえ」でお答えください。", withContact( isScheduleFlow ? [{ type: "message", label: "承諾", text: "承諾" }, { type: "message", label: "再入力", text: "再入力" }] : [{ type: "message", label: "はい", text: "はい" }, { type: "message", label: "いいえ", text: "いいえ" }] )); return buildResult("sent"); } // 以降、Step6のscheduleブロック・既存のattendance_310ブロック(Task4で書き換え)が続く ``` - [ ] **Step 8: PUT反映、Selenium確認** Task2 Step4/5と同じ手順でPUTし、513409の編集画面が正常に開くことをSeleniumで確認する。 - [ ] **Step 9: 実機トークで4シナリオを順番に確認する(都度Task0 Step5の復元スクリプトで巻き戻す)** 野木さん(513409)1人の実データで、パターンAの全分岐を1つずつ検証する。**各シナリオの開始前に必ず`_restore_513409.js`を実行し、Status=310・確定済みDate002/DescriptionBの状態に戻してから、いったんPleasanter画面で管理者にStatus=300へ手動で戻してもらう**(Statusの直接更新がAPIで通らない場合の前提。Task0 Step5で実機確認済みの方法に従う)。 **シナリオA1: 承諾ルート** 1. Status=300であることをAPIで確認 2. ユーザーが固定メニュー「健康診断について」→「受診日時について」をタップ → 候補日提示+「承諾」「受診日時の変更」「自分手配」ボタンが出ることを確認 3. ユーザーが「承諾」をタップ → 「日程を確定しました。」が届く 4. APIで508971のStatus=310、Date002=Date001と同じ値、DescriptionBの「日時:」行が確定日になっていることを確認 5. `_restore_513409.js`で巻き戻す **シナリオA2: 変更→自由入力→承諾ルート** 1. Status=300に戻っていることを確認(Step5の巻き戻し後、管理者に画面操作を依頼) 2. 「受診日時について」→「受診日時の変更」をタップ → 検査機関情報(病院名/電話番号/担当者)が届くことを確認 3. ユーザーが適当な日付(例: `9/20`)をテキスト送信 → 「2026-09-20でよろしいですか?」に「承諾」「再入力」ボタンが出ることを確認 4. 「承諾」をタップ → 「日程を確定しました。」。APIでDate002=2026-09-20、DescriptionBの日時行が`2026/09/20`になっていることを確認 5. 巻き戻す **シナリオA3: 自分手配ルート** 1. Status=300に戻っていることを確認 2. 「受診日時について」→「自分手配」をタップ 3. APIでStatus=400、ClassHash.Class003="513480"(自分手配)になっていることを確認 4. 巻き戻す **シナリオA4: パース失敗3回の強制中断** 1. Status=300に戻っていることを確認 2. 「受診日時について」→「受診日時の変更」をタップ 3. ユーザーが認識不能な文字列(例: `あいうえお`)を3回連続送信 4. 3回目で「日付を認識できないため、いったん受付を中断します。固定メニューから「受診日時について」を選び直してください」が届くことを確認 5. `healthcheck_bot_state`の該当行が`awaitInput: "none"`にリセットされていることをn8n Data Table APIで確認 6. 巻き戻す(このシナリオはStatus/Date002を変更していないので巻き戻し不要な場合もあるが、`healthcheck_bot_state`の状態だけ念のため確認する) - [ ] **Step 10: Commit** n8n上のみの変更なのでリポジトリへのコミットは無いが、ローカル一時ファイルを削除する: ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && rm -f _wf_hcwa.json _branch_next.js ``` --- ## Task 4: HC-WA branch.js — 受診結果について(パターンB) **Files:** - Modify: 同上ノード。旧`受診・再検査報告`/`診断書提出`ブロック(Task2時点で221-244行目相当)、`attendance_310`のyesno_confirm処理(306-314行目相当)を全面改修。新規状態`retest_choice`・`reschedule_input`を追加。**行番号は毎回Step1で取得する最新コードに合わせて実装者が特定すること**(2026-09-11追加の冒頭ブロック分だけ既存計画記載よりずれている)。 **Interfaces:** - Consumes: Task3の`buildHospitalContactMessage`、`replaceDateLineInDescription`、`formatDateTimeSlash`、`parseDateInput` - Produces: `awaitInput: "retest_choice"`(再手配/自分手配の選択待ち)、`awaitInput: "reschedule_input"`(再手配時の新日時自由入力待ち) - [ ] **Step 1: 「受診結果について」ボタンの中身をStatus310/400で分岐実装する** 216-239行目(旧`受診・再検査報告`+`診断書提出`)を置き換え: ```javascript if (text === "受診結果について") { if (currentStatus === 310) { await sendButtons.call(this, "健康診断を予定通り受診されましたか?", withContact([ { type: "message", label: "受診完了", text: "受診完了" }, { type: "message", label: "未受診", text: "未受診" }, ])); return buildResult("sent", { awaitInput: "yesno_confirm", awaitProcessId: "", awaitColumn: "", formDraft: JSON.stringify({ purpose: "attendance_310" }), failCount: 0 }); } if (currentStatus === 400) { await sendText.call(this, "診断書をPDFでスキャンしてアップロードしてください。写真は不可です。表裏が別ファイルの場合は複数枚送信できます。すべて送り終わったら「完了」と送信してください。"); return buildResult("sent", { awaitInput: "file", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount: 0 }); } await sendText.call(this, "現在対象外です"); return buildResult("sent", RESET_STATE); } if (text === "再検査について") { if (currentStatus === 800) { await sendButtons.call(this, "再検査の結果についてご報告をお願いします。", withContact([ { type: "message", label: "受診・再検査報告", text: "受診・再検査報告" }, ])); return buildResult("sent", RESET_STATE); } await sendText.call(this, "現在対象外です"); return buildResult("sent", RESET_STATE); } if (text === "受診・再検査報告") { if (currentStatus === 800) { await sendText.call(this, "再検査の結果について、以下を順番にお伺いします。\na. 再検査受診日を教えてください"); return buildResult("sent", { awaitInput: "report_form", awaitProcessId: "", awaitColumn: "a", formDraft: JSON.stringify({}), failCount: 0 }); } await sendText.call(this, "現在対象外です"); return buildResult("sent", RESET_STATE); } ``` `yesno_confirm`のボタンラベルは`attendance_310`用途では「受診完了/未受診」だが、`state.awaitInput`は既存の仕組みに合わせ`text`の値も"受診完了"/"未受診"に統一する(Step2で対応する既存の`text === "はい"`判定を書き換える)。 - [ ] **Step 2: `yesno_confirm`の入口ガード(Task3 Step7)にattendance_310用の選択肢も反映する** Task3 Step7の`validAnswers`分岐に`attendance_310`を追加: ```javascript const draft = JSON.parse(state.formDraft || "{}"); const isScheduleFlow = draft.purpose === "schedule"; const isAttendanceFlow = draft.purpose === "attendance_310"; const validAnswers = isScheduleFlow ? ["承諾", "再入力"] : isAttendanceFlow ? ["受診完了", "未受診"] : ["はい", "いいえ"]; if (contentType !== "text" || !validAnswers.includes(text)) { const actions = isScheduleFlow ? [{ type: "message", label: "承諾", text: "承諾" }, { type: "message", label: "再入力", text: "再入力" }] : isAttendanceFlow ? [{ type: "message", label: "受診完了", text: "受診完了" }, { type: "message", label: "未受診", text: "未受診" }] : [{ type: "message", label: "はい", text: "はい" }, { type: "message", label: "いいえ", text: "いいえ" }]; await sendButtons.call(this, "ボタンからお選びください。", withContact(actions)); return buildResult("sent"); } ``` - [ ] **Step 3: `attendance_310`の分岐(301-309行目)を全面改修する** ```javascript if (draft.purpose === "attendance_310") { if (text === "受診完了") { await pleasanterPost.call(this, `api/items/${resultId}/update`, { ProcessId: PROCESS_ID.REPORT_VISITED }); await sendText.call(this, "健康診断の受診、ありがとうございます。診断結果を受け取りましたら、PDFスキャンをしてこのトーク画面へ送信してください。"); return buildResult("sent", RESET_STATE); } // 未受診 await sendButtons.call(this, "改めて受診日程を確保する方法をお選びください。", withContact([ { type: "message", label: "再手配", text: "再手配" }, { type: "message", label: "自分手配", text: "自分手配" }, ])); return buildResult("sent", { awaitInput: "retest_choice", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount: 0 }); } ``` - [ ] **Step 4: 新規状態`retest_choice`のハンドラを追加する** Task3で改修した`schedule_confirm`ブロックの直後(旧`yesno_confirm`ブロックの前)に追加: ```javascript // ==== 4.5. awaitInput = retest_choice ==== if (state.awaitInput === "retest_choice") { if (contentType !== "text" || (text !== "再手配" && text !== "自分手配")) { await sendButtons.call(this, "「再手配」または「自分手配」からお選びください。", withContact([ { type: "message", label: "再手配", text: "再手配" }, { type: "message", label: "自分手配", text: "自分手配" }, ])); return buildResult("sent"); } if (text === "自分手配") { // 未受診→自分手配も最終的にStatus400(診断書保存フローへ)へ進める必要があるため、 // 検査機関変更とPROCESS_ID.REPORT_VISITED(310→400)実行を同時に行う。 await pleasanterPost.call(this, `api/items/${resultId}/update`, { ProcessId: PROCESS_ID.REPORT_VISITED, ClassHash: { Class003: "513480" } }); await sendText.call(this, "ご自分で検査機関を手配して受診されるとのことで承知しました。診断結果を受け取りましたら、こちらのトーク画面へ送信してください。"); return buildResult("sent", RESET_STATE); } // 再手配 const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const message = await buildHospitalContactMessage.call(this, recData.ClassHash.Class003); await sendText.call(this, message + "\n\n再度受診日時が決まりましたら、日時をお知らせください。"); return buildResult("sent", { awaitInput: "reschedule_input", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount: 0 }); } ``` - [ ] **Step 5: 新規状態`reschedule_input`のハンドラを追加する** ```javascript // ==== 4.6. awaitInput = reschedule_input ==== if (state.awaitInput === "reschedule_input") { if (contentType !== "text") { await sendText.call(this, "受診日時についてはテキストでお答えください"); return buildResult("sent"); } try { const parsedDate = parseDateInput(text); await sendButtons.call(this, `${parsedDate}でよろしいですか?`, withContact([ { type: "message", label: "承諾", text: "承諾" }, { type: "message", label: "再入力", text: "再入力" }, ])); return buildResult("sent", { awaitInput: "yesno_confirm", awaitProcessId: "", awaitColumn: "", formDraft: JSON.stringify({ purpose: "reschedule", date: parsedDate }), failCount: 0 }); } catch (err) { const failCount = (state.failCount || 0) + 1; if (failCount >= 3) { await sendText.call(this, "日付を認識できないため、いったん受付を中断します。固定メニューから「受診結果について」を選び直してください"); return buildResult("sent", RESET_STATE); } await sendText.call(this, err.message); return buildResult("sent", { awaitInput: "reschedule_input", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount }); } } ``` - [ ] **Step 6: `yesno_confirm`のvalidAnswers・分岐へ`reschedule`purposeを追加する** Step2の`validAnswers`分岐に`reschedule`を`schedule`と同じ扱いで追加し(承諾/再入力の選択肢は同じ)、Task3 Step6の`draft.purpose === "schedule"`ブロックの直後に以下を追加: ```javascript if (draft.purpose === "reschedule") { if (text === "承諾") { const recordRes = await pleasanterPost.call(this, `api/items/${resultId}/get`, {}); const recData = Array.isArray(recordRes.Response.Data) ? recordRes.Response.Data[0] : recordRes.Response.Data; const newDescriptionB = replaceDateLineInDescription(recData.DescriptionHash.DescriptionB, formatDateTimeSlash(draft.date)); await pleasanterPost.call(this, `api/items/${resultId}/update`, { DateHash: { Date002: draft.date }, DescriptionHash: { DescriptionB: newDescriptionB }, }); await sendText.call(this, "受診日時を更新しました。"); return buildResult("sent", RESET_STATE); } await sendText.call(this, "もう一度、受診日時を入力してください。"); return buildResult("sent", { awaitInput: "reschedule_input", awaitProcessId: "", awaitColumn: "", formDraft: "", failCount: 0 }); } ``` `reschedule`確定時は`ProcessId`を指定しない(Status310のまま変更しない、Date002とDescriptionBのみ更新)。これはTask0のStep3で実機確認した「ProcessId無しのDateHash更新が許可されるか」の結果に依存する。許可されない場合は、Status310→310の自己遷移Processを新設する必要がある(その場合はTask1と同様の手順でPleasanter画面から手動登録する)。 - [ ] **Step 7: Step2の`validAnswers`分岐を完成させる(purpose横断の最終形)** ```javascript const draft = JSON.parse(state.formDraft || "{}"); const purpose = draft.purpose; const OPTIONS = { schedule: [{ label: "承諾", text: "承諾" }, { label: "再入力", text: "再入力" }], reschedule: [{ label: "承諾", text: "承諾" }, { label: "再入力", text: "再入力" }], attendance_310: [{ label: "受診完了", text: "受診完了" }, { label: "未受診", text: "未受診" }], }; const options = OPTIONS[purpose] || [{ label: "はい", text: "はい" }, { label: "いいえ", text: "いいえ" }]; const validAnswers = options.map((o) => o.text); if (contentType !== "text" || !validAnswers.includes(text)) { await sendButtons.call(this, "ボタンからお選びください。", withContact(options.map((o) => ({ type: "message", label: o.label, text: o.text })))); return buildResult("sent"); } ``` (Task3 Step7で仮に書いた分岐はこのStepで置き換える。Task3実施時点では`schedule`のみ、Task4実施時点で`reschedule`・`attendance_310`を追加、という積み上げ) - [ ] **Step 8: PUT反映、Selenium確認** Task2 Step3/Task3 Step8と同じ手順でPUTし、513409の編集画面が正常に開くことをSeleniumで確認する。 - [ ] **Step 9: 実機トークで3シナリオを順番に確認する(都度Task0 Step5の復元スクリプトで巻き戻す)** Task3 Step9と同様、シナリオごとに`_restore_513409.js`で巻き戻してから次を試す。このタスクのシナリオはいずれもStatus=310の状態から開始する(Task3のシナリオA1で確定済みの状態がベース)。 **シナリオB1: 受診完了ルート** 1. Status=310、Date002確定済みであることを確認 2. ユーザーが「健康診断について」→「受診結果について」をタップ → 「受診完了」「未受診」ボタンが出ることを確認 3. 「受診完了」をタップ → 案内メッセージが届く 4. APIでStatus=400になっていることを確認 5. 巻き戻す **シナリオB2: 未受診→再手配ルート** 1. Status=310に戻っていることを確認 2. 「受診結果について」→「未受診」をタップ → 「再手配」「自分手配」ボタンが出ることを確認 3. 「再手配」をタップ → 検査機関情報+「日時をお知らせください」案内が届く 4. ユーザーが適当な日付を送信 → 「{日付}でよろしいですか?」承諾/再入力ボタン確認 5. 「承諾」をタップ → 「受診日時を更新しました。」 6. APIでDate002が新しい日付、DescriptionBの日時行も更新、**Statusは310のまま変わっていない**ことを確認(Task0 Step3で確認したProcessId無し更新の挙動通りか) 7. 巻き戻す **シナリオB3: 未受診→自分手配ルート** 1. Status=310に戻っていることを確認 2. 「受診結果について」→「未受診」→「自分手配」をタップ 3. APIでStatus=400、ClassHash.Class003="513480"になっていることを確認 4. 巻き戻す **シナリオB4: 診断書提出フローへの導線確認(Status=400)** 1. シナリオB1実行直後(巻き戻し前、Status=400のまま)に、「健康診断について」→「受診結果について」をタップ 2. 診断書アップロード案内(既存文言のまま)が届くことを確認 3. 巻き戻す - [ ] **Step 10: Commit** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && rm -f _wf_hcwa.json _branch_next.js ``` --- ## Task 5: HC-REMINDER — ボタンラベル変更 **Files:** - Modify: n8n `kyyd1BHpUJkJSN3W`(HC-REMINDER) ノード「対象レコード取得・分類」、`kind: "three"`ブロック(59-71行目相当) **Interfaces:** - Consumes: なし - Produces: `actions`配列のlabelを`"受診完了"`/`"未受診"`に変更(HC-WA側のTask4 Step3が`text === "受診完了"`で判定するため、`text`もあわせて変更する必要がある) - [ ] **Step 1: 現在のコードを取得する** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && node -e " const KEY = ''; const fs = require('fs'); (async () => { const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows/kyyd1BHpUJkJSN3W', { headers: { 'X-N8N-API-KEY': KEY } }); const j = await res.json(); fs.writeFileSync('./_wf_reminder.json', JSON.stringify(j)); const n = j.nodes.find(nn => nn.name === '対象レコード取得・分類'); fs.writeFileSync('./_reminder_next.js', n.parameters.jsCode); })(); " ``` - [ ] **Step 2: `kind: "three"`ブロックのactions・awaitInput予約用formDraftを書き換える** ```javascript targets.push({ resultId: r.ResultId, pleasanterUserId, kind: "three", messageText: "受診できましたでしょうか", actions: [ { type: "message", label: "受診完了", text: "受診完了" }, { type: "message", label: "未受診", text: "未受診" }, ], setAwaitInput: "yesno_confirm", setFormDraft: JSON.stringify({ purpose: "attendance_310" }), }); ``` (既存コードとの差分は`label`/`text`の文字列のみ。`setAwaitInput`/`setFormDraft`は変更なし) - [ ] **Step 3: PUT反映する** Task2 Step4と同じ手順、workflowIdを`kyyd1BHpUJkJSN3W`に変える。 - [ ] **Step 4: HC-REMINDERは現在inactiveなので、手動実行はできない(Public APIに`/run`が無い)。ロジック確認は次善策としてコードを目で追い、`text: "受診完了"`がHC-WA Task4 Step3の判定と一致していることを確認する** ```bash grep -n '"受診完了"' _reminder_next.js ``` 出力が1件出ること。HC-WA側の`_branch_next.js`(Task4完了時点のもの)にも同じ文字列があることを両方grepして突き合わせる。 - [ ] **Step 5: Commit** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && rm -f _wf_reminder.json _reminder_next.js ``` --- ## Task 6: design-fixed-menu.mdへ実装完了ログを追記する **Files:** - Modify: `Pleasanter/健康診断管理/docs/n8n/design-fixed-menu.md`(末尾に追記) - [ ] **Step 1: 実装内容のサマリを追記する** `design-fixed-menu.md`の末尾に、`2026-09-10-bot-flow-redesign.md`への参照と、実際にPUTしたタイムスタンプ・確認済み事項を記録する(既存の「n8n実装(フェーズ2)完了」節と同じ形式)。 - [ ] **Step 2: Commit** ```bash cd "/c/Users/k.nogi/#GitHub/ken_nogi/dev" && git add "Pleasanter/健康診断管理/docs/n8n/design-fixed-menu.md" git commit -m "docs(healthcheck): Botトークフロー再設計の実装完了を記録" ``` --- ## Task 7: 全Webhookの本番反映判断 Task1〜6完了後、健康診断関連の全Webhook(HC-WA/HC-SETUP/HC-FORM-SYNC/HC-WP、いずれも2026-09-11時点で`inactive`)を本番稼働状態に戻すかどうかの最終判断。 **Files:** なし(n8n active状態の変更のみ) - [ ] **Step 1: 健康診断関連ワークフロー全件のactive状態を一覧確認する** ```bash node -e " const KEY = ''; (async () => { const res = await fetch('https://n8n32.next-hd.net/api/v1/workflows?limit=100', { headers: { 'X-N8N-API-KEY': KEY } }); const j = await res.json(); for (const w of j.data) if (w.name.startsWith('HC-')) console.log(w.id, w.name, 'active=', w.active); })(); " ``` - [ ] **Step 2: ユーザーへ本番反映(activate)可否を確認する** Task1〜6の実機検証(野木さん513409、全シナリオ)がすべて成功していることを前提に、「HC-WA/HC-SETUP/HC-FORM-SYNC/HC-WPを本番activateしてよいか」をユーザーへ確認する。HC-REMINDER/HC-DOC-TIMEOUTは元々2026-09-07時点から「実運用開始タイミングで別途判断」の対象(このタスクのスコープ外、ユーザーが別途判断)。 - [ ] **Step 3: 承認を得たワークフローのみactivateする** ```bash node -e " const KEY = ''; const ids = ['0i0Ze3Gq0Waof3Fa', '0jVVF9WGlZrAygXX', 'G2nWfvPCsIKpup60', 'swXfpoDtwZDT3Hwk']; // HC-WA/HC-SETUP/HC-FORM-SYNC/HC-WP (async () => { for (const id of ids) { const res = await fetch(\`https://n8n32.next-hd.net/api/v1/workflows/\${id}/activate\`, { method: 'POST', headers: { 'X-N8N-API-KEY': KEY } }); const j = await res.json(); console.log(j.name, 'active=', j.active); } })(); " ``` --- ## Self-Review メモ - Task0で洗い出した4項目は、それぞれTask1 Step4(Process起点Status)、Task3 Step1-3(DescriptionB置換・ResultIdフィルタ書式)、Task4 Step6(ProcessId無し更新の可否)で実際に検証・反映される導線を確保した。 - 診断書保存フロー・再検査フローのa〜d収集ロジックは仕様書通り「既存のまま変更なし」のため、本計画にタスクを設けていない(意図的な除外)。 - HC-DOC-TIMEOUTワークフローは今回変更対象外(仕様書に変更点なし)。 - **2026-09-11追加分**: 着手前にresultId型エラー・"利用開始"イベント無条件応答の2バグを本番HC-WAへ緊急修正済み(Global Constraints・Task0 Step0参照)。この2件のロジックはTask2〜4の改修対象範囲(「健康診断について」以降の分岐)より前段に位置するため、Step1のGET→差分編集という手順を守る限り自然に保持される。Task7で本番activateの最終判断ステップを新設(旧計画にはなかった、全Webhook停止状態からの再開手順が欠けていたため)。