/* * 이미 있는 트윈을 다른 현장으로 옮긴다 — **판정만**. 순수. * * ── 왜 있나 (2026-08-13) ──────────────────────────────────────────────────── * 트윈↔현장 연결은 **만들 때만** 정할 수 있었다(템플릿 위자드·import 의 `spaceId`). 잘못 넣었거나, * 나중에 "이 창고는 사실 저 공장과 같은 부지다" 를 알게 되면 지우고 다시 만드는 것이 유일한 길이었다 — * 다시 만들면 그 트윈의 저널(일어난 일)이 사라진다. 고칠 수 있는 실수를 데이터 손실로 갚게 하는 설계다. * * ── 옮기는 것이 무엇을 옮기나 (엔티티 키를 보고 정했다) ────────────────────── * 무엇이 트윈에 매여 있고 무엇이 공간에 매여 있는지가 답을 정한다: * * · `TwinEvent` `(domain, instanceId, …)` → **따라온다.** 저널을 다시 쓰지 않는다. * · `TwinLocation/Equipment/Operation` `(domain, instance, …)` → **따라온다.** * · `TwinArea` `(domain, space, areaId)` → **남는다.** 구역은 공간의 것이다. * · `TwinSpaceRepresentation`(지도·이미지·보드) → **남는다.** 표현도 공간의 것이다. * * 그래서 옮기기는 이력 이전이 아니라 **소속 갱신**이다(`instance.spaceId` + `model.spaceId`). * 대신 공간에 매인 것들과의 연결이 끊길 수 있고, 그것을 **알리지 않고 넘기지 않는 것**이 이 모듈의 일이다. * * ── 거절과 경고를 가른다 ──────────────────────────────────────────────────── * 거절: 되돌릴 수 없거나 뜻이 성립하지 않는 것(없는 공간·같은 공간·벤치 사본). * 경고: 사용자가 알고 나면 정당할 수 있는 것(구역 소속이 끊긴다·시간대가 다르다·옛 표현이 남는다). * 경고를 거절로 올리면 정당한 이전을 막고, 거절을 경고로 낮추면 들어온 것이 없는 사고가 된다. */ /** 옮길 수 없는 이유 — 언어 중립 코드(발견 오류·인제스트 거절과 같은 규약). */ export type MoveSpaceRefusalCode = 'twin-not-found' | 'space-not-found' | 'same-space' | 'bench-copy' /** 옮겨도 되지만 알아야 하는 것 — 같은 규약. */ export type MoveSpaceWarningCode = 'areas-left-behind' | 'timezone-differs' | 'representations-left-behind' | 'running' export interface MoveSpaceNote { code: C params?: Record /** 영어 canonical — 로그에 남고, 번역이 없으면 그대로 보인다. */ message: string } export type MoveSpaceRefusal = MoveSpaceNote export type MoveSpaceWarning = MoveSpaceNote export interface MoveSpaceInput { /** 옮길 트윈. 없으면 거절한다. */ instanceId?: string /** 지금 속한 공간(없을 수 있다 — 옛 트윈). */ fromSpaceId?: string /** 옮겨 갈 공간. */ toSpaceId: string /** 그 공간이 이 테넌트에 실제로 있나(호출측이 조회해 넘긴다). */ toSpaceExists: boolean /** 운영 트윈인가 벤치 사본인가 — 사본은 원본의 공간을 따라야 하므로 옮기지 않는다. */ purpose?: string /** 지금 실행 중인가 — 시간대 변화는 재기동 뒤에 커널에 실린다. */ running?: boolean /** 두 공간의 시간대(IANA). 다르면 앞으로의 시각 해석이 달라진다. */ fromTimezone?: string toTimezone?: string /** 이 트윈의 로케이션들이 소속으로 가리키는 구역 id 들(중복 포함 가능). */ areaRefs?: readonly (string | undefined | null)[] /** 옮겨 갈 공간에 있는 구역 id 들. */ toAreaIds?: readonly string[] /** 옛 공간의 표현 수 — 그 표현의 폴리곤이 이 트윈을 가리키고 있었을 수 있다. */ fromRepresentationCount?: number } export interface MoveSpaceJudgement { /** 있으면 옮기지 않는다. */ refusal?: MoveSpaceRefusal /** 옮기되 함께 알린다. 비어 있으면 잃는 것이 없다. */ warnings: MoveSpaceWarning[] } /** 옮겨도 되나 — 되면 무엇을 알려야 하나. */ export function judgeMoveTwinSpace(input: MoveSpaceInput): MoveSpaceJudgement { const to = (input.toSpaceId ?? '').trim() const from = (input.fromSpaceId ?? '').trim() if (!input.instanceId) { return { refusal: { code: 'twin-not-found', message: 'no such twin in this tenant' }, warnings: [] } } if (!to || !input.toSpaceExists) { return { refusal: { code: 'space-not-found', params: { spaceId: to }, message: `space "${to}" not found — cannot move a twin into a space that does not exist` }, warnings: [] } } if (from && from === to) { return { refusal: { code: 'same-space', params: { spaceId: to }, message: `twin "${input.instanceId}" is already in space "${to}"` }, warnings: [] } } /* 벤치 사본은 "이 공장이 이만큼 커지면" 을 재는 사본이라 원본의 현장에 매여 있다. 옮기면 그 측정이 무엇의 측정인지 말할 수 없게 된다 — 사본을 옮기는 대신 원본을 옮기고 사본을 다시 뜬다. */ if (input.purpose === 'bench') { return { refusal: { code: 'bench-copy', params: { instanceId: input.instanceId }, message: `twin "${input.instanceId}" is a bench copy — move its source twin instead` }, warnings: [] } } const warnings: MoveSpaceWarning[] = [] /* 로케이션의 구역 소속 — 구역은 공간의 것이라 새 공간에 같은 id 가 없으면 소속이 끊긴다. 막지는 않는다(새 공간에서 구역을 다시 그리는 것이 정상 경로다). 다만 몇 개가 끊기는지 말한다. */ const refs = [...new Set((input.areaRefs ?? []).filter((x): x is string => !!x && !!x.trim()))] const have = new Set(input.toAreaIds ?? []) const orphaned = refs.filter(r => !have.has(r)).sort() if (orphaned.length) { warnings.push({ code: 'areas-left-behind', params: { count: orphaned.length, areas: orphaned.slice(0, 5).join(', ') }, message: `${orphaned.length} area reference(s) do not exist in the target space — those locations lose their grouping: ${orphaned .slice(0, 5) .join(', ')}` }) } /* 시간대 — 공간이 정하고 커널이 분 오프셋으로 받는다. 다르면 같은 저널이 다른 시각으로 읽힌다. */ const tzFrom = (input.fromTimezone ?? '').trim() const tzTo = (input.toTimezone ?? '').trim() if (tzFrom !== tzTo) { warnings.push({ code: 'timezone-differs', params: { from: tzFrom || '—', to: tzTo || '—' }, message: `time zone changes from "${tzFrom || 'unset'}" to "${tzTo || 'unset'}" — shift boundaries and day buckets will read differently` }) } /* 옛 공간의 표현 — 폴리곤 바인딩이 이 트윈의 로케이션·그룹을 가리키고 있었을 수 있다. 표현은 공간에 남으므로, 그 화면에서 이 트윈이 사라진다. 몇 개가 남는지 말해 두면 사람이 찾아갈 수 있다. */ if ((input.fromRepresentationCount ?? 0) > 0) { warnings.push({ code: 'representations-left-behind', params: { count: input.fromRepresentationCount as number, spaceId: from || '—' }, message: `${input.fromRepresentationCount} representation(s) stay with space "${from}" — bindings that pointed at this twin will no longer resolve there` }) } /* 실행 중 — 소속은 즉시 바뀌지만 시간대는 커널을 세울 때 실린다. 시간대가 같으면 말할 것이 없다. */ if (input.running && tzFrom !== tzTo) { warnings.push({ code: 'running', params: { instanceId: input.instanceId }, message: `twin "${input.instanceId}" is running — the new time zone applies after it is restarted` }) } return { warnings } } /* * ── 현장에서 떼기 (2026-08-13) ────────────────────────────────────────────── * * 더하기·옮기기만 있으면 잘못 넣은 것을 되돌릴 수 없다. 떼기는 "어느 현장에도 속하지 않음" 으로 * 되돌리는 일이고, 데이터 모델이 그 상태를 이미 허용한다(`TwinInstance.spaceId` 는 선택 컬럼). * * 떼면 무엇이 달라지나 — 옮기기와 같은 규칙이지만 **갈 곳이 없다**는 점이 다르다: * · 저널·로케이션·설비는 그대로 트윈에 남는다(잃는 것 없음). * · 구역·표현은 옛 현장에 남는다 → 그 화면에서 이 트윈이 사라진다. * · **볼 무대가 없어진다** — 현장 현황(지도·보드)은 현장의 표현으로 그리므로, 뗀 트윈은 목록과 * 조회 화면에서만 보인다. 이것을 말하지 않으면 사용자는 트윈이 고장 났다고 읽는다. * · 시간 기준(`utcOffsetMinutes`)은 **옛 현장의 것으로 남는다.** 지우면 알리지 않고 UTC 로 읽히므로 * 그쪽이 더 위험하다 — 남기고 그 사실을 말한다. */ /** 뗄 수 없는 이유 — 언어 중립 코드. */ export type DetachSpaceRefusalCode = 'twin-not-found' | 'no-space' | 'bench-copy' /** 떼도 되지만 알아야 하는 것. */ export type DetachSpaceWarningCode = 'no-stage' | 'representations-left-behind' | 'timebase-kept' export type DetachSpaceRefusal = MoveSpaceNote export type DetachSpaceWarning = MoveSpaceNote export interface DetachSpaceInput { instanceId?: string /** 지금 속한 공간 — 없으면 뗄 것이 없다. */ fromSpaceId?: string purpose?: string /** 옛 공간의 표현 수. */ fromRepresentationCount?: number /** 옛 공간의 시간대 — 있으면 그 기준이 model 에 남는다. */ fromTimezone?: string } export interface DetachSpaceJudgement { refusal?: DetachSpaceRefusal warnings: DetachSpaceWarning[] } /** 떼도 되나 — 되면 무엇을 알려야 하나. */ export function judgeDetachTwinSpace(input: DetachSpaceInput): DetachSpaceJudgement { const from = (input.fromSpaceId ?? '').trim() if (!input.instanceId) { return { refusal: { code: 'twin-not-found', message: 'no such twin in this tenant' }, warnings: [] } } if (!from) { return { refusal: { code: 'no-space', params: { instanceId: input.instanceId }, message: `twin "${input.instanceId}" is not on any site — nothing to detach` }, warnings: [] } } /* 벤치 사본은 원본의 현장을 따라야 측정이 무엇의 측정인지 말할 수 있다(옮기기와 같은 이유). */ if (input.purpose === 'bench') { return { refusal: { code: 'bench-copy', params: { instanceId: input.instanceId }, message: `twin "${input.instanceId}" is a bench copy — it must stay on the site it measures` }, warnings: [] } } const warnings: DetachSpaceWarning[] = [ { code: 'no-stage', params: { instanceId: input.instanceId }, message: `twin "${input.instanceId}" will have no site to be drawn on — it stays visible in lists and inspection only` } ] if ((input.fromRepresentationCount ?? 0) > 0) { warnings.push({ code: 'representations-left-behind', params: { count: input.fromRepresentationCount as number, spaceId: from }, message: `${input.fromRepresentationCount} representation(s) stay with space "${from}" — bindings that pointed at this twin will no longer resolve there` }) } const tz = (input.fromTimezone ?? '').trim() if (tz) { warnings.push({ code: 'timebase-kept', params: { timezone: tz }, message: `the time base stays as "${tz}" (the site it came from) — clearing it would silently read times as UTC` }) } return { warnings } }