ken_nogi/Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md
Kenichiro NOGI 95f2acba85 健康診断管理Botトークフロー再設計の実装計画を追加
パターンA(受診日時)・パターンB(受診結果確認、新規)の実装タスクと、
508971実データ1件を使い回す実機トークテスト手順(巻き戻しスクリプト込み)を明記。
2026-09-10 14:21:51 +09:00

55 KiB
Raw Blame History

健康診断管理 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を取得し、実際の改行・文字コードを確認する

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の該当箇所を書き換える

180-191行目(旧):

  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 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オブジェクトに追記:

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: 承諾ルート

  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上のみの変更なのでリポジトリへのコミットは無いが、ローカル一時ファイルを削除する:

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_choicereschedule_inputを追加。

Interfaces:

  • Consumes: Task3のbuildHospitalContactMessagereplaceDateLineInDescriptionformatDateTimeSlashparseDateInput

  • 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分岐にreschedulescheduleと同じ扱いで追加し(承諾/再入力の選択肢は同じ)、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実施時点でrescheduleattendance_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
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トークフロー再設計の実装完了を記録"

Self-Review メモ

  • Task0で洗い出した4項目は、それぞれTask1 Step4(Process起点Status)、Task3 Step1-3(DescriptionB置換・ResultIdフィルタ書式)、Task4 Step6(ProcessId無し更新の可否)で実際に検証・反映される導線を確保した。
  • 診断書保存フロー・再検査フローのa〜d収集ロジックは仕様書通り「既存のまま変更なし」のため、本計画にタスクを設けていない(意図的な除外)。
  • HC-DOC-TIMEOUTワークフローは今回変更対象外(仕様書に変更点なし)。