Compare commits

...

97 Commits

Author SHA1 Message Date
Kenichiro NOGI
76700845fe feat: get-voice-mail app-portalキッカー追加、app-portal UIをBootstrap化
get-voice-mailはn8n Webhookへの中継専用アプリ(SCP取得・プリザンター書込はn8n側で実施)。
app-portalはBootstrap 5導入で見た目を刷新(カード/モーダル/トースト化)。
dokploy-webapp SKILLはGitea統合(NodeSrv→ken_nogi)後の設定変更を反映。
2026-09-19 11:06:02 +09:00
Kenichiro NOGI
4b8498ba2d chore: callRestApiのデバッグログをコメントアウト(削除せず必要時に復活できるよう保持)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:09:58 +09:00
Kenichiro NOGI
0544365276 fix: テーブル書込みをDataBodyRangeプロパティに依存しない実装に全面変更
DataBodyRangeは、直前と同一サイズへのResizeだけでなくヘッダ+1データ行という特定サイズへのResizeでも
内部状態が更新されずNothingのままになるケースがあり、tempRows経由の2段階Resizeでも解消しなかった。
根本対応として、exportCSVDataToSheet/getRecordsDataToSheet/exportCSVDataToExistingTableの3関数で
DataBodyRangeを一切使わず、tbl.Range(ヘッダ含む全体範囲)から直接Offset/Resizeで計算したRangeに
クリア・書き込みを行う方式に統一。調査用デバッグログも整理した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:07:02 +09:00
Kenichiro NOGI
97f371db3f debug: D3セルの状態メッセージをイミディエイトウィンドウにも出力
処理開始/完了/失敗のD3表示内容を[D3]プレフィックス付きでDebug.Printし、フリーズ調査時にログだけで状態を追えるようにした。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:01:48 +09:00
Kenichiro NOGI
49ef4c5358 fix: テーブルResizeでヘッダのみ(1行)への一時縮小がExcelにエラーとして拒否される問題を修正
前回のDataBodyRange Nothing対策で「一旦ヘッダ行のみにリサイズ」する方式にしたところ、
「テーブルには1行以上のデータが必要」という理由でExcel側にResize自体を拒否されるケースがあった。
現在の行数・目的の行数のいずれとも確実に異なる一時サイズ(rows.count+現在行数+1等)を経由する方式に変更し、
常に1行以上のデータ行を含んだ状態でリサイズすることで回避。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:01:05 +09:00
Kenichiro NOGI
e879b0f584 feat: runExportFlowにもGet方式と同じFilter/Sorter条件を適用
exportCSVDataのOptional viewObj引数にBuildViewFromSheet()の結果を渡すことで、
FilterTable/SorterTableの絞り込み・並び替え条件がExport方式にも反映されるようにした。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:57:06 +09:00
Kenichiro NOGI
a60244b7b0 fix: テーブルResize時にDataBodyRangeがNothingのままになる問題を修正
Excelのテーブル(ListObject)は、直前と同一サイズへのResizeを行うとDataBodyRangeの内部状態が更新されないことがあり、
データ行数が変わらない場合(前回0件クリア後に1件だけ取得した場合など)にDataBodyRange.Value代入が効かず、
テーブルにデータが書き込まれない不具合があった。
exportCSVDataToSheet/getRecordsDataToSheet/exportCSVDataToExistingTableの3箇所で、
一旦ヘッダ行のみにリサイズしてから目的のサイズにリサイズし直すことで回避。
調査用の詳細デバッグログ([DEBUG] records.count等)も合わせて追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:56:37 +09:00
Kenichiro NOGI
67b9f8c0f8 fix: Export/Get方式とも0件取得時にテーブルのデータ部分をクリアするよう修正
従来は0件時にMsgBoxを出して即終了し、テーブルの前回データがそのまま残ってしまっていた。
runGetFlowはrecords.count=0でもgetRecordsDataToSheetを呼ぶよう変更し、getRecordsDataToSheet/exportCSVDataToSheetは0件時にMsgBoxを出さずヘッダを維持したままDataBodyRangeだけクリアするよう修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:44:42 +09:00
Kenichiro NOGI
06340cdb7b debug: レスポンスステータス・本文全文を出力するデバッグコードを追加
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:39:03 +09:00
Kenichiro NOGI
7745fc6858 debug: リクエストボディ出力ログを再度復活
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:38:05 +09:00
Kenichiro NOGI
413de865f0 fix: 入力チェックの名前付き範囲を「物件状況」から正しい「選択状況」に修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:34:10 +09:00
Kenichiro NOGI
51e1e166d4 chore: リクエストボディのデバッグログを削除
Filter/Sorter動作確認が完了したため撤去。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:31:17 +09:00
Kenichiro NOGI
b9150c0461 fix: 選択営業所/物件状況チェックの型不一致エラー(実行時エラー13)を解消
Range(...).Valueが配列やエラー値を返すケースでTrimが型不一致エラーになっていた。
Cells(1,1)で単一セル値を取得し、IsErrorチェック+CStrで文字列化してから判定するよう修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:29:48 +09:00
Kenichiro NOGI
bc03b2d47b feat: run()実行前に「選択営業所」「物件状況」の入力チェックを追加
名前付き範囲が空欄の場合はアラート表示して処理中断する。runSalesOfficeExportには適用しない。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:28:09 +09:00
Kenichiro NOGI
d7a8457114 fix: exportCSVDataToSheetが既存テーブルでもヘッダ行を毎回CSVの最新列構成で更新するよう修正
従来は新規テーブル作成時のみヘッダを設定し、既存テーブルがある場合はヘッダを書き換えていなかったため、
Pleasanter側で列の追加・削除・並び替えが起きるとヘッダラベルとデータ列がズレる問題があった。
getRecordsDataToSheet(Get方式)と同様、テーブルの有無に関わらず毎回CSVの1行目でヘッダを上書きするよう変更。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:12:31 +09:00
Kenichiro NOGI
13cad20ead fix: exportCSVDataToExistingTableをシート名指定からテーブル名検索(FindListObjectByName)に変更
「営業所」テーブルがどのシートにあってもブック全体から見つけられるよう、既存のFindListObjectByNameを再利用する方式に統一。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:02:27 +09:00
Kenichiro NOGI
6dc6ede9a9 fix: 営業所マスタExportを既存テーブルへの上書き専用に変更、新規シート・テーブル作成を廃止
exportCSVDataToSheetはtargetSheetと同名のテーブルを探す仕様のため、実際のテーブル名が一致せず新規シート・テーブルが作られてしまっていた。
exportCSVDataToExistingTableを新規追加し、テーブル名を問わず「営業所」シート上の既存テーブル(1つ目)に直接上書きする方式に変更。シート・テーブルが存在しない場合は新規作成せずエラー表示のみ。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:01:00 +09:00
Kenichiro NOGI
e455fe0a21 feat: runSalesOfficeExportにNumA昇順のソート条件を固定で追加
exportCSVDataにOptionalなviewObj引数を追加し、営業所Export専用にColumnSorterHash(NumA:asc)を渡すよう変更。
既存のrunExportFlowはviewObj省略のためViewなしのまま、既存動作に影響なし。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:58:00 +09:00
Kenichiro NOGI
70d9eb974b feat: 営業所マスタ(96243)Export専用の独立Sub runSalesOfficeExportを追加
既存run()とは別の独立エントリポイント。テーブルID 96243をExport方式(ExportId=1)で全件取得し「営業所」シートの既存テーブルを更新する。
Filter/Sorterは使わない。exportCSVDataToSheetが既存テーブルのヘッダを書き換えずデータ部分のみ更新する仕様をそのまま利用し、CSVのヘッダ行は自動的に捨てられる。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:56:02 +09:00
Kenichiro NOGI
631e66242f fix: FilterTable/SorterTableを基本情報シート限定でなく全シートから名前で検索するよう修正
defaultSh(基本情報シート)固定で探していたため、ユーザーが別シートにテーブルを配置した際にBuildViewFromSheetが見つけられずViewが常に空になっていた。
FindListObjectByNameを追加し、ブック内の全シートを走査してテーブル名で検索するよう変更。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:48:41 +09:00
Kenichiro NOGI
d83d74afdc debug: リクエストボディ出力ログを復活
検証がまだ終わっていない段階で削除してしまったため復活。Filter/Sorter動作確認が完了するまで残す。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:44:38 +09:00
Kenichiro NOGI
1eb1a7dc87 fix: BuildViewFromSheetをセル座標指定からテーブル名(FilterTable/SorterTable)参照に変更
Filter表・Sorter表の配置をユーザーが移動したところ、固定セル座標(F3/J3)読み取りのため条件が反映されずViewが空になっていた。
ListObjects("FilterTable")/("SorterTable")をテーブル名で取得し、1列目=項目名・2列目=値/昇降順という列位置ベースで読むよう変更。テーブルをシート上のどこに配置しても動作する。
デバッグ用に追加していたリクエストボディ出力ログも削除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:43:27 +09:00
Kenichiro NOGI
3bfdf3b73e fix: Filter値をJSON配列文字列(["値"])として渡すよう修正
Pleasanter ColumnFilterHashは選択肢項目の値をJSON配列文字列で受け取る仕様のため、単純な値のままでは条件が無視され全件返っていた。単一値を1要素配列にラップして解消。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 18:33:10 +09:00
Kenichiro NOGI
460b540997 feat: Filter/Sorter条件をGet方式のみに適用、Export方式からは除外
exportCSVDataのBuildViewFromSheet呼び出しを削除。Export方式は常に全件取得、Get方式のみ基本情報シートのFilter表・Sorter表を反映する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 18:09:07 +09:00
Kenichiro NOGI
a423b5fd6f docs: Process4のValidateInputsが無くてもServerScript+条件タブで入力漏れが構造的に防げることを確認
CheckC(日程情報入力済)がServerScriptにより3項目全入力時のみtrueになり、
Process4の条件タブがCheckC=true限定のため、Process5実行時点で入力漏れは起きない。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:57:02 +09:00
Kenichiro NOGI
99d4bb1b34 fix: Num047列がProcess4「検査機関確定」用に既存予約されていたのに気づかず衝突、Num048へ分離
Process19用カウンタ追加時に「空き番号」と誤認して既存予約と衝突していた。
Num047を本来の用途(検査機関確定)へ戻し、診断書再提出依頼はNum048を
新規追加して分離。両サイトで実機確認済み。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:53:58 +09:00
Kenichiro NOGI
44a08c2753 fix: 508971の既存12件でClassZ・ClassD未入力を発見、504412から補完してファイル名歯抜けを解消
実施年度エリア・フリガナが空のまま運用すると、診断書PDFファイル名や
通知文が歯抜けになる不具合を事前に発見。社員マスタから姓・名カナを
照合して全件補完した。必須化はユーザー側で後日設定する方針。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:48:26 +09:00
Kenichiro NOGI
326307ad0e feat: Process19「診断書再提出依頼」にNum047カウンタ列を追加、集計テーブルへも反映
既存17Processと同じパターンでカウンタ列・DataChanges・Summaries設定を
508971/513156両サイトへ追加し、実機でフラグ立ちと集計テーブルへの
反映を確認した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:37:42 +09:00
Kenichiro NOGI
70492700e7 docs: HC-DOC-TIMEOUT重複バグ修正の再検証完了を記録
修正反映後の実行履歴でtargetsが正しく1件になることを確認、実機でも
1通のみ届くことを確認した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:32:21 +09:00
Kenichiro NOGI
f9e85a29f5 fix: HC-DOC-TIMEOUT実機テストでn8n Data Tableノードの重複返却バグを発見・修正
returnAll:trueで取得した行が45件重複して返る不具合を確認。複数resultIdを
forループで個別処理する設計だったため実害リスクがあり、resultIdでの
ユニーク化を追加した。他ワークフローは最初の1件のみ参照または集約する
設計のため影響なしと確認。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:13:52 +09:00
Kenichiro NOGI
5e7bcb64be fix: HC-REMINDER実機テストでDate004列欠如と⑥文言バグを発見・修正、②③⑥⑦全パターン確認完了
⑦日程未確定の催促が実装当初から一度も動作していなかった根本原因(Date004列が
508971に未定義)を特定し、列追加+EditorColumnHash配置で解消。⑥再検査催促の
文言も⑦と同じ導線修正漏れがあったため合わせて修正。n8nコンテナのタイムゾーン
(Asia/Tokyo)も確認し、Schedule Triggerの発火時刻に問題がないことを確認した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:20:23 +09:00
Kenichiro NOGI
21b1593344 docs: Process→HC-WP通知の自動連携を実機確認、$btn未定義バグとCORSブロックの修正を記録
Pleasanter画面のボタンクリックのみでLINEWORKS通知が届くことを確認。
これまで挙げていた最優先の未実装課題(手動Webhook操作が必要な状態)が解消した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:41:20 +09:00
Kenichiro NOGI
88351e4165 feat: Process実行→HC-WP通知の連携をクライアントスクリプトから自動化、実行ログのコメント記録を追加
これまで担当者がPleasanter画面でProcess実行した後、別途n8n Webhookを
手動で叩く必要があった。プロセス実行スクリプトのOnClickへWebhook自動POSTを
追加し、通知失敗時はプロセス実行自体を中断するようにした。あわせて
全Process実行時にコメント欄へ実行者・プロセス名を自動記録する。
2026-09-12 15:24:19 +09:00
Kenichiro NOGI
bbf43f6097 docs: 診断書再提出依頼プロセス(4-7)・Status=410再提出対応・ファイル名規則をマニュアルへ反映
md本体と撮影済みスクリーンショットを使ったPPTX版(30枚)の両方を更新。
2026-09-12 14:58:47 +09:00
Kenichiro NOGI
4575f14d22 feat: 診断書PDFファイル名規則変更・JST対応、Status=410でのPDF再提出対応、担当者からの再提出依頼プロセス(Id19)を追加
撮影完了後にファイル名の可読性・再提出フローの欠如が判明し追加実装。
ファイル名を内部ID直書きから表示名ベースの命名規則に変更しJSTタイムスタンプ化、
診断書保存後もBot対話から再提出できるようメニュー条件を拡張、
担当者が能動的に再提出を促すHC-WP通知(Process19)を新設。いずれも実機確認済み。
2026-09-12 14:51:23 +09:00
Kenichiro NOGI
3bccc4d1b9 docs: 撮影テスト完了・追加バグ4件(過去日付バリデーション欠如/文字化け/テストデータ不整合/CONTACT_MESSAGE巻き戻り)の発見・修正を記録
マニュアル用スクリーンショット撮影を全ケース実施完了(計50枚)。並行して発見した
過去日付が確定できてしまう不具合の修正、文字化け修正、担当者連絡文言の巻き戻り
修正をHC-WAへ反映し、いずれも実機再検証済み。
2026-09-12 14:08:53 +09:00
Kenichiro NOGI
7af3f5ff84 fix: 待機状態中の固定メニュー「健康診断について」再選択で日付パースに誤って落ちるバグを修正
schedule_confirm等の待機状態中に固定メニューを再タップすると、
承諾/自分手配/受診日時の変更いずれにも一致せず自由入力の日付パースに
フォールバックしてしまい無関係なエラーが返っていた。担当者に連絡・
キャンセルワードと同様、待機状態を横断してメニュー表示へ戻す処理を追加。
2026-09-12 09:09:19 +09:00
Kenichiro NOGI
ccd1fa3947 docs: マニュアル画像挿入用のスクリーンショット撮影テスト計画を追加 2026-09-11 18:55:12 +09:00
Kenichiro NOGI
57104c9e67 docs: 健康診断Bot運用マニュアル(管理担当者向け)を新仕様ベースで再作成 2026-09-11 18:53:45 +09:00
Kenichiro NOGI
74f6215c2e docs: DescriptionB列のLabelText変更(健康診断内容)を記録 2026-09-11 18:44:59 +09:00
Kenichiro NOGI
b09c32a6c9 docs: 未対応事項3件(テストPDF削除/文言修正2件)の対応完了を記録 2026-09-11 18:39:32 +09:00
Kenichiro NOGI
e111d1405d docs: パターンC/HC-WP実機テスト結果とDescriptionB構造変更を記録 2026-09-11 18:31:48 +09:00
Kenichiro NOGI
5dc5056ad8 docs: 「担当者に連絡」文言修正(総務・契約関連窓口URL)を記録 2026-09-11 18:08:48 +09:00
Kenichiro NOGI
44ee8781a9 docs: Botトークフロー再設計の実装完了を記録 2026-09-11 18:02:17 +09:00
Kenichiro NOGI
db25941158 feat: 508971へ自分手配Process用の反映スクリプトを追加 2026-09-11 16:08:40 +09:00
Kenichiro NOGI
3e51710d9f feat: Get方式のISO8601日付文字列をyyyy/mm/dd表記に正規化、未設定日付(1899-12-30)は空欄化
NormalizeGetValueを追加し、getRecordsDataToSheetの値格納時に適用。
オブジェクト値の空欄化処理もこの関数に統合。
2026-09-11 14:30:01 +09:00
Kenichiro NOGI
cb01653d77 feat: runExportFlow/runGetFlowにAPI通信・書込処理の区間計測(Timer)を追加
Export方式の処理時間がGet方式より長い件を調査するため計測を追加。
結果、ボトルネックはAPI通信側(サーバー処理)でパース・書込処理ではないと判明したため、今後の検証用に計測コードは残す。
2026-09-11 14:26:56 +09:00
Kenichiro NOGI
81c9f67331 feat: D7切替を廃止しExport/Get両方を毎回実行、出力先をtableId+E/Gシートに分離
D7による方式切替をやめ、run()実行のたびにExport方式・Get方式を両方実行するよう変更。
Export結果は tableId&"E" シート、Get結果は tableId&"G" シートへ別々に保存。
両シートが分かれたため、Get→Export切替時のヘッダ残留対策(既存テーブル削除)は不要になり削除。
処理時間はD4〜D6に両方式合計で記録。
2026-09-11 14:20:30 +09:00
Kenichiro NOGI
2192c9718f fix: Sorter表をH:I列からJ:K列に移動しFilter表との隣接によるテーブル自動結合を回避
F2:G2(Filter表)とH2:I2(Sorter表)が隣接していたため、ListObjects.Add実行時にExcelが1つの連続テーブルとして扱い「テーブルは他のテーブルと重ねることはできません」エラーが発生。間に1列(I列)空くよう配置変更。
2026-09-11 14:04:20 +09:00
Kenichiro NOGI
d4a6b5c12a fix: getRecordsDataToSheetのrec変数をDictionary型に変更しByRef型不一致を解消
FlattenRecord(record As Dictionary)へVariant型変数をByRef渡ししていたためVBEでコンパイルエラー発生。呼び出し側の変数型をDictionaryに合わせて修正。
2026-09-11 14:01:42 +09:00
Kenichiro NOGI
243ecb1b4b fix: run()のエラーハンドリング・失敗検知とGet→Export切替時のヘッダ残留・配列値クラッシュを修正
最終レビューで指摘されたImportant4件に対応:
- run()にエラーハンドラを追加し、失敗時もCalculation/ScreenUpdatingを確実に復元
- runExportFlow/runGetFlowをBoolean化し、データ取得失敗をD3に反映
- Get方式実行後のExport方式実行でヘッダが残留する問題を、既存テーブル削除で解消
- getRecordsDataToSheetでオブジェクト値(JSON配列等)による実行時エラーを回避
2026-09-11 13:50:06 +09:00
Kenichiro NOGI
fabee98ea1 feat: Filter表・Sorter表を自動作成するSetupFilterSorterTablesを追加 2026-09-11 13:37:17 +09:00
Kenichiro NOGI
2454478c65 feat: run()をExport/Get分岐フロー化し処理時間計測(D4-D6)を追加 2026-09-11 13:34:35 +09:00
Kenichiro NOGI
96133b8929 feat: Get方式のレコードをテーブルに書き込むgetRecordsDataToSheetを追加 2026-09-11 13:30:34 +09:00
Kenichiro NOGI
167e2ba5d9 feat: ClassHash等のネスト構造を展開するFlattenRecordを追加 2026-09-11 13:27:26 +09:00
Kenichiro NOGI
d3d844d441 feat: Get API(api-record-get-multi)でレコードを取得するgetRecordsDataを追加 2026-09-11 13:25:01 +09:00
Kenichiro NOGI
adfd140d9b feat: exportCSVDataがBuildViewFromSheetでFilter/Sorterを組み立てるよう変更 2026-09-11 13:21:40 +09:00
Kenichiro NOGI
b8ef165fdf feat: Filter/Sorter表からViewパラメータを組み立てるBuildViewFromSheetを追加 2026-09-11 13:18:03 +09:00
Kenichiro NOGI
4dc4b0cc1d feat: 取得方式(D7)を読み込むfetchMethod変数を追加 2026-09-11 13:12:33 +09:00
Kenichiro NOGI
ee1448504c docs: Filter/Sorter対応・API仕様訂正をspecに反映
PleasanterのGet API仕様(PageSizeはクライアント指定不可、Response.Dataが直接配列、ClassHash等のネスト構造)を公式マニュアルで確認し訂正。
併せてFilter/Sorter条件をシート上の表で指定できる機能をExport/Get共通で追加する設計に更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:14:24 +09:00
Kenichiro NOGI
6f33339d43 docs: Export/Get方式切替の設計書を追加
Pleasanter Export APIとGet API(api-record-get-multi)の処理時間比較機能の設計をまとめた。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:02:14 +09:00
Kenichiro NOGI
95f2acba85 健康診断管理Botトークフロー再設計の実装計画を追加
パターンA(受診日時)・パターンB(受診結果確認、新規)の実装タスクと、
508971実データ1件を使い回す実機トークテスト手順(巻き戻しスクリプト込み)を明記。
2026-09-10 14:21:51 +09:00
Kenichiro NOGI
bd6a605539 健康診断管理システムのn8nワークフロー設計ドキュメントとPleasanter操作スクリプトを追加
n8nに構築済みのHC-SUB/HC-WP/HC-WAワークフロー設計・実装計画・エクスポートJSONを
Pleasanter/健康診断管理/docs/n8n/に集約管理。508971/513156のColumns/Styles/Summaries
反映用ワンショットスクリプトも合わせて追加。worktree-healthcheck-survey-botブランチを
mainへfast-forward mergeし、.claude/worktrees/はgit管理対象外に追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:02:14 +09:00
Kenichiro NOGI
bdec81838e docs: 計画書のテスト数見積もりを実測値(5ファイル32テスト)に修正 2026-09-05 18:52:49 +09:00
Kenichiro NOGI
c03a2376ca feat: 社員マスタ(504412)照合ロジック(メール整合性チェック・フリガナ補完)を追加 2026-09-05 18:50:21 +09:00
Kenichiro NOGI
1b8018da6e README更新: 全ワークフローのn8nデプロイ完了・フォルダ作成を記録
HC-SUB(7ノード)を既存id XRqcykbG2LuAjGG2へ上書き、HC-WP(5ノード)
を既存id swXfpoDtwZDT3Hwkへ上書き、HC-WA(14ノード)を新規id
0i0Ze3Gq0Waof3Faで作成、いずれもactive化してコントローラーが
直接実行(HTTP 200)。n8nフォルダ「健康診断ワークフロー」
(hOx8WKrdOucllbcU)も作成済み。3ワークフローのフォルダ移動は
transfer APIの制約(別プロジェクト専用+active解除の副作用)により
ユーザーがUIで手動実施する運用に。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:49:04 +09:00
Kenichiro NOGI
c1b62d19b4 fix(hc-wa-lineworks-response): alwaysOutputDataをノードトップレベルへ移動
n8nのINode仕様ではalwaysOutputDataはparameters内でなくノード
オブジェクト直下のプロパティ。前回の修正でparameters内に置いて
しまい効果が無かった(0件マッチ時に後続Codeノードが実行されない
問題が未解決のままだった)ため、正しい位置へ移動。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:40:59 +09:00
Kenichiro NOGI
3c8b98e48a fix(hc-wa-lineworks-response): レビュー指摘3件を修正
- ノード3: $input.first()を$('Webhook').first()に修正し、署名検証が
  常に失敗する致命的バグ(誤ったノードのJSONを参照していた)を解消
- ノード4: Data Table GetにalwaysOutputData:trueを追加し、0件ヒット時に
  後続の異常系ハンドリングが起動しない問題を解消
- ノード5: Processが見つからない場合の三項演算子フォールバックを
  廃止し、旧実装通り明示的にthrowするよう変更(黙って即実行させない)
