import { getRepository } from '@things-factory/shell' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinArea } from '../twin-space/twin-area.js' import { TwinLocation } from './twin-location.js' import { TwinEquipment } from './twin-equipment.js' import { TwinOperation } from './twin-operation.js' import { readBoardEquipment } from '@operato/ops-contract' /* * 구조 투영 — `model`(커널 입력 문서) → **표준 엔티티 행**(ADR-0032). * * ── 왜 전량 교체인가 ──────────────────────────────────────────────────────── * 이 행들은 **원본에서 온 캐시**다(ADR-0032-D). 원본이 진실이므로 diff 로 아끼는 것보다 * **매번 그대로 다시 그리는 것**이 맞다 — diff 는 "우리 쪽에 남겨야 할 것이 있다" 는 전제를 * 깔고, 그 전제가 소유 침범의 시작이다. * * 규모가 커지면 서명(`TwinStructure.signature`)이 같을 때 건너뛰는 장치가 이미 있으므로 * 그것을 앞에 둔다. 여기서 diff 를 발명하지 않는다. * * ── 순서가 중요하다 ──────────────────────────────────────────────────────── * 설비가 자리를 FK 로 가리키므로 **자리를 먼저** 넣는다. 지우는 것은 반대 순서다. * (자리 삭제 시 설비의 `homeLocation` 은 `SET NULL` 이지만, 전량 교체에서는 둘 다 지운다.) */ export interface ProjectionReport { locations: number equipment: number operations: number /** 자리를 못 찾아 기준 자리를 비운 설비 — **알리지 않고 넘기지 않는다.** */ unresolvedHome: string[] /** 구역을 못 찾아 비운 자리. 구역은 model 밖(호스트 개념)이라 어긋날 수 있다. */ unresolvedArea: string[] } /** * 이 트윈의 구조 행을 **원본 그대로 다시 그린다.** * * `sourceRef` 는 어느 원본에서 왔는지다(`ReferenceMaster.source` = instanceId). 없는 행은 * 만들 수 없다 — 캐시라는 사실을 구조로 지킨다. */ export async function projectStructure(domainId: string, instanceId: string, model: any, sourceRef: string): Promise { const instance = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!instance) throw new Error(`cannot project structure: twin instance "${instanceId}" is not registered`) /* * **같은 id 가 둘이면 그 사실을 말한다** — 제약 위반 문구를 사용자에게 보이지 않는다. * * 실측(2026-08-18): 현장 값을 그 현장의 트윈 전부에 얹다가 한 트윈에서 투영이 터졌다. 올라온 것은 * `SQLITE_CONSTRAINT: UNIQUE constraint failed: twin_equipment...` 뿐이라 **무엇이 잘못됐는지 아무도 * 알 수 없었다.** 원인은 그 모델에 설비 `load-1` 이 두 번 들어 있던 것이다(id 는 트윈 안에서 유일해야 * 한다). 알리지 않고 하나만 남기지 않는다 — 그것은 모델링 오류를 감추는 일이고, 어느 쪽이 맞는지 우리는 * 모른다. */ const dupOf = (list: any[] | undefined, what: string): string | undefined => { const seen = new Set() for (const e of list ?? []) { const id = String(e?.id ?? '') if (!id) continue if (seen.has(id)) return `twin "${instanceId}" has two ${what} with id "${id}" — ids must be unique within a twin, so the model cannot be projected` seen.add(id) } return undefined } const dupMsg = dupOf(model?.locations, 'locations') ?? dupOf(readBoardEquipment(model), 'equipment') if (dupMsg) throw new Error(dupMsg) const locRepo = getRepository(TwinLocation) const eqRepo = getRepository(TwinEquipment) const opRepo = getRepository(TwinOperation) const scope = { domain: { id: domainId } as any, instance: { id: instance.id } as any } const ingestedAt = new Date() /* * 전량 교체 — 이 행들은 **원본에서 온 캐시**이므로 매번 그대로 다시 그린다. * diff 로 아끼는 것은 "우리 쪽에 남겨야 할 것이 있다" 는 전제를 깔고, 그 전제가 소유 침범의 시작이다. * * **관측으로 자란 자원은 여기 쓰지 않는다.** 커널은 마스터에 없던 설비도 이벤트로 알게 되지만 * (`observed-reducer` 의 `origin: 'observed'`), 그것을 이 표에 밀어 넣으면 **쓰기 경로가 둘**이 되고 * 틱 루프에서 DB 로 쓰는 길이 생긴다(ADR-0032-D 위반). 그 차이는 **조회 시점에 비교해 보고**한다 — * 숨겨야 할 불일치가 아니라 "원본이 모르는 설비가 실행 중이다" 는 사실이다. */ await eqRepo.delete(scope) await opRepo.delete(scope) await locRepo.delete(scope) /* 구역은 model 밖에 산다(호스트의 TwinArea). 이름으로 찾아 FK 를 잇는다. */ const areas = await getRepository(TwinArea).find({ where: { domain: { id: domainId } as any } }) const areaOf = new Map(areas.map(a => [a.areaId, a])) const unresolvedArea: string[] = [] /* * ── 전기 상류를 「구역」으로 읽지 않는다 (2026-08-18) ──────────────────────── * * 자리의 부모가 **다른 자리**일 수 있다(분기의 상류는 수전이다). 그 부모를 구역 표에서 찾으면 당연히 * 없고, 예전에는 그것을 매번 「구역이 이 트윈에 없다」로 경고했다 — 에너지 트윈을 인제스트할 때마다 * 분기 수만큼 거짓 경보가 났다. 경보가 소음이 되면 **사람이 경보 자체를 읽지 않게 된다.** * * 자리를 가리키는 부모는 계층(자리 ⊃ 자리)이고, 구역은 model 밖의 다른 표다. 그래서 부모가 이 모델의 * 자리이면 구역 해석을 **시도하지 않는다**(없는 것을 못 찾았다고 말하지 않는다). */ const locationIds = new Set((model?.locations ?? []).filter((l: any) => l?.id).map((l: any) => String(l.id))) const locations: TwinLocation[] = (model?.locations ?? []) .filter((l: any) => l?.id) .map((l: any) => { const parentIsLocation = l.parentId ? locationIds.has(String(l.parentId)) : false const area = l.parentId && !parentIsLocation ? areaOf.get(l.parentId) : undefined if (l.parentId && !parentIsLocation && !area) unresolvedArea.push(`${l.id}→${l.parentId}`) return locRepo.create({ ...scope, locationId: l.id, /* **이름을 옮긴다.** 버리고 있었다 — 그래서 화면에 자리 id 만 남았다(§name 주석 참조). */ name: l.name ?? null, description: l.description ?? null, type: l.type, capacity: l.capacity ?? null, parallelism: l.parallelism ?? null, area: area ? ({ id: area.id } as any) : null, gs1Id: l.gs1Id ?? null, /* 자리의 속성도 옮긴다 — 옮기지 않으면 계약전력·요금이 항목 패널에서 사라진다(들어온 것이 없는 누락). */ properties: l.properties ?? null, sourceRef, ingestedAt } as any) }) const savedLocations = locations.length ? await locRepo.save(locations, { chunk: 500 }) : [] const locOf = new Map(savedLocations.map(l => [l.locationId, l])) const unresolvedHome: string[] = [] const equipment: TwinEquipment[] = (model?.equipment ?? []) .filter((m: any) => m?.id) .map((m: any) => { /* * ── 설비는 자리에도 구역에도 설 수 있다 (2026-09-06) ───────────────────── * 자리 목록에서 먼저 찾고, 없으면 구역에서 찾는다. `locations.parentId` 가 이미 같은 규율을 * 쓴다(§`parentIsLocation`). * * 실물이 있다 — `EQ-CAM-SHOWER-01`(에어샤워)이 `LINE-CAM`(구역)에 딸려 있고, 라인 전체에 * 붙는 설비는 자리 하나를 고를 수 없다. 그동안 자리에서만 찾아서 **끊어진 참조로 보고**했다. * * 마스터가 `homeLocationKind: 'area'` 로 말해 주지만 여기서는 조회로 판정한다 — 옛 트윈의 * model 에는 그 칸이 없고, 있는 것을 못 찾았다고 말하는 것이 더 나쁘다. */ const home = m.homeLocation ? locOf.get(m.homeLocation) : undefined const homeArea = m.homeLocation && !home ? areaOf.get(m.homeLocation) : undefined /* 끊어진 기준 자리는 **이름을 대고** 보고한다 — 자리에도 구역에도 없을 때만이다. */ if (m.homeLocation && !home && !homeArea) unresolvedHome.push(`${m.id}→${m.homeLocation}`) return eqRepo.create({ ...scope, equipmentId: m.id, name: m.name ?? null, description: m.description ?? null, kind: m.kind, /* 속성도 옮긴다 — 행에 자리가 없어 흘리고 있었다(소비처가 실제로 읽는 사실이다: 주행 속도). */ properties: m.properties ?? null, /* 시험 참조도 함께 — 흘리면 명세 쪽에서 "누가 나를 가리키나" 에 이 설비가 빠진다. */ testSpecificationIds: m.testSpecificationIds ?? null, /* 결과까지 — 참조만 있으면 "무엇으로 검증한다" 까지고, 자격 성립은 결과가 말한다. */ testResults: m.testResults ?? null, homeLocation: home ? ({ id: home.id } as any) : null, /* 둘 다 차는 일은 없다 — 자리에서 찾았으면 구역은 안 본다. */ homeArea: homeArea ? ({ id: homeArea.id } as any) : null, mtbfMs: m.mtbfMs ?? null, mttrMs: m.mttrMs ?? null, /* 선언된 정체성 — 예전에는 선언에 없는 `gs1Id` 를 읽어 이 칸이 늘 비어 있었다. */ identity: (m as any).identity ?? null, sourceRef, ingestedAt } as any) }) if (equipment.length) await eqRepo.save(equipment, { chunk: 500 }) const operations: TwinOperation[] = (model?.operations ?? []) .filter((o: any) => o?.key) .map((o: any, i: number) => opRepo.create({ ...scope, operationKey: o.key, /* * 공정은 마스터가 `label` 로 말한다 — 행에서는 **규약의 이름 하나만** 둔다. * * 한동안 `label` 컬럼도 함께 남겨 뒀더니 조회 화면에 "이름" 열이 **두 개** 떴다(같은 값이 * 두 번). 어휘를 하나로 한다고 하면서 둘을 남기면 반드시 이렇게 새어 나온다. */ name: o.name ?? o.label ?? null, description: o.description ?? null, intent: o.intent ?? null, locationType: o.locationType ?? null, resourceType: o.resourceType ?? null, /* 소요시간은 ISO 8601 문자열이 정본 — 분으로 줄이면 초가 오류 없이 사라진다. */ duration: o.duration ?? null, variability: o.variability ?? null, parameters: o.parameters ?? null, /* **네 자원 명세를 함께 옮긴다.** 셋이 빠져 있어서 조회 화면이 "원본이 말한 값" 이라 적고도 넷 중 하나만 보였다 — 사람이 필요한 공정을 보고 사람이 필요 없다고 결론 내게 된다. */ personnelSpecification: o.personnelSpecification ?? null, physicalAssetSpecification: o.physicalAssetSpecification ?? null, materialSpecification: o.materialSpecification ?? null, seq: i, sourceRef, ingestedAt } as any) ) if (operations.length) await opRepo.save(operations, { chunk: 500 }) return { locations: savedLocations.length, equipment: equipment.length, operations: operations.length, unresolvedHome, unresolvedArea } }