@operato/twin-kernel 0.6.14 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,7 +86,7 @@ export class MesKernel extends FlowEngine {
86
86
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
87
87
  *
88
88
  * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
89
- * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
89
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 구분한다) · 생산오더 연결. 코어의 일반
90
90
  * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
91
91
  *
92
92
  * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
@@ -193,7 +193,7 @@ export class MesKernel extends FlowEngine {
193
193
  /**
194
194
  * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
195
195
  *
196
- * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
196
+ * 씨앗이 이미 이행된 오더를 주입하지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
197
197
  * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
198
198
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
199
199
  */
@@ -26,8 +26,32 @@ const MES_LOCATION_CLS = {
26
26
  'assembly-line': { isa95: 'WorkCenter', epcis: 'bizLocation' },
27
27
  'fg-store': { epcis: 'bizLocation' }
28
28
  };
29
+ /**
30
+ * 자리의 **ISA-95 계층 단** — 여기가 단이 가장 크게 갈리는 곳이다.
31
+ *
32
+ * · `assembly-line` = 라인 → `ProductionLine`(`WorkCenter` 의 한 종류)
33
+ * · `cut-station`·`weld-station`·`paint-booth` = 라인 안의 작업 자리 → `WorkCell`(`WorkUnit` 의 한 종류)
34
+ * · `raw-store`·`fg-store` = 보관 구역 → `StorageZone`
35
+ *
36
+ * ── 남은 어긋남 (2026-08-14, 지금 고치지 않는다) ─────────────────────────────
37
+ * 스테이션들의 `standardClass.isa95` 는 예전부터 `WorkCenter` 로 적혀 있다. 그런데 `WorkCenter` 는
38
+ * ProcessCell·ProductionLine·ProductionUnit·StorageZone 의 **총칭**이므로, 라인 안의 작업 자리는
39
+ * 엄밀히는 `WorkUnit` 계열이다. 즉 클래스 투영이 한 단 위를 가리키고 있다.
40
+ *
41
+ * 여기서 그것을 바꾸지 않는다: `standardClass` 는 적합성 표가 세는 값이고(`isa95-coverage`),
42
+ * 바꾸면 그 표의 숫자가 함께 움직인다. **단(`level`)과 클래스(`standardClass`)는 다른 축**이므로
43
+ * 단을 옳게 적는 것으로 계층 질의는 살아난다. 클래스 재검토는 적합성 표와 함께 다룰 일이다.
44
+ */
45
+ const MES_LOCATION_LEVEL = {
46
+ 'raw-store': 'StorageZone',
47
+ 'cut-station': 'WorkCell',
48
+ 'weld-station': 'WorkCell',
49
+ 'paint-booth': 'WorkCell',
50
+ 'assembly-line': 'ProductionLine',
51
+ 'fg-store': 'StorageZone'
52
+ };
29
53
  export const MES_TYPES = [
30
- ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
54
+ ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: 'gs1:SGLN' }, level: MES_LOCATION_LEVEL[k], capabilities: ['storable'] })),
31
55
  { key: 'cutter', role: 'equipment', label: 'twin.type.cutter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
32
56
  { key: 'welder', role: 'equipment', label: 'twin.type.welder', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
33
57
  { key: 'painter', role: 'equipment', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
@@ -28,6 +28,23 @@ export interface ProjectedState {
28
28
  correctiveEventIDs: string[];
29
29
  eventID?: string;
30
30
  }[];
31
+ /**
32
+ * 반영하지 못한 사건의 종류 — **리비전은 올라갔는데 상태가 비어 있는 이유**를 여기서 말한다.
33
+ *
34
+ * 모르는 `eventType` 은 전방 호환을 위해 버린다(새 커넥터가 옛 커널에 보내는 것을 막을 수 없다).
35
+ * 그런데 **버렸다는 사실까지 버리면** 화면은 「비었다」와 「담을 줄 몰랐다」를 구별할 수 없다.
36
+ * 에너지 사건(`energy.*`)이 물류 리듀서에 들어오는 것이 정확히 그 경우다 — 리비전만 올라가고
37
+ * 아무것도 담기지 않는다. 그것을 조용히 두면 트윈은 「정상인데 빈」 모습이 된다.
38
+ *
39
+ * 비어 있지 않으면 뜻은 하나다: **이 트윈에 맞지 않는 어휘가 들어오고 있다**(커넥터 배선 오류이거나
40
+ * 커널이 낡았다). 세는 것에 그치고 판단은 소비처가 한다 — 여기서 던지면 저널 재생이 멈춘다.
41
+ */
42
+ unhandled?: {
43
+ eventType: string;
44
+ count: number;
45
+ firstAtMs?: number;
46
+ lastAtMs?: number;
47
+ }[];
31
48
  locations: LocationState[];
32
49
  items: ItemState[];
33
50
  /** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
@@ -63,11 +80,13 @@ export declare class ObservedReducer {
63
80
  revision: number;
64
81
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
65
82
  private corrections;
83
+ /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
84
+ private unhandled;
66
85
  /**
67
86
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
68
87
  *
69
88
  * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
70
- * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
89
+ * 어긋난다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
71
90
  */
72
91
  private shifts;
73
92
  /**
@@ -81,14 +100,14 @@ export declare class ObservedReducer {
81
100
  private utcOffsetMinutes?;
82
101
  constructor(model: TwinModelDef);
83
102
  /**
84
- * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
103
+ * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
85
104
  *
86
105
  * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
87
106
  * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
88
107
  * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
89
108
  *
90
109
  * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
91
- * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
110
+ * 불러 전환한 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
92
111
  *
93
112
  * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
94
113
  * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
@@ -129,13 +148,15 @@ export declare class ObservedReducer {
129
148
  /**
130
149
  * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
131
150
  *
132
- * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
151
+ * 미러는 스스로 시간을 진행시키지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
133
152
  * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
134
153
  * 판정하지 않는다(모르면 단정하지 않는다).
135
154
  */
136
155
  private observedAtMs?;
137
156
  /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
138
157
  apply(e: CanonicalEnvelope): void;
158
+ /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
159
+ private noteUnhandled;
139
160
  private applyEpcis;
140
161
  /**
141
162
  * 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 이 파일은 원래 미러 전용(`StateProjector`)이었다. 그런데 커널도 이벤트로 굴러갈 수 있게 되면서
5
5
  * (`FlowEngine.apply`, 통합 P0) **두 구동이 같은 규칙을 써야** 한다 — 규칙이 두 벌이면 반드시
6
- * 갈라진다(2026-08-01 하루에 아홉 곳, 적합성 하네스가 그 뒤 넷 더). 그래서 이름을 규칙 쪽으로 옮겼다.
6
+ * 어긋난다(2026-08-01 하루에 아홉 곳, 적합성 하네스가 그 뒤 넷 더). 그래서 이름을 규칙 쪽으로 옮겼다.
7
7
  * `StateProjector` 는 같은 것의 옛 이름으로 남는다(소비처 호환).
8
8
  *
9
9
  * (아래는 원래 주석 — 두 모드의 계약이 같다는 설계 근거.)
@@ -55,11 +55,13 @@ export class ObservedReducer {
55
55
  revision = 0;
56
56
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
57
57
  corrections = [];
58
+ /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
59
+ unhandled = new Map();
58
60
  /**
59
61
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
60
62
  *
61
63
  * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
62
- * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
64
+ * 어긋난다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
63
65
  */
64
66
  shifts = new Map();
65
67
  /**
@@ -92,14 +94,14 @@ export class ObservedReducer {
92
94
  this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
93
95
  }
94
96
  /**
95
- * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
97
+ * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
96
98
  *
97
99
  * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
98
100
  * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
99
101
  * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
100
102
  *
101
103
  * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
102
- * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
104
+ * 불러 전환한 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
103
105
  *
104
106
  * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
105
107
  * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
@@ -166,7 +168,7 @@ export class ObservedReducer {
166
168
  return;
167
169
  }
168
170
  const cur = this.master.get(u.location.id);
169
- /* **모르는 용량을 0 으로 뭉개지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
171
+ /* **모르는 용량을 0 으로 같게 다루지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
170
172
  * 계약(`LocationState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
171
173
  * 성립하고 배정 정책이 그 자리를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
172
174
  * 않는다** — 마스터가 말하지 않은 것은 기존 값을 지키는 것이 upsert 의 뜻이다. */
@@ -232,7 +234,7 @@ export class ObservedReducer {
232
234
  /**
233
235
  * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
234
236
  *
235
- * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
237
+ * 미러는 스스로 시간을 진행시키지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
236
238
  * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
237
239
  * 판정하지 않는다(모르면 단정하지 않는다).
238
240
  */
@@ -359,9 +361,25 @@ export class ObservedReducer {
359
361
  });
360
362
  break;
361
363
  }
362
- // 알 수 없는 eventType 은 무시(전방 호환).
364
+ default:
365
+ /* 알 수 없는 eventType — 상태에는 반영하지 않되(담을 줄 모른다) **세어서 밝힌다.**
366
+ 전방 호환은 버리는 것이지 숨기는 것이 아니다. `unhandled` 가 그 사실을 나른다. */
367
+ this.noteUnhandled(e);
363
368
  }
364
369
  }
