健康診断管理システムの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>
This commit is contained in:
Kenichiro NOGI 2026-09-06 09:02:14 +09:00
parent bdec81838e
commit bd6a605539
17 changed files with 3637 additions and 0 deletions

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

@ -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,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"
}
}