/* * Face2 마스터 인제스트(ADR-0018 반영 모드) — 레퍼런스 시스템(실 WMS/YMS/MES 또는 가상 레퍼런스)의 * 마스터를 읽어 트윈 구조(TwinModelDef) + 공간(Space content)을 생성. 손배선이 아니라 인제스트로 엔티티가 흐른다. * * 규율(ADR-0018): 도메인 타입(location.type=노드용)은 커널 카탈로그 어휘를 그대로 반영(발명 금지). * location 은 role 로 노드(운영점)·area(공간영역)·both 를 구분 — 노드→TwinModelDef, area→Space.areas. * 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트. */ import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/ops-contract' import { partitionLensLocations } from '@operato/ops-contract' export interface RefLocation { id: string type: string // 노드는 커널 노드타입(dock/storage/yard-slot…), area 는 공간 region 타입 name?: string description?: string role: 'location' | 'area' | 'both' parentId?: string /** * **이 자리를 정의하는 영역**(`wms` · `mes` …) — 기준정보가 말한 그대로. 이 트윈의 렌즈(`ReferenceMaster.system`)와 다르면 * 그 자리는 참조로만 든다(ADR-0081 보탬). 말하지 않은 자리는 이 렌즈의 자리다. */ system?: string /** * **전기적으로 어디서 받는가** — 이 자리에 전기를 주는 상류 자리(커널 계약의 `upstreamId`). * * `parentId`(공간 포함)와 따로 두는 이유는 커널 계약에 적혀 있다. 여기서 빠뜨리면 마스터가 말한 계통이 * 모델까지 오지 못하고, 뿌리 계량 판정이 분기 계량기까지 뿌리로 세어 **부하가 두 배로** 보인다. */ upstreamId?: string /** 저장 용량 — 이 자리에 동시에 놓일 수 있는 물품 수. */ capacity?: number /** * 동시 처리 수 — 이 자리에서 한 번에 진행될 수 있는 작업 수. **저장 용량과 다른 축**이다 * (자재를 20개 쌓아 두는 절단 스테이션이 한 번에 한 대만 깎는다). 미지정=제약 없음. */ parallelism?: number /** * **ISA-95 설비 계층 단계** — `EquipmentLevel` 열거값(`ProductionLine`·`StorageZone`·`WorkCenter`…). * * `type` 은 현장의 낱말(`paint-booth`)이고 이것은 **표준이 정한 역할**이다. 둘은 다른 축이라 함께 든다 — * 연동·집계는 표준 축으로, 화면은 현장 낱말로. 미지정이면 그 자리의 표준 역할을 아직 모른다는 뜻이고, * 짐작해 채우지 않는다. */ level?: EquipmentLevel /** * 자리의 **열린 속성** — 표준 `OperationalLocationProperty`(설비·사람·자산과 같은 모양). * * 무엇이 여기 실리나: **자리의 물리량**이다. 에너지의 계약전력(`contract.kW`)이 첫 사례다 — * 그 값이 앉을 자리가 없어서 EMS 커널은 계약 대비 판정을 할 수 없었다(자리가 없으면 사실이 * 들어오지 못한다). 커널은 자기가 **읽는** 속성 id 만 선언하고(`EMS_PROPERTY`) 나머지는 나른다. */ properties?: RefResourceProperty[] footprint?: { x: number; y: number; w: number; h: number } // 있으면 area 기하 + 노드 중심좌표 gs1Id?: string // 정식 GS1 정체성(§J) — 위치=SGLN. 결합(tag)/추적 canonical id. 받아들이기 경계서 부여(로컬 id=name/xref 로 보존). } /** * 설비 속성 — **ISA-95 `EquipmentSpecificationProperty`(`OpEquipmentSpecificationPropertyType`) 1:1.** * 그 타입도 `ID` + `Value`(`ValueString`·`DataType`·`UnitOfMeasure`) 이므로 공정 모수와 같은 모양이다. * * 단위 코드는 **UN/CEFACT 권고 20 공통코드**(EPCIS `uom` 과 같은 코드계): 속도는 `MTS`(metre per * second) 또는 `KMH`(kilometre per hour). 코드를 우리가 발명하지 않는다. */ /** * 자원 속성 — **커널 `ResourceProperty` 와 같은 모양**이고, 그 모양은 ISA-95 가 세 자원에 똑같이 * 정의한 것이다(`EquipmentPropertyType` · `PersonPropertyType` · `PhysicalAssetPropertyType`). * * 그래서 자원마다 다른 속성 타입을 두지 않는다 — 예전에는 설비만 속성을 가졌고(RefEquipmentProperty), * 인원·자산에 붙는 사실은 **실을 자리가 없어서 들어오지 못했다.** */ export interface RefResourceProperty { id: string description?: string value?: string /** 표준 `Value.DataType`. */ dataType?: string /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드. 물리량에 단위가 없으면 소비처가 거절한다. */ uom?: string /** 하위 속성(재귀) — 표준 `PropertyChild`. */ children?: RefResourceProperty[] /** 이 개체 속성이 구체화하는 등급 속성 — 표준 `ClassPropertyID`. */ classPropertyId?: string } /** 옛 이름 — 설비 전용이던 시절의 별칭(소비처 호환). 신규 소싱은 `RefResourceProperty`. */ export type RefEquipmentProperty = RefResourceProperty /** * 자원 **등급 정의** — 커널 `ResourceClassDef` 와 같은 모양이고, 그 모양은 ISA-95 가 세 자원에 똑같이 * 정의한 것이다(`PersonnelClassType`·`EquipmentClassType`·`PhysicalAssetClassType`). * * 왜 필요한가: 원 시스템은 등급 **체계**를 갖고 있다("6축 용접 자격자" 는 "용접 자격자" 이고, 그것은 * "생산직" 이다). 그 체계가 들어올 자리가 없으면 요구와 소속이 **문자열이 똑같을 때만** 맞아, * 현장에서는 되는 배정이 모델에서 막힌다. */ export interface RefResourceClass { id: string description?: string /** 상위 등급들 — 표준 `ClassBaseID`(복수 = 다중 상속). */ baseIds?: string[] properties?: RefResourceProperty[] testSpecificationIds?: string[] /** 유효 기간(ISO) — 표준 `EffectiveStartDate`/`EffectiveEndDate`. 밖이면 자격이 성립하지 않는다. */ effectiveStart?: string effectiveEnd?: string } /** * 검사 명세 — **자원이 가리키는 대상**(ISA-95 `TestSpecification`). * * 표준 필드는 셋만 담는다(`ID`·`Description`·`Version`) — 재는 속성과 합격 기준은 아직 1차 출처와 * 대조하지 않았다(커널 계약의 머리말이 그 사실을 적어 두었다). */ /** * 시험 결과 — **"이 개체가 그 시험을 통과했다"** 는 기록(ISA-95 결과). * * 결과를 낳는 곳은 트윈이 아니다(인사·정비 시스템이 기록한다) — 그래서 관측이면서 **선언 경로로** 온다. * 커널은 이 값이 있을 때만 자격을 판정한다: 없으면 제약이 아니고(라인이 굶지 않는다), 있으면 만료· * 불합격이 배정을 막는다. */ export interface RefTestResult { specId: string result: 'pass' | 'fail' at?: string /** 없으면 무기한 — 짐작으로 기한을 만들면 멀쩡한 자격자가 오류 없이 사라진다. */ expiresAt?: string } export interface RefTestSpecification { id: string /** 표시명 — i18n 키일 수 있다. 읽는 어휘를 하나로 맞추려고 인제스트가 `name` 으로 옮긴다. */ description?: string version?: string /** * 판정 기준들 — 커널 계약의 `TestSpecification.criteria` 를 그대로 나른다. * * **경계에서 어휘를 갈지 않는다.** 커널 타입을 그대로 쓰므로 `limit.{minimum,maximum,uom}` · * `expression` · `evaluatedPropertyId` 가 원본에서 커널까지 한 이름으로 간다. * * 선언하지 않아도 명세는 성립한다(이름만으로 「무엇으로 검증했나」에 답한다). 선언하면 **판정에 * 근거가 있는지**를 물을 수 있게 된다 — 커널의 `criterionSaysNothing` 이 그것을 센다. */ criteria?: TestSpecificationCriterion[] } export interface RefEquipment { id: string kind: string /** * 속한 **등급들** — 계약 선언의 `equipment[].equipmentClassIds` 로 그대로 간다. * * ── 왜 빠져 있었나 (2026-09-04) ───────────────────────────────────────── * 커널에 이 축을 여는 날 모델·상태·저널·미러까지 배선했는데 **접수면이 빠졌다.** 사람과 자산은 * 받는데(`personnelClassIds`·`assetClassIds`) 설비만 없었다. * * 그래서 원본이 설비 22대에 등급을 다 채워 보냈는데 **문턱에서 버려졌다** — 거절도 경고도 없이. * 등급 목록은 서고 소속은 아무도 없어서, 화면에서 등급을 골라도 설비가 안 나왔다. * * 소속은 이 선언과 `kind` 를 합친 것이다(§`equipmentClassMembership`) — 판정하는 쪽이 그 함수를 * 지난다. */ equipmentClassIds?: string[] /** 사람이 부르는 이름 — 없으면 화면이 id 를 보인다(id 를 이름으로 베끼지 않는다). */ name?: string /** 시험 결과 — 자격이 성립하는지는 이것이 말한다(§RefTestResult). */ testResults?: RefTestResult[] description?: string homeLocationId: string mtbfMs?: number // 평균 고장 간격(확률적 고장) — 지정 시 커널이 breakdown 시뮬 → 주목(고장) 발생 mttrMs?: number // 평균 수리 시간 /** * **이 설비의 전역 정체성** — 커널 선언의 `equipment[].identity` 로 그대로 간다. * * 예전 이름은 `gs1Id` 였다. 그런데 **GS1 키가 아닌 것이 정상이다** — 실측한 값은 인버터의 * 하드웨어 식별자(`ES100000007d8d061d-1`)이고, 원본이 GIAI 를 주면 그것도 여기 들어온다. * 이름이 한 종류만 가리키면 다른 종류가 들어올 때 그 자리를 못 쓴다. * * 자리(`RefLocation.gs1Id`)는 그대로다 — 그쪽은 실제로 SGLN 이 온다. */ identity?: string /** * 교대(가동시간) — 이 시간대에만 배정된다. `startHour <= endHour` 면 같은 날, 넘어가면 야간(22→6). * 미지정=24시간 가용. 교대 밖 자원은 고장·계획정지와 **구별되는 이유**로 쉰다(상태에 드러난다). */ window?: { startHour: number; endHour: number } /** * 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 있으면 `window` 보다 이것을 본다. * * 축을 프레임워크 `@things-factory/work-shift` 의 `WorkShift`(`fromDate`·`fromTime`·`toDate`·`toTime`)와 * 맞춰 두었다 — 그 엔티티를 교대의 정본으로 결선할 때 직선 매핑이 되게. **아직 결선하지 않았다**: * 그 엔티티에는 자원 연결과 비근무(휴일)가 없어 확장이 함께 필요하고, operato-twin 의 의존성 추가는 * 앱 아키텍처 결정이다(커버리지 §4-7 에 결선 지점으로 기록). */ workCalendar?: WorkCalendarEntry[] /** * 설비 속성(속도 등) — 없으면 **이동시간을 거리에서 계산할 수 없다**(속도 없이 거리만으로는 시간이 * 안 나온다). 그때는 커널 상수로 떨어지고 그 사실이 `specCoverage()` 에 남는다. */ properties?: RefResourceProperty[] /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`(교정 등). */ testSpecificationIds?: string[] /** * 유효 기간 — **표준 `EffectiveStartDate` / `EffectiveEndDate`**(개체 타입 셋 모두에 있다). * * 없으면 **폐기한 것이 영구히 살아 있다**: 배정 대상에 남고, 가용 수에 들어가고, 화면에 뜬다. * 도입 예정인 것은 반대로 오늘부터 있는 것처럼 보인다. 기간 밖이면 커널이 그 시각의 모델에서 * 빼고 **이유를 달아 남긴다**(`effectivity: 'not-yet' | 'expired'`) — 알리지 않고 지우지 않는다. */ effectiveStart?: string effectiveEnd?: string } /** * 공정 모수 — 커널 `OperationParameter`(= ISA-95 `ParameterSpecification`/`ParameterType`) 와 **같은 모양**. * 이름을 바꾸지 않는다: 인제스트 경계에서 어휘가 갈리면 그 자리에서 방언이 생긴다. * ID 어휘의 정본은 커널 `OP_PARAM`(표준이 파라미터 ID 를 정하지 않으므로 우리가 한 곳에서 정한다). */ export interface RefOperationParameter { id: string /** ISA-95 `Value.ValueString` — 표준이 문자열로 싣는다. 기간 모수는 ISO 8601(`PT8M`). */ value: string /** ISA-95 `Value.UnitOfMeasure`. 무차원(비율)이면 생략. */ uom?: string } /** * 공정의 자재 요구·산출 — **ISA-95 `OperationsSegment.MaterialSpecification`** 과 같은 모양. * 인원·설비·자산 명세와 같은 자리에 있어야 4대 자원이 대칭이 된다(자재만 빠져 있었다). */ export interface RefOpMaterialSpecification { id?: string /** 품목 등급으로 요구 — 표준 `MaterialClassID`. */ materialClass?: string /** 특정 품목으로 요구 — 표준 `MaterialDefinitionID`. */ materialDefinition?: string /** 표준 `MaterialUse` — 이 공정에서의 쓰임. */ use: 'consumed' | 'produced' | 'consumable' /** 표준 `Quantity`. 단위 미지정이면 개수. */ quantity: number uom?: string } /** 소요시간 변동 — **표준 밖 확장**(ISA-95 Duration 은 스칼라). 커널 `DurationVariability` 와 동형. */ export interface RefOperationVariability { distribution: 'constant' | 'exponential' | 'uniform' | 'triangular' min?: string max?: string mode?: string } /** * 오퍼레이션 명세 — ISA-95 `OperationsSegment`. 커널 `OperationDef` 와 필드 이름이 1:1이다. * * **이것이 없으면 시뮬레이션의 시간은 우리가 코드에 고정한 상수다.** 원 시스템(또는 가상 레퍼런스)이 * 공정별 소요·변동·수율을 말해 줘야 예측이 그 현장의 예측이 된다. 주지 않으면 커널 기본값으로 * 굴러가고, **기본값을 썼다는 사실은 커널 `specCoverage()` 가 밝힌다**(알리지 않고 넘어가지 않는다). */ export interface RefOperation { /** 작업 종류 키 — 커널 `FlowTask.kind` 와 같은 값이어야 명세가 그 작업에 붙는다. */ key: string label: string intent: 'transport' | 'process' | 'dwell' locationType?: string resourceType?: string bizStep?: string /** ISA-95 `OperationsSegment.Duration` — ISO 8601 기간(`PT12M`). */ duration?: string variability?: RefOperationVariability parameters?: RefOperationParameter[] /** * 필요 인원 — ISA-95 `PersonnelSpecification`(`PersonnelClassID` + `Quantity`) 1:1. * 없으면 사람 없이 도는 공정이다(설비만 잡는다). */ personnelSpecification?: { personnelClass?: string; quantity: number }[] /** 필요 물리 자산 — ISA-95 `PhysicalAssetSpecification`(등급 + 수량). 빈 팔레트가 없으면 못 나간다. */ physicalAssetSpecification?: { assetClass?: string; quantity: number }[] /** * 필요·산출 자재 — ISA-95 `MaterialSpecification`. **4대 자원 중 자재만 이 자리가 없었다** — * 그래서 부품이 떨어져 라인이 서는 상황이 예측에 나타나지 않았다. * BOM 의 "몇 개" 는 여기다(품목 정의의 조립 구조에는 수량이 없다 — 1차 출처 확인). */ materialSpecification?: RefOpMaterialSpecification[] } /** * 사람 — **ISA-95 `Person`.** 설비와 별개 자원이다(고장·설비효율이 아니라 등급·교대로 산다). * `personnelClass` 가 ISA-95 `PersonnelClassID` — 공정은 특정인이 아니라 **등급으로** 인원을 요구한다. */ export interface RefPerson { id: string name?: string /** 시험 결과 — 자격이 성립하는지는 이것이 말한다(§RefTestResult). */ testResults?: RefTestResult[] /** * **이 사람의 전역 정체성** — 커널 선언의 `personnel[].identity` 로 간다. * * ── 왜 필요한가 (인티그레이션 레인이 물음, 2026-09-05) ───────────────────── * 설비에서 이 자리를 만든 이유가 사람에게도 그대로 걸린다: **지역 이름은 그 현장 안에서만 * 고유하다.** 인버터 `001` 이 두 발전소에 있어서 함께 집계하니 한쪽 발전량이 사라졌다. * `E-1001` 도 다른 공장에 있을 수 있고, 여러 현장을 함께 보는 순간 같은 일이 난다. * * ── **여기에 개인정보를 넣지 않는다** ────────────────────────────────────── * 설비와 사람이 갈리는 자리가 이것이다. 설비의 정체성은 하드웨어 일련번호이고 아무에게도 해가 * 없다. 사람의 「전역에서 고유한 값」은 사원번호·출입카드·주민등록처럼 **그 사람에 관한 사실** * 이고, 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송된다 — 한 번 들어가면 지워지지 않는다. * * 그래서 이 칸이 요구하는 것은 **현장을 앞에 붙여 전역에서 고유해진 이름**이다. 그 이상이 아니다. * * ``` * 맞다 urn:hatiolab:mes-line-a:obj:E-1001 (§`objectUri` 가 만든다) * https://hatiotrailer.com/twin/obj/E-1001 * 아니다 카드번호 · 사원번호 원문 · 주민등록번호 · 생체 식별자 * ``` * * 「이 사람이 누구인가」는 인사 시스템이 답할 물음이고 트윈의 물음이 아니다. 트윈은 **같은 * 사람인지 다른 사람인지**만 알면 된다 — 그 일에 개인정보는 필요 없다. * * 값이 없으면 비운다. 지금 원본이 그런 값을 주지 않으므로 당분간 비어 있는 것이 정상이다. */ identity?: string /** * 속한 등급들 — **표준 `Person.PersonnelClassID`, `maxOccurs="unbounded"`**. * 복수인 것이 표준이고 그것이 자격 표현이다: 한 사람이 용접 자격과 지게차 자격을 함께 갖는다. */ personnelClassIds?: string[] /** 교대 — 설비와 같은 모양. 미지정=24시간 가용. */ window?: { startHour: number; endHour: number } /** 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 2교대·휴게·휴일을 함께 표현한다. */ workCalendar?: WorkCalendarEntry[] /** * 기본 위치 — 표준 `Person.OperationalLocation`. 없으면 "이 라인에 몇 명 있나" 를 물을 수 없다. * 사람은 움직이므로 이 값은 **출발점**이고, 실제 위치는 관측(person.status)이 갱신한다. */ homeLocationId?: string /** * 자원 속성 — 표준 `PersonProperty`. **자격증·숙련도가 여기 들어간다.** * * 표준에는 자격(Qualification)이라는 별도 요소가 없다(`B2MML-Personnel.xsd` 확인). 자격은 * ① 등급 복수 소속으로(`personnelClassIds: ['welder-certified','forklift-licensed']`) ② * 증빙은 이 속성으로(자격증 번호·만료일), ③ 검증 시험은 표준 `TestSpecificationID`(아직 미구현). */ properties?: RefResourceProperty[] /** 자격을 검증한 시험 명세들 — 표준 `TestSpecificationID`(참조만; 시험 명세·결과 모델은 아직 없다). */ testSpecificationIds?: string[] /** 유효 기간 — 사람에게는 **입사 전·퇴사 후**다(자격 만료는 등급 쪽 기간이다: 사람은 남고 자격만 끊긴다). */ effectiveStart?: string effectiveEnd?: string } /** * 물리 자산(반복사용) — **ISA-95 `PhysicalAsset`, GS1 `GRAI`.** * 팔레트·랙·용기처럼 **나갔다 돌아오는** 것. 물류단위(SSCC)와 다르다: SSCC 는 그 팔레트에 실린 화물 * 한 덩어리이고, GRAI 는 팔레트 자체다(오늘은 이 화물, 내일은 저 화물을 싣는다). */ export interface RefAsset { id: string name?: string /** 시험 결과 — 자격이 성립하는지는 이것이 말한다(§RefTestResult). */ testResults?: RefTestResult[] /** * 속한 등급들 — 표준 `PhysicalAssetClassID`(복수). 팔레트가 'euro-pallet' 이면서 'food-grade' 일 수 있다. * 공정의 요구는 등급 하나 + 수량이다(표준 `PhysicalAssetSpecification`). */ assetClassIds?: string[] /** 기본 위치. */ homeLocationId?: string /** * **이 자산의 전역 정체성** — 설비와 같은 이름이다(§`RefEquipment.identity`). * * ── 왜 이름을 바꿨나 (2026-09-04) ─────────────────────────────────────── * `gs1Id` 였다. 설비에서 그 이름을 바꾼 이유가 여기에도 그대로 적용된다 — **GS1 키가 아닌 것이 * 정상이다.** 자산의 실측 값도 제조번호·사내 관리번호이고, GS1 을 요구하는 이름을 두면 그것을 * 채우는 쪽이 없는 키를 지어내거나 칸을 비워 둔다. * * 그리고 마스터는 이미 `identity` 로 낸다(설비·자산 둘 다). 이 자리만 다른 이름이라 **원본이 * 채우는 순간 조용히 사라졌다.** 지금 자산 26대가 다 비어 있어 버려지는 값은 없었다 — * 인티그레이션 레인이 그것을 재서 「누가 채우는 순간」이라고 짚었다. * * 자리 쪽(`RefLocation.gs1Id`)은 그대로다 — 거기는 실제로 SGLN 이 온다. */ identity?: string /** 자원 속성 — 표준 `PhysicalAssetProperty`(적재 한도·규격 등). */ properties?: RefResourceProperty[] /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`. */ testSpecificationIds?: string[] /** 유효 기간 — 자산에서는 **폐기한 팔레트가 풀에서 빠지는 것**이다(회수 대상 수가 부풀지 않게). */ effectiveStart?: string effectiveEnd?: string } /** * 품목 정의 — **ISA-95 `MaterialDefinition`.** * * 없으면 로트만 관측으로 들어오고 **품목이 무엇인지는 아무 데도 없다.** 그래서 같은 로트를 100개로 * 받아도 "몇 kg 인가" 에 답할 수 없다(커널 `quantityIn` 이 환산을 거부하는 근거). * * **표준에 단위 환산 요소는 없다**(1차 출처 전수 확인) — 속성 주머니뿐이다. 그래서 환산 계수는 * 커널이 정한 속성 이름(`perBaseUnit`, `uom` 이 대상 단위)으로 `properties` 에 싣는다. */ export interface RefMaterialDefinition { /** 표준 `ID` — GTIN(`urn:epc:idpat:sgtin:…`)을 쓴다(정체성의 정본이 EPCIS 다). */ id: string name?: string /** 속한 등급들 — 표준 `MaterialClassID`(복수). */ materialClassIds?: string[] /** 품목 속성 — 표준 `MaterialDefinitionProperty`. 환산 계수가 여기 들어간다. */ properties?: RefResourceProperty[] testSpecificationIds?: string[] effectiveStart?: string effectiveEnd?: string } /** * **이 마스터를 다시 만들 수 있는 방법** — 트윈이 자기 원본을 기억하는 자리. * * 트윈은 원본을 비출 뿐이므로(ADR-0028), 원본이 달라지면 **다시 읽어야** 합니다. 그런데 지금까지 * 트윈은 "내가 무엇에서 나왔나" 를 남기지 않았다 — 어댑터로 들어온 트윈은 `TwinReference` 행이 * 있었지만 템플릿으로 세운 트윈에는 아무것도 없었고, 그래서 **다시 읽을 대상이 없는 트윈**이 * 만들어졌다. 원본을 잃은 트윈은 첫날의 모습에 영원히 머문다. * * 그래서 원천이 무엇이든 여기 한 자리에 남긴다. 다시 읽기는 이 값 하나만 보면 된다. */ export type MasterOrigin = /** * 등록된 레퍼런스(어댑터)에서 왔다 — 다시 읽기 = 그 어댑터로 마스터를 다시 가져온다. * * `source` 는 **레퍼런스 등록 이름**이고 그 참조가 보고하는 사이트 전부에 같다. 그래서 어느 사이트에서 * 온 트윈인지는 `siteId` 가 있어야 안다 — 없으면 다시 읽기가 사이트를 찾지 못한다(2026-08-18 실측: * 사이트가 여러 개인 참조에서 온 트윈은 재동기가 전부 거절됐다. 참조 이름을 사이트별 마스터의 * `source`(=인스턴스 id)와 비교하고 있었다 — 서로 다른 것을 비교한 것이다). */ | { kind: 'reference'; source: string; siteId?: string; spaceId?: string } /** * 템플릿 + 노브로 합성했다 — 다시 읽기 = 같은 노브로 다시 짓는다(합성은 결정적이다). * * `spaceId` 는 **만들 때 고른 현장**이다(합쳐 넣기). 기억하지 않으면 다시 읽을 때 트윈이 알리지 않고 자기 * 현장을 떠난다 — 템플릿이 낸 기본 공간으로 돌아가기 때문이다. 그러면 그 현장의 값(요금 등)도 함께 * 잃는다(2026-08-18 실측: 한 현장에 합쳐 둔 EMS 트윈 여덟이 재동기 뒤 각자 공간으로 흩어졌다). */ | { kind: 'template'; templateId: string; params: Record; spaceId?: string } export interface ReferenceMaster { source: string /** * 어느 종류의 트윈인가 — **커널 카탈로그가 정본**이다(`DomainSystem`). * * 예전에는 `'wms' | 'yms' | 'mes'` 를 여기 적어 뒀다. 커널에 종류가 하나 늘 때(에너지) 이 줄이 * 함께 움직여야 하는데, 손으로 적힌 목록은 그것을 알려 주지 않는다 — 어휘의 집은 한 곳이어야 한다. */ system: DomainSystem siteName: string /** 검사 명세 — 자원·등급의 `testSpecificationIds` 가 가리키는 대상. */ testSpecifications?: RefTestSpecification[] /** * 이 마스터가 어디서 나왔나 — **다시 읽기의 유일한 근거.** 없으면 그 트윈은 다시 읽을 수 없고, * 조회 화면이 그 사실을 그대로 말한다(없는 것을 있는 척하지 않는다). */ origin?: MasterOrigin /* spaceId 명시 시 그 공간을 참조(여러 트윈이 한 공간 공유, N:1). 없으면 source 에서 파생(1:1). */ /** * 현장(공간). **시각 기준(`timezone`)이 여기 있는 이유**: 한 테넌트가 Rosarito(태평양)와 한국 * 공장을 함께 가질 수 있다. 테넌트 단위(`Domain.timezone`)로 두면 **둘 중 하나는 반드시 틀린다.** * 시각 기준은 **물리 현장의 성질**이므로 공간이 그것을 갖는다(공간 중심 원칙). * * `timezone` 은 IANA 이름(`'America/Tijuana'`) — 오프셋 숫자가 아니다. 숫자로 두면 일광절약시간을 * 표현할 수 없고, 나중에 절대 구간으로 옮길 때도 이름이 있어야 계산할 수 있다. */ space: { spaceId?: string; width: number; depth: number; unit?: string; timezone?: string; geo?: { lat: number; lon: number; rotation?: number }; representations?: any[]; primaryId?: string } locations: RefLocation[] equipment?: RefEquipment[] /** 공정 명세(소요·변동·수율) — 없으면 커널 기본값으로 굴러간다(그 사실은 specCoverage 가 보고). */ operations?: RefOperation[] /** * **생산 정의**(레시피·라우트·자재 바인딩) — 커널의 정의-구동 MES 모드 입구. * * 없으면 커널은 레거시(하드코딩) 흐름으로 동작한다. 즉 **현장 레시피를 적어도 이 자리에 실리지 않으면 * 커널은 그것을 보지 못한다** — 선언과 실행이 어긋나는 지점이라 자리를 연다. */ /** * 생산 선언 — `TwinModelDef.productionSpec` 으로 통과한다(ISA-95 OperationsSegment + BOM). * * 예전 이름은 `mesSpec` 이었다. 담긴 것은 MES 만의 것이 아니라 "자재를 소비해 자재를 산출하는 * 공정" 이고, 이름이 MES 였기 때문에 창고 트윈은 이 자리를 쓸 생각조차 하지 못했다(유통가공 창고가 * BOM 을 설명 문서에만 적어 둔 채 키트를 못 만드는 트윈을 만든 원인). 근거는 커널 계약 주석에. */ productionSpec?: unknown /** * **이 원본이 식별자를 만드는 이름공간** — `TwinModelDef.identity` 로 통과한다. * * **선언이 없으면 커널이 거절한다** (0.7.45~). 예전에는 상수 프리픽스로 팔레트·트레일러·문서를 * 만들었는데(`0614141` — GS1 이 규격 예제로 쓰다 버린 값이고 `06…` 은 실 배정 대역이다), 그 값은 * 저널에 영원히 남으면서 지어낸 것이라고 말해 주는 데가 없었다. 그래서 커널이 만들지 않는다. * * 원본이 자기 도메인을 말하면 그 아래에서 만든다 — 그것이 표준 정합이다(CBV 2.0 §8.2.4·§8.5.5, * 「그 인터넷 도메인 소유자가 배정한다」). GS1 키를 쓰려면 `documentTypes` + `companyPrefix` 이고, * 그때 **자리 수가 맞아야 한다**(SGTIN 은 프리픽스 + 품목참조 = 13자리, GS1 TDS). * * 정체성은 생산과 무관하므로 `productionSpec` 이 아니라 **모델**이 갖는다 — 창고·야드는 아무것도 * 생산하지 않으면서 팔레트·트레일러를 식별한다. */ identity?: unknown /** * 사람 — 선언하지 않으면 **인원 제약이 없는 트윈**이 된다(설비만 있으면 언제나 가동되는 공장). * 공정이 인원을 요구하는데 사람이 없으면 그 작업은 시작되지 않는다 — 그것이 사실이므로 그대로 둔다. */ persons?: RefPerson[] /** * 물리 자산(반복사용) — 선언하지 않으면 자산 제약이 없는 트윈이다. * 공정이 자산을 요구하는데 없으면 그 작업은 시작되지 않는다 — 그것이 사실이므로 그대로 둔다. */ assets?: RefAsset[] /** * 등급 정의 — 표준 `PersonnelClass`·`EquipmentClass`·`PhysicalAssetClass`. * 안 실어도 트윈은 동작한다(소속 문자열 그대로 판정). 실으면 상속·유효기간이 살아난다. */ personnelClasses?: RefResourceClass[] equipmentClasses?: RefResourceClass[] assetClasses?: RefResourceClass[] /** 품목 정의·등급 — 표준 `MaterialDefinition` / `MaterialClass`. 없으면 단위 환산을 할 수 없다. */ materialDefinitions?: RefMaterialDefinition[] materialClasses?: RefResourceClass[] /** * 커넥터가 **옮기는 동안 알게 된 것.** 외부 시스템의 값을 우리 어휘로 못 옮겨 기본값을 넣었다면 * 여기에 담아 보낸다 — 그러면 `masterToTwin` 의 경고와 한 줄기로 합쳐져 화면까지 간다. * * 이 자리가 없던 동안 커넥터는 할 말이 있어도 **말할 곳이 없었다.** 그래서 도크를 랙으로 바꿔 * 놓고도 알리지 않고 성공했다. 옮기는 사람만 아는 사실은 옮기는 자리에서 실어야 한다. */ warnings?: IngestWarning[] /** * **원본이 가진 수** — 옮긴 수의 분모다. * * 총평은 도착한 모델만 볼 수 있어서 「레시피 1,261」까지만 말할 수 있었다. 그런데 사람이 묻는 것은 * 「그게 전부인가」다 — 원본에 1,408개가 있었고 147개를 옮기지 못했다면 그 사실이 총평의 첫 줄에 * 있어야 한다. 실제로 사용자가 품목 2,789 와 자재 1,789 의 차이를 화면에서 읽어 내지 못해 직접 * 물었다(2026-08-22). * * 분모를 아는 것은 **커넥터뿐이다.** 그래서 커넥터가 실어 보낸다. 보내지 않으면 총평은 분모 없이 * 옮긴 수만 말한다 — **지어내지 않는다.** * * 열쇠는 총평이 세는 이름과 같다(`recipes`·`materials`·`totalInputs`·…). 값은 **원본을 끝까지 읽어서 * 센 것**이어야 한다. 잘린 읽기의 수를 여기 담으면 「전부 옮겼다」는 거짓이 된다. */ originCounts?: Record } export interface IngestedTwin { model: any // TwinModelDef(+layout+spaceId) — 커널 소비 spaceContent: any // TwinSpace content — 뷰 소비 warnings: IngestWarning[] } /** * 마스터 → 트윈 구조 매핑(순수). 노드타입을 카탈로그로 검증(모르는 타입은 warning, 버리진 않음 — 반영 우선). * @param catalog DOMAIN_CATALOG (커널 SSOT). 없으면 검증 생략. */ /** * IANA 시간대 이름 → **그 시각의** UTC 오프셋(분). 의존성 없이 `Intl` 로 푼다. * * `Intl.DateTimeFormat` 의 `longOffset` 은 `"GMT-07:00"` 처럼 나오고, 이것은 **일광절약시간을 반영한** * 값이다. 그래서 8월의 `America/Tijuana` 는 −420(PDT), 1월은 −480(PST)이 된다. * * **한계를 밝힌다**: 여기서 나오는 것은 **한 시점의** 오프셋이다. 커널은 고정 오프셋으로 하루 중 분을 * 읽으므로, 모델을 만든 계절과 다른 계절을 길게 예측하면 한 시간 어긋난다. 표준에 충실한 끝 모습은 * 호스트가 절대 구간(`StartDateTime`/`FinishDateTime`)을 계산해 넣는 것이고, 그때 이 함수는 사라진다. * * 모르는 이름에는 **0 을 답하지 않는다**(`undefined`) — 0 은 "UTC 라고 선언했다" 는 뜻이고, 모르는 것을 * 그렇게 바꾸면 판정이 오류 없이 틀린 값을 낸다. */ export function utcOffsetOf(timezone?: string, at: Date = new Date()): number | undefined { if (!timezone) return undefined try { const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset' }).formatToParts(at) const name = parts.find(p => p.type === 'timeZoneName')?.value ?? '' if (name === 'GMT') return 0 // UTC 계열은 오프셋 표기가 없다 const m = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(name) if (!m) return undefined const sign = m[1] === '-' ? -1 : 1 return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) } catch { return undefined // 알 수 없는 이름 — Intl 이 오류를 낸다 } } /* ── 인제스트 경고 ────────────────────────────────────────────────────────── * 인제스트는 구조에 문제가 있어도 트윈을 만들어 준다(막지 않는 것이 옳다 — 부분적으로라도 세우고 * 고쳐 나가는 것이 이 시스템의 방식이다). 그래서 **경고가 유일한 신호**인데, 그 경고를 화면이 하나도 * 보여주지 않고 있었다: 유통가공 창고 템플릿은 커널이 모르는 자리 타입(`vas-station`)으로 트윈을 * 만들면서 경고를 냈지만, 위자드는 그것을 버렸고 사용자는 아무 말 없이 막히는 트윈을 받았다. * * 문장이 아니라 코드로 실어 보낸다. 예전 경고는 프로즈였고 그중 하나에는 한글이 섞여 있었다 * (`— 카탈로그에 없음`) — 다섯 언어 화면에 그대로 내보내면 이번에 걷어낸 문제를 다시 만든다. * 이 프로젝트가 이미 쓰는 규약과 같은 모양이다: 주의 신호(kind+params)·커맨드 거절(errorCode+params)· * 만만함 판정(code+영어 canonical message). * * code 언어 중립 — 화면이 `twin.ingest.` 로 사람 말을 만든다 * params 문장에 끼울 값 — 번역이 언어마다 어순을 바꿀 수 있게 * message 영어 canonical — 로그에 남고, 번역이 없을 때 그대로 보인다(빈칸보다 낫다) * * **여기 사는 이유**: 이 파일이 경고의 생산자이고, 테스트가 소스에서 직접 읽는 모듈이라 * 런타임 상대 import 를 가질 수 없다(이 저장소의 규약 — 그런 모듈은 자기완결이다). * ────────────────────────────────────────────────────────────────────────── */ /** 인제스트가 알리는 한 가지. */ export interface IngestWarning { /** 언어 중립 코드. 화면은 `twin.ingest.` 로 렌더한다. */ code: string /** 문장에 끼울 값. 번역이 어순을 바꿀 수 있게 이름으로 넘긴다. */ params?: Record /** 영어 canonical 문장 — 로그·폴백용. 번역이 없어도 읽을 수 있어야 한다. */ message: string } /** * **시각을 모르는 채 시각으로 판정하려 한다** — 발전 형상은 하루의 시각을 읽는데 이 트윈의 공간이 * 시간대를 말하지 않았다. * * ── 왜 경고인가 (2026-08-18) ──────────────────────────────────────────────── * 커널은 시간대를 모르면 UTC 로 읽는다(계약에 밝혀 둔 규율 — 모르는 값을 지어내지 않는다). 그런데 발전 * 형상은 **시각별 비율**이라, 시간대가 없으면 현장 정오가 아니라 UTC 정오에 피크가 선다(KST 현장이면 * 밤 9시). 값이 그럴듯하게 나오므로 아무도 알아채지 못한다. * * 그래서 시간대를 **지어내지 않고**(서버 로컬이나 임의 기본값을 넣으면 그 트윈의 모든 시각 판정이 알리지 않고 * 그 가정 위에 선다) 대신 **그 조합을 말한다** — 공간에 시간대를 선언하면 사라지는 경고다. */ export const generationWithoutTimeZone = (spaceId: string, equipmentId: string): IngestWarning => ({ code: 'generation-without-time-zone', params: { spaceId, equipmentId }, message: `"${equipmentId}" declares a daily generation shape but space "${spaceId}" declares no time zone — the hourly fractions will be read in UTC, ` + 'so the generation peak will not land at local noon. Declare the space time zone (we do not guess one).' }) /** * 아무도 선언하지 않은 자리 타입 — 그 자리는 흐름에 참여하지 못할 수 있다. * * **묻는 상대를 고쳤다 (2026-08-20).** 예전에는 내장 카탈로그에게만 물었다. 그런데 원본이 자기 * `DomainDefinition.locationTypes` 를 함께 보내면 **그 선언이 정본**이다 — 카탈로그는 우리가 아는 * 도메인들의 기본 어휘일 뿐이다. 물을 상대가 틀렸으니, 자기 어휘를 선언한 원본은 정상적인 타입마다 * 경고를 받았다(F&B 라인을 붙이면 자리마다 하나씩). 소음이 되면 사람은 경고를 읽지 않게 되고, 그때 * 진짜 경고도 함께 죽는다. */ export const unknownLocationType = (system: string, type: string, id: string): IngestWarning => ({ code: 'unknown-location-type', params: { system, type, id }, /* * ── 무슨 일이 벌어지는지 말한다 (2026-08-25) ──────────────────────────────── * 예전 문장은 「흐름에 참여하지 못할 수 있다」였다. 물건이 흐르는 도메인에서는 맞는 말인데, * 에너지 트윈에는 흐름이 없어서 그 문장이 아무것도 알려 주지 않았다(실제로 태양광 발전소를 * 붙이면서 이 경고를 받은 사람이 「이 경고가 뜻이 있는가」를 되물었다). * * 종류를 모르면 실제로 일어나는 일은 하나다: **그 자리에 능력이 붙지 않는다.** 능력이 없으면 * 상태 필드도 없고, 롤업에도 들어가지 않고, 저작면 팔레트에도 서지 않는다. 도메인을 가리지 않고 * 참인 문장이므로 이것을 적는다. */ message: `"${type}" is declared nowhere for ${system} (${id}) — neither in the master's own locationTypes nor in the built-in ` + `catalog. A location whose type is unknown gets no capabilities, so it carries no state, joins no rollup, ` + `and (in domains that move things) may be left out of the flow.` }) /** * 원본이 함께 보낸 자리 타입 선언 — 있으면 **이것이 정본**이다. * * 커널 계약의 `DomainDefinition.locationTypes` 는 `{ key, label, … }` 목록이다. 읽을 수 없는 모양이면 * `undefined` 를 돌려 **카탈로그로 떨어진다** — 빈 배열을 돌려주면 「아무 타입도 선언되지 않았다」가 * 되어 모든 자리가 경고를 받는다(없는 것과 못 읽은 것은 다르다). */ export function declaredLocationTypes(productionSpec: unknown): string[] | undefined { const list = (productionSpec as any)?.definition?.locationTypes if (!Array.isArray(list) || !list.length) return undefined const keys = list.map((t: any) => (typeof t === 'string' ? t : t?.key)).filter((k: any) => typeof k === 'string' && k) return keys.length ? keys : undefined } /** 시간대 문자열을 못 읽었다 — 시각 해석이 UTC 로 떨어진다(알리지 않고 현지 시각으로 가정하지 않는다). */ export const unknownTimezone = (timezone: string): IngestWarning => ({ code: 'unknown-timezone', params: { timezone }, message: `space timezone "${timezone}" is not a known IANA zone — the twin will read times as UTC` }) /** 작업 정의에 소요시간·파라미터가 없다 — 커널 기본값으로 동작한다. */ export const operationWithoutDuration = (operation: string): IngestWarning => ({ code: 'operation-without-duration', params: { operation }, message: `operation "${operation}" has no duration or parameters — kernel defaults will be used` }) /** 볼 수 있는 표현이 없다 — 현장이 화면에 뜨지 않는다. */ export const noViewableRepresentation = (): IngestWarning => ({ code: 'no-viewable-representation', message: 'the space has no viewable representation (no representations and no geo) — add one, or provide geo/board' }) /** * 구조 투영에서 **가리키는 대상을 못 찾았다.** * * 예전에는 이런 참조가 model JSON 안의 맨 문자열이라 화면만 경고할 수 있었고, 다른 경로로 들어온 * 데이터는 아무도 안 봤다. 이제 행으로 풀면서 **인제스트가 직접 안다** — 그러니 인제스트가 말한다. * 막지는 않는다(트윈은 model 로 이미 동작한다). 다만 알리지 않고 넘기지 않는다. */ export const unresolvedReference = (field: string, ref: string): IngestWarning => ({ code: 'unresolved-reference', params: { field, ref }, message: `${field} points at something that is not in this twin (${ref}) — the link was left empty` }) /** * 구조 투영 자체가 실패했다 — 조회는 못 하지만 **트윈은 동작한다**(커널은 model 를 이미 받았다). * 행은 원본에서 다시 그릴 수 있으므로 인제스트를 실패로 만들지 않되, 침묵하지도 않는다. */ export const projectionFailed = (reason: string): IngestWarning => ({ code: 'projection-failed', params: { reason }, message: `structure projection failed (${reason}) — the twin runs, but model inspection will be stale until the next ingest` }) /** * **동작 중에 공장을 전환했다** — 미러는 멈추지 않으므로, 무엇이 사라졌는지 여기서 말한다. * * 멈췄다 세우는 구조 전환이라면 사용자가 "다시 세웠다" 는 것을 안다. 미러는 그 마디가 없어서 * 자리 하나·설비 한 대가 알리지 않고 없어질 수 있다 — 자리가 수천인 현장에서 화면의 수를 세어 * 눈치채기를 기대할 수는 없다. 늘어난 것도 함께 말한다(무엇이 반영되었나가 곧 인제스트의 답이다). */ export const structureAdopted = (rev: number, shift: { locationsAdded: number; locationsDropped: number; equipmentDropped: number; personsDropped: number; assetsDropped: number }): IngestWarning => ({ code: 'structure-adopted', params: { rev, ...shift }, message: `the running mirror adopted a new structure (revision ${rev}) — ` + `+${shift.locationsAdded} location(s), -${shift.locationsDropped} location(s), ` + `-${shift.equipmentDropped} equipment, -${shift.personsDropped} person(s), -${shift.assetsDropped} asset(s)` }) /** * 커넥터가 외부 시스템의 값을 **우리 어휘로 옮기지 못해 기본값을 넣었다.** * * 예: Oracle WMS 가 우리 표에 없는 `locn_type` 을 주면 그 자리는 `storage` 가 된다 — 도크가 랙으로 * 모델링되는 것이다. 인제스트를 통째로 실패시킬 일은 아니지만(로케이션 하나 때문에 현장 전체가 * 안 들어오는 편이 더 나쁘다), **알리지 않고 넘어가면 안 된다.** 트윈이 실물과 다르게 생겼는데 * 아무도 그 사실을 모른 채 그 위에서 흐름을 논하게 된다. * * 값을 나열하는 이유: "3건" 만으로는 커넥터 매핑에 무엇을 더해야 하는지 알 수 없다. */ export const connectorMappingFallback = (field: string, fallback: string, values: readonly string[], count: number): IngestWarning => ({ code: 'connector-mapping-fallback', params: { field, fallback, values: values.join(', '), count }, message: `${count} ${field} value(s) had no mapping and fell back to "${fallback}" (${values.join(', ')}) — the twin's shape differs from the source system here` }) /** 로그 한 줄 — 개발자가 읽는 것이므로 영어 canonical 문장을 쓴다. */ export const describeWarnings = (warnings: readonly IngestWarning[]): string => warnings.map(w => `${w.code}: ${w.message}`).join(' | ') /** * 서버가 기본값으로 채운 노브들 — 사용자가 준 적 없는 값으로 트윈이 만들어졌음을 알린다. * * 알리지 않고 채우면 나중에 "왜 이 수치인가" 를 아무도 답할 수 없다. 막을 일은 아니다(기본값은 템플릿이 * 확신을 갖고 선언한 값이다) — 다만 밝힌다. */ export const knobDefaultsFilled = (keys: readonly string[]): IngestWarning => ({ code: 'knob-defaults-filled', params: { keys: keys.join(', '), count: keys.length }, message: `values you did not provide were filled from the template defaults: ${keys.join(', ')}` }) /** * 이 현장의 설비를 노브 값으로 채웠다 — **알리지 않고 채우지 않는다.** * * 에너지 트윈의 부하를 이웃 설비 id 로 선언할 때 쓴다. 몇 개를 가져왔는지 말해 주지 않으면, 사용자는 * 자기가 고르지 않은 설비들이 부하로 잡힌 것을 나중에 화면에서야 알게 된다. */ export const siteEquipmentAdopted = (key: string, count: number): IngestWarning => ({ code: 'site-equipment-adopted', params: { key, count }, message: `${count} piece(s) of equipment already on this site were adopted as loads (knob "${key}") — set their coefficients before trusting the numbers` }) export function masterToTwin(master: ReferenceMaster, catalog?: any): IngestedTwin { /* 커넥터가 옮기며 알게 된 것을 먼저 싣는다 — 원인이 앞에 오는 편이 읽기 쉽다(대체가 먼저, 그 결과가 뒤). */ const warnings: IngestWarning[] = [...(master.warnings ?? [])] /* * 원본이 자기 어휘를 선언했으면 **그것에 대조한다.** 내장 카탈로그는 그 선언이 없을 때의 기본이다 — * 상대를 바꾸지 않으면, 자기 어휘를 선언한 원본이 정상적인 자리마다 경고를 받는다. */ const known: string[] = declaredLocationTypes(master.productionSpec) ?? catalog?.[master.system]?.locationTypes ?? [] /* * **렌즈는 자기 영역의 자리만 세운다**(ADR-0081 보탬). 다른 영역의 자리는 참조로만 들고 종류를 검사하지 않는다 — 창고 트윈이 * plant 의 조립 라인 자리를 「종류를 모르는 자리」로 경고하던 것이 이 줄이다. 구역(`area`)은 현장이 함께 쓰므로 가르지 않는다. */ const lens = partitionLensLocations( master.locations.filter(l => l.role === 'location' || l.role === 'both'), master.system ) const locationEntries = lens.own const referencedLocations = lens.referenced const areaLocs = master.locations.filter(l => l.role === 'area' || l.role === 'both') const locations = locationEntries.map(l => { if (known.length && !known.includes(l.type)) warnings.push(unknownLocationType(master.system, l.type, l.id)) // gs1Id(정식 정체성)를 모델의 자리로 통과 — 결합(tag)/추적이 GS1 로(§J). 로컬 id 는 그대로 유지(name/xref). /* * **이름을 통과시킨다.** 여기서 버리고 있었다 — 그래서 모델에도, 투영한 행에도 이름이 없고 * 화면은 자리 id 를 그대로 찍었다(`cut-station`). 목록·표·관계 그림에서 같은 결함이 따로 * 보였던 이유가 이 한 줄이다. 이름 없는 원본은 이름 없이 통과한다(id 로 메우지 않는다). */ /* * **속성도 통과시킨다** — 설비와 같은 규율(손대지 않고 나른다). 여기가 없으면 자리에 실은 값이 * 커널에 닿지 않는다: 에너지의 계약전력(`contract.kW`)이 그 경로이고, 통과하지 않으면 커널이 * 계약을 모르는 채로 서서 **판정을 아예 하지 않는다**(오류 없이 알리지 않고). */ return { id: l.id, type: l.type, ...(l.name ? { name: l.name } : {}), ...(l.description ? { description: l.description } : {}), capacity: l.capacity ?? 0, ...(l.parallelism !== undefined ? { parallelism: l.parallelism } : {}), ...(l.level ? { level: l.level } : {}), ...(l.properties?.length ? { properties: l.properties } : {}), parentId: l.parentId, ...(l.upstreamId ? { upstreamId: l.upstreamId } : {}), gs1Id: l.gs1Id } }) /* * ── 설비가 구역에 서 있을 수 있다 (2026-09-06) ────────────────────────────── * 마스터는 자리와 구역을 **한 목록**에 담고 `role` 로 가른다(§`locationEntries` · `areaLocs`). * 그래서 설비의 `homeLocationId` 가 구역을 가리킬 수 있다 — 실제로 `EQ-CAM-SHOWER-01` 이 * `LINE-CAM`(구역)에 서 있고, 그것이 정상이다(에어샤워는 라인 전체에 딸린다). * * 계약이 그 구분을 위해 `homeLocationKind` 를 두었는데(`HomeKind = 'location' | 'area'`) * **아무도 안 채우고 있었다.** 그래서 인제스트가 그 참조를 「끊어졌다」고 경고했다. * * 짐작하지 않는다 — 마스터가 이미 `role` 로 말하고 있으므로 그것을 읽는다. */ const areaIds = new Set(master.locations.filter(l => l.role === 'area').map(l => l.id)) const equipment = (master.equipment ?? []).map(e => ({ id: e.id, kind: e.kind, ...(e.name ? { name: e.name } : {}), ...(e.description ? { description: e.description } : {}), ...(e.testResults?.length ? { testResults: e.testResults } : {}), /* 등급 소속 — 사람·자산과 같은 자리. 빠뜨리면 원본이 채운 등급이 문턱에서 조용히 버려진다. */ ...(e.equipmentClassIds?.length ? { equipmentClassIds: e.equipmentClassIds } : {}), homeLocation: e.homeLocationId, /* 자리면 안 적는다 — 계약의 기본값이 `location` 이고, 없는 칸을 만들면 화면이 그것을 그린다. */ ...(e.homeLocationId && areaIds.has(e.homeLocationId) ? { homeLocationKind: 'area' as const } : {}), mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, ...(e.identity ? { identity: e.identity } : {}), ...(e.window ? { window: e.window } : {}), /* 속성은 손대지 않고 통과 — 이동시간 추정기가 kind 별 속도를 여기서 읽는다(단위 코드 그대로). */ ...(e.properties?.length ? { properties: e.properties } : {}), ...(e.workCalendar?.length ? { workCalendar: e.workCalendar } : {}), ...(e.testSpecificationIds?.length ? { testSpecificationIds: e.testSpecificationIds } : {}), /* 유효 기간도 손대지 않고 통과 — 판정은 커널이 자기 시계로 한다(호스트가 미리 자르지 않는다). */ ...(e.effectiveStart ? { effectiveStart: e.effectiveStart } : {}), ...(e.effectiveEnd ? { effectiveEnd: e.effectiveEnd } : {}) })) // 좌표(layout) — footprint 중심. 없으면 생략(coordinate-free 유지). const layout: Record = {} for (const l of locationEntries) if (l.footprint) layout[l.id] = { x: l.footprint.x + l.footprint.w / 2, y: l.footprint.y + l.footprint.h / 2 } const spaceId = master.space.spaceId ?? `${master.source}-site` /* * 시각 기준을 커널이 읽을 형태로 푼다 — 커널은 zero-dep 이라 시간대 데이터베이스를 들이지 않고 * **분 오프셋**만 받는다(계약 `TwinModelDef.utcOffsetMinutes`). 푸는 일은 호스트의 몫이다. * * 선언이 없으면 **필드를 만들지 않는다** — 그러면 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다. * 여기서 0 을 넣으면 "UTC 라고 선언했다" 와 "선언하지 않았다" 가 구별되지 않는다. */ const utcOffsetMinutes = utcOffsetOf(master.space.timezone) if (master.space.timezone && utcOffsetMinutes === undefined) { warnings.push(unknownTimezone(String(master.space.timezone))) } /* * ── 이름도 함께 싣는다 (2026-09-07) ──────────────────────────────────────── * 위에서 푼 오프셋은 **이 순간의** 값이고, 이 모델은 저장된다. 그래서 그 수는 마스터를 받은 날의 * 계절로 굳는다 — `America/Tijuana`(PST −480 / PDT −420)가 자료에 있고, 지난 구간을 교대별로 접으면 * 그 안의 전환이 사실을 한 시간 밀어 **다른 교대로** 세게 한다. 오류는 나지 않는다. * * 이름이 함께 있으면 소비처가 순간마다 다시 풀 수 있다(§`shiftContext`). 커널은 이 값을 쓰지 않는다. * * 오프셋이 안 풀린 이름은 싣지 않는다 — 그 경우는 위에서 경고로 말했고, 풀리지 않는 이름을 실어 * 두면 소비처가 그것을 믿고 매번 실패한다. */ const timezone = utcOffsetMinutes !== undefined ? master.space.timezone : undefined /* * 오퍼레이션 명세를 model 에 통과시킨다 — 커널이 `loadOperations` 로 소비한다(TwinModelDef 확장 필드, * 모르는 커널은 무시). 여기서 값을 손대지 않는다: 기간은 표준 표기(ISO 8601)로 커널까지 그대로 간다. */ const operations = master.operations if (operations?.length) { for (const o of operations) { if (!o.duration && !o.parameters?.length) warnings.push(operationWithoutDuration(o.key)) } } /* 사람도 손대지 않고 통과 — 등급·교대 그대로 커널까지 간다(경계에서 어휘를 갈지 않는다). */ const persons = master.persons?.map(p => ({ id: p.id, ...(p.name ? { name: p.name } : {}), ...(p.testResults?.length ? { testResults: p.testResults } : {}), personnelClassIds: p.personnelClassIds, ...(p.identity ? { identity: p.identity } : {}), ...(p.window ? { window: p.window } : {}), ...(p.workCalendar?.length ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocationId ? { homeLocation: p.homeLocationId } : {}), ...(p.properties?.length ? { properties: p.properties } : {}), ...(p.testSpecificationIds?.length ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.effectiveStart ? { effectiveStart: p.effectiveStart } : {}), ...(p.effectiveEnd ? { effectiveEnd: p.effectiveEnd } : {}) })) /* 자산도 손대지 않고 통과 — 등급·자리 그대로 커널까지. */ const assets = master.assets?.map(a => ({ id: a.id, ...(a.name ? { name: a.name } : {}), ...(a.testResults?.length ? { testResults: a.testResults } : {}), assetClassIds: a.assetClassIds, homeLocation: a.homeLocationId, ...(a.identity ? { identity: a.identity } : {}), ...(a.properties?.length ? { properties: a.properties } : {}), ...(a.testSpecificationIds?.length ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.effectiveStart ? { effectiveStart: a.effectiveStart } : {}), ...(a.effectiveEnd ? { effectiveEnd: a.effectiveEnd } : {}) })) /* 품목 정의·등급도 손대지 않고 통과 — 경계에서 어휘를 갈지 않는다(환산 계수는 커널이 읽는다). */ const materialDefinitions = master.materialDefinitions?.map(d => ({ id: d.id, /* 이름은 이름 자리에 — 예전에는 `name` 을 `description` 으로 옮겨 담았다(어휘가 뒤집혀 있었다). */ ...(d.name ? { name: d.name } : {}), ...(d.materialClassIds?.length ? { materialClassIds: d.materialClassIds } : {}), ...(d.properties?.length ? { properties: d.properties } : {}), ...(d.testSpecificationIds?.length ? { testSpecificationIds: d.testSpecificationIds } : {}), ...(d.effectiveStart ? { effectiveStart: d.effectiveStart } : {}), ...(d.effectiveEnd ? { effectiveEnd: d.effectiveEnd } : {}) })) /* * **등급의 표시명은 `name` 이다.** * * 표준 마스터는 등급의 표시명을 `description` 에 담는다(`RefResourceClass.description` = i18n 키). * 그것을 그대로 모델에 실으면 화면의 "설명" 열에 이름이 앉는다 — 사용자에게는 이름인데 머리글은 * 설명이라, 같은 것을 축마다 다른 이름으로 부르게 된다. * * 그래서 **문 하나에서 어휘를 맞춘다**: 표시명은 어느 축이든 `name`. 원래의 `description` 은 * 남겨 두지 않는다 — 두 자리에 같은 값이 있으면 어느 것이 정본인지 다시 헷갈린다. */ const asName = (list?: RefResourceClass[]) => list?.map(({ description, ...rest }) => ({ ...rest, ...(description ? { name: description } : {}) })) /* * 검사 명세도 **표시명은 `name`** 이다 — 등급과 같은 규칙을 쓴다(어휘를 하나로). 표준 마스터는 * 표시명을 `description` 에 담으므로 문 하나에서 옮긴다. 그러지 않으면 화면의 "설명" 열에 이름이 * 앉아 축마다 다른 이름으로 같은 것을 부르게 된다(등급에서 이미 겪었다). */ const testSpecifications = master.testSpecifications?.map(({ description, ...rest }) => ({ ...rest, ...(description ? { name: description } : {}) })) const personnelClasses = asName(master.personnelClasses) const equipmentClasses = asName(master.equipmentClasses) const assetClasses = asName(master.assetClasses) const materialClasses = asName(master.materialClasses) const model = { locations, equipment, layout, spaceId, ...(referencedLocations.length ? { referencedLocations } : {}), /* 생산 정의도 손대지 않고 통과 — 경계에서 어휘를 갈지 않는다(커널이 그대로 소비한다). */ ...(master.productionSpec ? { productionSpec: master.productionSpec } : {}), /* 정체성 선언도 그대로 통과한다 — 없으면 만들지 않는다(커널이 레거시 상수로 떨어진다). */ ...(master.identity ? { identity: master.identity } : {}), ...(materialDefinitions?.length ? { materialDefinitions } : {}), ...(materialClasses?.length ? { materialClasses } : {}), ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}), ...(timezone ? { timezone } : {}), ...(operations?.length ? { operations } : {}), ...(testSpecifications?.length ? { testSpecifications } : {}), ...(personnelClasses?.length ? { personnelClasses } : {}), ...(equipmentClasses?.length ? { equipmentClasses } : {}), ...(assetClasses?.length ? { assetClasses } : {}), ...(persons?.length ? { persons } : {}), ...(assets?.length ? { assets } : {}) } const areas = areaLocs.map(l => ({ id: l.id, type: l.type, name: l.name ?? l.id, parentId: l.parentId, ...(l.upstreamId ? { upstreamId: l.upstreamId } : {}), x: l.footprint?.x ?? 0, y: l.footprint?.y ?? 0, w: l.footprint?.w ?? 10, h: l.footprint?.h ?? 10 })) // 인제스트 현장 물질화(space facet #4) — 표현이 없고 geo 가 있으면 기본 map 표현 합성. // "인제스트하면 알아볼 현장(내 place)이 뜬다"는 공간 중심 정수 보장(zero-to-twin): 커넥터/템플릿이 // 표현을 안 줘도 geo 만 있으면 위성 지도로 착지. (area 폴리곤 물질화는 좌표 투영 필요 → space #3.) const geo = master.space.geo const givenReps = master.space.representations ?? [] /* * ── 이름을 지어내지 않는다 (2026-08-26) ──────────────────────────────────── * 여기가 합성한 표현에 `name: '위성 지도'` 를 넣고 있었다. 이 층은 언어 중립이어야 하는데 한국어가 * 그대로 저장됐다. * * 낱말 열쇠(`twin.representation.map` 같은 것)를 넣는 길도 있는데 택하지 않았다. **표현의 이름은 * 사람이 바꿀 수 있는 값**이고, 바꿀 수 있는 값 안에 열쇠가 들어 있으면 사람이 한 번 고치는 순간 * 그 자리만 열쇠가 아니게 되어 규칙이 둘이 된다. * * 그래서 이름을 넣지 않는다. 화면이 종류(`map`)로 부르고, 사람이 붙이면 그때부터 그 이름이다. */ const representations = givenReps.length > 0 ? givenReps : geo ? [{ id: 'r-map', type: 'map', primary: true, center: { lat: geo.lat, lon: geo.lon }, zoom: 17 }] : [] if (representations.length === 0) { warnings.push(noViewableRepresentation()) } const spaceContent = { width: master.space.width, depth: master.space.depth, unit: master.space.unit ?? 'm', geo, representations, primaryId: master.space.primaryId ?? representations[0]?.id, areas } return { model, spaceContent, warnings } } /** * 다시 읽을 사이트를 고른다 — **기억해 둔 사이트 하나만**. * * 못 찾으면 **첫 사이트로 때우지 않는다**: 다른 현장의 구조를 이 트윈에 덮어쓰는 일이고, 그것은 오류가 * 아니라 **오류 없이 틀린 공장**으로 나타난다. 기억이 없는 옛 트윈은 `undefined` 를 돌려주고, 부르는 쪽이 * 마스터의 정체(`source` = 인스턴스 id)로 찾는 길을 택한다. */ export function pickOriginSite( sites: readonly T[], origin: { siteId?: string } | undefined | null ): { site?: T; reason?: 'no-remembered-site' | 'site-gone' } { const wanted = String(origin?.siteId ?? '').trim() if (!wanted) return { reason: 'no-remembered-site' } const site = sites.find(s => String(s.siteId) === wanted) return site ? { site } : { reason: 'site-gone' } }