@things-factory/headless-twin 10.0.5 → 10.0.7

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.
Files changed (161) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
package/README.md CHANGED
@@ -1,35 +1,48 @@
1
1
  # @things-factory/headless-twin
2
2
 
3
- operato-twin 커널(WMS/YMS/MES, zero-dep·결정적)을 things-factory 모듈로 호스팅하는 **walking skeleton**.
3
+ operato-twin 커널(WMS/YMS/MES, zero-dep·결정적)을 things-factory 모듈로 호스팅한다.
4
4
  State 를 GraphQL subscription 으로 스트리밍하고, 이벤트를 append-only 로 영속하며, 워커 tick 으로 시뮬을 진행하고, command/scenario 를 mutation 으로 받는다.
5
5
 
6
+ - 커널: `@operato/twin-kernel` **^0.2.3**
7
+ - 상태: **159 tests green** (`node --test test/*.test.ts`)
8
+
6
9
  설계 SoT: `operato-twin/design/integration/things-factory-host.md`. Kernel↔Host 경계(§6): **성능·의존성·스케일·기하 = host/scene, 정합성·결정성·zero-dep = kernel**.
7
10
 
8
- ## 구조 (integration-base 미러)
9
- - `engine/twin-engine.ts` — 메모리 인스턴스 맵 + `subscribe→pubsub` 브리지 + 워커 `tick` + append-only 영속. (ScenarioEngine 패턴)
10
- - `service/twin-state/``@Subscription twinState(instanceId)`: 구독 즉시 snapshot 발행 후 delta/clock 스트림. (state-register 패턴)
11
- - `service/twin-event/` — append-only `TwinEvent` 엔티티(멀티DB `simple-json` payload).
12
- - `service/twin-control/` — `dispatchTwinCommand`·`controlTwinScenario` mutation.
11
+ ## 구조
12
+
13
+ ### engine커널을 감싸는 것들
14
+ - `twin-engine.ts`인스턴스 맵 + `subscribe→pubsub` 브리지 + 워커 `tick` + append-only 영속. 마스터에서 공정 명세를 실어 주고(`applyOperations`) 추정기를 주입한다(`installEstimators`).
15
+ - `canonical-ingest.ts`실세계 피드 → 정규 EPCIS 한 문(`ingestLive`). **저널 기록은 여기 한 곳뿐이다.**
16
+ - `measured-estimator.ts` · `travel-estimator.ts` — 소요시간 추정기. 실측(저널 폴드, TTL 캐시) → 거리×속도(좌표+설비 속도). 셋 중 하나라도 모르면 **답하지 않는다**(커널이 명세·상수로 떨어진다).
17
+ - `spec-coverage.ts` · `model-basis.ts` — 예측이 무엇에 근거했는지(`relative`/`partial`/`absolute-capable`/`calibrated`)와 근거가 바뀌었는지(보정 신선도).
18
+ - `kpi-fold.ts` · `kpi-query.ts` · `oee-accumulator.ts` — 저널을 접어 성과를 낸다(순수 폴드, 사실만).
19
+ - `entity-delta.ts` · `live-attentions.ts` · `warm-start.ts` — 화면용 엔티티 델타, 라이브 주목 신호, 재기동 복구.
20
+
21
+ ### service — 바깥으로 낸 면
22
+ `twin-state`(구독) · `twin-event`(저널) · `twin-control`(command/scenario) · `twin-instance` · `twin-lifecycle` · `twin-forecast` · `twin-metrics` · `twin-journal` · `twin-attention` · `twin-space` · `reference`(마스터·커넥터).
23
+
24
+ ## 라이브도 커널이다
25
+
26
+ 예전에는 시뮬 인스턴스만 커널을 가졌고, 라이브(미러)는 투영기만 세웠다. 그래서 미러 트윈에 예측을 물으면 답할 것이 없어 **요청마다 일회용 커널을 세우는 우회**가 붙어 있었다.
27
+
28
+ 지금은 `startLive` 도 **관측 모드 커널**을 세운다. 규칙은 커널 안에 한 벌뿐이고(`ObservedReducer`), 호스트는 그 커널에 이벤트를 흘릴 뿐이다. 결과:
29
+
30
+ - 미러 트윈이 우회 없이 예측한다 — 인스턴스가 곧 커널이므로 찾을 것이 이미 거기 있다.
31
+ - 명세·추정기가 라이브에도 붙어 **예측 자격이 트윈 종류를 가리지 않는다.**
32
+ - 저널 중복 기록은 **구조적으로 불가능**하다 — 기록은 `ingestLive` 한 곳뿐이고, `startLive` 는 커널 방출을 구독하지 않는다.
13
33
 
