import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/ops-contract'; export interface RefLocation { id: string; type: string; 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; }; gs1Id?: string; } /** * 설비 속성 — **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; 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; /** * 현장(공간). **시각 기준(`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; spaceContent: any; 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 declare function utcOffsetOf(timezone?: string, at?: Date): number | undefined; /** 인제스트가 알리는 한 가지. */ export interface IngestWarning { /** 언어 중립 코드. 화면은 `twin.ingest.` 로 렌더한다. */ code: string; /** 문장에 끼울 값. 번역이 어순을 바꿀 수 있게 이름으로 넘긴다. */ params?: Record; /** 영어 canonical 문장 — 로그·폴백용. 번역이 없어도 읽을 수 있어야 한다. */ message: string; } /** * **시각을 모르는 채 시각으로 판정하려 한다** — 발전 형상은 하루의 시각을 읽는데 이 트윈의 공간이 * 시간대를 말하지 않았다. * * ── 왜 경고인가 (2026-08-18) ──────────────────────────────────────────────── * 커널은 시간대를 모르면 UTC 로 읽는다(계약에 밝혀 둔 규율 — 모르는 값을 지어내지 않는다). 그런데 발전 * 형상은 **시각별 비율**이라, 시간대가 없으면 현장 정오가 아니라 UTC 정오에 피크가 선다(KST 현장이면 * 밤 9시). 값이 그럴듯하게 나오므로 아무도 알아채지 못한다. * * 그래서 시간대를 **지어내지 않고**(서버 로컬이나 임의 기본값을 넣으면 그 트윈의 모든 시각 판정이 알리지 않고 * 그 가정 위에 선다) 대신 **그 조합을 말한다** — 공간에 시간대를 선언하면 사라지는 경고다. */ export declare const generationWithoutTimeZone: (spaceId: string, equipmentId: string) => IngestWarning; /** * 아무도 선언하지 않은 자리 타입 — 그 자리는 흐름에 참여하지 못할 수 있다. * * **묻는 상대를 고쳤다 (2026-08-20).** 예전에는 내장 카탈로그에게만 물었다. 그런데 원본이 자기 * `DomainDefinition.locationTypes` 를 함께 보내면 **그 선언이 정본**이다 — 카탈로그는 우리가 아는 * 도메인들의 기본 어휘일 뿐이다. 물을 상대가 틀렸으니, 자기 어휘를 선언한 원본은 정상적인 타입마다 * 경고를 받았다(F&B 라인을 붙이면 자리마다 하나씩). 소음이 되면 사람은 경고를 읽지 않게 되고, 그때 * 진짜 경고도 함께 죽는다. */ export declare const unknownLocationType: (system: string, type: string, id: string) => IngestWarning; /** * 원본이 함께 보낸 자리 타입 선언 — 있으면 **이것이 정본**이다. * * 커널 계약의 `DomainDefinition.locationTypes` 는 `{ key, label, … }` 목록이다. 읽을 수 없는 모양이면 * `undefined` 를 돌려 **카탈로그로 떨어진다** — 빈 배열을 돌려주면 「아무 타입도 선언되지 않았다」가 * 되어 모든 자리가 경고를 받는다(없는 것과 못 읽은 것은 다르다). */ export declare function declaredLocationTypes(productionSpec: unknown): string[] | undefined; /** 시간대 문자열을 못 읽었다 — 시각 해석이 UTC 로 떨어진다(알리지 않고 현지 시각으로 가정하지 않는다). */ export declare const unknownTimezone: (timezone: string) => IngestWarning; /** 작업 정의에 소요시간·파라미터가 없다 — 커널 기본값으로 동작한다. */ export declare const operationWithoutDuration: (operation: string) => IngestWarning; /** 볼 수 있는 표현이 없다 — 현장이 화면에 뜨지 않는다. */ export declare const noViewableRepresentation: () => IngestWarning; /** * 구조 투영에서 **가리키는 대상을 못 찾았다.** * * 예전에는 이런 참조가 model JSON 안의 맨 문자열이라 화면만 경고할 수 있었고, 다른 경로로 들어온 * 데이터는 아무도 안 봤다. 이제 행으로 풀면서 **인제스트가 직접 안다** — 그러니 인제스트가 말한다. * 막지는 않는다(트윈은 model 로 이미 동작한다). 다만 알리지 않고 넘기지 않는다. */ export declare const unresolvedReference: (field: string, ref: string) => IngestWarning; /** * 구조 투영 자체가 실패했다 — 조회는 못 하지만 **트윈은 동작한다**(커널은 model 를 이미 받았다). * 행은 원본에서 다시 그릴 수 있으므로 인제스트를 실패로 만들지 않되, 침묵하지도 않는다. */ export declare const projectionFailed: (reason: string) => IngestWarning; /** * **동작 중에 공장을 전환했다** — 미러는 멈추지 않으므로, 무엇이 사라졌는지 여기서 말한다. * * 멈췄다 세우는 구조 전환이라면 사용자가 "다시 세웠다" 는 것을 안다. 미러는 그 마디가 없어서 * 자리 하나·설비 한 대가 알리지 않고 없어질 수 있다 — 자리가 수천인 현장에서 화면의 수를 세어 * 눈치채기를 기대할 수는 없다. 늘어난 것도 함께 말한다(무엇이 반영되었나가 곧 인제스트의 답이다). */ export declare const structureAdopted: (rev: number, shift: { locationsAdded: number; locationsDropped: number; equipmentDropped: number; personsDropped: number; assetsDropped: number; }) => IngestWarning; /** * 커넥터가 외부 시스템의 값을 **우리 어휘로 옮기지 못해 기본값을 넣었다.** * * 예: Oracle WMS 가 우리 표에 없는 `locn_type` 을 주면 그 자리는 `storage` 가 된다 — 도크가 랙으로 * 모델링되는 것이다. 인제스트를 통째로 실패시킬 일은 아니지만(로케이션 하나 때문에 현장 전체가 * 안 들어오는 편이 더 나쁘다), **알리지 않고 넘어가면 안 된다.** 트윈이 실물과 다르게 생겼는데 * 아무도 그 사실을 모른 채 그 위에서 흐름을 논하게 된다. * * 값을 나열하는 이유: "3건" 만으로는 커넥터 매핑에 무엇을 더해야 하는지 알 수 없다. */ export declare const connectorMappingFallback: (field: string, fallback: string, values: readonly string[], count: number) => IngestWarning; /** 로그 한 줄 — 개발자가 읽는 것이므로 영어 canonical 문장을 쓴다. */ export declare const describeWarnings: (warnings: readonly IngestWarning[]) => string; /** * 서버가 기본값으로 채운 노브들 — 사용자가 준 적 없는 값으로 트윈이 만들어졌음을 알린다. * * 알리지 않고 채우면 나중에 "왜 이 수치인가" 를 아무도 답할 수 없다. 막을 일은 아니다(기본값은 템플릿이 * 확신을 갖고 선언한 값이다) — 다만 밝힌다. */ export declare const knobDefaultsFilled: (keys: readonly string[]) => IngestWarning; /** * 이 현장의 설비를 노브 값으로 채웠다 — **알리지 않고 채우지 않는다.** * * 에너지 트윈의 부하를 이웃 설비 id 로 선언할 때 쓴다. 몇 개를 가져왔는지 말해 주지 않으면, 사용자는 * 자기가 고르지 않은 설비들이 부하로 잡힌 것을 나중에 화면에서야 알게 된다. */ export declare const siteEquipmentAdopted: (key: string, count: number) => IngestWarning; export declare function masterToTwin(master: ReferenceMaster, catalog?: any): IngestedTwin; /** * 다시 읽을 사이트를 고른다 — **기억해 둔 사이트 하나만**. * * 못 찾으면 **첫 사이트로 때우지 않는다**: 다른 현장의 구조를 이 트윈에 덮어쓰는 일이고, 그것은 오류가 * 아니라 **오류 없이 틀린 공장**으로 나타난다. 기억이 없는 옛 트윈은 `undefined` 를 돌려주고, 부르는 쪽이 * 마스터의 정체(`source` = 인스턴스 id)로 찾는 길을 택한다. */ export declare function pickOriginSite(sites: readonly T[], origin: { siteId?: string; } | undefined | null): { site?: T; reason?: 'no-remembered-site' | 'site-gone'; };