Compare commits
97 Commits
06c28e8b5a
...
76700845fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76700845fe | ||
|
|
4b8498ba2d | ||
|
|
0544365276 | ||
|
|
97f371db3f | ||
|
|
49ef4c5358 | ||
|
|
e879b0f584 | ||
|
|
a60244b7b0 | ||
|
|
67b9f8c0f8 | ||
|
|
06340cdb7b | ||
|
|
7745fc6858 | ||
|
|
413de865f0 | ||
|
|
51e1e166d4 | ||
|
|
b9150c0461 | ||
|
|
bc03b2d47b | ||
|
|
d7a8457114 | ||
|
|
13cad20ead | ||
|
|
6dc6ede9a9 | ||
|
|
e455fe0a21 | ||
|
|
70d9eb974b | ||
|
|
631e66242f | ||
|
|
d83d74afdc | ||
|
|
1eb1a7dc87 | ||
|
|
3bfdf3b73e | ||
|
|
460b540997 | ||
|
|
a423b5fd6f | ||
|
|
99d4bb1b34 | ||
|
|
44a08c2753 | ||
|
|
326307ad0e | ||
|
|
70492700e7 | ||
|
|
f9e85a29f5 | ||
|
|
5e7bcb64be | ||
|
|
21b1593344 | ||
|
|
88351e4165 | ||
|
|
bbf43f6097 | ||
|
|
4575f14d22 | ||
|
|
3bccc4d1b9 | ||
|
|
7af3f5ff84 | ||
|
|
ccd1fa3947 | ||
|
|
57104c9e67 | ||
|
|
74f6215c2e | ||
|
|
b09c32a6c9 | ||
|
|
e111d1405d | ||
|
|
5dc5056ad8 | ||
|
|
44ee8781a9 | ||
|
|
db25941158 | ||
|
|
3e51710d9f | ||
|
|
cb01653d77 | ||
|
|
81c9f67331 | ||
|
|
2192c9718f | ||
|
|
d4a6b5c12a | ||
|
|
243ecb1b4b | ||
|
|
fabee98ea1 | ||
|
|
2454478c65 | ||
|
|
96133b8929 | ||
|
|
167e2ba5d9 | ||
|
|
d3d844d441 | ||
|
|
adfd140d9b | ||
|
|
b8ef165fdf | ||
|
|
4dc4b0cc1d | ||
|
|
ee1448504c | ||
|
|
6f33339d43 | ||
|
|
95f2acba85 | ||
|
|
bd6a605539 | ||
|
|
bdec81838e | ||
|
|
c03a2376ca | ||
|
|
1b8018da6e | ||
|
|
c1b62d19b4 | ||
|
|
3c8b98e48a | ||
|
|
530607203a | ||
|
|
83d568a493 | ||
|
|
c8ce15385a | ||
|
|
33057b8dab | ||
|
|
df0c836573 | ||
|
|
675cf4a321 | ||
|
|
935e58e384 | ||
|
|
3dccdefdcb | ||
|
|
8c8d748a07 | ||
|
|
ac27659061 | ||
|
|
4c11e4b868 | ||
|
|
90d057aac9 | ||
|
|
fa0e0f4faf | ||
|
|
231ae739eb | ||
|
|
ba474f998d | ||
|
|
8ecef72c18 | ||
|
|
66f31d5dee | ||
|
|
20396f465d | ||
|
|
0405c3a089 | ||
|
|
16732f0ab0 | ||
|
|
3573d1b78f | ||
|
|
0bee14f18c | ||
|
|
8c8cb332bb | ||
|
|
08a0b87dcf | ||
|
|
bee42dc56d | ||
|
|
5fa82fda43 | ||
|
|
64e8b8d011 | ||
|
|
3b904e6214 | ||
|
|
2939487c4f |
125
.claude/skills/dokploy-webapp/SKILL.md
Normal file
125
.claude/skills/dokploy-webapp/SKILL.md
Normal file
@ -0,0 +1,125 @@
|
||||
---
|
||||
name: dokploy-webapp
|
||||
description: NodeSrv配下(apps/<app-name>)にNode.js WEBアプリを新規作成し、ローカル開発から既存Dokploy環境(AWS Lightsail上で稼働中のセルフホストPaaS)へのデプロイ・運用までを行う。「新しいアプリを作って」「Dokployにデプロイして」「アプリをNodeSrvに追加して」「動作検証して」等の依頼で使う。
|
||||
---
|
||||
|
||||
# NodeSrv アプリ開発 & Dokployデプロイ
|
||||
|
||||
`NodeSrv/apps/<app-name>` を1アプリ1フォルダ1Dokploy Composeとして管理する。アプリ同士は完全に独立し、片方の開発・再デプロイがもう片方に影響しない設計。
|
||||
|
||||
## 新規アプリ作成
|
||||
|
||||
1. `NodeSrv/apps/_template` を `NodeSrv/apps/<app-name>` としてコピー
|
||||
2. `package.json` の `name` を `<app-name>` に変更
|
||||
3. 必要な依存関係を追加、`src/index.js` に実装を追加していく
|
||||
4. `/health` エンドポイントは削除・変更しない(Dokployのヘルスチェック対象)
|
||||
|
||||
## ローカル開発
|
||||
|
||||
```
|
||||
cd NodeSrv/apps/<app-name>
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`http://localhost:<PORT>/` と `/health` で動作確認。
|
||||
|
||||
## デプロイ前のDocker動作確認(必須)
|
||||
|
||||
Dokployは本番でDockerfileから直接ビルドするため、pushする前にローカルでも同じ手順でビルド・起動できることを確認する。
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
エラー無く起動し `/health` が200を返すことを確認してから次に進む。
|
||||
|
||||
## Gitソース: Gitea
|
||||
|
||||
GitHub直接連携ではなく、Dokploy上に自前ホストしたGitea(`https://git73.next-hd.net/mygit-admin/ken_nogi.git`、リモート名`gitea`)を経由する。理由: モノレポ(`ken_nogi`)のプライベートリポジトリをDokployのGit Provider連携で直接使う設定がうまくいかず、Gitea自前ホストに切り替えた経緯がある(詳細はプロジェクトメモリ参照)。
|
||||
|
||||
**注意(2026-09-19判明)**: 当初はGitea上に`NodeSrv`という単体リポジトリを作って運用していたが、その後`dev`配下(NodeSrv/Pleasanter等)を`ken_nogi`という1つのGiteaリポジトリへ統合した。`NodeSrv`単体リポジトリは既に削除済み(`git73.next-hd.net/mygit-admin/NodeSrv`は404)。Dokploy側の既存Compose(app-portal/n8n等)は統合後もしばらく古い`giteaRepository: "NodeSrv"`のままになっていて、pushしても反映されない状態だった。2026-09-19に全Compose(app-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
3
.gitignore
vendored
@ -6,3 +6,6 @@ logs/
|
||||
# 巨大バイナリ・アーカイブ(サイズが大きすぎるためgit管理対象外。ローカル/別バックアップで管理)
|
||||
NodeSrv/notepm/export_nextgroup_20260807020526491/
|
||||
NodeSrv/Keys/Pleasanter_1.5.7.1.zip
|
||||
|
||||
# git worktree実体(別ブランチの作業ツリー。中身は各ブランチ側でコミット管理)
|
||||
.claude/worktrees/
|
||||
|
||||
@ -1,3 +1,22 @@
|
||||
const logModalEl = document.getElementById('logModal');
|
||||
const logModalBody = document.getElementById('logModalBody');
|
||||
const logModal = logModalEl ? new bootstrap.Modal(logModalEl) : null;
|
||||
|
||||
const toastEl = document.getElementById('actionToast');
|
||||
const toastBody = document.getElementById('actionToastBody');
|
||||
const toast = toastEl ? new bootstrap.Toast(toastEl) : null;
|
||||
|
||||
function showToast(message, isError) {
|
||||
if (!toast) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
toastBody.textContent = message;
|
||||
toastEl.classList.toggle('text-bg-danger', Boolean(isError));
|
||||
toastEl.classList.toggle('text-bg-primary', !isError);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
document.getElementById('cards').addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
@ -6,18 +25,19 @@ document.getElementById('cards').addEventListener('click', async (event) => {
|
||||
if (action === 'logs') {
|
||||
const res = await fetch(`/api/compose/${composeId}/logs`);
|
||||
if (!res.ok) {
|
||||
alert(`ログ取得に失敗しました (status: ${res.status})`);
|
||||
showToast(`ログ取得に失敗しました (status: ${res.status})`, true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
alert(data.logs || 'ログなし');
|
||||
logModalBody.textContent = data.logs || 'ログなし';
|
||||
logModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
alert(`${action} を実行しました`);
|
||||
showToast(`${action} を実行しました`, false);
|
||||
} else {
|
||||
alert(`${action} に失敗しました (status: ${res.status})`);
|
||||
showToast(`${action} に失敗しました (status: ${res.status})`, true);
|
||||
}
|
||||
});
|
||||
|
||||
@ -6,55 +6,84 @@ function escapeHtml(value) {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
|
||||
const errorHtml = errorMessage
|
||||
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
|
||||
: '';
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="ja">
|
||||
<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>
|
||||
<h1>Admin Login</h1>
|
||||
<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">
|
||||
<input type="password" name="masterKey" placeholder="Master Key" required>
|
||||
<button type="submit">Login</button>
|
||||
<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>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderAdminPage(emails, errorMessage) {
|
||||
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
|
||||
const errorHtml = errorMessage
|
||||
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
|
||||
: '';
|
||||
const rows = emails
|
||||
.map(
|
||||
(email) => `<li>
|
||||
(email) => `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${escapeHtml(email)}
|
||||
<form method="POST" action="/admin/allowlist/remove" style="display:inline">
|
||||
<form method="POST" action="/admin/allowlist/remove" class="mb-0">
|
||||
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
||||
<button type="submit">削除</button>
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">削除</button>
|
||||
</form>
|
||||
</li>`
|
||||
)
|
||||
.join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Allowlist Admin</title>
|
||||
${BOOTSTRAP_CSS}
|
||||
</head>
|
||||
<body>
|
||||
<h1>Allowlist Admin</h1>
|
||||
<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>${rows}</ul>
|
||||
<form method="POST" action="/admin/allowlist/add">
|
||||
<input type="email" name="email" placeholder="user@example.com" required>
|
||||
<button type="submit">追加</button>
|
||||
<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">ログアウト</button>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">ログアウト</button>
|
||||
</form>
|
||||
</main>
|
||||
${BOOTSTRAP_JS}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -37,30 +37,50 @@ 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="card card-error" data-compose-id="${escapeHtml(card.composeId)}">
|
||||
<h3>${escapeHtml(card.name || card.composeId)}</h3>
|
||||
<p>状態取得失敗</p>
|
||||
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>
|
||||
</div>`;
|
||||
}
|
||||
const { composeId, composeStatus, containerState, meta } = card;
|
||||
const badgeClass = STATE_BADGE[containerState] || 'bg-warning text-dark';
|
||||
const openLink =
|
||||
meta.appType === 'web' && meta.url
|
||||
? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
|
||||
? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
|
||||
: '';
|
||||
const actionButtons =
|
||||
meta.appType === 'web'
|
||||
? `<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>
|
||||
? `<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">
|
||||
${openLink}
|
||||
${actionButtons}
|
||||
<button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -68,14 +88,45 @@ async function renderDashboard(environmentId) {
|
||||
const cards = await buildCards(environmentId);
|
||||
const cardsHtml = cards.map(renderCard).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="ja">
|
||||
<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>
|
||||
<h1>App Portal</h1>
|
||||
<div id="cards">${cardsHtml}</div>
|
||||
<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>
|
||||
<script src="/portal.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
4
NodeSrv/apps/get-voice-mail/.dockerignore
Normal file
4
NodeSrv/apps/get-voice-mail/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
14
NodeSrv/apps/get-voice-mail/.env.example
Normal file
14
NodeSrv/apps/get-voice-mail/.env.example
Normal file
@ -0,0 +1,14 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# app-portalキッカー登録用
|
||||
PORTAL_APP_TYPE=batch
|
||||
PORTAL_APP_LABEL=ボイスメール取得
|
||||
PORTAL_APP_URL=http://get-voice-mail:3000
|
||||
PORTAL_TRIGGER_PATH=/trigger
|
||||
PORTAL_SECRET=
|
||||
|
||||
# n8nワークフロー入口(Webhookノード)のURL。/trigger起動時にPOSTする先
|
||||
N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail
|
||||
# n8n Webhookノード側のHeader Auth Credential値(X-Api-Keyヘッダーに設定)
|
||||
N8N_WEBHOOK_SECRET=
|
||||
13
NodeSrv/apps/get-voice-mail/Dockerfile
Normal file
13
NodeSrv/apps/get-voice-mail/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/index.js"]
|
||||
7
NodeSrv/apps/get-voice-mail/docker-compose.local.yml
Normal file
7
NodeSrv/apps/get-voice-mail/docker-compose.local.yml
Normal file
@ -0,0 +1,7 @@
|
||||
services:
|
||||
get-voice-mail:
|
||||
build: .
|
||||
ports:
|
||||
- "3002:3000"
|
||||
env_file:
|
||||
- .env
|
||||
14
NodeSrv/apps/get-voice-mail/docker-compose.yml
Normal file
14
NodeSrv/apps/get-voice-mail/docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
||||
services:
|
||||
get-voice-mail:
|
||||
build: .
|
||||
expose:
|
||||
- 3000
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
14
NodeSrv/apps/get-voice-mail/package.json
Normal file
14
NodeSrv/apps/get-voice-mail/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "get-voice-mail",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
59
NodeSrv/apps/get-voice-mail/spec.md
Normal file
59
NodeSrv/apps/get-voice-mail/spec.md
Normal file
@ -0,0 +1,59 @@
|
||||
# get-voice-mail
|
||||
## 仕様
|
||||
|
||||
|
||||
- n8nを利用
|
||||
- SCPでサーバーから録音データ取得
|
||||
- プリザンターに音声保存
|
||||
|
||||
## サーバー情報
|
||||
pbx-server-secret.md 参照
|
||||
|
||||
## voiceメールのパス
|
||||
/var/spool/asterisk/monitor
|
||||
|
||||
この下に、年フォルダ、月フォルダ、日フォルダが階層化されている
|
||||
|
||||
取得ファイルルール ※あとで変わるかもだがいまは固定ルール
|
||||
- ファイル名の先頭がin-0420002706-で始まるもの
|
||||
{方向}-{番号A}-{番号B}-{日付}-{時刻}-{uniqueid}.wav
|
||||
|
||||
- 方向: in=着信 / out=発信 / generic=内線間 or 方向判定不能時
|
||||
- 番号A: 着信の場合は着信先(DID/内線番号)、発信の場合は発信元内線
|
||||
- 番号B: 相手側番号(外部番号 or 内線)
|
||||
- 日付: YYYYMMDD
|
||||
- 時刻: HHMMSS
|
||||
- uniqueid: Asteriskチャンネル固有ID (epoch秒.マイクロ秒)。通話開始時刻ベース、重複なし識別子
|
||||
|
||||
例:
|
||||
- in-0420002706-05057998188-20260918-165846-1789718326.114385.wav
|
||||
→ 着信、着信先0420002706、相手05057998188、2026-09-18 16:58:46
|
||||
- generic-0425400505-05017446045-20260918-170343-1789718623.114390.wav
|
||||
→ 内線間 or 方向不明、番号0425400505⇔05017446045
|
||||
|
||||
ファイル名からいつの音声データなのか日付情報も算出
|
||||
ファイル名の日付はJST
|
||||
|
||||
|
||||
## プリザンター
|
||||
取得した音声ファイルと内容をプリザンターのテーブルへ書き出し
|
||||
https://nextoffice.next-hd.co.jp/pleasanter/items/515888/index
|
||||
|
||||
- ClassA:番号A
|
||||
- ClassB:番号B
|
||||
- ClassC:WAVファイル名 ※すでに同じファイルが登録済みの場合はスキップ
|
||||
- AttachmentA:WAV本体保存
|
||||
|
||||
|
||||
## 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`参照
|
||||
|
||||
39
NodeSrv/apps/get-voice-mail/src/index.js
Normal file
39
NodeSrv/apps/get-voice-mail/src/index.js
Normal file
@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const { isTriggerAuthorized } = require('./triggerAuth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PORTAL_SECRET = process.env.PORTAL_SECRET;
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL;
|
||||
const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET;
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ app: 'get-voice-mail', status: 'ok' });
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ status: 'healthy' });
|
||||
});
|
||||
|
||||
app.post('/trigger', (req, res) => {
|
||||
if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
|
||||
res.sendStatus(403);
|
||||
return;
|
||||
}
|
||||
if (!N8N_WEBHOOK_URL) {
|
||||
console.error('get-voice-mail: N8N_WEBHOOK_URL未設定');
|
||||
res.sendStatus(500);
|
||||
return;
|
||||
}
|
||||
fetch(N8N_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {},
|
||||
}).catch((err) => {
|
||||
console.error('get-voice-mail: n8n webhook起動失敗', err.message);
|
||||
});
|
||||
res.sendStatus(202);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`get-voice-mail listening on port ${PORT}`);
|
||||
});
|
||||
5
NodeSrv/apps/get-voice-mail/src/triggerAuth.js
Normal file
5
NodeSrv/apps/get-voice-mail/src/triggerAuth.js
Normal file
@ -0,0 +1,5 @@
|
||||
function isTriggerAuthorized(headerValue, expectedSecret) {
|
||||
return Boolean(expectedSecret) && headerValue === expectedSecret;
|
||||
}
|
||||
|
||||
module.exports = { isTriggerAuthorized };
|
||||
11
NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js
Normal file
11
NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js
Normal file
@ -0,0 +1,11 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { isTriggerAuthorized } = require('../src/triggerAuth');
|
||||
|
||||
test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => {
|
||||
assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true);
|
||||
assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false);
|
||||
assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false);
|
||||
assert.strictEqual(isTriggerAuthorized('anything', undefined), false);
|
||||
assert.strictEqual(isTriggerAuthorized('anything', ''), false);
|
||||
});
|
||||
1
NodeSrv/apps/healthcheck-survey-bot/.gitignore
vendored
Normal file
1
NodeSrv/apps/healthcheck-survey-bot/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
||||
101
NodeSrv/apps/healthcheck-survey-bot/README.md
Normal file
101
NodeSrv/apps/healthcheck-survey-bot/README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# healthcheck-survey-bot
|
||||
|
||||
健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes`
|
||||
をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。
|
||||
|
||||
設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md`
|
||||
実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`
|
||||
|
||||
## 実機調査メモ(2026-09-05確認)
|
||||
|
||||
508971への新規テストProcess追加なしで、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の
|
||||
取得済み`processes.json`(`Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`)
|
||||
に入力検証タブを使ったProcessの実例があり、そこからJSON構造を確認できた。
|
||||
|
||||
Processの入力検証タブは`SiteSettings.Processes[].ValidateInputs`配列として保存される。
|
||||
各要素は`{"Id": number, "ColumnName": string, "Required": boolean}`(実例: `{"Id": 1, "ColumnName": "Class021", "Required": true}`)。
|
||||
配列名は`Validations`ではなく`ValidateInputs`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)は
|
||||
キー自体が省略される可能性が高い(今回の実例では未設定のため確認できていない)。
|
||||
|
||||
Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。
|
||||
|
||||
## n8nリソースID一覧
|
||||
|
||||
(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する)
|
||||
|
||||
- Data Table `healthcheck_bot_state`: `jqMDa2YZTI4f0iQ7`
|
||||
- projectId: `LOcxF69Gm4PvnkqA`(`org-master-sync`用プロジェクトを流用)
|
||||
- 列: `resultId`, `targetEmail`, `currentStatus`, `pendingProcesses`, `awaitInput`, `awaitProcessId`, `awaitColumn`(すべてstring型)
|
||||
- 注記: 設計上の8列目`updatedAt`はn8n Data Tableのシステム予約列名(`POST /data-tables`が`400 Column name "updatedAt" is reserved as a system column name.`を返す)のため、列定義には含めていない。各行の更新日時はn8nが自動管理する`updatedAt`メタデータで代替する。
|
||||
|
||||
- ワークフロー `HC-SUB: プロセス実行と案内送信`: `XRqcykbG2LuAjGG2`
|
||||
- ファイル: `workflows/hc-sub-run-process-and-notify.json`(Execute Workflow Trigger、入力`{resultId, processId}`)
|
||||
- **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて7ノード構成へ全面リビルド。旧19ノード版(個別キーごとのData Table Getノード×7、HTTP Requestノード×6を並べた構成)を置き換え、同じワークフローID`XRqcykbG2LuAjGG2`へPUTで上書き済み(2026-09-05、コントローラー実施、HTTP 200)。**
|
||||
- active化済み(Execute Workflow Triggerサブワークフローは呼び出し元から実行可能にするためactive必須、`asKvC5LSVg3q8tuU`と同じ運用)
|
||||
- ノード構成(7ノード、リニア接続):
|
||||
1. `Execute Workflow Trigger`(入力: `resultId`, `processId`)
|
||||
2. `Data Table`「設定値一括取得」: `workflow_config_values`(`bNkadTyDgDepYx2p`)を`filters`無し・`returnAll:true`で一括取得(個別キーごとのGetノードを廃止)
|
||||
3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立てまでを1ノードに集約。Pleasanter API呼び出しはすべて`this.helpers.httpRequest`(n8n Codeノード公式ヘルパー)で発行
|
||||
4. `Code`「JWTクレーム組み立て」: JWTクレームJSON文字列の組み立てとLINEWORKS `button_template`のactions配列組み立てを1ノードに集約
|
||||
5. `JWT`ノード「Sign JWT」(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」id `Hw0qlEaGfLPnQWp1`)
|
||||
6. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約(`this.helpers.httpRequest`)
|
||||
7. `Data Table`「状態更新」: `healthcheck_bot_state`(`jqMDa2YZTI4f0iQ7`)へ`insert`
|
||||
- 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
|
||||
- Execute Workflow Triggerの入力(`resultId`/`processId`)は、後続ノードの直接の入力でない箇所では必ず`$('Execute Workflow Trigger').item.json...`のようにノード名を明示して参照している(旧版にあった「Data Tableノード通過後に裸の`$json`を参照してしまうバグ」の再発防止)
|
||||
- 未検証・要判断の暫定実装あり(Task 12以降で要確認):
|
||||
①484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジック(実データ構造未調査のため`SiteSettings.BotIds`または`Processes[].BotId`を仮定し、無ければ例外を投げる。「JWTクレーム組み立て」ノードに実装)
|
||||
②`/api/users/get`のレスポンス形状(配列/単一オブジェクト両対応の防御的実装、「Pleasanter照会・選択肢組み立て」ノードに実装)
|
||||
③LINEWORKSトークン取得は`URLSearchParams`インスタンスをそのまま`body`に渡す形に修正済み(`.toString()`化や`json:true`併用はn8nのform-urlencoded自動処理と衝突する懸念があったため回避)。レスポンスは文字列/オブジェクトいずれでも`access_token`を読めるよう防御的にパースしている。ただしn8n本体での実機動作は本タスクでは未検証
|
||||
④`Data Table`Insertノードの`columns.mappingMode`/`value`/`schema`の形状(旧19ノード版から流用。旧版はデプロイ実績があるためスキーマ自体は疑わしくないが、実書き込みでの動作は本タスクでは未再検証)
|
||||
- テスト実行(Step 4相当)は本タスクでは未実施。次回、捨てレコードでの疎通確認とユーザー確認が必要
|
||||
|
||||
- ワークフロー `HC-WP: Statusプッシュ通知`: `swXfpoDtwZDT3Hwk`
|
||||
- ファイル: `workflows/hc-wp-status-push.json`
|
||||
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`)
|
||||
- active化済み
|
||||
- `X-Api-Key`は`workflow_config_values`の`HEALTHCHECK_WP_API_KEY`と照合。秘密値のハードコードなし
|
||||
- 実際のcurl疎通テスト(本番508971書き込み・LINEWORKS実送信を伴う)は未実施。ユーザー確認後に実施する
|
||||
- デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した(詳細: `task-10-report.md`)。設定値取得もfilter付き単一キーGetから一括取得+Codeフィルタへ変更済み
|
||||
|
||||
- ワークフロー `HC-WA: LINEWORKS応答受信`: `0i0Ze3Gq0Waof3Fa`
|
||||
- ファイル: `workflows/hc-wa-lineworks-response.json`
|
||||
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証)
|
||||
- active化済み(2026-09-05、コントローラーが新規デプロイ・activate、HTTP 200)
|
||||
- **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて14ノード構成で全面リビルド。旧60ノード版(個別キーごとのData Table/HTTP Requestノードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)を置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ノードのままだが、それらは機械的に必要な最小限(IF×2, JWT×1, Execute Workflow×1, Data Table×3)であり、本来Codeノードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeノードに集約した(ブリーフの目安「11ノード」に対し実際は14ノードだが、これはIF/JWT/Execute Workflowを専用ノードに保つというルール自体が要求する固定オーバーヘッドであり、60ノードからの削減という趣旨は達成している)
|
||||
- LINEWORKS Developer Console側のBot Callback URL設定・疎通テストは未実施。ユーザー確認後に実施する
|
||||
- ノード構成(14ノード):
|
||||
1. `Webhook`(`healthcheck-lineworks-response`, `rawBody:true`)
|
||||
2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得
|
||||
3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までを1ノードに集約。0件/複数件はthrow(自動判定しない)
|
||||
4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得
|
||||
5. `Code`「分岐処理・アクション決定」: `awaitInput`(none/date/file、または待機状態自体が無い場合)に応じた分岐ロジック全体を1ノードに集約。`action`(execute/send/error)と次の待機状態(`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`)を1つのJSONオブジェクトとして出力する
|
||||
6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update`
|
||||
7. `IF`「action==execute」: true→8、false→9
|
||||
8. `Execute Workflow`「HC-SUB実行」(Task 9, `XRqcykbG2LuAjGG2`)→13
|
||||
9. `IF`「action==send」: true→10、false→14
|
||||
10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ノードに集約
|
||||
11. `JWT`「Sign JWT」(`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`)
|
||||
12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約→13
|
||||
13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ノード
|
||||
14. `Respond to Webhook(異常系)`: 9のfalse分岐(action=="error")専用
|
||||
- **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ノード5は「今回どうするか(action/processId/messageText)」と「次に書き込む待機状態(next*)」を1つの出力オブジェクトにまとめて返す。ノード6(状態更新)はノード5の直接の後続ノードなのでそのまま`$json.next*`を参照して無条件更新するが、ノード6通過後の`$json`はData Table Updateの返り値(更新後の行データ)に置き換わり、ノード5の`action`/`processId`/`messageText`等は失われる。そのため、ノード7以降(IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とノード名を明示して参照している(HC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約)
|
||||
- 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
|
||||
- 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`):
|
||||
①Data Table `update`操作のパラメータ形状(get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用)
|
||||
②`/api/users/get`・`api/items/{SiteId}/get`(ColumnFilterHash検索)のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲
|
||||
③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装(`SiteSettings.BotIds`または`Processes[].BotId`を仮定)
|
||||
④**LINEWORKSファイル添付(`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`)にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTノード(ノード11、action=="send"経路にのみ存在しノード5より後段)でしか行えないため、ノード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持する(ユーザーが詰まらないための安全側実装)。実装するにはグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)が必要で、フォローアップタスクで対応すること
|
||||
- 設計上の注記: HC-SUB(Task 9)が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にした(Task 9側のファイルは変更していない)
|
||||
|
||||
## n8nフォルダ
|
||||
|
||||
- フォルダ `健康診断ワークフロー`: `hOx8WKrdOucllbcU`(projectId `LOcxF69Gm4PvnkqA`配下に2026-09-05作成)
|
||||
- HC-SUB/HC-WP/HC-WAの3ワークフローをこのフォルダへ移動する予定だが、Public APIの`/workflows/{id}/transfer`は別プロジェクトへの移動専用(同一プロジェクト内移動は拒否される)で、実行するとactive状態が解除される副作用があるため、**ユーザーがn8n UI上で手動ドラッグして移動する**運用にした(コントローラーからは未実施)
|
||||
|
||||
## workflow_config_values 追加登録キー(2026-09-05、healthcheck-survey-bot用)
|
||||
|
||||
既存の`workflow_config_values`(`bNkadTyDgDepYx2p`、org-master-sync用と共用)に以下を追加登録済み:
|
||||
- `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT`(LINEWORKS通知送信ワークフローと同じBot認証情報)
|
||||
- `HEALTHCHECK_SITE_ID`(508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(484184)/ `HEALTHCHECK_DATA_TABLE_ID`(`jqMDa2YZTI4f0iQ7`)
|
||||
- `HEALTHCHECK_WP_API_KEY`(HC-WP Webhook認証用に新規生成)
|
||||
- `LINEWORKS_BOT_SECRET`(署名検証用。値は現状プレースホルダー`UNSET_PENDING_BOT_CALLBACK_CONFIRMATION`。LINEWORKS Developer ConsoleでBot Callback URL設定時に実際のBot Secretへ更新が必要)
|
||||
10
NodeSrv/apps/healthcheck-survey-bot/package.json
Normal file
10
NodeSrv/apps/healthcheck-survey-bot/package.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "healthcheck-survey-bot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js",
|
||||
"deploy-workflow": "node scripts/deploy-workflow.js"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
// scripts/deploy-workflow.js
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("./n8n-api");
|
||||
|
||||
async function main() {
|
||||
const [, , filePath, ...rest] = process.argv;
|
||||
if (!filePath) {
|
||||
console.error("使い方: node scripts/deploy-workflow.js <workflows/xxx.json> [--id=<既存workflowId>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const idArg = rest.find((a) => a.startsWith("--id="));
|
||||
const existingId = idArg ? idArg.slice("--id=".length) : null;
|
||||
|
||||
const fullPath = path.resolve(filePath);
|
||||
const definition = JSON.parse(fs.readFileSync(fullPath, "utf8"));
|
||||
const body = {
|
||||
name: definition.name,
|
||||
nodes: definition.nodes,
|
||||
connections: definition.connections,
|
||||
settings: definition.settings || {},
|
||||
};
|
||||
|
||||
const { status, body: result } = existingId
|
||||
? await request("PUT", `/workflows/${existingId}`, body)
|
||||
: await request("POST", "/workflows", body);
|
||||
|
||||
console.log("HTTP status:", status);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (status >= 200 && status < 300 && result.id) {
|
||||
console.log(`\nワークフローID: ${result.id}`);
|
||||
console.log("README.mdの「n8nリソースID一覧」へ記録すること。");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
30
NodeSrv/apps/healthcheck-survey-bot/scripts/n8n-api.js
Normal file
30
NodeSrv/apps/healthcheck-survey-bot/scripts/n8n-api.js
Normal file
@ -0,0 +1,30 @@
|
||||
// scripts/n8n-api.js
|
||||
const N8N_BASE_URL = "https://n8n32.next-hd.net/api/v1";
|
||||
const N8N_API_KEY = process.env.N8N_API_KEY;
|
||||
|
||||
if (!N8N_API_KEY) {
|
||||
throw new Error(
|
||||
"環境変数 N8N_API_KEY が未設定です。NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API Keyを設定してください。"
|
||||
);
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const res = await fetch(`${N8N_BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"X-N8N-API-KEY": N8N_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = text;
|
||||
}
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
module.exports = { request };
|
||||
92
NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js
Normal file
92
NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js
Normal file
@ -0,0 +1,92 @@
|
||||
const ERA_INFO = {
|
||||
"令和": 2018,
|
||||
"平成": 1988,
|
||||
"昭和": 1925,
|
||||
"大正": 1911,
|
||||
};
|
||||
|
||||
const ERA_ALIASES = { R: "令和", H: "平成", S: "昭和", T: "大正" };
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function finalizeDateParts(year, month, day) {
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
throw new Error("日付形式で回答してください(例: 2026-03-01)");
|
||||
}
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
throw new Error("存在しない日付です");
|
||||
}
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
function tryParseEraDate(compact) {
|
||||
const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
let era = match[1];
|
||||
if (/^[RHST]$/i.test(era)) {
|
||||
era = ERA_ALIASES[era.toUpperCase()] || era;
|
||||
}
|
||||
if (!ERA_INFO[era]) return null;
|
||||
|
||||
const normalized = match[2]
|
||||
.replace(/年/g, "-")
|
||||
.replace(/月/g, "-")
|
||||
.replace(/日/g, "")
|
||||
.replace(/[.\/]/g, "-");
|
||||
const parts = normalized.split("-").filter((part) => part.length > 0);
|
||||
if (parts.length < 3) {
|
||||
throw new Error("月と日まで入力してください(例: 令和6年3月1日)");
|
||||
}
|
||||
|
||||
const eraYear = Number(parts[0]);
|
||||
const month = Number(parts[1]);
|
||||
const day = Number(parts[2]);
|
||||
if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
throw new Error("日付形式で回答してください(例: 令和6年3月1日)");
|
||||
}
|
||||
|
||||
return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);
|
||||
}
|
||||
|
||||
function tryParseMonthDay(compact) {
|
||||
const match = compact.match(/^(\d{1,2})(?:月|\/|-|\.)(\d{1,2})(?:日)?$/);
|
||||
if (!match) return null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));
|
||||
}
|
||||
|
||||
function parseDateInput(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("日付が空です");
|
||||
}
|
||||
const compact = trimmed.replace(/\s+/g, "");
|
||||
|
||||
const eraResult = tryParseEraDate(compact);
|
||||
if (eraResult) return eraResult;
|
||||
|
||||
const monthDayResult = tryParseMonthDay(compact);
|
||||
if (monthDayResult) return monthDayResult;
|
||||
|
||||
const normalized = compact
|
||||
.replace(/年/g, "-")
|
||||
.replace(/月/g, "-")
|
||||
.replace(/日/g, "")
|
||||
.replace(/[.\/]/g, "-");
|
||||
const isoParts = normalized.split("-").filter((part) => part.length > 0);
|
||||
if (isoParts.length === 3 && isoParts[0].length >= 4) {
|
||||
return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));
|
||||
}
|
||||
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error("日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)");
|
||||
}
|
||||
return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());
|
||||
}
|
||||
|
||||
module.exports = { parseDateInput };
|
||||
@ -0,0 +1,18 @@
|
||||
// src/lib/employeeMasterCheck.js
|
||||
function checkEmailConsistency(pleasanterEmail, masterRecord) {
|
||||
const masterEmail = masterRecord.Class036 || masterRecord.ClassB || null;
|
||||
if (!masterEmail) {
|
||||
return { consistent: false, masterEmail: null };
|
||||
}
|
||||
return { consistent: masterEmail === pleasanterEmail, masterEmail };
|
||||
}
|
||||
|
||||
function resolveKanaFromMaster(masterRecord) {
|
||||
return [masterRecord.Class003, masterRecord.Class004].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function needsKanaFill(currentKana) {
|
||||
return currentKana === null || currentKana === undefined || currentKana === "";
|
||||
}
|
||||
|
||||
module.exports = { checkEmailConsistency, resolveKanaFromMaster, needsKanaFill };
|
||||
30
NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js
Normal file
30
NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js
Normal file
@ -0,0 +1,30 @@
|
||||
function extractProcessesForStatus(processes, status) {
|
||||
return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);
|
||||
}
|
||||
|
||||
function matchProcessByLabel(processes, text) {
|
||||
const trimmed = String(text ?? "").trim();
|
||||
return processes.find((p) => (p.DisplayName || p.Name) === trimmed) || null;
|
||||
}
|
||||
|
||||
function getValidationColumnNames(process) {
|
||||
if (!Array.isArray(process.ValidateInputs)) return [];
|
||||
return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean);
|
||||
}
|
||||
|
||||
function classifyAwaitInput(process) {
|
||||
const columnNames = getValidationColumnNames(process);
|
||||
if (columnNames.length === 0) {
|
||||
return { awaitInput: "none", column: null };
|
||||
}
|
||||
const column = columnNames[0];
|
||||
if (column.startsWith("Date")) {
|
||||
return { awaitInput: "date", column };
|
||||
}
|
||||
if (column.startsWith("Attachments")) {
|
||||
return { awaitInput: "file", column };
|
||||
}
|
||||
return { awaitInput: "none", column: null };
|
||||
}
|
||||
|
||||
module.exports = { extractProcessesForStatus, matchProcessByLabel, classifyAwaitInput };
|
||||
@ -0,0 +1,24 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
function normalizeSignature(value) {
|
||||
return String(value || "").trim().replace(/^sha256=/i, "");
|
||||
}
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const ab = Buffer.from(String(a), "utf8");
|
||||
const bb = Buffer.from(String(b), "utf8");
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function verifySignature(rawBody, headerSignature, botSecret) {
|
||||
const headerSig = normalizeSignature(headerSignature);
|
||||
if (!headerSig || !botSecret) return false;
|
||||
|
||||
const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
|
||||
const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64");
|
||||
|
||||
return safeEqual(headerSig, expected);
|
||||
}
|
||||
|
||||
module.exports = { verifySignature };
|
||||
26
NodeSrv/apps/healthcheck-survey-bot/src/lib/templateFill.js
Normal file
26
NodeSrv/apps/healthcheck-survey-bot/src/lib/templateFill.js
Normal file
@ -0,0 +1,26 @@
|
||||
function isUnsetSentinel(value) {
|
||||
return typeof value === "string" && value.startsWith("1899");
|
||||
}
|
||||
|
||||
function fillTemplate(template, columns, valueHash) {
|
||||
const labelToColumnName = new Map();
|
||||
for (const column of columns) {
|
||||
if (column.LabelText) {
|
||||
labelToColumnName.set(column.LabelText, column.ColumnName);
|
||||
}
|
||||
}
|
||||
|
||||
return template.replace(/\{([^{}]+)\}/g, (matched, label) => {
|
||||
const columnName = labelToColumnName.get(label);
|
||||
if (!columnName) {
|
||||
return matched;
|
||||
}
|
||||
const value = valueHash[columnName];
|
||||
if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) {
|
||||
return "未設定";
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { fillTemplate };
|
||||
37
NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js
Normal file
37
NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js
Normal file
@ -0,0 +1,37 @@
|
||||
// test/dateParser.test.js
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { parseDateInput } = require("../src/lib/dateParser");
|
||||
|
||||
test("ISO形式の日付をそのまま解釈する", () => {
|
||||
assert.strictEqual(parseDateInput("2026-03-01"), "2026-03-01");
|
||||
});
|
||||
|
||||
test("スラッシュ区切りの日付を解釈する", () => {
|
||||
assert.strictEqual(parseDateInput("2026/3/1"), "2026-03-01");
|
||||
});
|
||||
|
||||
test("和暦(令和)を西暦に変換する", () => {
|
||||
assert.strictEqual(parseDateInput("令和6年3月1日"), "2024-03-01");
|
||||
});
|
||||
|
||||
test("和暦の略記(R)を西暦に変換する", () => {
|
||||
assert.strictEqual(parseDateInput("R6.3.1"), "2024-03-01");
|
||||
});
|
||||
|
||||
test("月日のみの入力は今年として解釈する", () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
assert.strictEqual(parseDateInput("3/1"), `${currentYear}-03-01`);
|
||||
});
|
||||
|
||||
test("空文字はエラーになる", () => {
|
||||
assert.throws(() => parseDateInput(""), /日付が空です/);
|
||||
});
|
||||
|
||||
test("存在しない日付はエラーになる", () => {
|
||||
assert.throws(() => parseDateInput("2026-02-30"), /存在しない日付です/);
|
||||
});
|
||||
|
||||
test("解釈不能な文字列はエラーになる", () => {
|
||||
assert.throws(() => parseDateInput("あいうえお"));
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
// test/employeeMasterCheck.test.js
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const {
|
||||
checkEmailConsistency,
|
||||
resolveKanaFromMaster,
|
||||
needsKanaFill,
|
||||
} = require("../src/lib/employeeMasterCheck");
|
||||
|
||||
test("checkEmailConsistency: PLメールアドレス(Class036)と一致すればconsistent:true", () => {
|
||||
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {
|
||||
Class036: "taro.yamada@next-hd.co.jp",
|
||||
ClassB: "taro.yamada@example.com",
|
||||
});
|
||||
assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@next-hd.co.jp" });
|
||||
});
|
||||
|
||||
test("checkEmailConsistency: Class036が無ければClassBにフォールバックする", () => {
|
||||
const result = checkEmailConsistency("taro.yamada@example.com", {
|
||||
ClassB: "taro.yamada@example.com",
|
||||
});
|
||||
assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@example.com" });
|
||||
});
|
||||
|
||||
test("checkEmailConsistency: 不一致ならconsistent:false", () => {
|
||||
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {
|
||||
Class036: "different@next-hd.co.jp",
|
||||
});
|
||||
assert.deepStrictEqual(result, { consistent: false, masterEmail: "different@next-hd.co.jp" });
|
||||
});
|
||||
|
||||
test("checkEmailConsistency: マスタ側にメールが無ければmasterEmail:null・consistent:false", () => {
|
||||
const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {});
|
||||
assert.deepStrictEqual(result, { consistent: false, masterEmail: null });
|
||||
});
|
||||
|
||||
test("resolveKanaFromMaster: 姓カナ+名カナを空白区切りで結合する", () => {
|
||||
assert.strictEqual(
|
||||
resolveKanaFromMaster({ Class003: "ヤマダ", Class004: "タロウ" }),
|
||||
"ヤマダ タロウ"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveKanaFromMaster: 片方欠けていても結合できる", () => {
|
||||
assert.strictEqual(resolveKanaFromMaster({ Class003: "ヤマダ" }), "ヤマダ");
|
||||
});
|
||||
|
||||
test("needsKanaFill: 空文字・未定義はtrue", () => {
|
||||
assert.strictEqual(needsKanaFill(""), true);
|
||||
assert.strictEqual(needsKanaFill(undefined), true);
|
||||
assert.strictEqual(needsKanaFill(null), true);
|
||||
});
|
||||
|
||||
test("needsKanaFill: 値が入っていればfalse", () => {
|
||||
assert.strictEqual(needsKanaFill("ヤマダ タロウ"), false);
|
||||
});
|
||||
53
NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js
Normal file
53
NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js
Normal file
@ -0,0 +1,53 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const {
|
||||
extractProcessesForStatus,
|
||||
matchProcessByLabel,
|
||||
classifyAwaitInput,
|
||||
} = require("../src/lib/processFlow");
|
||||
|
||||
const processes = [
|
||||
{ Id: 1, Name: "了承", DisplayName: "了承", CurrentStatus: 100, ChangedStatus: 200 },
|
||||
{ Id: 2, Name: "日程変更", DisplayName: "日程変更", CurrentStatus: 100, ChangedStatus: 150, ValidateInputs: [{ Id: 1, ColumnName: "Date001", Required: true }] },
|
||||
{ Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 },
|
||||
{ Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, ValidateInputs: [{ Id: 1, ColumnName: "AttachmentsA", Required: true }] },
|
||||
];
|
||||
|
||||
test("extractProcessesForStatus: 現在のStatusに一致するProcessのみ返す", () => {
|
||||
const result = extractProcessesForStatus(processes, 100);
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.deepStrictEqual(result.map((p) => p.Id), [1, 2]);
|
||||
});
|
||||
|
||||
test("extractProcessesForStatus: 一致するProcessが無ければ空配列", () => {
|
||||
assert.deepStrictEqual(extractProcessesForStatus(processes, 999), []);
|
||||
});
|
||||
|
||||
test("matchProcessByLabel: DisplayNameが完全一致するProcessを返す", () => {
|
||||
const candidates = extractProcessesForStatus(processes, 100);
|
||||
const matched = matchProcessByLabel(candidates, "日程変更");
|
||||
assert.strictEqual(matched.Id, 2);
|
||||
});
|
||||
|
||||
test("matchProcessByLabel: 前後の空白を無視して一致判定する", () => {
|
||||
const candidates = extractProcessesForStatus(processes, 100);
|
||||
const matched = matchProcessByLabel(candidates, " 了承 ");
|
||||
assert.strictEqual(matched.Id, 1);
|
||||
});
|
||||
|
||||
test("matchProcessByLabel: 一致しなければnull", () => {
|
||||
const candidates = extractProcessesForStatus(processes, 100);
|
||||
assert.strictEqual(matchProcessByLabel(candidates, "存在しない選択肢"), null);
|
||||
});
|
||||
|
||||
test("classifyAwaitInput: ValidateInputsが無いProcessはnone", () => {
|
||||
assert.deepStrictEqual(classifyAwaitInput(processes[0]), { awaitInput: "none", column: null });
|
||||
});
|
||||
|
||||
test("classifyAwaitInput: Date*列はdate", () => {
|
||||
assert.deepStrictEqual(classifyAwaitInput(processes[1]), { awaitInput: "date", column: "Date001" });
|
||||
});
|
||||
|
||||
test("classifyAwaitInput: Attachments*列はfile", () => {
|
||||
assert.deepStrictEqual(classifyAwaitInput(processes[3]), { awaitInput: "file", column: "AttachmentsA" });
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
// test/signatureVerify.test.js
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const crypto = require("node:crypto");
|
||||
const { verifySignature } = require("../src/lib/signatureVerify");
|
||||
|
||||
test("正しい署名はtrueを返す", () => {
|
||||
const secret = "test-secret";
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64");
|
||||
assert.strictEqual(verifySignature(body, signature, secret), true);
|
||||
});
|
||||
|
||||
test("sha256=プレフィックス付き署名も検証できる", () => {
|
||||
const secret = "test-secret";
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64");
|
||||
assert.strictEqual(verifySignature(body, `sha256=${signature}`, secret), true);
|
||||
});
|
||||
|
||||
test("不正な署名はfalseを返す", () => {
|
||||
assert.strictEqual(verifySignature("body", "invalid-signature", "secret"), false);
|
||||
});
|
||||
|
||||
test("署名ヘッダーが空ならfalseを返す", () => {
|
||||
assert.strictEqual(verifySignature("body", "", "secret"), false);
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
// test/templateFill.test.js
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { fillTemplate } = require("../src/lib/templateFill");
|
||||
|
||||
const columns = [
|
||||
{ ColumnName: "Class003", LabelText: "検査機関" },
|
||||
{ ColumnName: "Date001", LabelText: "検査日" },
|
||||
];
|
||||
|
||||
test("プレースホルダーをレコード値で置換する", () => {
|
||||
const result = fillTemplate(
|
||||
"検査機関: {検査機関}\n日程: {検査日}",
|
||||
columns,
|
||||
{ Class003: "next健診クリニック", Date001: "2026-04-01T00:00:00" }
|
||||
);
|
||||
assert.strictEqual(result, "検査機関: next健診クリニック\n日程: 2026-04-01T00:00:00");
|
||||
});
|
||||
|
||||
test("未設定の日付センチネル値は「未設定」に変換する", () => {
|
||||
const result = fillTemplate("日程: {検査日}", columns, {
|
||||
Date001: "1899-12-30T00:00:00",
|
||||
});
|
||||
assert.strictEqual(result, "日程: 未設定");
|
||||
});
|
||||
|
||||
test("値が無い列は「未設定」に変換する", () => {
|
||||
const result = fillTemplate("検査機関: {検査機関}", columns, {});
|
||||
assert.strictEqual(result, "検査機関: 未設定");
|
||||
});
|
||||
|
||||
test("対応するラベルが見つからないプレースホルダーはそのまま残す", () => {
|
||||
const result = fillTemplate("不明: {存在しないラベル}", columns, {});
|
||||
assert.strictEqual(result, "不明: {存在しないラベル}");
|
||||
});
|
||||
@ -0,0 +1,261 @@
|
||||
{
|
||||
"name": "HC-SUB: プロセス実行と案内送信",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"name": "Execute Workflow Trigger",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"workflowInputs": {
|
||||
"values": [
|
||||
{
|
||||
"name": "resultId"
|
||||
},
|
||||
{
|
||||
"name": "processId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-get-config",
|
||||
"name": "設定値一括取得",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "bNkadTyDgDepYx2p"
|
||||
},
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-pleasanter-query",
|
||||
"name": "Pleasanter照会・選択肢組み立て",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-jwt-claims",
|
||||
"name": "JWTクレーム組み立て",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jwt-sign",
|
||||
"name": "Sign JWT",
|
||||
"type": "n8n-nodes-base.jwt",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "sign",
|
||||
"useJson": true,
|
||||
"claimsJson": "={{ $json.jwtClaims }}",
|
||||
"options": {
|
||||
"algorithm": "RS256"
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"jwtAuth": {
|
||||
"id": "Hw0qlEaGfLPnQWp1",
|
||||
"name": "LINEWORKS Bot Private Key (v4)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-lineworks-send",
|
||||
"name": "アクセストークン取得・LINEWORKS送信",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で\n// 行うため、素のURLSearchParamsを渡す(.toString()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-state-insert",
|
||||
"name": "状態更新",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "insert",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "jqMDa2YZTI4f0iQ7"
|
||||
},
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"resultId": "={{ $json.resultId }}",
|
||||
"targetEmail": "={{ $json.targetEmail }}",
|
||||
"currentStatus": "={{ String($json.currentStatus) }}",
|
||||
"pendingProcesses": "={{ JSON.stringify($json.options) }}",
|
||||
"awaitInput": "none",
|
||||
"awaitProcessId": "",
|
||||
"awaitColumn": ""
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"id": "resultId",
|
||||
"displayName": "resultId",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "targetEmail",
|
||||
"displayName": "targetEmail",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "currentStatus",
|
||||
"displayName": "currentStatus",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "pendingProcesses",
|
||||
"displayName": "pendingProcesses",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitInput",
|
||||
"displayName": "awaitInput",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitProcessId",
|
||||
"displayName": "awaitProcessId",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitColumn",
|
||||
"displayName": "awaitColumn",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Execute Workflow Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "設定値一括取得",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"設定値一括取得": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Pleasanter照会・選択肢組み立て",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Pleasanter照会・選択肢組み立て": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "JWTクレーム組み立て",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"JWTクレーム組み立て": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sign JWT",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sign JWT": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "アクセストークン取得・LINEWORKS送信",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"アクセストークン取得・LINEWORKS送信": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "状態更新",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,142 @@
|
||||
{
|
||||
"name": "HC-WP: Statusプッシュ通知",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-status-push",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"webhookId": "healthcheck-status-push",
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "healthcheck-status-push",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-get-healthcheck_wp_api_key",
|
||||
"name": "設定値一括取得",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "bNkadTyDgDepYx2p"
|
||||
},
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-validate",
|
||||
"name": "検証",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const configRows = $('設定値一括取得').all().map((item) => item.json);\nconst expectedApiKey = configRows.find((row) => row.configKey === \"HEALTHCHECK_WP_API_KEY\")?.configValue;\nconst headers = $input.first().json.headers || {};\nif (headers[\"x-api-key\"] !== expectedApiKey) {\n throw new Error(\"Unauthorized: invalid API key\");\n}\nconst body = $input.first().json.body || {};\nif (!body.resultId || !body.processId) {\n throw new Error(\"Bad Request: resultId, processId は必須です\");\n}\nreturn [{ json: { resultId: body.resultId, processId: body.processId } }];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "execute-hc-sub",
|
||||
"name": "HC-SUB実行",
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "XRqcykbG2LuAjGG2"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"resultId": "={{ $json.resultId }}",
|
||||
"processId": "={{ $json.processId }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "respond-ok",
|
||||
"name": "Respond to Webhook",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}",
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "設定値一括取得",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"設定値一括取得": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "検証",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"検証": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HC-SUB実行",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"HC-SUB実行": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,120 +1,146 @@
|
||||
# 健康診断管理×LINEWORKSアンケート n8n化 設計書
|
||||
# 健康診断管理×LINEWORKS Bot連携 n8n化 設計書
|
||||
|
||||
- 作成日: 2026-09-05
|
||||
- 対象: プリザンター「健康診断管理」プロジェクト(SiteId 508971)へLINEWORKS Bot経由アンケート機能を実装
|
||||
- 位置付け: 既存Express実装(`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)と同等機能をn8nで新規構築。Expressの流用・移植ではなく作り直し
|
||||
- 対象: プリザンター「健康診断管理」プロジェクト(SiteId 508971)へLINEWORKS Bot経由の対話型ステータス管理機能を実装
|
||||
- 位置付け: 既存Express実装(`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)とは別物としてn8nで新規構築。単純な順次質問アンケートではなく、**プリザンターのProcess機能をそのままフロー定義として使うステータス駆動型対話ボット**
|
||||
|
||||
## 1. 背景・既存Express実装との違い
|
||||
|
||||
Express版(ケアセブンプロジェクト向け)は以下の構成だった。
|
||||
Express版(ケアセブンプロジェクト向け)は「アンケート開始→設問を順番に送信→全問回答で1レコードcreate」という一方向の順次アンケートだった。今回の健康診断管理版は要件が異なる。
|
||||
|
||||
- 「アンケート実行管理」を独立サイト(484186)として持ち、対象テーブル(例: 車両点検報告484182)へ回答を新規レコードcreateする
|
||||
- Bot・対象者マスタも専用サイト(484184/484181)を新設
|
||||
- セッション状態はExpressプロセスのメモリ(`Map`)で保持
|
||||
|
||||
今回の健康診断管理版は要件が異なるため、以下の方針で作り直す。
|
||||
|
||||
- **アンケート実行管理を独立サイトにせず、508971自体に統合**(Process+列追加)。回答は新規レコードcreateではなく、発行元の508971レコード自体へupdateで書き戻す
|
||||
- **Botマスタは新設せず、既存の「LINEWORKSアンケート管理」プロジェクトのSiteId 484184をそのまま参照**
|
||||
- **対象者マスタは新設せず、プリザンター標準Usersのメールアドレスをそのまま LINEWORKS の宛先ID(userId)として使う**(LINEWORKS ID=メールアドレスという前提。484181のような変換用マスタは不要)
|
||||
- **サーバー実装はExpressでなくn8n**。セッション状態はn8nプロセスのメモリではなく、n8n Data Table(新規テーブル `survey_sessions`)で保持する
|
||||
- 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. 全体アーキテクチャ
|
||||
## 2. 前提とした既存資産の流用
|
||||
|
||||
```
|
||||
プリザンター(508971 健康診断管理データ本体)
|
||||
└ Process「アンケート発行」ボタン(flowIdごとに1つ)
|
||||
│ クライアントスクリプト fetch
|
||||
▼
|
||||
n8n: W1「健診アンケート開始」(Webhook)
|
||||
└ resultId取得 → ClassC(Users参照列)からPleasanterUserId解決
|
||||
└ POST api/users/get(View:{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へupdate(Hashネスト)→ アンケート進捗列更新 → セッション削除
|
||||
|
||||
n8n: W3「健診アンケートタイムアウト監視」(Schedule Trigger、数分毎)
|
||||
└ survey_sessions走査 → 期限切れセッションに終了通知 → 削除
|
||||
```
|
||||
|
||||
## 3. プリザンター側(508971)変更点
|
||||
|
||||
| 追加項目 | 内容 |
|
||||
| 用途 | 流用元 |
|
||||
|---|---|
|
||||
| 設問対象列 | アンケートで使う列の`ExtendedControlCss`に`survey survey-<flowId>`トークンを付与。1列が複数flowに属してもよい(スペース区切りで複数トークン) |
|
||||
| アンケート進捗列(新規、列名は実装時に採番) | アンケートの発行・回答状況を管理する専用列。既存業務Status(100申込中〜910対象外)とは別軸で持つ |
|
||||
| Process「アンケート発行」 | flowIdごとに1つずつ追加。`OnClick`でn8n WebhookのW1へPOST(`resultId`・`flowId`を送信) |
|
||||
| ClassC(Users参照、既存) | そのまま流用。ここからPleasanterUserIdを取得し、n8n側でメールアドレスを解決する |
|
||||
| Bot | 既存「LINEWORKSアンケート管理」プロジェクトのSiteId 484184(LINEWORKS Bot管理)をそのまま参照。健康診断管理側にBotマスタは新設しない |
|
||||
| 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先ID(userId)として使う。LINE WORKS側からの受信時も`source.userId`=メールアドレスという前提で扱う(専用マッピングマスタは不要) |
|
||||
| 対象レコード特定 | LINEWORKSから届いたメッセージの送信者メール→508971の中で、そのユーザーが紐づくレコードのうち**未完了(Status 900/910以外)の最新レコード**を対象とする。複数該当時の自動判定は行わず、実装上は「複数ヒット」を異常系として扱う |
|
||||
|
||||
新規アンケート(flowId)を追加する際の運用手順:
|
||||
1. 該当列の`ExtendedControlCss`に`survey-<新flowId>`トークンを追記(既存列流用 or 新規列追加はflow内容次第で都度判断)
|
||||
2. 508971にProcess「◯◯アンケート発行」を追加、`flowId`を固定値でn8n Webhookへ渡すよう設定
|
||||
3. n8n側の設問抽出ロジックは`ExtendedControlCss`のトークンでflowIdを判定するため、n8nワークフロー自体の改修は不要(列追加・Process追加のみで新flowを増やせる設計)
|
||||
対象者解決は基本的にプリザンター標準Usersのメールアドレスだけで足りる想定。データ品質の裏付けチェック・補完に社員・組織マスタ管理テーブル(SiteId 504412)を使う件は8章参照(Bot対話フローとは独立した補助機能)。
|
||||
|
||||
アンケート進捗列の具体的な値・遷移(業務Statusとの整合を取るか等)は、実際のアンケートフロー内容を設計する際に個別に確定する。本設計書では「既存業務Statusとは独立した列で管理する」という方針のみを定める。
|
||||
## 3. 全体アーキテクチャ
|
||||
|
||||
## 4. n8n側詳細
|
||||
```
|
||||
[プリザンター508971] Statusが変わる契機は2種類
|
||||
(a) 担当者がProcessボタンを押す(例:①日程通知発行、日程確定時)
|
||||
(b) Bot対話の結果としてProcessが実行される(例:②→③)
|
||||
|
||||
### Data Table「survey_sessions」(新規作成)
|
||||
(a)の場合
|
||||
▼
|
||||
n8n: WP「Statusプッシュ通知」(Webhook, 508971クライアントスクリプトから起動)
|
||||
└ resultId・processIdを受け取りレコード取得
|
||||
└ 該当Processの案内文(ツールチップ欄)をレコード値で置換
|
||||
└ 対象者メール解決 → LINEWORKS Bot APIで案内+選択肢を送信
|
||||
└ n8n Data Table「bot_conversation_state」に提示内容を記録
|
||||
▼
|
||||
LINEWORKS トーク
|
||||
│ ユーザー返信(ボタン押下/テキスト/ファイル)
|
||||
▼
|
||||
n8n: WA「LINEWORKS応答受信」(Webhook、唯一の受信口、署名検証)
|
||||
└ 送信者メール解決 → 508971の対象レコード特定
|
||||
└ bot_conversation_stateの待機状態を見て回答を処理
|
||||
└ 該当Processを`ProcessId`指定でapi/items/updateへ実行 → Status遷移
|
||||
└ 遷移後の新Statusに紐づく次のProcess群を取得し、次の案内を送信(WPと共通ロジック)
|
||||
```
|
||||
|
||||
## 4. フロー定義: 508971のProcess機能をそのまま流用
|
||||
|
||||
新規マスタサイト・新規テーブルは作らない。プリザンター標準の「プロセス」設定画面(画面種別・現在の状況・変更後の状況・表示名・ツールチップ・入力検証タブ等)を、Bot対話の定義としてそのまま使う。
|
||||
|
||||
| Processの項目 | Bot連携での役割 |
|
||||
|---|---|
|
||||
| 現在の状況(CurrentStatus)/変更後の状況(ChangedStatus) | 既存のStatus遷移定義をそのまま使う |
|
||||
| 表示名(DisplayName) | Botが提示する選択肢ボタンのラベル |
|
||||
| ツールチップ | Bot案内文言。`{検査機関}` `{日程}`のようなプレースホルダーを書いておくと、n8nがレコードの実際の値に置換してから送信する |
|
||||
| 入力検証タブの「項目」 | このProcess実行に追加入力を伴うかの判定に流用。列名プレフィックスで種別を判定する(`Date*`→日付入力を1往復挟む、`Attachments*`→ファイル受信を1往復挟む、項目なし→即実行) |
|
||||
| ProcessId | n8nが`POST /api/items/{resultId}/update`に`ProcessId`パラメータとして渡して実行する。公式マニュアル記載の通り、APIからのプロセス実行では入力検証(プリザンター標準の検証機能)は適用されない点に注意 |
|
||||
|
||||
新フローを追加する際の運用手順:
|
||||
1. 508971のProcessを1つ追加(現在の状況・変更後の状況・表示名・ツールチップ文言を設定)
|
||||
2. 追加入力が必要なら、入力検証タブの「項目」に対象列(`Date*`または`Attachments*`)を登録
|
||||
3. n8nワークフロー(WP/WA)は無改修。現在Statusに紐づくProcess一覧を都度`getsite`から動的に取得する設計のため、Process追加だけで新フローを反映できる
|
||||
|
||||
## 5. n8n側詳細
|
||||
|
||||
### Data Table「bot_conversation_state」(新規)
|
||||
|
||||
| 列 | 内容 |
|
||||
|---|---|
|
||||
| sessionId | UUID |
|
||||
| resultId | 508971のResultId |
|
||||
| flowId | アンケート種別識別子 |
|
||||
| botId | 484184から取得したBotId |
|
||||
| targetEmail | 対象者メールアドレス(LINEWORKS userId) |
|
||||
| currentIndex | 現在の設問番号 |
|
||||
| answersJson | ここまでの回答(JSON文字列) |
|
||||
| expiresAt | 現在設問のタイムアウト時刻 |
|
||||
| startedAt | 開始日時(無応答タイムアウト判定用) |
|
||||
| firstAnswerAt | 初回応答有無判定用 |
|
||||
| currentStatus | 直近提示時点のStatus値 |
|
||||
| pendingProcesses | 直近提示した選択肢一覧(`[{processId, label, validateColumn}]`のJSON文字列) |
|
||||
| awaitInput | `none`(選択肢待ち)/`date`(日付入力待ち)/`file`(ファイル受信待ち) |
|
||||
| awaitProcessId | 追加入力完了後に実行すべきProcessId(`awaitInput`が`date`/`file`の間のみ使用) |
|
||||
| awaitColumn | 追加入力先の列名(例: `Date001`) |
|
||||
| updatedAt | 最終更新日時 |
|
||||
|
||||
n8n-guide.md 7-1(Clear出力の握り潰し)・7-2(複数行が後続へそのまま渡ると行数分繰り返し実行される)の罠を踏まえ、1セッション=1行の読み書きに限定し、複数セッションを一括で展開・集約する処理は入れない。
|
||||
n8n-guide.md 7-1(Clear出力の握り潰し)・7-2(複数行が後続へそのまま渡ると行数分繰り返し実行される)の罠を踏まえ、1レコード=1行の読み書きに限定し、複数行を横断する集約処理は入れない。セッションタイムアウトの概念は持たない(ユーザーの都合のいいタイミングで返信されればよい性質のフローのため、待機状態は無期限に保持する)。
|
||||
|
||||
### ワークフロー構成
|
||||
|
||||
- **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等の自動化は今回のスコープ外」としているが、**本機能ではセッションタイムアウト監視のために自動実行を解禁する**(今回の設計で追加する例外)
|
||||
- **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}`、Offset+TotalCountページング)。org-master-sync③で実績のある実装パターンをそのまま使う
|
||||
|
||||
## 5. 設問の型・検証ルール
|
||||
|
||||
Express版(選択式/数値/自由記述の3種、日付は和暦・月日省略に対応)と同等の検証ロジックをn8n上(Codeノード)に再実装する。列名プレフィックスによる型判定(`Class*`→選択式 or 短文、`Num*`→数値、`Date*`→日付、`Description*`→長文)もExpress版を踏襲する。
|
||||
- LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、BotIdとの対応関係を実装時に確認する
|
||||
- 対象者メール解決: `POST api/users/get`(`View:{ApiGetMailAddresses:true}`)。org-master-sync③で実績のある実装パターンを流用
|
||||
|
||||
## 6. エラーハンドリング・異常系
|
||||
|
||||
- キャンセルキーワード(取消/キャンセル/終了等)受信 → セッション削除+508971のアンケート進捗列を「キャンセル」相当へ
|
||||
- 必須設問での無回答・スキップ要求 → 再送(同じ設問を再送信)
|
||||
- 型検証失敗(数値・日付フォーマット不正)→ エラーメッセージ+再送
|
||||
- セッション未検出(期限切れ後の回答等)→ 「アンケートが開始されていません」を返信
|
||||
- 対象レコードが複数ヒット(同一ユーザーの未完了レコードが2件以上)→ 自動判定せず、担当者確認が必要な異常系として扱う(Bot応答は保留し、通知等は今後の実装計画で検討)
|
||||
- 選択肢に一致しない回答 → 選択肢を再提示(4節のフォールバックと同じ経路)
|
||||
- 日付形式不正/ファイル未送信 → エラーメッセージ+再送、待機状態は維持
|
||||
- LINEWORKS送信失敗・508971 update失敗 → n8n Execution History(Postgres保存)に残す。追加のログ実装はしない
|
||||
|
||||
## 7. 導入・検証方針
|
||||
|
||||
- n8n運用ルール(n8n-guide.md 9章)に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認**
|
||||
- 初回検証は1flow・1件のみで実施し、疎通確認後に複数flow展開へ進める
|
||||
- 初回検証は1Process・1件のみで実施し、疎通確認後にフロー全体(①〜③)の通し検証に進める
|
||||
- 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う
|
||||
|
||||
## 8. 未確定事項(今後のflow設計時に個別確定)
|
||||
## 8. 補助機能(Bot対話フローとは独立): 社員マスタ(504412)によるデータ品質チェック・補完
|
||||
|
||||
- アンケートflowの具体的な設問内容・対象列・flowId命名は本設計書のスコープ外。flow追加のたびに3章の運用手順に従って個別設計する
|
||||
- アンケート進捗列の具体的な値・遷移
|
||||
- LINEWORKS Bot Private Key CredentialとBotIdの対応関係の実機確認
|
||||
3〜7章のBot対話フローとは関係のない、508971のデータ品質を担保するための補助機能。n8nのHC-SUB/HC-WP/HC-WAワークフローには組み込まず、別途の仕組み(バッチ・Pleasanter Process/ServerScript等、実装方式は別途検討)として扱う。
|
||||
|
||||
社員・組織マスタ管理テーブル(SiteId 504412、2章参照)の列構成(`NodeSrv/apps/org-master-sync/configs/site-504412_社員・組織マスタ管理テーブル/`で取得済み):
|
||||
|
||||
| 504412の列 | 内容 |
|
||||
|---|---|
|
||||
| `Class011` | ユーザID(PleasanterUserId) |
|
||||
| `ClassB` | メールアドレス |
|
||||
| `Class036` | PLメールアドレス |
|
||||
| `Class003` / `Class004` | 姓(カナ)/名(カナ) |
|
||||
|
||||
- **メールアドレス整合性チェック**: 508971の`ClassC`(Users参照)から解決した対象者のPleasanterUserIdを軸に504412の`Class011`と突き合わせ、その社員の`Class036`(PLメールアドレス)と、`api/users/get`で解決した実際のメールアドレスが一致するかを検証する
|
||||
- **フリガナ補完**: 508971の`ClassD`(フリガナ、必須項目)が空欄の場合、504412の`Class003`(姓(カナ))+`Class004`(名(カナ))から補完する
|
||||
|
||||
## 9. 未確定事項(実装時に個別確定)
|
||||
|
||||
- 具体的なProcess定義(①日程通知〜③検査結果受取りの各Process内容、Status値の追加・修正)は508971のStatus設計がまだ未完成のため、実装着手時に個別に設計する
|
||||
- LINEWORKS Bot Private Key CredentialとBotId(484184側)の対応関係の実機確認
|
||||
- LINEWORKS Bot APIでのファイル受信(ダウンロードURL取得)とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法
|
||||
- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組み(Process流用)の汎用性を保ったまま個別Processを増やしていく
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
// 健康診断管理 SiteId 508971: 手動登録済みProcess「自分手配」(Id:18)に対し、
|
||||
// Class003(検査機関)を「自分手配」(511924 ResultId 513480)にセットするDataChangesを追記する。
|
||||
// OnClickが存在すれば中断する安全策付き(既存の同種スクリプトと同じパターン)。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 508971;
|
||||
const REQUEST_LABEL = "add-process-jibunanpai";
|
||||
const PROCESS_NAME = "自分手配";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, `config_${ENV}.json`), "utf-8"));
|
||||
}
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
const proc = settings.Processes.find((p) => p.Name === PROCESS_NAME);
|
||||
if (!proc) {
|
||||
console.error(`[エラー] Process「${PROCESS_NAME}」が見つかりません。先にPleasanter画面で手動登録してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (proc.OnClick !== undefined) {
|
||||
console.error(`[エラー] Process「${PROCESS_NAME}」(Id:${proc.Id})にOnClickフィールドが存在します。中断します。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (proc.CurrentStatus !== 250 || proc.ChangedStatus !== 400) {
|
||||
console.error(`[エラー] Status遷移が想定外です(CurrentStatus:${proc.CurrentStatus}, ChangedStatus:${proc.ChangedStatus})。250→400になっているか確認してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
proc.DataChanges = [
|
||||
{ Id: 1, Type: "InputValue", ColumnName: "Class003", Value: "513480", ValueFormulaNotUseDisplayName: false, ValueFormulaIsDisplayError: false },
|
||||
];
|
||||
|
||||
const body = {
|
||||
ApiVersion: 1.1, ApiKey: server.ApiKey, SiteId: SITE_ID,
|
||||
Title: current.Title, ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId, InheritPermission: current.InheritPermission,
|
||||
SiteSettings: settings,
|
||||
};
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
console.log(`Process「${PROCESS_NAME}」(Id:${proc.Id}) に Class003=513480(自分手配) のDataChangesを追加します。`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("(--execute 未指定のためプレビューのみ)");
|
||||
return;
|
||||
}
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json()));
|
||||
})();
|
||||
@ -0,0 +1,133 @@
|
||||
// 健康診断管理 SiteId 508971(社員別健康診断管理): Summaries(513156への集計設定)に、
|
||||
// 既存のNum031/Num032と同じ条件(SiteId:513156, DestinationReferenceType:Results,
|
||||
// LinkColumn:ClassZ, Type:Total, SourceColumn=DestinationColumn)でNum033~038/Num061~071/081~091を
|
||||
// 追加するワンショットスクリプト。updatesite(Mode:full)を使う。
|
||||
// --execute指定時のみ実際に送信、指定なければプレビューのみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 508971;
|
||||
const SITE_NAME = "社員別健康診断管理";
|
||||
const DEST_SITE_ID = 513156;
|
||||
const REQUEST_LABEL = "add-summaries-num033-038-and-061-091";
|
||||
|
||||
const TARGET_COLUMNS = [
|
||||
"Num033", "Num034", "Num035", "Num036", "Num037", "Num038",
|
||||
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
|
||||
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
|
||||
];
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
const summaries = settings.Summaries || [];
|
||||
|
||||
const existingIds = summaries.map((s) => s.Id);
|
||||
let nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
|
||||
|
||||
const added = [];
|
||||
for (const col of TARGET_COLUMNS) {
|
||||
if (summaries.some((s) => s.SourceColumn === col && s.SiteId === DEST_SITE_ID)) {
|
||||
console.error(`[エラー] Summaries内に${col}(SiteId${DEST_SITE_ID}向け)が既に存在します(重複防止のため中断)。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const entry = {
|
||||
SiteId: DEST_SITE_ID,
|
||||
DestinationReferenceType: "Results",
|
||||
DestinationColumn: col,
|
||||
LinkColumn: "ClassZ",
|
||||
Type: "Total",
|
||||
SourceColumn: col,
|
||||
Id: nextId,
|
||||
};
|
||||
summaries.push(entry);
|
||||
added.push(entry);
|
||||
nextId++;
|
||||
}
|
||||
settings.Summaries = summaries;
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
return { settings, added };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, added } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log(`追加するSummaries(SiteId${DEST_SITE_ID}向け):`);
|
||||
for (const s of added) console.log(` - Id${s.Id}: ${s.SourceColumn} → ${s.DestinationColumn} (Type:${s.Type}, LinkColumn:${s.LinkColumn})`);
|
||||
console.log(`\nSummaries件数: 変更前${(current.SiteSettings.Summaries || []).length} → 変更後${desiredSettings.Summaries.length}`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,140 @@
|
||||
// 健康診断管理 SiteId 513156(実施年度-集計): site-508971_社員別健康診断管理のNum061~071/081~091の
|
||||
// Column設定(LabelText/ControlType/NoWrap/Min/Max/Description)をそのまま複製して新規追加し、
|
||||
// 508971のStyles(Id1:基本スタイル調整→Id2:数値エリア調整の順)も複製して適用するワンショットスクリプト。
|
||||
// EditorColumnHash.General(Num061~091)は画面上で追加済みのため変更しない。updatesite(Mode:full)を使う。
|
||||
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SRC_SITE_ID = 508971;
|
||||
const DST_SITE_ID = 513156;
|
||||
const DST_SITE_NAME = "実施年度-集計";
|
||||
const REQUEST_LABEL = "apply-508971-num-columns-and-styles";
|
||||
|
||||
const NUM_COLUMN_NAMES = [
|
||||
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
|
||||
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
|
||||
];
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest(siteId) {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, siteId);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(srcCurrent, dstCurrent) {
|
||||
const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings));
|
||||
|
||||
const addedColumns = [];
|
||||
for (const name of NUM_COLUMN_NAMES) {
|
||||
if (settings.Columns.some((c) => c.ColumnName === name)) {
|
||||
console.error(`[エラー] 移行先Columns内に${name}が既に存在します(重複追加防止のため中断)。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name);
|
||||
if (!src) {
|
||||
console.error(`[エラー] 移行元(508971)Columns内に${name}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const col = JSON.parse(JSON.stringify(src));
|
||||
settings.Columns.push(col);
|
||||
addedColumns.push(col);
|
||||
}
|
||||
|
||||
// Styles: 508971のId1(基本スタイル調整)→Id2(数値エリア調整)の順で複製
|
||||
const srcStyles = srcCurrent.SiteSettings.Styles || [];
|
||||
const style1 = srcStyles.find((s) => s.Id === 1);
|
||||
const style2 = srcStyles.find((s) => s.Id === 2);
|
||||
if (!style1 || !style2) {
|
||||
console.error("[エラー] 移行元(508971)にStyle Id1/Id2が見つかりません。");
|
||||
process.exit(1);
|
||||
}
|
||||
settings.Styles = [
|
||||
{ ...style1 },
|
||||
{ ...style2 },
|
||||
];
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める。移行先に元々設定が無ければ空配列)
|
||||
settings.Links = dstCurrent.SiteSettings.Links || [];
|
||||
|
||||
return { settings, addedColumns };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: DST_SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current: srcCurrent } = loadLatest(SRC_SITE_ID);
|
||||
const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID);
|
||||
|
||||
const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent);
|
||||
const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`);
|
||||
console.log("追加するColumns(508971から複製):");
|
||||
for (const c of addedColumns) console.log(` - ${c.ColumnName}: ${c.LabelText}`);
|
||||
console.log("適用するStyles(508971から複製、順序: Id1→Id2):");
|
||||
for (const s of desiredSettings.Styles) console.log(` - Id${s.Id}: ${s.Title}`);
|
||||
console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,128 @@
|
||||
// 健康診断管理 SiteId 508971(社員別健康診断管理): 513156(実施年度-集計)側で
|
||||
// NumA~H→Num031~038へ置き換えた後の設定(LabelText/Unit/TextAlign/NoWrap等)をそのまま複製し、
|
||||
// 508971のColumnsへ新規追加するワンショットスクリプト。EditorColumnHash._Tab-2(カウンタータブ)には
|
||||
// 画面上で既にNum031~038が配置済み(Columns定義のみ未設定)のため、EditorColumnHashは変更しない。
|
||||
// 508971の既存Num項目(NumA=年齢, Num001~003, Num061~091)には一切触れない。
|
||||
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SRC_SITE_ID = 513156;
|
||||
const DST_SITE_ID = 508971;
|
||||
const DST_SITE_NAME = "社員別健康診断管理";
|
||||
const REQUEST_LABEL = "apply-num031-038-columns";
|
||||
|
||||
const NUM_COLUMN_NAMES = ["Num031", "Num032", "Num033", "Num034", "Num035", "Num036", "Num037", "Num038"];
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest(siteId) {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, siteId);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(srcCurrent, dstCurrent) {
|
||||
const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings));
|
||||
|
||||
const tab2 = settings.EditorColumnHash["_Tab-2"] || [];
|
||||
const addedColumns = [];
|
||||
for (const name of NUM_COLUMN_NAMES) {
|
||||
if (settings.Columns.some((c) => c.ColumnName === name)) {
|
||||
console.error(`[エラー] 移行先(508971)Columns内に${name}が既に存在します(重複防止のため中断)。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!tab2.includes(name)) {
|
||||
console.error(`[エラー] EditorColumnHash._Tab-2内に${name}が見つかりません。画面上の配置状況を確認してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name);
|
||||
if (!src) {
|
||||
console.error(`[エラー] 移行元(513156)Columns内に${name}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const col = JSON.parse(JSON.stringify(src));
|
||||
settings.Columns.push(col);
|
||||
addedColumns.push(col);
|
||||
}
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = dstCurrent.SiteSettings.Links || [];
|
||||
|
||||
return { settings, addedColumns };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: DST_SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current: srcCurrent } = loadLatest(SRC_SITE_ID);
|
||||
const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID);
|
||||
|
||||
const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent);
|
||||
const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`);
|
||||
console.log("追加するColumns(513156から複製):");
|
||||
for (const c of addedColumns) console.log(` - ${JSON.stringify(c)}`);
|
||||
console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,113 @@
|
||||
// 健康診断管理 SiteId 508971: 有所見者数側(Num081~091)のNoWrapをfalseに修正するワンショットスクリプト。
|
||||
// restructure-site-508971-tab2-counts.jsで追加した際、実施人数側(Num061~071)のNoWrap:trueを
|
||||
// そのまま複製していたため、有所見者数側のみfalseへ訂正する。updatesite(Mode:full)を使う。
|
||||
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 508971;
|
||||
const SITE_NAME = "健康診断管理";
|
||||
const REQUEST_LABEL = "fix-arishoken-nowrap";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
const TARGET_COLUMNS = [
|
||||
"Num081", "Num082", "Num083", "Num084", "Num085",
|
||||
"Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
|
||||
];
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
const changed = [];
|
||||
for (const name of TARGET_COLUMNS) {
|
||||
const col = settings.Columns.find((c) => c.ColumnName === name);
|
||||
if (!col) {
|
||||
console.error(`[エラー] Columns内に${name}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const before = col.NoWrap;
|
||||
col.NoWrap = false;
|
||||
changed.push({ name, before, after: col.NoWrap, label: col.LabelText });
|
||||
}
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
return { settings, changed };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log("NoWrap変更:");
|
||||
for (const c of changed) console.log(` - ${c.name} (${c.label}): ${c.before} → ${c.after}`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,129 @@
|
||||
// 健康診断管理 SiteId 513156(実施年度-集計): EditorColumnHash.Generalの並びを
|
||||
// Num061→Num081→Num062→Num082…と交互配置に組み替えるワンショットスクリプト
|
||||
// (508971の並びに合わせる。508971にはNum061~071→Num081~091の連続配置で反映されており見た目が揃わないため)。
|
||||
// Columns自体の変更は無く配置順のみ変更。updatesite(Mode:full)を使う。
|
||||
// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 513156;
|
||||
const SITE_NAME = "実施年度-集計";
|
||||
const REQUEST_LABEL = "fix-editorcolumnhash-order";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
// [実施人数側, 有所見者数側] のペア。左の直後に右を差し込む
|
||||
const PAIRS = [
|
||||
["Num061", "Num081"],
|
||||
["Num062", "Num082"],
|
||||
["Num063", "Num083"],
|
||||
["Num064", "Num084"],
|
||||
["Num065", "Num085"],
|
||||
["Num066", "Num086"],
|
||||
["Num067", "Num087"],
|
||||
["Num068", "Num088"],
|
||||
["Num069", "Num089"],
|
||||
["Num070", "Num090"],
|
||||
["Num071", "Num091"],
|
||||
];
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
|
||||
const general = settings.EditorColumnHash.General;
|
||||
const pairSrcSet = new Set(PAIRS.map(([src]) => src));
|
||||
const arishokenSet = new Set(PAIRS.map(([, dst]) => dst));
|
||||
const rebuilt = [];
|
||||
for (const name of general) {
|
||||
if (arishokenSet.has(name)) continue; // 有所見者数側は元位置から除去し、対応する実施人数側の直後へ差し込む
|
||||
rebuilt.push(name);
|
||||
if (pairSrcSet.has(name)) {
|
||||
const pair = PAIRS.find(([src]) => src === name);
|
||||
rebuilt.push(pair[1]);
|
||||
}
|
||||
}
|
||||
settings.EditorColumnHash.General = rebuilt;
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
return { settings, rebuilt };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, rebuilt } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log("EditorColumnHash.General 変更後の並び:");
|
||||
console.log(` [${rebuilt.join(", ")}]`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,120 @@
|
||||
// 健康診断管理 SiteId 513156(実施年度-集計): 診断結果集計Num項目(Num061~071/081~091)の
|
||||
// ControlType/Min/Maxを削除し、Unit:"人"/TextAlign:20を付与するワンショットスクリプト
|
||||
// (NumA~Hは既にUnit:"人"/TextAlign:20設定済みのため対象外。508971側のSpinner/Min0/Max1指定は
|
||||
// このサイトの集計項目には不要なため、508971とは別設定にする)。
|
||||
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 513156;
|
||||
const SITE_NAME = "実施年度-集計";
|
||||
const REQUEST_LABEL = "fix-num-attrs-unit-textalign";
|
||||
|
||||
const TARGET_COLUMNS = [
|
||||
"Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071",
|
||||
"Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091",
|
||||
];
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
const changed = [];
|
||||
for (const name of TARGET_COLUMNS) {
|
||||
const col = settings.Columns.find((c) => c.ColumnName === name);
|
||||
if (!col) {
|
||||
console.error(`[エラー] Columns内に${name}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const before = JSON.parse(JSON.stringify(col));
|
||||
delete col.ControlType;
|
||||
delete col.Min;
|
||||
delete col.Max;
|
||||
col.Unit = "人";
|
||||
col.TextAlign = 20;
|
||||
changed.push({ name, before, after: JSON.parse(JSON.stringify(col)) });
|
||||
}
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
return { settings, changed };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log("変更内容:");
|
||||
for (const c of changed) {
|
||||
console.log(` - ${c.name}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
||||
}
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,133 @@
|
||||
// 健康診断管理 SiteId 513156(実施年度-集計): NumA~NumH(社員集計項目)をNum031~038へ置き換える
|
||||
// ワンショットスクリプト。設定(LabelText/Unit/TextAlign/NoWrap等)はそのまま維持し、ColumnNameのみ変更。
|
||||
// EditorColumnHash.General内の配置位置もNumA~H→Num031~038に置換。既存アイテム件数0件を確認済みのため
|
||||
// データ移行は行わない(ColumnNameはPleasanterの物理列名に直結するため、データがあれば別途移行が必要)。
|
||||
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 513156;
|
||||
const SITE_NAME = "実施年度-集計";
|
||||
const REQUEST_LABEL = "rename-numah-to-num031-038";
|
||||
|
||||
// [旧ColumnName, 新ColumnName]
|
||||
const RENAME_PAIRS = [
|
||||
["NumA", "Num031"],
|
||||
["NumB", "Num032"],
|
||||
["NumC", "Num033"],
|
||||
["NumD", "Num034"],
|
||||
["NumE", "Num035"],
|
||||
["NumF", "Num036"],
|
||||
["NumG", "Num037"],
|
||||
["NumH", "Num038"],
|
||||
];
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
const renamedColumns = [];
|
||||
|
||||
for (const [oldName, newName] of RENAME_PAIRS) {
|
||||
const col = settings.Columns.find((c) => c.ColumnName === oldName);
|
||||
if (!col) {
|
||||
console.error(`[エラー] Columns内に${oldName}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (settings.Columns.some((c) => c.ColumnName === newName)) {
|
||||
console.error(`[エラー] Columns内に${newName}が既に存在します(重複防止のため中断)。`);
|
||||
process.exit(1);
|
||||
}
|
||||
col.ColumnName = newName;
|
||||
renamedColumns.push({ oldName, newName, label: col.LabelText });
|
||||
}
|
||||
|
||||
const general = settings.EditorColumnHash.General;
|
||||
const renameMap = new Map(RENAME_PAIRS);
|
||||
settings.EditorColumnHash.General = general.map((name) => renameMap.get(name) || name);
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
return { settings, renamedColumns };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, renamedColumns } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log("置き換え内容:");
|
||||
for (const c of renamedColumns) console.log(` - ${c.oldName} → ${c.newName} (${c.label})`);
|
||||
console.log("EditorColumnHash.General 変更後:");
|
||||
console.log(` [${desiredSettings.EditorColumnHash.General.join(", ")}]`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,160 @@
|
||||
// 健康診断管理 SiteId 508971: タブ「診断結果」のNum061~071(検査項目)に
|
||||
// ラベル末尾" (実施人数)"を付与し、同じ設定(ControlType/NoWrap/Min/Max/Description)を
|
||||
// 複製したNum081~091(ラベル末尾" (有所見者数)")を新規追加、EditorColumnHash._Tab-1の並びを
|
||||
// Num061→Num081→Num062→Num082…のように交互配置へ組み替えるワンショットスクリプト。
|
||||
// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 508971;
|
||||
const SITE_NAME = "健康診断管理";
|
||||
const REQUEST_LABEL = "tab2-jisshi-arishoken-counts";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
|
||||
// [実施人数側(既存), 有所見者数側(新規)] のペア。左の設定をそのまま右へ複製する
|
||||
const PAIRS = [
|
||||
["Num061", "Num081"],
|
||||
["Num062", "Num082"],
|
||||
["Num063", "Num083"],
|
||||
["Num064", "Num084"],
|
||||
["Num065", "Num085"],
|
||||
["Num066", "Num086"],
|
||||
["Num067", "Num087"],
|
||||
["Num068", "Num088"],
|
||||
["Num069", "Num089"],
|
||||
["Num070", "Num090"],
|
||||
["Num071", "Num091"],
|
||||
];
|
||||
|
||||
function loadServerConfig() {
|
||||
const configPath = path.join(REPO_ROOT, `config_${ENV}.json`);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
if (!config.BaseUrl || !config.ApiKey) {
|
||||
console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
if (!siteDir) {
|
||||
console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
function buildDesiredSiteSettings(current) {
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
const columns = settings.Columns;
|
||||
|
||||
const addedColumns = [];
|
||||
for (const [srcName, newName] of PAIRS) {
|
||||
const src = columns.find((c) => c.ColumnName === srcName);
|
||||
if (!src) {
|
||||
console.error(`[エラー] Columns内に${srcName}が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (columns.some((c) => c.ColumnName === newName)) {
|
||||
console.error(`[エラー] Columns内に${newName}が既に存在します(重複追加防止のため中断)。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const originalLabel = src.LabelText;
|
||||
src.LabelText = `${originalLabel} (実施人数)`;
|
||||
|
||||
const newCol = JSON.parse(JSON.stringify(src));
|
||||
newCol.ColumnName = newName;
|
||||
newCol.LabelText = `${originalLabel} (有所見者数)`;
|
||||
columns.push(newCol);
|
||||
addedColumns.push(newCol);
|
||||
}
|
||||
|
||||
// EditorColumnHash._Tab-1 の並びをNum061→Num081→Num062→Num082…と交互に組み替える
|
||||
const tab1 = settings.EditorColumnHash["_Tab-1"];
|
||||
const pairSrcSet = new Set(PAIRS.map(([src]) => src));
|
||||
const rebuilt = [];
|
||||
for (const name of tab1) {
|
||||
rebuilt.push(name);
|
||||
if (pairSrcSet.has(name)) {
|
||||
const pair = PAIRS.find(([src]) => src === name);
|
||||
rebuilt.push(pair[1]);
|
||||
}
|
||||
}
|
||||
settings.EditorColumnHash["_Tab-1"] = rebuilt;
|
||||
|
||||
// Links省略厳禁(全置換のため既存値をそのまま含める)
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
return { settings, addedColumns, rebuiltTab1: rebuilt };
|
||||
}
|
||||
|
||||
function buildUpdateBody(apiKey, current, desiredSettings) {
|
||||
return {
|
||||
ApiVersion: "1.1",
|
||||
ApiKey: apiKey,
|
||||
SiteId: SITE_ID,
|
||||
Title: current.Title,
|
||||
ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId,
|
||||
InheritPermission: current.InheritPermission,
|
||||
SiteSettings: desiredSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const { settings: desiredSettings, addedColumns, rebuiltTab1 } = buildDesiredSiteSettings(current);
|
||||
const body = buildUpdateBody(server.ApiKey, current, desiredSettings);
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`);
|
||||
fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8");
|
||||
|
||||
console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`);
|
||||
console.log("ラベル変更(実施人数):");
|
||||
for (const [srcName] of PAIRS) {
|
||||
const c = desiredSettings.Columns.find((x) => x.ColumnName === srcName);
|
||||
console.log(` - ${srcName}: ${c.LabelText}`);
|
||||
}
|
||||
console.log("新規追加(有所見者数):");
|
||||
for (const c of addedColumns) {
|
||||
console.log(` - ${c.ColumnName}: ${c.LabelText}`);
|
||||
}
|
||||
console.log("EditorColumnHash._Tab-1 変更後の並び:");
|
||||
console.log(` [${rebuiltTab1.join(", ")}]`);
|
||||
console.log(`\nColumns件数: 変更前${current.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`);
|
||||
console.log(`プレビュー保存先: ${updatedPath}`);
|
||||
|
||||
if (!execute) {
|
||||
console.log("\n(--execute 未指定のためプレビューのみ。送信していません)");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
console.log(`送信先: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json));
|
||||
const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8");
|
||||
})();
|
||||
@ -0,0 +1,68 @@
|
||||
// 健康診断管理 SiteId 508971: 既存のクライアントスクリプト「プロセス実行スクリプト」のBodyのみ
|
||||
// ローカルファイルの最新内容で上書きする。Columns/EditorColumnHash/Processesには一切触れない。
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { findSiteDir, newModifyDir } = require("../../site-paths.js");
|
||||
|
||||
const PROJECT_NAME = "健康診断管理";
|
||||
const ENV = "production";
|
||||
const SITE_ID = 508971;
|
||||
const REQUEST_LABEL = "update-clientscript-process";
|
||||
const TITLE = "プロセス実行スクリプト";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME);
|
||||
const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV);
|
||||
const SCRIPT_FILE = path.join(
|
||||
CONFIGS_DIR,
|
||||
"site-508971_社員別健康診断管理",
|
||||
"scripts",
|
||||
"3_プロセス実行スクリプト.js"
|
||||
);
|
||||
|
||||
function loadServerConfig() {
|
||||
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, `config_${ENV}.json`), "utf-8"));
|
||||
}
|
||||
function loadLatest() {
|
||||
const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID);
|
||||
const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`);
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") };
|
||||
}
|
||||
|
||||
const execute = process.argv.includes("--execute");
|
||||
const server = loadServerConfig();
|
||||
|
||||
(async () => {
|
||||
const { current, siteDir, rawText } = loadLatest();
|
||||
const settings = JSON.parse(JSON.stringify(current.SiteSettings));
|
||||
settings.Links = current.SiteSettings.Links || [];
|
||||
|
||||
const script = (settings.Scripts || []).find((s) => s.Title === TITLE);
|
||||
if (!script) {
|
||||
console.error(`[エラー] スクリプト「${TITLE}」が見つかりません。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const newBody = fs.readFileSync(SCRIPT_FILE, "utf-8");
|
||||
script.Body = newBody;
|
||||
|
||||
const requestBody = {
|
||||
ApiVersion: 1.1, ApiKey: server.ApiKey, SiteId: SITE_ID,
|
||||
Title: current.Title, ReferenceType: current.ReferenceType,
|
||||
ParentId: current.ParentId, InheritPermission: current.InheritPermission,
|
||||
SiteSettings: settings,
|
||||
};
|
||||
|
||||
const modifyDir = newModifyDir(siteDir, REQUEST_LABEL);
|
||||
fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8");
|
||||
console.log("更新後Body文字数:", newBody.length);
|
||||
|
||||
if (!execute) {
|
||||
console.log("(--execute 未指定のためプレビューのみ)");
|
||||
return;
|
||||
}
|
||||
const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`;
|
||||
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) });
|
||||
console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(await res.json()));
|
||||
})();
|
||||
1037
Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md
Normal file
1037
Pleasanter/健康診断管理/docs/n8n/2026-09-10-bot-flow-redesign-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
105
Pleasanter/健康診断管理/docs/n8n/2026-09-12-screenshot-test-plan.md
Normal file
105
Pleasanter/健康診断管理/docs/n8n/2026-09-12-screenshot-test-plan.md
Normal file
@ -0,0 +1,105 @@
|
||||
# スクリーンショット撮影用テストケース計画(2026-09-12実施予定)
|
||||
|
||||
目的: マニュアル「健康診断Bot機能_仕様(管理担当者向け).md」への画像挿入用。全ケース、対象レコード513409(野木健一郎さん)、事前Status=日程確定(前回セッション終了時点)。
|
||||
|
||||
各ケース、実施前に前提Statusへ調整(API直接updateで可)。実施後は元の状態(Status=日程確定、Class003=511926、Date002=2026-09-08T10:11:00)へ復元してから次ケースへ。
|
||||
|
||||
活性化必要ワークフロー: HC-WA(`0i0Ze3Gq0Waof3Fa`)常時、HC-WP(`swXfpoDtwZDT3Hwk`)はHC-WP経由ケースのみ。両方、検証後は必ずinactiveへ戻す。
|
||||
|
||||
---
|
||||
|
||||
## ケース1: 健康診断メニュー表示(3パターン)
|
||||
|
||||
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 1-1 | 日程通知済み | 「健康診断について」タップ | ボタン「受診日時について」+担当者に連絡の2択 | 5-1 |
|
||||
| 1-2 | 日程確定 | 同上 | ボタン「受診日時について」「受診結果について」の3択 | 5-1 |
|
||||
| 1-3 | 受診済 | 同上 | ボタン「受診結果について」の2択 | 5-1 |
|
||||
| 1-4 | 再検査通知 | 同上 | ボタン「再検査について」の2択 | 5-1 |
|
||||
| 1-5 | 完了等(対象外) | 同上 | 「現在ご案内できる項目はありません」 | 5-1(任意) |
|
||||
|
||||
---
|
||||
|
||||
## ケース2: 受診日時について(パターンA)
|
||||
|
||||
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 2-1 | 日程通知済み | 「受診日時について」タップ | 候補日提示+承諾/受診日時の変更/自分手配/担当者に連絡の4択 | 5-2 |
|
||||
| 2-2 | 2-1の続き | 「承諾」タップ | 「日程を確定しました。」 | 5-2 |
|
||||
| 2-3 | 日程通知済み(再設定) | 「受診日時について」→「受診日時の変更」 | 検査機関連絡先(病院名・電話番号・担当者) | 5-2 |
|
||||
| 2-4 | 2-3の続き | 日付テキスト送信(例: `10/20`) | 「2026-10-20でよろしいですか?」承諾/再入力 | 5-2 |
|
||||
| 2-5 | 2-4の続き | 「承諾」タップ | 「日程を確定しました。」 | 5-2 |
|
||||
| 2-6 | 日程通知済み(再設定) | 「受診日時について」→「自分手配」 | 「ご自分で検査機関を手配して受診されるとのことで承知しました。」 | 5-2 |
|
||||
| 2-7 | 日程通知済み(再設定) | 「受診日時の変更」→認識不能文字列を3回連続送信 | 3回目「日付を認識できないため、いったん受付を中断します。」 | 5-2 |
|
||||
| 2-8 | 日程確定 | 「受診日時について」タップ | 「■あなたの本年度の健康診断について」+確定内容+受診日時の変更ボタン | 5-2 |
|
||||
|
||||
---
|
||||
|
||||
## ケース3: 受診結果について(パターンB)
|
||||
|
||||
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 3-1 | 日程確定 | 「受診結果について」タップ | 「受診完了」「未受診」の2択 | 5-3 |
|
||||
| 3-2 | 3-1の続き | 「受診完了」タップ | 「健康診断の受診、ありがとうございます。」 | 5-3 |
|
||||
| 3-3 | 日程確定(再設定) | 「受診結果について」→「未受診」 | 「再手配」「自分手配」の2択 | 5-3 |
|
||||
| 3-4 | 3-3の続き | 「再手配」タップ | 検査機関連絡先提示 | 5-3 |
|
||||
| 3-5 | 3-4の続き | 日付送信→承諾 | 「受診日時を更新しました。」 | 5-3 |
|
||||
| 3-6 | 日程確定(再設定) | 「未受診」→「自分手配」 | 「ご自分で検査機関を手配して受診されるとのことで承知しました。」 | 5-3 |
|
||||
| 3-7 | 受診済 | 「受診結果について」タップ | 健康診断結果アップロード案内(5-4と共通) | 5-3 |
|
||||
|
||||
---
|
||||
|
||||
## ケース4: 健康診断結果の提出
|
||||
|
||||
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 4-1 | 受診済(file待ち) | PDFファイル送信 | 「1件受け付けました。続けてファイルがあれば送信、なければ「完了」と送信してください」 | 5-4 |
|
||||
| 4-2 | 同上 | PDF以外(画像等)送信 | 「診断結果をお送りいただきましたが、画像での送信でしたので、PDFで再度お送り頂きますようおねがいいたします。」 | 5-4 |
|
||||
| 4-3 | 4-1の続き | 「完了」送信 | 「診断書を受け付けました。」 | 5-4 |
|
||||
|
||||
4-1完了後、テスト添付ファイルは撮影後に必ずPleasanter画面から手動削除(API削除は反映されないことを確認済み)。
|
||||
|
||||
---
|
||||
|
||||
## ケース5: 再検査について(パターンC)
|
||||
|
||||
前提Status=再検査通知(一時的に設定、テスト後は復元)。
|
||||
|
||||
| # | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|
|
||||
| 5-1 | 「健康診断について」→「再検査について」 | 「受診・再検査報告」ボタン1択 | 5-5 |
|
||||
| 5-2 | 「受診・再検査報告」タップ | 1問目「a. 再検査受診日を教えてください」 | 5-5 |
|
||||
| 5-3 | 日付回答(例: `11/10`) | 確認「a. 再検査受診日:11/10でよろしいですか?」はい/いいえ | 5-5 |
|
||||
| 5-4 | 「はい」 | 2問目「b. 受診病院および医師名を教えてください」 | 5-5 |
|
||||
| 5-5 | c・d問も同様に回答(各確認込み) | 4問目回答後の確認 | 5-5(任意) |
|
||||
| 5-6 | d確認「はい」 | 「再検査の報告を受け付けました。ご対応ありがとうございました。」 | 5-5 |
|
||||
|
||||
---
|
||||
|
||||
## ケース6: HC-WP経由の担当者プッシュ通知
|
||||
|
||||
要HC-WP activate。Webhook直接叩きで実施(Pleasanter側Process実行→n8n Webhook POST)。
|
||||
|
||||
| # | 前提Status | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 6-1 | 検査機関確定 | プロセス「日程通知済み」実行→Webhook POST(resultId/processId=5) | 「■本年度の健康診断が手配されました」+確定内容 | 4-2 |
|
||||
| 6-2 | 6-1直後 | 同一送信の続き | 候補日確認ボタン(承諾/受診日時の変更/自分手配/担当者に連絡) | 4-2 |
|
||||
| 6-3 | 就業判定(不可) | プロセス「再検査通知」実行→Webhook POST(processId=11) | 「■健康診断の再検査について」固定文言 | 4-3 |
|
||||
|
||||
---
|
||||
|
||||
## ケース7: 担当者に連絡
|
||||
|
||||
| # | 経由 | 操作 | 撮る画面 | マニュアル挿入先 |
|
||||
|---|---|---|---|---|
|
||||
| 7-1 | button_template内 | 「健康診断について」→3択内の「担当者に連絡」 | 案内文言+《総務・契約関連窓口》URL | 7 |
|
||||
| 7-2 | 固定メニュー直接 | 固定メニューの「担当者に連絡」 | 同上(待機状態リセットされる点は画面上は同じ表示) | 7(任意、1本で足りる) |
|
||||
|
||||
---
|
||||
|
||||
## 撮影時の共通注意
|
||||
|
||||
- スマホ実機(LINEWORKSアプリ)での撮影を基本とする。マニュアルの主読者は担当者であり、社員が実際に見る画面(スマホ)を見せる方が伝わりやすい
|
||||
- 個人情報(本名・電話番号等)が写り込む画面は、マニュアル掲載前にモザイク等の要否を確認する(野木健一郎さんの実データを使うため、`医療機関:日の出ヶ丘病院`等は許容範囲と思われるが、電話番号・担当者名部分は要相談)
|
||||
- 各ケース実施後、Pleasanter側の状態(Status・Class003・Date002・DescriptionB)とn8n Data Table(healthcheck_bot_state)を毎回、次ケース前に復元・確認する
|
||||
- HC-WA/HC-WPは実施の都度activate→検証→deactivateを徹底する(常時activeにしない)
|
||||
1771
Pleasanter/健康診断管理/docs/n8n/design-fixed-menu.md
Normal file
1771
Pleasanter/健康診断管理/docs/n8n/design-fixed-menu.md
Normal file
File diff suppressed because it is too large
Load Diff
146
Pleasanter/健康診断管理/docs/n8n/design.md
Normal file
146
Pleasanter/健康診断管理/docs/n8n/design.md
Normal file
@ -0,0 +1,146 @@
|
||||
# 健康診断管理×LINEWORKS Bot連携 n8n化 設計書
|
||||
|
||||
- 作成日: 2026-09-05
|
||||
- 対象: プリザンター「健康診断管理」プロジェクト(SiteId 508971)へLINEWORKS Bot経由の対話型ステータス管理機能を実装
|
||||
- 位置付け: 既存Express実装(`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)とは別物としてn8nで新規構築。単純な順次質問アンケートではなく、**プリザンターのProcess機能をそのままフロー定義として使うステータス駆動型対話ボット**
|
||||
|
||||
## 1. 背景・既存Express実装との違い
|
||||
|
||||
Express版(ケアセブンプロジェクト向け)は「アンケート開始→設問を順番に送信→全問回答で1レコードcreate」という一方向の順次アンケートだった。今回の健康診断管理版は要件が異なる。
|
||||
|
||||
- 508971は1レコード=1回の健診。フローは「日程通知→了承/変更→受診確認→結果受取り」のようにレコードのStatusを段階的に進める対話であり、設問の連続ではない
|
||||
- LINE WORKS Bot APIの制約上、**「トークを開いた瞬間」を検知するイベントは存在しない**(コールバックイベントは`message`/`join`/`leave`/`joined`/`left`/`postback`のみ)。そのため「初期表示切替」は、①Statusが変わった瞬間にBotから能動的にメッセージを送る(プッシュ型)、②ユーザーが何か送信したら現在Statusの案内を返す(フォールバック)、の組み合わせで実現する
|
||||
- アンケート実行管理・Bot・対象者マスタは新設せず、既存資産を流用する(後述)
|
||||
- **フロー定義(Statusごとの案内文・選択肢・遷移先)を管理する専用マスタは新設しない。508971自体の`SiteSettings.Processes`(プロセス機能)をそのままフロー定義として使う**
|
||||
- サーバー実装はExpressでなくn8n。会話の待機状態はn8nプロセスのメモリではなく、n8n Data Table(新規`bot_conversation_state`)で保持する
|
||||
|
||||
n8n環境自体の詳細(URL・API・既存ワークフロー・過去の罠)は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。以下の設計はこのガイドの制約(コンテナメモリ768MB、Data Table操作の罠、Schedule Trigger運用方針等)を踏まえている。
|
||||
|
||||
## 2. 前提とした既存資産の流用
|
||||
|
||||
| 用途 | 流用元 |
|
||||
|---|---|
|
||||
| Bot | 既存「LINEWORKSアンケート管理」プロジェクトのSiteId 484184(LINEWORKS Bot管理)をそのまま参照。健康診断管理側にBotマスタは新設しない |
|
||||
| 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先ID(userId)として使う。LINE WORKS側からの受信時も`source.userId`=メールアドレスという前提で扱う(専用マッピングマスタは不要) |
|
||||
| 対象レコード特定 | LINEWORKSから届いたメッセージの送信者メール→508971の中で、そのユーザーが紐づくレコードのうち**未完了(Status 900/910以外)の最新レコード**を対象とする。複数該当時の自動判定は行わず、実装上は「複数ヒット」を異常系として扱う |
|
||||
|
||||
対象者解決は基本的にプリザンター標準Usersのメールアドレスだけで足りる想定。データ品質の裏付けチェック・補完に社員・組織マスタ管理テーブル(SiteId 504412)を使う件は8章参照(Bot対話フローとは独立した補助機能)。
|
||||
|
||||
## 3. 全体アーキテクチャ
|
||||
|
||||
```
|
||||
[プリザンター508971] Statusが変わる契機は2種類
|
||||
(a) 担当者がProcessボタンを押す(例:①日程通知発行、日程確定時)
|
||||
(b) Bot対話の結果としてProcessが実行される(例:②→③)
|
||||
|
||||
(a)の場合
|
||||
▼
|
||||
n8n: WP「Statusプッシュ通知」(Webhook, 508971クライアントスクリプトから起動)
|
||||
└ resultId・processIdを受け取りレコード取得
|
||||
└ 該当Processの案内文(ツールチップ欄)をレコード値で置換
|
||||
└ 対象者メール解決 → LINEWORKS Bot APIで案内+選択肢を送信
|
||||
└ n8n Data Table「bot_conversation_state」に提示内容を記録
|
||||
▼
|
||||
LINEWORKS トーク
|
||||
│ ユーザー返信(ボタン押下/テキスト/ファイル)
|
||||
▼
|
||||
n8n: WA「LINEWORKS応答受信」(Webhook、唯一の受信口、署名検証)
|
||||
└ 送信者メール解決 → 508971の対象レコード特定
|
||||
└ bot_conversation_stateの待機状態を見て回答を処理
|
||||
└ 該当Processを`ProcessId`指定でapi/items/updateへ実行 → Status遷移
|
||||
└ 遷移後の新Statusに紐づく次のProcess群を取得し、次の案内を送信(WPと共通ロジック)
|
||||
```
|
||||
|
||||
## 4. フロー定義: 508971のProcess機能をそのまま流用
|
||||
|
||||
新規マスタサイト・新規テーブルは作らない。プリザンター標準の「プロセス」設定画面(画面種別・現在の状況・変更後の状況・表示名・ツールチップ・入力検証タブ等)を、Bot対話の定義としてそのまま使う。
|
||||
|
||||
| Processの項目 | Bot連携での役割 |
|
||||
|---|---|
|
||||
| 現在の状況(CurrentStatus)/変更後の状況(ChangedStatus) | 既存のStatus遷移定義をそのまま使う |
|
||||
| 表示名(DisplayName) | Botが提示する選択肢ボタンのラベル |
|
||||
| ツールチップ | Bot案内文言。`{検査機関}` `{日程}`のようなプレースホルダーを書いておくと、n8nがレコードの実際の値に置換してから送信する |
|
||||
| 入力検証タブの「項目」 | このProcess実行に追加入力を伴うかの判定に流用。列名プレフィックスで種別を判定する(`Date*`→日付入力を1往復挟む、`Attachments*`→ファイル受信を1往復挟む、項目なし→即実行) |
|
||||
| ProcessId | n8nが`POST /api/items/{resultId}/update`に`ProcessId`パラメータとして渡して実行する。公式マニュアル記載の通り、APIからのプロセス実行では入力検証(プリザンター標準の検証機能)は適用されない点に注意 |
|
||||
|
||||
新フローを追加する際の運用手順:
|
||||
1. 508971のProcessを1つ追加(現在の状況・変更後の状況・表示名・ツールチップ文言を設定)
|
||||
2. 追加入力が必要なら、入力検証タブの「項目」に対象列(`Date*`または`Attachments*`)を登録
|
||||
3. n8nワークフロー(WP/WA)は無改修。現在Statusに紐づくProcess一覧を都度`getsite`から動的に取得する設計のため、Process追加だけで新フローを反映できる
|
||||
|
||||
## 5. n8n側詳細
|
||||
|
||||
### Data Table「bot_conversation_state」(新規)
|
||||
|
||||
| 列 | 内容 |
|
||||
|---|---|
|
||||
| resultId | 508971のResultId |
|
||||
| targetEmail | 対象者メールアドレス(LINEWORKS userId) |
|
||||
| currentStatus | 直近提示時点のStatus値 |
|
||||
| pendingProcesses | 直近提示した選択肢一覧(`[{processId, label, validateColumn}]`のJSON文字列) |
|
||||
| awaitInput | `none`(選択肢待ち)/`date`(日付入力待ち)/`file`(ファイル受信待ち) |
|
||||
| awaitProcessId | 追加入力完了後に実行すべきProcessId(`awaitInput`が`date`/`file`の間のみ使用) |
|
||||
| awaitColumn | 追加入力先の列名(例: `Date001`) |
|
||||
| updatedAt | 最終更新日時 |
|
||||
|
||||
n8n-guide.md 7-1(Clear出力の握り潰し)・7-2(複数行が後続へそのまま渡ると行数分繰り返し実行される)の罠を踏まえ、1レコード=1行の読み書きに限定し、複数行を横断する集約処理は入れない。セッションタイムアウトの概念は持たない(ユーザーの都合のいいタイミングで返信されればよい性質のフローのため、待機状態は無期限に保持する)。
|
||||
|
||||
### ワークフロー構成
|
||||
|
||||
- **WP Statusプッシュ通知**: Webhookトリガー(`X-API-Key`ヘッダー認証、508971のクライアントスクリプト専用)。担当者がProcessボタンを押した時に起動
|
||||
- **WA LINEWORKS応答受信**: Webhookトリガー(LINE WORKS本体からの直接コールバック、`x-works-signature`をHMAC-SHA256検証)。唯一の受信口
|
||||
- タイムアウト監視ワークフローは持たない(3節参照)
|
||||
|
||||
### WAの処理詳細
|
||||
|
||||
1. 署名検証 → 送信内容(テキスト/ボタン応答/ファイル)を判定
|
||||
2. 送信者メール解決 → 508971の対象レコード(未完了の最新1件)を特定
|
||||
3. `bot_conversation_state`から該当resultIdの待機状態を取得
|
||||
4. **待機状態なし、または`awaitInput=none`で回答が選択肢と不一致** → 現在Statusに紐づくProcess群を`getsite`から再取得し、案内を再送(フォールバック)
|
||||
5. **`awaitInput=none`で回答が選択肢(DisplayName)と一致**:
|
||||
- 対象列なし → 即座に`ProcessId`実行 → 新Statusの次Process群を取得 → 次の案内を送信
|
||||
- 対象列が`Date*` → `awaitInput=date`/`awaitProcessId`/`awaitColumn`を記録し、「日付を入力してください」を追加送信(Process実行は保留)
|
||||
- 対象列が`Attachments*` → 同様に`awaitInput=file`で保留し、ファイル送信を促す
|
||||
6. **`awaitInput=date`** → 受信テキストを日付として検証(Express版の和暦・月日省略対応ロジックを踏襲)→ `awaitColumn`をupdate → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
|
||||
7. **`awaitInput=file`** → 受信がファイルでなければ再送要求。ファイルならLINEWORKS Bot APIでダウンロード → Pleasanter添付ファイルAPIで`awaitColumn`へアップロード → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す
|
||||
|
||||
### 認証まわり
|
||||
|
||||
- LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、BotIdとの対応関係を実装時に確認する
|
||||
- 対象者メール解決: `POST api/users/get`(`View:{ApiGetMailAddresses:true}`)。org-master-sync③で実績のある実装パターンを流用
|
||||
|
||||
## 6. エラーハンドリング・異常系
|
||||
|
||||
- 対象レコードが複数ヒット(同一ユーザーの未完了レコードが2件以上)→ 自動判定せず、担当者確認が必要な異常系として扱う(Bot応答は保留し、通知等は今後の実装計画で検討)
|
||||
- 選択肢に一致しない回答 → 選択肢を再提示(4節のフォールバックと同じ経路)
|
||||
- 日付形式不正/ファイル未送信 → エラーメッセージ+再送、待機状態は維持
|
||||
- LINEWORKS送信失敗・508971 update失敗 → n8n Execution History(Postgres保存)に残す。追加のログ実装はしない
|
||||
|
||||
## 7. 導入・検証方針
|
||||
|
||||
- n8n運用ルール(n8n-guide.md 9章)に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認**
|
||||
- 初回検証は1Process・1件のみで実施し、疎通確認後にフロー全体(①〜③)の通し検証に進める
|
||||
- 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う
|
||||
|
||||
## 8. 補助機能(Bot対話フローとは独立): 社員マスタ(504412)によるデータ品質チェック・補完
|
||||
|
||||
3〜7章のBot対話フローとは関係のない、508971のデータ品質を担保するための補助機能。n8nのHC-SUB/HC-WP/HC-WAワークフローには組み込まず、別途の仕組み(バッチ・Pleasanter Process/ServerScript等、実装方式は別途検討)として扱う。
|
||||
|
||||
社員・組織マスタ管理テーブル(SiteId 504412、2章参照)の列構成(`NodeSrv/apps/org-master-sync/configs/site-504412_社員・組織マスタ管理テーブル/`で取得済み):
|
||||
|
||||
| 504412の列 | 内容 |
|
||||
|---|---|
|
||||
| `Class011` | ユーザID(PleasanterUserId) |
|
||||
| `ClassB` | メールアドレス |
|
||||
| `Class036` | PLメールアドレス |
|
||||
| `Class003` / `Class004` | 姓(カナ)/名(カナ) |
|
||||
|
||||
- **メールアドレス整合性チェック**: 508971の`ClassC`(Users参照)から解決した対象者のPleasanterUserIdを軸に504412の`Class011`と突き合わせ、その社員の`Class036`(PLメールアドレス)と、`api/users/get`で解決した実際のメールアドレスが一致するかを検証する
|
||||
- **フリガナ補完**: 508971の`ClassD`(フリガナ、必須項目)が空欄の場合、504412の`Class003`(姓(カナ))+`Class004`(名(カナ))から補完する
|
||||
|
||||
## 9. 未確定事項(実装時に個別確定)
|
||||
|
||||
- 具体的なProcess定義(①日程通知〜③検査結果受取りの各Process内容、Status値の追加・修正)は508971のStatus設計がまだ未完成のため、実装着手時に個別に設計する
|
||||
- LINEWORKS Bot Private Key CredentialとBotId(484184側)の対応関係の実機確認
|
||||
- LINEWORKS Bot APIでのファイル受信(ダウンロードURL取得)とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法
|
||||
- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組み(Process流用)の汎用性を保ったまま個別Processを増やしていく
|
||||
1474
Pleasanter/健康診断管理/docs/n8n/plan.md
Normal file
1474
Pleasanter/健康診断管理/docs/n8n/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
101
Pleasanter/健康診断管理/docs/n8n/workflows-status.md
Normal file
101
Pleasanter/健康診断管理/docs/n8n/workflows-status.md
Normal file
@ -0,0 +1,101 @@
|
||||
# healthcheck-survey-bot
|
||||
|
||||
健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes`
|
||||
をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。
|
||||
|
||||
設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md`
|
||||
実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`
|
||||
|
||||
## 実機調査メモ(2026-09-05確認)
|
||||
|
||||
508971への新規テストProcess追加なしで、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の
|
||||
取得済み`processes.json`(`Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`)
|
||||
に入力検証タブを使ったProcessの実例があり、そこからJSON構造を確認できた。
|
||||
|
||||
Processの入力検証タブは`SiteSettings.Processes[].ValidateInputs`配列として保存される。
|
||||
各要素は`{"Id": number, "ColumnName": string, "Required": boolean}`(実例: `{"Id": 1, "ColumnName": "Class021", "Required": true}`)。
|
||||
配列名は`Validations`ではなく`ValidateInputs`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)は
|
||||
キー自体が省略される可能性が高い(今回の実例では未設定のため確認できていない)。
|
||||
|
||||
Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。
|
||||
|
||||
## n8nリソースID一覧
|
||||
|
||||
(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する)
|
||||
|
||||
- Data Table `healthcheck_bot_state`: `jqMDa2YZTI4f0iQ7`
|
||||
- projectId: `LOcxF69Gm4PvnkqA`(`org-master-sync`用プロジェクトを流用)
|
||||
- 列: `resultId`, `targetEmail`, `currentStatus`, `pendingProcesses`, `awaitInput`, `awaitProcessId`, `awaitColumn`(すべてstring型)
|
||||
- 注記: 設計上の8列目`updatedAt`はn8n Data Tableのシステム予約列名(`POST /data-tables`が`400 Column name "updatedAt" is reserved as a system column name.`を返す)のため、列定義には含めていない。各行の更新日時はn8nが自動管理する`updatedAt`メタデータで代替する。
|
||||
|
||||
- ワークフロー `HC-SUB: プロセス実行と案内送信`: `XRqcykbG2LuAjGG2`
|
||||
- ファイル: `workflows/hc-sub-run-process-and-notify.json`(Execute Workflow Trigger、入力`{resultId, processId}`)
|
||||
- **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて7ノード構成へ全面リビルド。旧19ノード版(個別キーごとのData Table Getノード×7、HTTP Requestノード×6を並べた構成)を置き換え、同じワークフローID`XRqcykbG2LuAjGG2`へPUTで上書き済み(2026-09-05、コントローラー実施、HTTP 200)。**
|
||||
- active化済み(Execute Workflow Triggerサブワークフローは呼び出し元から実行可能にするためactive必須、`asKvC5LSVg3q8tuU`と同じ運用)
|
||||
- ノード構成(7ノード、リニア接続):
|
||||
1. `Execute Workflow Trigger`(入力: `resultId`, `processId`)
|
||||
2. `Data Table`「設定値一括取得」: `workflow_config_values`(`bNkadTyDgDepYx2p`)を`filters`無し・`returnAll:true`で一括取得(個別キーごとのGetノードを廃止)
|
||||
3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立てまでを1ノードに集約。Pleasanter API呼び出しはすべて`this.helpers.httpRequest`(n8n Codeノード公式ヘルパー)で発行
|
||||
4. `Code`「JWTクレーム組み立て」: JWTクレームJSON文字列の組み立てとLINEWORKS `button_template`のactions配列組み立てを1ノードに集約
|
||||
5. `JWT`ノード「Sign JWT」(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」id `Hw0qlEaGfLPnQWp1`)
|
||||
6. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約(`this.helpers.httpRequest`)
|
||||
7. `Data Table`「状態更新」: `healthcheck_bot_state`(`jqMDa2YZTI4f0iQ7`)へ`insert`
|
||||
- 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
|
||||
- Execute Workflow Triggerの入力(`resultId`/`processId`)は、後続ノードの直接の入力でない箇所では必ず`$('Execute Workflow Trigger').item.json...`のようにノード名を明示して参照している(旧版にあった「Data Tableノード通過後に裸の`$json`を参照してしまうバグ」の再発防止)
|
||||
- 未検証・要判断の暫定実装あり(Task 12以降で要確認):
|
||||
①484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジック(実データ構造未調査のため`SiteSettings.BotIds`または`Processes[].BotId`を仮定し、無ければ例外を投げる。「JWTクレーム組み立て」ノードに実装)
|
||||
②`/api/users/get`のレスポンス形状(配列/単一オブジェクト両対応の防御的実装、「Pleasanter照会・選択肢組み立て」ノードに実装)
|
||||
③LINEWORKSトークン取得は`URLSearchParams`インスタンスをそのまま`body`に渡す形に修正済み(`.toString()`化や`json:true`併用はn8nのform-urlencoded自動処理と衝突する懸念があったため回避)。レスポンスは文字列/オブジェクトいずれでも`access_token`を読めるよう防御的にパースしている。ただしn8n本体での実機動作は本タスクでは未検証
|
||||
④`Data Table`Insertノードの`columns.mappingMode`/`value`/`schema`の形状(旧19ノード版から流用。旧版はデプロイ実績があるためスキーマ自体は疑わしくないが、実書き込みでの動作は本タスクでは未再検証)
|
||||
- テスト実行(Step 4相当)は本タスクでは未実施。次回、捨てレコードでの疎通確認とユーザー確認が必要
|
||||
|
||||
- ワークフロー `HC-WP: Statusプッシュ通知`: `swXfpoDtwZDT3Hwk`
|
||||
- ファイル: `workflows/hc-wp-status-push.json`
|
||||
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`)
|
||||
- active化済み
|
||||
- `X-Api-Key`は`workflow_config_values`の`HEALTHCHECK_WP_API_KEY`と照合。秘密値のハードコードなし
|
||||
- 実際のcurl疎通テスト(本番508971書き込み・LINEWORKS実送信を伴う)は未実施。ユーザー確認後に実施する
|
||||
- デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した(詳細: `task-10-report.md`)。設定値取得もfilter付き単一キーGetから一括取得+Codeフィルタへ変更済み
|
||||
|
||||
- ワークフロー `HC-WA: LINEWORKS応答受信`: `0i0Ze3Gq0Waof3Fa`
|
||||
- ファイル: `workflows/hc-wa-lineworks-response.json`
|
||||
- Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証)
|
||||
- active化済み(2026-09-05、コントローラーが新規デプロイ・activate、HTTP 200)
|
||||
- **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて14ノード構成で全面リビルド。旧60ノード版(個別キーごとのData Table/HTTP Requestノードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)を置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ノードのままだが、それらは機械的に必要な最小限(IF×2, JWT×1, Execute Workflow×1, Data Table×3)であり、本来Codeノードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeノードに集約した(ブリーフの目安「11ノード」に対し実際は14ノードだが、これはIF/JWT/Execute Workflowを専用ノードに保つというルール自体が要求する固定オーバーヘッドであり、60ノードからの削減という趣旨は達成している)
|
||||
- LINEWORKS Developer Console側のBot Callback URL設定・疎通テストは未実施。ユーザー確認後に実施する
|
||||
- ノード構成(14ノード):
|
||||
1. `Webhook`(`healthcheck-lineworks-response`, `rawBody:true`)
|
||||
2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得
|
||||
3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までを1ノードに集約。0件/複数件はthrow(自動判定しない)
|
||||
4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得
|
||||
5. `Code`「分岐処理・アクション決定」: `awaitInput`(none/date/file、または待機状態自体が無い場合)に応じた分岐ロジック全体を1ノードに集約。`action`(execute/send/error)と次の待機状態(`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`)を1つのJSONオブジェクトとして出力する
|
||||
6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update`
|
||||
7. `IF`「action==execute」: true→8、false→9
|
||||
8. `Execute Workflow`「HC-SUB実行」(Task 9, `XRqcykbG2LuAjGG2`)→13
|
||||
9. `IF`「action==send」: true→10、false→14
|
||||
10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ノードに集約
|
||||
11. `JWT`「Sign JWT」(`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`)
|
||||
12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約→13
|
||||
13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ノード
|
||||
14. `Respond to Webhook(異常系)`: 9のfalse分岐(action=="error")専用
|
||||
- **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ノード5は「今回どうするか(action/processId/messageText)」と「次に書き込む待機状態(next*)」を1つの出力オブジェクトにまとめて返す。ノード6(状態更新)はノード5の直接の後続ノードなのでそのまま`$json.next*`を参照して無条件更新するが、ノード6通過後の`$json`はData Table Updateの返り値(更新後の行データ)に置き換わり、ノード5の`action`/`processId`/`messageText`等は失われる。そのため、ノード7以降(IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とノード名を明示して参照している(HC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約)
|
||||
- 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし
|
||||
- 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`):
|
||||
①Data Table `update`操作のパラメータ形状(get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用)
|
||||
②`/api/users/get`・`api/items/{SiteId}/get`(ColumnFilterHash検索)のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲
|
||||
③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装(`SiteSettings.BotIds`または`Processes[].BotId`を仮定)
|
||||
④**LINEWORKSファイル添付(`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`)にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTノード(ノード11、action=="send"経路にのみ存在しノード5より後段)でしか行えないため、ノード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持する(ユーザーが詰まらないための安全側実装)。実装するにはグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)が必要で、フォローアップタスクで対応すること
|
||||
- 設計上の注記: HC-SUB(Task 9)が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にした(Task 9側のファイルは変更していない)
|
||||
|
||||
## n8nフォルダ
|
||||
|
||||
- フォルダ `健康診断ワークフロー`: `hOx8WKrdOucllbcU`(projectId `LOcxF69Gm4PvnkqA`配下に2026-09-05作成)
|
||||
- HC-SUB/HC-WP/HC-WAの3ワークフローをこのフォルダへ移動する予定だが、Public APIの`/workflows/{id}/transfer`は別プロジェクトへの移動専用(同一プロジェクト内移動は拒否される)で、実行するとactive状態が解除される副作用があるため、**ユーザーがn8n UI上で手動ドラッグして移動する**運用にした(コントローラーからは未実施)
|
||||
|
||||
## workflow_config_values 追加登録キー(2026-09-05、healthcheck-survey-bot用)
|
||||
|
||||
既存の`workflow_config_values`(`bNkadTyDgDepYx2p`、org-master-sync用と共用)に以下を追加登録済み:
|
||||
- `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT`(LINEWORKS通知送信ワークフローと同じBot認証情報)
|
||||
- `HEALTHCHECK_SITE_ID`(508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(484184)/ `HEALTHCHECK_DATA_TABLE_ID`(`jqMDa2YZTI4f0iQ7`)
|
||||
- `HEALTHCHECK_WP_API_KEY`(HC-WP Webhook認証用に新規生成)
|
||||
- `LINEWORKS_BOT_SECRET`(署名検証用。値は現状プレースホルダー`UNSET_PENDING_BOT_CALLBACK_CONFIRMATION`。LINEWORKS Developer ConsoleでBot Callback URL設定時に実際のBot Secretへ更新が必要)
|
||||
@ -0,0 +1,261 @@
|
||||
{
|
||||
"name": "HC-SUB: プロセス実行と案内送信",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"name": "Execute Workflow Trigger",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"workflowInputs": {
|
||||
"values": [
|
||||
{
|
||||
"name": "resultId"
|
||||
},
|
||||
{
|
||||
"name": "processId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-get-config",
|
||||
"name": "設定値一括取得",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "bNkadTyDgDepYx2p"
|
||||
},
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-pleasanter-query",
|
||||
"name": "Pleasanter照会・選択肢組み立て",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-jwt-claims",
|
||||
"name": "JWTクレーム組み立て",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jwt-sign",
|
||||
"name": "Sign JWT",
|
||||
"type": "n8n-nodes-base.jwt",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "sign",
|
||||
"useJson": true,
|
||||
"claimsJson": "={{ $json.jwtClaims }}",
|
||||
"options": {
|
||||
"algorithm": "RS256"
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"jwtAuth": {
|
||||
"id": "Hw0qlEaGfLPnQWp1",
|
||||
"name": "LINEWORKS Bot Private Key (v4)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-lineworks-send",
|
||||
"name": "アクセストークン取得・LINEWORKS送信",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で\n// 行うため、素のURLSearchParamsを渡す(.toString()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-state-insert",
|
||||
"name": "状態更新",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "insert",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "jqMDa2YZTI4f0iQ7"
|
||||
},
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"resultId": "={{ $json.resultId }}",
|
||||
"targetEmail": "={{ $json.targetEmail }}",
|
||||
"currentStatus": "={{ String($json.currentStatus) }}",
|
||||
"pendingProcesses": "={{ JSON.stringify($json.options) }}",
|
||||
"awaitInput": "none",
|
||||
"awaitProcessId": "",
|
||||
"awaitColumn": ""
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"id": "resultId",
|
||||
"displayName": "resultId",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "targetEmail",
|
||||
"displayName": "targetEmail",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "currentStatus",
|
||||
"displayName": "currentStatus",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "pendingProcesses",
|
||||
"displayName": "pendingProcesses",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitInput",
|
||||
"displayName": "awaitInput",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitProcessId",
|
||||
"displayName": "awaitProcessId",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
},
|
||||
{
|
||||
"id": "awaitColumn",
|
||||
"displayName": "awaitColumn",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"canBeUsedToMatch": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Execute Workflow Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "設定値一括取得",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"設定値一括取得": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Pleasanter照会・選択肢組み立て",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Pleasanter照会・選択肢組み立て": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "JWTクレーム組み立て",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"JWTクレーム組み立て": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sign JWT",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sign JWT": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "アクセストークン取得・LINEWORKS送信",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"アクセストークン取得・LINEWORKS送信": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "状態更新",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
142
Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json
Normal file
142
Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json
Normal file
@ -0,0 +1,142 @@
|
||||
{
|
||||
"name": "HC-WP: Statusプッシュ通知",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-status-push",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"webhookId": "healthcheck-status-push",
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "healthcheck-status-push",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dt-get-healthcheck_wp_api_key",
|
||||
"name": "設定値一括取得",
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "bNkadTyDgDepYx2p"
|
||||
},
|
||||
"returnAll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "code-validate",
|
||||
"name": "検証",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const configRows = $('設定値一括取得').all().map((item) => item.json);\nconst expectedApiKey = configRows.find((row) => row.configKey === \"HEALTHCHECK_WP_API_KEY\")?.configValue;\nconst headers = $input.first().json.headers || {};\nif (headers[\"x-api-key\"] !== expectedApiKey) {\n throw new Error(\"Unauthorized: invalid API key\");\n}\nconst body = $input.first().json.body || {};\nif (!body.resultId || !body.processId) {\n throw new Error(\"Bad Request: resultId, processId は必須です\");\n}\nreturn [{ json: { resultId: body.resultId, processId: body.processId } }];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "execute-hc-sub",
|
||||
"name": "HC-SUB実行",
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "XRqcykbG2LuAjGG2"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"resultId": "={{ $json.resultId }}",
|
||||
"processId": "={{ $json.processId }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "respond-ok",
|
||||
"name": "Respond to Webhook",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}",
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "設定値一括取得",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"設定値一括取得": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "検証",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"検証": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HC-SUB実行",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"HC-SUB実行": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
BIN
Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx
Normal file
BIN
Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx
Normal file
Binary file not shown.
BIN
Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx
Normal file
BIN
Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx
Normal file
Binary file not shown.
207
Pleasanter/健康診断管理/健康診断Bot機能_仕様(管理担当者向け).md
Normal file
207
Pleasanter/健康診断管理/健康診断Bot機能_仕様(管理担当者向け).md
Normal file
@ -0,0 +1,207 @@
|
||||
# 健康診断Bot機能 運用マニュアル(管理担当者向け)
|
||||
|
||||
対象: 健康診断管理担当者。LINEWORKS Bot連携で健康診断の手続きを進める仕組み全体を説明する。
|
||||
|
||||
## 1. 概要
|
||||
|
||||
「社員別健康診断管理」上のレコード1件=社員1人分の健康診断手続き。ステータスの遷移に応じて、担当者操作・社員操作・自動送信の3種が組み合わさって進行する。
|
||||
|
||||
- 担当者操作: プリザンター画面上でプロセスボタンを押す、または検査機関確定情報を入力・保存する
|
||||
- 社員操作: LINEWORKS Bot(健康診断Bot)とのトークで、固定メニュー・ボタンから回答する
|
||||
- 自動送信: 日次12:00のリマインダーで、未対応者へ自動催促
|
||||
|
||||
ステータスは常に一方向に進む。プリザンター画面上でステータス列を直接書き換える運用は想定しない(担当者が押すべきボタンを押す・社員が対話に答える、のいずれかでのみ進む)。
|
||||
|
||||
## 2. ステータス一覧
|
||||
|
||||
| ステータス | 進め方 |
|
||||
|---|---|
|
||||
| 新規登録 | フェーズ1(アンケートフォーム連携)で自動 |
|
||||
| アンケート発行 | フェーズ1で自動 |
|
||||
| アンケート回答 | フェーズ1で自動、本人が回答 |
|
||||
| 申込中 | 担当者操作 |
|
||||
| 検査機関確定 | 検査機関・健康診断候補日・コースの3項目を入力し保存すると自動 |
|
||||
| 日程通知済み | 担当者がプロセス実行(Bot通知が飛ぶ) |
|
||||
| 日程確定 | 本人がBot対話で承諾、または日時変更 |
|
||||
| 受診済 | 本人がBot対話で受診報告 |
|
||||
| 診断書保存 | 本人がBot対話で健康診断結果を提出 |
|
||||
| 就業判定(可) | 担当者・産業医が判定 |
|
||||
| 就業判定(不可) | 担当者・産業医が判定 |
|
||||
| 再検査通知 | 担当者がプロセス実行(Bot通知が飛ぶ) |
|
||||
| 再検査受診済み | 本人がBot対話で再検査報告 |
|
||||
| 完了 | 担当者操作(手動のみ) |
|
||||
| 対象外 | 担当者操作 |
|
||||
| 保留 | 担当者操作 |
|
||||
| その他 | 担当者操作 |
|
||||
|
||||
## 3. 全体の流れ
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[申込中] -->|検査機関・候補日・コース入力| B[検査機関確定]
|
||||
B -->|担当者: 日程通知済みプロセス| C[日程通知済み]
|
||||
C -->|本人: 承諾| D[日程確定]
|
||||
C -->|本人: 自分手配| H[受診済]
|
||||
D -->|本人: 受診完了| H
|
||||
D -->|本人: 未受診→再手配| D
|
||||
D -->|本人: 未受診→自分手配| H
|
||||
H -->|本人: 健康診断結果提出| I[診断書保存]
|
||||
I -->|担当者・産業医: 判定| J[就業判定 可/不可]
|
||||
J -->|可| K[完了]
|
||||
J -->|不可・担当者: 再検査通知プロセス| L[再検査通知]
|
||||
L -->|本人: 再検査報告| M[再検査受診済み]
|
||||
M -->|担当者: 完了プロセス| K
|
||||
```
|
||||
|
||||
対象外・保留・その他は、上記のどの段階からでも担当者が手動で切り替え可能。
|
||||
|
||||
## 4. 担当者が行う操作
|
||||
|
||||
### 4-1. 検査機関確定情報の入力
|
||||
|
||||
対象レコードの編集画面で、以下を入力し保存する。
|
||||
|
||||
- 検査機関
|
||||
- 健康診断候補日
|
||||
- コース
|
||||
- バリウム検査(対象者のみ)
|
||||
- 追加オプション(希望する場合のみ)
|
||||
- 窓口清算予定
|
||||
|
||||
上記3項目(検査機関・健康診断候補日・コース)が揃った状態で保存すると、自動でステータスが「検査機関確定」に進む。同時に「健康診断内容」欄へ、入力内容をまとめた確定内容が自動で組み立てられる(表示名込み、例: 検査機関の選択肢名がそのまま入る)。この欄は担当者が直接編集する必要はない。
|
||||
|
||||
### 4-2. 日程通知(担当者操作、Bot通知あり)
|
||||
|
||||
ステータス「検査機関確定」のレコードで「日程通知済み」プロセスを実行する。
|
||||
|
||||
実行すると、社員のLINEWORKSトークへ以下が自動で連続送信される。
|
||||
|
||||
1. 「■本年度の健康診断が手配されました」+健康診断内容一式(テキスト)
|
||||
2. 候補日の確認ボタン(承諾/受診日時の変更/自分手配)
|
||||
|
||||
社員が承諾すればステータスは「日程確定」に自動で進む。日程変更・自分手配の対応もBot側で完結する(4-6参照)。
|
||||
|
||||
### 4-3. 再検査通知(担当者操作、Bot通知あり)
|
||||
|
||||
ステータス「就業判定(不可)」のレコードで「再検査通知」プロセスを実行する。
|
||||
|
||||
実行すると、社員へ「■健康診断の再検査について」から始まる固定文言の案内が届く。以後の再検査結果報告はBot対話(4項目の順次収集)で行われる。
|
||||
|
||||
### 4-4. 就業判定
|
||||
|
||||
ステータス「診断書保存」のレコードについて、産業医・担当者が健康診断結果(受診・再検査報告両方共通で参照する項目)を確認し、「就業判定(可)」または「就業判定(不可)」プロセスを実行する。自動判定は行わない。
|
||||
|
||||
### 4-5. 完了・対象外・保留・その他
|
||||
|
||||
- 完了: 「就業判定(可)」または「再検査受診済み」のレコードで、担当者が手動でプロセスを実行する(自動では進まない)
|
||||
- 対象外・保留・その他: 任意の段階で担当者が手動実行可能
|
||||
|
||||
### 4-6. 自分手配への切り替え
|
||||
|
||||
社員が自分で検査機関を探して受診する場合、Bot対話側の「自分手配」ボタン操作でステータスが「受診済」まで自動的に進む(担当者側の操作は不要)。担当者が事前に把握している場合は、プリザンター画面から「自分手配」プロセスを直接実行してもよい(ステータス「申込中」から「受診済」へ直行)。
|
||||
|
||||
### 4-7. 診断書の再提出依頼(担当者操作、Bot通知あり)
|
||||
|
||||
ステータス「受診済」または「診断書保存」のレコードで「診断書再提出依頼」プロセスを実行する。提出されたPDFに不備があった場合など、担当者から能動的に再提出を促したいときに使う。ステータスは変化しない。
|
||||
|
||||
実行すると、社員へ以下が届く。
|
||||
|
||||
```
|
||||
■健康診断結果の再提出について
|
||||
|
||||
健康診断結果の再提出をお願いいたします。
|
||||
|
||||
固定メニューから「健康診断について」→「受診結果について」を選び、PDFを再度送信してください。
|
||||
```
|
||||
|
||||
ステータス「診断書保存」でも「受診結果について」から再度PDFを提出できる(5-4参照)。
|
||||
|
||||
## 5. 社員側の操作(LINEWORKS Botとの対話)
|
||||
|
||||
固定メニューは「健康診断について」「担当者に連絡」の2つ。「健康診断について」をタップすると、現在のステータスに応じた選択肢だけがボタンで表示される(対象外の項目は表示されない)。
|
||||
|
||||
### 5-1. 健康診断メニューの出し分け
|
||||
|
||||
| ボタン | 表示されるステータス |
|
||||
|---|---|
|
||||
| 受診日時について | 日程通知済み/日程確定 |
|
||||
| 受診結果について | 日程確定/受診済/診断書保存 |
|
||||
| 再検査について | 再検査通知 |
|
||||
|
||||
該当する項目が1つもない場合は「現在ご案内できる項目はありません」とだけ返す。
|
||||
|
||||
### 5-2. 受診日時について
|
||||
|
||||
- ステータス「日程通知済み」: 候補日提示+承諾/受診日時の変更/自分手配
|
||||
- 承諾 → 日程確定。健康診断内容の日時部分だけを確定日に書き換える
|
||||
- 受診日時の変更 → 検査機関の連絡先(病院名・電話番号・担当者)を提示 → 社員が新しい日時をテキストで送る → 「よろしいですか?」の確認 → 承諾で確定
|
||||
- 自分手配 → 検査機関を「自分手配」に、ステータスは「受診済」へ
|
||||
- ステータス「日程確定」: 「■あなたの本年度の健康診断について」+確定済みの健康診断内容を表示。受診日時の変更ボタンあり(上記と同じ流れ)
|
||||
|
||||
自由入力の日付は和暦・月日省略にも対応(例: 「令和6年3月1日」「3/1」)。認識できない入力が3回連続すると、いったん受付を中断し、メニューから選び直すよう案内する。
|
||||
|
||||
### 5-3. 受診結果について
|
||||
|
||||
- ステータス「日程確定」: 「健康診断を予定通り受診されましたか?」に受診完了/未受診
|
||||
- 受診完了 → 受診済。健康診断結果の提出案内へ続く
|
||||
- 未受診 → 再手配/自分手配
|
||||
- 再手配 → 検査機関の連絡先を提示 → 新しい日時をテキストで送る → 確認 → 更新(ステータスは日程確定のまま維持、次回のリマインダー対象に自動的に戻る)
|
||||
- 自分手配 → 検査機関を「自分手配」に、ステータスは「受診済」へ
|
||||
- ステータス「受診済」: 健康診断結果の提出案内へ直結(5-4参照)
|
||||
|
||||
### 5-4. 健康診断結果の提出
|
||||
|
||||
- 案内: 「PDFでスキャンしてアップロードしてください。写真は不可。表裏が別ファイルなら複数枚送信可」
|
||||
- ファイルを受け取るたびに1件ずつ受付、PDF以外は再送依頼(3回連続失敗で受付中断、メニューから選び直し)
|
||||
- 「完了」と送信すると受付完了、ステータスは「診断書保存」へ
|
||||
- 「完了」を送らないまま、最後のファイル受信から10分経過すると自動で受付完了になる(ファイルを1枚も送っていない場合は自動確定しない)
|
||||
- ステータス「診断書保存」になった後でも、「健康診断について」→「受診結果について」から追加でPDFを提出できる(誤ったファイルを送ってしまった場合や、担当者から再提出を依頼された場合の対応)
|
||||
|
||||
提出されたPDFは、対象レコードの「健康診断結果」欄へ以下の形式で保存される。
|
||||
|
||||
```
|
||||
{実施年度エリア}■診断書■{氏名}(フリガナ)_yyyyMMdd-hhmmss.pdf
|
||||
```
|
||||
|
||||
例: `2026■東京■診断書■野木健一郎(ノギケンイチロウ)_20260912-142512.pdf`(アップロード時刻・日本時間)
|
||||
|
||||
### 5-5. 再検査について
|
||||
|
||||
ステータス「再検査通知」で「再検査について」→「受診・再検査報告」を選ぶと、以下4項目を1問ずつ聞かれる。各項目、回答のたびに「よろしいですか?」の確認が入る。
|
||||
|
||||
1. 再検査受診日
|
||||
2. 受診病院および医師名
|
||||
3. 検査項目、結果(概要)
|
||||
4. 就労の可否
|
||||
|
||||
4項目すべて回答すると、まとめて健康診断内容(再検査報告欄)へ保存され、ステータスは「再検査受診済み」へ進む。
|
||||
|
||||
## 6. 自動送信されるメッセージ(日次12:00、リマインダー)
|
||||
|
||||
| 対象 | 条件 |
|
||||
|---|---|
|
||||
| 受診日の事前連絡 | ステータス「日程確定」のまま、受診日の3日前 |
|
||||
| 受診日当日の確認 | ステータス「日程確定」のまま、受診日当日(受診完了/未受診を自動で問いかける) |
|
||||
| 日程未確定の催促 | ステータス「日程通知済み」のまま、通知から1日経過するごとに繰り返し |
|
||||
| 再検査報告の催促 | ステータス「再検査通知」のまま、通知から14日経過するごとに繰り返し |
|
||||
|
||||
いずれも、対象の対応が済めば自動的に催促対象から外れる。
|
||||
|
||||
## 7. 担当者への問い合わせ導線
|
||||
|
||||
社員がBot対話中いつでも「担当者に連絡」を選ぶと、以下が届く。
|
||||
|
||||
```
|
||||
ご用の方は総務総合窓口へお問合せください🙇
|
||||
《総務・契約関連窓口》
|
||||
https://line.worksmobile.com/message/send?userNoList=800300000001350&version=25&type=counselor
|
||||
```
|
||||
|
||||
選択肢の一部として選んだ場合は、それまでの対話の続きをそのまま再開できる。固定メニューから直接選んだ場合は、対話がいったんリセットされる。
|
||||
|
||||
## 8. 注意事項
|
||||
|
||||
- 個人対話(1対1)専用。グループトークでの利用は想定していない
|
||||
- 対象レコードが1件も見つからない社員(新入社員など)がトークを開始・操作すると「対象の健診情報がありません」と返る。故障ではない
|
||||
- 対象レコードが複数ヒットする異常時は「複数の対象レコードが見つかりました。お手数ですが管理者にお問い合わせください」と返る。プリザンター側のデータ重複を確認すること
|
||||
- プリザンター画面上でステータス列を直接編集しないこと。プロセスボタン、またはBot対話経由でのみ進める
|
||||
BIN
Pleasanter/健康診断管理/健康診断Bot機能_運用マニュアル(管理担当者向け).pptx
Normal file
BIN
Pleasanter/健康診断管理/健康診断Bot機能_運用マニュアル(管理担当者向け).pptx
Normal file
Binary file not shown.
@ -0,0 +1,137 @@
|
||||
# Export/Get方式切替 設計書
|
||||
|
||||
## 背景・目的
|
||||
|
||||
現状 `Module1.bas` はPleasanterの **Export API**(CSV全件取得)でデータを取得し、CSVパース後にテーブルへ書き込んでいる。これに加えて **Get API**(`api-record-get-multi`)でデータを取得する方式を追加し、基本情報シートのセルで方式を切り替えて実行できるようにする。両方式の処理時間を比較できるよう、開始・終了時刻と経過秒数をシートに記録する。
|
||||
|
||||
対象件数は200件以下を想定し、ページング処理は考慮しない。
|
||||
|
||||
## セル配置(基本情報シート)
|
||||
|
||||
| セル | 内容 |
|
||||
|---|---|
|
||||
| D3 | ステータスメッセージ(「取込処理中...」「取込処理完了」) |
|
||||
| D4 | 処理開始時刻(`Now`) |
|
||||
| D5 | 処理終了時刻(`Now`) |
|
||||
| D6 | 経過秒数(`(D5 - D4) * 86400` の数値) |
|
||||
| D7 | 取得方式(`"Export"` または `"Get"`。未入力・それ以外の値は `"Export"` 扱い) |
|
||||
|
||||
## Filter・Sorter条件(基本情報シート、Export/Get共通)
|
||||
|
||||
Pleasanter APIの `ColumnFilterHash` / `ColumnSorterHash` に渡す条件を、基本情報シート上の表で指定できるようにする。Export方式・Get方式の両方で同じ条件を使う。
|
||||
|
||||
### セル配置
|
||||
|
||||
| 表 | 見出し | データ行 |
|
||||
|---|---|---|
|
||||
| Filter表 | F2:G2(列名 / 値) | F3以降(列名が空白の行で終了) |
|
||||
| Sorter表 | H2:I2(列名 / 昇降順) | H3以降(`asc`または`desc`。列名が空白の行で終了) |
|
||||
|
||||
- Filter表・Sorter表とも、データ行が1件も無ければ該当するHashはAPIリクエストに含めない
|
||||
- 両方とも0件の場合は `View` パラメータ自体をリクエストボディに含めない
|
||||
|
||||
### `SetupFilterSorterTables()`(新規・セットアップ用Sub)
|
||||
|
||||
基本情報シートのF2:G2、H2:I2を見出しとするExcelテーブル(ListObject)を新規作成する。`新版着工要因閲覧シートv0.1.xlsm` 上で手動実行することを想定したセットアップ専用処理(`run()` からは呼ばない)。既にテーブルが存在する場合は何もしない。
|
||||
|
||||
### `BuildViewFromSheet() As Object`(新規・共通関数)
|
||||
|
||||
基本情報シートのFilter表・Sorter表を読み取り、APIリクエストの `View` に渡す Dictionary を組み立てて返す。Filter表・Sorter表がともに0件の場合は `Nothing` を返す。
|
||||
|
||||
- Filter表の各行から `colFilter.Add 列名, 値` で `ColumnFilterHash` 用Dictionaryを作る(1件以上ある場合のみ)
|
||||
- Sorter表の各行から `colSorter.Add 列名, 昇降順` で `ColumnSorterHash` 用Dictionaryを作る(1件以上ある場合のみ)
|
||||
- どちらか一方でもあれば `View` Dictionaryに `ColumnFilterHash` / `ColumnSorterHash` をそれぞれ追加(無い方は追加しない)
|
||||
- `exportCSVData` と `getRecordsData` の両方から呼び出し、`viewObj` が `Nothing` でなければ `apiBody.Add "View", viewObj` する
|
||||
|
||||
## 全体フロー(`Sub run()`)
|
||||
|
||||
```
|
||||
Sub run()
|
||||
Call init ' defaultSh, tableId, apiKey, baseURL, fetchMethod(D7の値) をセット
|
||||
|
||||
defaultSh.Range("D4").Value = Now
|
||||
defaultSh.Range("D3").Value = "取込処理中..."
|
||||
Application.ScreenUpdating = False
|
||||
Application.Calculation = xlManual
|
||||
|
||||
Select Case fetchMethod
|
||||
Case "Get"
|
||||
Call runGetFlow
|
||||
Case Else
|
||||
Call runExportFlow
|
||||
End Select
|
||||
|
||||
Application.Calculation = xlAutomatic
|
||||
Application.ScreenUpdating = True
|
||||
defaultSh.Range("D5").Value = Now
|
||||
defaultSh.Range("D6").Value = (defaultSh.Range("D5").Value - defaultSh.Range("D4").Value) * 86400
|
||||
defaultSh.Range("D3").Value = "取込処理完了"
|
||||
End Sub
|
||||
```
|
||||
|
||||
- `init()` に `fetchMethod = defaultSh.Range("D7").Value` の読み込みを追加する。
|
||||
- `runExportFlow` は既存の `exportCSVData` → `exportCSVDataToSheet` 呼び出しをそのまま移設したもの(ロジック無変更)。
|
||||
- `runGetFlow` は新規追加の `getRecordsData` → `getRecordsDataToSheet` を呼ぶ。
|
||||
|
||||
## Export方式(Filter/Sorter対応のため一部変更)
|
||||
|
||||
`exportCSVDataToSheet` / `ParseCsv` / `CollectionToArray` は変更しない。
|
||||
|
||||
`exportCSVData` のみ、既存のコメントアウトされた `colFilter` / `view` 部分を `BuildViewFromSheet()` 呼び出しに置き換える。
|
||||
|
||||
```
|
||||
Dim viewObj As Object
|
||||
Set viewObj = BuildViewFromSheet()
|
||||
If Not viewObj Is Nothing Then
|
||||
apiBody.Add "View", viewObj
|
||||
End If
|
||||
```
|
||||
|
||||
(旧来の `yearFilter` を使ったコメントアウトコードは削除する)
|
||||
|
||||
## Get方式(新規追加)
|
||||
|
||||
### `getRecordsData(tableId As String) As Collection`
|
||||
|
||||
Pleasanterの `api-record-get-multi` を呼び出し、レコード配列(Collection of Dictionary)を返す。
|
||||
|
||||
- エンドポイント: `POST {baseURL}/pleasanter/api/items/{tableId}/get`
|
||||
- リクエストボディ: `ApiVersion`, `ApiKey`
|
||||
- 取得件数は指定しない。1回のリクエストでの取得上限はサーバー側既定値(`Api.json` の `PageSize`、既定200件)であり、クライアント側から `PageSize` を指定するパラメータは存在しない。200件以下想定のため既定動作のまま1回のリクエストで取得しきる(`Offset` も指定不要)
|
||||
- レスポンス: `res("Response")("Data")` を返す(レコードDictionaryの配列。`Results` という入れ子は存在しない)
|
||||
- 既存の `callRestApi` をそのまま利用する
|
||||
- Filter/Sorter条件は `BuildViewFromSheet()` を呼び出し、`viewObj` が `Nothing` でなければ `apiBody.Add "View", viewObj` する(Export方式と同じ共通関数を利用)
|
||||
|
||||
### レコードのフラット化(`FlattenRecord(record As Dictionary) As Dictionary`)
|
||||
|
||||
Pleasanterのget APIレスポンスは、`ClassA`〜`ClassZ` などの項目が `ClassHash`(同様に `NumHash`, `DateHash`, `DescriptionHash`, `CheckHash` など)という**入れ子のDictionary**の中に格納されている。トップレベルキーをそのままヘッダにすると、これらHash系フィールドが展開されずネストしたDictionaryのまま1セルに入ってしまう。
|
||||
|
||||
そのため `getRecordsDataToSheet` に渡す前に、各レコードをフラット化する処理を挟む。
|
||||
|
||||
- レコードDictionaryの各キー・値を走査する
|
||||
- 値が `Dictionary` である場合(`ClassHash` など `*Hash` 系フィールド)は、そのキー自体は使わず、**中身のキー・値をそのままフラット化後のDictionaryにマージ**する(例: `ClassHash("ClassA")` → フラット化後の `ClassA` キー)
|
||||
- 値が `Dictionary` でない単純値(`Title`, `Body`, `Status`, `ResultId` など)はそのままキー・値を引き継ぐ
|
||||
- 全レコードに対して同じ処理を行う
|
||||
|
||||
### `getRecordsDataToSheet(records As Collection, targetSheet As String)`
|
||||
|
||||
`exportCSVDataToSheet` とは完全に独立した新規関数として実装する(既存ロジックへの影響を避けるため共通化しない)。
|
||||
|
||||
- `records.count = 0` の場合はメッセージ表示して終了
|
||||
- 各レコードを `FlattenRecord` でフラット化する
|
||||
- ヘッダは **フラット化後の1件目レコードのキー一覧をそのまま使用**(`Title`, `Body`, `ClassA`, `DateA` などPleasanterの内部キー名。日本語ラベルへの変換は行わない)
|
||||
- 各レコード(フラット化後)の値をヘッダ順に2次元配列へ格納
|
||||
- シート・テーブル(ListObject)の取得/新規作成、ヘッダ設定、`DataBodyRange` のクリアと書き込みは `exportCSVDataToSheet` と同様の手順を独自に実装する
|
||||
- テーブル名・出力先シートは `targetSheet`(=`tableId`)を共用し、Export方式実行時に作られた既存テーブルを上書きする(方式を切り替えるたびにヘッダ列構成が変わる点は許容する)
|
||||
|
||||
## 変更対象・追加対象まとめ
|
||||
|
||||
- 変更: `init()`(`fetchMethod` 読み込み追加), `Sub run()`(分岐フローに書き換え), `exportCSVData`(`View` 組み立てを `BuildViewFromSheet()` 呼び出しに置き換え)
|
||||
- 追加: `runExportFlow`, `runGetFlow`, `getRecordsData`, `getRecordsDataToSheet`, `FlattenRecord`, `BuildViewFromSheet`, `SetupFilterSorterTables`
|
||||
- 無改造: `exportCSVDataToSheet`, `ParseCsv`, `CollectionToArray`, `callRestApi`
|
||||
|
||||
## 非対象・考慮しないこと
|
||||
|
||||
- 200件を超えるページング処理
|
||||
- Get方式でのヘッダの日本語ラベル化・Export方式との列対応付け
|
||||
- Get方式・Export方式のテーブルを別シートに分離すること(同一テーブルを共用する)
|
||||
748
XVBA/新版着工要因閲覧シート/vba-files/Module/Module1.bas
Normal file
748
XVBA/新版着工要因閲覧シート/vba-files/Module/Module1.bas
Normal file
@ -0,0 +1,748 @@
|
||||
Attribute VB_Name = "Module1"
|
||||
Option Explicit
|
||||
|
||||
Public apiKey As String
|
||||
Public baseURL As String
|
||||
Public yearFilter As String
|
||||
Public tableId As String
|
||||
Public defaultSh As Worksheet
|
||||
|
||||
Function init()
|
||||
'Pleasanter API KEY
|
||||
apiKey = "6504c8a807677a3a576e10327f3c19876c55736ee45d4a845796b9e7f5e087bfd4bd0d8184863da8cf1733ca635111432ab334aea59102b06a96ee6d2c05190d"
|
||||
baseURL = "https://nextoffice.Next-hd.co.jp"
|
||||
tableId = Range("着工要因ID")
|
||||
|
||||
Set defaultSh = ThisWorkbook.Sheets("基本情報")
|
||||
End Function
|
||||
|
||||
'******************************************************************************
|
||||
Sub run()
|
||||
'初期化処理
|
||||
Call init
|
||||
|
||||
'入力チェック(営業所・物件状況が未選択なら処理中断)
|
||||
Dim selSalesOffice As Variant, selPropertyStatus As Variant
|
||||
selSalesOffice = Range("選択営業所").Cells(1, 1).Value
|
||||
selPropertyStatus = Range("選択状況").Cells(1, 1).Value
|
||||
If IsError(selSalesOffice) Then selSalesOffice = ""
|
||||
If IsError(selPropertyStatus) Then selPropertyStatus = ""
|
||||
|
||||
If Trim(CStr(selSalesOffice)) = "" Or Trim(CStr(selPropertyStatus)) = "" Then
|
||||
MsgBox "営業所と物件状況は必ず選択してください", vbExclamation
|
||||
Exit Sub
|
||||
End If
|
||||
|
||||
On Error GoTo Cleanup
|
||||
|
||||
defaultSh.Range("D4").Value = Now
|
||||
defaultSh.Range("D3").Value = "取込処理中..."
|
||||
Debug.Print "[D3] " & defaultSh.Range("D3").Value
|
||||
Application.ScreenUpdating = False '画面更新停止
|
||||
Application.Calculation = xlManual '自動計算停止
|
||||
'------------------------------------------
|
||||
Debug.Print "処理開始"
|
||||
|
||||
Dim exportSuccess As Boolean
|
||||
Dim getSuccess As Boolean
|
||||
exportSuccess = runExportFlow()
|
||||
getSuccess = runGetFlow()
|
||||
|
||||
Debug.Print "処理終了"
|
||||
'------------------------------------------
|
||||
|
||||
Cleanup:
|
||||
Application.Calculation = xlAutomatic '自動計算再開
|
||||
Application.ScreenUpdating = True '画面更新再開
|
||||
defaultSh.Range("D5").Value = Now
|
||||
defaultSh.Range("D6").Value = (defaultSh.Range("D5").Value - defaultSh.Range("D4").Value) * 86400
|
||||
|
||||
If Err.Number <> 0 Then
|
||||
defaultSh.Range("D3").Value = "取込処理失敗(エラー): " & Err.Description
|
||||
Debug.Print "[D3] " & defaultSh.Range("D3").Value
|
||||
Err.Clear
|
||||
ElseIf Not (exportSuccess And getSuccess) Then
|
||||
defaultSh.Range("D3").Value = "取込処理失敗(データ取得エラー)"
|
||||
Debug.Print "[D3] " & defaultSh.Range("D3").Value
|
||||
Else
|
||||
defaultSh.Range("D3").Value = "取込処理完了"
|
||||
Debug.Print "[D3] " & defaultSh.Range("D3").Value
|
||||
End If
|
||||
End Sub
|
||||
|
||||
'******************************************************************************
|
||||
'Export方式(CSV全件取得)でのデータ取込処理
|
||||
'成功時True、データを取得できなかった場合Falseを返す
|
||||
Function runExportFlow() As Boolean
|
||||
Dim resData As Variant
|
||||
Dim t0 As Double, t1 As Double, t2 As Double
|
||||
|
||||
Debug.Print "データ取得処理開始(Export方式)"
|
||||
t0 = Timer
|
||||
'Filter・Sorter条件(基本情報シートのFilterTable・SorterTableから組み立てる、Get方式と同条件)
|
||||
Dim viewObj As Object
|
||||
Set viewObj = BuildViewFromSheet()
|
||||
resData = exportCSVData(tableId, viewObj)
|
||||
t1 = Timer
|
||||
Debug.Print " exportCSVData(API通信) 所要時間: " & Format(t1 - t0, "0.000") & "秒"
|
||||
|
||||
If IsEmpty(resData) Or resData = "" Then
|
||||
Debug.Print "データが取得できませんでした"
|
||||
runExportFlow = False
|
||||
Else
|
||||
Call exportCSVDataToSheet(resData, tableId & "E")
|
||||
t2 = Timer
|
||||
Debug.Print " exportCSVDataToSheet(パース+書込) 所要時間: " & Format(t2 - t1, "0.000") & "秒"
|
||||
runExportFlow = True
|
||||
End If
|
||||
End Function
|
||||
|
||||
'******************************************************************************
|
||||
'Get方式(api-record-get-multi)でのデータ取込処理
|
||||
'成功時True、データを取得できなかった場合Falseを返す
|
||||
Function runGetFlow() As Boolean
|
||||
Dim records As Collection
|
||||
Dim t0 As Double, t1 As Double, t2 As Double
|
||||
|
||||
Debug.Print "データ取得処理開始(Get方式)"
|
||||
t0 = Timer
|
||||
Set records = getRecordsData(tableId)
|
||||
t1 = Timer
|
||||
Debug.Print " getRecordsData(API通信) 所要時間: " & Format(t1 - t0, "0.000") & "秒"
|
||||
|
||||
If records Is Nothing Then
|
||||
Debug.Print "データが取得できませんでした"
|
||||
runGetFlow = False
|
||||
Else
|
||||
Call getRecordsDataToSheet(records, tableId & "G")
|
||||
t2 = Timer
|
||||
Debug.Print " getRecordsDataToSheet(書込) 所要時間: " & Format(t2 - t1, "0.000") & "秒"
|
||||
runGetFlow = True
|
||||
End If
|
||||
End Function
|
||||
|
||||
'******************************************************************************
|
||||
'CSVデータを取得
|
||||
Function exportCSVData(tableId As String, Optional viewObj As Object = Nothing) As Variant
|
||||
'共通変数
|
||||
Dim apiUrl As String
|
||||
Dim apiUrlParam As String
|
||||
|
||||
'リクエストURL
|
||||
apiUrl = baseURL & "/pleasanter/api/items/" & tableId & "/"
|
||||
apiUrlParam = "export"
|
||||
|
||||
'ヘッダ
|
||||
Dim apiHeaders As New Dictionary
|
||||
apiHeaders.Add "Content-Type", "application/json;charset=utf-8"
|
||||
|
||||
'リクエストデータ
|
||||
Dim apiBody As New Dictionary
|
||||
apiBody.Add "ApiVersion", "1.1"
|
||||
apiBody.Add "ApiKey", apiKey
|
||||
apiBody.Add "ExportId", "1"
|
||||
|
||||
If Not viewObj Is Nothing Then
|
||||
apiBody.Add "View", viewObj
|
||||
End If
|
||||
|
||||
'HTTPリクエスト送信メソッド呼び出し
|
||||
Dim res As Object
|
||||
Set res = callRestApi("POST", apiUrl, apiUrlParam, apiHeaders, apiBody)
|
||||
|
||||
If res("StatusCode") = 200 Then
|
||||
Debug.Print "CSVデータの取得に成功しました"
|
||||
exportCSVData = res("Response")("Content")
|
||||
|
||||
End If
|
||||
|
||||
End Function
|
||||
|
||||
'******************************************************************************
|
||||
'CSVデータをシートに書き込む
|
||||
Function exportCSVDataToSheet(csvData As Variant, targetSheet As String)
|
||||
Debug.Print "CSVデータをシートに出力開始: " & targetSheet
|
||||
'------------------------------------------
|
||||
'CSVデータを「テーブル」部分に出力する処理
|
||||
Dim ws As Worksheet
|
||||
On Error Resume Next
|
||||
Set ws = ThisWorkbook.Sheets(targetSheet)
|
||||
On Error Goto 0
|
||||
|
||||
If ws Is Nothing Then
|
||||
Debug.Print "シートが存在しないため新規作成: " & targetSheet
|
||||
Set ws = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.count))
|
||||
ws.Name = targetSheet
|
||||
End If
|
||||
|
||||
'CSVデータをテーブルに挿入(ダブルクォート内の改行・カンマに対応したCSVパース)
|
||||
Dim i As Long, j As Long
|
||||
Dim arr() As Variant
|
||||
Dim maxCols As Long
|
||||
|
||||
Dim rows As Collection
|
||||
Set rows = ParseCsv(csvData)
|
||||
|
||||
If rows.count <= 1 Then
|
||||
Debug.Print "データが0件のため、既存テーブルのデータ部分をクリアします: " & targetSheet
|
||||
Dim tblEmpty As ListObject
|
||||
On Error Resume Next
|
||||
Set tblEmpty = ws.ListObjects(targetSheet)
|
||||
On Error GoTo 0
|
||||
If Not tblEmpty Is Nothing Then
|
||||
If tblEmpty.Range.Rows.count > 1 Then
|
||||
tblEmpty.Range.Resize(tblEmpty.Range.Rows.count - 1, tblEmpty.Range.Columns.count).Offset(1, 0).ClearContents
|
||||
End If
|
||||
End If
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
maxCols = 0
|
||||
Dim rowArr As Variant
|
||||
For Each rowArr In rows
|
||||
If UBound(rowArr) + 1 > maxCols Then
|
||||
maxCols = UBound(rowArr) + 1
|
||||
End If
|
||||
Next rowArr
|
||||
|
||||
' 二次元配列を初期化
|
||||
ReDim arr(0 To rows.count - 1, 0 To maxCols - 1)
|
||||
|
||||
' 配列に格納
|
||||
i = 0
|
||||
For Each rowArr In rows
|
||||
For j = LBound(rowArr) To UBound(rowArr)
|
||||
arr(i, j) = rowArr(j)
|
||||
Next j
|
||||
i = i + 1
|
||||
Next rowArr
|
||||
|
||||
'テーブル(ListObject)取得
|
||||
Dim tbl As ListObject
|
||||
On Error Resume Next
|
||||
Set tbl = ws.ListObjects(targetSheet)
|
||||
On Error Goto 0
|
||||
|
||||
'ヘッダ行はテーブルの有無に関わらず毎回CSVの1行目(arr(0,*))で更新する(列構成の変更に追従するため)
|
||||
Dim headerRange As Range
|
||||
Set headerRange = ws.Range("A1").Resize(1, maxCols)
|
||||
For j = 0 To maxCols - 1
|
||||
headerRange.Cells(1, j + 1).Value = arr(0, j)
|
||||
Next j
|
||||
|
||||
If tbl Is Nothing Then
|
||||
Debug.Print "テーブルが存在しないため新規作成: " & targetSheet
|
||||
Set tbl = ws.ListObjects.Add(xlSrcRange, headerRange, , xlYes)
|
||||
tbl.Name = targetSheet
|
||||
End If
|
||||
|
||||
'既存データ部分を毎回クリア(DataBodyRangeプロパティは内部状態が更新されず
|
||||
'Nothingのままになることがあるため使わず、テーブル範囲から直接計算する)
|
||||
If tbl.Range.Rows.count > 1 Then
|
||||
tbl.Range.Resize(tbl.Range.Rows.count - 1, tbl.Range.Columns.count).Offset(1, 0).ClearContents
|
||||
End If
|
||||
|
||||
' テーブルを目的のサイズにリサイズ
|
||||
tbl.Resize tbl.Range.Resize(rows.count, maxCols)
|
||||
|
||||
Dim dataArr() As Variant
|
||||
ReDim dataArr(1 To rows.count - 1, 1 To maxCols)
|
||||
For i = 1 To rows.count - 1
|
||||
For j = 1 To maxCols
|
||||
dataArr(i, j) = arr(i, j - 1)
|
||||
Next j
|
||||
Next i
|
||||
|
||||
'データ書き込み(DataBodyRangeプロパティを使わず、ヘッダ行の次からテーブル範囲を直接計算する)
|
||||
Dim dataRange As Range
|
||||
Set dataRange = tbl.Range.Resize(rows.count - 1, maxCols).Offset(1, 0)
|
||||
dataRange.Value = dataArr
|
||||
|
||||
'------------------------------------------
|
||||
Debug.Print "CSVデータをシートに出力完了: " & targetSheet
|
||||
|
||||
End Function
|
||||
|
||||
'******************************************************************************
|
||||
'CSV文字列をパースし、行ごとのフィールド配列を格納したCollectionを返す
|
||||
'ダブルクォートで囲まれたフィールド内の改行・カンマ・エスケープされた""に対応(RFC4180準拠)
|
||||
Private Function ParseCsv(Byval csvText As String) As Collection
|
||||
Dim rows As New Collection
|
||||
Dim fields As Collection
|
||||
Set fields = New Collection
|
||||
|
||||
Dim field As String
|
||||
Dim inQuotes As Boolean
|
||||
Dim i As Long, ch As String, nextCh As String
|
||||
Dim textLen As Long
|
||||
textLen = Len(csvText)
|
||||
inQuotes = False
|
||||
field = ""
|
||||
|
||||
i = 1
|
||||
Do While i <= textLen
|
||||
ch = Mid(csvText, i, 1)
|
||||
|
||||
If inQuotes Then
|
||||
If ch = """" Then
|
||||
nextCh = Mid(csvText, i + 1, 1)
|
||||
If nextCh = """" Then
|
||||
field = field & """"
|
||||
i = i + 1
|
||||
Else
|
||||
inQuotes = False
|
||||
End If
|
||||
Else
|
||||
field = field & ch
|
||||
End If
|
||||
Else
|
||||
Select Case ch
|
||||
Case """"
|
||||
inQuotes = True
|
||||
Case ","
|
||||
fields.Add field
|
||||
field = ""
|
||||
Case vbCr, vbLf
|
||||
If ch = vbCr And Mid(csvText, i + 1, 1) = vbLf Then i = i + 1
|
||||
fields.Add field
|
||||
field = ""
|
||||
If Not (fields.count = 1 And fields(1) = "") Then
|
||||
rows.Add CollectionToArray(fields)
|
||||
End If
|
||||
Set fields = New Collection
|
||||
Case Else
|
||||
field = field & ch
|
||||
End Select
|
||||
End If
|
||||
i = i + 1
|
||||
Loop
|
||||
|
||||
'最終フィールド・行(末尾に改行が無い場合)
|
||||
If field <> "" Or fields.count > 0 Then
|
||||
fields.Add field
|
||||
rows.Add CollectionToArray(fields)
|
||||
End If
|
||||
|
||||
Set ParseCsv = rows
|
||||
End Function
|
||||
|
||||
'Collection(1次元)を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内のネストした*Hash(ClassHash、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
|
||||
Loading…
Reference in New Issue
Block a user