14
34
  ## 커널 연동
15
- - 커널은 ESM+CJS 이중배포. 이 모듈(CommonJS, Node 20)은 **CJS 번들을 `require`** 로 로드(require(ESM) 회피). 타입은 `import type` 로 `.d.ts` 참조. import·require 모두 패키지명 `@operato/twin-kernel`.
16
- - `package.json` 의존: `"@operato/twin-kernel": "^0.0.1"` (npm 공개 배포). 로컬 개발은 `yarn link @operato/twin-kernel` 또는 file: 대체 가능.
17
-
18
- ## 빌드·등록·검증 (things-factory 환경)
19
- 1. **커널 준비**: `@operato/twin-kernel` npm 배포돼 있어야 함(또는 로컬 링크). 소비되는 건 dist(ESM+d.ts) + dist-cjs(CJS).
20
- 2. **설치·빌드**: 모노레포에서 `yarn install`(→ `@operato/twin-kernel` 설치) `npm run build`(tsc → `dist-server`).
21
- 3. **모듈 등록**: 패키지가 `@things-factory/headless-twin` 의존해야 `orderedModuleNames` 로드(schema/entity 병합).
22
- 4. **인스턴스 기동**: `TwinEngine.start(id, domainId, 'wms', boardDef)` (부팅 설정 or 관리 mutation 에서). BoardDef→씬 저작물 어댑터는 향후 — 지금은 코드 BoardDef.
23
- 5. **검증**: `subscription { twinState(instanceId) { kind revision payload } }` snapshot 먼저, 이후 delta/clock. `mutation controlTwinScenario(instanceId, "start")` delta 흐름 + `TwinEvent` 행 적재.
24
-
25
- ## ⚠️ 이 환경(operato-twin)에서 미검증 — things-factory 첫 빌드에서 확인 필요
26
- type-graphql/TypeORM/@things-factory/shell 툴체인이 없어 여기선 빌드 못 함. 첫 빌드에서 점검:
27
- - `require('@operato/twin-kernel')` 타이핑(@types/node NodeRequire → any) 및 CJS 해상도.
28
- - `ScalarObject` 를 `@Field` 타입 + `simple-json` Column 조합(엔티티/구독 payload).
29
- - `@Subscription(returns => …, { subscribe, filter })` 시그니처, `pubsub.publish/subscribe` 페이로드 형태.
30
- - `getRepository(TwinEvent)` 제너릭 + `bootstrap-module-start` 페이로드.
35
+ - 커널은 ESM+CJS 이중배포. 이 모듈(CommonJS)은 **CJS 번들을 `require`** 로 로드(require(ESM) 회피). 타입은 `import type` 로 `.d.ts` 참조. 양쪽 패키지명 `@operato/twin-kernel`.
36
+ - **0.x semver 함정**: `^0.2.0` 은 0.2.1 집지 않는다. 커널을 올릴 **루트와 소비 패키지 다** 범위를 올려야 하고, 안 그러면 호이스팅된 옛 사본이 이긴다(중첩 `node_modules` 잔여 사본도 확인).
37
+
38
+ ## 검증
39
+ 1. `yarn install` `npm run build`(tsc `dist-server`).
40
+ 2. 패키지가 `@things-factory/headless-twin` 의존해야 `orderedModuleNames` 로드한다(schema/entity 병합).
41
+ 3. `subscription { twinState(instanceId) { kind revision payload } }` snapshot 먼저, 이후 delta/clock.
42
+ 4. `mutation controlTwinScenario(instanceId, "start")` delta 흐름 + `TwinEvent` 적재.
43
+ 5. 단위 테스트는 **가벼운 import 만** 한다 `twin-engine.ts` DB·shell 잡으므로 단위 테스트로 닿지 않는다. 층은 실행 중인 앱에서 확인한다.
31
44
 
32
45
  ## 스케일 하드닝 (향후, §host 경계)
33
- - 워커 tick 은 스켈레톤 `setInterval`(메인 루프). 긴 시뮬·다수 인스턴스는 **worker thread 오프-루프**.
46
+ - 워커 tick 은 메인 루프 `setInterval`. 긴 시뮬·다수 인스턴스는 **worker thread 오프-루프**.
34
47
  - ingest 검증 throughput 필요 시 **host 에서 Ajv 컴파일**(커널 zero-dep 검증기는 정본 유지).
35
- - 재부팅 복구: `TwinEvent`(DB) → 커널 `replay` 로 상태 재구성.
48
+ - 재부팅 복구: `TwinEvent`(DB) → `warm-start` 로 상태 재구성.
@@ -2,6 +2,18 @@ export interface EntityDelta {
2
2
  tag: string;
3
3
  data: any;
4
4
  }