370
+ /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
371
+ noteUnhandled(e) {
372
+ const at = Date.parse(String(e.eventTime ?? ''));
373
+ const cur = this.unhandled.get(e.eventType) ?? { count: 0 };
374
+ cur.count++;
375
+ if (Number.isFinite(at)) {
376
+ if (cur.firstAtMs === undefined || at < cur.firstAtMs)
377
+ cur.firstAtMs = at;
378
+ if (cur.lastAtMs === undefined || at > cur.lastAtMs)
379
+ cur.lastAtMs = at;
380
+ }
381
+ this.unhandled.set(e.eventType, cur);
382
+ }
365
383
  applyEpcis(ev, envelope) {
366
384
  /* 정정 선언이 붙은 이벤트는 **새 사실이 아니다** — 앞선 이벤트를 취소·수정하는 선언이다.
367
385
  * 무엇을 어떻게 되돌릴지는 도메인 판단이 필요하므로(원본을 찾아 역적용) 지금은 **상태에 반영하지
@@ -379,7 +397,7 @@ export class ObservedReducer {
379
397
  if (ev.type === 'AggregationEvent') {
380
398
  if (ev.action === 'ADD' && ev.childEPCs?.length) {
381
399
  this.aggregation.set(ev.parentID, [...ev.childEPCs]);
382
- /* 조립 관계를 물품에도 심는다 — 예전에는 내부 맵에만 두고 밖으로 내보내지 않아, 3D 가
400
+ /* 조립 관계를 물품에도 주입한다 — 예전에는 내부 맵에만 두고 밖으로 내보내지 않아, 3D 가
383
401
  * "이 상자가 어느 팔레트에 실렸나" 를 알 수 없었다. */
384
402
  for (const child of ev.childEPCs) {
385
403
  const cur = this.items.get(child);
@@ -506,7 +524,7 @@ export class ObservedReducer {
506
524
  *
507
525
  * **한 항목뿐이면 담지 않는다.** 그 하나는 `qty`/`uom` 이 이미 무손실로 들고 있고, 1개짜리
508
526
  * 배열을 더 두면 같은 사실이 두 곳에 생긴다(그리고 시뮬은 그 배열을 만들지 않아 두 구동이
509
- * 갈라진다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
527
+ * 어긋난다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
510
528
  * 읽을 때는 단위를 가리지 않는 `quantityIn` 을 쓴다(단일이든 복수든 같은 답).
511
529
  */
512
530
  quantities: (() => {
@@ -599,6 +617,9 @@ export class ObservedReducer {
599
617
  return {
600
618
  revision: this.revision,
601
619
  ...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
620
+ ...(this.unhandled.size
621
+ ? { unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })).sort((a, b) => b.count - a.count) }
622
+ : {}),
602
623
  locations: [...this.master.values()].map(n => {
603
624
  const occupancy = occ.get(n.id) ?? 0;
604
625
  const status = locationStatusOf({ occupancy, capacity: n.capacity });
@@ -37,17 +37,17 @@ export interface OperationCapability {
37
37
  * `Math.max(0, …)` 하나로 덮는 일이 실제로 이 트윈에서 여러 번 있었다.
38
38
  */
39
39
  available: number | null;
40
- /** 무엇이 **천장**을 묶고 있나 — 축과 등급(용량 분석이 이미 낸다). 선언 기준이다. */
40
+ /** 무엇이 **상한**을 정하고 있나 — 축과 등급(용량 분석이 이미 낸다). 선언 기준이다. */
41
41
  constraint?: CapacityRequirement;
42
42
  /**
43
- * **남은 능력을 묶는 축** — 달성불가를 되찾으려면 여기부터다. `unattainable > 0` 일 때만 있다.
43
+ * **남은 능력을 제약하는 축** — 달성불가를 되찾으려면 여기부터다. `unattainable > 0` 일 때만 있다.
44
44
  *
45
- * 천장을 묶는 축과 **다를 수 있고**, 그 차이가 곧 사람이 헛걸음하는 자리다: 용접기 2대와 용접
46
- * 자격자 2명이 같은 천장을 낼 때 천장은 어느 쪽으로도 묶이지만, 자격 하나가 만료돼 잃은 몫의
47
- * 원인은 인원이다. 화면이 천장 쪽만 보이면 사람이 **설비를 보러 간다**(실제로 그랬다).
45
+ * 상한을 제약하는 축과 **다를 수 있고**, 그 차이가 곧 사람이 헛걸음하는 자리다: 용접기 2대와 용접
46
+ * 자격자 2명이 같은 상한을 낼 때 상한은 어느 쪽으로도 제약되지만, 자격 하나가 만료돼 잃은 몫의
47
+ * 원인은 인원이다. 화면이 상한 쪽만 보이면 사람이 **설비를 보러 간다**(실제로 그랬다).
48
48
  *
49
- * 값은 이미 손에 있다 — 달성불가를 내려고 가용 자원 기준으로 같은 계산을 한 번 더 돌리기 때문이다.
50
- * 그 계산이 묶는 축이 곧 이 답이고, 따로 판정을 만들지 않는다.
49
+ * 값은 이미 손에 있다 — 달성불가를 내려고 가용 자원 기준으로 같은 계산을 한 번 더 실행하기 때문이다.
50
+ * 그 계산이 제약하는 축이 곧 이 답이고, 따로 판정을 만들지 않는다.
51
51
  */
52
52
  unattainableBy?: CapacityRequirement;
53
53
  }
@@ -112,6 +112,6 @@ export declare function workingHoursBetween(calendar: readonly WorkCalendarEntry
112
112
  * 생산 능력 보고서 — 있는 용량 분석 위에 **구간 · 종류 · 약정**을 올린다.
113
113
  *
114
114
  * 용량 분석을 두 번 부른다(전량 · 가용). 같은 함수를 두 번 부르는 것이 요점이다: 달성불가를 따로
115
- * 계산하면 규칙이 두 벌이 되고, 두 벌은 갈라진다.
115
+ * 계산하면 규칙이 두 벌이 되고, 두 벌은 어긋난다.
116
116
  */
117
117
  export declare function operationsCapabilityOf(input: OperationsCapabilityInput): OperationsCapabilityReport;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ── 왜 용량 분석만으로는 부족한가 ────────────────────────────────────────────
5
5
  * `capacity.ts` 는 이미 네 축(설비·인원·자산·자리) 정상상태 상한과 병목을 낸다. 그런데 그것은 **비율**
6
- * 이다(시간당 몇 대). 표준이 게시하라는 것은 **구간의 양**이고, 그 양을 **종류로 가른다.**
6
+ * 이다(시간당 몇 대). 표준이 게시하라는 것은 **구간의 양**이고, 그 양을 **종류로 구분한다.**
7
7
  *
8
8
  * 종류가 없으면 답할 수 없는 질문이 있다 — 그리고 그것이 이 개념을 쓰는 가장 흔한 질문이다:
9
9
  * **"지금 주문을 더 받을 수 있나."** 상한만 있으면 "시간당 12대" 까지고, 이미 약속한 물량을 빼야
@@ -51,7 +51,7 @@ export function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
51
51
  * 생산 능력 보고서 — 있는 용량 분석 위에 **구간 · 종류 · 약정**을 올린다.
52
52
  *
53
53
  * 용량 분석을 두 번 부른다(전량 · 가용). 같은 함수를 두 번 부르는 것이 요점이다: 달성불가를 따로
54
- * 계산하면 규칙이 두 벌이 되고, 두 벌은 갈라진다.
54
+ * 계산하면 규칙이 두 벌이 되고, 두 벌은 어긋난다.
55
55
  */
56
56
  export function operationsCapabilityOf(input) {
57
57
  const fromMs = Date.parse(input.window.fromTime);
@@ -3,7 +3,7 @@ import type { TaskStatusDelta } from './contract.ts';
3
3
  * 되읽을 저널 한 줄 — 필요한 것만.
4
4
  *
5
5
  * `data`(정본 봉투) 와 `payload`(호스트가 감싸 저장한 형태) 를 모두 받는다. 저장 계층마다 감싸는 방식이
6
- * 달라 소비처가 각자 벗기고 있었고, 그 벗기는 규칙이 또 갈라졌다.
6
+ * 달라 소비처가 각자 벗기고 있었고, 그 벗기는 규칙이 또 어긋났다.
7
7
  */
8
8
  export interface TaskDeltaRow {
9
9
  /** ISO 시각. 파싱 불가하면 그 줄은 버린다(조용히 0 으로 세지 않는다). */
package/dist/task-fold.js CHANGED
@@ -4,7 +4,7 @@ function ms(value) {
4
4
  const t = Date.parse(value);
5
5
  return Number.isFinite(t) ? t : null;
6
6
  }
7
- /** 저장 형태 차이를 여기서 한 번만 벗긴다(소비처가 각자 벗기면 그 규칙이 갈라진다). */
7
+ /** 저장 형태 차이를 여기서 한 번만 벗긴다(소비처가 각자 벗기면 그 규칙이 어긋난다). */
8
8
  function deltaOf(row) {
9
9
  const p = row.payload;
10
10
  return (row.data ?? p?.data ?? p ?? {});
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * TwinObserver — 자동 정합 루프 (트윈이 자기 모델↔현실 이탈을 능동 감지).
3
3
  *
4
- * fork(현재예측) + compareStates(정합)를 살아있는 루프로: 주기적으로 fork 해서 horizon 앞으로 굴려
4
+ * fork(현재예측) + compareStates(정합)를 살아있는 루프로: 주기적으로 fork 해서 horizon 앞으로 시뮬레이션해
5
5
  * 예측을 저장하고, 실제(live)가 그 시점에 도달하면 예측과 대조해 드리프트를 알린다.
6
6
  * 순수 sim(방해 없음)이면 예측=미래(RNG 연속) → 발산 0. 방해(커맨드·실이벤트)면 발산 → 이상/개입 신호.
7
7
  */
@@ -17,15 +17,4 @@ export declare const BTT: {
17
17
  * 도메인 어휘 SSOT: 호스트·UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).
18
18
  */
19
19
  export declare const WMS_LOCATION_TYPES: readonly ["dock", "storage", "staging", "dock-ship", "vas-station"];
20
- /**
21
- * 유통가공 작업대(`vas-station`) — 창고에서 **자재를 소비해 자재를 산출하는** 자리.
22
- *
23
- * 표준으로는 ISA-95 `WorkCenter` 이고, 하는 일은 EPCIS 로 보면 변환(부품 → 세트 SKU)이다.
24
- * "VAS" 는 물류업계 용어일 뿐 표준 엔티티가 아니라, 커널은 그 이름의 개념을 새로 만들지 않는다 —
25
- * 능력으로 말한다: 물건을 담고(`storable`) 가공한다(`processable`).
26
- *
27
- * 예전에는 이 타입이 카탈로그에 **없었다.** 그래서 유통가공 창고 템플릿이 이 자리를 만들 때마다
28
- * 인제스트가 "모르는 자리 타입" 경고를 냈고(그 경고는 화면에도 보이지 않았다), 커널은 그 자리를
29
- * 흐름에서 빼 두었다. 키팅이 일어나지 않는 유통가공 창고가 그렇게 만들어졌다.
30
- */
31
20
  export declare const WMS_TYPES: TwinTypeInfo[];
@@ -34,9 +34,24 @@ export const WMS_LOCATION_TYPES = ['dock', 'storage', 'staging', 'dock-ship', 'v
34
34
  * 인제스트가 "모르는 자리 타입" 경고를 냈고(그 경고는 화면에도 보이지 않았다), 커널은 그 자리를
35
35
  * 흐름에서 빼 두었다. 키팅이 일어나지 않는 유통가공 창고가 그렇게 만들어졌다.
36
36
  */
37
+ /**
38
+ * 자리의 **ISA-95 계층 단** — 총칭 관계를 그대로 쓴다(`WorkUnit` ⊂ `WorkCenter` 단이 아니라,
39
+ * `StorageUnit` 은 `WorkUnit` 의 한 종류이고 `StorageZone` 은 `WorkCenter` 의 한 종류다).
40
+ *
41
+ * · `storage` = 보관 단위 하나(랙·빈) → `StorageUnit`
42
+ * · `staging`·`dock`·`dock-ship` = 여러 단위를 담는 구역 → `StorageZone`
43
+ * · `vas-station` = 자재를 소비해 산출하는 작업 자리 → `WorkCell`
44
+ */
45
+ const WMS_LOCATION_LEVEL = {
46
+ dock: 'StorageZone',
47
+ storage: 'StorageUnit',
48
+ staging: 'StorageZone',
49
+ 'dock-ship': 'StorageZone',
50
+ 'vas-station': 'WorkCell'
51
+ };
37
52
  export const WMS_TYPES = [
38
- ...WMS_LOCATION_TYPES.filter(k => k !== 'vas-station').map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
39
- { key: 'vas-station', role: 'location', label: 'twin.type.vas-station', standardClass: { epcis: 'bizLocation', isa95: 'WorkCenter' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable', 'processable'] },
53
+ ...WMS_LOCATION_TYPES.filter(k => k !== 'vas-station').map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, level: WMS_LOCATION_LEVEL[k], capabilities: ['storable'] })),
54
+ { key: 'vas-station', role: 'location', label: 'twin.type.vas-station', standardClass: { epcis: 'bizLocation', isa95: 'WorkCenter' }, identity: { scheme: 'gs1:SGLN' }, level: WMS_LOCATION_LEVEL['vas-station'], capabilities: ['storable', 'processable'] },
40
55
  { key: 'forklift', role: 'equipment', label: 'twin.type.forklift', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] },
41
56
  /** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
42
57
  { key: 'packer', role: 'equipment', label: 'twin.type.packer', standardClass: { epcis: 'object', isa95: 'Equipment' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
@@ -18,7 +18,22 @@ export const YMS_LOCATION_TYPES = ['gate', 'yard-slot', 'dock-door', 'staging'];
18
18
  * 트윈 타입 서술(ADR-0018 확장) — 자리 키는 YMS_LOCATION_TYPES 단일 출처에서 파생 + 설비 타입 추가.
19
19
  * YMS=EPCIS zone: 로케이션/존=bizLocation(SGLN), 설비(야드 트랙터)=오브젝트/자산(GIAI).
20
20
  */
21
+ /**
22
+ * 자리의 **ISA-95 계층 단.**
23
+ *
24
+ * · `yard-slot`·`dock-door` = 트레일러 한 대가 서는 자리 → `StorageUnit`
25
+ * · `staging` = 여러 자리를 담는 구역 → `StorageZone`
26
+ * · `gate` = **표준에 대응하는 단이 없다.** 게이트는 보관도 작업도 아닌 **통과점**이다. 가까운
27
+ * 이름을 억지로 적으면(예: StorageUnit) 롤업이 게이트를 보관 자리로 세고 점유율이 거짓이 된다.
28
+ * 표준이 `Other` 를 탈출구로 두었으므로 그것을 쓰고, 현장의 낱말은 `type` 에 남는다.
29
+ */
30
+ const YMS_LOCATION_LEVEL = {
31
+ gate: 'Other',
32
+ 'yard-slot': 'StorageUnit',
33
+ 'dock-door': 'StorageUnit',
34
+ staging: 'StorageZone'
35
+ };
21
36
  export const YMS_TYPES = [
22
- ...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
37
+ ...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, level: YMS_LOCATION_LEVEL[k], capabilities: ['storable'] })),
23
38
  { key: 'hostler', role: 'equipment', label: 'twin.type.hostler', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
24
39
  ];