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