5
- /** 오더 집약 고정 태그 — 클라 provider(ORDERS_TAG) 일치. */
5
+ /** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
6
6
  export declare const ORDERS_TAG = "__orders__";
7
+ export declare const TASKS_TAG = "__tasks__";
8
+ export declare const ITEMS_TAG = "__items__";
9
+ export declare const PERSONS_TAG = "__persons__";
10
+ export declare const ASSETS_TAG = "__assets__";
11
+ /**
12
+ * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.
13
+ *
14
+ * **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
15
+ * "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
16
+ * 이 걸려 **변한 것만** 재방송된다.
17
+ */
18
+ export declare const ITEMS_AGGREGATE_LIMIT = 500;
7
19
  export declare function buildEntityDeltas(state: any): EntityDelta[];
@@ -2,17 +2,50 @@
2
2
  /*
3
3
  * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).
4
4
  * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.
5
- * payload 계약은 씬 컴포넌트가 소비(twin-storage·mover·equipment·trend·order-tracker·resource-control):
6
- * node: occupancy/capacity/status/type · mover: location/motion/status/held/oee(정수 퍼센트) ·
7
- * order: status/progress/held · __orders__: 전체 오더 집약(Trackable 뷰).
5
+ *
6
+ * ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ────────────────────────────
7
+ * 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는
8
+ * 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.**
9
+ *
10
+ * 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 조용히 누락됐다
11
+ * (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은
12
+ * **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는
13
+ * 것을 잊으면 아무 신호 없이 정보가 사라진다.
14
+ *
15
+ * 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께
16
+ * 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다.
17
+ *
18
+ * ── 표식은 도메인 낱말을 훔치지 않는다 ─────────────────────────────────────
19
+ * 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 —
20
+ * 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을
21
+ * 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다).
22
+ * 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다.
23
+ *
24
+ * payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함).
25
+ * entity = location · equipment · person · asset · order · task · item,
26
+ * 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__).
8
27
  */
9
28
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.ORDERS_TAG = void 0;
29
+ exports.ITEMS_AGGREGATE_LIMIT = exports.ASSETS_TAG = exports.PERSONS_TAG = exports.ITEMS_TAG = exports.TASKS_TAG = exports.ORDERS_TAG = void 0;
11
30
  exports.buildEntityDeltas = buildEntityDeltas;
12
- /** 오더 집약 고정 태그 — 클라 provider(ORDERS_TAG) 일치. */
31
+ /** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
13
32
  exports.ORDERS_TAG = '__orders__';
14
- /** OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트. 원시 누적 ms(idleMs 등)는 매 delta 증가해
15
- * 무변화-생략을 무력화하므로 제외(idle 자원의 매초 재방송 억제). oee 미측정이면 undefined. */
33
+ exports.TASKS_TAG = '__tasks__';
34
+ exports.ITEMS_TAG = '__items__';
35
+ exports.PERSONS_TAG = '__persons__';
36
+ exports.ASSETS_TAG = '__assets__';
37
+ /**
38
+ * 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.**
39
+ *
40
+ * · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다).
41
+ * · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 "무변화 생략" 을 무력화한다. shapeOee 로 정형화해 얹는다.
42
+ */
43
+ const EXCLUDED = new Set(['id', 'epc', 'oee']);
44
+ /**
45
+ * OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.
46
+ * 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재방송 억제).
47
+ * oee 미측정이면 undefined.
48
+ */
16
49
  function shapeOee(o) {
17
50
  if (!o)
18
51
  return undefined;
@@ -25,22 +58,103 @@ function shapeOee(o) {
25
58
  scrap: o.scrapCount ?? 0
26
59
  };
27
60
  }
