# 設計書:【B】リハ訪問カレンダー独立動作化 + A/B予定コピー機能 - 対象プロジェクト: ケアセブン(リハ) - 対象サイト: - 【A】親: site-482691((仮)リハ訪問カレンダー) - 482693【A】リハ訪問カレンダー / 482696【A】周回予定 / 482701【A】個別予定 / 482700【A】例外予定日 - 【B】親: 同じ site-482691 配下(ParentId共通) - 505009【B】リハ訪問カレンダー / 505010【B】周回予定 / 505011【B】個別予定 / 505012【B】例外予定日 - 共有マスタ(【A】【B】共通): 顧客リスト482703、担当一覧482697、祝日・休日482695、周回パターン482692、カテゴリ管理482694 ## 1. 背景・現状 【A】(482693)は既存の月〜金曜スケジュール入力運用。【B】(505009)は新設サイトだが、取得時点で482693のスクリプト・スタイルがそのまま複製されており、`scripts/1_訪問カレンダー.js`冒頭の参照先テーブルIDが【A】側(482696/482700/482701)のままになっている。このため【B】画面を開いても実際には【A】のデータが表示されてしまう状態。 ## 2. 機能要件 ### 2.1 【B】独立動作化 `site-505009_【B】リハ訪問カレンダー/scripts/1_訪問カレンダー.js` 冒頭の定数を修正する。 | 変数名 | 現状 | 修正後 | |---|---|---| | shukaiTableId | 482696 | 505010 | | reigaiTableId | 482700 | 505012 | | kobetsuTableId | 482701 | 505011 | | kokyakuTableId | 482703 | 変更なし(共有マスタ) | | resourceTableId | 482697 | 変更なし(共有マスタ) | | holidayTableId | 482695 | 変更なし(共有マスタ) | `serverscripts/1_マスタデータ取得.js`(周回パターン482692・カテゴリ管理482694を取得)は共有マスタ参照のため変更不要。 ### 2.2 A/B切替ボタン 画面左上端、「▶顧客リスト」ボタンより前の位置に【A】【B】の2ボタンを配置する。 - 現在表示中のサイトに対応するボタンはハイライト表示(選択中スタイル・非活性) - 他方のボタンはクリックで対象サイトのカレンダー画面へ`location.href`遷移(顧客リストボタンと同じ遷移方式) - 【A】【B】双方のスクリプトに現在サイトのSiteIdを判定する定数を追加し、比較して表示を切り替える ### 2.3 予定コピー機能(【A】→【B】、月単位) コピー対象は常に周回予定・個別予定・例外予定日の3テーブル、方向は常に【A】→【B】。 **抽出条件**(既存`createEvents()`と同じ考え方でコピー元月を基準に抽出) - 周回予定: 開始日(DateE)・終了日(DateF)がコピー元月に重なる有効レコード全件 - 個別予定・例外予定日: 訪問日/例外日がコピー元月内のレコード **コピー先への書き込み内容** - 周回予定: `DateE`/`DateF`をコピー先月の月初〜月末に再設定 - 個別予定・例外予定日: 日付の年月のみコピー先月に置換、日はそのまま **A由来フラグ管理(CheckG列で統一)** 3テーブルとも`CheckG`列(現状いずれのテーブルでも未使用)を新規に「A由来フラグ」として使用する。新規作成時の自動デフォルト値設定機能は不要(移行=コピー実行時にAPIで明示的にCheckG=trueをセットするのみで足りる)。列は編集画面から**非表示**にし、ユーザーが任意に変更できないようにする。 **実際の反映内容(2026-08-28、6サイト全て統一。482696はユーザーが本番管理画面から手動追加、残り5サイトはその実物設定に合わせてスクリプトで反映)** | サイト | 列 | 設定 | |---|---|---| | 482696【A】周回予定 | CheckG | `LabelText:"A由来フラグ", DefaultInput:"false", NoWrap:true, Hide:true` | | 482701【A】個別予定 | CheckG | 同上 | | 482700【A】例外予定日 | CheckG | 同上 | | 505010【B】周回予定 | CheckG | 同上 | | 505011【B】個別予定 | CheckG | 同上 | | 505012【B】例外予定日 | CheckG | 同上 | - コピー実行時、【B】側へ作成するレコードは常に`CheckG=true`を明示的にセットする(=A由来マーク) - コピー実行時の削除範囲は「【B】側の対象月かつ`CheckG=true`のレコードのみ」。【B】側でユーザーが独自に手入力した予定(`CheckG=false`)は削除対象に含めず保持する - 列は`Hide:true`のため通常の編集画面には現れない。ユーザーによる誤操作での値変更は発生しない **UI操作フロー** - 【A】画面: 「▶Bへコピー」ボタン → モーダルでコピー先(B)の対象月をプルダウン選択 → 内容確認 → 実行。コピー元月は【A】画面の表示中月に固定 - 【B】画面: 「▶Aからコピー」ボタン → モーダルでコピー元(A)の対象月をプルダウン選択 → 内容確認 → 実行。コピー先月は【B】画面の表示中月に固定 - いずれも実行前に確認ステップを挟む **実装方式** 既存コードパターン(`$p.apiGet`/`$p.apiUpdate`によるクライアントJS完結型API呼び出し)を踏襲する。取得→【B】側対象データ削除→【A】由来フラグ付きで新規作成、の順に実行。 ## 3. 対象ファイル - `configs/production/site-482693_【A】リハ訪問カレンダー/scripts/1_訪問カレンダー.js`(A/B切替ボタン、コピーボタン追加) - `configs/production/site-505009_【B】リハ訪問カレンダー/scripts/1_訪問カレンダー.js`(参照先テーブルID修正、A/B切替ボタン、コピーボタン追加) - 482696/482701/482700/505010/505011/505012のSiteSettings(CheckG列追加、`Hide:true`・自動デフォルト値なしで統一。**2026-08-28、6サイトとも反映済み**) ## 4. 未確定・実装時に要検証の事項 - ~~Pleasanterの列単位デフォルト値設定の値表現~~ → 2026-08-28、方針変更によりCheckGへの自動デフォルト値設定機能自体を不要とした(コピー実行時にAPIで明示的にセットする方式へ変更)ため解消。 - ~~`$p.apiCreate`/`$p.apiDelete`/`$p.apiBulkDel`相当メソッドの実在有無~~ → 2026-08-28、本番【B】側(505011)でテストレコード作成・削除を実施し確定済み。`$p.apiXxx`は使わず、Pleasanter標準REST APIを直接呼び出す方式で確定。 - create: `POST {baseUrl}/pleasanter/api/items/{siteId}/create`、Body: `{ApiVersion, ApiKey, ClassHash:{...}, DateHash:{...}, NumHash:{...}, CheckHash:{...}, Body:"..."}`。レスポンス: `{Id, StatusCode, Message}`(`Id`はトップレベル直下、`Response.Data`ではない)。 - delete: `POST {baseUrl}/pleasanter/api/items/{itemId}/delete`、Body: `{ApiVersion, ApiKey}`のみ(`itemId`はURLパスに含む)。レスポンス: `{Id, StatusCode, Message}`。 - `CheckHash`はboolean値をそのまま送信・保存できることを確認済み(例: `CheckHash: {CheckA: true}`)。 - モーダルの月選択プルダウンの選択範囲(既存`selectMonth`と同様、前後2ヶ月〜6ヶ月先を想定)。 ## 本番検証時の運用制約(2026-08-28追記) - 【A】側4サイト(482693/482696/482701/482700): **SiteSettings改修(列追加・スクリプト修正等)は許可、レコード(実データ)の新規登録・削除は禁止**。 - 【B】側4サイト(505009/505010/505011/505012): 書込み全般許可。 - 上記制約により、API仕様検証(create/delete)は【A】側ではなく【B】側(505011)で実施した。 ## 5. 追加機能: 周回予定の複製方法選択(2026-08-28追記) コピー実行時、周回予定の複製方法を2種類から選べるようにした(モーダル内ラジオボタン、デフォルトは「個別予定として展開」)。 - **個別予定として展開**(新方式・デフォルト): 対象月内で実際に発生する日を計算し、発生日ごとに1件ずつ「個別予定」として作成する。例外予定日で除外される日は生成しない(除外がそのまま反映される)。コピー先には周回予定・例外予定日を一切作らず、全て個別予定に統合される。 - 利点: 各回が完全に独立したレコードになるため、特定の週だけ曜日・時間を変更したい場合、その個別予定を1件編集するだけで完結する(従来の「例外予定日+個別予定」の2手順が不要)。 - 削除対象はコピー先の個別予定(対象月・CheckG=true)のみ。 - **周回予定のまま複製**(従来方式): 従来通り、周回予定は周回予定としてコピーする(`DateE`/`DateF`をコピー先月の月初〜月末に再設定)。 **発生日の計算方法**: `rrule`ライブラリ(`window.rrule.RRule`、`between()`)は実機のブラウザコンソールで動作検証済み(2026-08-28、CSP report-onlyモードのため実害なし、luxon依存も問題にならないことを確認)だが、確実性を優先し外部ライブラリに依存しない自前ロジックで実装した。`createEvents()`のfreqId判定ロジックを踏襲し、対象月を1日ずつループして「該当曜日か」「第何週に該当するか(`Math.ceil(day/7)`)」「有効期間内か」を判定する。Node.jsで単体テスト済み(毎週/第1,3週/毎月指定週/有効期間開始日/奇数月フィルタの5パターンを検証、全て期待通りの結果)。 ## 6. 障害対応: コピー実行のたびに重複が蓄積するバグ(2026-08-28発生・当日中に修正) ユーザーが複数回(86分前・76分前・4分前)コピーを試した結果、【B】側に同一予定が最大3件重複表示される障害が発生。原因は2つの独立したバグの組み合わせ。 **バグ1: `apiGetItems`のページング未対応** `get`APIは1回のリクエストで最大200件しか返さない。ページングには`Offset`パラメータが必要だが、`View`オブジェクトの中に入れて送信しており機能していなかった(`Offset`は**リクエストのトップレベル**に指定する必要がある。本番検証で確認: `View.Offset`は無視され常に同じ200件が返る、トップレベル`Offset`なら正しく次ページが返る)。修正: `apiGetItems`にページングループを実装し、`TotalCount`に達するまで`Offset`を進めながら全件取得するよう変更。 **バグ2: 削除フィルタがクライアント側でCheckHash.CheckGを判定していたため機能していなかった** CheckG列はEditorColumnHash/GridColumnsに一切追加していない(`Hide:true`のみ)ため、`getitems`のレスポンス(`CheckHash`オブジェクト)に**CheckGキー自体が含まれない**(値はサーバー側に正しく保存されているが、レスポンスには返らない。`ColumnFilterHash`でのフィルタ検索は正常に機能することは確認済み)。このため`deleteTargetMonthData`内の`item.CheckHash.CheckG !== true`という判定が常に`undefined !== true`=真となり、**削除条件に一致するレコードが1件も見つからず、既存データが一切削除されないまま新規作成だけが繰り返されていた**。修正: クライアント側判定を廃止し、`ColumnFilterHash`に`CheckG: true`条件を含めてサーバー側でフィルタする方式に変更。 **復旧作業**: 両バグを修正・本番反映後、蓄積した重複データをクリーンアップ。505010(周回予定)255件・505011(個別予定)367件、合計622件(全てCheckG=true=A由来、B固有データはCheckG=falseのため対象外)を削除し、3テーブルとも0件のクリーンな状態に復旧を確認済み。 ## 7. 作成処理をcreate連発からimport一括登録へ変更(2026-08-28追記) コピー実行時の作成処理を、`create`APIを1件ずつ`await`で呼ぶ方式から、対象データをCSVにまとめて`import`APIで一括登録する方式に変更した(速度改善が目的)。削除処理(`deleteTargetMonthData`)も、ユーザーが教示した`bulkdelete`API(`POST /api/items/{siteId}/bulkdelete`、`View.ColumnFilterHash`で絞り込み指定)に切り替え、1件ずつの`delete`ループを廃止した。日付範囲+`CheckG:true`の複合フィルタでB固有データ(CheckG=false)を巻き込まないことを実機で確認済み(テスト: CheckG=trueレコード2件・CheckG=falseレコード1件を用意→bulkdelete実行→true側2件のみ削除、false側1件は残存)。これで作成・削除とも1件ずつのAPI呼び出しがなくなり、月あたりのAPIコール数はテーブルごとに数回程度に収まる。 **import APIの調査で判明した仕様(すべて実機で動作確認済み)** - エンドポイント: `POST {baseUrl}/pleasanter/api/items/{siteId}/import`、`multipart/form-data`。`parameters`(JSON文字列: `ApiVersion`/`ApiKey`/`Encoding:"UTF-8"`/`UpdatableImport:false`)と`file`(CSVのBlob)の2フィールドを送る。 - **CSVヘッダーは内部列名(ClassA等)ではなく、サイト設定の表示ラベル(LabelText)**。ラベル未設定の列は`分類K`のようなデフォルトラベルになる(export APIで実際の出力を確認して特定した)。列の並び順は任意(ヘッダー名でマッチングされる)。 - Check型の値は文字列`"1"`(true)/`"0"`(false)でよい。 - **重要な注意点**: 「顧客選択」(`ClassA`)は顧客リスト(482703)への`Lookups`設定(`ChoicesText`に`{SiteId:482703, Lookups:[{From:"ClassA",To:"ClassB"}]}`)を持ち、サーバー側で自動的に「要件名」(`ClassB`)へ値を反映する。**ClassAに実在しない値を送るとClassBが自動的に空にクリアされる**(テスト用のダミー値で検証中に発覚。これはimport特有ではなく`create`APIでも同じ挙動——既存バグではなくPleasanterの正規機能)。コピー処理はコピー元の実在する顧客ID(`ClassA`)をそのまま使うため実運用では問題にならないが、CSV行を組み立てる際に架空の値を入れないよう注意。周回予定側の「顧客名」(`ClassA`)にも同じLookup設定がある。 **CSVヘッダー構成(各サイトの表示ラベルに準拠)** - 周回予定: 顧客名/パターンコード/曜日/時間/枠数/担当/カテゴリコード/指定週/開始日/終了日/コメント1〜5/日付1〜5/パターン/カテゴリ/A由来フラグ/内容・備考 - 個別予定: 顧客選択/枠数/時間/訪問日/要件名/担当/カテゴリコード/カテゴリ/開始・時/分/備考フラグ/A由来フラグ/内容 - 例外予定日: 周回予定/例外予定日/分類K/A由来フラグ **検証内容**: Node.jsでCSVエスケープ処理(カンマ・ダブルクォート・undefined値を含む値)を単体テスト、本番【B】側(505011)で実在顧客IDを使った複数行(3件)の一括登録が正しく反映されることを確認済み。テストデータは検証後に削除しクリーンな状態に復旧。 ## 8. 障害対応: カレンダー描画時も200件制限で表示が欠落していた(2026-08-28発生・当日中に修正) 「分解コピー(個別予定として展開)の内容が正しくない」との報告を受けて調査。コピー結果自体(505011の実データ)は正しかった(重複なし、日付・時間・担当等すべて元の周回予定と一致、例外予定日での除外も正しく反映)が、**カレンダー描画時のデータ取得(既存コード、今回のコピー機能追加より前からあるもの)が6節・バグ1と全く同じ理由でページング未対応だった**。 対象は`care7_Scheduler.prototype.createResource`(担当一覧)・`createEvents`内の例外予定取得/周回予定取得/個別予定取得・`getHolidays`(祝日)の5箇所、いずれも`$p.apiGet({..., async:false, done:...})`を直接呼んでおり`Offset`指定がなかった。505011は9月だけで367件(『個別予定として展開』方式の想定通りの件数で重複ではない。多数の利用者×週1〜2回訪問×月4〜5週で普通に数百件になる)あり、200件を超える分がカレンダーに表示されていなかった。祝日・休日(482695)も全件278件(月次フィルタ後は少数のはずだが、一貫性のため対応させた)。 **修正**: 共通ヘルパー`apiGetAllPages(siteId, baseData, onPage)`を追加し、既存の`$p.apiGet({id, async:false, data:{...}, done:...})`という構造を保ったまま(`done`コールバックの中身は無変更)、`Offset`を`TotalCount`に達するまで進める同期ループでラップする形に置き換えた。5箇所すべてに適用、本番反映済み。 ## 9. 顧客リスト(482703)の重複データ統合(2026-08-28実施) **関連する仕様上の注意点**: `/api/items/{siteId}/get`にView条件を付けずに482703を呼ぶと、なぜか同一テーブルを共有しているらしい482696(周回予定)・482701(個別予定)のレコードも一緒に返ってくる(レスポンスの`SiteId`フィールドを見ないと気づけない)。今後482703を扱う調査・スクリプトでは、取得後に必ず`SiteId===482703`でフィルタすること。 **発見した問題**: 顧客リスト482703(正しくは104件)のうち、20人分の氏名(`ClassA`)が重複登録されていた(同一人物が複数のResultIdで別々に存在)。しかも多くのケースで、周回予定(482696)・個別予定(482701)・B側個別予定(505011)から重複IDの両方(または複数)が実際に参照されており、同一人物のスケジュールが複数の顧客IDに分散登録されている状態だった。 **対応方針**(ユーザー指示): 各重複グループにつき**ResultIdが最も新しい(最大の)レコードを正**とし、それ以外(古い方)を統合先として削除する。 **実施内容**: 1. 482696/482701/505011で、削除予定の古いIDを参照している計110件(482696:24件、482701:2件、505011:84件)の`ClassA`を、正規(最新)IDへ`update`APIで更新 2. 更新後、参照が正しく切り替わったことを確認(3テーブルとも古いID残存0件) 3. 顧客リストの重複29件を`bulkdelete`(`Selected`配列でID指定)で一括削除 4. 最終確認: 顧客リスト104件→75件、残存重複0件 **表記ゆれについて**: 調査中、「地曵良子」「地曳良子」のような異体字による表記ゆれ(機械的な文字列一致では別人扱いになる)を発見したが、今回の統合対象(完全一致の重複20件)には含まれていない。表記ゆれの名寄せは別途対応が必要な場合、改めて洗い出す。 ## 10. 顧客リスト重複の再発防止(2026-08-28実施、ユーザーが本番管理画面から手動反映) 9節の統合後も、手動での新規登録・更新時に同じ人物が再度別IDで重複登録されるのを防ぐため、`ClassZ`列(`NoDuplication:true`設定済み、重複時メッセージ「この顧客はすでに登録されています」)をキーとして活用する仕組みを追加。 - 新規ServerScript(Id=2、`BeforeCreate`/`BeforeUpdate`): `model.ClassZ = model.ClassB + '-' + model.ClassA;`(グループ頭文字-氏名の結合値を自動生成) - レコード作成・更新の直前に自動でキーを生成させることで、Pleasanter標準の`NoDuplication`制約が効き、同一グループ・同一氏名の組み合わせを持つ2件目の登録がサーバー側で弾かれるようになる - 本番反映はユーザーが管理画面から手動で実施(`ClaudePleasanter`側の反映ツールは未使用)。反映後にget-site-config.jsで再取得し、`configs/production/site-482703_顧客リスト/`のローカルコピーを同期済み ## 11. 顧客リスト重複再チェック:スペース違いの表記ゆれ対応(2026-08-28実施) 9節の完全一致チェックでは見つからなかった、氏名中のスペース有無による表記ゆれ重複を追加検出。全角・半角スペースを除去して正規化した氏名で再照合したところ、「内田アイ子」(488112)と「内田 アイ子」(全角スペースあり、483244)の1件が重複と判明。両方とも482696・482701・505011から実際に参照されていた。 **対応**: 9節と同じ方針(ResultIdが最も新しい方=488112を正)で統合。482696(2件)・482701(1件)・505011(4件)、計7件の`ClassA`を483244→488112へ更新後、483244を顧客リストから削除。最終確認: 顧客リスト75件→74件、スペース正規化後も残存重複0件。 ## 12. ServerScript拡張:ClassAの自動正規化(スペース除去+半角カナ→全角カナ)と既存データのClassZバックフィル(2026-08-28実施) 10節のServerScript(Id=2)を拡張し、`ClassA`(氏名)保存前に以下を自動適用するようにした: 1. 半角・全角スペースの除去 2. 半角カナ→全角カナ変換(濁点・半濁点付き含む、標準的な変換テーブル方式) 3. 正規化後の`ClassA`をそのまま`model.ClassA`へ上書き保存 4. 正規化後の値で`ClassZ = ClassB + '-' + ClassA`を生成(10節の重複防止キー) ロジックはNode.jsで単体テスト済み("ヤマダ 太郎"→"ヤマダ太郎"、"内田 アイ子"→"内田アイ子"、濁点付き半角カナも正常変換)。クラウド側で`apply-desired-config-full.js --execute`により本番反映。 **既存データの再チェック**: 正規化ロジックを既存74件に適用し直したところ、半角カナ違いによる新規の重複は0件(未正規化な生データも0件、既存データは元から正規化済みの状態だった)。 **ClassZバックフィル**: ServerScript追加は新規作成・更新時のみ発火するため、既存74件は`ClassZ`が空のままだった(重複チェックのキーとして機能していなかった)。既存全74件を`ClassA`の値をそのまま送るだけの`update`APIで再保存し、ServerScriptを発火させて`ClassZ`を埋めた。バックフィル後、全74件で`ClassZ`が正しく設定されていることを確認済み(例: `483502→"て-手嶋秀子"`)。 **未対応の表記ゆれ**: 9節に記載の「地曵良子」「地曳良子」等、漢字の異体字によるゆれはスペース正規化では検出できない。別途対応が必要な場合は改めて依頼が必要。 ## 13. 顧客リスト重複再チェック:漢字の異体字対応(2026-08-28実施) 編集距離(レーベンシュタイン距離)1文字以内のペアを全74件総当たりで検出し、異体字候補を洗い出した。5件の候補が見つかったが、精査すると真の異体字(同一人物の表記ゆれ)と、たまたま1文字違いの別人が混在していた。 | ペア | 判定 | |---|---| | 地曵良子 / 地曳良子(`曵`/`曳`) | 異体字(同一人物) | | 岡部昭恵 / 岡部昭惠(`恵`/`惠`、旧字体) | 異体字(同一人物) | | 中村順子 / 中丸順子(`村`/`丸`) | 別人(対象外) | | 伊藤久子 / 佐藤久子(`伊`/`佐`) | 別人(対象外) | | 中丸順子 / 中丸孝子(`順`/`孝`) | 別人(対象外、同姓の別人) | **正しい表記の判定はユーザー確認**(機械的な参照件数の多寡だけでは判断しない): 「地曳良子」(488124)が正、「岡部昭恵」(487994、旧字体でない方)が正。 **対応**: 「地曵良子」(485061)参照の6件(482696×1・505011×5)を488124へ、「岡部昭惠」(491502)参照の1件(482701×1)を487994へ更新。統合元の485061・491502を顧客リストから削除。最終確認: 顧客リスト74件→72件。 **残る留意点**: 編集距離ベースの検出は総当たり方式のため件数が増えると計算量が増える(今回74件で2701ペア、瞬時に完了)。また「別人だが1文字違い」の誤検出を人間が判定する必要があり、完全自動化はできない。 **訂正(2026-08-28追記)**: 「中村順子」/「中丸順子」は当初「別人」と判定したが、ユーザー確認の結果**同一人物**と判明(`村`/`丸`が実際には表記ゆれだった)。「中丸順子」(488077)を正とし、「中村順子」(483612)参照の5件(482696×1・505011×4)を488077へ更新、483612を削除。最終確認: 顧客リスト72件→71件。編集距離1の候補であっても、姓の一部が違って見えるケースは特に人間の最終確認が必須であることを示す実例。