ken_nogi/Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md

1038 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 健康診断管理 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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 = '<NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API 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停止状態からの再開手順が欠けていたため)。