/**
* 検索の照合に使う文字列の正規化(指示書AG D-1〜D-5)。
*
* ★5系統(清掃詳細ログ / 施設マスタ / スケジュール / D-S制御盤 / レポート)が
* すべてこの1本を呼ぶ★。CLAUDE.md §4.6 が契約フラグに課したのと同じ規律
* (「純粋関数1箇所に閉じ込める」)を、検索の照合規則にも当てる。
* 以前は照合規則が3種類に分裂しており、同じシリアルを同じ社内画面で探しても
* 入力欄によって当たったり当たらなかったりしていた。
*
* ------------------------------------------------------------------------
* ★なぜ強さの違う2種類があるのか★ = フィールドによって記号の意味が違うから(D-2)
* ------------------------------------------------------------------------
* | 強さ | 使うフィールド |
* |----------------------|-----------------------------------------------------|
* | `foldName`(強) | facility_name / prefecture |
* | `foldPlain`(弱) | address / serial_number / maker / model / model_name |
*
* ★施設名だけ強いのは、店名の記号が表記ゆれで、桁位置の意味を持たないから★
* 実データが `ドン・キホーテ戸塚` のとき、`ドンキホーテ` や `ドンキ` と打つのは
* 自然な操作だが、記号を残したままでは 0件になる。
*
* ------------------------------------------------------------------------
* ★★シリアルと住所に `foldName` を使ってはいけない★★(D-2。実データで誤ヒットする)
* ------------------------------------------------------------------------
* 住所 — ★ハイフンは番地の区切り★:
*
* 東京都○○区1-2-3 →(記号除去)→ 東京都○○区123
* 東京都○○区2-3-4 →(記号除去)→ 東京都○○区234
*
* 住所欄に `23` と入れるとこの2件が両方ヒットしてしまう(現状はどちらもヒットしない)。
* 言い換えると `1-23番地` と `12-3番地` と `1-2-3番地` が区別できなくなる。
*
* シリアル — ★桁の境界が消える★:
*
* PUDU-CC1-0001 →(記号除去)→ PUDUCC10001
*
* シリアル欄に `10001`(連番10001号機のつもり)と入れると、`CC1` の `1` と `0001` が
* 繋がった `PUDU-CC1-0001` にヒットしてしまう(現状は0件)。
* ★そして利得がほぼ無い★ — 実データの主形式 `8111R5628060011` には記号が1文字も無く、
* 記号除去は no-op。`DS-001` 形式のために上のリスクを負うことになる。
* さらに `lib/config/index.ts` が「シリアルの文字種は絞らない(形式が未確定)」と
* 明示しており、「記号は意味を持たない」という前提を置くこと自体が
* CLAUDE.md §7「仕様が不明な箇所を推測で実装しない」に反する。
*
* メーカー・機種は `ROBOT_MODELS`(`lib/config/index.ts`)に記号が1文字も無いので、
* 記号除去の効果がそもそもゼロ。直すべきは `cc1` が当たらないことのほうである。
*
* ------------------------------------------------------------------------
* ★入れていないもの(意図的に残した穴。D-4。ユーザーが選ばなかった。2026-09-13)★
* ------------------------------------------------------------------------
* - ★カナ・全半角の統一★(Unicode 正規化 / NFKC を含む)
* → `ドン` や `どん` では `ドン・キホーテ` を引けないまま。
* - ★スペース区切りの AND 検索★
* → `ドン・キホーテ 戸塚` はスペースが除かれて1語として扱われる。
* - ★英字とカナの読み替え(`MEGA` ⇄ `メガ`)★
* → どの語を対にするかは仕様判断(CLAUDE.md §7)。
* ★実店舗名が `MEGAドン・キホーテ` なので「メガドンキホーテ」では引けない。これは仕様★。
* 不具合として報告されたら、この行と指示書AG D-4 を示すこと。
*
* ------------------------------------------------------------------------
* ★正規化は必ず「両側」に掛ける(D-5)★
* ------------------------------------------------------------------------
* 入力側だけ正規化してもデータ側の中点が残るので、結局ヒットしない。
* 呼び出し側が片側だけ正規化する間違いをできないよう、比較まで含めた
* `includesFoldName` / `includesFoldPlain` を使うこと(`foldName` を直接呼ばない)。
*
* ★DBアクセスなし・例外を投げない純粋関数★(CLAUDE.md §9.1。表示整形なので `lib/format/`)。
*/
/**
* ★`foldName` が除く記号(D-3)★。これ以外は除かない。
*
* | 種類 | 文字 | 何のため |
* |------------|-------------------------------------------------------|----------------------------------------------|
* | 中点 | `・`(U+30FB) `・`(U+FF65) `·`(U+00B7) `•`(U+2022) | `ドンキホーテ` → `ドン・キホーテ`(本命) |
* | 長音 | `ー`(U+30FC) | `サーバ` で `サーバー` を引けるのと同じ |
* | 空白 | 半角スペース(U+0020) / ` `(U+3000) / タブ(U+0009) | `ドンキホーテ港南` → `MEGAドン・キホーテ 港南` |
* | ハイフン類 | `-`(U+002D) `‐`〜`―`(U+2010-2015) `-`(U+FF0D) `_`(U+005F) | 店名の区切り |
*
* ★正規表現の文字クラスではなくコードポイントの集合で持つ理由★
* 1. 除く文字に半角スペース・全角スペース・タブが含まれ、リテラルで書くと
* **読んで確認できない**(空白とタブが目で見分けられない)。
* 2. 文字クラスの中では `-` が範囲指定に化ける。`[…\t-‐…]` のように並べると
* タブから U+2010 までの**約8000文字**が静かに除去対象になり、
* エラーもテスト失敗も出ないまま検索結果だけが狂う。
*/
const NAME_SYMBOL_CODE_POINTS: readonly number[] = [
// 中点4種
0x30fb, // ・ KATAKANA MIDDLE DOT
0xff65, // ・ HALFWIDTH KATAKANA MIDDLE DOT
0x00b7, // · MIDDLE DOT
0x2022, // • BULLET
// 長音
0x30fc, // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
// 空白3種
0x0020, // 半角スペース
0x3000, // IDEOGRAPHIC SPACE
0x0009, // タブ
// ハイフン類
0x002d, // - HYPHEN-MINUS
0x2010, // ‐ HYPHEN
0x2011, // ‑ NON-BREAKING HYPHEN
0x2012, // ‒ FIGURE DASH
0x2013, // – EN DASH
0x2014, // — EM DASH
0x2015, // ― HORIZONTAL BAR
0xff0d, // - FULLWIDTH HYPHEN-MINUS
0x005f, // _ LOW LINE
];
const NAME_SYMBOLS: ReadonlySet<string> = new Set(
NAME_SYMBOL_CODE_POINTS.map((code) => String.fromCodePoint(code))
);
/**
* 大文字小文字だけを揃える(★記号は残す★)。
* 住所・シリアル・メーカー・機種で使う(記号を除いてはいけない理由はファイル冒頭)。
*/
export function foldPlain(value: string): string {
return value.toLowerCase();
}
/**
* 大文字小文字を揃え、さらに記号(中点・長音・空白・ハイフン類)を除く。
* 施設名・都道府県で使う。除く記号の集合は `NAME_SYMBOL_CODE_POINTS` の表を見ること。
*
* ★記号だけの入力(例 `・`)は空文字になる★ — これは正しい(除く記号しか無いのだから)。
* ★この関数は「空文字になった」という事実を返すだけで、当たり外れは決めない★。
* ★記号だけの入力を「当たらない」に倒す判断は `includesFoldName` が持つ★
* (2026-09-14。理由はそちらの docstring)。★ここを変えないこと★ —
* `foldName("・") === ""` を固定した既存テストは正しい。
*/
export function foldName(value: string): string {
let folded = "";
for (const char of foldPlain(value)) {
if (!NAME_SYMBOLS.has(char)) folded += char;
}
return folded;
}
/**
* haystack と needle の**両方**を `foldName` に通してから部分一致を見る。
* 施設名・都道府県の照合はこれを使う(D-2 / D-5)。
*
* ------------------------------------------------------------------------
* ★記号だけの入力は「当たらない」(0件)★(★ユーザー判断 2026-09-14。案A★)
* ------------------------------------------------------------------------
* | 入力(needle) | 判定 |
* |---------------------|------------------------------|
* | `""`(未指定) | ★true(素通し)★ |
* | `・` `-` ` ` など | ★false(記号だけ)★ |
* | `ドン・` | 従来どおり(記号を除いて照合)|
*
* ★★なぜ「未指定(空文字)」と「記号だけ」を区別するのか★★
* どちらも `foldName` を通せば `""` になるが、★利用者の意図が正反対★である。
* - ★`""` は「絞り込まない」という指示★ … 一覧を全部見たい。素通しが正しい。
* - ★`・` は「この語で絞り込む」という指示★ … 利用者は何かを入力している。
* ★これを素通しにすると「検索したのに全件が出る」★ — 0件と違って
* ★画面上は成功に見えるので、打ち間違いに気づけない★。
* ★キーボードの切替ミスで `-` だけ残った状態で「絞り込めている」と誤認するのが
* いちばん危ない★(翌朝確認の運用で、見落としが「全件表示」に紛れる)。
* ★`foldName(needle) === ""` だけで判定してはいけない★ — それでは `""` も巻き込んで
* 未指定が 0件になり、★施設名を入力していないのに一覧が空になる★
* (`facilityIdsMatchingName` の `null` / `[]` の区別と同じ罠)。
*
* ★★なぜ `includesFoldPlain` を同じにしないのか★★(★揃えないのが正しい★)
* `foldPlain` は★記号を残す★ので、`-` は `-` のまま照合される。
* ★`-` はシリアル `PUDU-CC1-0001` に実際に当たる★ = ★意味のある検索★であり、
* 弾いてはいけない。施設マスタ・スケジュール・D-S制御盤の検索欄は
* ★「施設名 OR シリアル」★なので、`-` と打つと
* ★施設名では 0件・シリアルでは当たる → ハイフン入りのシリアルの行だけが出る★。
* これが狙った結果である(★「全件」でも「0件」でもない★)。
* ★`includesFoldPlain` にも同じ早期 return を足さないこと★ — 足すと
* ★シリアルをハイフンで探せなくなる★。
*/
export function includesFoldName(haystack: string, needle: string): boolean {
// 1. 未指定 — 絞り込まない(★ここは従来どおり★)
if (needle === "") return true;
const foldedNeedle = foldName(needle);
// 2. 記号だけ — 入力はあるのに条件が消えるので、素通しではなく「当たらない」に倒す
if (foldedNeedle === "") return false;
// 3. それ以外 — 両側を `foldName` に通して部分一致(D-5)
return foldName(haystack).includes(foldedNeedle);
}
/**
* haystack と needle の**両方**を `foldPlain` に通してから部分一致を見る。
* 住所・シリアル・メーカー・機種の照合はこれを使う(D-2 / D-5)。
*/
export function includesFoldPlain(haystack: string, needle: string): boolean {
return foldPlain(haystack).includes(foldPlain(needle));
}