2026-09-05 18:37:42 +09:00
Kenichiro NOGI
530607203a 計画書サンプルコードの$input.first()バグを修正(Task11再構築で発見)
署名検証Codeノードの直前がWebhookでなくData Table(設定値一括取得)
になったため、裸のinput.first()だとconfig行を拾ってしまい常に
署名検証失敗になるバグだった。$('Webhook').first()に修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:35:44 +09:00
Kenichiro NOGI
83d568a493 feat(healthcheck-survey-bot): HC-WAワークフローを60ノードから14ノードへ全面リビルド
分岐ロジック・Pleasanter/LINEWORKS API呼び出しをCodeノードへ集約する新ルールに
合わせて再構築。IF/JWT/Execute Workflow/Data Tableは専用ノードのまま維持しつつ、
業務ロジックは4つのCodeノードに集約した。デプロイ・n8n API呼び出しは未実施。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:29:30 +09:00
Kenichiro NOGI
c8ce15385a refactor: HC-WPの設定値取得をfilter付きGetから一括取得+Codeフィルタに変更
n8nワークフロー構築の基本ルール(Data Table利用最小限)に合わせ、
HEALTHCHECK_WP_API_KEYの取得方法をfilters付き単一キーGetから
returnAll:trueの一括取得+Codeノード内find()に変更。ノード数は
5のまま変わらず。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:17:37 +09:00
Kenichiro NOGI
33057b8dab docs: README記述をToken取得ボディ修正後の実装に合わせて更新 2026-09-05 18:16:14 +09:00
Kenichiro NOGI
df0c836573 fix(healthcheck-survey-bot): LINEWORKS token交換のhttpRequestボディ指定を修正
URLSearchParamsを.toString()した上でjson:trueを併用すると
Content-Typeがapplication/jsonに上書きされうる不整合があり、
LINEWORKSアクセストークン取得が失敗する恐れがあったため、
生のURLSearchParamsをbodyに渡しjson:trueと手動Content-Typeを除去。
レスポンスはtypeofで文字列/オブジェクトを判定しJSON.parseする形に変更。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:13:43 +09:00
Kenichiro NOGI
675cf4a321 refactor: HC-SUBワークフローを7ノード構成にリビルド
新プロジェクトルール(Data Table最小限・Code集約)に合わせ、旧19ノード版
(個別キーごとのData Table Get×7、HTTP Requestノード×6)を廃止。
設定値一括取得→Pleasanter照会・選択肢組み立て→JWTクレーム組み立て→
Sign JWT→アクセストークン取得・LINEWORKS送信→状態更新の7ノードに集約。
デプロイは行わず、ローカルJSON作成・検証のみ(コントローラーが同一ID
XRqcykbG2LuAjGG2へPUTする想定)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:07:15 +09:00
Kenichiro NOGI
935e58e384 計画書Task9-11をCodeノード集約方式に全面改訂(基本ルール反映)
ユーザー指示により、n8nワークフロー構築の基本ルールを確定:
(1)Data Table利用は最小限(config値取得は1回のreturnAll取得+
Codeノードでフィルタ、複数のGetノードを並べない)、(2)入口は
Webhook+Codeでノード数を抑える、(3)外部システムへのHTTP
Requestノードは使ってよい(Codeノード内this.helpers.httpRequest
への無理な集約は不要)。JWT署名・Data Table読み書きのみ専用
ノードを維持。

これによりHC-SUB 19→7ノード、HC-WA 60→11ノード目安に再設計。
Task11で発生した「認証チェーンを分岐ごとに複製」問題も、
Codeノード1つに分岐ロジックを集約することで解消。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:02:26 +09:00
Kenichiro NOGI
3dccdefdcb fix(hc-wa-lineworks-response): 認証チェーンの分岐前無条件実行と暗黙ハングを解消
Botマスタ取得/JWTクレーム組み立て/Sign JWT/アクセストークン取得をSwitch分岐前の
トランクから外し、直接LINEWORKS API呼び出しを行う4箇所(追加案内送信/日付エラー
送信/ファイル未着送信/LINEWORKSファイルダウンロード)それぞれの直前に複製配置。
待機状態取得0件時とSwitch未一致時に無応答でハングする経路には、明示的な
エラーRespond to Webhookへ誘導するIFノード/フォールバック出力を追加した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:49:02 +09:00
Kenichiro NOGI
8c8d748a07 feat: HC-WAワークフロー(LINEWORKS応答受信)を追加
LINE WORKSからの応答を受ける唯一のWebhook受信口(45ノード)をローカルJSONとして作成。
awaitInput(none/date/file)3分岐のSwitchで、既存プロセス選択・日付入力・ファイル添付の
各応答パターンをHC-SUB(Task 9)へ橋渡しする。秘密値はすべてworkflow_config_values経由の
Data Table参照。デプロイ・activateはコントローラー側で別途実施(本コミットはローカル定義のみ)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:48:02 +09:00
Kenichiro NOGI
ac27659061 feat: HC-WPワークフロー(Statusプッシュ通知)を追加
サブエージェントがJSON構築とセルフレビューを実施(実キーAPIコール
がAuto Modeの分類器にブロックされたためデプロイ未実施)。デプロイ・
active化はコントローラーが直接実行(workflow id swXfpoDtwZDT3Hwk)。
秘密情報はworkflow_config_valuesから参照、ハードコードなし。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:34:03 +09:00
Kenichiro NOGI
4c11e4b868 fix(healthcheck-survey-bot): IF processId existsの参照バグを修正
HEALTHCHECK_SITE_ID取得(Data Table Get)の直後で$json.processIdを直接参照
していたため、上流フィールドが置き換えられ常にundefinedと評価され、
Process実行分岐が発火しない不具合を修正。他ノードと同様に
$('Execute Workflow Trigger').item.json.processIdを参照するよう変更し、
n8n(id: XRqcykbG2LuAjGG2)へ再デプロイ済み。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:18:51 +09:00
Kenichiro NOGI
90d057aac9 feat: HC-SUBワークフロー(プロセス実行+案内送信)を追加
WP・WAから共通利用するExecute Workflow Triggerサブワークフローを実装。
秘密値・SiteId等はすべてworkflow_config_values(bNkadTyDgDepYx2p)から
Data Tableノード経由で取得し式参照、ハードコードなし。デプロイ・active化
まで実施、実行テスト(LINEWORKS実送信)は次段階でユーザー確認後に実施する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:11:30 +09:00
Kenichiro NOGI
fa0e0f4faf 計画書Task9-12を秘密情報のworkflow_config_values参照方式に修正
ユーザー指摘により、n8nワークフロー内の秘密情報(Pleasanter
ApiKey、LINEWORKS Client Secret等)をCodeノードに直接ハードコード
する設計(旧計画)から、org-master-sync系ワークフローが実際に
使っている方式(既存n8n Data Table「workflow_config_values」
id:bNkadTyDgDepYx2pから各キーをDataTableノードで都度取得し、
式で参照する)へ全面的に修正した。

あわせて健康診断管理用の新規設定値6件をworkflow_config_valuesへ
実際に登録済み: LW_BOT_CLIENT_ID/LW_BOT_CLIENT_SECRET/
LW_BOT_SERVICE_ACCOUNT(LINEWORKS通知送信ワークフローと同じBot
認証情報)、HEALTHCHECK_SITE_ID(508971)、
LINEWORKS_BOT_MASTER_SITE_ID(484184)、HEALTHCHECK_DATA_TABLE_ID
(jqMDa2YZTI4f0iQ7)。HEALTHCHECK_WP_API_KEY(WP Webhook認証用に
新規生成)も追加登録済み。LINEWORKS_BOT_SECRET(署名検証用)は
値未確定のためキーの参照方法のみ計画に反映し、値はTask11着手時に
別途登録する。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:59:56 +09:00
Kenichiro NOGI
231ae739eb 計画書をTask8の実結果(updatedAt列非対応)に合わせて修正
n8n Data Tableはupdatedatを予約システム列名として拒否するため
7列構成になった(Task8実施結果)。Task9のDataTable upsert記述
からupdatedAtへの言及を削除し、作成済みテーブルid(jqMDa2YZTI4f0iQ7)
を明記した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:47:49 +09:00
Kenichiro NOGI
ba474f998d docs: healthcheck_bot_state Data Table作成結果を記録 2026-09-05 14:45:13 +09:00
Kenichiro NOGI
8ecef72c18 feat: n8n Public API操作用の共通スクリプトを追加 2026-09-05 14:41:11 +09:00
Kenichiro NOGI
66f31d5dee feat: LINEWORKS Webhook署名検証ロジックを追加 2026-09-05 14:37:44 +09:00
Kenichiro NOGI
20396f465d feat: Process抽出・回答照合・追加入力判定ロジックを追加 2026-09-05 14:35:08 +09:00
Kenichiro NOGI
0405c3a089 feat: ツールチップ文言のプレースホルダー置換ロジックを追加 2026-09-05 14:32:42 +09:00
Kenichiro NOGI
16732f0ab0 計画書サンプルコードの和暦計算バグを修正(Task3実装で発見)
ERA_INFO[era] + eraYear - 1 は令和6年を2023年に誤変換するバグ
だった(正しくはeraYear単純加算で2024年)。Task3実装者が実装時に
発見・修正し、reviewerも正しさを検証済み。計画書のサンプルコード
側も今後の参照者のため同じ修正を反映。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:31:43 +09:00
Kenichiro NOGI
3573d1b78f feat: 日付パースロジックを追加 2026-09-05 14:29:08 +09:00
Kenichiro NOGI
0bee14f18c Task2完了: Process入力検証タブのJSON構造を実機実績から確認
508971への新規テストProcess追加なしで、既存本番プロジェクト
「実行予算WF申請」(SiteId 376872)の取得済みprocesses.jsonに
入力検証タブの実例があり、そこから構造(ValidateInputs配列、
各要素{Id, ColumnName, Required})を確認できた。508971への
書き込みが不要になったため、Task2の内容と手順を計画書側も
実態に合わせて修正。Task5のサンプルコード内のキー名も
Validations→ValidateInputsへ修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:27:09 +09:00
Kenichiro NOGI
8c8cb332bb feat: healthcheck-survey-botプロジェクト雛形を追加 2026-09-05 14:14:34 +09:00
Kenichiro NOGI
08a0b87dcf 実装計画にTask13(社員マスタ504412照合ロジック)を追加
メールアドレス整合性チェック・フリガナ補完の判定/補完ロジックを
TDDで実装するタスクを追加。実行タイミング(保存時/定期バッチ/
手動)は未確定のため、呼び出しトリガーは意図的にスコープ外とし、
決まり次第別タスクとして追加する方針を明記した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:04:06 +09:00
Kenichiro NOGI
bee42dc56d 実装計画に504412連携は別タスク扱いである旨を明記
Bot対話フロー(HC-SUB/WP/WA)の実装計画と、社員マスタ(504412)に
よるメールアドレス整合性チェック・フリガナ補完は別軸のタスクと
して切り離すことをGlobal Constraintsに追記。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:59:14 +09:00
Kenichiro NOGI
5fa82fda43 設計書に504412利用目的(メール整合性チェック・フリガナ補完)を具体化
Bot対話フローとは独立した補助機能として8章に新設。504412の
列構成(Class011=ユーザID, Class036=PLメールアドレス,
Class003/004=姓名カナ)を明記し、508971のメールアドレス整合性
チェックとClassD(フリガナ)補完の参照先として使う設計にした。
2章の記述は重複を避け8章への参照に整理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:58:28 +09:00
Kenichiro NOGI
64e8b8d011 設計書に社員マスタ(504412)の裏付け利用先を追記
508971自体の入力値だけでは社員属性の信頼性が不十分な場合の
裏付け手段として、org-master-syncが同期している社員・組織
マスタ管理テーブル(SiteId 504412)を参照できる旨を明記。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:56:16 +09:00
Kenichiro NOGI
3b904e6214 健康診断管理LINEWORKS連携の実装計画を追加
設計書(2026-09-05-healthcheck-lineworks-survey-n8n-design.md)を基に、
12タスクの実装計画を作成。純粋ロジック(日付パース/プレースホルダー
置換/Process抽出/署名検証)はNode.jsでTDD、n8nワークフロー構築は
既存のLINEWORKS通知送信ワークフロー(JWT署名→トークン取得→Bot API
送信)とdataTableノードの実機パターンを踏襲する形で具体化した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:25:45 +09:00
Kenichiro NOGI
2939487c4f 健康診断管理LINEWORKS連携設計をプロセス機能流用型に全面改訂
ヒアリングで判明した実際のフロー(日程通知→了承/変更→受診確認→
結果受取り)がステータス駆動の対話型であり、単純な順次質問アンケート
ではないと判明。LINE WORKS Bot APIに「トークを開いた」検知イベントが
存在しない制約も踏まえ、プッシュ型通知+フォールバック応答方式に変更。
フロー定義用の新規マスタは作らず、プリザンター標準のProcess機能
(現在の状況/変更後の状況/ツールチップ/入力検証タブ)をそのまま
定義source として使う設計に切り替えた。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:57:20 +09:00
60 changed files with 11254 additions and 129 deletions

View File

@ -0,0 +1,125 @@
---
name: dokploy-webapp
description: NodeSrv配下apps/<app-name>にNode.js WEBアプリを新規作成し、ローカル開発から既存Dokploy環境AWS Lightsail上で稼働中のセルフホストPaaSへのデプロイ・運用までを行う。「新しいアプリを作って」「Dokployにデプロイして」「アプリをNodeSrvに追加して」「動作検証して」等の依頼で使う。
---
# NodeSrv アプリ開発 & Dokployデプロイ
`NodeSrv/apps/<app-name>` を1アプリ1フォルダ1Dokploy Composeとして管理する。アプリ同士は完全に独立し、片方の開発・再デプロイがもう片方に影響しない設計。
## 新規アプリ作成
1. `NodeSrv/apps/_template``NodeSrv/apps/<app-name>` としてコピー
2. `package.json``name``<app-name>` に変更
3. 必要な依存関係を追加、`src/index.js` に実装を追加していく
4. `/health` エンドポイントは削除・変更しないDokployのヘルスチェック対象
## ローカル開発
```
cd NodeSrv/apps/<app-name>
npm install
cp .env.example .env
npm run dev
```
`http://localhost:<PORT>/``/health` で動作確認。
## デプロイ前のDocker動作確認必須
Dokployは本番でDockerfileから直接ビルドするため、pushする前にローカルでも同じ手順でビルド・起動できることを確認する。
```
docker compose -f docker-compose.local.yml up --build
```
エラー無く起動し `/health` が200を返すことを確認してから次に進む。
## Gitソース: Gitea
GitHub直接連携ではなく、Dokploy上に自前ホストしたGitea`https://git73.next-hd.net/mygit-admin/ken_nogi.git`、リモート名`gitea`)を経由する。理由: モノレポ(`ken_nogi`のプライベートリポジトリをDokployのGit Provider連携で直接使う設定がうまくいかず、Gitea自前ホストに切り替えた経緯がある詳細はプロジェクトメモリ参照
**注意2026-09-19判明**: 当初はGitea上に`NodeSrv`という単体リポジトリを作って運用していたが、その後`dev`配下NodeSrv/Pleasanter等を`ken_nogi`という1つのGiteaリポジトリへ統合した。`NodeSrv`単体リポジトリは既に削除済み(`git73.next-hd.net/mygit-admin/NodeSrv`は404。Dokploy側の既存Composeapp-portal/n8n等は統合後もしばらく古い`giteaRepository: "NodeSrv"`のままになっていて、pushしても反映されない状態だった。2026-09-19に全Composeapp-portal/authgw-poc/auth-redirect/portal-sample-batch/n8n/keycloak-monitor/lineworks-user-authを`giteaRepository: "ken_nogi"`・`composePath`の先頭に`NodeSrv/`を付ける形へ`dokploy compose update`で修正済み。**新規Compose作成時は`giteaRepository`に`ken_nogi`、`composePath`は必ず`NodeSrv/apps/<app-name>/docker-compose.yml`(先頭に`NodeSrv/`が要る)を指定すること。**
```
git remote -v # "gitea" が登録済みか確認
git push gitea main
```
**注意**: Windows環境でBashツールからpushすると、Git Credential Manager (GCM) がGUIダイアログを出そうとして `tty` が無い環境では失敗する(`fatal: helper error (-1): User cancelled dialog.`)。その場合は一時的な `GIT_ASKPASS` スクリプトで認証情報(`AUTH_SECRETS.md` の Gitea項目を渡し、push後は必ずスクリプトを削除する。認証情報を `git push` のURLやBashコマンド引数に直接書かない。
## Dokployへの接続
`@dokploy/cli``npm install -g @dokploy/cli`)をインストール済みで使う。認証は `dokploy auth -u https://dokploy45.next-hd.net -t <token>` 済みでセッションが保持されている前提(**トークンは絶対にチャットに貼らせない・貼られたら即Revoke指示**。認証はユーザー自身のターミナルで実行させる)。以後は `dokploy <command>` をそのまま実行してよい(本番操作なので実行前に毎回内容を提示し確認を取る)。
**既存環境の実態**2026-07-25確認、CLI v0.29.4:
- NodeSrv用アプリは `webapps` Project配下に **Compose**Docker Compose、Provider: Gitea、`sourceType: gitea`として追加する。Dokploy標準の「Application」type・「Domains」タブは使わないREADME.md「共通ルール」参照
- `projectId: 3T-drY1Q377JcLeTiNTDK` / `environmentId: Cm0HjMIFyl11UdIcIGRy8``production`環境)
- **CLIのGET系コマンドはほぼ全滅**`compose one`, `docker get-containers*` 等が軒並み `Request failed with status code 400`。POST/PUT系`compose deploy`等は動く。状態確認はCLIに頼らずSSH直接調査下記を使う
- **原因特定済み2026-07-25**: `@dokploy/cli``dist/client.js`の`apiGet`関数がtRPCのGETクエリを組み立てる際、`apiPost`と違い`{json: params}`でラップせず生の`params`を`?input=`にJSON化しているだけの実装バグ。DokployのtRPCサーバーは`{"json": {...}}`形式のラップを要求するため、これがない全てのGETリクエストが400になる
- Dokploy tRPC API自体は正常。`x-api-key`ヘッダー付きで`GET {DOKPLOY_URL}/api/trpc/<endpoint>?input=<encodeURIComponent('{"json":{...}}')>`の形式で直接叩けば200が返ることを確認済み`compose.one`で実証。ポータル等でDokploy API連携が必要な場合は、CLIを介さずこの正しい形式で自前クライアントを実装すること
- CLI自体へのパッチは行わない方針グローバルnpmパッケージの一時修正は`npm update`等で消えるため、恒常的な対応にならない)
## DokployへのデプロイCLI手順
1アプリ = 1 Dokploy "Compose"。`webapps` Project配下に、`apps/<app-name>/docker-compose.yml` をCompose Pathとして指定する。
### 新規Compose作成CLI手順、2026-07-25実地検証済み
`dokploy compose create`はGitHub向けのデフォルト値で作成されるため、直後に`dokploy compose update`でGitea連携用の値へ切り替える2段階が必要。
```
# 1. Compose自体を作成webapps projectのenvironmentIdを指定
dokploy compose create \
--name "<app-name>" \
--environmentId "Cm0HjMIFyl11UdIcIGRy8" \
--composeType "docker-compose" \
--appName "<app-name>" \
--json
# → composeId が返る
# 2. sourceTypeをgiteaへ切替、リポジトリ・パスを指定
dokploy compose update \
--composeId "<上で得たcomposeId>" \
--sourceType "gitea" \
--giteaId "O5-CqLQwVdlzXw3KfmN-8" \
--giteaOwner "mygit-admin" \
--giteaRepository "ken_nogi" \
--giteaBranch "main" \
--composePath "NodeSrv/apps/<app-name>/docker-compose.yml" \
--json
# 3. デプロイ実行
dokploy compose deploy --composeId "<composeId>" --title "<変更内容>" --json
```
`serverId`は省略可(単一サーバー構成のため)。
## デプロイ後の検証
CLIの `compose one` 等GET系コマンドは400エラーで使えないため、以下のいずれかで代替する。
```
# SSH経由でコンテナ状態・ラベルを直接確認
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
"sudo docker ps --filter name=<app-name>"
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
"sudo docker inspect <container-name> --format '{{json .Config.Labels}}'"
# 外部疎通確認
curl -I https://<app-name>.next-hd.net/health
```
**SSHが自動モード分類器にブロックされる場合**2026-07-26確認、`sudo`付きコマンド等でブロックされることがあるは、上記57行目の自前fetchクライアント方式でDokploy側の状態デプロイ履歴の`status`、compose設定等を確認する。認証情報はCLIの`config.json``C:/Users/k.nogi/AppData/Roaming/npm/node_modules/@dokploy/cli/config.json`)から読み取り、結果は必ずファイルへ書き出してから必要な値だけ抽出する(レスポンスに機密情報が含まれるため画面に出さない)。デプロイ完了確認は`deployment.allByCompose`エンドポイントの`status`(`done`か)を見る。
- 割り当てたドメインへ `curl``/health` が200
- 別アプリを同時にredeployし、対象アプリ以外が無停止であることを確認独立性の担保
## アプリポータルへの登録(任意)
新規アプリを`NodeSrv/apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装は`NodeSrv/apps/app-portal/src/dokployClient.js`参照)。
## 参考
- 全体の運用方針・認証ゲートウェイの詳細: [../../../NodeSrv/README.md](../../../NodeSrv/README.md)
- Dokployインフラの経緯: プロジェクトメモリ `project_nodesrv-dokploy-lightsail`

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ logs/
# 巨大バイナリ・アーカイブ(サイズが大きすぎるためgit管理対象外。ローカル/別バックアップで管理) # 巨大バイナリ・アーカイブ(サイズが大きすぎるためgit管理対象外。ローカル/別バックアップで管理)
NodeSrv/notepm/export_nextgroup_20260807020526491/ NodeSrv/notepm/export_nextgroup_20260807020526491/
NodeSrv/Keys/Pleasanter_1.5.7.1.zip NodeSrv/Keys/Pleasanter_1.5.7.1.zip
# git worktree実体(別ブランチの作業ツリー。中身は各ブランチ側でコミット管理)
.claude/worktrees/

View File

@ -1,3 +1,22 @@
const logModalEl = document.getElementById('logModal');
const logModalBody = document.getElementById('logModalBody');
const logModal = logModalEl ? new bootstrap.Modal(logModalEl) : null;
const toastEl = document.getElementById('actionToast');
const toastBody = document.getElementById('actionToastBody');
const toast = toastEl ? new bootstrap.Toast(toastEl) : null;
function showToast(message, isError) {
if (!toast) {
alert(message);
return;
}
toastBody.textContent = message;
toastEl.classList.toggle('text-bg-danger', Boolean(isError));
toastEl.classList.toggle('text-bg-primary', !isError);
toast.show();
}
document.getElementById('cards').addEventListener('click', async (event) => { document.getElementById('cards').addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]'); const button = event.target.closest('button[data-action]');
if (!button) return; if (!button) return;
@ -6,18 +25,19 @@ document.getElementById('cards').addEventListener('click', async (event) => {
if (action === 'logs') { if (action === 'logs') {
const res = await fetch(`/api/compose/${composeId}/logs`); const res = await fetch(`/api/compose/${composeId}/logs`);
if (!res.ok) { if (!res.ok) {
alert(`ログ取得に失敗しました (status: ${res.status})`); showToast(`ログ取得に失敗しました (status: ${res.status})`, true);
return; return;
} }
const data = await res.json(); const data = await res.json();
alert(data.logs || 'ログなし'); logModalBody.textContent = data.logs || 'ログなし';
logModal.show();
return; return;
} }
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' }); const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
if (res.ok) { if (res.ok) {
alert(`${action} を実行しました`); showToast(`${action} を実行しました`, false);
} else { } else {
alert(`${action} に失敗しました (status: ${res.status})`); showToast(`${action} に失敗しました (status: ${res.status})`, true);
} }
}); });

View File

@ -6,55 +6,84 @@ function escapeHtml(value) {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
const BOOTSTRAP_CSS =
'<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">';
const BOOTSTRAP_JS =
'<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>';
function renderLoginPage(errorMessage) { function renderLoginPage(errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : ''; const errorHtml = errorMessage
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
: '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App Portal Admin Login</title> <title>App Portal Admin Login</title>
${BOOTSTRAP_CSS}
</head> </head>
<body> <body class="bg-body-tertiary d-flex align-items-center" style="min-height:100vh">
<h1>Admin Login</h1> <main class="container" style="max-width:400px">
${errorHtml} <div class="card shadow-sm">
<form method="POST" action="/admin/login"> <div class="card-body">
<input type="password" name="masterKey" placeholder="Master Key" required> <h1 class="h4 mb-3">Admin Login</h1>
<button type="submit">Login</button> ${errorHtml}
</form> <form method="POST" action="/admin/login">
<div class="mb-3">
<input type="password" class="form-control" name="masterKey" placeholder="Master Key" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</main>
</body> </body>
</html>`; </html>`;
} }
function renderAdminPage(emails, errorMessage) { function renderAdminPage(emails, errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : ''; const errorHtml = errorMessage
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
: '';
const rows = emails const rows = emails
.map( .map(
(email) => `<li> (email) => `<li class="list-group-item d-flex justify-content-between align-items-center">
${escapeHtml(email)} ${escapeHtml(email)}
<form method="POST" action="/admin/allowlist/remove" style="display:inline"> <form method="POST" action="/admin/allowlist/remove" class="mb-0">
<input type="hidden" name="email" value="${escapeHtml(email)}"> <input type="hidden" name="email" value="${escapeHtml(email)}">
<button type="submit">削除</button> <button type="submit" class="btn btn-outline-danger btn-sm">削除</button>
</form> </form>
</li>` </li>`
) )
.join('\n'); .join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Allowlist Admin</title> <title>Allowlist Admin</title>
${BOOTSTRAP_CSS}
</head> </head>
<body> <body class="bg-body-tertiary">
<h1>Allowlist Admin</h1> <nav class="navbar navbar-dark bg-dark mb-4">
${errorHtml} <div class="container">
<ul>${rows}</ul> <span class="navbar-brand mb-0 h1">Allowlist Admin</span>
<form method="POST" action="/admin/allowlist/add"> <a class="btn btn-outline-light btn-sm" href="/">ダッシュボード</a>
<input type="email" name="email" placeholder="user@example.com" required> </div>
<button type="submit">追加</button> </nav>
</form> <main class="container pb-5" style="max-width:600px">
<form method="POST" action="/admin/logout"> ${errorHtml}
<button type="submit">ログアウト</button> <ul class="list-group mb-3">${rows}</ul>
</form> <form method="POST" action="/admin/allowlist/add" class="input-group mb-4">
<input type="email" class="form-control" name="email" placeholder="user@example.com" required>
<button type="submit" class="btn btn-primary">追加</button>
</form>
<form method="POST" action="/admin/logout">
<button type="submit" class="btn btn-outline-secondary btn-sm">ログアウト</button>
</form>
</main>
${BOOTSTRAP_JS}
</body> </body>
</html>`; </html>`;
} }

View File

@ -37,30 +37,50 @@ async function buildCards(environmentId) {
return cards; return cards;
} }
const STATE_BADGE = {
running: 'bg-success',
stopped: 'bg-secondary',
unknown: 'bg-warning text-dark',
};
function renderCard(card) { function renderCard(card) {
if (card.error) { if (card.error) {
return `<div class="card card-error" data-compose-id="${escapeHtml(card.composeId)}"> return `<div class="col-12 col-md-6 col-lg-4">
<h3>${escapeHtml(card.name || card.composeId)}</h3> <div class="card border-danger-subtle h-100" data-compose-id="${escapeHtml(card.composeId)}">
<p>状態取得失敗</p> <div class="card-body">
<h3 class="card-title h5">${escapeHtml(card.name || card.composeId)}</h3>
<p class="card-text text-danger mb-0">状態取得失敗</p>
</div>
</div>
</div>`; </div>`;
} }
const { composeId, composeStatus, containerState, meta } = card; const { composeId, composeStatus, containerState, meta } = card;
const badgeClass = STATE_BADGE[containerState] || 'bg-warning text-dark';
const openLink = const openLink =
meta.appType === 'web' && meta.url meta.appType === 'web' && meta.url
? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>` ? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
: ''; : '';
const actionButtons = const actionButtons =
meta.appType === 'web' meta.appType === 'web'
? `<button data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button> ? `<button class="btn btn-success btn-sm" data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
<button data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button> <button class="btn btn-danger btn-sm" data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
<button data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>` <button class="btn btn-warning btn-sm" data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
: `<button data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`; : `<button class="btn btn-primary btn-sm" data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
return `<div class="card" data-compose-id="${escapeHtml(composeId)}"> return `<div class="col-12 col-md-6 col-lg-4">
<h3>${escapeHtml(meta.label)}</h3> <div class="card h-100" data-compose-id="${escapeHtml(composeId)}">
<p>compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}</p> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h3 class="card-title h5 mb-0">${escapeHtml(meta.label)}</h3>
<span class="badge ${badgeClass}">${escapeHtml(containerState)}</span>
</div>
<p class="card-text text-muted small mb-3">compose: ${escapeHtml(composeStatus)}</p>
<div class="mt-auto d-flex flex-wrap gap-2">
${openLink} ${openLink}
${actionButtons} ${actionButtons}
<button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button> <button class="btn btn-outline-secondary btn-sm" data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
</div>
</div>
</div>
</div>`; </div>`;
} }
@ -68,14 +88,45 @@ async function renderDashboard(environmentId) {
const cards = await buildCards(environmentId); const cards = await buildCards(environmentId);
const cardsHtml = cards.map(renderCard).join('\n'); const cardsHtml = cards.map(renderCard).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App Portal</title> <title>App Portal</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head> </head>
<body> <body class="bg-body-tertiary">
<h1>App Portal</h1> <nav class="navbar navbar-dark bg-dark mb-4">
<div id="cards">${cardsHtml}</div> <div class="container">
<span class="navbar-brand mb-0 h1">App Portal</span>
<a class="btn btn-outline-light btn-sm" href="/admin">管理</a>
</div>
</nav>
<main class="container pb-5">
<div id="cards" class="row g-3">${cardsHtml}</div>
</main>
<div class="modal fade" id="logModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">ログ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre id="logModalBody" class="mb-0 small"></pre>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="actionToast" class="toast align-items-center text-bg-primary border-0" role="status" aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="actionToastBody"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/portal.js"></script> <script src="/portal.js"></script>
</body> </body>
</html>`; </html>`;

View File

@ -0,0 +1,4 @@
node_modules
npm-debug.log
.env
.git

View File

@ -0,0 +1,14 @@
PORT=3000
NODE_ENV=development
# app-portalキッカー登録用
PORTAL_APP_TYPE=batch
PORTAL_APP_LABEL=ボイスメール取得
PORTAL_APP_URL=http://get-voice-mail:3000
PORTAL_TRIGGER_PATH=/trigger
PORTAL_SECRET=
# n8nワークフロー入口(Webhookード)のURL。/trigger起動時にPOSTする先
N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail
# n8n Webhookード側のHeader Auth Credential値(X-Api-Keyヘッダーに設定)
N8N_WEBHOOK_SECRET=

View File

@ -0,0 +1,13 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]

