diff --git a/.gitignore b/.gitignore index ab0a6403..e4df53f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ logs/ # 巨大バイナリ・アーカイブ(サイズが大きすぎるためgit管理対象外。ローカル/別バックアップで管理) NodeSrv/notepm/export_nextgroup_20260807020526491/ NodeSrv/Keys/Pleasanter_1.5.7.1.zip + +# git worktree実体(別ブランチの作業ツリー。中身は各ブランチ側でコミット管理) +.claude/worktrees/ diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js new file mode 100644 index 00000000..44ded75f --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js new file mode 100644 index 00000000..82efa299 --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js new file mode 100644 index 00000000..d8bf9f05 --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js new file mode 100644 index 00000000..ec032d3e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js new file mode 100644 index 00000000..34d3068e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js new file mode 100644 index 00000000..5fc7535e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js new file mode 100644 index 00000000..8eaf1a2d --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js @@ -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"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js new file mode 100644 index 00000000..d22278ca --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js @@ -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"); +})(); diff --git a/Pleasanter/健康診断管理/docs/n8n/design.md b/Pleasanter/健康診断管理/docs/n8n/design.md new file mode 100644 index 00000000..f9b1f6bf --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/design.md @@ -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 484184(LINEWORKS Bot管理)をそのまま参照。健康診断管理側にBotマスタは新設しない | +| 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先ID(userId)として使う。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-1(Clear出力の握り潰し)・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 History(Postgres保存)に残す。追加のログ実装はしない + +## 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` | ユーザID(PleasanterUserId) | +| `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とBotId(484184側)の対応関係の実機確認 +- LINEWORKS Bot APIでのファイル受信(ダウンロードURL取得)とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法 +- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組み(Process流用)の汎用性を保ったまま個別Processを増やしていく diff --git a/Pleasanter/健康診断管理/docs/n8n/plan.md b/Pleasanter/健康診断管理/docs/n8n/plan.md new file mode 100644 index 00000000..35107723 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/plan.md @@ -0,0 +1,1474 @@ +# 健康診断管理×LINEWORKS Bot連携 n8n実装 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** プリザンター「健康診断管理」(SiteId 508971)のProcess機能を定義源として、LINEWORKS Botとの対話でStatusを段階的に進める仕組みをn8n上に構築する。 + +**Architecture:** n8nに3本のワークフロー(HC-SUB: プロセス実行+案内送信の共通ロジック/HC-WP: 担当者操作起点のプッシュ通知/HC-WA: LINEWORKS応答受信の唯一の受信口)を構築する。フロー定義は508971の`SiteSettings.Processes`(現在の状況/変更後の状況/表示名/ツールチップ/入力検証タブ)をそのまま使い、新規マスタは作らない。会話の待機状態はn8n Data Table(新規)で保持する。純粋ロジック(日付パース・プレースホルダー置換・Process抽出・署名検証)はNode.jsモジュールとしてTDDで開発し、動作確認済みのコードをn8n Codeノードへ書き写す。 + +**Tech Stack:** Node.js(`node --test`によるユニットテスト、追加npmパッケージなし)、n8n(Public API経由でのワークフロー・Data Table構築)、LINE WORKS Bot API、Pleasanter API + +## Global Constraints + +- n8nはコンテナメモリ768MB制限。複数行データの一括展開・集約は行わない(`NodeSrv/apps/n8n/docs/n8n-guide.md` 7-2参照) +- n8n Data Table操作は「Clear→Insert」を直列に繋がない。後続ノードは前段ノードを`$('ノード名')`で明示的に再参照する(同ガイド7-1参照) +- サーバー環境の指定は本番のみ対象(`https://nextoffice.next-hd.co.jp/pleasanter/`)。テスト環境は今回のスコープ外 +- n8nワークフローの構築・編集(PUT/POST)は確認不要。**Webhookを実際に叩く・508971へ書き込みを伴うテスト実行は毎回ユーザーへ事前確認**(同ガイド9章) +- 508971は本番の健診データそのもの。検証は既存レコードを壊さない捨てレコードで行う +- Pleasanter日本語ボディを含むリクエストはシェル引数に直書きせず、Writeツールでファイル化してから`curl --data-binary "@file"`で送る(同ガイド7-6参照) +- 具体的なProcess内容(①日程通知〜③検査結果受取り等の本番仕様)は本計画のスコープ外。本計画は「Process流用型フロー定義」の枠組みを動かすことがゴールで、テスト用Process1件で疎通確認する +- 設計書8章の社員マスタ(504412)によるメールアドレス整合性チェック・フリガナ補完は、Bot対話フローと独立した別タスクとして扱う。本計画には含まない +- **【n8nワークフロー構築の基本ルール、必ず守ること】** 以下3点を踏まえてノード数の肥大を避ける(`NodeSrv/apps/n8n/docs/n8n-guide.md` 8-1参照): + - **Data Table利用は最小限にする。**`workflow_config_values`からの設定値取得は1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)にまとめ、後続のCodeノードで`configKey`→`configValue`のMapに変換して使う。「1キー=1回のGet」でノードを積み上げない(n8n-guide.md 7-2の注意は619件規模のマスタデータ展開の話であり、config値十数件程度の一括取得には当てはまらない) + - **入口はWebhookノード+Codeノードで処理し、なるべくノード数を抑える。** データ整形・分岐判定・業務ロジック(ツールチップ置換、Process抽出、日付パース等)はCodeノードにまとめる + - **外部システム(Pleasanter、LINEWORKS等)へのHTTP Requestノードは普通に使ってよい。** 個々のAPI呼び出しをCodeノード内の`this.helpers.httpRequest`に無理に押し込む必要はない + - **JWT署名(Credential使用)だけは専用の`n8n-nodes-base.jwt`ノードを残す**(秘密鍵を安全に扱う既存の実証済みパターンのため) + - **Data Tableの読み書き自体(`workflow_config_values`の取得、`healthcheck_bot_state`の取得・更新)は専用の`Data Table`ノードのまま残す**(Codeノードから直接Data Tableを操作する手段が無いため) + +--- + +## File Structure + +``` +NodeSrv/apps/healthcheck-survey-bot/ + package.json + README.md -- 実機調査結果、n8nリソースID一覧を記録 + src/lib/ + dateParser.js -- 日付文字列パース(和暦・月日省略対応) + templateFill.js -- ツールチップ文言の{ラベル名}プレースホルダー置換 + processFlow.js -- Processes配列からの選択肢抽出・回答照合・追加入力種別判定 + signatureVerify.js -- LINEWORKS Webhook署名検証(HMAC-SHA256) + test/ + dateParser.test.js + templateFill.test.js + processFlow.test.js + signatureVerify.test.js + scripts/ + n8n-api.js -- n8n Public API共通fetchヘルパー + deploy-workflow.js -- workflows/*.json を n8n へPUT/POSTするCLI + workflows/ + hc-sub-run-process-and-notify.json + hc-wp-status-push.json + hc-wa-lineworks-response.json +``` + +n8n環境の接続情報(URL・APIキー・既存Credential)は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。Pleasanter本番APIキーは`Pleasanter/config_production.json`参照。 + +--- + +### Task 1: プロジェクト雛形作成 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/package.json` +- Create: `NodeSrv/apps/healthcheck-survey-bot/README.md` +- Create: `NodeSrv/apps/healthcheck-survey-bot/.gitignore` + +**Interfaces:** +- Produces: `node --test test/*.test.js`で実行できるテスト環境 + +- [ ] **Step 1: package.json作成** + +```json +{ + "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" + } +} +``` + +- [ ] **Step 2: README.md作成** + +```markdown +# 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` + +## 実機調査メモ + +(Task 2完了後、ここにPleasanterのProcess入力検証タブのJSON構造を記録する) + +## n8nリソースID一覧 + +(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する) +``` + +- [ ] **Step 3: .gitignore作成** + +``` +node_modules/ +``` + +- [ ] **Step 4: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/package.json NodeSrv/apps/healthcheck-survey-bot/README.md NodeSrv/apps/healthcheck-survey-bot/.gitignore +git commit -m "feat: healthcheck-survey-botプロジェクト雛形を追加" +``` + +--- + +### Task 2: 実機調査 — Processの入力検証タブのJSON構造を確認(完了) + +Pleasanter公式マニュアルには構造の記載がなく、508971の既存Processesにも入力検証を使った実例がなかったが、508971へテストProcessを追加する前に、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の取得済み`processes.json`に入力検証タブを使ったProcessの実例が見つかり、508971への書き込みなしで構造を確認できた。 + +**Files:** +- Modified: `NodeSrv/apps/healthcheck-survey-bot/README.md`(調査結果を追記済み) + +**Interfaces:** +- Produces: Task 5(processFlow.js)が前提とする、Process内で入力検証対象列を表すJSONキー名とその構造 + +**確認できた構造:** + +`Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`のId:1「入力完了」Processに実例あり: + +```json +{ + "Id": 1, + "Name": "入力完了", + "ValidateInputs": [ + { "Id": 1, "ColumnName": "Class021", "Required": true }, + { "Id": 2, "ColumnName": "Class022", "Required": true } + ] +} +``` + +配列名は`Validations`ではなく`ValidateInputs`。各要素は`{Id, ColumnName, Required}`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)はキー自体が省略される可能性が高い(この実例では未設定のため確認できていない)。 + +Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。 + +--- + +### Task 3: dateParser.js — 日付パースロジック + +Express版`OldCode/express/modules/lineworksSurvey.js`の`parseDateInput`系ロジックを移植する。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js` + +**Interfaces:** +- Produces: `parseDateInput(value: string): string`(`"YYYY-MM-DD"`形式を返す。パース不能なら`Error`をthrow) + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// 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("あいうえお")); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +node --test test/dateParser.test.js +``` + +Expected: `Cannot find module '../src/lib/dateParser'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/dateParser.js +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 }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/dateParser.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/dateParser.js test/dateParser.test.js +git commit -m "feat: 日付パースロジックを追加" +``` + +--- + +### Task 4: templateFill.js — ツールチップ文言のプレースホルダー置換 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/templateFill.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/templateFill.test.js` + +**Interfaces:** +- Consumes: なし(Task 3とは独立) +- Produces: `fillTemplate(template: string, columns: Array<{ColumnName: string, LabelText?: string}>, valueHash: Record): string` + +`columns`はPleasanter `getsite`の`SiteSettings.Columns`配列そのもの。`valueHash`はレコードの`ClassHash`/`NumHash`/`DateHash`/`DescriptionHash`を`{...ClassHash, ...NumHash, ...DateHash, ...DescriptionHash}`のようにマージしたフラットオブジェクト(呼び出し側で用意する)。日付の未設定センチネル値(`"1899-12-30..."`で始まる文字列)は「未設定」として扱う。 + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// 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, "不明: {存在しないラベル}"); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: `Cannot find module '../src/lib/templateFill'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/templateFill.js +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 }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/templateFill.js test/templateFill.test.js +git commit -m "feat: ツールチップ文言のプレースホルダー置換ロジックを追加" +``` + +--- + +### Task 5: processFlow.js — Process抽出・回答照合・追加入力判定 + +Task 2で確認したJSON構造を前提に実装する。入力検証対象列は`process.ValidateInputs`(各要素`{Id: number, ColumnName: string, Required: boolean}`)に入っている。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js` + +**Interfaces:** +- Consumes: なし +- Produces: + - `extractProcessesForStatus(processes: Array, status: number): Array` + - `matchProcessByLabel(processes: Array, text: string): Process | null` + - `classifyAwaitInput(process: Process): { awaitInput: "none" | "date" | "file", column: string | null }` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/processFlow.test.js +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" }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: `Cannot find module '../src/lib/processFlow'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/processFlow.js +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 }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/processFlow.js test/processFlow.test.js +git commit -m "feat: Process抽出・回答照合・追加入力判定ロジックを追加" +``` + +--- + +### Task 6: signatureVerify.js — LINEWORKS Webhook署名検証 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/signatureVerify.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/signatureVerify.test.js` + +**Interfaces:** +- Produces: `verifySignature(rawBody: Buffer | string, headerSignature: string, botSecret: string): boolean` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// 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); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: `Cannot find module '../src/lib/signatureVerify'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/signatureVerify.js +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 }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: 全テストを通しで実行** + +```bash +node --test test/*.test.js +``` + +Expected: 4ファイル・24テスト、全てPASS + +- [ ] **Step 6: コミット** + +```bash +git add src/lib/signatureVerify.js test/signatureVerify.test.js +git commit -m "feat: LINEWORKS Webhook署名検証ロジックを追加" +``` + +--- + +### Task 7: n8n Public API共通ヘルパー+デプロイスクリプト + +n8nワークフロー・Data Tableの作成/更新をコマンドから行うための共通スクリプト。APIキーは`NodeSrv/apps/n8n/docs/n8n-guide.md`記載の値を使う。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/n8n-api.js` +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/deploy-workflow.js` + +**Interfaces:** +- Produces: + - `n8nApi.js`: `request(method: string, path: string, body?: object): Promise<{status: number, body: any}>` + - `deploy-workflow.js`: CLI `node scripts/deploy-workflow.js [--id=<既存workflowId>]` + +- [ ] **Step 1: n8n-api.js を作成** + +```javascript +// 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 }; +``` + +- [ ] **Step 2: deploy-workflow.js を作成** + +```javascript +// 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 [--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); +}); +``` + +- [ ] **Step 3: 動作確認(既存ワークフローのダミー取得で疎通のみ確認)** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/workflows?limit=1').then(r => console.log(r.status)); +" +``` + +Expected: `200`が出力される + +- [ ] **Step 4: コミット** + +```bash +git add scripts/n8n-api.js scripts/deploy-workflow.js +git commit -m "feat: n8n Public API操作用の共通スクリプトを追加" +``` + +--- + +### Task 8: n8n Data Table「healthcheck_bot_state」作成 + +**Files:** +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md`(作成したテーブルIDを記録) + +**Interfaces:** +- Produces: n8n Data Table(テーブル名`healthcheck_bot_state`)。列: `resultId`(string), `targetEmail`(string), `currentStatus`(string), `pendingProcesses`(string, JSON), `awaitInput`(string), `awaitProcessId`(string), `awaitColumn`(string)(`updatedAt`はn8n Data Tableのシステム予約列名のため定義できず、7列で作成した。行の更新日時はn8nが自動管理するメタデータに委ねる。実際に作成したテーブルid: `jqMDa2YZTI4f0iQ7`) + +n8n Public APIの`POST /data-tables`の必須パラメータ(`projectId`要否等)は`n8n-guide.md`に記載が無いため、実機で確認しながら進める。 + +- [ ] **Step 1: 既存Data Table一覧からprojectIdを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=10').then(r => console.log(JSON.stringify(r.body, null, 2))); +" +``` + +Expected: 既存テーブル(`workflow_config_values`等)のリストと、それぞれの`projectId`が確認できる + +- [ ] **Step 2: Data Table作成を試みる** + +Step 1で確認した既存`projectId`のいずれか(`org-master-sync`用の`LOcxF69Gm4PvnkqA`等)を指定して作成を試す。 + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/data-tables', { + name: 'healthcheck_bot_state', + projectId: 'LOcxF69Gm4PvnkqA', + columns: [ + { name: 'resultId', type: 'string' }, + { name: 'targetEmail', type: 'string' }, + { name: 'currentStatus', type: 'string' }, + { name: 'pendingProcesses', type: 'string' }, + { name: 'awaitInput', type: 'string' }, + { name: 'awaitProcessId', type: 'string' }, + { name: 'awaitColumn', type: 'string' }, + ], +}).then(r => console.log(r.status, JSON.stringify(r.body, null, 2))); +" +``` + +- [ ] **Step 2a: 作成に失敗した場合の代替手順** + +`projectId`必須エラー等でAPI経由の作成が通らない場合は、n8n UI(`https://n8n32.next-hd.net`、n8n-guide.md 2章のログイン情報)から手動でData Tableを作成する。列構成はStep 2と同じにする。 + +- [ ] **Step 3: 作成結果を確認** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=20').then(r => { + const t = r.body.data.find(d => d.name === 'healthcheck_bot_state'); + console.log(JSON.stringify(t, null, 2)); +}); +" +``` + +Expected: 作成した8列のテーブルが表示される。表示された`id`を記録する + +- [ ] **Step 4: README.mdへ記録** + +```markdown +## n8nリソースID一覧 + +- Data Table `healthcheck_bot_state`: `` +``` + +- [ ] **Step 5: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "docs: healthcheck_bot_state Data Table作成結果を記録" +``` + +--- + +### Task 9: HC-SUBワークフロー — プロセス実行+案内送信 + +WP・WAの両方から呼ばれる共通ロジック。「(任意)ProcessIdを実行→現在Statusの選択肢を組み立ててLINEWORKSへ送信→Data Table更新」を1本のExecute Workflow Triggerサブワークフローにまとめる。**Global Constraintsのn8nワークフロー構築ルール(Data Table最小限、Webhook+Codeでノード数抑制、HTTP Requestノードは可)に従い、Pleasanter API呼び出し群・選択肢組み立てを1つのCodeノードに集約する。** + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-sub-run-process-and-notify.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 3〜6のロジック(Codeノードへ書き写す)、Task 8のData Table ID、`workflow_config_values`(id `bNkadTyDgDepYx2p`、既存のorg-master-sync用n8n Data Table)に登録済みの設定値 +- Produces: n8n上のワークフロー(Execute Workflow Trigger、入力`{resultId: string, processId: string | null}`)。WP・WAはこのワークフローIDを`Execute Workflow`ノードで呼び出す + +**秘密情報・設定値の扱い方針(重要):** **PleasanterのApiKeyやLINEWORKS Client Secret等をCodeノードに直接ハードコードしない。** 代わりに`workflow_config_values`(Data Table、id `bNkadTyDgDepYx2p`)を**1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)でまとめて取得**し、直後のCodeノードで`configKey`→`configValue`のMapに変換して使う(個別キーごとにGetノードを分けない)。 + +このワークフローで使うキー(今回追加登録済み、値は取得済みの全行から後続Codeノードでフィルタする): +`PLEASANTER_BASE_URL_PROD` / `PLEASANTER_API_KEY_PROD` / `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT` / `HEALTHCHECK_SITE_ID`(=508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(=484184)/ `HEALTHCHECK_DATA_TABLE_ID`(=`jqMDa2YZTI4f0iQ7`) + +**ノード構成(7ノード):** + +1. `Execute Workflow Trigger`(入力: `resultId`, `processId`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立て(Task 4の`fillTemplate`とTask 5の`extractProcessesForStatus`をそのまま使う)までを1つのCodeノードにまとめる。Pleasanter APIへの各呼び出しは`await this.helpers.httpRequest({method:"POST", url, body, json:true})`で行う(n8n Codeノードの公式ヘルパー、外部HTTPリクエストを直接発行できる) +4. `Code`「JWTクレーム組み立て」: `iss`/`sub`に設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使ってJWTクレームJSON文字列を組み立てる。あわせて選択肢からLINEWORKS `button_template`のactions配列を組み立てる +5. `JWT`ノード(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`) +6. `Code`「アクセストークン取得・LINEWORKS送信」: `POST https://auth.worksmobile.com/oauth2/v2.0/token`(form-urlencoded、`client_id`/`client_secret`は設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_CLIENT_SECRET`)でトークン取得後、続けて`POST https://www.worksapis.com/v1.0/bots/{BOT_ID}/users/{userId}/messages`(`button_template`形式)へ送信するところまでを1つのCodeノードで行う(いずれも`this.helpers.httpRequest`) +7. `Data Table`ノード「状態更新」: `healthcheck_bot_state`(id: `jqMDa2YZTI4f0iQ7`)へ`resultId`をキーに`insert`(`currentStatus`, `pendingProcesses`=JSON化したoptions, `awaitInput: "none"`)。`updatedAt`列は存在しない(Task 8参照)ため送信対象に含めない + +Step 3のCodeノードの中身(骨格。各`this.helpers.httpRequest`呼び出しの`body`はTask 4〜7で確認したAPI形式に沿って埋める): + +```javascript +// Codeノード「Pleasanter照会・選択肢組み立て」の中身 +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); + }); +} +function extractProcessesForStatus(processes, status) { + return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1); +} + +const configRows = $('設定値一括取得').all().map((item) => item.json); +const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); + +const trigger = $('Execute Workflow Trigger').item.json; +const baseUrl = config.PLEASANTER_BASE_URL_PROD; +const apiKey = config.PLEASANTER_API_KEY_PROD; + +async function pleasanterPost(path, body) { + const res = await this.helpers.httpRequest({ + method: "POST", + url: `${baseUrl}${path}`, + body: { ApiVersion: 1.1, ApiKey: apiKey, ...body }, + json: true, + }); + return res; +} + +if (trigger.processId) { + await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId }); +} + +const recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {}); +const record = recordRes.Response.Data; + +const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {}); +const siteSettings = siteRes.Response.Data.SiteSettings; +const columns = siteSettings.Columns || []; +const processes = siteSettings.Processes || []; + +const botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {}); +// TODO(Task 12): 484184の実データ構造を見てBotIdの解決方法を確定させる + +const valueHash = { + ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash, +}; + +const candidates = extractProcessesForStatus(processes, record.Status); +const options = candidates.map((p) => ({ + processId: p.Id, + label: p.DisplayName || p.Name, + tooltip: fillTemplate(p.ToolTip || "", columns, valueHash), +})); + +const userRes = await pleasanterPost.call(this, `api/users/get`, { + View: { ApiGetMailAddresses: true }, + Where: { UserId: record.ClassHash.ClassC }, +}); + +return [{ + json: { + resultId: record.ResultId, + currentStatus: record.Status, + options, + targetEmail: userRes.Response.Data[0]?.MailAddress, + config, + }, +}]; +``` + +(`ToolTip`のキー名はTask 2の実機調査結果で確定させ、異なる場合はここだけ修正する。上記は骨格であり、実装時にPleasanter API実レスポンスの構造に合わせて調整する) + +- [ ] **Step 1: ワークフローJSON雛形を作成** + +`workflows/hc-sub-run-process-and-notify.json`に、上記7ノードの`nodes`配列と`connections`を、Task 7の`n8n-api.js`が期待する`{name, nodes, connections, settings}`形式で書く(`n8n-nodes-base.code`, `n8n-nodes-base.jwt`, `n8n-nodes-base.executeWorkflowTrigger`, `n8n-nodes-base.dataTable`の各ノードタイプを使う)。**秘密情報はCodeノードに直接値として書かず、必ず「設定値一括取得」ノードから得たMapを経由して参照する。** + +- [ ] **Step 2: デプロイ** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-sub-run-process-and-notify.json +``` + +Expected: HTTP status 200、ワークフローIDが出力される + +- [ ] **Step 3: README.mdへワークフローIDを記録** + +```markdown +- ワークフロー `HC-SUB: プロセス実行と案内送信`: `` +``` + +- [ ] **Step 4: ユーザーへ確認のうえ、テスト実行** + +Task 2で作成・削除したテストProcessとは別に、動作確認用の捨てレコード・テストProcess(`CurrentStatus`をnullまたは既存Statusのどれかにして`ChangedStatus`は同じ値、`processId`無しでの疎通確認から始める)を使い、n8n UIの「Test workflow」または`Execute Workflow`ノード経由で1回実行し、LINEWORKSへメッセージが届くこと・`healthcheck_bot_state`に行が作られることを確認する。**実行前に必ずユーザーへ確認する。** + +- [ ] **Step 5: コミット** + +```bash +git add workflows/hc-sub-run-process-and-notify.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-SUBワークフロー(プロセス実行+案内送信)を追加" +``` + +--- + +### Task 10: HC-WPワークフロー — Statusプッシュ通知(担当者操作起点) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wp-status-push.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 9のワークフローID(`Execute Workflow`ノードで参照) +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`) + +**秘密情報の扱い**: Webhook認証キーはCodeノードに直接書かず、`workflow_config_values`(`bNkadTyDgDepYx2p`)から`Data Table`ノードで取得して比較する(Task 9と同じ方針。1回の`returnAll:true`取得+後続Codeノードでキーを引く)。 + +**ノード構成:** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-status-push`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「検証」: `X-Api-Key`ヘッダーとbodyの`resultId`/`processId`必須チェック(不正なら`throw new Error(...)`でワークフローを失敗させる) +4. `Execute Workflow`(Task 9のワークフローIDを指定、入力: `resultId`, `processId`) +5. `Respond to Webhook`(`{"result":"ok"}`を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +`workflows/hc-wp-status-push.json`を作成。Codeノード「検証」の中身: + +```javascript +const configRows = $('設定値一括取得').all().map((item) => item.json); +const expectedApiKey = configRows.find((row) => row.configKey === "HEALTHCHECK_WP_API_KEY")?.configValue; +const headers = $input.first().json.headers || {}; +if (headers["x-api-key"] !== expectedApiKey) { + throw new Error("Unauthorized: invalid API key"); +} +const body = $input.first().json.body || {}; +if (!body.resultId || !body.processId) { + throw new Error("Bad Request: resultId, processId は必須です"); +} +return [{ json: { resultId: body.resultId, processId: body.processId } }]; +``` + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wp-status-push.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status, JSON.stringify(r.body))); +" +``` + +- [ ] **Step 4: README.mdへ記録** + +```markdown +- ワークフロー `HC-WP: Statusプッシュ通知`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`) +``` + +- [ ] **Step 5: ユーザー確認のうえ疎通テスト** + +```bash +curl -X POST "https://n8n32.next-hd.net/webhook/healthcheck-status-push" \ + -H "X-Api-Key: " \ + -H "Content-Type: application/json" \ + -d '{"resultId": <テスト用ResultId>, "processId": null}' +``` + +Expected: `{"result":"ok"}`、かつLINEWORKSへ現在Statusの案内が届く(**実行前に必ずユーザーへ確認する**) + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wp-status-push.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WPワークフロー(Statusプッシュ通知)を追加" +``` + +--- + +### Task 11: HC-WAワークフロー — LINEWORKS応答受信(唯一の受信口) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 6の署名検証ロジック、Task 3の日付パース、Task 5の`matchProcessByLabel`/`classifyAwaitInput`、Task 9のワークフローID +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証) + +**Global Constraintsのn8nワークフロー構築ルールに従い、分岐ロジック・Pleasanter/LINEWORKS API呼び出しは可能な限りCodeノードに集約する。** Data Tableの読み書き(設定値取得・待機状態の取得/更新)のみ専用ノードを使う。JWT署名も専用ノードのまま。 + +**ノード構成(目安11ノード):** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-lineworks-response`, `options.rawBody: true`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「署名検証・対象レコード特定」: Task 6の`verifySignature`で検証(失敗なら`throw`)。成功したら`this.helpers.httpRequest`で対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までをこの1ノードで行う。0件/複数件は`throw`(6章の異常系方針、自動判定しない) + +```javascript +// Codeノード「署名検証・対象レコード特定」の中身 +const crypto = require("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); +} + +const configRows = $('設定値一括取得').all().map((item) => item.json); +const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); + +const item = $('Webhook').first(); // この直前ノードは「設定値一括取得」(Data Table)のため、裸の$input.first()だとconfig行を拾ってしまいheaders/bodyが無い +const headers = item.json.headers || {}; +const rawBody = item.binary && item.binary.data + ? Buffer.from(item.binary.data.data, "base64") + : Buffer.from(JSON.stringify(item.json.body || {}), "utf8"); + +if (!verifySignature(rawBody, headers["x-works-signature"], config.LINEWORKS_BOT_SECRET)) { + throw new Error("Unauthorized: signature mismatch"); +} + +const body = item.json.body || {}; +const source = body.source || {}; +const content = body.content || {}; +const targetEmail = source.userId; + +async function pleasanterPost(path, reqBody) { + return this.helpers.httpRequest({ + method: "POST", + url: `${config.PLEASANTER_BASE_URL_PROD}${path}`, + body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody }, + json: true, + }); +} + +const userRes = await pleasanterPost.call(this, "api/users/get", { + View: { ApiGetMailAddresses: true }, + Where: { MailAddress: targetEmail }, +}); +const userId = userRes.Response.Data[0]?.UserId; +if (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`); + +const recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, { + View: { + ColumnFilterHash: { ClassC: String(userId) }, + ColumnFilterSearchTypes: { ClassC: "ExactMatch" }, + }, +}); +const candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910); +if (candidates.length !== 1) { + throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`); +} +const record = candidates[0]; + +return [{ + json: { + config, + resultId: record.ResultId, + currentStatus: record.Status, + contentType: content.type, + text: content.type === "text" ? content.text : null, + fileId: content.type === "file" ? content.fileId : null, + }, +}]; +``` + +4. `Data Table`ノード「待機状態取得」(`operation: get`, `filters.conditions: [{keyName:"resultId", keyValue:"={{ $json.resultId }}"}]`): `healthcheck_bot_state`から該当resultIdの行を取得 +5. `Code`「分岐処理・アクション決定」: `awaitInput`(`none`/`date`/`file`、または待機状態自体が無い場合)に応じた全ロジックをここに集約する。以下を1つのCodeノードで行う: + - 待機状態が無い、または`awaitInput`が想定外の値 → `action: "error"` + - `awaitInput: "none"`(選択肢待ち): `pendingProcesses`(JSON文字列)をパースし、Task 5の`matchProcessByLabel`相当で`text`と一致するか判定 + - 一致・追加入力対象列なし → `action: "execute"`, `processId` + - 一致・追加入力対象列が`Date*`/`Attachments*` → `action: "send"`(追加入力を促すメッセージ)、次の待機状態(`awaitInput: "date"|"file"`, `awaitProcessId`, `awaitColumn`) + - 不一致 → `action: "execute"`, `processId: null`(フォールバック再送、HC-SUBが現在Statusの案内を再送する) + - `awaitInput: "date"`: Task 3の`parseDateInput`を実行 + - 成功 → `this.helpers.httpRequest`で該当列(`DateHash`)をupdate → `action: "execute"`, `processId: awaitProcessId`, 待機状態を`none`に戻す + - 失敗 → `action: "send"`(エラーメッセージ)、待機状態は維持 + - `awaitInput: "file"`: `contentType`が`file`でなければ`action: "send"`(再送要求)、待機状態維持。`file`なら`this.helpers.httpRequest`でLINEWORKSファイルダウンロード→Pleasanter添付アップロード(具体的なエンドポイントは実装時にPleasanter公式マニュアル`api-attachment`系を確認する)→`action: "execute"`, `processId: awaitProcessId`、待機状態を`none`に戻す + - 出力に必ず「次に`healthcheck_bot_state`へ書き込むべき状態」(`nextAwaitInput`, `nextAwaitProcessId`, `nextAwaitColumn`)を含める(ノード6で無条件更新するため) +6. `Data Table`ノード「状態更新」(`operation: update`、`resultId`をキーに`nextAwaitInput`等を書き込む。無条件で1回呼ぶ) +7. `IF`「`action == "execute"`」 + - true分岐 → `Execute Workflow`(Task 9、`processId`)→ `Respond to Webhook`(成功) + - false分岐 → 次へ +8. `IF`「`action == "send"`」 + - true分岐 → `Code`「JWTクレーム組み立て」(設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使用)→ `JWT`ノード(Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`)→ `Code`「アクセストークン取得・LINEWORKS送信」(Task 9のノード6と同じ`this.helpers.httpRequest`パターン)→ `Respond to Webhook`(成功) + - false分岐 → `Respond to Webhook`(`action == "error"`のケース。エラー内容を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +上記ノード構成に従い`workflows/hc-wa-lineworks-response.json`を作成する。ノード5の`Code`ノードにはTask 3・5のロジックをそのまま貼り付ける。 + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status)); +" +``` + +- [ ] **Step 4: LINEWORKS Developer Console側にBot Callback URLを設定** + +対象Bot(484184で管理しているBotのうち、健康診断管理で使うもの)のCallback URLを`https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`に設定する(**ユーザー確認必須**。他プロジェクトと同じBotを共用している場合、Callback URL変更が既存フローに影響しないか要確認)。 + +- [ ] **Step 5: README.mdへ記録** + +```markdown +- ワークフロー `HC-WA: LINEWORKS応答受信`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`) +``` + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wa-lineworks-response.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WAワークフロー(LINEWORKS応答受信)を追加" +``` + +--- + +### Task 12: 508971側クライアントスクリプト追加+エンドツーエンド確認 + +**Files:** +- Modify: `Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/`配下に新規スクリプトファイルを追加 +- Modify: 508971にテスト用Processを1つ追加(`pleasanter-site-spec`スキルの標準フロー: `get-site-config.js`→編集→`build-desired-config.js`→`apply-site-config.js`ドライラン確認→ユーザー確認後にcurl実行) + +**Interfaces:** +- Consumes: Task 10のWebhook URL + +- [ ] **Step 1: クライアントスクリプトを作成** + +`Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js`を新規作成: + +```javascript +const WEBHOOK_URL = "https://n8n32.next-hd.net/webhook/healthcheck-status-push"; +// n8n Data Table workflow_config_values の HEALTHCHECK_WP_API_KEY と同じ値を使う +const API_KEY = ""; + +$p.events.on_process = function (processId) { + sendStatusPush(processId); +}; + +async function sendStatusPush(processId) { + const resultId = $p.id(); + try { + const res = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Api-Key": API_KEY }, + body: JSON.stringify({ resultId, processId }), + }); + if (!res.ok) { + console.error("Status push failed:", res.status, await res.text()); + } + } catch (error) { + console.error("Status push error:", error.message); + } +} +``` + +- [ ] **Step 2: 標準フローで508971へ反映** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\Pleasanter" +node .claude/js/build-desired-config.js --project="健康診断管理" --env=production site-508971 +node .claude/js/apply-site-config.js --project="健康診断管理" --env=production +``` + +Expected: 差分・送信予定Body・curlコマンドが表示される(常にドライラン)。内容をユーザーに提示し、確認を得てから表示されたcurlコマンドを実行する + +- [ ] **Step 3: テスト用Processを追加** + +ユーザー確認のうえ、508971に検証用Process(例: `CurrentStatus`を既存の`900`完了のまま、`ChangedStatus`も`900`、`OnClick`不要、実行種別「追加したボタン」)を1つ追加する。Task 1のクライアントスクリプトと連携させ、押下時に`on_process`イベント経由でTask 10のWebhookが呼ばれることを確認する。 + +- [ ] **Step 4: エンドツーエンド確認** + +1. 508971の捨てレコードでテストProcessボタンを押す → LINEWORKSに案内が届くことを確認 +2. LINEWORKSで選択肢に回答する → `healthcheck_bot_state`の該当行が更新され、Processが実行されて捨てレコードのStatusが変わることを確認(`get-site-config.js`相当で該当レコードを`api/items/{id}/get`し直して確認) +3. n8n Execution History(`GET /executions?workflowId=&limit=5`)でエラーが出ていないことを確認 + +**すべて実データ・実LINEWORKS送信を伴うため、着手前に必ずユーザーへ確認する。** + +- [ ] **Step 5: テスト用Processを削除** + +確認完了後、ユーザーに508971からテスト用Processを削除してもらう(本番のProcess一覧を汚さないため)。 + +- [ ] **Step 6: コミット** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev" +git add "Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js" +git commit -m "feat: 508971にStatusプッシュ通知用クライアントスクリプトを追加" +``` + +--- + +### Task 13: employeeMasterCheck.js — 社員マスタ(504412)によるメール整合性チェック・フリガナ補完ロジック + +設計書8章(Bot対話フローとは独立した補助機能)に対応。**実行タイミング(保存時/定期バッチ/手動)はまだ決まっていない**ため、本タスクは判定・補完の純粋ロジックとテストのみを作る。504412へのAPI呼び出しをどこから叩くか(クライアントスクリプト/n8n Schedule Trigger/Process)は、タイミングが決まった時点で別タスクとして追加する。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/employeeMasterCheck.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/employeeMasterCheck.test.js` + +**Interfaces:** +- Consumes: なし(Task 3〜6とは独立) +- Produces: + - `checkEmailConsistency(pleasanterEmail: string, masterRecord: {Class036?: string, ClassB?: string}): { consistent: boolean, masterEmail: string | null }` + - `resolveKanaFromMaster(masterRecord: {Class003?: string, Class004?: string}): string` + - `needsKanaFill(currentKana: string | null | undefined): boolean` + +`masterRecord`は504412の`api/items/{id}/get`レスポンスの`ClassHash`(`Class011`=ユーザID、`Class036`=PLメールアドレス、`ClassB`=メールアドレス、`Class003`=姓(カナ)、`Class004`=名(カナ)を含む)を想定する。整合性チェックは`Class036`(PLメールアドレス)を優先し、無ければ`ClassB`にフォールバックする。 + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// 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); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +node --test test/employeeMasterCheck.test.js +``` + +Expected: `Cannot find module '../src/lib/employeeMasterCheck'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// 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 }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/employeeMasterCheck.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: 全テストを通しで実行** + +```bash +node --test test/*.test.js +``` + +Expected: 5ファイル・32テスト、全てPASS + +- [ ] **Step 6: コミット** + +```bash +git add src/lib/employeeMasterCheck.js test/employeeMasterCheck.test.js +git commit -m "feat: 社員マスタ(504412)照合ロジック(メール整合性チェック・フリガナ補完)を追加" +``` + +**次に必要な作業(本計画のスコープ外):** 実行タイミングが決まったら、(a) 504412から`Class011`=対象PleasanterUserIdでレコードを取得する呼び出し元(クライアントスクリプト/n8nワークフロー/Process)、(b) 不一致・補完が見つかった場合の通知・自動反映方法、を別タスクとして設計する。 + +--- + +## Self-Review + +**Spec coverage:** +- 全体アーキテクチャ(WP/WA + Process流用)→ Task 9〜11でカバー +- 社員マスタ(504412)連携(設計書8章)→ Task 13でロジックのみカバー。呼び出しトリガーは実行タイミング未確定のため意図的にスコープ外 +- Data Table「bot_conversation_state」→ Task 8(実装上は`healthcheck_bot_state`という名前にしたが、設計書の構造要件は満たす。理由: 既存のorg-master-sync用テーブルと並んだ一覧で識別しやすくするため) +- Process定義(ツールチップ・入力検証タブ)→ Task 2(実機調査)・Task 5(ロジック)・Task 9(利用) +- 対象者解決(メール=LINEWORKS userId)→ Task 9・11のPleasanter `api/users/get`呼び出し +- 日付・ファイル入力の追加往復 → Task 11の`date`/`file`分岐 +- タイムアウト監視は「不要」という設計判断 → 本計画にタイムアウト監視ワークフローは含めていない(意図通り) +- エラーハンドリング(複数レコードヒット等)→ Task 11 Step3の異常系(`throw`で失敗させる) + +**Placeholder scan:** 「実装時に確認する」という記述がTask 8(projectId要否)・Task 11(Pleasanter添付アップロードの正確なエンドポイント)に残っている。これはn8n-guide.mdが明記していない未検証事項であり、調査ステップ自体をタスク内の手順として書いてあるため、内容のないプレースホルダーではなく「次に確認すべきこと」を伴う具体的な調査タスクとして扱う。 + +**Type consistency:** `extractProcessesForStatus`/`matchProcessByLabel`/`classifyAwaitInput`の関数名・戻り値の形は Task 5→Task 9→Task 11で一貫させた。`fillTemplate`も同様。 + +--- + +Plan complete and saved to `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`. 実行方式を選んでほしい。 + +1. **Subagent-Driven(推奨)** — タスクごとに新しいsubagentを立てて実装、タスク間でレビューを挟む +2. **Inline Execution** — このセッション内で`executing-plans`を使い、チェックポイントを挟みながらまとめて実行 diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows-status.md b/Pleasanter/健康診断管理/docs/n8n/workflows-status.md new file mode 100644 index 00000000..d2515770 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows-status.md @@ -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 5(processFlow.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-SUB(Task 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へ更新が必要) diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json new file mode 100644 index 00000000..60898f98 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json @@ -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" + } +} diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json new file mode 100644 index 00000000..6ee2ac06 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json @@ -0,0 +1,454 @@ +{ + "name": "HC-WA: LINEWORKS応答受信", + "nodes": [ + { + "id": "webhook-lineworks-response", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 200, + 400 + ], + "webhookId": "healthcheck-lineworks-response", + "parameters": { + "httpMethod": "POST", + "path": "healthcheck-lineworks-response", + "responseMode": "responseNode", + "options": { + "rawBody": true + } + } + }, + { + "id": "dt-get-config", + "name": "設定値一括取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 420, + 400 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "bNkadTyDgDepYx2p" + }, + "returnAll": true + } + }, + { + "id": "code-verify-and-identify", + "name": "署名検証・対象レコード特定", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 640, + 400 + ], + "parameters": { + "jsCode": "const crypto = require(\"crypto\");\nfunction normalizeSignature(value) {\n return String(value || \"\").trim().replace(/^sha256=/i, \"\");\n}\nfunction safeEqual(a, b) {\n const ab = Buffer.from(String(a), \"utf8\");\n const bb = Buffer.from(String(b), \"utf8\");\n if (ab.length !== bb.length) return false;\n return crypto.timingSafeEqual(ab, bb);\n}\nfunction verifySignature(rawBody, headerSignature, botSecret) {\n const headerSig = normalizeSignature(headerSignature);\n if (!headerSig || !botSecret) return false;\n const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), \"utf8\");\n const expected = crypto.createHmac(\"sha256\", botSecret).update(payload).digest(\"base64\");\n return safeEqual(headerSig, expected);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst item = $('Webhook').first();\nconst headers = item.json.headers || {};\nconst rawBody = item.binary && item.binary.data\n ? Buffer.from(item.binary.data.data, \"base64\")\n : Buffer.from(JSON.stringify(item.json.body || {}), \"utf8\");\n\nif (!verifySignature(rawBody, headers[\"x-works-signature\"], config.LINEWORKS_BOT_SECRET)) {\n throw new Error(\"Unauthorized: signature mismatch\");\n}\n\nconst body = item.json.body || {};\nconst source = body.source || {};\nconst content = body.content || {};\nconst targetEmail = source.userId;\n\nasync function pleasanterPost(path, reqBody) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody },\n json: true,\n });\n}\n\nconst userRes = await pleasanterPost.call(this, \"api/users/get\", {\n View: { ApiGetMailAddresses: true },\n Where: { MailAddress: targetEmail },\n});\nconst userId = userRes.Response.Data[0]?.UserId;\nif (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`);\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, {\n View: {\n ColumnFilterHash: { ClassC: String(userId) },\n ColumnFilterSearchTypes: { ClassC: \"ExactMatch\" },\n },\n});\nconst candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910);\nif (candidates.length !== 1) {\n throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`);\n}\nconst record = candidates[0];\n\nreturn [{\n json: {\n config,\n resultId: record.ResultId,\n currentStatus: record.Status,\n contentType: content.type,\n text: content.type === \"text\" ? content.text : null,\n fileId: content.type === \"file\" ? content.fileId : null,\n },\n}];" + } + }, + { + "id": "dt-get-state", + "name": "待機状態取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 860, + 400 + ], + "alwaysOutputData": true, + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "jqMDa2YZTI4f0iQ7" + }, + "filters": { + "conditions": [ + { + "keyName": "resultId", + "keyValue": "={{ $json.resultId }}" + } + ] + }, + "returnAll": true + } + }, + { + "id": "code-decide-action", + "name": "分岐処理・アクション決定", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1080, + 400 + ], + "parameters": { + "jsCode": "function matchProcessByLabel(processes, text) {\n const trimmed = String(text ?? \"\").trim();\n return processes.find((p) => (p.label || p.DisplayName || p.Name) === trimmed) || null;\n}\nfunction getValidationColumnNames(process) {\n if (!Array.isArray(process.ValidateInputs)) return [];\n return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean);\n}\nfunction classifyAwaitInput(process) {\n const columnNames = getValidationColumnNames(process);\n if (columnNames.length === 0) return { awaitInput: \"none\", column: null };\n const column = columnNames[0];\n if (column.startsWith(\"Date\")) return { awaitInput: \"date\", column };\n if (column.startsWith(\"Attachments\")) return { awaitInput: \"file\", column };\n return { awaitInput: \"none\", column: null };\n}\n\nconst ERA_INFO = { \"令和\": 2018, \"平成\": 1988, \"昭和\": 1925, \"大正\": 1911 };\nconst ERA_ALIASES = { R: \"令和\", H: \"平成\", S: \"昭和\", T: \"大正\" };\nfunction pad2(value) { return String(value).padStart(2, \"0\"); }\nfunction finalizeDateParts(year, month, day) {\n if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01)\");\n }\n const date = new Date(year, month - 1, day);\n if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {\n throw new Error(\"存在しない日付です\");\n }\n return `${year}-${pad2(month)}-${pad2(day)}`;\n}\nfunction tryParseEraDate(compact) {\n const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);\n if (!match) return null;\n let era = match[1];\n if (/^[RHST]$/i.test(era)) era = ERA_ALIASES[era.toUpperCase()] || era;\n if (!ERA_INFO[era]) return null;\n const normalized = match[2].replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const parts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (parts.length < 3) throw new Error(\"月と日まで入力してください(例: 令和6年3月1日)\");\n const eraYear = Number(parts[0]);\n const month = Number(parts[1]);\n const day = Number(parts[2]);\n if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 令和6年3月1日)\");\n }\n return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);\n}\nfunction tryParseMonthDay(compact) {\n const match = compact.match(/^(\\d{1,2})(?:月|\\/|-|\\.)(\\d{1,2})(?:日)?$/);\n if (!match) return null;\n const currentYear = new Date().getFullYear();\n return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));\n}\nfunction parseDateInput(value) {\n const trimmed = String(value ?? \"\").trim();\n if (!trimmed) throw new Error(\"日付が空です\");\n const compact = trimmed.replace(/\\s+/g, \"\");\n const eraResult = tryParseEraDate(compact);\n if (eraResult) return eraResult;\n const monthDayResult = tryParseMonthDay(compact);\n if (monthDayResult) return monthDayResult;\n const normalized = compact.replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const isoParts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (isoParts.length === 3 && isoParts[0].length >= 4) {\n return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));\n }\n const parsed = new Date(trimmed);\n if (Number.isNaN(parsed.getTime())) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)\");\n }\n return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());\n}\n\nconst identity = $('署名検証・対象レコード特定').item.json;\nconst { config, resultId, contentType, text } = identity;\n\nconst stateRows = $('待機状態取得').all().map((item) => item.json);\nconst state = stateRows[0] || null;\n\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\nfunction buildResult(action, extra, nextState) {\n const fallbackNext = state\n ? { awaitInput: state.awaitInput, awaitProcessId: state.awaitProcessId, awaitColumn: state.awaitColumn }\n : { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" };\n const next = nextState || fallbackNext;\n return [{\n json: {\n action,\n resultId,\n config,\n targetEmail: state ? state.targetEmail : null,\n processId: null,\n messageText: null,\n ...extra,\n nextAwaitInput: next.awaitInput,\n nextAwaitProcessId: String(next.awaitProcessId ?? \"\"),\n nextAwaitColumn: next.column !== undefined ? (next.column || \"\") : (next.awaitColumn || \"\"),\n },\n }];\n}\n\nif (!state || ![\"none\", \"date\", \"file\"].includes(state.awaitInput)) {\n return buildResult(\"error\", {\n messageText: `待機状態が不正です(resultId=${resultId})。healthcheck_bot_stateの該当行を確認してください。`,\n }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n}\n\nif (state.awaitInput === \"none\") {\n const pendingProcesses = JSON.parse(state.pendingProcesses || \"[]\");\n const matched = matchProcessByLabel(pendingProcesses, text);\n\n if (!matched) {\n // 不一致 → フォールバック再送(HC-SUBが現在Statusの選択肢案内を再送する)\n return buildResult(\"execute\", { processId: null }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n // HC-SUBが保存するpendingProcessesは{processId, label, tooltip}のみでValidateInputsを\n // 持たないため、追加入力の要否判定にはサイト設定(getsite)からフルのProcess定義を\n // 引き直す必要がある(README.md記載の設計注記のとおり)。\n const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\n const siteSettings = siteRes.Response.Data.SiteSettings;\n const processes = siteSettings.Processes || [];\n const fullProcess = processes.find((p) => p.Id === matched.processId);\n if (!fullProcess) {\n throw new Error(`ProcessId ${matched.processId} がサイト設定内に見つかりません`);\n }\n const classification = classifyAwaitInput(fullProcess);\n\n if (classification.awaitInput === \"none\") {\n return buildResult(\"execute\", { processId: matched.processId }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n const promptText = classification.awaitInput === \"date\"\n ? \"日付を入力してください(例: 2026-03-01)\"\n : \"ファイルを送信してください\";\n return buildResult(\"send\", { messageText: promptText }, {\n awaitInput: classification.awaitInput,\n awaitProcessId: String(matched.processId),\n column: classification.column || \"\",\n });\n}\n\nif (state.awaitInput === \"date\") {\n try {\n const parsedDate = parseDateInput(text);\n await pleasanterPost.call(this, `api/items/${resultId}/update`, {\n DateHash: { [state.awaitColumn]: parsedDate },\n });\n return buildResult(\"execute\", { processId: Number(state.awaitProcessId) }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n } catch (err) {\n return buildResult(\"send\", { messageText: err.message }, {\n awaitInput: \"date\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n }\n}\n\n// state.awaitInput === \"file\"\nif (contentType !== \"file\") {\n return buildResult(\"send\", { messageText: \"ファイルを送信してください\" }, {\n awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n}\n\n// TODO(要フォローアップ、未実装): LINEWORKSファイル添付のダウンロード\n// (GET https://www.worksapis.com/v1.0/bots/{botId}/attachments/{fileId})には\n// LINEWORKS Bot APIのアクセストークン(JWTアサーション経由)が必要だが、プロジェクトルールにより\n// JWT署名は専用のJWTノード(このワークフローではaction:\"send\"経路のノードのみに存在し、\n// このCodeノードより後段に位置する)でしか行えない。そのため本Codeノード内では\n// トークンを取得する手段がなく、ダウンロード〜Pleasanter添付アップロード\n// (Pleasanter公式マニュアルapi-attachment系、具体的なエンドポイントも実装時要確認)は未実装。\n// フォローアップタスクでグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)を検討すること。\nreturn buildResult(\"send\", {\n messageText: \"現在、ファイル添付の処理は準備中です。しばらくお待ちいただくか、担当者にご連絡ください。\",\n}, { awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn });" + } + }, + { + "id": "dt-update-state", + "name": "状態更新", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 1300, + 400 + ], + "parameters": { + "operation": "update", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "jqMDa2YZTI4f0iQ7" + }, + "filters": { + "conditions": [ + { + "keyName": "resultId", + "keyValue": "={{ $json.resultId }}" + } + ] + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "awaitInput": "={{ $json.nextAwaitInput }}", + "awaitProcessId": "={{ $json.nextAwaitProcessId }}", + "awaitColumn": "={{ $json.nextAwaitColumn }}" + } + } + } + }, + { + "id": "if-action-execute", + "name": "IF: action==execute", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1520, + 400 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "cond-execute", + "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'execute' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "execute-hc-sub", + "name": "HC-SUB実行", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [ + 1740, + 260 + ], + "parameters": { + "workflowId": { + "__rl": true, + "mode": "id", + "value": "XRqcykbG2LuAjGG2" + }, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": { + "resultId": "={{ $('分岐処理・アクション決定').item.json.resultId }}", + "processId": "={{ $('分岐処理・アクション決定').item.json.processId }}" + } + } + } + }, + { + "id": "if-action-send", + "name": "IF: action==send", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1740, + 540 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "cond-send", + "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'send' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "code-jwt-claims", + "name": "JWTクレーム組み立て", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1960, + 460 + ], + "parameters": { + "jsCode": "const decision = $('分岐処理・アクション決定').item.json;\nconst config = decision.config;\nconst now = Math.floor(Date.now() / 1000);\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\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: 'POST',\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\n// TODO(Task 12で要確認・HC-SUBと同一の暫定実装): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の\n// 実データ構造が未調査のため、SiteSettings.BotIds または Processes[].BotId のいずれかを想定する。\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\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)から解決できませんでした。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${decision.targetEmail}/messages`;\n\nconst messageContent = {\n type: 'text',\n text: decision.messageText || '',\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n config,\n resultId: decision.resultId,\n },\n}];" + } + }, + { + "id": "jwt-sign", + "name": "Sign JWT", + "type": "n8n-nodes-base.jwt", + "typeVersion": 1, + "position": [ + 2180, + 460 + ], + "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": [ + 2400, + 460 + ], + "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()しない、ヘッダーも手動指定しない)。\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 [{ json: { result: 'ok', resultId: claims.resultId, lineworksResponse: sendRes } }];" + } + }, + { + "id": "respond-ok", + "name": "Respond to Webhook(成功)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 2620, + 320 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}", + "options": {} + } + }, + { + "id": "respond-error", + "name": "Respond to Webhook(異常系)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1960, + 680 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ result: \"error\", message: $('分岐処理・アクション決定').item.json.messageText || \"unknown error\" }) }}", + "options": {} + } + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "設定値一括取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "設定値一括取得": { + "main": [ + [ + { + "node": "署名検証・対象レコード特定", + "type": "main", + "index": 0 + } + ] + ] + }, + "署名検証・対象レコード特定": { + "main": [ + [ + { + "node": "待機状態取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "待機状態取得": { + "main": [ + [ + { + "node": "分岐処理・アクション決定", + "type": "main", + "index": 0 + } + ] + ] + }, + "分岐処理・アクション決定": { + "main": [ + [ + { + "node": "状態更新", + "type": "main", + "index": 0 + } + ] + ] + }, + "状態更新": { + "main": [ + [ + { + "node": "IF: action==execute", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: action==execute": { + "main": [ + [ + { + "node": "HC-SUB実行", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "IF: action==send", + "type": "main", + "index": 0 + } + ] + ] + }, + "HC-SUB実行": { + "main": [ + [ + { + "node": "Respond to Webhook(成功)", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: action==send": { + "main": [ + [ + { + "node": "JWTクレーム組み立て", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond to Webhook(異常系)", + "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": "Respond to Webhook(成功)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json new file mode 100644 index 00000000..cc37724b --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json @@ -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" + } +} diff --git a/Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx b/Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx new file mode 100644 index 00000000..577f4902 Binary files /dev/null and b/Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx differ diff --git a/Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx b/Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx new file mode 100644 index 00000000..0a1a35d9 Binary files /dev/null and b/Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx differ