/* * 로컬 선언 — **구조는 원천이, 수는 우리가.** * * ── 낱말부터: `reference` ↔ `local` (2026-08-18 확정) ─────────────────────── * 이 파일이 다루는 축은 **「이 값을 누가 말했나」** 하나다. 답은 둘뿐이다. * * · `reference` — 외부 원천 시스템이 준 값(SCADA·WMS·MES 의 마스터). 우리가 고르지 않았다. * · `local` — **이 트윈 쪽에서 정한 값.** 원천이 모르는 수를 운영 주체가 넣은 것이다. * * 「사람이 넣었나」가 아니라 **「권위가 어디 있나」**가 기준이다 — 앞으로 AI 가 사용자 대리로 채워도 * 여전히 `local` 이다. 표식은 커널 계약의 `PropertySource` 로 선언돼 있고, **표식이 없으면 모델을 그린 * 쪽**(참조에서 그렸으면 원천, 템플릿으로 만들었으면 그 템플릿)이다. * * 장소 낱말을 쓰지 않는 이유(옛 이름 `siteDeclarations` 를 버린 이유): * · `site` — 이 코드에서는 **외부 시스템이 부르는 사업장**이다(SAP 는 `P3`, Oracle 은 `W01` 로 같은 * 현장을 부른다). 여기 담기는 것은 그 시스템이 **모르는** 값이므로 이름이 내용과 정반대다. * · `facility` — 화면에 나가는 영어(현장)이고, 코드에 남은 것은 남의 API 필드뿐이다(Oracle lgfapi). * · `space` — 「어디」에 답하는 낱말이다(트윈이 서는 그릇). 이 축은 「누가」에 답한다. 게다가 공간 * 수준 선언(한 현장의 여러 트윈이 함께 읽는 요금)은 앞으로 정말 필요할 수 있어 비워 둔다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 커넥터로 만든 트윈은 구조를 원 시스템에서 읽어 온다(계통도·계량 지점·설비). 그런데 요금 단가·계약 * 정보·축전지 충전율은 원 시스템이 모른다 — SCADA 게이트웨이는 우리 전기 계약을 알지 못한다. 그래서 그 * 값들을 넣을 문이 없었고, 계량이 흐르는 트윈에서도 금액이 영원히 나오지 않았다(실제로 그 벽에 부딪혔다). * * ── 왜 모델에 바로 쓰지 않고 겹으로 두나 ──────────────────────────────────── * 모델은 **원천의 사본**이다. 다시 읽기(resync)는 그 사본을 새로 그리므로, 모델에 직접 쓴 값은 다음 * 다시 읽기에서 **오류 없이 사라진다** — 요금이 사라지면 금액도 사라지고, 사용자는 이유를 알 수 없다. * 그래서 로컬 선언은 따로 보관하고, 모델을 그릴 때마다 그 위에 얹는다. 덤으로 「누가 말한 값인가」에 * 답할 수 있다 — 이 시스템이 근거를 값과 함께 다니게 하는 규율과 같다(`basis`·`derived` 와 한 결). * * ── 지어낸 속성을 받지 않는다 ─────────────────────────────────────────────── * 커널이 읽는 속성만 받는다. 모르는 id 를 받아 두면 사용자는 값을 넣었다고 믿고 화면은 아무것도 바뀌지 * 않는다 — 그 침묵이 가장 비싼 오류다. 단위·범위도 커널의 명세(`EMS_PROPERTY_SPEC`)로 검사한다: * 「SOC 80%」를 `0.8` 로 넣으면 배터리가 0.8% 로 서고, 그 사실을 아무도 모른 채 피크가 깎이지 않는다. * * **속성 어휘는 사용자가 정의하지 않는다** — 커널이 정한 속성에 값을 넣는 문이다(그래서 이름도 * 「사용자 정의 속성」이 아니다). */ import { ATTENTION_PROPERTY, OPERATION_PROPERTY } from '@operato/twin-kernel' import { EMS_PROPERTY_SPEC, OP_PARAM } from '@operato/ops-contract' import { EQUIPMENT_PROPERTY, SPEED_TO_MPS } from './travel-estimator.ts' import { declarationTargetsOf } from './property-effects.ts' /** 로컬 선언이 겨눌 수 있는 대상 — 자원 둘과 **공정 종류**(자원이 아니라 명세다). */ export type DeclarationTarget = 'location' | 'equipment' | 'operation' /** 한 대상(자리·설비·공정)에 얹을 로컬 선언. */ export interface LocalDeclaration { target: DeclarationTarget /** 대상 id — 모델에 있는 그 이름. */ id: string properties: { id: string value: string | number /** * 단위(UN/CEFACT 코드) — **어떤 값은 단위 없이는 뜻이 없다.** * * 속도가 그렇다: 단위가 없으면 이동 시간 추정기가 그 설비를 **거절한다**(그러면 공정 시간이 알리지 않고 * 사라진다). 명세가 단위를 요구하면(`uomChoices`) 받고, 그 목록에 없는 코드는 거절한다 — 읽는 쪽이 * 모르는 단위를 받아 두면 화면은 넣었다고 말하고 계산은 그 값을 버린다. */ uom?: string /** * 이 선언이 **덮은 원천 값** — 철회하면 이것으로 되돌린다. * * ── 왜 기억해야 하나 (2026-08-18) ──────────────────────────────────────── * 선언은 모델에 얹혀 **저장된 모델에 구워진다.** 그래서 겹에서 줄만 지워도 원래 값이 돌아오지 * 않는다 — 실제로 되돌릴 방법이 없어서, 잘못 넣은 값을 「원래 값을 다시 선언하는」 우회로만 * 고칠 수 있었다(사용자가 원래 값을 알고 있어야 한다는 뜻이다). * * · 문자열 — 원천이 그 자리에 갖고 있던 값이다. * · `null` — 원천에는 그 속성이 **없었다**(철회하면 속성을 지운다). * · 없음 — **우리가 모른다**(이 필드가 생기기 전에 선언된 것). 지어내지 않고 그렇게 말한다. * * 사용자가 넣는 값이 아니라 얹을 때 우리가 채우는 값이다(매 원천 재읽기마다 새로 채워져 낡지 않는다). */ replaced?: string | null }[] } export interface RejectedDeclaration { target: string id: string propertyId: string /** * 왜 받지 않았나 — 언어중립 코드(화면이 옮긴다). * · `unknown-property` — 커널이 읽지 않는 속성 id 다 * · `out-of-range` — 명세가 정한 범위 밖이다(단위를 잘못 읽은 값이 대개 여기 걸린다) * · `not-a-number` — 수를 요구하는 속성에 수가 아닌 값이 왔다 * · `empty-value` — 값이 비어 있다(지우려면 그 뜻을 따로 말해야 한다) * · `no-property-vocabulary` — 이 종류의 트윈에는 선언 어휘가 아직 없다 * · `no-target-id` — 어느 대상에 얹을지 말하지 않았다(선언의 모양이 틀렸다) * · `no-property-id` — 어느 속성인지 말하지 않았다(선언의 모양이 틀렸다) * * 뒤의 둘이 있는 이유: 예전에는 대상 id·속성 id 가 비면 **아무 말 없이 건너뛰었다.** 그래서 필드 이름을 * 틀린 호출이 「받아준 선언이 없다」는 답만 받고 `rejected` 는 비어 있었다 — 무엇이 틀렸는지 알 길이 * 없는 침묵이다(실제로 그 침묵에 걸렸다). */ reason: | 'unknown-property' | 'out-of-range' | 'not-a-number' | 'empty-value' | 'no-property-vocabulary' | 'no-target-id' | 'no-property-id' /** 단위를 요구하는 속성인데 오지 않았다(그 값은 읽는 쪽이 버린다). */ | 'uom-required' /** 읽는 쪽이 모르는 단위 코드다(명세가 받는 목록에 없다). */ | 'unknown-uom' /** * 이 속성은 그 종류의 대상에 얹히지 않는다 — 읽는 코드가 다른 축을 본다. * * 예전에는 지게차에 계약전력을 넣을 수 있었다(문은 받고 화면은 「넣었습니다」라고 말하는데 읽는 * 쪽은 자리만 본다). 어느 축의 속성인지는 효과 등록부가 이미 알고 있으므로 그것으로 거절한다. */ | 'wrong-target' /** * 그 이름의 공정을 이 트윈에서 확인할 수 없다 — 커널이 돌린 적도 없고 명세 행에도 없다. * * 공정 선언은 자리·설비와 달리 **모델에 행이 없어도 성립한다**(창고·야드에는 행이 아예 없다). * 그래서 오타를 잡아 줄 것이 이름 대조뿐이다 — 그마저 없으면 값은 알리지 않고 아무 일도 하지 않는다. */ | 'unknown-operation' /** 무엇을 기대했나 — 사람이 고칠 수 있게(명세의 문장). */ expected?: string } /** 이 종류의 트윈에서 커널이 읽는 선언 명세. 없으면 그 종류에는 선언 어휘가 없다. */ function specOf(kind: string | undefined, propertyId: string): DeclarablePropertySpec | undefined { /* 종류 어휘와 공용 어휘를 함께 본다 — 속도는 어느 트윈에서나 읽히므로 EMS 밖에서도 받아야 한다. */ return declarablePropertySpecs(kind).find(s => s.id === propertyId) } /** 이 종류의 트윈에 선언 어휘가 있나 — 없으면 검사할 근거가 없으므로 받지 않는다. */ export function hasPropertyVocabulary(kind: string | undefined): boolean { return kind === 'ems' } /** 이 종류에 선언할 수 있는 것이 하나라도 있나 — 공용 어휘가 있으므로 대개 참이다. */ export function hasAnyDeclarable(kind: string | undefined): boolean { return declarablePropertySpecs(kind).length > 0 } /** * 이 종류의 트윈에 **선언할 수 있는 속성 목록** — 화면이 도메인의 모양을 알지 않게. * * 화면이 「에너지 트윈이면 계약전력·요금 단가…」를 알고 있으면, 도메인이 늘 때마다 화면을 고쳐야 하고 * 단위·범위가 두 곳에 적힌다(ADR-0031: 카탈로그가 선언하고 화면은 그린다). 그래서 커널의 명세를 그대로 * 낸다 — 화면은 받은 것을 렌더할 뿐이다. * * 어휘가 없는 종류는 **빈 목록**이다(「아직 없다」와 「아무 값이나 받는다」는 다르다). */ export interface DeclarablePropertySpec { id: string /** 사람이 읽는 단위(명세가 정한 것) — 값에 붙여 보인다. */ uom?: string dataType: string range?: [number, number] note: string /** * 값과 **함께 단위를 골라야 하는** 속성 — 그 목록(UN/CEFACT 코드). * * 어떤 값은 단위 없이는 뜻이 없다: 속도는 단위가 없으면 추정기가 **거절한다**(「speed has no unit of * measure」). 그래서 명세가 「단위를 받아야 한다」와 「무엇을 받나」를 함께 말한다 — 화면이 목록을 * 지어내면 화면이 받아 준 단위를 읽는 쪽이 모르는 일이 생긴다. */ uomChoices?: string[] /** * 어느 종류의 대상에 얹히나(효과 등록부의 축) — 없으면 **우리가 제한을 모른다**(받아 준다). * * 화면은 이것으로 속성 목록을 대상에 맞게 좁힌다: 공정 시간을 설비에 넣으려는 선택지를 아예 보이지 * 않게 하는 것이, 넣게 하고 거절하는 것보다 낫다. */ targets?: string[] } /** * **어느 종류에나 있는 선언** — 「읽는 코드가 있는 것」만. * * 효과 등록부(`PROPERTY_EFFECTS`)가 그 목록의 근거다: 값이 계산을 바꾸는 속성만 여기 온다. 지금은 * 설비의 `speed` 하나다(이동 시간 추정이 읽는다) — 자재의 `perBaseUnit` 은 자원 축이 아니라 자재 * 정의에 얹히므로 이 화면의 대상이 아니다. * * 지어내지 않는다: 새 효과를 코드로 붙이는 사람이 등록부에 한 줄을 더하면 여기에도 따라 나온다. */ /** * 시간 단위(UN/CEFACT) → ms. **목록은 여기 하나** — 화면·AI 는 명세의 `uomChoices` 로 받아 간다. * * 셋만 두는 이유: 현장이 공정 시간을 말할 때 쓰는 단위가 초·분·시간이다. 일 단위는 공정이 아니라 체류에 * 가깝고, 그때는 그 값을 넣는 사람이 시간으로 환산해 말하는 편이 오해가 없다. */ const DURATION_UOM_MS: Record = { SEC: 1_000, MIN: 60_000, HUR: 3_600_000 } const COMMON_SPECS: DeclarablePropertySpec[] = [ /* * 주의 판정 기준 — **경보가 소음이 되지 않게 현장이 정한다.** * * 혼잡 90%·불량 15%는 우리가 정한 수였다. 통로를 95%로 채워 쓰는 창고는 그 기준에서 영구히 「병목」 * 이고, 초기 라인은 계속 「불량 과다」로 운다 — 그때 진짜 경보도 함께 묻힌다. 선언이 없으면 기본값을 * 쓰되 주의가 `basis: 'default'` 로 그 사실을 밝힌다(알리지 않고 기본값을 진실로 두지 않는다). */ { id: ATTENTION_PROPERTY.congestionRatio, uom: '%', dataType: 'xs:double', range: [1, 100], note: 'congestion threshold as a percentage 1-100 (95 means 95% of capacity) — above it this location is called a bottleneck. default 90.' }, { id: ATTENTION_PROPERTY.scrapRate, uom: '%', dataType: 'xs:double', range: [1, 100], note: 'scrap threshold as a percentage 1-100 (25 means 25%) — above it this equipment is called high-scrap, and twice it is severe. default 15.' }, { id: ATTENTION_PROPERTY.scrapMinSamples, dataType: 'xs:double', range: [1, 100000], note: 'how many pieces must be counted before the scrap rate is judged at all — a ratio over few pieces is not evidence. default 10.' }, { id: EQUIPMENT_PROPERTY.speed, dataType: 'xs:double', range: [0.01, 50], note: 'travel speed of this equipment — the estimator turns distance into duration with it. a unit is required.', uomChoices: Object.keys(SPEED_TO_MPS) }, /* * 공정 소요시간 — **측정이 없을 때 상수를 대신한다.** * * 대상은 자리·설비가 아니라 **공정 종류**(`putaway`·`pick`·`spot`…)이고, 그 종류에 명세 행이 없어도 * 성립한다(실측: 창고·야드 트윈에는 행이 0개다). 단위를 반드시 받는다 — 「4」만으로는 4초인지 4분인지 * 알 수 없고, 짐작하면 예측이 60배 틀린다. */ { id: OPERATION_PROPERTY.duration, dataType: 'xs:double', range: [0.01, 100000], note: 'how long this operation takes at this site — the simulation and the forecast use it instead of the built-in constant. a unit is required. history still wins where it exists.', uomChoices: Object.keys(DURATION_UOM_MS) }, /* * 양품률 — **퍼센트로 받는다**(80 이 80%다). * * 커널이 읽는 값은 0..1 이지만(ISA-95 `Parameter` 값 그대로) 사람에게 0.8 을 넣으라고 하면 SOC 에서 * 겪은 함정을 되풀게 된다(0.8 을 0.8% 로 쓴 일이 있다). 그래서 화면·AI 는 퍼센트로 말하고, 비율로 * 바꾸는 일은 얹는 자리 한 곳에서 한다. */ { id: OPERATION_PROPERTY.yield, uom: '%', dataType: 'xs:double', range: [1, 100], note: 'good-output rate of this operation as a percentage 1-100 (95 means 95%) — the kernel reads it as a ratio, and this value replaces the built-in default that also drives the high-scrap attention.' }, /* 셋업·체인지오버 — 가동 앞에 붙는 시간. 소요시간과 같은 단위 목록을 쓴다(같은 종류의 값이다). */ { id: OPERATION_PROPERTY.setupDuration, dataType: 'xs:double', range: [0.01, 100000], note: 'changeover time before this operation runs — added in front of the cycle. a unit is required.', uomChoices: Object.keys(DURATION_UOM_MS) } ] /** * 값 + 단위 → **ISO 8601 기간** — 커널이 읽는 표기로 바꾼다(커널은 초·분을 모른다, 표준 표기만 안다). * * 읽을 수 없으면 `undefined` — 부르는 쪽이 그 사실을 거절 사유로 올린다(0 을 만들지 않는다: 소요 0 인 * 작업은 영원히 끝나지 않는다). */ /** * 공정 모수 표에 한 칸 담는다 — **커널이 읽는 표기 그대로**(수율 0..1, 셋업 ISO 8601). * * 표를 통째로 새로 만들어 얹는다: 같은 모델 객체를 여러 겹이 지나므로(현장 → 트윈) 그때마다 새 객체여야 * 앞 겹의 것을 알리지 않고 덮어쓰지 않는다. */ function putParam(next: any, kind: string, paramId: string, value: string): void { const table = { ...(next.localParams ?? {}) } table[kind] = { ...(table[kind] ?? {}), [paramId]: value } next.localParams = table } export function isoDurationOf(value: string | number, uom: string): string | undefined { const n = Number(value) const per = DURATION_UOM_MS[String(uom ?? '').trim()] if (!Number.isFinite(n) || n <= 0 || !per) return undefined return `PT${(n * per) / 1000}S` } /** * 이 종류의 트윈에 **선언할 수 있는 속성 목록** — 화면이 도메인의 모양을 알지 않게. * * 종류와 무관한 것(효과 등록부에서 온다) + 그 종류의 어휘(지금은 EMS 뿐). 어휘가 없는 종류도 **빈손이 * 아니다**: 속도는 어느 트윈에서나 읽히므로 창고·야드·생산 트윈에서도 넣을 수 있어야 한다. */ export function declarablePropertySpecs(kind: string | undefined): DeclarablePropertySpec[] { const domain = hasPropertyVocabulary(kind) ? Object.entries(EMS_PROPERTY_SPEC as Record).map(([id, spec]) => ({ id, ...(spec.uom ? { uom: spec.uom } : {}), dataType: spec.dataType, ...(spec.range ? { range: spec.range as [number, number] } : {}), note: spec.note })) : [] /* * **어느 대상에 얹히나는 효과 등록부가 답한다** — 명세에 손으로 또 적지 않는다(두 곳에 적으면 한쪽만 * 바뀐다). 등록부에 없는 속성은 제한을 **모르는** 것이므로 그대로 둔다(속성은 열려 있다). */ const all: DeclarablePropertySpec[] = [...COMMON_SPECS, ...domain] return all.map(s => { const targets = s.targets ?? declarationTargetsOf(s.id) return targets ? { ...s, targets } : s }) } /** * 로컬 선언을 검사한다 — **받은 것과 받지 않은 것을 함께** 돌려준다. * * 알리지 않고 걸러내지 않는 이유: 사용자는 값을 넣었다고 믿는다. 무엇이 왜 빠졌는지 말해야 고칠 수 있다. */ export function validateLocalDeclarations( kind: string | undefined, declarations: readonly LocalDeclaration[] | undefined | null, opts?: { /** * 이 트윈에서 확인된 공정 이름들 — 있으면 그 밖의 이름을 거절한다(오타를 잡는 유일한 수단이다). * * **모르면 넘기지 않고 모른다고 둔다**: 아직 돌지 않은 트윈은 어떤 공정을 돌릴지 알 수 없으므로 * 목록을 주지 않고(=undefined) 받아 준다. 부르는 쪽이 「확인하지 못했다」는 사실을 답에 싣는다. */ knownOperations?: readonly string[] } ): { accepted: LocalDeclaration[]; rejected: RejectedDeclaration[] } { const accepted: LocalDeclaration[] = [] const rejected: RejectedDeclaration[] = [] for (const d of declarations ?? []) { const target: DeclarationTarget = d?.target === 'equipment' ? 'equipment' : d?.target === 'operation' ? 'operation' : 'location' const id = String(d?.id ?? '').trim() if (!id) { /* 어느 대상인지 모르면 얹을 자리가 없다 — 그 사실을 말한다(알리지 않고 버리지 않는다). */ rejected.push({ target, id: '', propertyId: '', reason: 'no-target-id' }) continue } const props: { id: string; value: string }[] = [] for (const p of d?.properties ?? []) { const propertyId = String(p?.id ?? '').trim() if (!propertyId) { rejected.push({ target, id, propertyId: '', reason: 'no-property-id' }) continue } const raw = p?.value if (raw === undefined || raw === null || String(raw).trim() === '') { rejected.push({ target, id, propertyId, reason: 'empty-value' }) continue } if (!hasAnyDeclarable(kind)) { rejected.push({ target, id, propertyId, reason: 'no-property-vocabulary' }) continue } const spec = specOf(kind, propertyId) if (!spec) { rejected.push({ target, id, propertyId, reason: 'unknown-property' }) continue } /* * **읽는 쪽이 보는 축에만 얹는다** — 지게차에 계약전력을 넣을 수 있던 구멍을 여기서 막는다. * 명세가 제한을 말하지 않으면(등록부에 없는 속성) 받아 준다: 속성은 열려 있고, 효과만 닫혀 있다. */ if (spec.targets?.length && !spec.targets.includes(target)) { rejected.push({ target, id, propertyId, reason: 'wrong-target', expected: `one of ${spec.targets.join(', ')}` }) continue } /* 공정 이름은 대조로만 걸러진다 — 모델에 행이 없어도 성립하는 선언이라 다른 안전망이 없다. */ if (target === 'operation' && opts?.knownOperations && !opts.knownOperations.includes(id)) { rejected.push({ target, id, propertyId, reason: 'unknown-operation', expected: `one of ${[...opts.knownOperations].join(', ')}` }) continue } if (spec.dataType === 'xs:double') { const n = Number(raw) if (!Number.isFinite(n)) { rejected.push({ target, id, propertyId, reason: 'not-a-number', expected: spec.note }) continue } if (spec.range && (n < spec.range[0] || n > spec.range[1])) { rejected.push({ target, id, propertyId, reason: 'out-of-range', expected: spec.note }) continue } } /* * 단위를 요구하는 속성은 **단위까지 검사한다** — 값만 받아 두면 읽는 쪽이 그 설비를 건너뛴다. * 목록은 명세가 든다(`uomChoices`): 화면이 목록을 지어내면 화면이 받아 준 단위를 계산이 모른다. */ const uom = String((p as any)?.uom ?? '').trim() if (spec.uomChoices?.length) { if (!uom) { rejected.push({ target, id, propertyId, reason: 'uom-required', expected: `one of ${spec.uomChoices.join(', ')}` }) continue } if (!spec.uomChoices.includes(uom)) { rejected.push({ target, id, propertyId, reason: 'unknown-uom', expected: `one of ${spec.uomChoices.join(', ')}` }) continue } } props.push({ id: propertyId, value: String(raw), ...(uom ? { uom } : {}) }) } if (props.length) accepted.push({ target, id, properties: props }) } return { accepted, rejected } } /** * 겹을 합친다 — 같은 대상·같은 속성이면 **새 값이 이긴다**(현장이 값을 고치는 일은 흔하다). * * 대상이나 속성을 지우는 일은 여기서 하지 않는다: 지움은 「값을 모르는 상태로 되돌린다」는 뜻이고, * 그것은 값을 넣는 것과 다른 결정이므로 다른 문으로 말해야 한다. */ export function mergeLocalDeclarations( current: readonly LocalDeclaration[] | undefined | null, incoming: readonly LocalDeclaration[] ): LocalDeclaration[] { const key = (t: string, id: string) => `${t}:${id}` const out = new Map() for (const d of current ?? []) out.set(key(d.target, d.id), { ...d, properties: [...d.properties] }) for (const d of incoming) { const k = key(d.target, d.id) const cur = out.get(k) if (!cur) { out.set(k, { ...d, properties: [...d.properties] }) continue } for (const p of d.properties) { const at = cur.properties.findIndex(x => x.id === p.id) if (at >= 0) cur.properties[at] = { ...p } else cur.properties.push({ ...p }) } } return [...out.values()] } /** * 모델 위에 로컬 선언을 얹는다 — **모델을 바꾸지 않고 새 모델을 돌려준다.** * * 원천이 같은 속성을 이미 말했으면 **로컬 값이 이긴다**: 계약전력을 게이트웨이가 말하더라도, 우리가 * 고쳐 넣었다는 것은 「원천의 값이 낡았다」는 뜻이다. 다만 그 사실이 보여야 하므로 `source: 'local'` 를 * 값에 붙인다 — 화면·감사가 「누가 말한 값인가」를 되짚을 수 있다. * * 모델에 없는 대상에 얹으려 하면 **알리지 않고 버리지 않고** 그 사실을 돌려준다(구조가 바뀌어 그 설비가 * 사라졌을 수 있고, 그때 사용자의 선언은 갈 곳이 없다). */ export function applyLocalDeclarations< T extends { locations?: any[]; equipment?: any[]; localDurations?: Record; localParams?: Record> } >( model: T, declarations: readonly LocalDeclaration[] | undefined | null ): { model: T missingTargets: { target: string; id: string }[] /** * 읽을 수 있는 값으로 바꿀 수 없었던 선언 — **알리지 않고 버리지 않기 위한 자리.** * * 지금은 공정 시간에서만 난다(단위가 없거나 수가 아니면 ISO 8601 기간을 만들 수 없다). 문에서 이미 * 검사하므로 정상 경로에서는 비어 있고, 옛 겹이나 손으로 넣은 데이터에서 드러난다. */ unreadable: { target: string; id: string; propertyId: string }[] layer: LocalDeclaration[] } { const decls = declarations ?? [] if (!decls.length) return { model, missingTargets: [], unreadable: [], layer: [] } const missingTargets: { target: string; id: string }[] = [] const unreadable: { target: string; id: string; propertyId: string }[] = [] /* 얹으면서 **무엇을 덮었는지** 함께 적어 돌려준다 — 철회는 그 기억이 있어야 성립한다. */ const layer: LocalDeclaration[] = decls.map(d => ({ ...d, properties: d.properties.map(p => ({ ...p })) })) const put = (list: any[] | undefined, d: LocalDeclaration, memo: LocalDeclaration): boolean => { const item = (list ?? []).find(x => String(x?.id ?? '') === d.id) if (!item) return false const props: any[] = Array.isArray(item.properties) ? [...item.properties] : [] for (let i = 0; i < d.properties.length; i++) { const p = d.properties[i] const at = props.findIndex(x => String(x?.id ?? '') === p.id) const noted = memo.properties[i] /* * 기억은 **표식이 없는 값일 때만** 갱신한다 — ADR-0034 의 규칙 그대로: 표식이 없으면 모델을 그린 * 쪽(대개 원천)이 말한 값이고, 표식이 있으면 **누군가 찍은 것**이므로 맨 원천 값이 아니다. * * 이 함수는 원천에서 새로 그린 모델에도, 이미 선언이 구워진 저장 모델에도 얹힌다. 뒤쪽에서 * 무턱대고 적으면 **우리 값을 원천 값으로 기억**하게 되고(600 을 원천이 말한 것으로 적는다), * 철회해도 같은 값으로 되돌아간다. * * `!== 'local'` 로 보지 않는 이유: 옛 표식(`'site'`)이 구워진 모델이 남아 있고, 그것을 「표식 * 아님」으로 읽으면 바로 그 오인이 일어난다. 호환 코드를 두는 것이 아니라 **규칙을 그대로 적는 * 것**이다 — 표식의 종류를 따지지 않는다. */ if (at < 0) noted.replaced = null else if (props[at]?.source === undefined) noted.replaced = String(props[at]?.value ?? '') /* * **단위도 함께 얹는다** (2026-08-18 실측으로 고침). * * 겹에는 단위를 담고도 모델에 얹을 때 떨어뜨리고 있었다 — 그러면 속도를 넣어도 이동 시간 추정기가 * 그 설비를 **버린다**(「speed has no unit of measure」). 화면은 「넣었습니다」라고 말하고 계산은 * 그 값을 못 쓰는, 가장 들어온 것이 없는 종류의 결함이다. */ const next = { id: p.id, value: String(p.value), ...((p as any).uom ? { uom: String((p as any).uom) } : {}), source: 'local' as const } if (at >= 0) props[at] = { ...props[at], ...next } else props.push(next) } item.properties = props return true } /** * 공정 시간은 **자원 속성이 아니다** — 종류별 표(`localDurations`)에 담는다. * * 왜 자리·설비처럼 속성으로 담지 않나: 커널의 공정 명세 행(`OperationDef`)은 `label`·`intent` 를 * 요구하는데(ISA-95 `OperationsSegment`) 창고·야드 트윈에는 그 행이 **아예 없다**(실측: 35개 중 * WMS·YMS 전부 0개). 행을 지어 만들면 지어낸 `intent` 가 능력 계산까지 오염시킨다 — 「몇 분 걸리나」를 * 말하려고 「무슨 작업인가」를 발명하지 않는다. * * 이 표는 **전부 우리가 정한 값**이다(원천이 그리는 자리가 아니다). 그래서 기억(`replaced`)은 언제나 * `null` 이고, 철회는 「표에서 뺀다」로 끝난다 — 그러면 원천 명세나 커널 상수가 다시 답한다. */ const putOperation = (next: any, d: LocalDeclaration, memo: LocalDeclaration): void => { for (let i = 0; i < d.properties.length; i++) { const p = d.properties[i] const uom = String((p as any).uom ?? '') /* * 값을 **커널이 읽는 표기로** 바꿔 담는다 — 시간은 ISO 8601, 수율은 비율(0..1). * 바꿀 수 없으면 담지 않고 그 사실을 낸다(알리지 않고 0 이나 빈 값으로 만들지 않는다). */ if (p.id === OPERATION_PROPERTY.duration) { const iso = isoDurationOf(p.value, uom) if (!iso) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue } next.localDurations = { ...(next.localDurations ?? {}), [d.id]: iso } memo.properties[i].replaced = null continue } if (p.id === OPERATION_PROPERTY.setupDuration) { const iso = isoDurationOf(p.value, uom) if (!iso) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue } putParam(next, d.id, OP_PARAM.setupDuration, iso) memo.properties[i].replaced = null continue } if (p.id === OPERATION_PROPERTY.yield) { const pct = Number(p.value) if (!Number.isFinite(pct) || pct <= 0 || pct > 100) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue } /* 퍼센트 → 비율. 이 환산이 있는 자리는 여기 하나다(두 곳에서 나누면 100 배가 두 번 걸린다). */ putParam(next, d.id, OP_PARAM.yield, String(pct / 100)) memo.properties[i].replaced = null continue } unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }) } } /* 얕은 복사로는 안 된다 — 자리·설비 항목을 손대므로 그 배열과 항목까지 새로 만든다. */ const next: any = { ...model, ...(model.locations ? { locations: model.locations.map(n => ({ ...n, ...(n?.properties ? { properties: [...n.properties] } : {}) })) } : {}), ...(model.equipment ? { equipment: model.equipment.map(e => ({ ...e, ...(e?.properties ? { properties: [...e.properties] } : {}) })) } : {}), ...(model.localDurations ? { localDurations: { ...model.localDurations } } : {}), ...(model.localParams ? { localParams: Object.fromEntries(Object.entries(model.localParams).map(([k, v]) => [k, { ...v }])) } : {}) } for (let i = 0; i < decls.length; i++) { const d = decls[i] if (d.target === 'operation') { /* 빈 표를 남기지 않는다 — 「선언이 있다」와 「읽을 수 있는 선언이 하나도 없다」는 다른 사실이다 (`putOperation` 이 담을 것이 있을 때만 표를 만든다). */ putOperation(next, d, layer[i]) continue } const ok = put(d.target === 'equipment' ? next.equipment : next.locations, d, layer[i]) if (!ok) missingTargets.push({ target: d.target, id: d.id }) } return { model: next as T, missingTargets, unreadable, layer } } /** 철회 요청 하나 — 어느 대상의 어느 속성을 원천으로 되돌리나. */ export interface DeclarationRevocation { target: DeclarationTarget id: string propertyId: string } /** 철회된 항목 — 되돌릴 값을 함께 든다(모르면 `replaced` 가 없다). */ export interface RevokedDeclaration extends DeclarationRevocation { replaced?: string | null } /** * 겹에서 선언을 **뺀다** — 값을 넣는 것과 다른 결정이므로 다른 문이다. * * 없는 것을 뺐다고 말하지 않는다: 겹에 없던 대상·속성은 `notFound` 로 돌려준다(알리지 않고 성공하면 * 사용자는 지웠다고 믿고 값은 그대로 남는다 — 가장 비싼 종류의 침묵이다). * * 속성이 하나도 남지 않은 대상은 겹에서 사라진다(빈 껍데기를 남기지 않는다). */ export function revokeLocalDeclarations( current: readonly LocalDeclaration[] | undefined | null, revocations: readonly DeclarationRevocation[] | undefined | null ): { layer: LocalDeclaration[]; revoked: RevokedDeclaration[]; notFound: DeclarationRevocation[] } { const layer: LocalDeclaration[] = (current ?? []).map(d => ({ ...d, properties: d.properties.map(p => ({ ...p })) })) const revoked: RevokedDeclaration[] = [] const notFound: DeclarationRevocation[] = [] for (const r of revocations ?? []) { const target: DeclarationTarget = r?.target === 'equipment' ? 'equipment' : r?.target === 'operation' ? 'operation' : 'location' const id = String(r?.id ?? '').trim() const propertyId = String(r?.propertyId ?? '').trim() if (!id || !propertyId) { notFound.push({ target, id, propertyId }) continue } const d = layer.find(x => x.target === target && x.id === id) const at = d ? d.properties.findIndex(p => p.id === propertyId) : -1 if (!d || at < 0) { notFound.push({ target, id, propertyId }) continue } const [gone] = d.properties.splice(at, 1) revoked.push({ target, id, propertyId, ...('replaced' in gone ? { replaced: gone.replaced } : {}) }) } return { layer: layer.filter(d => d.properties.length), revoked, notFound } } /** * 철회한 속성을 **모델에서 원천 값으로 되돌린다** — 저장된 모델에 값이 구워져 있기 때문이다. * * · 기억한 값이 있으면 그 값으로 되돌리고 `source: 'local'` 표식을 뗀다(다시 원천이 말한 값이다). * · `null` 이면 원천에 없던 속성이므로 **지운다**. * · 기억이 없으면(옛 선언) **손대지 않고** `unknownSource` 로 말한다 — 원래 값을 지어내는 것은 * 잘못 넣은 값을 그대로 두는 것보다 나쁘다(다음 원천 재읽기가 그 자리를 바로잡는다). */ export function restoreRevoked< T extends { locations?: any[]; equipment?: any[]; localDurations?: Record; localParams?: Record> } >( model: T, revoked: readonly RevokedDeclaration[] ): { model: T; restored: RevokedDeclaration[]; unknownSource: DeclarationRevocation[]; missingTargets: { target: string; id: string }[] } { if (!revoked.length) return { model, restored: [], unknownSource: [], missingTargets: [] } const next: any = { ...model, ...(model.locations ? { locations: model.locations.map(n => ({ ...n, ...(n?.properties ? { properties: [...n.properties] } : {}) })) } : {}), ...(model.equipment ? { equipment: model.equipment.map(e => ({ ...e, ...(e?.properties ? { properties: [...e.properties] } : {}) })) } : {}), ...(model.localDurations ? { localDurations: { ...model.localDurations } } : {}), ...(model.localParams ? { localParams: Object.fromEntries(Object.entries(model.localParams).map(([k, v]) => [k, { ...v }])) } : {}) } const restored: RevokedDeclaration[] = [] const unknownSource: DeclarationRevocation[] = [] const missingTargets: { target: string; id: string }[] = [] for (const r of revoked) { /* * 공정 시간은 표에서 **뺀다** — 되돌릴 원천 값이 없는 자리다(전부 우리가 정한 값이었다). * 빼면 원천 명세(있으면)나 커널 상수가 다시 답한다: 그것이 「선언 이전」의 상태다. */ if (r.target === 'operation') { /* 시간은 시간 표에서, 모수(수율·셋업)는 모수 표에서 뺀다 — 담긴 자리에서 빼야 실제로 빠진다. */ const paramId = r.propertyId === OPERATION_PROPERTY.yield ? OP_PARAM.yield : r.propertyId === OPERATION_PROPERTY.setupDuration ? OP_PARAM.setupDuration : undefined if (paramId) { const params: Record> = next.localParams ?? {} if (!params[r.id] || !(paramId in params[r.id])) { missingTargets.push({ target: r.target, id: r.id }) continue } delete params[r.id][paramId] /* 빈 껍데기를 남기지 않는다 — 「선언이 있다」와 「빈 표가 있다」가 구별되지 않는다. */ if (!Object.keys(params[r.id]).length) delete params[r.id] if (!Object.keys(params).length) delete next.localParams else next.localParams = params restored.push(r) continue } const table: Record = next.localDurations ?? {} if (!(r.id in table)) { missingTargets.push({ target: r.target, id: r.id }) continue } delete table[r.id] if (!Object.keys(table).length) delete next.localDurations else next.localDurations = table restored.push(r) continue } const list: any[] = (r.target === 'equipment' ? next.equipment : next.locations) ?? [] const item = list.find(x => String(x?.id ?? '') === r.id) if (!item) { missingTargets.push({ target: r.target, id: r.id }) continue } if (!('replaced' in r)) { unknownSource.push({ target: r.target, id: r.id, propertyId: r.propertyId }) continue } const props: any[] = Array.isArray(item.properties) ? [...item.properties] : [] const at = props.findIndex(x => String(x?.id ?? '') === r.propertyId) if (r.replaced === null) { if (at >= 0) props.splice(at, 1) } else if (at >= 0) { const { source: _dropped, ...rest } = props[at] ?? {} props[at] = { ...rest, value: String(r.replaced) } } else { /* 겹은 있었는데 모델에는 없다 — 원천 값을 되살려 넣는다(우리가 지웠을 수 있다). */ props.push({ id: r.propertyId, value: String(r.replaced) }) } item.properties = props restored.push(r) } return { model: next as T, restored, unknownSource, missingTargets } } /** 선언이 어느 범위의 것인가 — 좁은 것이 이긴다(`space` 먼저, `instance` 나중). */ export type DeclarationScope = 'space' | 'instance' /** * 두 겹을 **순서대로** 얹는다 — 현장 먼저, 트윈이 나중. * * ── 왜 순서가 규율인가 (2026-08-18) ───────────────────────────────────────── * 요금 단가는 현장의 계약이므로 그 현장의 트윈 전부가 같은 값을 읽어야 한다. 그런데 한 트윈만 다른 * 값으로 실험하는 일이 있다(인상된 단가로 what-if). 그때 현장 값을 고치게 하면 **나머지 트윈이 함께 * 흔들린다.** 그래서 더 좁은 선언이 이긴다 — 넓은 값은 그대로 두고 좁은 자리에서만 달라진다. * * 기억(`replaced`)은 각 겹이 **자기가 덮은 값**을 적는다: 트윈 겹의 기억은 「현장 값을 덮었다」가 되고, * 철회하면 현장 값으로 돌아간다(원천까지 가지 않는다). 그것이 사람이 기대하는 되돌림이다. */ export function applyDeclarationLayers< T extends { locations?: any[]; equipment?: any[]; localDurations?: Record; localParams?: Record> } >( model: T, spaceLayer: readonly LocalDeclaration[] | undefined | null, instanceLayer: readonly LocalDeclaration[] | undefined | null ): { model: T /** 현장 겹을 얹은 결과 — 저장해야 기억이 낡지 않는다. */ spaceLayerApplied: LocalDeclaration[] instanceLayerApplied: LocalDeclaration[] /** 갈 곳이 없던 대상 — 어느 범위에서 났는지 함께 낸다(현장 겹은 그 트윈에 없을 수 있다). */ missingTargets: { scope: DeclarationScope; target: string; id: string }[] /** 읽을 수 있는 값으로 바꿀 수 없던 선언 — 범위와 함께(알리지 않고 버리지 않는다). */ unreadable: { scope: DeclarationScope; target: string; id: string; propertyId: string }[] } { const first = applyLocalDeclarations(model, spaceLayer) const second = applyLocalDeclarations(first.model, instanceLayer) return { model: second.model, spaceLayerApplied: first.layer, instanceLayerApplied: second.layer, unreadable: [ ...first.unreadable.map(u => ({ scope: 'space' as const, ...u })), ...second.unreadable.map(u => ({ scope: 'instance' as const, ...u })) ], /* * **현장 겹의 「갈 곳 없음」은 오류가 아니다.** * * 한 현장에는 여러 종류의 트윈이 산다(창고·야드·에너지). 요금을 현장에 선언하면 그 값은 수전 자리를 * 가진 에너지 트윈에만 얹히고, 창고 트윈에는 얹을 자리가 없다 — 그것이 정상이다. 그래서 범위를 함께 * 실어 보내고, 부르는 쪽이 현장 겹의 것은 경고로 올리지 않는다. */ missingTargets: [ ...first.missingTargets.map(t => ({ scope: 'space' as const, ...t })), ...second.missingTargets.map(t => ({ scope: 'instance' as const, ...t })) ] } }