diff --git a/Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md b/Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md new file mode 100644 index 00000000..952f6c05 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md @@ -0,0 +1,954 @@ +# 健康診断管理 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. + +**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 + +- 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 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`の該当箇所を書き換える** + +180-191行目(旧): +```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 4: 野木さん(513409、現在Status=310)の実機トークで確認する** + +固定メニュー「健康診断について」をタップし、「受診日時について」「受診結果について」の2択(Status=310なので両方出る)が表示されることを確認する。まだ中身は未実装なので、押しても`if`に一致せず`buildResult("noop")`扱いになる(何も返らない)想定。ここでは**ボタンの見た目だけ**確認する。 + +--- + +## Task 3: HC-WA branch.js — 受診日時について(パターンA) + +**Files:** +- Modify: 同上ノード。193-214行目(旧`健診日程`ブロック)と244-280行目(`schedule_confirm`状態)を全面改修。 + +**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: 同上ノード。216-239行目(旧`受診・再検査報告`/`診断書提出`ブロック)、301-309行目(`attendance_310`のyesno_confirm処理)を全面改修。新規状態`retest_choice`・`reschedule_input`を追加。 + +**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トークフロー再設計の実装完了を記録" +``` + +--- + +## Self-Review メモ + +- Task0で洗い出した4項目は、それぞれTask1 Step4(Process起点Status)、Task3 Step1-3(DescriptionB置換・ResultIdフィルタ書式)、Task4 Step6(ProcessId無し更新の可否)で実際に検証・反映される導線を確保した。 +- 診断書保存フロー・再検査フローのa〜d収集ロジックは仕様書通り「既存のまま変更なし」のため、本計画にタスクを設けていない(意図的な除外)。 +- HC-DOC-TIMEOUTワークフローは今回変更対象外(仕様書に変更点なし)。