61
+ /**
62
+ * 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함).
63
+ *
64
+ * `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은
65
+ * 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다.
66
+ */
67
+ function payloadOf(entityKind, entity) {
68
+ /* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */
69
+ const data = { entity: entityKind };
70
+ for (const [k, v] of Object.entries(entity ?? {})) {
71
+ if (EXCLUDED.has(k) || v === undefined)
72
+ continue;
73
+ data[k] = v;
74
+ }
75
+ return data;
76
+ }
77
+ /**
78
+ * 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자.
79
+ * 집약은 "전체를 한 컴포넌트가 추적" 하는 용도라 식별자가 있어야 한다.
80
+ */
81
+ function aggregateItem(entity, idKey) {
82
+ const out = { [idKey]: entity?.[idKey] };
83
+ for (const [k, v] of Object.entries(entity ?? {})) {
84
+ if (k === idKey || k === 'oee' || v === undefined)
85
+ continue;
86
+ out[k] = v;
87
+ }
88
+ const shaped = shapeOee(entity?.oee);
89
+ if (shaped)
90
+ out.oee = shaped;
91
+ return out;
92
+ }
93
+ /**
94
+ * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.
95
+ *
96
+ * **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
97
+ * "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
98
+ * 이 걸려 **변한 것만** 재방송된다.
99
+ */
100
+ exports.ITEMS_AGGREGATE_LIMIT = 500;
28
101
  function buildEntityDeltas(state) {
29
102
  const out = [];
30
- for (const n of state?.nodes ?? []) {
31
- out.push({ tag: n.id, data: { kind: 'node', occupancy: n.occupancy, capacity: n.capacity, status: n.status, type: n.type } });
103
+ for (const n of state?.locations ?? []) {
104
+ out.push({ tag: n.id, data: payloadOf('location', n) });
105
+ }
106
+ for (const m of state?.equipment ?? []) {
107
+ /* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로.
108
+ * oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */
109
+ const data = payloadOf('equipment', m);
110
+ const oee = shapeOee(m?.oee);
111
+ if (oee)
112
+ data.oee = oee;
113
+ out.push({ tag: m.id, data });
32
114
  }
33
- for (const m of state?.movers ?? []) {
34
- // Mobile(location/motion) + Operable(status/held) + Processable(oee) 를 한 payload 로.
35
- out.push({ tag: m.id, data: { kind: 'mover', location: m.location, motion: m.motion, status: m.status, held: m.held, oee: shapeOee(m.oee) } });
115
+ /* 사람 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */
116
+ for (const p of state?.persons ?? []) {
117
+ out.push({ tag: p.id, data: payloadOf('person', p) });
118
+ }
119
+ /* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */
120
+ for (const a of state?.assets ?? []) {
121
+ out.push({ tag: a.id, data: payloadOf('asset', a) });
36
122
  }
37
123
  for (const o of state?.orders ?? []) {
38
- out.push({ tag: o.id, data: { kind: 'order', status: o.status, progress: o.progress, held: o.held } });
124
+ out.push({ tag: o.id, data: payloadOf('order', o) });
125
+ }
126
+ for (const t of state?.tasks ?? []) {
127
+ out.push({ tag: t.id, data: payloadOf('task', t) });
128
+ }
129
+ for (const it of state?.items ?? []) {
130
+ /* 물품의 태그는 epc(개체 식별자) — 노드·무버와 달리 id 가 아니다. */
131
+ if (it?.epc)
132
+ out.push({ tag: it.epc, data: payloadOf('item', it) });
39
133
  }
40
- // 집약(Trackable) — 오더는 런타임 생성이라 개별 배치 불가 → 한 컴포넌트가 전체 추적(집약 N:M). 항상 방출.
134
+ /* ── 집약 ──────────────────────────────────────────────────────────────
135
+ * 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가
136
+ * 전체를 추적하는 집약 태그를 항상 방출한다. */
137
+ const orders = state?.orders ?? [];
138
+ out.push({ tag: exports.ORDERS_TAG, data: { entity: 'orders', orders: orders.map((o) => aggregateItem(o, 'id')) } });
139
+ const tasks = state?.tasks ?? [];
140
+ out.push({ tag: exports.TASKS_TAG, data: { entity: 'tasks', tasks: tasks.map((t) => aggregateItem(t, 'id')) } });
141
+ /* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.
142
+ * 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */
143
+ const persons = state?.persons ?? [];
144
+ out.push({ tag: exports.PERSONS_TAG, data: { entity: 'persons', persons: persons.map((p) => aggregateItem(p, 'id')) } });
145
+ const assets = state?.assets ?? [];
146
+ out.push({ tag: exports.ASSETS_TAG, data: { entity: 'assets', assets: assets.map((a) => aggregateItem(a, 'id')) } });
147
+ const items = state?.items ?? [];
148
+ const shown = items.slice(0, exports.ITEMS_AGGREGATE_LIMIT);
41
149
  out.push({
42
- tag: exports.ORDERS_TAG,
43
- data: { kind: 'orders', orders: (state?.orders ?? []).map((o) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.progress, held: o.held })) }
150
+ tag: exports.ITEMS_TAG,
151
+ data: {
152
+ entity: 'items',
153
+ items: shown.map((it) => aggregateItem(it, 'epc')),
154
+ total: items.length,
155
+ /* 잘렸으면 그 사실을 말한다 — "이게 전부" 로 읽히면 재고를 과소 판단한다. */
156
+ ...(items.length > shown.length ? { truncated: items.length - shown.length } : {})
157
+ }
44
158
  });
45
159
  return out;
46
160
  }
