62 KiB
健康診断管理 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として最初に潰す。
- DescriptionB書き換え正規表現の妥当性: 実際のDescriptionB文字列(
医療機関:...\n日時:2026/09/08 10:11\n...)に対し、「日時:」行だけを確実に置換できるか。複数行文字列・改行コードの実機値をPleasanterから取得して検証する。 - Process「自分手配」の起点Status: 「最初から自分手配とわかっている社員」がクリックするタイミングは、CurrentStatus=250(申込中)想定で問題ないか。250以外(200/210等)からでも押せる必要があるか、現場運用を踏まえて確認する。
- 再手配時のDate002更新でStatus遷移が要るか: 「未受診→再手配」で新日程が決まった際、ProcessId指定なしの
items/updateでDateHash.Date002だけを更新する設計にする(Status310のまま変更なしなのでProcess実行は不要という想定)。この想定が正しいか、Pleasanter側のバリデーション(Process未経由でのDate列更新が許可されているか)を実機で確認する。 - 「受診結果について」ボタンの二重定義: 現行実装では「受診・再検査報告」ボタンが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本番に反映済みであることを確認する
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を取得し、実際の改行・文字コードを確認する
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: 置換関数を書いてローカルで検証する
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確定済み)をスナップショットとして保存し、任意のタイミングで復元できるスクリプトを用意する。
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画面で用意する代替案を検討する):
// _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経由で追記するスクリプトを書く
// 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の最新設定をローカルへ再取得してから実行する
cd Pleasanter && node .claude/js/get-site-config.js --project="健康診断管理" --env=production
(ユーザーがStep1で画面登録した直後の最新状態をローカルキャッシュに反映させるため。省略するとローカルの古いProcesses配列で上書きしてしまう)
- Step 3: プレビュー実行して内容を確認する
cd Pleasanter && node ".claude/js/site-scripts/健康診断管理/add-process-jibunanpai-508971.js"
Expected: Process「自分手配」(Id:18) に Class003=513480(自分手配) のDataChangesを追加します。 (--execute 未指定のためプレビューのみ) のような出力(Idは実際に採番された値)。
- Step 4: ユーザーに実行可否を確認してから本番反映する
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個別レコード編集画面が正常に開くか確認する
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
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から取得してローカルへ保存する
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行目相当(旧):
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);
}
新:
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で反映する
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してよいかユーザーに確認してから実施する:
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オブジェクトに追記:
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関数の直後に追記:
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件取得し、病院名・電話番号・担当者を組み立てる:
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指定フィルタの書式を実機で確認する
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行目を置き換え:
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行目を置き換え:
// ==== 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行目)をボタンラベル「承諾/再入力」対応に書き換える
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別に分岐させる
// ==== 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: 承諾ルート
- Status=300であることをAPIで確認
- ユーザーが固定メニュー「健康診断について」→「受診日時について」をタップ → 候補日提示+「承諾」「受診日時の変更」「自分手配」ボタンが出ることを確認
- ユーザーが「承諾」をタップ → 「日程を確定しました。」が届く
- APIで508971のStatus=310、Date002=Date001と同じ値、DescriptionBの「日時:」行が確定日になっていることを確認
_restore_513409.jsで巻き戻す
シナリオA2: 変更→自由入力→承諾ルート
- Status=300に戻っていることを確認(Step5の巻き戻し後、管理者に画面操作を依頼)
- 「受診日時について」→「受診日時の変更」をタップ → 検査機関情報(病院名/電話番号/担当者)が届くことを確認
- ユーザーが適当な日付(例:
9/20)をテキスト送信 → 「2026-09-20でよろしいですか?」に「承諾」「再入力」ボタンが出ることを確認 - 「承諾」をタップ → 「日程を確定しました。」。APIでDate002=2026-09-20、DescriptionBの日時行が
2026/09/20になっていることを確認 - 巻き戻す
シナリオA3: 自分手配ルート
- Status=300に戻っていることを確認
- 「受診日時について」→「自分手配」をタップ
- APIでStatus=400、ClassHash.Class003="513480"(自分手配)になっていることを確認
- 巻き戻す
シナリオA4: パース失敗3回の強制中断
- Status=300に戻っていることを確認
- 「受診日時について」→「受診日時の変更」をタップ
- ユーザーが認識不能な文字列(例:
あいうえお)を3回連続送信 - 3回目で「日付を認識できないため、いったん受付を中断します。固定メニューから「受診日時について」を選び直してください」が届くことを確認
healthcheck_bot_stateの該当行がawaitInput: "none"にリセットされていることをn8n Data Table APIで確認- 巻き戻す(このシナリオはStatus/Date002を変更していないので巻き戻し不要な場合もあるが、
healthcheck_bot_stateの状態だけ念のため確認する)
- Step 10: Commit
n8n上のみの変更なのでリポジトリへのコミットは無いが、ローカル一時ファイルを削除する:
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行目(旧受診・再検査報告+診断書提出)を置き換え:
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を追加:
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行目)を全面改修する
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ブロックの前)に追加:
// ==== 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のハンドラを追加する
// ==== 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・分岐へreschedulepurposeを追加する
Step2のvalidAnswers分岐にrescheduleをscheduleと同じ扱いで追加し(承諾/再入力の選択肢は同じ)、Task3 Step6のdraft.purpose === "schedule"ブロックの直後に以下を追加:
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横断の最終形)
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: 受診完了ルート
- Status=310、Date002確定済みであることを確認
- ユーザーが「健康診断について」→「受診結果について」をタップ → 「受診完了」「未受診」ボタンが出ることを確認
- 「受診完了」をタップ → 案内メッセージが届く
- APIでStatus=400になっていることを確認
- 巻き戻す
シナリオB2: 未受診→再手配ルート
- Status=310に戻っていることを確認
- 「受診結果について」→「未受診」をタップ → 「再手配」「自分手配」ボタンが出ることを確認
- 「再手配」をタップ → 検査機関情報+「日時をお知らせください」案内が届く
- ユーザーが適当な日付を送信 → 「{日付}でよろしいですか?」承諾/再入力ボタン確認
- 「承諾」をタップ → 「受診日時を更新しました。」
- APIでDate002が新しい日付、DescriptionBの日時行も更新、Statusは310のまま変わっていないことを確認(Task0 Step3で確認したProcessId無し更新の挙動通りか)
- 巻き戻す
シナリオB3: 未受診→自分手配ルート
- Status=310に戻っていることを確認
- 「受診結果について」→「未受診」→「自分手配」をタップ
- APIでStatus=400、ClassHash.Class003="513480"になっていることを確認
- 巻き戻す
シナリオB4: 診断書提出フローへの導線確認(Status=400)
- シナリオB1実行直後(巻き戻し前、Status=400のまま)に、「健康診断について」→「受診結果について」をタップ
- 診断書アップロード案内(既存文言のまま)が届くことを確認
- 巻き戻す
- Step 10: Commit
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: 現在のコードを取得する
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を書き換える
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の判定と一致していることを確認する
grep -n '"受診完了"' _reminder_next.js
出力が1件出ること。HC-WA側の_branch_next.js(Task4完了時点のもの)にも同じ文字列があることを両方grepして突き合わせる。
- Step 5: Commit
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
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状態を一覧確認する
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する
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停止状態からの再開手順が欠けていたため)。