@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.
- package/README.md +37 -24
- package/dist-server/engine/entity-delta.d.ts +13 -1
- package/dist-server/engine/entity-delta.js +130 -16
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +4 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +20 -3
- package/dist-server/engine/kpi-fold.js +45 -53
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +16 -0
- package/dist-server/engine/kpi-query.js +129 -19
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +141 -0
- package/dist-server/engine/kpi-target.js +137 -0
- package/dist-server/engine/kpi-target.js.map +1 -0
- package/dist-server/engine/live-attentions.d.ts +4 -2
- package/dist-server/engine/live-attentions.js +5 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/measured-estimator.d.ts +50 -0
- package/dist-server/engine/measured-estimator.js +78 -0
- package/dist-server/engine/measured-estimator.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +23 -0
- package/dist-server/engine/model-basis.js +100 -0
- package/dist-server/engine/model-basis.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +2 -2
- package/dist-server/engine/oee-accumulator.js +4 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +49 -0
- package/dist-server/engine/spec-coverage.js +60 -0
- package/dist-server/engine/spec-coverage.js.map +1 -0
- package/dist-server/engine/structure-diff.d.ts +25 -0
- package/dist-server/engine/structure-diff.js +63 -0
- package/dist-server/engine/structure-diff.js.map +1 -0
- package/dist-server/engine/travel-estimator.d.ts +57 -0
- package/dist-server/engine/travel-estimator.js +120 -0
- package/dist-server/engine/travel-estimator.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +181 -18
- package/dist-server/engine/twin-engine.js +545 -96
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +39 -0
- package/dist-server/engine/warm-start.js +37 -0
- package/dist-server/engine/warm-start.js.map +1 -0
- package/dist-server/index.js +8 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +21 -14
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +2 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +307 -1
- package/dist-server/service/reference/reference-master.js +96 -8
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -3
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
- package/dist-server/service/twin-event/backfill-keys.js +63 -0
- package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event-keys.js +95 -0
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
- package/dist-server/service/twin-event/twin-event-type.js +32 -0
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +16 -0
- package/dist-server/service/twin-event/twin-event.js +50 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +1 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-area.js +1 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +11 -0
- package/dist-server/service/twin-space/twin-space.js +5 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/index.d.ts +2 -0
- package/dist-server/service/twin-structure/index.js +6 -0
- package/dist-server/service/twin-structure/index.js.map +1 -0
- package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
- package/dist-server/service/twin-structure/twin-structure.js +97 -0
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
- package/dist-server/service/twin-target/index.d.ts +4 -0
- package/dist-server/service/twin-target/index.js +8 -0
- package/dist-server/service/twin-target/index.js.map +1 -0
- package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
- package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
- package/dist-server/service/twin-target/twin-target.d.ts +25 -0
- package/dist-server/service/twin-target/twin-target.js +112 -0
- package/dist-server/service/twin-target/twin-target.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +62 -59
- package/server/engine/kpi-query.ts +140 -19
- package/server/engine/kpi-target.ts +226 -0
- package/server/engine/live-attentions.ts +12 -2
- package/server/engine/measured-estimator.ts +91 -0
- package/server/engine/model-basis.ts +94 -0
- package/server/engine/oee-accumulator.ts +5 -5
- package/server/engine/spec-coverage.ts +85 -0
- package/server/engine/structure-diff.ts +88 -0
- package/server/engine/travel-estimator.ts +133 -0
- package/server/engine/twin-engine.ts +587 -106
- package/server/engine/warm-start.ts +53 -0
- package/server/index.ts +9 -0
- package/server/service/index.ts +7 -0
- package/server/service/reference/reference-live.ts +2 -1
- package/server/service/reference/reference-master.ts +383 -10
- package/server/service/reference/reference-resolver.ts +3 -3
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-attention/twin-attention-query.ts +1 -1
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-event/backfill-keys.ts +72 -0
- package/server/service/twin-event/twin-event-keys.ts +102 -0
- package/server/service/twin-event/twin-event-type.ts +27 -0
- package/server/service/twin-event/twin-event.ts +62 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +129 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
- package/server/service/twin-space/twin-space-area.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +3 -3
- package/server/service/twin-space/twin-space.ts +14 -0
- package/server/service/twin-structure/index.ts +3 -0
- package/server/service/twin-structure/twin-structure.ts +93 -0
- package/server/service/twin-target/index.ts +5 -0
- package/server/service/twin-target/twin-target-resolver.ts +124 -0
- package/server/service/twin-target/twin-target.ts +97 -0
- package/test/capability-mapping.test.ts +5 -5
- package/test/duration-estimators.test.ts +144 -0
- package/test/entity-delta.test.ts +150 -19
- package/test/ingest-bench.test.ts +9 -9
- package/test/kpi-fold.test.ts +254 -3
- package/test/live-mirror-parity.test.ts +53 -19
- package/test/master-to-twin.test.ts +87 -13
- package/test/model-basis.test.ts +86 -0
- package/test/oee-accumulator.test.ts +9 -9
- package/test/scale-twin-bench.test.ts +22 -22
- package/test/spec-coverage.test.ts +113 -0
- package/test/streamline-e2e.test.ts +10 -10
- package/test/structure-revision-db.test.ts +310 -0
- package/test/twin-event-keys.test.ts +108 -0
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/headless-twin",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.7",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"test": "node --test test/*.test.ts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@operato/twin-kernel": "^0.0
|
|
29
|
-
"@things-factory/auth-base": "^10.0.
|
|
30
|
-
"@things-factory/cache-service": "^10.0.
|
|
28
|
+
"@operato/twin-kernel": "^0.4.0",
|
|
29
|
+
"@things-factory/auth-base": "^10.0.7",
|
|
30
|
+
"@things-factory/cache-service": "^10.0.7",
|
|
31
31
|
"@things-factory/env": "^10.0.0",
|
|
32
|
-
"@things-factory/shell": "^10.0.
|
|
32
|
+
"@things-factory/shell": "^10.0.7"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "fffd94e886b67ed9019993a572a632054e812fa3"
|
|
35
35
|
}
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).
|
|
3
3
|
* publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ────────────────────────────
|
|
6
|
+
* 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는
|
|
7
|
+
* 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.**
|
|
8
|
+
*
|
|
9
|
+
* 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 조용히 누락됐다
|
|
10
|
+
* (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은
|
|
11
|
+
* **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는
|
|
12
|
+
* 것을 잊으면 아무 신호 없이 정보가 사라진다.
|
|
13
|
+
*
|
|
14
|
+
* 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께
|
|
15
|
+
* 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다.
|
|
16
|
+
*
|
|
17
|
+
* ── 표식은 도메인 낱말을 훔치지 않는다 ─────────────────────────────────────
|
|
18
|
+
* 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 —
|
|
19
|
+
* 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을
|
|
20
|
+
* 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다).
|
|
21
|
+
* 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다.
|
|
22
|
+
*
|
|
23
|
+
* payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함).
|
|
24
|
+
* entity = location · equipment · person · asset · order · task · item,
|
|
25
|
+
* 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__).
|
|
7
26
|
*/
|
|
8
27
|
|
|
9
28
|
export interface EntityDelta {
|
|
@@ -11,11 +30,26 @@ export interface EntityDelta {
|
|
|
11
30
|
data: any
|
|
12
31
|
}
|
|
13
32
|
|
|
14
|
-
/**
|
|
33
|
+
/** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
|
|
15
34
|
export const ORDERS_TAG = '__orders__'
|
|
35
|
+
export const TASKS_TAG = '__tasks__'
|
|
36
|
+
export const ITEMS_TAG = '__items__'
|
|
37
|
+
export const PERSONS_TAG = '__persons__'
|
|
38
|
+
export const ASSETS_TAG = '__assets__'
|
|
16
39
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
40
|
+
/**
|
|
41
|
+
* 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.**
|
|
42
|
+
*
|
|
43
|
+
* · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다).
|
|
44
|
+
* · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 "무변화 생략" 을 무력화한다. shapeOee 로 정형화해 얹는다.
|
|
45
|
+
*/
|
|
46
|
+
const EXCLUDED = new Set(['id', 'epc', 'oee'])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.
|
|
50
|
+
* 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재방송 억제).
|
|
51
|
+
* oee 미측정이면 undefined.
|
|
52
|
+
*/
|
|
19
53
|
function shapeOee(o: any): any {
|
|
20
54
|
if (!o) return undefined
|
|
21
55
|
return {
|
|
@@ -28,22 +62,108 @@ function shapeOee(o: any): any {
|
|
|
28
62
|
}
|
|
29
63
|
}
|
|
30
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함).
|
|
67
|
+
*
|
|
68
|
+
* `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은
|
|
69
|
+
* 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다.
|
|
70
|
+
*/
|
|
71
|
+
function payloadOf(entityKind: string, entity: any): any {
|
|
72
|
+
/* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */
|
|
73
|
+
const data: any = { entity: entityKind }
|
|
74
|
+
for (const [k, v] of Object.entries(entity ?? {})) {
|
|
75
|
+
if (EXCLUDED.has(k) || v === undefined) continue
|
|
76
|
+
data[k] = v
|
|
77
|
+
}
|
|
78
|
+
return data
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자.
|
|
83
|
+
* 집약은 "전체를 한 컴포넌트가 추적" 하는 용도라 식별자가 있어야 한다.
|
|
84
|
+
*/
|
|
85
|
+
function aggregateItem(entity: any, idKey: 'id' | 'epc'): any {
|
|
86
|
+
const out: any = { [idKey]: entity?.[idKey] }
|
|
87
|
+
for (const [k, v] of Object.entries(entity ?? {})) {
|
|
88
|
+
if (k === idKey || k === 'oee' || v === undefined) continue
|
|
89
|
+
out[k] = v
|
|
90
|
+
}
|
|
91
|
+
const shaped = shapeOee(entity?.oee)
|
|
92
|
+
if (shaped) out.oee = shaped
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.
|
|
98
|
+
*
|
|
99
|
+
* **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
|
|
100
|
+
* "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
|
|
101
|
+
* 이 걸려 **변한 것만** 재방송된다.
|
|
102
|
+
*/
|
|
103
|
+
export const ITEMS_AGGREGATE_LIMIT = 500
|
|
104
|
+
|
|
31
105
|
export function buildEntityDeltas(state: any): EntityDelta[] {
|
|
32
106
|
const out: EntityDelta[] = []
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
|
|
108
|
+
for (const n of state?.locations ?? []) {
|
|
109
|
+
out.push({ tag: n.id, data: payloadOf('location', n) })
|
|
110
|
+
}
|
|
111
|
+
for (const m of state?.equipment ?? []) {
|
|
112
|
+
/* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로.
|
|
113
|
+
* oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */
|
|
114
|
+
const data = payloadOf('equipment', m)
|
|
115
|
+
const oee = shapeOee(m?.oee)
|
|
116
|
+
if (oee) data.oee = oee
|
|
117
|
+
out.push({ tag: m.id, data })
|
|
35
118
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
out.push({ tag:
|
|
119
|
+
/* 사람 — 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */
|
|
120
|
+
for (const p of state?.persons ?? []) {
|
|
121
|
+
out.push({ tag: p.id, data: payloadOf('person', p) })
|
|
122
|
+
}
|
|
123
|
+
/* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */
|
|
124
|
+
for (const a of state?.assets ?? []) {
|
|
125
|
+
out.push({ tag: a.id, data: payloadOf('asset', a) })
|
|
39
126
|
}
|
|
40
127
|
for (const o of state?.orders ?? []) {
|
|
41
|
-
out.push({ tag: o.id, data:
|
|
128
|
+
out.push({ tag: o.id, data: payloadOf('order', o) })
|
|
129
|
+
}
|
|
130
|
+
for (const t of state?.tasks ?? []) {
|
|
131
|
+
out.push({ tag: t.id, data: payloadOf('task', t) })
|
|
132
|
+
}
|
|
133
|
+
for (const it of state?.items ?? []) {
|
|
134
|
+
/* 물품의 태그는 epc(개체 식별자) — 노드·무버와 달리 id 가 아니다. */
|
|
135
|
+
if (it?.epc) out.push({ tag: it.epc, data: payloadOf('item', it) })
|
|
42
136
|
}
|
|
43
|
-
|
|
137
|
+
|
|
138
|
+
/* ── 집약 뷰 ──────────────────────────────────────────────────────────────
|
|
139
|
+
* 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가
|
|
140
|
+
* 전체를 추적하는 집약 태그를 항상 방출한다. */
|
|
141
|
+
const orders = state?.orders ?? []
|
|
142
|
+
out.push({ tag: ORDERS_TAG, data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')) } })
|
|
143
|
+
|
|
144
|
+
const tasks = state?.tasks ?? []
|
|
145
|
+
out.push({ tag: TASKS_TAG, data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } })
|
|
146
|
+
|
|
147
|
+
/* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.
|
|
148
|
+
* 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */
|
|
149
|
+
const persons = state?.persons ?? []
|
|
150
|
+
out.push({ tag: PERSONS_TAG, data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } })
|
|
151
|
+
|
|
152
|
+
const assets = state?.assets ?? []
|
|
153
|
+
out.push({ tag: ASSETS_TAG, data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } })
|
|
154
|
+
|
|
155
|
+
const items = state?.items ?? []
|
|
156
|
+
const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT)
|
|
44
157
|
out.push({
|
|
45
|
-
tag:
|
|
46
|
-
data: {
|
|
158
|
+
tag: ITEMS_TAG,
|
|
159
|
+
data: {
|
|
160
|
+
entity: 'items',
|
|
161
|
+
items: shown.map((it: any) => aggregateItem(it, 'epc')),
|
|
162
|
+
total: items.length,
|
|
163
|
+
/* 잘렸으면 그 사실을 말한다 — "이게 전부" 로 읽히면 재고를 과소 판단한다. */
|
|
164
|
+
...(items.length > shown.length ? { truncated: items.length - shown.length } : {})
|
|
165
|
+
}
|
|
47
166
|
})
|
|
167
|
+
|
|
48
168
|
return out
|
|
49
169
|
}
|
package/server/engine/index.ts
CHANGED
|
@@ -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'
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/*
|
|
23
|
+
* 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
|
|
24
|
+
* 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
|
|
25
|
+
* 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
|
|
26
|
+
* 이 파일은 이제 "접힌 기록 → 창 지표" 만 한다.
|
|
27
|
+
*/
|
|
28
|
+
import { foldTaskRecords, activeShiftAt, type TaskFacets, type WorkCalendarEntry } from '@operato/twin-kernel'
|
|
29
|
+
|
|
22
30
|
/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
|
|
23
31
|
export interface KpiEvent {
|
|
24
32
|
eventType?: string
|
|
@@ -36,6 +44,11 @@ export interface KpiWindow {
|
|
|
36
44
|
export interface DurationStats {
|
|
37
45
|
/** 짝이 맞아 계산된 건수. */
|
|
38
46
|
count: number
|
|
47
|
+
/**
|
|
48
|
+
* 하위 꼬리 — **분포를 양쪽에서 접지하기 위해** 낸다.
|
|
49
|
+
* p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다.
|
|
50
|
+
*/
|
|
51
|
+
p10Ms: number
|
|
39
52
|
p50Ms: number
|
|
40
53
|
p90Ms: number
|
|
41
54
|
avgMs: number
|
|
@@ -48,10 +61,11 @@ export interface DurationStats {
|
|
|
48
61
|
* 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.
|
|
49
62
|
*
|
|
50
63
|
* resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)
|
|
51
|
-
*
|
|
64
|
+
* location 도착 지점별(어디서) order 오더별(무엇을 위해)
|
|
52
65
|
* area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
|
|
66
|
+
* shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)
|
|
53
67
|
*/
|
|
54
|
-
export type KpiGroupBy = 'resource' | 'taskKind' | '
|
|
68
|
+
export type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift'
|
|
55
69
|
|
|
56
70
|
export interface KpiGroup {
|
|
57
71
|
/** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */
|
|
@@ -107,7 +121,17 @@ export interface KpiFoldOptions {
|
|
|
107
121
|
* 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
|
|
108
122
|
* 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).
|
|
109
123
|
*/
|
|
110
|
-
|
|
124
|
+
locationArea?: Record<string, string>
|
|
125
|
+
/**
|
|
126
|
+
* 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다.
|
|
127
|
+
* 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 조용히 바꾸지 않는다).
|
|
128
|
+
*/
|
|
129
|
+
shifts?: WorkCalendarEntry[]
|
|
130
|
+
/**
|
|
131
|
+
* 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다
|
|
132
|
+
* (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC.
|
|
133
|
+
*/
|
|
134
|
+
utcOffsetMinutes?: number
|
|
111
135
|
/**
|
|
112
136
|
* 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.
|
|
113
137
|
*
|
|
@@ -121,14 +145,6 @@ export interface KpiFoldOptions {
|
|
|
121
145
|
* 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode·
|
|
122
146
|
* resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다.
|
|
123
147
|
*/
|
|
124
|
-
interface TaskFacets {
|
|
125
|
-
resource?: string
|
|
126
|
-
taskKind?: string
|
|
127
|
-
/** 작업이 **도착한** 지점. 없으면 출발 지점(둘 다 없으면 축은 'unknown'). */
|
|
128
|
-
node?: string
|
|
129
|
-
order?: string
|
|
130
|
-
}
|
|
131
|
-
|
|
132
148
|
/** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */
|
|
133
149
|
interface CompletionRecord {
|
|
134
150
|
taskId: string
|
|
@@ -139,23 +155,6 @@ interface CompletionRecord {
|
|
|
139
155
|
waitMs?: number
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
/**
|
|
143
|
-
* 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.
|
|
144
|
-
*
|
|
145
|
-
* 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
|
|
146
|
-
* 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
|
|
147
|
-
*/
|
|
148
|
-
function rememberFacets(store: Map<string, TaskFacets>, id: string, d: any): void {
|
|
149
|
-
const prev = store.get(id) ?? {}
|
|
150
|
-
const node = d.toNode ?? d.fromNode
|
|
151
|
-
store.set(id, {
|
|
152
|
-
resource: prev.resource ?? (d.resourceRef || undefined),
|
|
153
|
-
taskKind: prev.taskKind ?? (d.kind || undefined),
|
|
154
|
-
order: prev.order ?? (d.orderId || undefined),
|
|
155
|
-
node: node || prev.node
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
158
|
/** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
|
|
160
159
|
function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
|
|
161
160
|
const f = rec.facets
|
|
@@ -163,15 +162,30 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
|
|
|
163
162
|
case 'resource':
|
|
164
163
|
return f.resource ?? 'unknown'
|
|
165
164
|
case 'taskKind':
|
|
166
|
-
return f.
|
|
167
|
-
case '
|
|
168
|
-
return f.
|
|
165
|
+
return f.kind ?? 'unknown'
|
|
166
|
+
case 'location':
|
|
167
|
+
return f.location ?? 'unknown'
|
|
169
168
|
case 'order':
|
|
170
169
|
return f.order ?? 'unknown'
|
|
171
170
|
case 'area':
|
|
172
171
|
/* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가
|
|
173
172
|
* 그것을 구역으로 오해한다). */
|
|
174
|
-
return (f.
|
|
173
|
+
return (f.location && options.locationArea?.[f.location]) ?? 'unknown'
|
|
174
|
+
case 'shift':
|
|
175
|
+
/*
|
|
176
|
+
* 교대는 **저널에 찍혀 있지 않다 — 계산한다.**
|
|
177
|
+
*
|
|
178
|
+
* 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고,
|
|
179
|
+
* ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는
|
|
180
|
+
* `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 —
|
|
181
|
+
* 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다.
|
|
182
|
+
*
|
|
183
|
+
* 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
|
|
184
|
+
*/
|
|
185
|
+
if (!options.shifts?.length) return 'unknown'
|
|
186
|
+
/* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도
|
|
187
|
+
않은 교대에 집계돼, 교대별 성과가 조용히 거짓이 된다. */
|
|
188
|
+
return activeShiftAt(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown'
|
|
175
189
|
default:
|
|
176
190
|
return 'unknown'
|
|
177
191
|
}
|
|
@@ -235,11 +249,12 @@ function quantile(sorted: number[], q: number): number {
|
|
|
235
249
|
}
|
|
236
250
|
|
|
237
251
|
function stats(values: number[]): DurationStats {
|
|
238
|
-
if (values.length === 0) return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
|
|
252
|
+
if (values.length === 0) return { count: 0, p10Ms: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
|
|
239
253
|
const sorted = [...values].sort((a, b) => a - b)
|
|
240
254
|
const sum = sorted.reduce((a, b) => a + b, 0)
|
|
241
255
|
return {
|
|
242
256
|
count: sorted.length,
|
|
257
|
+
p10Ms: quantile(sorted, 0.1),
|
|
243
258
|
p50Ms: quantile(sorted, 0.5),
|
|
244
259
|
p90Ms: quantile(sorted, 0.9),
|
|
245
260
|
avgMs: Math.round(sum / sorted.length)
|
|
@@ -257,13 +272,8 @@ function stats(values: number[]): DurationStats {
|
|
|
257
272
|
* (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
|
|
258
273
|
*/
|
|
259
274
|
export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult {
|
|
260
|
-
/*
|
|
261
|
-
const
|
|
262
|
-
const started = new Map<string, number>()
|
|
263
|
-
/* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
|
|
264
|
-
* 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
|
|
265
|
-
const facets = new Map<string, TaskFacets>()
|
|
266
|
-
const completedTasks: { id: string; at: number; resourceId?: string }[] = []
|
|
275
|
+
/* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 — 두 곳에 적으면 같은 저널로 다른 답이 나온다). */
|
|
276
|
+
const { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status'))
|
|
267
277
|
let completedOrders = 0
|
|
268
278
|
let seen = 0
|
|
269
279
|
let inWindow = 0
|
|
@@ -275,15 +285,6 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
275
285
|
if (at >= window.fromMs && at <= window.toMs) inWindow++
|
|
276
286
|
const d = e.payload?.data ?? e.payload ?? {}
|
|
277
287
|
|
|
278
|
-
if (e.eventType === 'task.status') {
|
|
279
|
-
const id = d.taskId
|
|
280
|
-
if (!id) continue
|
|
281
|
-
rememberFacets(facets, id, d)
|
|
282
|
-
if (d.status === 'created') created.set(id, at)
|
|
283
|
-
else if (d.status === 'in-progress' && !started.has(id)) started.set(id, at)
|
|
284
|
-
else if (d.status === 'completed') completedTasks.push({ id, at, resourceId: d.resourceRef })
|
|
285
|
-
continue
|
|
286
|
-
}
|
|
287
288
|
if (e.eventType === 'order.status') {
|
|
288
289
|
/* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
|
|
289
290
|
* 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
|
|
@@ -305,19 +306,21 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
305
306
|
let unpairedLead = 0
|
|
306
307
|
let unpairedWork = 0
|
|
307
308
|
|
|
308
|
-
for (const t of
|
|
309
|
-
|
|
309
|
+
for (const t of tasks) {
|
|
310
|
+
const completed = t.completedMs
|
|
311
|
+
if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다
|
|
312
|
+
if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다
|
|
310
313
|
tasksInWindow++
|
|
311
|
-
const c =
|
|
312
|
-
const s =
|
|
313
|
-
const rec: CompletionRecord = { taskId: t.
|
|
314
|
-
|
|
315
|
-
if (c !== undefined &&
|
|
314
|
+
const c = t.createdMs
|
|
315
|
+
const s = t.startedMs
|
|
316
|
+
const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets }
|
|
317
|
+
const resource = rec.facets.resource
|
|
318
|
+
if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c))
|
|
316
319
|
else unpairedLead++
|
|
317
|
-
if (s !== undefined &&
|
|
318
|
-
const busy =
|
|
320
|
+
if (s !== undefined && completed >= s) {
|
|
321
|
+
const busy = completed - s
|
|
319
322
|
works.push((rec.workMs = busy))
|
|
320
|
-
if (
|
|
323
|
+
if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy)
|
|
321
324
|
} else {
|
|
322
325
|
unpairedWork++
|
|
323
326
|
}
|