View File

@ -0,0 +1,7 @@
services:
get-voice-mail:
build: .
ports:
- "3002:3000"
env_file:
- .env

View File

@ -0,0 +1,14 @@
services:
get-voice-mail:
build: .
expose:
- 3000
env_file:
- .env
networks:
- dokploy-network
restart: unless-stopped
networks:
dokploy-network:
external: true

View File

@ -0,0 +1,14 @@
{
"name": "get-voice-mail",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.0"
}
}

View File

@ -0,0 +1,59 @@
# get-voice-mail
## 仕様
- n8nを利用
- SCPでサーバーから録音データ取得
- プリザンターに音声保存
## サーバー情報
pbx-server-secret.md 参照
## voiceメールのパス
/var/spool/asterisk/monitor
この下に、年フォルダ、月フォルダ、日フォルダが階層化されている
取得ファイルルール ※あとで変わるかもだがいまは固定ルール
- ファイル名の先頭がin-0420002706-で始まるもの
{方向}-{番号A}-{番号B}-{日付}-{時刻}-{uniqueid}.wav
- 方向: in=着信 / out=発信 / generic=内線間 or 方向判定不能時
- 番号A: 着信の場合は着信先(DID/内線番号)、発信の場合は発信元内線
- 番号B: 相手側番号(外部番号 or 内線)
- 日付: YYYYMMDD
- 時刻: HHMMSS
- uniqueid: Asteriskチャンネル固有ID (epoch秒.マイクロ秒)。通話開始時刻ベース、重複なし識別子
例:
- in-0420002706-05057998188-20260918-165846-1789718326.114385.wav
→ 着信、着信先0420002706、相手05057998188、2026-09-18 16:58:46
- generic-0425400505-05017446045-20260918-170343-1789718623.114390.wav
→ 内線間 or 方向不明、番号0425400505⇔05017446045
ファイル名からいつの音声データなのか日付情報も算出
ファイル名の日付はJST
## プリザンター
取得した音声ファイルと内容をプリザンターのテーブルへ書き出し
https://nextoffice.next-hd.co.jp/pleasanter/items/515888/index
- ClassA番号A
- ClassB番号B
- ClassCWAVファイル名 ※すでに同じファイルが登録済みの場合はスキップ
- AttachmentAWAV本体保存
## n8nワークフロー起動方式
入口=Webhookード固定。cron等の自前スケジューラは使わない。
起動元=app-portal`NodeSrv/apps/app-portal`のキッカーbatch型トリガー
実装=`NodeSrv/apps/get-voice-mail`直下のNode.jsアプリ他アプリと同じ1フォルダ1Dokploy Compose
役割=中継のみ。`/trigger`受信→n8n Webhook URLへPOST→202即返却。SCP取得・WAV判定・プリザンター書込は全部n8n側workflow内で実施。
- POST `/trigger`(ヘッダ`X-Portal-Secret`必須、`PORTAL_SECRET`と一致時のみ許可)
- 認証通過後、環境変数`N8N_WEBHOOK_URL`n8n Webhookードの入口URLへPOST、結果を待たず202返却
- `.env.example`にPORTAL_APP_TYPE=batch等のポータル登録変数一式を用意済み。Dokploy Environment画面で`PORTAL_SECRET`・`N8N_WEBHOOK_URL`を設定してから使う
- ポータル管理・トリガー実装パターンの詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装例は`NodeSrv/apps/portal-sample-batch`参照

View File

@ -0,0 +1,39 @@
const express = require('express');
const { isTriggerAuthorized } = require('./triggerAuth');
const app = express();
const PORT = process.env.PORT || 3000;
const PORTAL_SECRET = process.env.PORTAL_SECRET;
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL;
const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET;
app.get('/', (req, res) => {
res.json({ app: 'get-voice-mail', status: 'ok' });
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
app.post('/trigger', (req, res) => {
if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
res.sendStatus(403);
return;
}
if (!N8N_WEBHOOK_URL) {
console.error('get-voice-mail: N8N_WEBHOOK_URL未設定');
res.sendStatus(500);
return;
}
fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {},
}).catch((err) => {
console.error('get-voice-mail: n8n webhook起動失敗', err.message);
});
res.sendStatus(202);
});
app.listen(PORT, () => {
console.log(`get-voice-mail listening on port ${PORT}`);
});

View File

@ -0,0 +1,5 @@
function isTriggerAuthorized(headerValue, expectedSecret) {
return Boolean(expectedSecret) && headerValue === expectedSecret;
}
module.exports = { isTriggerAuthorized };

View File

@ -0,0 +1,11 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { isTriggerAuthorized } = require('../src/triggerAuth');
test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => {
assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true);
assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false);
assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false);
assert.strictEqual(isTriggerAuthorized('anything', undefined), false);
assert.strictEqual(isTriggerAuthorized('anything', ''), false);
});

View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,101 @@
# healthcheck-survey-bot
健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes`
をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。
設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md`
実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`
## 実機調査メモ2026-09-05確認
508971への新規テストProcess追加なしで、既存本番プロジェクト「実行予算WF申請」SiteId 376872
取得済み`processes.json``Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`
に入力検証タブを使ったProcessの実例があり、そこからJSON構造を確認できた。
Processの入力検証タブは`SiteSettings.Processes[].ValidateInputs`配列として保存される。
各要素は`{"Id": number, "ColumnName": string, "Required": boolean}`(実例: `{"Id": 1, "ColumnName": "Class021", "Required": true}`)。
配列名は`Validations`ではなく`ValidateInputs`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)は
キー自体が省略される可能性が高い(今回の実例では未設定のため確認できていない)。
Task 5processFlow.jsの`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。
## n8nリソースID一覧
(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する)
- Data Table `healthcheck_bot_state`: `jqMDa2YZTI4f0iQ7`
- projectId: `LOcxF69Gm4PvnkqA``org-master-sync`用プロジェクトを流用)
- 列: `resultId`, `targetEmail`, `currentStatus`, `pendingProcesses`, `awaitInput`, `awaitProcessId`, `awaitColumn`すべてstring型
- 注記: 設計上の8列目`updatedAt`はn8n Data Tableのシステム予約列名`POST /data-tables`が`400 Column name "updatedAt" is reserved as a system column name.`を返すのため、列定義には含めていない。各行の更新日時はn8nが自動管理する`updatedAt`メタデータで代替する。
- ワークフロー `HC-SUB: プロセス実行と案内送信`: `XRqcykbG2LuAjGG2`
- ファイル: `workflows/hc-sub-run-process-and-notify.json`Execute Workflow Trigger、入力`{resultId, processId}`
- **プロジェクト全体のn8nワークフロー構築ルール2026-09-05改訂に合わせて7ード構成へ全面リビルド。旧19ード版個別キーごとのData Table Getード×7、HTTP Requestード×6を並べた構成を置き換え、同じワークフローID`XRqcykbG2LuAjGG2`へPUTで上書き済み2026-09-05、コントローラー実施、HTTP 200。**
- active化済みExecute Workflow Triggerサブワークフローは呼び出し元から実行可能にするためactive必須、`asKvC5LSVg3q8tuU`と同じ運用)
- ード構成7ード、リニア接続:
1. `Execute Workflow Trigger`(入力: `resultId`, `processId`
2. `Data Table`「設定値一括取得」: `workflow_config_values``bNkadTyDgDepYx2p`)を`filters`無し・`returnAll:true`で一括取得個別キーごとのGetードを廃止
3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立てまでを1ードに集約。Pleasanter API呼び出しはすべて`this.helpers.httpRequest`n8n Codeード公式ヘルパーで発行
4. `Code`「JWTクレーム組み立て」: JWTクレームJSON文字列の組み立てとLINEWORKS `button_template`のactions配列組み立てを1ードに集約
5. `JWT`ード「Sign JWT」`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」id `Hw0qlEaGfLPnQWp1`
6. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ードに集約`this.helpers.httpRequest`
7. `Data Table`「状態更新」: `healthcheck_bot_state``jqMDa2YZTI4f0iQ7`)へ`insert`
- 秘密情報・SiteId等はすべて`workflow_config_values``bNkadTyDgDepYx2p`から「設定値一括取得」1ードで取得し、直後のCodeードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
- Execute Workflow Triggerの入力`resultId`/`processId`)は、後続ノードの直接の入力でない箇所では必ず`$('Execute Workflow Trigger').item.json...`のようにード名を明示して参照している旧版にあった「Data Tableード通過後に裸の`$json`を参照してしまうバグ」の再発防止)
- 未検証・要判断の暫定実装ありTask 12以降で要確認:
①484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジック実データ構造未調査のため`SiteSettings.BotIds`または`Processes[].BotId`を仮定し、無ければ例外を投げる。「JWTクレーム組み立て」ードに実装
②`/api/users/get`のレスポンス形状(配列/単一オブジェクト両対応の防御的実装、「Pleasanter照会・選択肢組み立て」ードに実装
③LINEWORKSトークン取得は`URLSearchParams`インスタンスをそのまま`body`に渡す形に修正済み(`.toString()`化や`json:true`併用はn8nのform-urlencoded自動処理と衝突する懸念があったため回避。レスポンスは文字列/オブジェクトいずれでも`access_token`を読めるよう防御的にパースしている。ただしn8n本体での実機動作は本タスクでは未検証
④`Data Table`Insertードの`columns.mappingMode`/`value`/`schema`の形状旧19ード版から流用。旧版はデプロイ実績があるためスキーマ自体は疑わしくないが、実書き込みでの動作は本タスクでは未再検証
- テスト実行Step 4相当は本タスクでは未実施。次回、捨てレコードでの疎通確認とユーザー確認が必要
- ワークフロー `HC-WP: Statusプッシュ通知`: `swXfpoDtwZDT3Hwk`
- ファイル: `workflows/hc-wp-status-push.json`
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push``X-Api-Key`ヘッダー認証、body `{resultId, processId}`
- active化済み
- `X-Api-Key`は`workflow_config_values`の`HEALTHCHECK_WP_API_KEY`と照合。秘密値のハードコードなし
- 実際のcurl疎通テスト本番508971書き込み・LINEWORKS実送信を伴うは未実施。ユーザー確認後に実施する
- デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した詳細: `task-10-report.md`。設定値取得もfilter付き単一キーGetから一括取得+Codeフィルタへ変更済み
- ワークフロー `HC-WA: LINEWORKS応答受信`: `0i0Ze3Gq0Waof3Fa`
- ファイル: `workflows/hc-wa-lineworks-response.json`
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`LINE WORKS本体からの直接コールバック、`x-works-signature`検証)
- active化済み2026-09-05、コントローラーが新規デプロイ・activate、HTTP 200
- **プロジェクト全体のn8nワークフロー構築ルール2026-09-05改訂に合わせて14ード構成で全面リビルド。旧60ード版個別キーごとのData Table/HTTP Requestードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)を置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ードのままだが、それらは機械的に必要な最小限IF×2, JWT×1, Execute Workflow×1, Data Table×3であり、本来Codeードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeードに集約したブリーフの目安「11ード」に対し実際は14ードだが、これはIF/JWT/Execute Workflowを専用ードに保つというルール自体が要求する固定オーバーヘッドであり、60ードからの削減という趣旨は達成している
- LINEWORKS Developer Console側のBot Callback URL設定・疎通テストは未実施。ユーザー確認後に実施する
- ード構成14ード:
1. `Webhook``healthcheck-lineworks-response`, `rawBody:true`
2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得
3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索`ColumnFilterHash`までを1ードに集約。0件/複数件はthrow自動判定しない
4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得
5. `Code`「分岐処理・アクション決定」: `awaitInput`none/date/file、または待機状態自体が無い場合に応じた分岐ロジック全体を1ードに集約。`action`execute/send/errorと次の待機状態`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`を1つのJSONオブジェクトとして出力する
6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update`
7. `IF`「action==execute」: true→8、false→9
8. `Execute Workflow`「HC-SUB実行」Task 9, `XRqcykbG2LuAjGG2`→13
9. `IF`「action==send」: true→10、false→14
10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ードに集約
11. `JWT`「Sign JWT」`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`
12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ードに集約→13
13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ード
14. `Respond to Webhook(異常系)`: 9のfalse分岐action=="error")専用
- **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ード5は「今回どうするかaction/processId/messageText」と「次に書き込む待機状態next*」を1つの出力オブジェクトにまとめて返す。ード6状態更新ード5の直接の後続ードなのでそのまま`$json.next*`を参照して無条件更新するが、ード6通過後の`$json`はData Table Updateの返り値更新後の行データに置き換わり、ード5の`action`/`processId`/`messageText`等は失われる。そのため、ード7以降IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とード名を明示して参照しているHC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約)
- 秘密情報・SiteId等はすべて`workflow_config_values``bNkadTyDgDepYx2p`から「設定値一括取得」1ードで取得し、直後のCodeードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
- 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`:
①Data Table `update`操作のパラメータ形状get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用
②`/api/users/get`・`api/items/{SiteId}/get`ColumnFilterHash検索のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲
③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装`SiteSettings.BotIds`または`Processes[].BotId`を仮定)
④**LINEWORKSファイル添付`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTードード11、action=="send"経路にのみ存在しード5より後段でしか行えないため、ード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持するユーザーが詰まらないための安全側実装。実装するにはグラフ構成の見直しファイル受信専用のJWT/Tokenペアを追加する等が必要で、フォローアップタスクで対応すること
- 設計上の注記: HC-SUBTask 9が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にしたTask 9側のファイルは変更していない
## n8nフォルダ
- フォルダ `健康診断ワークフロー`: `hOx8WKrdOucllbcU`projectId `LOcxF69Gm4PvnkqA`配下に2026-09-05作成
- HC-SUB/HC-WP/HC-WAの3ワークフローをこのフォルダへ移動する予定だが、Public APIの`/workflows/{id}/transfer`は別プロジェクトへの移動専用同一プロジェクト内移動は拒否されるで、実行するとactive状態が解除される副作用があるため、**ユーザーがn8n UI上で手動ドラッグして移動する**運用にした(コントローラーからは未実施)
## workflow_config_values 追加登録キー2026-09-05、healthcheck-survey-bot用
既存の`workflow_config_values``bNkadTyDgDepYx2p`、org-master-sync用と共用に以下を追加登録済み:
- `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT`LINEWORKS通知送信ワークフローと同じBot認証情報
- `HEALTHCHECK_SITE_ID`508971/ `LINEWORKS_BOT_MASTER_SITE_ID`484184/ `HEALTHCHECK_DATA_TABLE_ID``jqMDa2YZTI4f0iQ7`
- `HEALTHCHECK_WP_API_KEY`HC-WP Webhook認証用に新規生成
- `LINEWORKS_BOT_SECRET`(署名検証用。値は現状プレースホルダー`UNSET_PENDING_BOT_CALLBACK_CONFIRMATION`。LINEWORKS Developer ConsoleでBot Callback URL設定時に実際のBot Secretへ更新が必要

View File

@ -0,0 +1,10 @@
{
"name": "healthcheck-survey-bot",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"test": "node --test test/*.test.js",
"deploy-workflow": "node scripts/deploy-workflow.js"
}
}

View File

@ -0,0 +1,41 @@
// scripts/deploy-workflow.js
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("./n8n-api");
async function main() {
const [, , filePath, ...rest] = process.argv;
if (!filePath) {
console.error("使い方: node scripts/deploy-workflow.js <workflows/xxx.json> [--id=<既存workflowId>]");
process.exit(1);
}
const idArg = rest.find((a) => a.startsWith("--id="));
const existingId = idArg ? idArg.slice("--id=".length) : null;
const fullPath = path.resolve(filePath);
const definition = JSON.parse(fs.readFileSync(fullPath, "utf8"));
const body = {
name: definition.name,
nodes: definition.nodes,
connections: definition.connections,
settings: definition.settings || {},
};
const { status, body: result } = existingId
? await request("PUT", `/workflows/${existingId}`, body)
: await request("POST", "/workflows", body);
console.log("HTTP status:", status);
console.log(JSON.stringify(result, null, 2));
if (status >= 200 && status < 300 && result.id) {
console.log(`\nワークフローID: ${result.id}`);
console.log("README.mdの「n8nリソースID一覧」へ記録すること。");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,30 @@
// scripts/n8n-api.js
const N8N_BASE_URL = "https://n8n32.next-hd.net/api/v1";
const N8N_API_KEY = process.env.N8N_API_KEY;
if (!N8N_API_KEY) {
throw new Error(
"環境変数 N8N_API_KEY が未設定です。NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API Keyを設定してください。"
);
}
async function request(method, path, body) {
const res = await fetch(`${N8N_BASE_URL}${path}`, {
method,
headers: {
"X-N8N-API-KEY": N8N_API_KEY,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = text;
}
return { status: res.status, body: json };
}
module.exports = { request };

View File

@ -0,0 +1,92 @@
const ERA_INFO = {
"令和": 2018,
"平成": 1988,
"昭和": 1925,
"大正": 1911,
};
const ERA_ALIASES = { R: "令和", H: "平成", S: "昭和", T: "大正" };
function pad2(value) {
return String(value).padStart(2, "0");
}
function finalizeDateParts(year, month, day) {
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error("日付形式で回答してください(例: 2026-03-01");
}
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
throw new Error("存在しない日付です");
}
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function tryParseEraDate(compact) {
const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);
if (!match) return null;
let era = match[1];
if (/^[RHST]$/i.test(era)) {
era = ERA_ALIASES[era.toUpperCase()] || era;
}
if (!ERA_INFO[era]) return null;
const normalized = match[2]
.replace(/年/g, "-")
.replace(/月/g, "-")
.replace(/日/g, "")
.replace(/[.\/]/g, "-");
const parts = normalized.split("-").filter((part) => part.length > 0);
if (parts.length < 3) {
throw new Error("月と日まで入力してください(例: 令和6年3月1日");
}
const eraYear = Number(parts[0]);
const month = Number(parts[1]);
const day = Number(parts[2]);
if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error("日付形式で回答してください(例: 令和6年3月1日");
}
return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);
}
function tryParseMonthDay(compact) {
const match = compact.match(/^(\d{1,2})(?:月|\/|-|\.)(\d{1,2})(?:日)?$/);
if (!match) return null;
const currentYear = new Date().getFullYear();
return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));
}
function parseDateInput(value) {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
throw new Error("日付が空です");
}
const compact = trimmed.replace(/\s+/g, "");
const eraResult = tryParseEraDate(compact);
if (eraResult) return eraResult;
const monthDayResult = tryParseMonthDay(compact);
if (monthDayResult) return monthDayResult;
const normalized = compact
.replace(/年/g, "-")
.replace(/月/g, "-")
.replace(/日/g, "")
.replace(/[.\/]/g, "-");
const isoParts = normalized.split("-").filter((part) => part.length > 0);
if (isoParts.length === 3 && isoParts[0].length >= 4) {
return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new Error("日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日");
}
return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());
}
module.exports = { parseDateInput };

View File

@ -0,0 +1,18 @@
// src/lib/employeeMasterCheck.js
function checkEmailConsistency(pleasanterEmail, masterRecord) {
const masterEmail = masterRecord.Class036 || masterRecord.ClassB || null;
if (!masterEmail) {
return { consistent: false, masterEmail: null };
}
return { consistent: masterEmail === pleasanterEmail, masterEmail };
}
function resolveKanaFromMaster(masterRecord) {
return [masterRecord.Class003, masterRecord.Class004].filter(Boolean).join(" ");
}
function needsKanaFill(currentKana) {
return currentKana === null || currentKana === undefined || currentKana === "";
}
module.exports = { checkEmailConsistency, resolveKanaFromMaster, needsKanaFill };

View File

@ -0,0 +1,30 @@
function extractProcessesForStatus(processes, status) {
return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);
}
function matchProcessByLabel(processes, text) {
const trimmed = String(text ?? "").trim();
return processes.find((p) => (p.DisplayName || p.Name) === trimmed) || null;
}
function getValidationColumnNames(process) {
if (!Array.isArray(process.ValidateInputs)) return [];
return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean);
}
function classifyAwaitInput(process) {
const columnNames = getValidationColumnNames(process);
if (columnNames.length === 0) {
return { awaitInput: "none", column: null };
}
const column = columnNames[0];
if (column.startsWith("Date")) {
return { awaitInput: "date", column };
}
if (column.startsWith("Attachments")) {
return { awaitInput: "file", column };
}
return { awaitInput: "none", column: null };
}
module.exports = { extractProcessesForStatus, matchProcessByLabel, classifyAwaitInput };

View File

@ -0,0 +1,24 @@
const crypto = require("node:crypto");
function normalizeSignature(value) {
return String(value || "").trim().replace(/^sha256=/i, "");
}
function safeEqual(a, b) {
const ab = Buffer.from(String(a), "utf8");
const bb = Buffer.from(String(b), "utf8");
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function verifySignature(rawBody, headerSignature, botSecret) {
const headerSig = normalizeSignature(headerSignature);
if (!headerSig || !botSecret) return false;
const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64");
return safeEqual(headerSig, expected);
}
module.exports = { verifySignature };

View File

@ -0,0 +1,26 @@
function isUnsetSentinel(value) {
return typeof value === "string" && value.startsWith("1899");
}
function fillTemplate(template, columns, valueHash) {
const labelToColumnName = new Map();
for (const column of columns) {
if (column.LabelText) {
labelToColumnName.set(column.LabelText, column.ColumnName);
}
}
return template.replace(/\{([^{}]+)\}/g, (matched, label) => {
const columnName = labelToColumnName.get(label);
if (!columnName) {
return matched;
}
const value = valueHash[columnName];
if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) {
return "未設定";
}
return String(value);
});
}
module.exports = { fillTemplate };

View File

