/* * 저장된 트윈 모델의 **어휘 세대를 흡수한다** — 읽는 순간 한 어휘로. 순수. * * ── 계기 (2026-08-06, 사용자 신고) ─────────────────────────────────────────── * 트윈 모델러의 엔티티 목록이 **통째로 비어 있었다.** 오류도 없었다. 원인은 이 인스턴스의 저장된 모델가 * 옛 어휘였고 화면과 호스트는 새 어휘(`locations`/`equipment`)를 읽었기 때문이다. 그 옛 이름들은 * 아래 `RETIRED_KEYS` 에 있다 — 흡수할 세대의 이름을 적지 않고는 이 파일을 쓸 수 없다. * * 커널에는 이미 세대를 흡수하는 리더가 있다(`readBoardLocations`·`readBoardEquipment` — 세 세대 * `movers`→`equipmentList`→`equipment`, 실측 주석에 "저장된 모델 23개 중 13개가 `movers`"). 그런데 **그 * 리더를 거치는 곳이 목록뿐**이었다. 상세·구조 서명·주행 추정기·설비 동기는 컬렉션을 직접 읽어 * **오류 없이 빈 공장**으로 읽었다: * * · 모델러·팔레트·보드 자동생성 → 엔티티 0(사용자가 본 증상) * · 구조 서명 → `N[]M[]` — **서로 다른 두 공장이 구조적으로 같아 보인다**(변경 감지가 죽는다) * · 주행 추정기 → 설비 0이라 거리 기반 소요시간을 못 만든다 * · 설비 동기 → 아는 설비가 0이라 중복 append + 한 모델에 두 세대가 섞인다 * * ── 왜 자리마다 고치지 않나 ───────────────────────────────────────────────── * 저장된 모델를 읽는 자리가 **27곳**이다. 자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠진다 — * 그리고 빠진 것은 오류가 아니라 **빈 공장**이라 들어온 것이 없다. 그래서 **DB에서 읽는 순간 한 번** 정규화한다 * (엔티티 컬럼 변환기). 그 뒤로는 코드가 세대를 알 필요가 없다. * * ── 쓰기는 건드리지 않는다 ────────────────────────────────────────────────── * 저장은 주는 대로 한다(새 쓰기는 이미 새 어휘다). 읽기가 정규화되므로, 저장된 옛 모델는 **다음에 * 저장될 때 자연히 새 어휘로 옮겨간다** — 마이그레이션을 따로 실행하지 않는다. */ import { readBoardLocations, readBoardEquipment } from '@operato/ops-contract' /** 흡수하고 **내보내지 않는** 옛 키 — 남겨 두면 소비처가 두 어휘를 다시 보게 된다. */ const RETIRED_KEYS = ['nodes', 'movers', 'equipmentList'] as const // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다) /** 등급 축들 — 넷이 같은 모양이라 같은 규칙을 쓴다(어느 자원이든 표시명은 `name`). */ const CLASS_KEYS = ['personnelClasses', 'equipmentClasses', 'assetClasses', 'materialClasses'] as const /** * 보드를 정본 어휘로 — `locations`·`equipment` 만 남는다. 새 어휘 모델에는 **아무 일도 하지 않는다** * (같은 값을 다시 담을 뿐이라 멱등). * * 모델이 아닌 것(없음·배열·원시값)은 **그대로 통과**시킨다 — 모양을 짐작해 고치지 않는다. */ export function canonicalModel(model: T): T { if (!model || typeof model !== 'object' || Array.isArray(model)) return model const rest: Record = {} for (const [k, v] of Object.entries(model as Record)) { if ((RETIRED_KEYS as readonly string[]).includes(k)) continue rest[k] = v } /* * **등급의 표시명은 `name` 이다** — 옛 모델는 `description` 에 담았다. * * 표준 마스터가 등급 표시명을 `description` 으로 주기 때문에 그대로 저장돼 있었고, 화면은 그것을 * "설명" 열에 앉혔다 — 사용자에게는 이름인데 머리글은 설명이라, 같은 것을 축마다 다른 이름으로 * 부르게 된다. 인제스트 문에서 이미 `name` 으로 옮기고 있지만, **이미 저장된 모델**가 그대로 남아 * 새 트윈만 고쳐진다. 정규화의 집은 여기 하나다(위 머리말의 이유 그대로). */ for (const key of CLASS_KEYS) { const list = rest[key] if (!Array.isArray(list)) continue rest[key] = list.map((c: any) => c && typeof c === 'object' && !c.name && c.description ? (({ description, ...keep }) => ({ ...keep, name: description }))(c) : c ) } /* 리더가 세대를 고른다 — 여기서 `??` 사슬을 다시 적으면 세대 규칙이 두 곳이 된다. */ return { ...rest, locations: readBoardLocations(model as any), equipment: readBoardEquipment(model as any) } as T } /** * 엔티티 컬럼 변환기 — **읽을 때만** 정규화한다. * * `to`(쓰기)는 손대지 않는다: 저장은 호출부가 준 그대로가 진실이고, 읽기가 정규화되므로 다음 저장에서 * 자연히 새 어휘가 된다. */ export const modelColumnTransformer = { from: (value: any) => canonicalModel(value), to: (value: any) => value }