@@ -1 +1 @@
1
- {"version":3,"file":"entity-delta.js","sourceRoot":"","sources":["../../server/engine/entity-delta.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).\n * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.\n * payload 계약은 씬 컴포넌트가 소비(twin-storage·mover·equipment·trend·order-tracker·resource-control):\n * node: occupancy/capacity/status/type · mover: location/motion/status/held/oee(정수 퍼센트) ·\n * order: status/progress/held · __orders__: 전체 오더 집약(Trackable 뷰).\n */\n\nexport interface EntityDelta {\n tag: string\n data: any\n}\n\n/** 오더 집약 고정 태그 — 클라 provider(ORDERS_TAG)와 일치. */\nexport const ORDERS_TAG = '__orders__'\n\n/** OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트. 원시 누적 ms(idleMs 등)는 매 delta 증가해\n * 무변화-생략을 무력화하므로 제외(idle 자원의 매초 재방송 억제). oee 미측정이면 undefined. */\nfunction shapeOee(o: any): any {\n if (!o) return undefined\n return {\n overall: Math.round((o.overall ?? 0) * 100),\n availability: Math.round((o.availability ?? 0) * 100),\n performance: Math.round((o.performance ?? 0) * 100),\n quality: Math.round((o.quality ?? 0) * 100),\n good: o.goodCount ?? 0,\n scrap: o.scrapCount ?? 0\n }\n}\n\nexport function buildEntityDeltas(state: any): EntityDelta[] {\n const out: EntityDelta[] = []\n for (const n of state?.nodes ?? []) {\n out.push({ tag: n.id, data: { kind: 'node', occupancy: n.occupancy, capacity: n.capacity, status: n.status, type: n.type } })\n }\n for (const m of state?.movers ?? []) {\n // Mobile(location/motion) + Operable(status/held) + Processable(oee) 를 한 payload 로.\n out.push({ tag: m.id, data: { kind: 'mover', location: m.location, motion: m.motion, status: m.status, held: m.held, oee: shapeOee(m.oee) } })\n }\n for (const o of state?.orders ?? []) {\n out.push({ tag: o.id, data: { kind: 'order', status: o.status, progress: o.progress, held: o.held } })\n }\n // 집약(Trackable) — 오더는 런타임 생성이라 개별 배치 불가 → 한 컴포넌트가 전체 추적(집약 N:M). 항상 방출.\n out.push({\n tag: ORDERS_TAG,\n data: { kind: 'orders', orders: (state?.orders ?? []).map((o: any) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.progress, held: o.held })) }\n })\n return out\n}\n"]}
1
+ {"version":3,"file":"entity-delta.js","sourceRoot":"","sources":["../../server/engine/entity-delta.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).\n * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.\n *\n * ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ────────────────────────────\n * 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는\n * 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.**\n *\n * 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 조용히 누락됐다\n * (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은\n * **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는\n * 것을 잊으면 아무 신호 없이 정보가 사라진다.\n *\n * 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께\n * 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다.\n *\n * ── 표식은 도메인 낱말을 훔치지 않는다 ─────────────────────────────────────\n * 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 —\n * 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을\n * 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다).\n * 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다.\n *\n * payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함).\n * entity = location · equipment · person · asset · order · task · item,\n * 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__).\n */\n\nexport interface EntityDelta {\n tag: string\n data: any\n}\n\n/** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */\nexport const ORDERS_TAG = '__orders__'\nexport const TASKS_TAG = '__tasks__'\nexport const ITEMS_TAG = '__items__'\nexport const PERSONS_TAG = '__persons__'\nexport const ASSETS_TAG = '__assets__'\n\n/**\n * 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.**\n *\n * · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다).\n * · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 \"무변화 생략\" 을 무력화한다. shapeOee 로 정형화해 얹는다.\n */\nconst EXCLUDED = new Set(['id', 'epc', 'oee'])\n\n/**\n * OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.\n * 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재방송 억제).\n * oee 미측정이면 undefined.\n */\nfunction shapeOee(o: any): any {\n if (!o) return undefined\n return {\n overall: Math.round((o.overall ?? 0) * 100),\n availability: Math.round((o.availability ?? 0) * 100),\n performance: Math.round((o.performance ?? 0) * 100),\n quality: Math.round((o.quality ?? 0) * 100),\n good: o.goodCount ?? 0,\n scrap: o.scrapCount ?? 0\n }\n}\n\n/**\n * 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함).\n *\n * `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은\n * 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다.\n */\nfunction payloadOf(entityKind: string, entity: any): any {\n /* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */\n const data: any = { entity: entityKind }\n for (const [k, v] of Object.entries(entity ?? {})) {\n if (EXCLUDED.has(k) || v === undefined) continue\n data[k] = v\n }\n return data\n}\n\n/**\n * 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자.\n * 집약은 \"전체를 한 컴포넌트가 추적\" 하는 용도라 식별자가 있어야 한다.\n */\nfunction aggregateItem(entity: any, idKey: 'id' | 'epc'): any {\n const out: any = { [idKey]: entity?.[idKey] }\n for (const [k, v] of Object.entries(entity ?? {})) {\n if (k === idKey || k === 'oee' || v === undefined) continue\n out[k] = v\n }\n const shaped = shapeOee(entity?.oee)\n if (shaped) out.oee = shaped\n return out\n}\n\n/**\n * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.\n *\n * **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가\n * \"이게 전부\" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup\n * 이 걸려 **변한 것만** 재방송된다.\n */\nexport const ITEMS_AGGREGATE_LIMIT = 500\n\nexport function buildEntityDeltas(state: any): EntityDelta[] {\n const out: EntityDelta[] = []\n\n for (const n of state?.locations ?? []) {\n out.push({ tag: n.id, data: payloadOf('location', n) })\n }\n for (const m of state?.equipment ?? []) {\n /* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로.\n * oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */\n const data = payloadOf('equipment', m)\n const oee = shapeOee(m?.oee)\n if (oee) data.oee = oee\n out.push({ tag: m.id, data })\n }\n /* 사람 — 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */\n for (const p of state?.persons ?? []) {\n out.push({ tag: p.id, data: payloadOf('person', p) })\n }\n /* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */\n for (const a of state?.assets ?? []) {\n out.push({ tag: a.id, data: payloadOf('asset', a) })\n }\n for (const o of state?.orders ?? []) {\n out.push({ tag: o.id, data: payloadOf('order', o) })\n }\n for (const t of state?.tasks ?? []) {\n out.push({ tag: t.id, data: payloadOf('task', t) })\n }\n for (const it of state?.items ?? []) {\n /* 물품의 태그는 epc(개체 식별자) — 노드·무버와 달리 id 가 아니다. */\n if (it?.epc) out.push({ tag: it.epc, data: payloadOf('item', it) })\n }\n\n /* ── 집약 뷰 ──────────────────────────────────────────────────────────────\n * 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가\n * 전체를 추적하는 집약 태그를 항상 방출한다. */\n const orders = state?.orders ?? []\n out.push({ tag: ORDERS_TAG, data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')) } })\n\n const tasks = state?.tasks ?? []\n out.push({ tag: TASKS_TAG, data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } })\n\n /* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.\n * 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */\n const persons = state?.persons ?? []\n out.push({ tag: PERSONS_TAG, data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } })\n\n const assets = state?.assets ?? []\n out.push({ tag: ASSETS_TAG, data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } })\n\n const items = state?.items ?? []\n const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT)\n out.push({\n tag: ITEMS_TAG,\n data: {\n entity: 'items',\n items: shown.map((it: any) => aggregateItem(it, 'epc')),\n total: items.length,\n /* 잘렸으면 그 사실을 말한다 — \"이게 전부\" 로 읽히면 재고를 과소 판단한다. */\n ...(items.length > shown.length ? { truncated: items.length - shown.length } : {})\n }\n })\n\n return out\n}\n"]}
@@ -2,3 +2,7 @@ export * from './twin-engine.js';
2
2
  export * from './canonical-ingest.js';