@ -0,0 +1,37 @@
// test/dateParser.test.js
const { test } = require("node:test");
const assert = require("node:assert");
const { parseDateInput } = require("../src/lib/dateParser");
test("ISO形式の日付をそのまま解釈する", () => {
assert.strictEqual(parseDateInput("2026-03-01"), "2026-03-01");
});
test("スラッシュ区切りの日付を解釈する", () => {
assert.strictEqual(parseDateInput("2026/3/1"), "2026-03-01");
});
test("和暦(令和)を西暦に変換する", () => {
assert.strictEqual(parseDateInput("令和6年3月1日"), "2024-03-01");
});
test("和暦の略記(R)を西暦に変換する", () => {
assert.strictEqual(parseDateInput("R6.3.1"), "2024-03-01");
});
test("月日のみの入力は今年として解釈する", () => {
const currentYear = new Date().getFullYear();
assert.strictEqual(parseDateInput("3/1"), `${currentYear}-03-01`);
});
test("空文字はエラーになる", () => {
assert.throws(() => parseDateInput(""), /日付が空です/);
});
test("存在しない日付はエラーになる", () => {
assert.throws(() => parseDateInput("2026-02-30"), /存在しない日付です/);
});
test("解釈不能な文字列はエラーになる", () => {
assert.throws(() => parseDateInput("あいうえお"));
});

View File

@ -0,0 +1,56 @@
// test/employeeMasterCheck.test.js
const { test } = require("node:test");
const assert = require("node:assert");
const {
checkEmailConsistency,
resolveKanaFromMaster,
needsKanaFill,
} = require("../src/lib/employeeMasterCheck");
test("checkEmailConsistency: PLメールアドレス(Class036)と一致すればconsistent:true", () => {
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {
Class036: "taro.yamada@next-hd.co.jp",
ClassB: "taro.yamada@example.com",
});
assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@next-hd.co.jp" });
});
test("checkEmailConsistency: Class036が無ければClassBにフォールバックする", () => {
const result = checkEmailConsistency("taro.yamada@example.com", {
ClassB: "taro.yamada@example.com",
});
assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@example.com" });
});
test("checkEmailConsistency: 不一致ならconsistent:false", () => {
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {
Class036: "different@next-hd.co.jp",
});
assert.deepStrictEqual(result, { consistent: false, masterEmail: "different@next-hd.co.jp" });
});
test("checkEmailConsistency: マスタ側にメールが無ければmasterEmail:null・consistent:false", () => {
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {});
assert.deepStrictEqual(result, { consistent: false, masterEmail: null });
});
test("resolveKanaFromMaster: 姓カナ+名カナを空白区切りで結合する", () => {
assert.strictEqual(
resolveKanaFromMaster({ Class003: "ヤマダ", Class004: "タロウ" }),
"ヤマダ タロウ"
);
});
test("resolveKanaFromMaster: 片方欠けていても結合できる", () => {
assert.strictEqual(resolveKanaFromMaster({ Class003: "ヤマダ" }), "ヤマダ");
});
test("needsKanaFill: 空文字・未定義はtrue", () => {
assert.strictEqual(needsKanaFill(""), true);
assert.strictEqual(needsKanaFill(undefined), true);
assert.strictEqual(needsKanaFill(null), true);
});
test("needsKanaFill: 値が入っていればfalse", () => {
assert.strictEqual(needsKanaFill("ヤマダ タロウ"), false);
});

View File

@ -0,0 +1,53 @@
const { test } = require("node:test");
const assert = require("node:assert");
const {
extractProcessesForStatus,
matchProcessByLabel,
classifyAwaitInput,
} = require("../src/lib/processFlow");
const processes = [
{ Id: 1, Name: "了承", DisplayName: "了承", CurrentStatus: 100, ChangedStatus: 200 },
{ Id: 2, Name: "日程変更", DisplayName: "日程変更", CurrentStatus: 100, ChangedStatus: 150, ValidateInputs: [{ Id: 1, ColumnName: "Date001", Required: true }] },
{ Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 },
{ Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, ValidateInputs: [{ Id: 1, ColumnName: "AttachmentsA", Required: true }] },
];
test("extractProcessesForStatus: 現在のStatusに一致するProcessのみ返す", () => {
const result = extractProcessesForStatus(processes, 100);
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result.map((p) => p.Id), [1, 2]);
});
test("extractProcessesForStatus: 一致するProcessが無ければ空配列", () => {
assert.deepStrictEqual(extractProcessesForStatus(processes, 999), []);
});
test("matchProcessByLabel: DisplayNameが完全一致するProcessを返す", () => {
const candidates = extractProcessesForStatus(processes, 100);
const matched = matchProcessByLabel(candidates, "日程変更");
assert.strictEqual(matched.Id, 2);
});
test("matchProcessByLabel: 前後の空白を無視して一致判定する", () => {
const candidates = extractProcessesForStatus(processes, 100);
const matched = matchProcessByLabel(candidates, " 了承 ");
assert.strictEqual(matched.Id, 1);
});
test("matchProcessByLabel: 一致しなければnull", () => {
const candidates = extractProcessesForStatus(processes, 100);
assert.strictEqual(matchProcessByLabel(candidates, "存在しない選択肢"), null);
});
test("classifyAwaitInput: ValidateInputsが無いProcessはnone", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[0]), { awaitInput: "none", column: null });
});
test("classifyAwaitInput: Date*列はdate", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[1]), { awaitInput: "date", column: "Date001" });
});
test("classifyAwaitInput: Attachments*列はfile", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[3]), { awaitInput: "file", column: "AttachmentsA" });
});

View File

@ -0,0 +1,27 @@
// test/signatureVerify.test.js
const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { verifySignature } = require("../src/lib/signatureVerify");
test("正しい署名はtrueを返す", () => {
const secret = "test-secret";
const body = JSON.stringify({ hello: "world" });
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64");
assert.strictEqual(verifySignature(body, signature, secret), true);
});
test("sha256=プレフィックス付き署名も検証できる", () => {
const secret = "test-secret";
const body = JSON.stringify({ hello: "world" });
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64");
assert.strictEqual(verifySignature(body, `sha256=${signature}`, secret), true);
});
test("不正な署名はfalseを返す", () => {
assert.strictEqual(verifySignature("body", "invalid-signature", "secret"), false);
});
test("署名ヘッダーが空ならfalseを返す", () => {
assert.strictEqual(verifySignature("body", "", "secret"), false);
});

View File

@ -0,0 +1,35 @@
// test/templateFill.test.js
const { test } = require("node:test");
const assert = require("node:assert");
const { fillTemplate } = require("../src/lib/templateFill");
const columns = [
{ ColumnName: "Class003", LabelText: "検査機関" },
{ ColumnName: "Date001", LabelText: "検査日" },
];
test("プレースホルダーをレコード値で置換する", () => {
const result = fillTemplate(
"検査機関: {検査機関}\n日程: {検査日}",
columns,
{ Class003: "next健診クリニック", Date001: "2026-04-01T00:00:00" }
);
assert.strictEqual(result, "検査機関: next健診クリニック\n日程: 2026-04-01T00:00:00");
});
test("未設定の日付センチネル値は「未設定」に変換する", () => {
const result = fillTemplate("日程: {検査日}", columns, {
Date001: "1899-12-30T00:00:00",
});
assert.strictEqual(result, "日程: 未設定");
});
test("値が無い列は「未設定」に変換する", () => {
const result = fillTemplate("検査機関: {検査機関}", columns, {});
assert.strictEqual(result, "検査機関: 未設定");
});
test("対応するラベルが見つからないプレースホルダーはそのまま残す", () => {
const result = fillTemplate("不明: {存在しないラベル}", columns, {});
assert.strictEqual(result, "不明: {存在しないラベル}");
});

View File

