Compare commits

..

No commits in common. "76700845fe6b8b7bfd7c90122da45ccc7c827bd6" and "06c28e8b5aa8a12df7e55fe85e7bed37c8b34ca4" have entirely different histories.

60 changed files with 125 additions and 11250 deletions

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
node_modules/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -1,72 +0,0 @@
// 健康診断管理 SiteId 508971: 手動登録済みProcess「自分手配」(Id:18)に対し、
// Class003(検査機関)を「自分手配」(511924 ResultId 513480)にセットするDataChangesを追記する。
// OnClickが存在すれば中断する安全策付き(既存の同種スクリプトと同じパターン)。
"use strict";
const fs = require("fs");
const path = require("path");
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
const PROJECT_NAME = "健康診断管理";
const ENV = "production";
const SITE_ID = 508971;
const REQUEST_LABEL = "add-process-jibunanpai";
const PROCESS_NAME = "自分手配";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
function loadServerConfig() {
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, `config_${ENV}.json`), "utf-8"));
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
settings.Links = current.SiteSettings.Links || [];
const proc = settings.Processes.find((p) => p.Name === PROCESS_NAME);
if (!proc) {
console.error(`[エラー] Process「${PROCESS_NAME}」が見つかりません。先にPleasanter画面で手動登録してください。`);
process.exit(1);
}
if (proc.OnClick !== undefined) {
console.error(`[エラー] Process「${PROCESS_NAME}」(Id:${proc.Id})にOnClickフィールドが存在します。中断します。`);
process.exit(1);
}
if (proc.CurrentStatus !== 250 || proc.ChangedStatus !== 400) {
console.error(`[エラー] Status遷移が想定外です(CurrentStatus:${proc.CurrentStatus}, ChangedStatus:${proc.ChangedStatus})。250→400になっているか確認してください。`);
process.exit(1);
}
proc.DataChanges = [
{ Id: 1, Type: "InputValue", ColumnName: "Class003", Value: "513480", ValueFormulaNotUseDisplayName: false, ValueFormulaIsDisplayError: false },
];
const body = {
ApiVersion: 1.1, ApiKey: server.ApiKey, SiteId: SITE_ID,
Title: current.Title, ReferenceType: current.ReferenceType,
ParentId: current.ParentId, InheritPermission: current.InheritPermission,
SiteSettings: settings,
};
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
console.log(`Process「${PROCESS_NAME}」(Id:${proc.Id}) に Class003=513480(自分手配) のDataChangesを追加します。`);
if (!execute) {
console.log("(--execute 未指定のためプレビューのみ)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json()));
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +0,0 @@
// 健康診断管理 SiteId 508971: 既存のクライアントスクリプト「プロセス実行スクリプト」のBodyのみ
// ローカルファイルの最新内容で上書きする。Columns/EditorColumnHash/Processesには一切触れない。
"use strict";
const fs = require("fs");
const path = require("path");
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
const PROJECT_NAME = "健康診断管理";
const ENV = "production";
const SITE_ID = 508971;
const REQUEST_LABEL = "update-clientscript-process";
const TITLE = "プロセス実行スクリプト";
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
const SCRIPT_FILE = path.join(
CONFIGS_DIR,
"site-508971_社員別健康診断管理",
"scripts",
"3_プロセス実行スクリプト.js"
);
function loadServerConfig() {
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, `config_${ENV}.json`), "utf-8"));
}
function loadLatest() {
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
}
const execute = process.argv.includes("--execute");
const server = loadServerConfig();
(async () => {
const { current, siteDir, rawText } = loadLatest();
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
settings.Links = current.SiteSettings.Links || [];
const script = (settings.Scripts || []).find((s) => s.Title === TITLE);
if (!script) {
console.error(`[エラー] スクリプト「${TITLE}」が見つかりません。`);
process.exit(1);
}
const newBody = fs.readFileSync(SCRIPT_FILE, "utf-8");
script.Body = newBody;
const requestBody = {
ApiVersion: 1.1, ApiKey: server.ApiKey, SiteId: SITE_ID,
Title: current.Title, ReferenceType: current.ReferenceType,
ParentId: current.ParentId, InheritPermission: current.InheritPermission,
SiteSettings: settings,
};
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
console.log("更新後Body文字数:", newBody.length);
if (!execute) {
console.log("(--execute 未指定のためプレビューのみ)");
return;
}
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) });
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json()));
})();

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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