3
3
  export * from './kpi-fold.js';
4
4
  export * from './kpi-query.js';
5
+ export * from './spec-coverage.js';
6
+ export * from './travel-estimator.js';
7
+ export * from './measured-estimator.js';
8
+ export * from './model-basis.js';
@@ -5,4 +5,8 @@ tslib_1.__exportStar(require("./twin-engine.js"), exports);
5
5
  tslib_1.__exportStar(require("./canonical-ingest.js"), exports);
6
6
  tslib_1.__exportStar(require("./kpi-fold.js"), exports);
7
7
  tslib_1.__exportStar(require("./kpi-query.js"), exports);
8
+ tslib_1.__exportStar(require("./spec-coverage.js"), exports);
9
+ tslib_1.__exportStar(require("./travel-estimator.js"), exports);
10
+ tslib_1.__exportStar(require("./measured-estimator.js"), exports);
11
+ tslib_1.__exportStar(require("./model-basis.js"), exports);
8
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/engine/index.ts"],"names":[],"mappings":";;;AAAA,2DAAgC;AAChC,gEAAqC;AACrC,wDAA6B;AAC7B,yDAA8B","sourcesContent":["export * from './twin-engine.js'\nexport * from './canonical-ingest.js'\nexport * from './kpi-fold.js'\nexport * from './kpi-query.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/engine/index.ts"],"names":[],"mappings":";;;AAAA,2DAAgC;AAChC,gEAAqC;AACrC,wDAA6B;AAC7B,yDAA8B;AAC9B,6DAAkC;AAClC,gEAAqC;AACrC,kEAAuC;AACvC,2DAAgC","sourcesContent":["export * from './twin-engine.js'\nexport * from './canonical-ingest.js'\nexport * from './kpi-fold.js'\nexport * from './kpi-query.js'\nexport * from './spec-coverage.js'\nexport * from './travel-estimator.js'\nexport * from './measured-estimator.js'\nexport * from './model-basis.js'\n"]}
@@ -1,3 +1,4 @@
1
+ import { type WorkCalendarEntry } from '@operato/twin-kernel';
1
2
  /** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
2
3
  export interface KpiEvent {
3
4
  eventType?: string;
@@ -13,6 +14,11 @@ export interface KpiWindow {
13
14
  export interface DurationStats {
14
15
  /** 짝이 맞아 계산된 건수. */