@ -0,0 +1,261 @@
{
"name": "HC-SUB: プロセス実行と案内送信",
"nodes": [
{
"id": "trigger-1",
"name": "Execute Workflow Trigger",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
220,
300
],
"parameters": {
"workflowInputs": {
"values": [
{
"name": "resultId"
},
{
"name": "processId"
}
]
}
}
},
{
"id": "dt-get-config",
"name": "設定値一括取得",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
440,
300
],
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "bNkadTyDgDepYx2p"
},
"returnAll": true
}
},
{
"id": "code-pleasanter-query",
"name": "Pleasanter照会・選択肢組み立て",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];"
}
},
{
"id": "code-jwt-claims",
"name": "JWTクレーム組み立て",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];"
}
},
{
"id": "jwt-sign",
"name": "Sign JWT",
"type": "n8n-nodes-base.jwt",
"typeVersion": 1,
"position": [
1100,
300
],
"parameters": {
"operation": "sign",
"useJson": true,
"claimsJson": "={{ $json.jwtClaims }}",
"options": {
"algorithm": "RS256"
}
},
"credentials": {
"jwtAuth": {
"id": "Hw0qlEaGfLPnQWp1",
"name": "LINEWORKS Bot Private Key (v4)"
}
}
},
{
"id": "code-lineworks-send",
"name": "アクセストークン取得・LINEWORKS送信",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
300
],
"parameters": {
"jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で\n// 行うため、素のURLSearchParamsを渡す.toString()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];"
}
},
{
"id": "dt-state-insert",
"name": "状態更新",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
1540,
300
],
"parameters": {
"operation": "insert",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "jqMDa2YZTI4f0iQ7"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"resultId": "={{ $json.resultId }}",
"targetEmail": "={{ $json.targetEmail }}",
"currentStatus": "={{ String($json.currentStatus) }}",
"pendingProcesses": "={{ JSON.stringify($json.options) }}",
"awaitInput": "none",
"awaitProcessId": "",
"awaitColumn": ""
},
"schema": [
{
"id": "resultId",
"displayName": "resultId",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "targetEmail",
"displayName": "targetEmail",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "currentStatus",
"displayName": "currentStatus",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "pendingProcesses",
"displayName": "pendingProcesses",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitInput",
"displayName": "awaitInput",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitProcessId",
"displayName": "awaitProcessId",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitColumn",
"displayName": "awaitColumn",
"required": false,
"type": "string",
"canBeUsedToMatch": true
}
]
}
}
}
],
"connections": {
"Execute Workflow Trigger": {
"main": [
[
{
"node": "設定値一括取得",
"type": "main",
"index": 0
}
]
]
},
"設定値一括取得": {
"main": [
[
{
"node": "Pleasanter照会・選択肢組み立て",
"type": "main",
"index": 0
}
]
]
},
"Pleasanter照会・選択肢組み立て": {
"main": [
[
{
"node": "JWTクレーム組み立て",
"type": "main",
"index": 0
}
]
]
},
"JWTクレーム組み立て": {
"main": [
[
{
"node": "Sign JWT",
"type": "main",
"index": 0
}
]
]
},
"Sign JWT": {
"main": [
[
{
"node": "アクセストークン取得・LINEWORKS送信",
"type": "main",
"index": 0
}
]
]
},
"アクセストークン取得・LINEWORKS送信": {
"main": [
[
{
"node": "状態更新",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,142 @@
{
"name": "HC-WP: Statusプッシュ通知",
"nodes": [
{
"id": "webhook-status-push",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "healthcheck-status-push",
"parameters": {
"httpMethod": "POST",
"path": "healthcheck-status-push",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "dt-get-healthcheck_wp_api_key",
"name": "設定値一括取得",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
440,
300
],
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "bNkadTyDgDepYx2p"
},
"returnAll": true
}
},
{
"id": "code-validate",
"name": "検証",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"jsCode": "const configRows = $('設定値一括取得').all().map((item) => item.json);\nconst expectedApiKey = configRows.find((row) => row.configKey === \"HEALTHCHECK_WP_API_KEY\")?.configValue;\nconst headers = $input.first().json.headers || {};\nif (headers[\"x-api-key\"] !== expectedApiKey) {\n throw new Error(\"Unauthorized: invalid API key\");\n}\nconst body = $input.first().json.body || {};\nif (!body.resultId || !body.processId) {\n throw new Error(\"Bad Request: resultId, processId は必須です\");\n}\nreturn [{ json: { resultId: body.resultId, processId: body.processId } }];"
}
},
{
"id": "execute-hc-sub",
"name": "HC-SUB実行",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [
880,
300
],
"parameters": {
"workflowId": {
"__rl": true,
"mode": "id",
"value": "XRqcykbG2LuAjGG2"
},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"resultId": "={{ $json.resultId }}",
"processId": "={{ $json.processId }}"
}
}
}
},
{
"id": "respond-ok",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1100,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}",
"options": {}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "設定値一括取得",
"type": "main",
"index": 0
}
]
]
},
"設定値一括取得": {
"main": [
[
{
"node": "検証",
"type": "main",
"index": 0
}
]
]
},
"検証": {
"main": [
[
{
"node": "HC-SUB実行",
"type": "main",
"index": 0
}
]
]
},
"HC-SUB実行": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,120 +1,146 @@
# 健康診断管理×LINEWORKSアンケート n8n化 設計書 # 健康診断管理×LINEWORKS Bot連携 n8n化 設計書
- 作成日: 2026-09-05 - 作成日: 2026-09-05
- 対象: プリザンター「健康診断管理」プロジェクトSiteId 508971へLINEWORKS Bot経由アンケート機能を実装 - 対象: プリザンター「健康診断管理」プロジェクトSiteId 508971へLINEWORKS Bot経由の対話型ステータス管理機能を実装
- 位置付け: 既存Express実装`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)と同等機能をn8nで新規構築。Expressの流用・移植ではなく作り直し - 位置付け: 既存Express実装`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)とは別物としてn8nで新規構築。単純な順次質問アンケートではなく、**プリザンターのProcess機能をそのままフロー定義として使うステータス駆動型対話ボット**
## 1. 背景・既存Express実装との違い ## 1. 背景・既存Express実装との違い
Express版ケアセブンプロジェクト向け以下の構成だった Express版ケアセブンプロジェクト向け「アンケート開始→設問を順番に送信→全問回答で1レコードcreate」という一方向の順次アンケートだった。今回の健康診断管理版は要件が異なる
- 「アンケート実行管理」を独立サイト484186として持ち、対象テーブル例: 車両点検報告484182へ回答を新規レコードcreateする - 508971は1レコード1回の健診。フローは「日程通知→了承/変更→受診確認→結果受取り」のようにレコードのStatusを段階的に進める対話であり、設問の連続ではない
- Bot・対象者マスタも専用サイト484184/484181を新設 - LINE WORKS Bot APIの制約上、**「トークを開いた瞬間」を検知するイベントは存在しない**(コールバックイベントは`message`/`join`/`leave`/`joined`/`left`/`postback`のみ。そのため「初期表示切替」は、①Statusが変わった瞬間にBotから能動的にメッセージを送るプッシュ型、②ユーザーが何か送信したら現在Statusの案内を返すフォールバック、の組み合わせで実現する
- セッション状態はExpressプロセスのメモリ`Map`)で保持 - アンケート実行管理・Bot・対象者マスタは新設せず、既存資産を流用する後述
- **フロー定義Statusごとの案内文・選択肢・遷移先を管理する専用マスタは新設しない。508971自体の`SiteSettings.Processes`(プロセス機能)をそのままフロー定義として使う**
今回の健康診断管理版は要件が異なるため、以下の方針で作り直す。 - サーバー実装はExpressでなくn8n。会話の待機状態はn8nプロセスのメモリではなく、n8n Data Table新規`bot_conversation_state`)で保持する
- **アンケート実行管理を独立サイトにせず、508971自体に統合**Process列追加。回答は新規レコードcreateではなく、発行元の508971レコード自体へupdateで書き戻す
- **Botマスタは新設せず、既存の「LINEWORKSアンケート管理」プロジェクトのSiteId 484184をそのまま参照**
- **対象者マスタは新設せず、プリザンター標準Usersのメールアドレスをそのまま LINEWORKS の宛先IDuserIdとして使う**LINEWORKS IDメールアドレスという前提。484181のような変換用マスタは不要
- **サーバー実装はExpressでなくn8n**。セッション状態はn8nプロセスのメモリではなく、n8n Data Table新規テーブル `survey_sessions`)で保持する
n8n環境自体の詳細URL・API・既存ワークフロー・過去の罠は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。以下の設計はこのガイドの制約コンテナメモリ768MB、Data Table操作の罠、Schedule Trigger運用方針等を踏まえている。 n8n環境自体の詳細URL・API・既存ワークフロー・過去の罠は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。以下の設計はこのガイドの制約コンテナメモリ768MB、Data Table操作の罠、Schedule Trigger運用方針等を踏まえている。
## 2. 全体アーキテクチャ ## 2. 前提とした既存資産の流用
``` | 用途 | 流用元 |
プリザンター(508971 健康診断管理データ本体)
└ Process「アンケート発行」ボタンflowIdごとに1つ
│ クライアントスクリプト fetch
n8n: W1「健診アンケート開始」(Webhook)
└ resultId取得 → ClassC(Users参照列)からPleasanterUserId解決
└ POST api/users/getView:{ApiGetMailAddresses:true})でメールアドレス取得
→ LINEWORKS userIdとしてそのまま使用
└ 508971自身のColumnsからflowId該当設問を抽出
└ n8n Data Table「survey_sessions」へセッション行Insert
└ 484184(LINEWORKS Bot)から該当BotId取得 → LINEWORKS Bot APIで第1問送信
LINEWORKS トークBot⇔対象者
│ 回答
n8n: W2「健診アンケート回答受信」(Webhook, 署名検証)
└ targetId(=メール)でsurvey_sessions検索 → 型検証 → 回答蓄積
└ 未完了 → 次設問送信 / セッション更新
└ 完了 → 508971へupdateHashネスト→ アンケート進捗列更新 → セッション削除
n8n: W3「健診アンケートタイムアウト監視」(Schedule Trigger、数分毎)
└ survey_sessions走査 → 期限切れセッションに終了通知 → 削除
```
## 3. プリザンター側508971変更点
| 追加項目 | 内容 |
|---|---| |---|---|
| 設問対象列 | アンケートで使う列の`ExtendedControlCss`に`survey survey-<flowId>`トークンを付与。1列が複数flowに属してもよいスペース区切りで複数トークン | | Bot | 既存「LINEWORKSアンケート管理」プロジェクトのSiteId 484184LINEWORKS Bot管理をそのまま参照。健康診断管理側にBotマスタは新設しない |
| アンケート進捗列(新規、列名は実装時に採番) | アンケートの発行・回答状況を管理する専用列。既存業務Status100申込中〜910対象外とは別軸で持つ | | 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先IDuserIdとして使う。LINE WORKS側からの受信時も`source.userId`=メールアドレスという前提で扱う(専用マッピングマスタは不要) |
| Process「アンケート発行」 | flowIdごとに1つずつ追加。`OnClick`でn8n WebhookのW1へPOST`resultId`・`flowId`を送信) | | 対象レコード特定 | LINEWORKSから届いたメッセージの送信者メール→508971の中で、そのユーザーが紐づくレコードのうち**未完了Status 900/910以外の最新レコード**を対象とする。複数該当時の自動判定は行わず、実装上は「複数ヒット」を異常系として扱う |
| ClassCUsers参照、既存 | そのまま流用。ここからPleasanterUserIdを取得し、n8n側でメールアドレスを解決する |
新規アンケートflowIdを追加する際の運用手順: 対象者解決は基本的にプリザンター標準Usersのメールアドレスだけで足りる想定。データ品質の裏付けチェック・補完に社員・組織マスタ管理テーブルSiteId 504412を使う件は8章参照Bot対話フローとは独立した補助機能
1. 該当列の`ExtendedControlCss`に`survey-<新flowId>`トークンを追記(既存列流用 or 新規列追加はflow内容次第で都度判断
2. 508971にProcess「◯◯アンケート発行」を追加、`flowId`を固定値でn8n Webhookへ渡すよう設定
3. n8n側の設問抽出ロジックは`ExtendedControlCss`のトークンでflowIdを判定するため、n8nワークフロー自体の改修は不要列追加・Process追加のみで新flowを増やせる設計
アンケート進捗列の具体的な値・遷移業務Statusとの整合を取るか等は、実際のアンケートフロー内容を設計する際に個別に確定する。本設計書では「既存業務Statusとは独立した列で管理する」という方針のみを定める。 ## 3. 全体アーキテクチャ
## 4. n8n側詳細 ```
[プリザンター508971] Statusが変わる契機は2種類
(a) 担当者がProcessボタンを押す例:①日程通知発行、日程確定時)
(b) Bot対話の結果としてProcessが実行される例:②→③)
### Data Table「survey_sessions」新規作成 (a)の場合
n8n: WP「Statusプッシュ通知」(Webhook, 508971クライアントスクリプトから起動)
└ resultId・processIdを受け取りレコード取得
└ 該当Processの案内文ツールチップ欄をレコード値で置換
└ 対象者メール解決 → LINEWORKS Bot APIで案内選択肢を送信
└ n8n Data Table「bot_conversation_state」に提示内容を記録
LINEWORKS トーク
│ ユーザー返信(ボタン押下/テキスト/ファイル)
n8n: WA「LINEWORKS応答受信」(Webhook、唯一の受信口、署名検証)
└ 送信者メール解決 → 508971の対象レコード特定
└ bot_conversation_stateの待機状態を見て回答を処理
└ 該当Processを`ProcessId`指定でapi/items/updateへ実行 → Status遷移
└ 遷移後の新Statusに紐づく次のProcess群を取得し、次の案内を送信WPと共通ロジック
```
## 4. フロー定義: 508971のProcess機能をそのまま流用
新規マスタサイト・新規テーブルは作らない。プリザンター標準の「プロセス」設定画面画面種別・現在の状況・変更後の状況・表示名・ツールチップ・入力検証タブ等を、Bot対話の定義としてそのまま使う。
| Processの項目 | Bot連携での役割 |
|---|---|
| 現在の状況CurrentStatus変更後の状況ChangedStatus | 既存のStatus遷移定義をそのまま使う |
| 表示名DisplayName | Botが提示する選択肢ボタンのラベル |
| ツールチップ | Bot案内文言。`{検査機関}` `{日程}`のようなプレースホルダーを書いておくと、n8nがレコードの実際の値に置換してから送信する |
| 入力検証タブの「項目」 | このProcess実行に追加入力を伴うかの判定に流用。列名プレフィックスで種別を判定する`Date*`→日付入力を1往復挟む、`Attachments*`→ファイル受信を1往復挟む、項目なし→即実行 |
| ProcessId | n8nが`POST /api/items/{resultId}/update`に`ProcessId`パラメータとして渡して実行する。公式マニュアル記載の通り、APIからのプロセス実行では入力検証プリザンター標準の検証機能は適用されない点に注意 |
新フローを追加する際の運用手順:
1. 508971のProcessを1つ追加現在の状況・変更後の状況・表示名・ツールチップ文言を設定
2. 追加入力が必要なら、入力検証タブの「項目」に対象列(`Date*`または`Attachments*`)を登録
3. n8nワークフローWP/WAは無改修。現在Statusに紐づくProcess一覧を都度`getsite`から動的に取得する設計のため、Process追加だけで新フローを反映できる
## 5. n8n側詳細
### Data Table「bot_conversation_state」新規
| 列 | 内容 | | 列 | 内容 |
|---|---| |---|---|
| sessionId | UUID |
| resultId | 508971のResultId | | resultId | 508971のResultId |
| flowId | アンケート種別識別子 |
| botId | 484184から取得したBotId |
| targetEmail | 対象者メールアドレスLINEWORKS userId | | targetEmail | 対象者メールアドレスLINEWORKS userId |
| currentIndex | 現在の設問番号 | | currentStatus | 直近提示時点のStatus値 |
| answersJson | ここまでの回答JSON文字列 | | pendingProcesses | 直近提示した選択肢一覧(`[{processId, label, validateColumn}]`のJSON文字列 |
| expiresAt | 現在設問のタイムアウト時刻 | | awaitInput | `none`(選択肢待ち)/`date`(日付入力待ち)/`file`(ファイル受信待ち) |
| startedAt | 開始日時(無応答タイムアウト判定用) | | awaitProcessId | 追加入力完了後に実行すべきProcessId`awaitInput`が`date`/`file`の間のみ使用) |
| firstAnswerAt | 初回応答有無判定用 | | awaitColumn | 追加入力先の列名(例: `Date001` |
| updatedAt | 最終更新日時 |
n8n-guide.md 7-1Clear出力の握り潰し・7-2複数行が後続へそのまま渡ると行数分繰り返し実行されるの罠を踏まえ、1セッション=1行の読み書きに限定し、複数セッションを一括で展開・集約する処理は入れない。 n8n-guide.md 7-1Clear出力の握り潰し・7-2複数行が後続へそのまま渡ると行数分繰り返し実行されるの罠を踏まえ、1レコード=1行の読み書きに限定し、複数行を横断する集約処理は入れない。セッションタイムアウトの概念は持たないユーザーの都合のいいタイミングで返信されればよい性質のフローのため、待機状態は無期限に保持する
### ワークフロー構成 ### ワークフロー構成
- **W1 開始**: Webhookトリガー`X-API-Key`ヘッダー認証、508971のクライアントスクリプト専用 - **WP Statusプッシュ通知**: Webhookトリガー`X-API-Key`ヘッダー認証、508971のクライアントスクリプト専用。担当者がProcessボタンを押した時に起動
- **W2 回答受信**: WebhookトリガーLINEWORKS本体からの直接コールバック、`x-works-signature`をHMAC-SHA256検証。Express版`verifyWebhookSignature`と同等ロジック) - **WA LINEWORKS応答受信**: WebhookトリガーLINE WORKS本体からの直接コールバック、`x-works-signature`をHMAC-SHA256検証。唯一の受信口
- **W3 タイムアウト監視**: Schedule Trigger数分間隔。n8n-guide.md 9章では「Schedule Trigger等の自動化は今回のスコープ外」としているが、**本機能ではセッションタイムアウト監視のために自動実行を解禁する**(今回の設計で追加する例外) - タイムアウト監視ワークフローは持たない3節参照
### WAの処理詳細
1. 署名検証 → 送信内容(テキスト/ボタン応答/ファイル)を判定
2. 送信者メール解決 → 508971の対象レコード未完了の最新1件を特定
3. `bot_conversation_state`から該当resultIdの待機状態を取得
4. **待機状態なし、または`awaitInput=none`で回答が選択肢と不一致** → 現在Statusに紐づくProcess群を`getsite`から再取得し、案内を再送(フォールバック)
5. **`awaitInput=none`で回答が選択肢DisplayNameと一致**:
- 対象列なし → 即座に`ProcessId`実行 → 新Statusの次Process群を取得 → 次の案内を送信
- 対象列が`Date*` → `awaitInput=date``awaitProcessId``awaitColumn`を記録し、「日付を入力してください」を追加送信Process実行は保留
- 対象列が`Attachments*` → 同様に`awaitInput=file`で保留し、ファイル送信を促す
6. **`awaitInput=date`** → 受信テキストを日付として検証Express版の和暦・月日省略対応ロジックを踏襲`awaitColumn`をupdate → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
7. **`awaitInput=file`** → 受信がファイルでなければ再送要求。ファイルならLINEWORKS Bot APIでダウンロード → Pleasanter添付ファイルAPIで`awaitColumn`へアップロード → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
### 認証まわり ### 認証まわり
- LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、実装時にBotIdとの対応関係を確認する - LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、BotIdとの対応関係を実装時に確認する
- 対象者メール解決: `POST api/users/get``View:{ApiGetMailAddresses:true}`、Offset+TotalCountページング。org-master-sync③で実績のある実装パターンをそのまま使う - 対象者メール解決: `POST api/users/get``View:{ApiGetMailAddresses:true}`。org-master-sync③で実績のある実装パターンを流用
## 5. 設問の型・検証ルール
Express版選択式数値自由記述の3種、日付は和暦・月日省略に対応と同等の検証ロジックをn8n上Codeードに再実装する。列名プレフィックスによる型判定`Class*`→選択式 or 短文、`Num*`→数値、`Date*`→日付、`Description*`→長文もExpress版を踏襲する。
## 6. エラーハンドリング・異常系 ## 6. エラーハンドリング・異常系
- キャンセルキーワード(取消/キャンセル/終了等)受信 → セッション削除508971のアンケート進捗列を「キャンセル」相当へ - 対象レコードが複数ヒット同一ユーザーの未完了レコードが2件以上→ 自動判定せず、担当者確認が必要な異常系として扱うBot応答は保留し、通知等は今後の実装計画で検討
- 必須設問での無回答・スキップ要求 → 再送(同じ設問を再送信) - 選択肢に一致しない回答 → 選択肢を再提示4節のフォールバックと同じ経路
- 型検証失敗(数値・日付フォーマット不正)→ エラーメッセージ+再送 - 日付形式不正/ファイル未送信 → エラーメッセージ+再送、待機状態は維持
- セッション未検出(期限切れ後の回答等)→ 「アンケートが開始されていません」を返信
- LINEWORKS送信失敗・508971 update失敗 → n8n Execution HistoryPostgres保存に残す。追加のログ実装はしない - LINEWORKS送信失敗・508971 update失敗 → n8n Execution HistoryPostgres保存に残す。追加のログ実装はしない
## 7. 導入・検証方針 ## 7. 導入・検証方針
- n8n運用ルールn8n-guide.md 9章に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認** - n8n運用ルールn8n-guide.md 9章に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認**
- 初回検証は1flow・1件のみで実施し、疎通確認後に複数flow展開へ進める - 初回検証は1Process・1件のみで実施し、疎通確認後にフロー全体①〜③の通し検証に進める
- 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う - 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う
## 8. 未確定事項今後のflow設計時に個別確定 ## 8. 補助機能Bot対話フローとは独立: 社員マスタ(504412)によるデータ品質チェック・補完
- アンケートflowの具体的な設問内容・対象列・flowId命名は本設計書のスコープ外。flow追加のたびに3章の運用手順に従って個別設計する 3〜7章のBot対話フローとは関係のない、508971のデータ品質を担保するための補助機能。n8nのHC-SUB/HC-WP/HC-WAワークフローには組み込まず、別途の仕組みバッチ・Pleasanter Process/ServerScript等、実装方式は別途検討として扱う。
- アンケート進捗列の具体的な値・遷移
- LINEWORKS Bot Private Key CredentialとBotIdの対応関係の実機確認 社員・組織マスタ管理テーブルSiteId 504412、2章参照の列構成`NodeSrv/apps/org-master-sync/configs/site-504412_社員・組織マスタ管理テーブル/`で取得済み):
| 504412の列 | 内容 |
|---|---|
| `Class011` | ユーザIDPleasanterUserId |
| `ClassB` | メールアドレス |
| `Class036` | PLメールアドレス |
| `Class003` / `Class004` | 姓(カナ)/名(カナ) |
- **メールアドレス整合性チェック**: 508971の`ClassC`Users参照から解決した対象者のPleasanterUserIdを軸に504412の`Class011`と突き合わせ、その社員の`Class036`PLメールアドレスと、`api/users/get`で解決した実際のメールアドレスが一致するかを検証する
- **フリガナ補完**: 508971の`ClassD`フリガナ、必須項目が空欄の場合、504412の`Class003`(姓(カナ))+`Class004`(名(カナ))から補完する
## 9. 未確定事項(実装時に個別確定)
- 具体的なProcess定義①日程通知〜③検査結果受取りの各Process内容、Status値の追加・修正は508971のStatus設計がまだ未完成のため、実装着手時に個別に設計する
- LINEWORKS Bot Private Key CredentialとBotId484184側の対応関係の実機確認
- LINEWORKS Bot APIでのファイル受信ダウンロードURL取得とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法
- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組みProcess流用の汎用性を保ったまま個別Processを増やしていく

View File

@ -0,0 +1,72 @@
// 健康診断管理 SiteId 508971: 手動登録済みProcess「自分手配」(Id:18)に対し、
// 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 = "自分手配";
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()));
})();

View File

@ -0,0 +1,133 @@
// 健康診断管理 SiteId 508971(社員別健康診断管理): Summaries(513156への集計設定)に、
// 既存のNum031/Num032と同じ条件(SiteId:513156, DestinationReferenceType:Results,
// LinkColumn:ClassZ, Type:Total, SourceColumn=DestinationColumn)でNum033~038/Num061~071/081~091を
// 追加するワンショットスクリプト。updatesite(Mode:full)を使う。
// --execute指定時のみ実際に送信、指定なければプレビューのみ出力。
"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 SITE_NAME = "社員別健康診断管理";
const DEST_SITE_ID = 513156;
const REQUEST_LABEL = "add-summaries-num033-038-and-061-091";
const TARGET_COLUMNS = [
"Num033", "Num034", "Num035", "Num036", "Num037", "Num038",
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
];
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() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const summaries = settings.Summaries || [];
const existingIds = summaries.map((s) => s.Id);
let nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
const added = [];
for (const col of TARGET_COLUMNS) {
if (summaries.some((s) => s.SourceColumn === col && s.SiteId === DEST_SITE_ID)) {
console.error(`[エラー] Summaries内に${col}(SiteId${DEST_SITE_ID}向け)が既に存在します(重複防止のため中断)。`);
process.exit(1);
}
const entry = {
SiteId: DEST_SITE_ID,
DestinationReferenceType: "Results",
DestinationColumn: col,
LinkColumn: "ClassZ",
Type: "Total",
SourceColumn: col,
Id: nextId,
};
summaries.push(entry);
added.push(entry);
nextId++;
}
settings.Summaries = summaries;
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, added };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, added } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log(`追加するSummaries(SiteId${DEST_SITE_ID}向け):`);
for (const s of added) console.log(` - Id${s.Id}: ${s.SourceColumn}${s.DestinationColumn} (Type:${s.Type}, LinkColumn:${s.LinkColumn})`);
console.log(`\nSummaries件数: 変更前${(current.SiteSettings.Summaries || []).length} → 変更後${desiredSettings.Summaries.length}`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,140 @@
// 健康診断管理 SiteId 513156(実施年度-集計): site-508971_社員別健康診断管理のNum061~071/081~091の
// Column設定(LabelText/ControlType/NoWrap/Min/Max/Description)をそのまま複製して新規追加し、
// 508971のStyles(Id1:基本スタイル調整→Id2:数値エリア調整の順)も複製して適用するワンショットスクリプト。
// EditorColumnHash.General(Num061~091)は画面上で追加済みのため変更しない。updatesite(Mode:full)を使う。
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
"use strict";
const fs = require("fs");
const path = require("path");
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
const PROJECT_NAME = "健康診断管理";
const ENV = "production";
const SRC_SITE_ID = 508971;
const DST_SITE_ID = 513156;
const DST_SITE_NAME = "実施年度-集計";
const REQUEST_LABEL = "apply-508971-num-columns-and-styles";
const NUM_COLUMN_NAMES = [
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
];
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() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest(siteId) {
const siteDir = findSiteDir(CONFIGS_DIR, siteId);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`);
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
}
function buildDesiredSiteSettings(srcCurrent, dstCurrent) {
const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings));
const addedColumns = [];
for (const name of NUM_COLUMN_NAMES) {
if (settings.Columns.some((c) => c.ColumnName === name)) {
console.error(`[エラー] 移行先Columns内に${name}が既に存在します(重複追加防止のため中断)。`);
process.exit(1);
}
const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name);
if (!src) {
console.error(`[エラー] 移行元(508971)Columns内に${name}が見つかりません。`);
process.exit(1);
}
const col = JSON.parse(JSON.stringify(src));
settings.Columns.push(col);
addedColumns.push(col);
}
// Styles: 508971のId1(基本スタイル調整)→Id2(数値エリア調整)の順で複製
const srcStyles = srcCurrent.SiteSettings.Styles || [];
const style1 = srcStyles.find((s) => s.Id === 1);
const style2 = srcStyles.find((s) => s.Id === 2);
if (!style1 || !style2) {
console.error("[エラー] 移行元(508971)にStyle Id1/Id2が見つかりません。");
process.exit(1);
}
settings.Styles = [
{ ...style1 },
{ ...style2 },
];
// Links省略厳禁(全置換のため既存値をそのまま含める。移行先に元々設定が無ければ空配列)
settings.Links = dstCurrent.SiteSettings.Links || [];
return { settings, addedColumns };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: DST_SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current: srcCurrent } = loadLatest(SRC_SITE_ID);
const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID);
const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent);
const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings);
const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`);
console.log("追加するColumns(508971から複製):");
for (const c of addedColumns) console.log(` - ${c.ColumnName}: ${c.LabelText}`);
console.log("適用するStyles(508971から複製、順序: Id1→Id2):");
for (const s of desiredSettings.Styles) console.log(` - Id${s.Id}: ${s.Title}`);
console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,128 @@
// 健康診断管理 SiteId 508971(社員別健康診断管理): 513156(実施年度-集計)側で
// NumA~H→Num031~038へ置き換えた後の設定(LabelText/Unit/TextAlign/NoWrap等)をそのまま複製し、
// 508971のColumnsへ新規追加するワンショットスクリプト。EditorColumnHash._Tab-2(カウンタータブ)には
// 画面上で既にNum031~038が配置済み(Columns定義のみ未設定)のため、EditorColumnHashは変更しない。
// 508971の既存Num項目(NumA=年齢, Num001~003, Num061~091)には一切触れない。
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
"use strict";
const fs = require("fs");
const path = require("path");
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
const PROJECT_NAME = "健康診断管理";
const ENV = "production";
const SRC_SITE_ID = 513156;
const DST_SITE_ID = 508971;
const DST_SITE_NAME = "社員別健康診断管理";
const REQUEST_LABEL = "apply-num031-038-columns";
const NUM_COLUMN_NAMES = ["Num031", "Num032", "Num033", "Num034", "Num035", "Num036", "Num037", "Num038"];
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() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest(siteId) {
const siteDir = findSiteDir(CONFIGS_DIR, siteId);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`);
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
}
function buildDesiredSiteSettings(srcCurrent, dstCurrent) {
const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings));
const tab2 = settings.EditorColumnHash["_Tab-2"] || [];
const addedColumns = [];
for (const name of NUM_COLUMN_NAMES) {
if (settings.Columns.some((c) => c.ColumnName === name)) {
console.error(`[エラー] 移行先(508971)Columns内に${name}が既に存在します(重複防止のため中断)。`);
process.exit(1);
}
if (!tab2.includes(name)) {
console.error(`[エラー] EditorColumnHash._Tab-2内に${name}が見つかりません。画面上の配置状況を確認してください。`);
process.exit(1);
}
const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name);
if (!src) {
console.error(`[エラー] 移行元(513156)Columns内に${name}が見つかりません。`);
process.exit(1);
}
const col = JSON.parse(JSON.stringify(src));
settings.Columns.push(col);
addedColumns.push(col);
}
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = dstCurrent.SiteSettings.Links || [];
return { settings, addedColumns };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: DST_SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current: srcCurrent } = loadLatest(SRC_SITE_ID);
const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID);
const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent);
const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings);
const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`);
console.log("追加するColumns(513156から複製):");
for (const c of addedColumns) console.log(` - ${JSON.stringify(c)}`);
console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,113 @@
// 健康診断管理 SiteId 508971: 有所見者数側(Num081~091)のNoWrapをfalseに修正するワンショットスクリプト。
// restructure-site-508971-tab2-counts.jsで追加した際、実施人数側(Num061~071)のNoWrap:trueを
// そのまま複製していたため、有所見者数側のみfalseへ訂正する。updatesite(Mode:full)を使う。
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
"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 SITE_NAME = "健康診断管理";
const REQUEST_LABEL = "fix-arishoken-nowrap";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
const TARGET_COLUMNS = [
"Num081", "Num082", "Num083", "Num084", "Num085",
"Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
];
function loadServerConfig() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const changed = [];
for (const name of TARGET_COLUMNS) {
const col = settings.Columns.find((c) => c.ColumnName === name);
if (!col) {
console.error(`[エラー] Columns内に${name}が見つかりません。`);
process.exit(1);
}
const before = col.NoWrap;
col.NoWrap = false;
changed.push({ name, before, after: col.NoWrap, label: col.LabelText });
}
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, changed };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log("NoWrap変更:");
for (const c of changed) console.log(` - ${c.name} (${c.label}): ${c.before}${c.after}`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,129 @@
// 健康診断管理 SiteId 513156(実施年度-集計): EditorColumnHash.Generalの並びを
// Num061→Num081→Num062→Num082…と交互配置に組み替えるワンショットスクリプト
// (508971の並びに合わせる。508971にはNum061~071→Num081~091の連続配置で反映されており見た目が揃わないため)。
// Columns自体の変更は無く配置順のみ変更。updatesite(Mode:full)を使う。
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
"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 = 513156;
const SITE_NAME = "実施年度-集計";
const REQUEST_LABEL = "fix-editorcolumnhash-order";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
// [実施人数側, 有所見者数側] のペア。左の直後に右を差し込む
const PAIRS = [
["Num061", "Num081"],
["Num062", "Num082"],
["Num063", "Num083"],
["Num064", "Num084"],
["Num065", "Num085"],
["Num066", "Num086"],
["Num067", "Num087"],
["Num068", "Num088"],
["Num069", "Num089"],
["Num070", "Num090"],
["Num071", "Num091"],
];
function loadServerConfig() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const general = settings.EditorColumnHash.General;
const pairSrcSet = new Set(PAIRS.map(([src]) => src));
const arishokenSet = new Set(PAIRS.map(([, dst]) => dst));
const rebuilt = [];
for (const name of general) {
if (arishokenSet.has(name)) continue; // 有所見者数側は元位置から除去し、対応する実施人数側の直後へ差し込む
rebuilt.push(name);
if (pairSrcSet.has(name)) {
const pair = PAIRS.find(([src]) => src === name);
rebuilt.push(pair[1]);
}
}
settings.EditorColumnHash.General = rebuilt;
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, rebuilt };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, rebuilt } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log("EditorColumnHash.General 変更後の並び:");
console.log(` [${rebuilt.join(", ")}]`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,120 @@
// 健康診断管理 SiteId 513156(実施年度-集計): 診断結果集計Num項目(Num061~071/081~091)の
// ControlType/Min/Maxを削除し、Unit:"人"/TextAlign:20を付与するワンショットスクリプト
// (NumA~Hは既にUnit:"人"/TextAlign:20設定済みのため対象外。508971側のSpinner/Min0/Max1指定は
// このサイトの集計項目には不要なため、508971とは別設定にする)。
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
"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 = 513156;
const SITE_NAME = "実施年度-集計";
const REQUEST_LABEL = "fix-num-attrs-unit-textalign";
const TARGET_COLUMNS = [
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
];
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() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const changed = [];
for (const name of TARGET_COLUMNS) {
const col = settings.Columns.find((c) => c.ColumnName === name);
if (!col) {
console.error(`[エラー] Columns内に${name}が見つかりません。`);
process.exit(1);
}
const before = JSON.parse(JSON.stringify(col));
delete col.ControlType;
delete col.Min;
delete col.Max;
col.Unit = "人";
col.TextAlign = 20;
changed.push({ name, before, after: JSON.parse(JSON.stringify(col)) });
}
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, changed };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log("変更内容:");
for (const c of changed) {
console.log(` - ${c.name}: ${JSON.stringify(c.before)}${JSON.stringify(c.after)}`);
}
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,133 @@
// 健康診断管理 SiteId 513156(実施年度-集計): NumA~NumH(社員集計項目)をNum031~038へ置き換える
// ワンショットスクリプト。設定(LabelText/Unit/TextAlign/NoWrap等)はそのまま維持し、ColumnNameのみ変更。
// EditorColumnHash.General内の配置位置もNumA~H→Num031~038に置換。既存アイテム件数0件を確認済みのため
// データ移行は行わない(ColumnNameはPleasanterの物理列名に直結するため、データがあれば別途移行が必要)。
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
"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 = 513156;
const SITE_NAME = "実施年度-集計";
const REQUEST_LABEL = "rename-numah-to-num031-038";
// [旧ColumnName, 新ColumnName]
const RENAME_PAIRS = [
["NumA", "Num031"],
["NumB", "Num032"],
["NumC", "Num033"],
["NumD", "Num034"],
["NumE", "Num035"],
["NumF", "Num036"],
["NumG", "Num037"],
["NumH", "Num038"],
];
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() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const renamedColumns = [];
for (const [oldName, newName] of RENAME_PAIRS) {
const col = settings.Columns.find((c) => c.ColumnName === oldName);
if (!col) {
console.error(`[エラー] Columns内に${oldName}が見つかりません。`);
process.exit(1);
}
if (settings.Columns.some((c) => c.ColumnName === newName)) {
console.error(`[エラー] Columns内に${newName}が既に存在します(重複防止のため中断)。`);
process.exit(1);
}
col.ColumnName = newName;
renamedColumns.push({ oldName, newName, label: col.LabelText });
}
const general = settings.EditorColumnHash.General;
const renameMap = new Map(RENAME_PAIRS);
settings.EditorColumnHash.General = general.map((name) => renameMap.get(name) || name);
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, renamedColumns };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, renamedColumns } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log("置き換え内容:");
for (const c of renamedColumns) console.log(` - ${c.oldName}${c.newName} (${c.label})`);
console.log("EditorColumnHash.General 変更後:");
console.log(` [${desiredSettings.EditorColumnHash.General.join(", ")}]`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,160 @@
// 健康診断管理 SiteId 508971: タブ「診断結果」のNum061~071(検査項目)に
// ラベル末尾" (実施人数)"を付与し、同じ設定(ControlType/NoWrap/Min/Max/Description)を
// 複製したNum081~091(ラベル末尾" (有所見者数)")を新規追加、EditorColumnHash._Tab-1の並びを
// Num061→Num081→Num062→Num082…のように交互配置へ組み替えるワンショットスクリプト。
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
"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 SITE_NAME = "健康診断管理";
const REQUEST_LABEL = "tab2-jisshi-arishoken-counts";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
// [実施人数側(既存), 有所見者数側(新規)] のペア。左の設定をそのまま右へ複製する
const PAIRS = [
["Num061", "Num081"],
["Num062", "Num082"],
["Num063", "Num083"],
["Num064", "Num084"],
["Num065", "Num085"],
["Num066", "Num086"],
["Num067", "Num087"],
["Num068", "Num088"],
["Num069", "Num089"],
["Num070", "Num090"],
["Num071", "Num091"],
];
function loadServerConfig() {
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (!config.BaseUrl || !config.ApiKey) {
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
process.exit(1);
}
return config;
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
if (!siteDir) {
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
process.exit(1);
}
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") };
}
function buildDesiredSiteSettings(current) {
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
const columns = settings.Columns;
const addedColumns = [];
for (const [srcName, newName] of PAIRS) {
const src = columns.find((c) => c.ColumnName === srcName);
if (!src) {
console.error(`[エラー] Columns内に${srcName}が見つかりません。`);
process.exit(1);
}
if (columns.some((c) => c.ColumnName === newName)) {
console.error(`[エラー] Columns内に${newName}が既に存在します(重複追加防止のため中断)。`);
process.exit(1);
}
const originalLabel = src.LabelText;
src.LabelText = `${originalLabel} (実施人数)`;
const newCol = JSON.parse(JSON.stringify(src));
newCol.ColumnName = newName;
newCol.LabelText = `${originalLabel} (有所見者数)`;
columns.push(newCol);
addedColumns.push(newCol);
}
// EditorColumnHash._Tab-1 の並びをNum061→Num081→Num062→Num082…と交互に組み替える
const tab1 = settings.EditorColumnHash["_Tab-1"];
const pairSrcSet = new Set(PAIRS.map(([src]) => src));
const rebuilt = [];
for (const name of tab1) {
rebuilt.push(name);
if (pairSrcSet.has(name)) {
const pair = PAIRS.find(([src]) => src === name);
rebuilt.push(pair[1]);
}
}
settings.EditorColumnHash["_Tab-1"] = rebuilt;
// Links省略厳禁(全置換のため既存値をそのまま含める)
settings.Links = current.SiteSettings.Links || [];
return { settings, addedColumns, rebuiltTab1: rebuilt };
}
function buildUpdateBody(apiKey, current, desiredSettings) {
return {
ApiVersion: "1.1",
ApiKey: apiKey,
SiteId: SITE_ID,
Title: current.Title,
ReferenceType: current.ReferenceType,
ParentId: current.ParentId,
InheritPermission: current.InheritPermission,
SiteSettings: desiredSettings,
};
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const { settings: desiredSettings, addedColumns, rebuiltTab1 } = buildDesiredSiteSettings(current);
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
console.log("ラベル変更(実施人数):");
for (const [srcName] of PAIRS) {
const c = desiredSettings.Columns.find((x) => x.ColumnName === srcName);
console.log(` - ${srcName}: ${c.LabelText}`);
}
console.log("新規追加(有所見者数):");
for (const c of addedColumns) {
console.log(` - ${c.ColumnName}: ${c.LabelText}`);
}
console.log("EditorColumnHash._Tab-1 変更後の並び:");
console.log(` [${rebuiltTab1.join(", ")}]`);
console.log(`\nColumns件数: 変更前${current.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
console.log(`プレビュー保存先: ${updatedPath}`);
if (!execute) {
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
console.log(`送信先: ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
})();

View File

@ -0,0 +1,68 @@
// 健康診断管理 SiteId 508971: 既存のクライアントスクリプト「プロセス実行スクリプト」のBodyのみ
// ローカルファイルの最新内容で上書きする。Columns/EditorColumnHash/Processesには一切触れない。
"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 = "update-clientscript-process";
const TITLE = "プロセス実行スクリプト";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
const SCRIPT_FILE = path.join(
CONFIGS_DIR,
"site-508971_社員別健康診断管理",
"scripts",
"3_プロセス実行スクリプト.js"
);
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 script = (settings.Scripts || []).find((s) => s.Title === TITLE);
if (!script) {
console.error(`[エラー] スクリプト「${TITLE}」が見つかりません。`);
process.exit(1);
}
const newBody = fs.readFileSync(SCRIPT_FILE, "utf-8");
script.Body = newBody;
const requestBody = {
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("更新後Body文字数:", newBody.length);
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(requestBody) });
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json()));
})();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
# スクリーンショット撮影用テストケース計画2026-09-12実施予定
目的: マニュアル「健康診断Bot機能_仕様(管理担当者向け).md」への画像挿入用。全ケース、対象レコード513409野木健一郎さん、事前Status=日程確定(前回セッション終了時点)。
各ケース、実施前に前提Statusへ調整API直接updateで可。実施後は元の状態Status=日程確定、Class003=511926、Date002=2026-09-08T10:11:00へ復元してから次ケースへ。
活性化必要ワークフロー: HC-WA`0i0Ze3Gq0Waof3Fa`常時、HC-WP`swXfpoDtwZDT3Hwk`はHC-WP経由ケースのみ。両方、検証後は必ずinactiveへ戻す。
---
## ケース1: 健康診断メニュー表示3パターン
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 1-1 | 日程通知済み | 「健康診断について」タップ | ボタン「受診日時について」担当者に連絡の2択 | 5-1 |
| 1-2 | 日程確定 | 同上 | ボタン「受診日時について」「受診結果について」の3択 | 5-1 |
| 1-3 | 受診済 | 同上 | ボタン「受診結果について」の2択 | 5-1 |
| 1-4 | 再検査通知 | 同上 | ボタン「再検査について」の2択 | 5-1 |
| 1-5 | 完了等(対象外) | 同上 | 「現在ご案内できる項目はありません」 | 5-1任意 |
---
## ケース2: 受診日時についてパターンA
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 2-1 | 日程通知済み | 「受診日時について」タップ | 候補日提示+承諾/受診日時の変更/自分手配/担当者に連絡の4択 | 5-2 |
| 2-2 | 2-1の続き | 「承諾」タップ | 「日程を確定しました。」 | 5-2 |
| 2-3 | 日程通知済み(再設定) | 「受診日時について」→「受診日時の変更」 | 検査機関連絡先(病院名・電話番号・担当者) | 5-2 |
| 2-4 | 2-3の続き | 日付テキスト送信(例: `10/20` | 「2026-10-20でよろしいですか」承諾/再入力 | 5-2 |
| 2-5 | 2-4の続き | 「承諾」タップ | 「日程を確定しました。」 | 5-2 |
| 2-6 | 日程通知済み(再設定) | 「受診日時について」→「自分手配」 | 「ご自分で検査機関を手配して受診されるとのことで承知しました。」 | 5-2 |
| 2-7 | 日程通知済み(再設定) | 「受診日時の変更」→認識不能文字列を3回連続送信 | 3回目「日付を認識できないため、いったん受付を中断します。」 | 5-2 |
| 2-8 | 日程確定 | 「受診日時について」タップ | 「■あなたの本年度の健康診断について」+確定内容+受診日時の変更ボタン | 5-2 |
---
## ケース3: 受診結果についてパターンB
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 3-1 | 日程確定 | 「受診結果について」タップ | 「受診完了」「未受診」の2択 | 5-3 |
| 3-2 | 3-1の続き | 「受診完了」タップ | 「健康診断の受診、ありがとうございます。」 | 5-3 |
| 3-3 | 日程確定(再設定) | 「受診結果について」→「未受診」 | 「再手配」「自分手配」の2択 | 5-3 |
| 3-4 | 3-3の続き | 「再手配」タップ | 検査機関連絡先提示 | 5-3 |
| 3-5 | 3-4の続き | 日付送信→承諾 | 「受診日時を更新しました。」 | 5-3 |
| 3-6 | 日程確定(再設定) | 「未受診」→「自分手配」 | 「ご自分で検査機関を手配して受診されるとのことで承知しました。」 | 5-3 |
| 3-7 | 受診済 | 「受診結果について」タップ | 健康診断結果アップロード案内5-4と共通 | 5-3 |
---
## ケース4: 健康診断結果の提出
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 4-1 | 受診済file待ち | PDFファイル送信 | 「1件受け付けました。続けてファイルがあれば送信、なければ「完了」と送信してください」 | 5-4 |
| 4-2 | 同上 | PDF以外画像等送信 | 「診断結果をお送りいただきましたが、画像での送信でしたので、PDFで再度お送り頂きますようおねがいいたします。」 | 5-4 |
| 4-3 | 4-1の続き | 「完了」送信 | 「診断書を受け付けました。」 | 5-4 |
4-1完了後、テスト添付ファイルは撮影後に必ずPleasanter画面から手動削除API削除は反映されないことを確認済み
---
## ケース5: 再検査についてパターンC
前提Status=再検査通知(一時的に設定、テスト後は復元)。
| # | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|
| 5-1 | 「健康診断について」→「再検査について」 | 「受診・再検査報告」ボタン1択 | 5-5 |
| 5-2 | 「受診・再検査報告」タップ | 1問目「a. 再検査受診日を教えてください」 | 5-5 |
| 5-3 | 日付回答(例: `11/10` | 確認「a. 再検査受診日11/10でよろしいですか」はい/いいえ | 5-5 |
| 5-4 | 「はい」 | 2問目「b. 受診病院および医師名を教えてください」 | 5-5 |
| 5-5 | c・d問も同様に回答各確認込み | 4問目回答後の確認 | 5-5任意 |
| 5-6 | d確認「はい」 | 「再検査の報告を受け付けました。ご対応ありがとうございました。」 | 5-5 |
---
## ケース6: HC-WP経由の担当者プッシュ通知
要HC-WP activate。Webhook直接叩きで実施Pleasanter側Process実行→n8n Webhook POST
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 6-1 | 検査機関確定 | プロセス「日程通知済み」実行→Webhook POSTresultId/processId=5 | 「■本年度の健康診断が手配されました」+確定内容 | 4-2 |
| 6-2 | 6-1直後 | 同一送信の続き | 候補日確認ボタン(承諾/受診日時の変更/自分手配/担当者に連絡) | 4-2 |
| 6-3 | 就業判定(不可) | プロセス「再検査通知」実行→Webhook POSTprocessId=11 | 「■健康診断の再検査について」固定文言 | 4-3 |
---
## ケース7: 担当者に連絡
| # | 経由 | 操作 | 撮る画面 | マニュアル挿入先 |
|---|---|---|---|---|
| 7-1 | button_template内 | 「健康診断について」→3択内の「担当者に連絡」 | 案内文言《総務・契約関連窓口》URL | 7 |
| 7-2 | 固定メニュー直接 | 固定メニューの「担当者に連絡」 | 同上(待機状態リセットされる点は画面上は同じ表示) | 7任意、1本で足りる |
---
## 撮影時の共通注意
- スマホ実機LINEWORKSアプリでの撮影を基本とする。マニュアルの主読者は担当者であり、社員が実際に見る画面スマホを見せる方が伝わりやすい
- 個人情報(本名・電話番号等)が写り込む画面は、マニュアル掲載前にモザイク等の要否を確認する(野木健一郎さんの実データを使うため、`医療機関:日の出ヶ丘病院`等は許容範囲と思われるが、電話番号・担当者名部分は要相談)
- 各ケース実施後、Pleasanter側の状態Status・Class003・Date002・DescriptionBとn8n Data Tablehealthcheck_bot_stateを毎回、次ケース前に復元・確認する
- HC-WA/HC-WPは実施の都度activate→検証→deactivateを徹底する常時activeにしない

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
# 健康診断管理×LINEWORKS Bot連携 n8n化 設計書
- 作成日: 2026-09-05
- 対象: プリザンター「健康診断管理」プロジェクトSiteId 508971へLINEWORKS Bot経由の対話型ステータス管理機能を実装
- 位置付け: 既存Express実装`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`とは別物としてn8nで新規構築。単純な順次質問アンケートではなく、**プリザンターのProcess機能をそのままフロー定義として使うステータス駆動型対話ボット**
## 1. 背景・既存Express実装との違い
Express版ケアセブンプロジェクト向けは「アンケート開始→設問を順番に送信→全問回答で1レコードcreate」という一方向の順次アンケートだった。今回の健康診断管理版は要件が異なる。
- 508971は1レコード1回の健診。フローは「日程通知→了承/変更→受診確認→結果受取り」のようにレコードのStatusを段階的に進める対話であり、設問の連続ではない
- LINE WORKS Bot APIの制約上、**「トークを開いた瞬間」を検知するイベントは存在しない**(コールバックイベントは`message`/`join`/`leave`/`joined`/`left`/`postback`のみ。そのため「初期表示切替」は、①Statusが変わった瞬間にBotから能動的にメッセージを送るプッシュ型、②ユーザーが何か送信したら現在Statusの案内を返すフォールバック、の組み合わせで実現する
- アンケート実行管理・Bot・対象者マスタは新設せず、既存資産を流用する後述
- **フロー定義Statusごとの案内文・選択肢・遷移先を管理する専用マスタは新設しない。508971自体の`SiteSettings.Processes`(プロセス機能)をそのままフロー定義として使う**
- サーバー実装はExpressでなくn8n。会話の待機状態はn8nプロセスのメモリではなく、n8n Data Table新規`bot_conversation_state`)で保持する
n8n環境自体の詳細URL・API・既存ワークフロー・過去の罠は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。以下の設計はこのガイドの制約コンテナメモリ768MB、Data Table操作の罠、Schedule Trigger運用方針等を踏まえている。
## 2. 前提とした既存資産の流用
| 用途 | 流用元 |
|---|---|
| Bot | 既存「LINEWORKSアンケート管理」プロジェクトのSiteId 484184LINEWORKS Bot管理をそのまま参照。健康診断管理側にBotマスタは新設しない |
| 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先IDuserIdとして使う。LINE WORKS側からの受信時も`source.userId`=メールアドレスという前提で扱う(専用マッピングマスタは不要) |
| 対象レコード特定 | LINEWORKSから届いたメッセージの送信者メール→508971の中で、そのユーザーが紐づくレコードのうち**未完了Status 900/910以外の最新レコード**を対象とする。複数該当時の自動判定は行わず、実装上は「複数ヒット」を異常系として扱う |
対象者解決は基本的にプリザンター標準Usersのメールアドレスだけで足りる想定。データ品質の裏付けチェック・補完に社員・組織マスタ管理テーブルSiteId 504412を使う件は8章参照Bot対話フローとは独立した補助機能
## 3. 全体アーキテクチャ
```
[プリザンター508971] Statusが変わる契機は2種類
(a) 担当者がProcessボタンを押す例:①日程通知発行、日程確定時)
(b) Bot対話の結果としてProcessが実行される例:②→③)
(a)の場合
n8n: WP「Statusプッシュ通知」(Webhook, 508971クライアントスクリプトから起動)
└ resultId・processIdを受け取りレコード取得
└ 該当Processの案内文ツールチップ欄をレコード値で置換
└ 対象者メール解決 → LINEWORKS Bot APIで案内選択肢を送信
└ n8n Data Table「bot_conversation_state」に提示内容を記録
LINEWORKS トーク
│ ユーザー返信(ボタン押下/テキスト/ファイル)
n8n: WA「LINEWORKS応答受信」(Webhook、唯一の受信口、署名検証)
└ 送信者メール解決 → 508971の対象レコード特定
└ bot_conversation_stateの待機状態を見て回答を処理
└ 該当Processを`ProcessId`指定でapi/items/updateへ実行 → Status遷移
└ 遷移後の新Statusに紐づく次のProcess群を取得し、次の案内を送信WPと共通ロジック
```
## 4. フロー定義: 508971のProcess機能をそのまま流用
新規マスタサイト・新規テーブルは作らない。プリザンター標準の「プロセス」設定画面画面種別・現在の状況・変更後の状況・表示名・ツールチップ・入力検証タブ等を、Bot対話の定義としてそのまま使う。
| Processの項目 | Bot連携での役割 |
|---|---|
| 現在の状況CurrentStatus変更後の状況ChangedStatus | 既存のStatus遷移定義をそのまま使う |
| 表示名DisplayName | Botが提示する選択肢ボタンのラベル |
| ツールチップ | Bot案内文言。`{検査機関}` `{日程}`のようなプレースホルダーを書いておくと、n8nがレコードの実際の値に置換してから送信する |
| 入力検証タブの「項目」 | このProcess実行に追加入力を伴うかの判定に流用。列名プレフィックスで種別を判定する`Date*`→日付入力を1往復挟む、`Attachments*`→ファイル受信を1往復挟む、項目なし→即実行 |
| ProcessId | n8nが`POST /api/items/{resultId}/update`に`ProcessId`パラメータとして渡して実行する。公式マニュアル記載の通り、APIからのプロセス実行では入力検証プリザンター標準の検証機能は適用されない点に注意 |
新フローを追加する際の運用手順:
1. 508971のProcessを1つ追加現在の状況・変更後の状況・表示名・ツールチップ文言を設定
2. 追加入力が必要なら、入力検証タブの「項目」に対象列(`Date*`または`Attachments*`)を登録
3. n8nワークフローWP/WAは無改修。現在Statusに紐づくProcess一覧を都度`getsite`から動的に取得する設計のため、Process追加だけで新フローを反映できる
## 5. n8n側詳細
### Data Table「bot_conversation_state」新規
| 列 | 内容 |
|---|---|
| resultId | 508971のResultId |
| targetEmail | 対象者メールアドレスLINEWORKS userId |
| currentStatus | 直近提示時点のStatus値 |
| pendingProcesses | 直近提示した選択肢一覧(`[{processId, label, validateColumn}]`のJSON文字列 |
| awaitInput | `none`(選択肢待ち)/`date`(日付入力待ち)/`file`(ファイル受信待ち) |
| awaitProcessId | 追加入力完了後に実行すべきProcessId`awaitInput`が`date`/`file`の間のみ使用) |
| awaitColumn | 追加入力先の列名(例: `Date001` |
| updatedAt | 最終更新日時 |
n8n-guide.md 7-1Clear出力の握り潰し・7-2複数行が後続へそのまま渡ると行数分繰り返し実行されるの罠を踏まえ、1レコード=1行の読み書きに限定し、複数行を横断する集約処理は入れない。セッションタイムアウトの概念は持たないユーザーの都合のいいタイミングで返信されればよい性質のフローのため、待機状態は無期限に保持する
### ワークフロー構成
- **WP Statusプッシュ通知**: Webhookトリガー`X-API-Key`ヘッダー認証、508971のクライアントスクリプト専用。担当者がProcessボタンを押した時に起動
- **WA LINEWORKS応答受信**: WebhookトリガーLINE WORKS本体からの直接コールバック、`x-works-signature`をHMAC-SHA256検証。唯一の受信口
- タイムアウト監視ワークフローは持たない3節参照
### WAの処理詳細
1. 署名検証 → 送信内容(テキスト/ボタン応答/ファイル)を判定
2. 送信者メール解決 → 508971の対象レコード未完了の最新1件を特定
3. `bot_conversation_state`から該当resultIdの待機状態を取得
4. **待機状態なし、または`awaitInput=none`で回答が選択肢と不一致** → 現在Statusに紐づくProcess群を`getsite`から再取得し、案内を再送(フォールバック)
5. **`awaitInput=none`で回答が選択肢DisplayNameと一致**:
- 対象列なし → 即座に`ProcessId`実行 → 新Statusの次Process群を取得 → 次の案内を送信
- 対象列が`Date*` → `awaitInput=date``awaitProcessId``awaitColumn`を記録し、「日付を入力してください」を追加送信Process実行は保留
- 対象列が`Attachments*` → 同様に`awaitInput=file`で保留し、ファイル送信を促す
6. **`awaitInput=date`** → 受信テキストを日付として検証Express版の和暦・月日省略対応ロジックを踏襲`awaitColumn`をupdate → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
7. **`awaitInput=file`** → 受信がファイルでなければ再送要求。ファイルならLINEWORKS Bot APIでダウンロード → Pleasanter添付ファイルAPIで`awaitColumn`へアップロード → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
### 認証まわり
- LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、BotIdとの対応関係を実装時に確認する
- 対象者メール解決: `POST api/users/get``View:{ApiGetMailAddresses:true}`。org-master-sync③で実績のある実装パターンを流用
## 6. エラーハンドリング・異常系
- 対象レコードが複数ヒット同一ユーザーの未完了レコードが2件以上→ 自動判定せず、担当者確認が必要な異常系として扱うBot応答は保留し、通知等は今後の実装計画で検討
- 選択肢に一致しない回答 → 選択肢を再提示4節のフォールバックと同じ経路
- 日付形式不正/ファイル未送信 → エラーメッセージ+再送、待機状態は維持
- LINEWORKS送信失敗・508971 update失敗 → n8n Execution HistoryPostgres保存に残す。追加のログ実装はしない
## 7. 導入・検証方針
- n8n運用ルールn8n-guide.md 9章に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認**
- 初回検証は1Process・1件のみで実施し、疎通確認後にフロー全体①〜③の通し検証に進める
- 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う
## 8. 補助機能Bot対話フローとは独立: 社員マスタ(504412)によるデータ品質チェック・補完
3〜7章のBot対話フローとは関係のない、508971のデータ品質を担保するための補助機能。n8nのHC-SUB/HC-WP/HC-WAワークフローには組み込まず、別途の仕組みバッチ・Pleasanter Process/ServerScript等、実装方式は別途検討として扱う。
社員・組織マスタ管理テーブルSiteId 504412、2章参照の列構成`NodeSrv/apps/org-master-sync/configs/site-504412_社員・組織マスタ管理テーブル/`で取得済み):
| 504412の列 | 内容 |
|---|---|
| `Class011` | ユーザIDPleasanterUserId |
| `ClassB` | メールアドレス |
| `Class036` | PLメールアドレス |
| `Class003` / `Class004` | 姓(カナ)/名(カナ) |
- **メールアドレス整合性チェック**: 508971の`ClassC`Users参照から解決した対象者のPleasanterUserIdを軸に504412の`Class011`と突き合わせ、その社員の`Class036`PLメールアドレスと、`api/users/get`で解決した実際のメールアドレスが一致するかを検証する
- **フリガナ補完**: 508971の`ClassD`フリガナ、必須項目が空欄の場合、504412の`Class003`(姓(カナ))+`Class004`(名(カナ))から補完する
## 9. 未確定事項(実装時に個別確定)
- 具体的なProcess定義①日程通知〜③検査結果受取りの各Process内容、Status値の追加・修正は508971のStatus設計がまだ未完成のため、実装着手時に個別に設計する
- LINEWORKS Bot Private Key CredentialとBotId484184側の対応関係の実機確認
- LINEWORKS Bot APIでのファイル受信ダウンロードURL取得とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法
- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組みProcess流用の汎用性を保ったまま個別Processを増やしていく

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
# healthcheck-survey-bot
健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes`
をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。
設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md`
実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`
## 実機調査メモ2026-09-05確認
508971への新規テストProcess追加なしで、既存本番プロジェクト「実行予算WF申請」SiteId 376872
取得済み`processes.json``Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`
に入力検証タブを使ったProcessの実例があり、そこからJSON構造を確認できた。
Processの入力検証タブは`SiteSettings.Processes[].ValidateInputs`配列として保存される。
各要素は`{"Id": number, "ColumnName": string, "Required": boolean}`(実例: `{"Id": 1, "ColumnName": "Class021", "Required": true}`)。
配列名は`Validations`ではなく`ValidateInputs`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)は
キー自体が省略される可能性が高い(今回の実例では未設定のため確認できていない)。
Task 5processFlow.jsの`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。
## n8nリソースID一覧
(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する)
- Data Table `healthcheck_bot_state`: `jqMDa2YZTI4f0iQ7`
- projectId: `LOcxF69Gm4PvnkqA``org-master-sync`用プロジェクトを流用)
- 列: `resultId`, `targetEmail`, `currentStatus`, `pendingProcesses`, `awaitInput`, `awaitProcessId`, `awaitColumn`すべてstring型
- 注記: 設計上の8列目`updatedAt`はn8n Data Tableのシステム予約列名`POST /data-tables`が`400 Column name "updatedAt" is reserved as a system column name.`を返すのため、列定義には含めていない。各行の更新日時はn8nが自動管理する`updatedAt`メタデータで代替する。
- ワークフロー `HC-SUB: プロセス実行と案内送信`: `XRqcykbG2LuAjGG2`
- ファイル: `workflows/hc-sub-run-process-and-notify.json`Execute Workflow Trigger、入力`{resultId, processId}`
- **プロジェクト全体のn8nワークフロー構築ルール2026-09-05改訂に合わせて7ード構成へ全面リビルド。旧19ード版個別キーごとのData Table Getード×7、HTTP Requestード×6を並べた構成を置き換え、同じワークフローID`XRqcykbG2LuAjGG2`へPUTで上書き済み2026-09-05、コントローラー実施、HTTP 200。**
- active化済みExecute Workflow Triggerサブワークフローは呼び出し元から実行可能にするためactive必須、`asKvC5LSVg3q8tuU`と同じ運用)
- ード構成7ード、リニア接続:
1. `Execute Workflow Trigger`(入力: `resultId`, `processId`
2. `Data Table`「設定値一括取得」: `workflow_config_values``bNkadTyDgDepYx2p`)を`filters`無し・`returnAll:true`で一括取得個別キーごとのGetードを廃止
3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立てまでを1ードに集約。Pleasanter API呼び出しはすべて`this.helpers.httpRequest`n8n Codeード公式ヘルパーで発行
4. `Code`「JWTクレーム組み立て」: JWTクレームJSON文字列の組み立てとLINEWORKS `button_template`のactions配列組み立てを1ードに集約
5. `JWT`ード「Sign JWT」`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」id `Hw0qlEaGfLPnQWp1`
6. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ードに集約`this.helpers.httpRequest`
7. `Data Table`「状態更新」: `healthcheck_bot_state``jqMDa2YZTI4f0iQ7`)へ`insert`
- 秘密情報・SiteId等はすべて`workflow_config_values``bNkadTyDgDepYx2p`から「設定値一括取得」1ードで取得し、直後のCodeードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
- Execute Workflow Triggerの入力`resultId`/`processId`)は、後続ノードの直接の入力でない箇所では必ず`$('Execute Workflow Trigger').item.json...`のようにード名を明示して参照している旧版にあった「Data Tableード通過後に裸の`$json`を参照してしまうバグ」の再発防止)
- 未検証・要判断の暫定実装ありTask 12以降で要確認:
①484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジック実データ構造未調査のため`SiteSettings.BotIds`または`Processes[].BotId`を仮定し、無ければ例外を投げる。「JWTクレーム組み立て」ードに実装
②`/api/users/get`のレスポンス形状(配列/単一オブジェクト両対応の防御的実装、「Pleasanter照会・選択肢組み立て」ードに実装
③LINEWORKSトークン取得は`URLSearchParams`インスタンスをそのまま`body`に渡す形に修正済み(`.toString()`化や`json:true`併用はn8nのform-urlencoded自動処理と衝突する懸念があったため回避。レスポンスは文字列/オブジェクトいずれでも`access_token`を読めるよう防御的にパースしている。ただしn8n本体での実機動作は本タスクでは未検証
④`Data Table`Insertードの`columns.mappingMode`/`value`/`schema`の形状旧19ード版から流用。旧版はデプロイ実績があるためスキーマ自体は疑わしくないが、実書き込みでの動作は本タスクでは未再検証
- テスト実行Step 4相当は本タスクでは未実施。次回、捨てレコードでの疎通確認とユーザー確認が必要
- ワークフロー `HC-WP: Statusプッシュ通知`: `swXfpoDtwZDT3Hwk`
- ファイル: `workflows/hc-wp-status-push.json`
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push``X-Api-Key`ヘッダー認証、body `{resultId, processId}`
- active化済み
- `X-Api-Key`は`workflow_config_values`の`HEALTHCHECK_WP_API_KEY`と照合。秘密値のハードコードなし
- 実際のcurl疎通テスト本番508971書き込み・LINEWORKS実送信を伴うは未実施。ユーザー確認後に実施する
- デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した詳細: `task-10-report.md`。設定値取得もfilter付き単一キーGetから一括取得+Codeフィルタへ変更済み
- ワークフロー `HC-WA: LINEWORKS応答受信`: `0i0Ze3Gq0Waof3Fa`
- ファイル: `workflows/hc-wa-lineworks-response.json`
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`LINE WORKS本体からの直接コールバック、`x-works-signature`検証)
- active化済み2026-09-05、コントローラーが新規デプロイ・activate、HTTP 200
- **プロジェクト全体のn8nワークフロー構築ルール2026-09-05改訂に合わせて14ード構成で全面リビルド。旧60ード版個別キーごとのData Table/HTTP Requestードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)を置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ードのままだが、それらは機械的に必要な最小限IF×2, JWT×1, Execute Workflow×1, Data Table×3であり、本来Codeードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeードに集約したブリーフの目安「11ード」に対し実際は14ードだが、これはIF/JWT/Execute Workflowを専用ードに保つというルール自体が要求する固定オーバーヘッドであり、60ードからの削減という趣旨は達成している
- LINEWORKS Developer Console側のBot Callback URL設定・疎通テストは未実施。ユーザー確認後に実施する
- ード構成14ード:
1. `Webhook``healthcheck-lineworks-response`, `rawBody:true`
2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得
3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索`ColumnFilterHash`までを1ードに集約。0件/複数件はthrow自動判定しない
4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得
5. `Code`「分岐処理・アクション決定」: `awaitInput`none/date/file、または待機状態自体が無い場合に応じた分岐ロジック全体を1ードに集約。`action`execute/send/errorと次の待機状態`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`を1つのJSONオブジェクトとして出力する
6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update`
7. `IF`「action==execute」: true→8、false→9
8. `Execute Workflow`「HC-SUB実行」Task 9, `XRqcykbG2LuAjGG2`→13
9. `IF`「action==send」: true→10、false→14
10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ードに集約
11. `JWT`「Sign JWT」`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`
12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ードに集約→13
13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ード
14. `Respond to Webhook(異常系)`: 9のfalse分岐action=="error")専用
- **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ード5は「今回どうするかaction/processId/messageText」と「次に書き込む待機状態next*」を1つの出力オブジェクトにまとめて返す。ード6状態更新ード5の直接の後続ードなのでそのまま`$json.next*`を参照して無条件更新するが、ード6通過後の`$json`はData Table Updateの返り値更新後の行データに置き換わり、ード5の`action`/`processId`/`messageText`等は失われる。そのため、ード7以降IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とード名を明示して参照しているHC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約)
- 秘密情報・SiteId等はすべて`workflow_config_values``bNkadTyDgDepYx2p`から「設定値一括取得」1ードで取得し、直後のCodeードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
- 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`:
①Data Table `update`操作のパラメータ形状get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用
②`/api/users/get`・`api/items/{SiteId}/get`ColumnFilterHash検索のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲
③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装`SiteSettings.BotIds`または`Processes[].BotId`を仮定)
④**LINEWORKSファイル添付`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTードード11、action=="send"経路にのみ存在しード5より後段でしか行えないため、ード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持するユーザーが詰まらないための安全側実装。実装するにはグラフ構成の見直しファイル受信専用のJWT/Tokenペアを追加する等が必要で、フォローアップタスクで対応すること
- 設計上の注記: HC-SUBTask 9が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にしたTask 9側のファイルは変更していない
## n8nフォルダ
- フォルダ `健康診断ワークフロー`: `hOx8WKrdOucllbcU`projectId `LOcxF69Gm4PvnkqA`配下に2026-09-05作成
- HC-SUB/HC-WP/HC-WAの3ワークフローをこのフォルダへ移動する予定だが、Public APIの`/workflows/{id}/transfer`は別プロジェクトへの移動専用同一プロジェクト内移動は拒否されるで、実行するとactive状態が解除される副作用があるため、**ユーザーがn8n UI上で手動ドラッグして移動する**運用にした(コントローラーからは未実施)
## workflow_config_values 追加登録キー2026-09-05、healthcheck-survey-bot用
既存の`workflow_config_values``bNkadTyDgDepYx2p`、org-master-sync用と共用に以下を追加登録済み:
- `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT`LINEWORKS通知送信ワークフローと同じBot認証情報
- `HEALTHCHECK_SITE_ID`508971/ `LINEWORKS_BOT_MASTER_SITE_ID`484184/ `HEALTHCHECK_DATA_TABLE_ID``jqMDa2YZTI4f0iQ7`
- `HEALTHCHECK_WP_API_KEY`HC-WP Webhook認証用に新規生成
- `LINEWORKS_BOT_SECRET`(署名検証用。値は現状プレースホルダー`UNSET_PENDING_BOT_CALLBACK_CONFIRMATION`。LINEWORKS Developer ConsoleでBot Callback URL設定時に実際のBot Secretへ更新が必要

View File

@ -0,0 +1,261 @@
{
"name": "HC-SUB: プロセス実行と案内送信",
"nodes": [
{
"id": "trigger-1",
"name": "Execute Workflow Trigger",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
220,
300
],
"parameters": {
"workflowInputs": {
"values": [
{
"name": "resultId"
},
{
"name": "processId"
}
]
}
}
},
{
"id": "dt-get-config",
"name": "設定値一括取得",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
440,
300
],
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "bNkadTyDgDepYx2p"
},
"returnAll": true
}
},
{
"id": "code-pleasanter-query",
"name": "Pleasanter照会・選択肢組み立て",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];"
}
},
{
"id": "code-jwt-claims",
"name": "JWTクレーム組み立て",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];"
}
},
{
"id": "jwt-sign",
"name": "Sign JWT",
"type": "n8n-nodes-base.jwt",
"typeVersion": 1,
"position": [
1100,
300
],
"parameters": {
"operation": "sign",
"useJson": true,
"claimsJson": "={{ $json.jwtClaims }}",
"options": {
"algorithm": "RS256"
}
},
"credentials": {
"jwtAuth": {
"id": "Hw0qlEaGfLPnQWp1",
"name": "LINEWORKS Bot Private Key (v4)"
}
}
},
{
"id": "code-lineworks-send",
"name": "アクセストークン取得・LINEWORKS送信",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
300
],
"parameters": {
"jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で\n// 行うため、素のURLSearchParamsを渡す.toString()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];"
}
},
{
"id": "dt-state-insert",
"name": "状態更新",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
1540,
300
],
"parameters": {
"operation": "insert",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "jqMDa2YZTI4f0iQ7"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"resultId": "={{ $json.resultId }}",
"targetEmail": "={{ $json.targetEmail }}",
"currentStatus": "={{ String($json.currentStatus) }}",
"pendingProcesses": "={{ JSON.stringify($json.options) }}",
"awaitInput": "none",
"awaitProcessId": "",
"awaitColumn": ""
},
"schema": [
{
"id": "resultId",
"displayName": "resultId",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "targetEmail",
"displayName": "targetEmail",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "currentStatus",
"displayName": "currentStatus",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "pendingProcesses",
"displayName": "pendingProcesses",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitInput",
"displayName": "awaitInput",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitProcessId",
"displayName": "awaitProcessId",
"required": false,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "awaitColumn",
"displayName": "awaitColumn",
"required": false,
"type": "string",
"canBeUsedToMatch": true
}
]
}
}
}
],
"connections": {
"Execute Workflow Trigger": {
"main": [
[
{
"node": "設定値一括取得",
"type": "main",
"index": 0
}
]
]
},
"設定値一括取得": {
"main": [
[
{
"node": "Pleasanter照会・選択肢組み立て",
"type": "main",
"index": 0
}
]
]
},
"Pleasanter照会・選択肢組み立て": {
"main": [
[
{
"node": "JWTクレーム組み立て",
"type": "main",
"index": 0
}
]
]
},
"JWTクレーム組み立て": {
"main": [
[
{
"node": "Sign JWT",
"type": "main",
"index": 0
}
]
]
},
"Sign JWT": {
"main": [
[
{
"node": "アクセストークン取得・LINEWORKS送信",
"type": "main",
"index": 0
}
]
]
},
"アクセストークン取得・LINEWORKS送信": {
"main": [
[
{
"node": "状態更新",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,142 @@
{
"name": "HC-WP: Statusプッシュ通知",
"nodes": [
{
"id": "webhook-status-push",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
300
],
"webhookId": "healthcheck-status-push",
"parameters": {
"httpMethod": "POST",
"path": "healthcheck-status-push",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "dt-get-healthcheck_wp_api_key",
"name": "設定値一括取得",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
440,
300
],
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"mode": "id",
"value": "bNkadTyDgDepYx2p"
},
"returnAll": true
}
},
{
"id": "code-validate",
"name": "検証",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"jsCode": "const configRows = $('設定値一括取得').all().map((item) => item.json);\nconst expectedApiKey = configRows.find((row) => row.configKey === \"HEALTHCHECK_WP_API_KEY\")?.configValue;\nconst headers = $input.first().json.headers || {};\nif (headers[\"x-api-key\"] !== expectedApiKey) {\n throw new Error(\"Unauthorized: invalid API key\");\n}\nconst body = $input.first().json.body || {};\nif (!body.resultId || !body.processId) {\n throw new Error(\"Bad Request: resultId, processId は必須です\");\n}\nreturn [{ json: { resultId: body.resultId, processId: body.processId } }];"
}
},
{
"id": "execute-hc-sub",
"name": "HC-SUB実行",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [
880,
300
],
"parameters": {
"workflowId": {
"__rl": true,
"mode": "id",
"value": "XRqcykbG2LuAjGG2"
},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"resultId": "={{ $json.resultId }}",
"processId": "={{ $json.processId }}"
}
}
}
},
{
"id": "respond-ok",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1100,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}",
"options": {}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "設定値一括取得",
"type": "main",
"index": 0
}
]
]
},
"設定値一括取得": {
"main": [
[
{
"node": "検証",
"type": "main",
"index": 0
}
]
]
},
"検証": {
"main": [
[
{
"node": "HC-SUB実行",
"type": "main",
"index": 0
}
]
]
},
"HC-SUB実行": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}

View File

@ -0,0 +1,207 @@
# 健康診断Bot機能 運用マニュアル(管理担当者向け)
対象: 健康診断管理担当者。LINEWORKS Bot連携で健康診断の手続きを進める仕組み全体を説明する。
## 1. 概要
「社員別健康診断管理」上のレコード1件社員1人分の健康診断手続き。ステータスの遷移に応じて、担当者操作・社員操作・自動送信の3種が組み合わさって進行する。
- 担当者操作: プリザンター画面上でプロセスボタンを押す、または検査機関確定情報を入力・保存する
- 社員操作: LINEWORKS Bot健康診断Botとのトークで、固定メニュー・ボタンから回答する
- 自動送信: 日次12:00のリマインダーで、未対応者へ自動催促
ステータスは常に一方向に進む。プリザンター画面上でステータス列を直接書き換える運用は想定しない(担当者が押すべきボタンを押す・社員が対話に答える、のいずれかでのみ進む)。
## 2. ステータス一覧
| ステータス | 進め方 |
|---|---|
| 新規登録 | フェーズ1アンケートフォーム連携で自動 |
| アンケート発行 | フェーズ1で自動 |
| アンケート回答 | フェーズ1で自動、本人が回答 |
| 申込中 | 担当者操作 |
| 検査機関確定 | 検査機関・健康診断候補日・コースの3項目を入力し保存すると自動 |
| 日程通知済み | 担当者がプロセス実行Bot通知が飛ぶ |
| 日程確定 | 本人がBot対話で承諾、または日時変更 |
| 受診済 | 本人がBot対話で受診報告 |
| 診断書保存 | 本人がBot対話で健康診断結果を提出 |
| 就業判定(可) | 担当者・産業医が判定 |
| 就業判定(不可) | 担当者・産業医が判定 |
| 再検査通知 | 担当者がプロセス実行Bot通知が飛ぶ |
| 再検査受診済み | 本人がBot対話で再検査報告 |
| 完了 | 担当者操作(手動のみ) |
| 対象外 | 担当者操作 |
| 保留 | 担当者操作 |
| その他 | 担当者操作 |
## 3. 全体の流れ
```mermaid
flowchart TD
A[申込中] -->|検査機関・候補日・コース入力| B[検査機関確定]
B -->|担当者: 日程通知済みプロセス| C[日程通知済み]
C -->|本人: 承諾| D[日程確定]
C -->|本人: 自分手配| H[受診済]
D -->|本人: 受診完了| H
D -->|本人: 未受診→再手配| D
D -->|本人: 未受診→自分手配| H
H -->|本人: 健康診断結果提出| I[診断書保存]
I -->|担当者・産業医: 判定| J[就業判定 可/不可]
J -->|可| K[完了]
J -->|不可・担当者: 再検査通知プロセス| L[再検査通知]
L -->|本人: 再検査報告| M[再検査受診済み]
M -->|担当者: 完了プロセス| K
```
対象外・保留・その他は、上記のどの段階からでも担当者が手動で切り替え可能。
## 4. 担当者が行う操作
### 4-1. 検査機関確定情報の入力
対象レコードの編集画面で、以下を入力し保存する。
- 検査機関
- 健康診断候補日
- コース
- バリウム検査(対象者のみ)
- 追加オプション(希望する場合のみ)
- 窓口清算予定
上記3項目検査機関・健康診断候補日・コースが揃った状態で保存すると、自動でステータスが「検査機関確定」に進む。同時に「健康診断内容」欄へ、入力内容をまとめた確定内容が自動で組み立てられる表示名込み、例: 検査機関の選択肢名がそのまま入る)。この欄は担当者が直接編集する必要はない。
### 4-2. 日程通知担当者操作、Bot通知あり
ステータス「検査機関確定」のレコードで「日程通知済み」プロセスを実行する。
実行すると、社員のLINEWORKSトークへ以下が自動で連続送信される。
1. 「■本年度の健康診断が手配されました」+健康診断内容一式(テキスト)
2. 候補日の確認ボタン(承諾/受診日時の変更/自分手配)
社員が承諾すればステータスは「日程確定」に自動で進む。日程変更・自分手配の対応もBot側で完結する4-6参照
### 4-3. 再検査通知担当者操作、Bot通知あり
ステータス「就業判定(不可)」のレコードで「再検査通知」プロセスを実行する。
実行すると、社員へ「■健康診断の再検査について」から始まる固定文言の案内が届く。以後の再検査結果報告はBot対話4項目の順次収集で行われる。
### 4-4. 就業判定
ステータス「診断書保存」のレコードについて、産業医・担当者が健康診断結果(受診・再検査報告両方共通で参照する項目)を確認し、「就業判定(可)」または「就業判定(不可)」プロセスを実行する。自動判定は行わない。
### 4-5. 完了・対象外・保留・その他
- 完了: 「就業判定(可)」または「再検査受診済み」のレコードで、担当者が手動でプロセスを実行する(自動では進まない)
- 対象外・保留・その他: 任意の段階で担当者が手動実行可能
### 4-6. 自分手配への切り替え
社員が自分で検査機関を探して受診する場合、Bot対話側の「自分手配」ボタン操作でステータスが「受診済」まで自動的に進む担当者側の操作は不要。担当者が事前に把握している場合は、プリザンター画面から「自分手配」プロセスを直接実行してもよいステータス「申込中」から「受診済」へ直行
### 4-7. 診断書の再提出依頼担当者操作、Bot通知あり
ステータス「受診済」または「診断書保存」のレコードで「診断書再提出依頼」プロセスを実行する。提出されたPDFに不備があった場合など、担当者から能動的に再提出を促したいときに使う。ステータスは変化しない。
実行すると、社員へ以下が届く。
```
■健康診断結果の再提出について
健康診断結果の再提出をお願いいたします。
固定メニューから「健康診断について」→「受診結果について」を選び、PDFを再度送信してください。
```
ステータス「診断書保存」でも「受診結果について」から再度PDFを提出できる5-4参照
## 5. 社員側の操作LINEWORKS Botとの対話
固定メニューは「健康診断について」「担当者に連絡」の2つ。「健康診断について」をタップすると、現在のステータスに応じた選択肢だけがボタンで表示される対象外の項目は表示されない
### 5-1. 健康診断メニューの出し分け
| ボタン | 表示されるステータス |
|---|---|
| 受診日時について | 日程通知済み/日程確定 |
| 受診結果について | 日程確定/受診済/診断書保存 |
| 再検査について | 再検査通知 |
該当する項目が1つもない場合は「現在ご案内できる項目はありません」とだけ返す。
### 5-2. 受診日時について
- ステータス「日程通知済み」: 候補日提示+承諾/受診日時の変更/自分手配
- 承諾 → 日程確定。健康診断内容の日時部分だけを確定日に書き換える
- 受診日時の変更 → 検査機関の連絡先(病院名・電話番号・担当者)を提示 → 社員が新しい日時をテキストで送る → 「よろしいですか?」の確認 → 承諾で確定
- 自分手配 → 検査機関を「自分手配」に、ステータスは「受診済」へ
- ステータス「日程確定」: 「■あなたの本年度の健康診断について」+確定済みの健康診断内容を表示。受診日時の変更ボタンあり(上記と同じ流れ)
自由入力の日付は和暦・月日省略にも対応(例: 「令和6年3月1日」「3/1」。認識できない入力が3回連続すると、いったん受付を中断し、メニューから選び直すよう案内する。
### 5-3. 受診結果について
- ステータス「日程確定」: 「健康診断を予定通り受診されましたか?」に受診完了/未受診
- 受診完了 → 受診済。健康診断結果の提出案内へ続く
- 未受診 → 再手配/自分手配
- 再手配 → 検査機関の連絡先を提示 → 新しい日時をテキストで送る → 確認 → 更新(ステータスは日程確定のまま維持、次回のリマインダー対象に自動的に戻る)
- 自分手配 → 検査機関を「自分手配」に、ステータスは「受診済」へ
- ステータス「受診済」: 健康診断結果の提出案内へ直結5-4参照
### 5-4. 健康診断結果の提出
- 案内: 「PDFでスキャンしてアップロードしてください。写真は不可。表裏が別ファイルなら複数枚送信可」
- ファイルを受け取るたびに1件ずつ受付、PDF以外は再送依頼3回連続失敗で受付中断、メニューから選び直し
- 「完了」と送信すると受付完了、ステータスは「診断書保存」へ
- 「完了」を送らないまま、最後のファイル受信から10分経過すると自動で受付完了になるファイルを1枚も送っていない場合は自動確定しない
- ステータス「診断書保存」になった後でも、「健康診断について」→「受診結果について」から追加でPDFを提出できる誤ったファイルを送ってしまった場合や、担当者から再提出を依頼された場合の対応
提出されたPDFは、対象レコードの「健康診断結果」欄へ以下の形式で保存される。
```
{実施年度エリア}■診断書■{氏名}(フリガナ)_yyyyMMdd-hhmmss.pdf
```
例: `2026■東京■診断書■野木健一郎(ノギケンイチロウ)_20260912-142512.pdf`(アップロード時刻・日本時間)
### 5-5. 再検査について
ステータス「再検査通知」で「再検査について」→「受診・再検査報告」を選ぶと、以下4項目を1問ずつ聞かれる。各項目、回答のたびに「よろしいですか」の確認が入る。
1. 再検査受診日
2. 受診病院および医師名
3. 検査項目、結果(概要)
4. 就労の可否
4項目すべて回答すると、まとめて健康診断内容再検査報告欄へ保存され、ステータスは「再検査受診済み」へ進む。
## 6. 自動送信されるメッセージ日次12:00、リマインダー
| 対象 | 条件 |
|---|---|
| 受診日の事前連絡 | ステータス「日程確定」のまま、受診日の3日前 |
| 受診日当日の確認 | ステータス「日程確定」のまま、受診日当日(受診完了/未受診を自動で問いかける) |
| 日程未確定の催促 | ステータス「日程通知済み」のまま、通知から1日経過するごとに繰り返し |
| 再検査報告の催促 | ステータス「再検査通知」のまま、通知から14日経過するごとに繰り返し |
いずれも、対象の対応が済めば自動的に催促対象から外れる。
## 7. 担当者への問い合わせ導線
社員がBot対話中いつでも「担当者に連絡」を選ぶと、以下が届く。
```
ご用の方は総務総合窓口へお問合せください🙇
《総務・契約関連窓口》
https://line.worksmobile.com/message/send?userNoList=800300000001350&version=25&type=counselor
```
選択肢の一部として選んだ場合は、それまでの対話の続きをそのまま再開できる。固定メニューから直接選んだ場合は、対話がいったんリセットされる。
## 8. 注意事項
- 個人対話1対1専用。グループトークでの利用は想定していない
- 対象レコードが1件も見つからない社員新入社員などがトークを開始・操作すると「対象の健診情報がありません」と返る。故障ではない
- 対象レコードが複数ヒットする異常時は「複数の対象レコードが見つかりました。お手数ですが管理者にお問い合わせください」と返る。プリザンター側のデータ重複を確認すること
- プリザンター画面上でステータス列を直接編集しないこと。プロセスボタン、またはBot対話経由でのみ進める

View File

@ -0,0 +1,137 @@
# Export/Get方式切替 設計書
## 背景・目的
現状 `Module1.bas` はPleasanterの **Export API**(CSV全件取得)でデータを取得し、CSVパース後にテーブルへ書き込んでいる。これに加えて **Get API**(`api-record-get-multi`)でデータを取得する方式を追加し、基本情報シートのセルで方式を切り替えて実行できるようにする。両方式の処理時間を比較できるよう、開始・終了時刻と経過秒数をシートに記録する。
対象件数は200件以下を想定し、ページング処理は考慮しない。
## セル配置(基本情報シート)
| セル | 内容 |
|---|---|
| D3 | ステータスメッセージ(「取込処理中...」「取込処理完了」) |
| D4 | 処理開始時刻(`Now` |
| D5 | 処理終了時刻(`Now` |
| D6 | 経過秒数(`(D5 - D4) * 86400` の数値) |
| D7 | 取得方式(`"Export"` または `"Get"`。未入力・それ以外の値は `"Export"` 扱い) |
## Filter・Sorter条件基本情報シート、Export/Get共通
Pleasanter APIの `ColumnFilterHash` / `ColumnSorterHash` に渡す条件を、基本情報シート上の表で指定できるようにする。Export方式・Get方式の両方で同じ条件を使う。
### セル配置
| 表 | 見出し | データ行 |
|---|---|---|
| Filter表 | F2:G2列名 / 値) | F3以降列名が空白の行で終了 |
| Sorter表 | H2:I2列名 / 昇降順) | H3以降`asc`または`desc`。列名が空白の行で終了) |
- Filter表・Sorter表とも、データ行が1件も無ければ該当するHashはAPIリクエストに含めない
- 両方とも0件の場合は `View` パラメータ自体をリクエストボディに含めない
### `SetupFilterSorterTables()`新規・セットアップ用Sub
基本情報シートのF2:G2、H2:I2を見出しとするExcelテーブルListObjectを新規作成する。`新版着工要因閲覧シートv0.1.xlsm` 上で手動実行することを想定したセットアップ専用処理(`run()` からは呼ばない)。既にテーブルが存在する場合は何もしない。
### `BuildViewFromSheet() As Object`(新規・共通関数)
基本情報シートのFilter表・Sorter表を読み取り、APIリクエストの `View` に渡す Dictionary を組み立てて返す。Filter表・Sorter表がともに0件の場合は `Nothing` を返す。
- Filter表の各行から `colFilter.Add 列名, 値``ColumnFilterHash` 用Dictionaryを作る1件以上ある場合のみ
- Sorter表の各行から `colSorter.Add 列名, 昇降順``ColumnSorterHash` 用Dictionaryを作る1件以上ある場合のみ
- どちらか一方でもあれば `View` Dictionaryに `ColumnFilterHash` / `ColumnSorterHash` をそれぞれ追加(無い方は追加しない)
- `exportCSVData``getRecordsData` の両方から呼び出し、`viewObj` が `Nothing` でなければ `apiBody.Add "View", viewObj` する
## 全体フロー(`Sub run()`
```
Sub run()
Call init ' defaultSh, tableId, apiKey, baseURL, fetchMethod(D7の値) をセット
defaultSh.Range("D4").Value = Now
defaultSh.Range("D3").Value = "取込処理中..."
Application.ScreenUpdating = False
Application.Calculation = xlManual
Select Case fetchMethod
Case "Get"
Call runGetFlow
Case Else
Call runExportFlow
End Select
Application.Calculation = xlAutomatic
Application.ScreenUpdating = True
defaultSh.Range("D5").Value = Now
defaultSh.Range("D6").Value = (defaultSh.Range("D5").Value - defaultSh.Range("D4").Value) * 86400
defaultSh.Range("D3").Value = "取込処理完了"
End Sub
```
- `init()``fetchMethod = defaultSh.Range("D7").Value` の読み込みを追加する。
- `runExportFlow` は既存の `exportCSVData``exportCSVDataToSheet` 呼び出しをそのまま移設したもの(ロジック無変更)。
- `runGetFlow` は新規追加の `getRecordsData``getRecordsDataToSheet` を呼ぶ。
## Export方式Filter/Sorter対応のため一部変更
`exportCSVDataToSheet` / `ParseCsv` / `CollectionToArray` は変更しない。
`exportCSVData` のみ、既存のコメントアウトされた `colFilter` / `view` 部分を `BuildViewFromSheet()` 呼び出しに置き換える。
```
Dim viewObj As Object
Set viewObj = BuildViewFromSheet()
If Not viewObj Is Nothing Then
apiBody.Add "View", viewObj
End If
```
(旧来の `yearFilter` を使ったコメントアウトコードは削除する)
## Get方式新規追加
### `getRecordsData(tableId As String) As Collection`
Pleasanterの `api-record-get-multi` を呼び出し、レコード配列Collection of Dictionaryを返す。
- エンドポイント: `POST {baseURL}/pleasanter/api/items/{tableId}/get`
- リクエストボディ: `ApiVersion`, `ApiKey`
- 取得件数は指定しない。1回のリクエストでの取得上限はサーバー側既定値`Api.json` の `PageSize`、既定200件であり、クライアント側から `PageSize` を指定するパラメータは存在しない。200件以下想定のため既定動作のまま1回のリクエストで取得しきる`Offset` も指定不要)
- レスポンス: `res("Response")("Data")` を返すレコードDictionaryの配列。`Results` という入れ子は存在しない)
- 既存の `callRestApi` をそのまま利用する
- Filter/Sorter条件は `BuildViewFromSheet()` を呼び出し、`viewObj` が `Nothing` でなければ `apiBody.Add "View", viewObj` するExport方式と同じ共通関数を利用
### レコードのフラット化(`FlattenRecord(record As Dictionary) As Dictionary`
Pleasanterのget APIレスポンスは、`ClassA`〜`ClassZ` などの項目が `ClassHash`(同様に `NumHash`, `DateHash`, `DescriptionHash`, `CheckHash` など)という**入れ子のDictionary**の中に格納されている。トップレベルキーをそのままヘッダにすると、これらHash系フィールドが展開されずネストしたDictionaryのまま1セルに入ってしまう。
そのため `getRecordsDataToSheet` に渡す前に、各レコードをフラット化する処理を挟む。
- レコードDictionaryの各キー・値を走査する
- 値が `Dictionary` である場合(`ClassHash` など `*Hash` 系フィールド)は、そのキー自体は使わず、**中身のキー・値をそのままフラット化後のDictionaryにマージ**する(例: `ClassHash("ClassA")` → フラット化後の `ClassA` キー)
- 値が `Dictionary` でない単純値(`Title`, `Body`, `Status`, `ResultId` など)はそのままキー・値を引き継ぐ
- 全レコードに対して同じ処理を行う
### `getRecordsDataToSheet(records As Collection, targetSheet As String)`
`exportCSVDataToSheet` とは完全に独立した新規関数として実装する(既存ロジックへの影響を避けるため共通化しない)。
- `records.count = 0` の場合はメッセージ表示して終了
- 各レコードを `FlattenRecord` でフラット化する
- ヘッダは **フラット化後の1件目レコードのキー一覧をそのまま使用**`Title`, `Body`, `ClassA`, `DateA` などPleasanterの内部キー名。日本語ラベルへの変換は行わない
- 各レコードフラット化後の値をヘッダ順に2次元配列へ格納
- シート・テーブルListObjectの取得新規作成、ヘッダ設定、`DataBodyRange` のクリアと書き込みは `exportCSVDataToSheet` と同様の手順を独自に実装する
- テーブル名・出力先シートは `targetSheet``tableId`を共用し、Export方式実行時に作られた既存テーブルを上書きする方式を切り替えるたびにヘッダ列構成が変わる点は許容する
## 変更対象・追加対象まとめ
- 変更: `init()``fetchMethod` 読み込み追加), `Sub run()`(分岐フローに書き換え), `exportCSVData``View` 組み立てを `BuildViewFromSheet()` 呼び出しに置き換え)
- 追加: `runExportFlow`, `runGetFlow`, `getRecordsData`, `getRecordsDataToSheet`, `FlattenRecord`, `BuildViewFromSheet`, `SetupFilterSorterTables`
- 無改造: `exportCSVDataToSheet`, `ParseCsv`, `CollectionToArray`, `callRestApi`
## 非対象・考慮しないこと
- 200件を超えるページング処理
- Get方式でのヘッダの日本語ラベル化・Export方式との列対応付け
- Get方式・Export方式のテーブルを別シートに分離すること同一テーブルを共用する

View File

@ -0,0 +1,748 @@
Attribute VB_Name = "Module1"
Option Explicit
Public apiKey As String
Public baseURL As String
Public yearFilter As String
Public tableId As String
Public defaultSh As Worksheet
Function init()
'Pleasanter API KEY
apiKey = "6504c8a807677a3a576e10327f3c19876c55736ee45d4a845796b9e7f5e087bfd4bd0d8184863da8cf1733ca635111432ab334aea59102b06a96ee6d2c05190d"
baseURL = "https://nextoffice.Next-hd.co.jp"
tableId = Range("着工要因ID")
Set defaultSh = ThisWorkbook.Sheets("基本情報")
End Function
'******************************************************************************
Sub run()
'初期化処理
Call init
'入力チェック(営業所・物件状況が未選択なら処理中断)
Dim selSalesOffice As Variant, selPropertyStatus As Variant
selSalesOffice = Range("選択営業所").Cells(1, 1).Value
selPropertyStatus = Range("選択状況").Cells(1, 1).Value
If IsError(selSalesOffice) Then selSalesOffice = ""
If IsError(selPropertyStatus) Then selPropertyStatus = ""
If Trim(CStr(selSalesOffice)) = "" Or Trim(CStr(selPropertyStatus)) = "" Then
MsgBox "営業所と物件状況は必ず選択してください", vbExclamation
Exit Sub
End If
On Error GoTo Cleanup
defaultSh.Range("D4").Value = Now
defaultSh.Range("D3").Value = "取込処理中..."
Debug.Print "[D3] " & defaultSh.Range("D3").Value
Application.ScreenUpdating = False '画面更新停止
Application.Calculation = xlManual '自動計算停止
'------------------------------------------
Debug.Print "処理開始"
Dim exportSuccess As Boolean
Dim getSuccess As Boolean
exportSuccess = runExportFlow()
getSuccess = runGetFlow()
Debug.Print "処理終了"
'------------------------------------------
Cleanup:
Application.Calculation = xlAutomatic '自動計算再開
Application.ScreenUpdating = True '画面更新再開
defaultSh.Range("D5").Value = Now
defaultSh.Range("D6").Value = (defaultSh.Range("D5").Value - defaultSh.Range("D4").Value) * 86400
If Err.Number <> 0 Then
defaultSh.Range("D3").Value = "取込処理失敗(エラー): " & Err.Description
Debug.Print "[D3] " & defaultSh.Range("D3").Value
Err.Clear
ElseIf Not (exportSuccess And getSuccess) Then
defaultSh.Range("D3").Value = "取込処理失敗(データ取得エラー)"
Debug.Print "[D3] " & defaultSh.Range("D3").Value
Else
defaultSh.Range("D3").Value = "取込処理完了"
Debug.Print "[D3] " & defaultSh.Range("D3").Value
End If
End Sub
'******************************************************************************
'Export方式CSV全件取得でのデータ取込処理
'成功時True、データを取得できなかった場合Falseを返す
Function runExportFlow() As Boolean
Dim resData As Variant
Dim t0 As Double, t1 As Double, t2 As Double
Debug.Print "データ取得処理開始Export方式"
t0 = Timer
'Filter・Sorter条件基本情報シートのFilterTable・SorterTableから組み立てる、Get方式と同条件
Dim viewObj As Object
Set viewObj = BuildViewFromSheet()
resData = exportCSVData(tableId, viewObj)
t1 = Timer
Debug.Print " exportCSVData(API通信) 所要時間: " & Format(t1 - t0, "0.000") & "秒"
If IsEmpty(resData) Or resData = "" Then
Debug.Print "データが取得できませんでした"
runExportFlow = False
Else
Call exportCSVDataToSheet(resData, tableId & "E")
t2 = Timer
Debug.Print " exportCSVDataToSheet(パース+書込) 所要時間: " & Format(t2 - t1, "0.000") & "秒"
runExportFlow = True
End If
End Function
'******************************************************************************
'Get方式api-record-get-multiでのデータ取込処理
'成功時True、データを取得できなかった場合Falseを返す
Function runGetFlow() As Boolean
Dim records As Collection
Dim t0 As Double, t1 As Double, t2 As Double
Debug.Print "データ取得処理開始Get方式"
t0 = Timer
Set records = getRecordsData(tableId)
t1 = Timer
Debug.Print " getRecordsData(API通信) 所要時間: " & Format(t1 - t0, "0.000") & "秒"
If records Is Nothing Then
Debug.Print "データが取得できませんでした"
runGetFlow = False
Else
Call getRecordsDataToSheet(records, tableId & "G")
t2 = Timer
Debug.Print " getRecordsDataToSheet(書込) 所要時間: " & Format(t2 - t1, "0.000") & "秒"
runGetFlow = True
End If
End Function
'******************************************************************************
'CSVデータを取得
Function exportCSVData(tableId As String, Optional viewObj As Object = Nothing) As Variant
'共通変数
Dim apiUrl As String
Dim apiUrlParam As String
'リクエストURL
apiUrl = baseURL & "/pleasanter/api/items/" & tableId & "/"
apiUrlParam = "export"
'ヘッダ
Dim apiHeaders As New Dictionary
apiHeaders.Add "Content-Type", "application/json;charset=utf-8"
'リクエストデータ
Dim apiBody As New Dictionary
apiBody.Add "ApiVersion", "1.1"
apiBody.Add "ApiKey", apiKey
apiBody.Add "ExportId", "1"
If Not viewObj Is Nothing Then
apiBody.Add "View", viewObj
End If
'HTTPリクエスト送信メソッド呼び出し
Dim res As Object
Set res = callRestApi("POST", apiUrl, apiUrlParam, apiHeaders, apiBody)
If res("StatusCode") = 200 Then
Debug.Print "CSVデータの取得に成功しました"
exportCSVData = res("Response")("Content")
End If
End Function
'******************************************************************************
'CSVデータをシートに書き込む
Function exportCSVDataToSheet(csvData As Variant, targetSheet As String)
Debug.Print "CSVデータをシートに出力開始: " & targetSheet
'------------------------------------------
'CSVデータを「テーブル」部分に出力する処理
Dim ws As Worksheet
On Error Resume Next
Set ws = ThisWorkbook.Sheets(targetSheet)
On Error Goto 0
If ws Is Nothing Then
Debug.Print "シートが存在しないため新規作成: " & targetSheet
Set ws = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.count))
ws.Name = targetSheet
End If
'CSVデータをテーブルに挿入ダブルクォート内の改行・カンマに対応したCSVパース
Dim i As Long, j As Long
Dim arr() As Variant
Dim maxCols As Long
Dim rows As Collection
Set rows = ParseCsv(csvData)
If rows.count <= 1 Then
Debug.Print "データが0件のため、既存テーブルのデータ部分をクリアします: " & targetSheet
Dim tblEmpty As ListObject
On Error Resume Next
Set tblEmpty = ws.ListObjects(targetSheet)
On Error GoTo 0
If Not tblEmpty Is Nothing Then
If tblEmpty.Range.Rows.count > 1 Then
tblEmpty.Range.Resize(tblEmpty.Range.Rows.count - 1, tblEmpty.Range.Columns.count).Offset(1, 0).ClearContents
End If
End If
Exit Function
End If
maxCols = 0
Dim rowArr As Variant
For Each rowArr In rows
If UBound(rowArr) + 1 > maxCols Then
maxCols = UBound(rowArr) + 1
End If
Next rowArr
' 二次元配列を初期化
ReDim arr(0 To rows.count - 1, 0 To maxCols - 1)
' 配列に格納
i = 0
For Each rowArr In rows
For j = LBound(rowArr) To UBound(rowArr)
arr(i, j) = rowArr(j)
Next j
i = i + 1
Next rowArr
'テーブルListObject取得
Dim tbl As ListObject
On Error Resume Next
Set tbl = ws.ListObjects(targetSheet)
On Error Goto 0
'ヘッダ行はテーブルの有無に関わらず毎回CSVの1行目(arr(0,*))で更新する(列構成の変更に追従するため)
Dim headerRange As Range
Set headerRange = ws.Range("A1").Resize(1, maxCols)
For j = 0 To maxCols - 1
headerRange.Cells(1, j + 1).Value = arr(0, j)
Next j
If tbl Is Nothing Then
Debug.Print "テーブルが存在しないため新規作成: " & targetSheet
Set tbl = ws.ListObjects.Add(xlSrcRange, headerRange, , xlYes)
tbl.Name = targetSheet
End If
'既存データ部分を毎回クリアDataBodyRangeプロパティは内部状態が更新されず
'Nothingのままになることがあるため使わず、テーブル範囲から直接計算する
If tbl.Range.Rows.count > 1 Then
tbl.Range.Resize(tbl.Range.Rows.count - 1, tbl.Range.Columns.count).Offset(1, 0).ClearContents
End If
' テーブルを目的のサイズにリサイズ
tbl.Resize tbl.Range.Resize(rows.count, maxCols)
Dim dataArr() As Variant
ReDim dataArr(1 To rows.count - 1, 1 To maxCols)
For i = 1 To rows.count - 1
For j = 1 To maxCols
dataArr(i, j) = arr(i, j - 1)
Next j
Next i
'データ書き込みDataBodyRangeプロパティを使わず、ヘッダ行の次からテーブル範囲を直接計算する
Dim dataRange As Range
Set dataRange = tbl.Range.Resize(rows.count - 1, maxCols).Offset(1, 0)
dataRange.Value = dataArr
'------------------------------------------
Debug.Print "CSVデータをシートに出力完了: " & targetSheet
End Function
'******************************************************************************
'CSV文字列をパースし、行ごとのフィールド配列を格納したCollectionを返す
'ダブルクォートで囲まれたフィールド内の改行・カンマ・エスケープされた""に対応RFC4180準拠
Private Function ParseCsv(Byval csvText As String) As Collection
Dim rows As New Collection
Dim fields As Collection
Set fields = New Collection
Dim field As String
Dim inQuotes As Boolean
Dim i As Long, ch As String, nextCh As String
Dim textLen As Long
textLen = Len(csvText)
inQuotes = False
field = ""
i = 1
Do While i <= textLen
ch = Mid(csvText, i, 1)
If inQuotes Then
If ch = """" Then
nextCh = Mid(csvText, i + 1, 1)
If nextCh = """" Then
field = field & """"
i = i + 1
Else
inQuotes = False
End If
Else
field = field & ch
End If
Else
Select Case ch
Case """"
inQuotes = True
Case ","
fields.Add field
field = ""
Case vbCr, vbLf
If ch = vbCr And Mid(csvText, i + 1, 1) = vbLf Then i = i + 1
fields.Add field
field = ""
If Not (fields.count = 1 And fields(1) = "") Then
rows.Add CollectionToArray(fields)
End If
Set fields = New Collection
Case Else
field = field & ch
End Select
End If
i = i + 1
Loop
'最終フィールド・行(末尾に改行が無い場合)
If field <> "" Or fields.count > 0 Then
fields.Add field
rows.Add CollectionToArray(fields)
End If
Set ParseCsv = rows
End Function
'Collection1次元を0始まりのVariant配列に変換
Private Function CollectionToArray(Byval col As Collection) As Variant
Dim arr() As Variant
ReDim arr(0 To col.count - 1)
Dim k As Long
For k = 1 To col.count
arr(k - 1) = col(k)
Next k
CollectionToArray = arr
End Function
'******************************************************************************
'基本情報シート上の「FilterTable」「SorterTable」という名前のExcelテーブルListObject
'テーブル名で探して読み取り、APIリクエストのViewパラメータに渡すDictionaryを組み立てる。
'セルの絶対位置には依存しないテーブルを移動しても動作する。各テーブルは1列目=項目名、2列目=値/昇降順とする。
'Filter・Sorterともに0件の場合はNothingを返すViewパラメータ自体を付与しないため
'ブック内の全シートを走査し、指定した名前のExcelテーブル(ListObject)を探す。見つからなければNothingを返す
Function FindListObjectByName(ByVal tblName As String) As ListObject
Dim sh As Worksheet
Dim tbl As ListObject
For Each sh In ThisWorkbook.Worksheets
On Error Resume Next
Set tbl = sh.ListObjects(tblName)
On Error GoTo 0
If Not tbl Is Nothing Then
Set FindListObjectByName = tbl
Exit Function
End If
Next sh
Set FindListObjectByName = Nothing
End Function
Function BuildViewFromSheet() As Object
Dim colFilter As New Dictionary
Dim filterTbl As ListObject
Set filterTbl = FindListObjectByName("FilterTable")
If Not filterTbl Is Nothing Then
If Not filterTbl.DataBodyRange Is Nothing Then
Dim r As Long
For r = 1 To filterTbl.DataBodyRange.Rows.count
Dim filterKey As String
filterKey = Trim(filterTbl.DataBodyRange.Cells(r, 1).Value)
If filterKey <> "" Then
colFilter.Add filterKey, "[" & Chr(34) & CStr(filterTbl.DataBodyRange.Cells(r, 2).Value) & Chr(34) & "]"
End If
Next r
End If
End If
Dim colSorter As New Dictionary
Dim sorterTbl As ListObject
Set sorterTbl = FindListObjectByName("SorterTable")
If Not sorterTbl Is Nothing Then
If Not sorterTbl.DataBodyRange Is Nothing Then
Dim r2 As Long
For r2 = 1 To sorterTbl.DataBodyRange.Rows.count
Dim sorterKey As String
sorterKey = Trim(sorterTbl.DataBodyRange.Cells(r2, 1).Value)
If sorterKey <> "" Then
colSorter.Add sorterKey, sorterTbl.DataBodyRange.Cells(r2, 2).Value
End If
Next r2
End If
End If
If colFilter.count = 0 And colSorter.count = 0 Then
Set BuildViewFromSheet = Nothing
Exit Function
End If
Dim viewObj As New Dictionary
If colFilter.count > 0 Then
viewObj.Add "ColumnFilterHash", colFilter
End If
If colSorter.count > 0 Then
viewObj.Add "ColumnSorterHash", colSorter
End If
Set BuildViewFromSheet = viewObj
End Function
'******************************************************************************
'Get方式api-record-get-multiでレコードを取得する
'成功時、レコードDictionaryのCollection(Response.Data)を返す
Function getRecordsData(tableId As String) As Collection
Dim apiUrl As String
Dim apiUrlParam As String
apiUrl = baseURL & "/pleasanter/api/items/" & tableId & "/"
apiUrlParam = "get"
Dim apiHeaders As New Dictionary
apiHeaders.Add "Content-Type", "application/json;charset=utf-8"
Dim apiBody As New Dictionary
apiBody.Add "ApiVersion", "1.1"
apiBody.Add "ApiKey", apiKey
Dim viewObj As Object
Set viewObj = BuildViewFromSheet()
If Not viewObj Is Nothing Then
apiBody.Add "View", viewObj
End If
Dim res As Object
Set res = callRestApi("POST", apiUrl, apiUrlParam, apiHeaders, apiBody)
If res("StatusCode") = 200 Then
Debug.Print "レコードの取得に成功しました"
Set getRecordsData = res("Response")("Data")
End If
End Function
'******************************************************************************
'レコードDictionary内のネストした*HashClassHash、NumHash、DateHash、DescriptionHash、CheckHashなど
'展開し、1階層のDictionaryにフラット化する
Function FlattenRecord(record As Dictionary) As Dictionary
Dim flat As New Dictionary
Dim key As Variant
For Each key In record.Keys
If TypeName(record(key)) = "Dictionary" Then
Dim innerKey As Variant
For Each innerKey In record(key).Keys
flat.Add innerKey, record(key)(innerKey)
Next innerKey
Else
flat.Add key, record(key)
End If
Next key
Set FlattenRecord = flat
End Function
'******************************************************************************
'Get方式で取得したレコードをシートに書き込むexportCSVDataToSheetとは独立実装
'ヘッダはフラット化後の1件目レコードのキー一覧をそのまま使用する内部キー名のまま、日本語ラベル変換はしない
Function getRecordsDataToSheet(records As Collection, targetSheet As String)
Debug.Print "レコードをシートに出力開始: " & targetSheet
Dim ws As Worksheet
On Error Resume Next
Set ws = ThisWorkbook.Sheets(targetSheet)
On Error GoTo 0
If ws Is Nothing Then
Debug.Print "シートが存在しないため新規作成: " & targetSheet
Set ws = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.count))
ws.Name = targetSheet
End If
If records.count = 0 Then
Debug.Print "データが0件のため、既存テーブルのデータ部分をクリアします: " & targetSheet
Dim tblEmpty As ListObject
On Error Resume Next
Set tblEmpty = ws.ListObjects(targetSheet)
On Error GoTo 0
If Not tblEmpty Is Nothing Then
If tblEmpty.Range.Rows.count > 1 Then
tblEmpty.Range.Resize(tblEmpty.Range.Rows.count - 1, tblEmpty.Range.Columns.count).Offset(1, 0).ClearContents
End If
End If
Exit Function
End If
Dim flatRecords As New Collection
Dim rec As Dictionary
For Each rec In records
flatRecords.Add FlattenRecord(rec)
Next rec
Dim headerDict As Dictionary
Set headerDict = flatRecords(1)
Dim headerKeys As Variant
headerKeys = headerDict.Keys
Dim maxCols As Long
maxCols = headerDict.count
Dim i As Long, j As Long
Dim dataArr() As Variant
ReDim dataArr(1 To flatRecords.count, 1 To maxCols)
For i = 1 To flatRecords.count
Dim curRec As Dictionary
Set curRec = flatRecords(i)
For j = 1 To maxCols
dataArr(i, j) = NormalizeGetValue(curRec(headerKeys(j - 1)))
Next j
Next i
Dim tbl As ListObject
On Error Resume Next
Set tbl = ws.ListObjects(targetSheet)
On Error GoTo 0
Dim headerRange As Range
Set headerRange = ws.Range("A1").Resize(1, maxCols)
For j = 0 To maxCols - 1
headerRange.Cells(1, j + 1).Value = headerKeys(j)
Next j
If tbl Is Nothing Then
Debug.Print "テーブルが存在しないため新規作成: " & targetSheet
Set tbl = ws.ListObjects.Add(xlSrcRange, headerRange, , xlYes)
tbl.Name = targetSheet
End If
'既存データ部分を毎回クリアDataBodyRangeプロパティは内部状態が更新されず
'Nothingのままになることがあるため使わず、テーブル範囲から直接計算する
If tbl.Range.Rows.count > 1 Then
tbl.Range.Resize(tbl.Range.Rows.count - 1, tbl.Range.Columns.count).Offset(1, 0).ClearContents
End If
' テーブルを目的のサイズにリサイズ
tbl.Resize tbl.Range.Resize(flatRecords.count + 1, maxCols)
'データ書き込みDataBodyRangeプロパティを使わず、ヘッダ行の次からテーブル範囲を直接計算する
Dim dataRange As Range
Set dataRange = tbl.Range.Resize(flatRecords.count, maxCols).Offset(1, 0)
dataRange.Value = dataArr
Debug.Print "レコードのシート出力完了: " & targetSheet
End Function
'******************************************************************************
'Get方式で取得した値のうち、ISO8601形式の日付時刻文字列を正規化する
'YYYY-MM-DDThh:mm:ss形式 → "yyyy/mm/dd"、未設定を示す1899-12-30は空欄にする
'オブジェクト値JSON配列等は空欄にする
Function NormalizeGetValue(ByVal v As Variant) As Variant
If IsObject(v) Then
NormalizeGetValue = ""
Exit Function
End If
Dim s As String
s = CStr(v)
If Len(s) >= 10 And Mid(s, 5, 1) = "-" And Mid(s, 8, 1) = "-" And Mid(s, 11, 1) = "T" Then
If Left(s, 10) = "1899-12-30" Then
NormalizeGetValue = ""
Else
NormalizeGetValue = Mid(s, 1, 4) & "/" & Mid(s, 6, 2) & "/" & Mid(s, 9, 2)
End If
Else
NormalizeGetValue = v
End If
End Function
'******************************************************************************
'REST API呼出処理
' method GetかPOSTか
' url REST APIのURL
' urlParam リクエストパラメータオプション
' headers ヘッダオプション
Function callRestApi(Byval method As String, Byval url As String, Optional Byval urlParam As String = "", Optional Byval headers As Dictionary = Null, Optional Byval body As Dictionary = Null) As Object
'HTTPリクエストのオブジェクトを定義
Dim objHTTP As Object
Set objHTTP = New XMLHTTP60
'HTTPリクエストの接続先を設定
objHTTP.Open method, url & urlParam, False
'リクエストヘッダーを設定複数ある場合はsetRequestHeaderを複数書けば良いのだ
Dim i As Long
For i = 0 To headers.count - 1
objHTTP.setRequestHeader headers.keys(i), headers.items(i)
Next i
'デバッグ用: 送信するリクエストボディを出力(必要な時だけコメントアウトを外す)
'Debug.Print " [DEBUG] Request Body: " & JsonConverter.ConvertToJson(body)
'リクエスト送信
objHTTP.send JsonConverter.ConvertToJson(body)
Do While objHTTP.readyState < 4
DoEvents
Loop
'デバッグ用: レスポンス全文を出力(必要な時だけコメントアウトを外す)
'Debug.Print " [DEBUG] Response Status: " & objHTTP.Status & " " & objHTTP.statusText
'Debug.Print " [DEBUG] Response Body (全文):"
'Debug.Print objHTTP.responseText
'レスポンスの文字列objHTTP.responseTextをJsonに変換して返却
Set callRestApi = JsonConverter.ParseJson(objHTTP.responseText)
End Function
'******************************************************************************
'基本情報シートにFilter表(F2:G2)・Sorter表(H2:I2)のテーブルをセットアップする
'新版着工要因閲覧シートv0.1.xlsm上で手動実行する想定run()からは呼ばない)
'既にテーブルが存在する場合は何もしない
Sub SetupFilterSorterTables()
Dim ws As Worksheet
Set ws = ThisWorkbook.Sheets("基本情報")
Dim filterTbl As ListObject
On Error Resume Next
Set filterTbl = ws.ListObjects("FilterTable")
On Error GoTo 0
If filterTbl Is Nothing Then
Dim filterHeader As Range
Set filterHeader = ws.Range("F2:G2")
filterHeader.Cells(1, 1).Value = "列名"
filterHeader.Cells(1, 2).Value = "値"
Set filterTbl = ws.ListObjects.Add(xlSrcRange, filterHeader, , xlYes)
filterTbl.Name = "FilterTable"
End If
Dim sorterTbl As ListObject
On Error Resume Next
Set sorterTbl = ws.ListObjects("SorterTable")
On Error GoTo 0
If sorterTbl Is Nothing Then
Dim sorterHeader As Range
Set sorterHeader = ws.Range("J2:K2")
sorterHeader.Cells(1, 1).Value = "列名"
sorterHeader.Cells(1, 2).Value = "昇降順"
Set sorterTbl = ws.ListObjects.Add(xlSrcRange, sorterHeader, , xlYes)
sorterTbl.Name = "SorterTable"
End If
End Sub
'******************************************************************************
'ブック内の全シートからテーブル名でテーブルListObjectを探し、CSVデータヘッダ行を除くを上書きする。
'シート名は問わない。テーブルが見つからない場合は新規作成せずメッセージを出して終了する
Function exportCSVDataToExistingTable(csvData As Variant, tableName As String)
Debug.Print "CSVデータを既存テーブルに出力開始: " & tableName
Dim tbl As ListObject
Set tbl = FindListObjectByName(tableName)
If tbl Is Nothing Then
MsgBox "テーブルが存在しません: " & tableName, vbExclamation
Exit Function
End If
Dim i As Long, j As Long
Dim arr() As Variant
Dim maxCols As Long
Dim rows As Collection
Set rows = ParseCsv(csvData)
If rows.count <= 1 Then
Debug.Print "データが0件のため、既存テーブルのデータ部分をクリアします: " & tableName
If tbl.Range.Rows.count > 1 Then
tbl.Range.Resize(tbl.Range.Rows.count - 1, tbl.Range.Columns.count).Offset(1, 0).ClearContents
End If
Exit Function
End If
maxCols = 0
Dim rowArr As Variant
For Each rowArr In rows
If UBound(rowArr) + 1 > maxCols Then
maxCols = UBound(rowArr) + 1
End If
Next rowArr
ReDim arr(0 To rows.count - 1, 0 To maxCols - 1)
i = 0
For Each rowArr In rows
For j = LBound(rowArr) To UBound(rowArr)
arr(i, j) = rowArr(j)
Next j
i = i + 1
Next rowArr
'既存データ部分を毎回クリアDataBodyRangeプロパティは内部状態が更新されず
'Nothingのままになることがあるため使わず、テーブル範囲から直接計算する
If tbl.Range.Rows.count > 1 Then
tbl.Range.Resize(tbl.Range.Rows.count - 1, tbl.Range.Columns.count).Offset(1, 0).ClearContents
End If
' テーブルを目的のサイズにリサイズ
tbl.Resize tbl.Range.Resize(rows.count, maxCols)
Dim dataArr() As Variant
ReDim dataArr(1 To rows.count - 1, 1 To maxCols)
For i = 1 To rows.count - 1
For j = 1 To maxCols
dataArr(i, j) = arr(i, j - 1)
Next j
Next i
'データ書き込みDataBodyRangeプロパティを使わず、ヘッダ行の次からテーブル範囲を直接計算する
Dim dataRange As Range
Set dataRange = tbl.Range.Resize(rows.count - 1, maxCols).Offset(1, 0)
dataRange.Value = dataArr
Debug.Print "CSVデータを既存テーブルに出力完了: " & tableName
End Function
'テーブルID 96243営業所マスタをExport方式で全件取得し、「営業所」シートの既存テーブルを更新する
'ソートはNumA昇順で固定。Filterは使用しない。ヘッダ行は既存テーブルのものをそのまま使い、
'エクスポートしたCSVのヘッダ行1行目は捨てる。新規シート・テーブルの作成は行わない
Sub runSalesOfficeExport()
Call init
Dim resData As Variant
'ソート条件NumA昇順を固定で組み立てる
Dim colSorter As New Dictionary
colSorter.Add "NumA", "asc"
Dim viewObj As New Dictionary
viewObj.Add "ColumnSorterHash", colSorter
Debug.Print "データ取得処理開始(営業所マスタ Export"
resData = exportCSVData("96243", viewObj)
If IsEmpty(resData) Or resData = "" Then
Debug.Print "データが取得できませんでした"
Else
Call exportCSVDataToExistingTable(resData, "営業所")
End If
End Sub