15
16
  count: number;
17
+ /**
18
+ * 하위 꼬리 — **분포를 양쪽에서 접지하기 위해** 낸다.
19
+ * p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다.
20
+ */
21
+ p10Ms: number;
16
22
  p50Ms: number;
17
23
  p90Ms: number;
18
24
  avgMs: number;
@@ -24,10 +30,11 @@ export interface DurationStats {
24
30
  * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.
25
31
  *
26
32
  * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)
27
- * node 도착 지점별(어디서) order 오더별(무엇을 위해)
33
+ * location 도착 지점별(어디서) order 오더별(무엇을 위해)
28
34
  * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
35
+ * shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)
29
36
  */
30
- export type KpiGroupBy = 'resource' | 'taskKind' | 'node' | 'order' | 'area';
37
+ export type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift';
31
38
  export interface KpiGroup {
32
39
  /** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */
33
40
  key: string;
@@ -98,7 +105,17 @@ export interface KpiFoldOptions {
98
105
  * 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
99
106
  * 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).
100
107
  */
101
- nodeArea?: Record<string, string>;
108
+ locationArea?: Record<string, string>;
109
+ /**
110
+ * 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다.
111
+ * 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 조용히 바꾸지 않는다).
112
+ */
113
+ shifts?: WorkCalendarEntry[];
114
+ /**
115
+ * 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다
116
+ * (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC.
117
+ */
118
+ utcOffsetMinutes?: number;
102
119
  /**
103
120
  * 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.
104
121
  *
@@ -25,22 +25,13 @@ exports.crossGroups = crossGroups;
25
25
  exports.shiftWindow = shiftWindow;
26
26
  exports.deltaP50 = deltaP50;
27
27
  exports.foldKpiBuckets = foldKpiBuckets;
28
- /**
29
- * 작업에 대해 축의 값을 기억한다 **처음 값을 남긴다**.
30
- *
31
- * 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
32
- * 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
28
+ /*
29
+ * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
30
+ * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
31
+ * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 건으로 세어 처리량·점유가 부풀려졌다).
32
+ * 파일은 이제 "접힌 기록 지표" 한다.
33
33
  */
34
- function rememberFacets(store, id, d) {
35
- const prev = store.get(id) ?? {};
36
- const node = d.toNode ?? d.fromNode;
37
- store.set(id, {
38
- resource: prev.resource ?? (d.resourceRef || undefined),
39
- taskKind: prev.taskKind ?? (d.kind || undefined),
40
- order: prev.order ?? (d.orderId || undefined),
41
- node: node || prev.node
42
- });
43
- }
34
+ const twin_kernel_1 = require("@operato/twin-kernel");
44
35
  /** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
45
36
  function facetKey(rec, options) {
46
37
  const f = rec.facets;
@@ -48,15 +39,31 @@ function facetKey(rec, options) {
48
39
  case 'resource':
49
40
  return f.resource ?? 'unknown';
50
41
  case 'taskKind':
51
- return f.taskKind ?? 'unknown';
52
- case 'node':
53
- return f.node ?? 'unknown';
42
+ return f.kind ?? 'unknown';
43
+ case 'location':
44
+ return f.location ?? 'unknown';
54
45
  case 'order':
55
46
  return f.order ?? 'unknown';
56
47
  case 'area':
57
48
  /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가
58
49
  * 그것을 구역으로 오해한다). */
59
- return (f.node && options.nodeArea?.[f.node]) ?? 'unknown';
50
+ return (f.location && options.locationArea?.[f.location]) ?? 'unknown';
51
+ case 'shift':
52
+ /*
53
+ * 교대는 **저널에 찍혀 있지 않다 — 계산한다.**
54
+ *
55
+ * 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고,
56
+ * ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는
57
+ * `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 —
58
+ * 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다.
59
+ *
60
+ * 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
61
+ */
62
+ if (!options.shifts?.length)
63
+ return 'unknown';
64
+ /* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도
65
+ 않은 교대에 집계돼, 교대별 성과가 조용히 거짓이 된다. */
66
+ return (0, twin_kernel_1.activeShiftAt)(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown';
60
67
  default:
61
68
  return 'unknown';
62
69
  }
@@ -116,11 +123,12 @@ function quantile(sorted, q) {
116
123
  }
117
124
  function stats(values) {
118
125
  if (values.length === 0)
119
- return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 };
126
+ return { count: 0, p10Ms: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 };
120
127
  const sorted = [...values].sort((a, b) => a - b);
121
128
  const sum = sorted.reduce((a, b) => a + b, 0);
122
129
  return {
123
130
  count: sorted.length,
131
+ p10Ms: quantile(sorted, 0.1),
124
132
  p50Ms: quantile(sorted, 0.5),
125
133
  p90Ms: quantile(sorted, 0.9),
126
134
  avgMs: Math.round(sum / sorted.length)
@@ -137,13 +145,8 @@ function stats(values) {
137
145
  * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
138
146
  */
139
147
  function foldKpi(events, window, options = {}) {
140
- /* 작업별 이정표 시각 마지막 값을 남긴다(재시도로 같은 전이가 오면 나중 것이 사실). */
141
- const created = new Map();
142
- const started = new Map();
143
- /* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
144
- * 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
145
- const facets = new Map();
146
- const completedTasks = [];
148
+ /* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 곳에 적으면 같은 저널로 다른 답이 나온다). */
149
+ const { records: tasks } = (0, twin_kernel_1.foldTaskRecords)(events.filter(e => e.eventType === 'task.status'));
147
150
  let completedOrders = 0;
148
151
  let seen = 0;
149
152
  let inWindow = 0;
@@ -155,19 +158,6 @@ function foldKpi(events, window, options = {}) {
155
158
  if (at >= window.fromMs && at <= window.toMs)
156
159
  inWindow++;
157
160
  const d = e.payload?.data ?? e.payload ?? {};
158
- if (e.eventType === 'task.status') {
159
- const id = d.taskId;
160
- if (!id)
161
- continue;
162
- rememberFacets(facets, id, d);
163
- if (d.status === 'created')
164
- created.set(id, at);
165
- else if (d.status === 'in-progress' && !started.has(id))
166
- started.set(id, at);
167
- else if (d.status === 'completed')
168
- completedTasks.push({ id, at, resourceId: d.resourceRef });
169
- continue;
170
- }
171
161
  if (e.eventType === 'order.status') {
172
162
  /* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
173
163
  * 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
@@ -187,24 +177,26 @@ function foldKpi(events, window, options = {}) {
187
177
  let tasksInWindow = 0;
188
178
  let unpairedLead = 0;
189
179
  let unpairedWork = 0;
190
- for (const t of completedTasks) {
191
- if (t.at < window.fromMs || t.at > window.toMs)
180
+ for (const t of tasks) {
181
+ const completed = t.completedMs;
182
+ if (completed === undefined)
183
+ continue; // 아직 완료되지 않은 작업 — 성과로 세지 않는다
184
+ if (completed < window.fromMs || completed > window.toMs)
192
185
  continue; // 성과는 완료 시점으로 센다
193
186
  tasksInWindow++;
194
- const c = created.get(t.id);
195
- const s = started.get(t.id);
196
- const rec = { taskId: t.id, at: t.at, facets: facets.get(t.id) ?? {} };
197
- if (t.resourceId)
198
- rec.facets = { ...rec.facets, resource: t.resourceId };
199
- if (c !== undefined && t.at >= c)
200
- leads.push((rec.leadMs = t.at - c));
187
+ const c = t.createdMs;
188
+ const s = t.startedMs;
189
+ const rec = { taskId: t.taskId, at: completed, facets: t.facets };
190
+ const resource = rec.facets.resource;
191
+ if (c !== undefined && completed >= c)
192
+ leads.push((rec.leadMs = completed - c));
201
193
  else
202
194
  unpairedLead++;
203
- if (s !== undefined && t.at >= s) {
204
- const busy = t.at - s;
195
+ if (s !== undefined && completed >= s) {
196
+ const busy = completed - s;
205
197
  works.push((rec.workMs = busy));
206
- if (t.resourceId)
207
- busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy);
198
+ if (resource)
199
+ busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy);
208
200
  }
209
201
  else {
210
202
  unpairedWork++;