@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { IsNull } from 'typeorm'
|
|
2
|
+
|
|
3
|
+
import { getRepository } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinEvent } from './twin-event.js'
|
|
6
|
+
import { twinEventKeys } from './twin-event-keys.js'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* 승격 검색 키 백필 — 컬럼이 생기기 **전에** 쌓인 저널 행을 채운다.
|
|
10
|
+
*
|
|
11
|
+
* 이게 없으면 검색은 "오늘부터의 이력" 만 찾는다. 사용자에게는 그냥 **과거가 없는 것으로 보이고**,
|
|
12
|
+
* 그건 이번에 고치려던 결함(조용히 빠진 데이터)과 정확히 같은 종류다.
|
|
13
|
+
*
|
|
14
|
+
* 성질:
|
|
15
|
+
* · 멱등 — 이미 채워진 행은 건드리지 않는다(bizStep IS NULL 인 것만 집는다).
|
|
16
|
+
* · 재개 가능 — 중간에 죽어도 다음 기동에서 남은 것부터 이어간다.
|
|
17
|
+
* · 조각내서 — 한 번에 다 읽지 않는다. 저널은 크다는 전제로 만든다.
|
|
18
|
+
* · payload 는 손대지 않는다 — 정본은 그대로 두고 파생 색인만 채운다.
|
|
19
|
+
*
|
|
20
|
+
* 아주 큰 운영 저널이라면 기동 시 백그라운드보다 **마이그레이션으로 한 번** 도는 편이 낫다.
|
|
21
|
+
* 이 함수를 그대로 부르면 되므로 경로는 하나다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CHUNK = 1000
|
|
25
|
+
|
|
26
|
+
export interface BackfillResult {
|
|
27
|
+
/** 실제로 갱신한 행 수. */
|
|
28
|
+
updated: number
|
|
29
|
+
/** 훑었지만 payload 가 없어 채울 수 없던 행 수 — 0 이 아니면 인제스트 쪽을 봐야 한다. */
|
|
30
|
+
skipped: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은
|
|
35
|
+
* 멈춘 것과 구분되지 않는다.
|
|
36
|
+
*/
|
|
37
|
+
export async function backfillTwinEventKeys(): Promise<BackfillResult> {
|
|
38
|
+
const repo = getRepository(TwinEvent)
|
|
39
|
+
let updated = 0
|
|
40
|
+
let skipped = 0
|
|
41
|
+
let round = 0
|
|
42
|
+
|
|
43
|
+
for (;;) {
|
|
44
|
+
/* bizStep 은 이벤트 타입에서라도 유추되므로 **정상 인제스트라면 반드시 채워진다** —
|
|
45
|
+
* 즉 NULL 은 "승격 이전 행" 의 확실한 표식이다. epc 로 판정하면 품목 없는 이벤트를
|
|
46
|
+
* 매번 다시 집어 무한히 돈다. */
|
|
47
|
+
const rows = await repo.find({ where: { bizStep: IsNull() }, take: CHUNK, order: { revision: 'ASC' } })
|
|
48
|
+
if (rows.length === 0) break
|
|
49
|
+
|
|
50
|
+
const dirty: TwinEvent[] = []
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (!row.payload) {
|
|
53
|
+
skipped++
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
Object.assign(row, twinEventKeys(row.payload))
|
|
57
|
+
dirty.push(row)
|
|
58
|
+
}
|
|
59
|
+
if (dirty.length) await repo.save(dirty, { chunk: 500 })
|
|
60
|
+
updated += dirty.length
|
|
61
|
+
|
|
62
|
+
/* payload 가 없는 행만 남으면 같은 조각을 영원히 다시 읽는다 — 진도가 없으면 멈춘다. */
|
|
63
|
+
if (dirty.length === 0) break
|
|
64
|
+
|
|
65
|
+
if (++round % 10 === 0) console.log(`[twin-event backfill] ${updated} rows filled…`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (updated || skipped) {
|
|
69
|
+
console.log(`[twin-event backfill] done — ${updated} filled, ${skipped} skipped (no payload)`)
|
|
70
|
+
}
|
|
71
|
+
return { updated, skipped }
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 승격하는가 ───────────────────────────────────────────────────────────
|
|
5
|
+
* 사용자가 저널에서 실제로 찾는 것은 "이 팔레트의 이력", "이 오더가 어디까지 갔나", "이 도크에서
|
|
6
|
+
* 무슨 일이 있었나" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.
|
|
7
|
+
* things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —
|
|
8
|
+
* 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온
|
|
9
|
+
* 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.
|
|
10
|
+
*
|
|
11
|
+
* 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —
|
|
12
|
+
* 이건 파생 색인이지 새로운 진실이 아니다).
|
|
13
|
+
*
|
|
14
|
+
* ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────
|
|
15
|
+
* 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면
|
|
16
|
+
* 반드시 어긋나고, 어긋난 색인은 "없는 것처럼 보이는 이벤트" 를 만든다 — 저널에서 가장 나쁜 결함이다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */
|
|
20
|
+
export interface TwinEventKeys {
|
|
21
|
+
bizStep?: string
|
|
22
|
+
epc?: string
|
|
23
|
+
orderId?: string
|
|
24
|
+
locationId?: string
|
|
25
|
+
moverId?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.
|
|
30
|
+
* 넘치는 값이 오면 **조용히 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,
|
|
31
|
+
* 그 사실이 어디에도 안 남으면 아무도 모른다.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_KEY = 255
|
|
34
|
+
|
|
35
|
+
function clip(v: unknown, field: string): string | undefined {
|
|
36
|
+
if (v === undefined || v === null) return undefined
|
|
37
|
+
const s = String(v)
|
|
38
|
+
if (!s) return undefined
|
|
39
|
+
if (s.length <= MAX_KEY) return s
|
|
40
|
+
console.warn(
|
|
41
|
+
`[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +
|
|
42
|
+
`search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`
|
|
43
|
+
)
|
|
44
|
+
return s.slice(0, MAX_KEY)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
|
|
48
|
+
export function bizStepOf(envelope: any): string | undefined {
|
|
49
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
50
|
+
const tail = String(d.bizStep ?? '').split(':').pop()
|
|
51
|
+
return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
|
|
56
|
+
*
|
|
57
|
+
* 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라
|
|
58
|
+
* 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면
|
|
59
|
+
* URN 으로 찾는 경로가 사라진다.
|
|
60
|
+
*/
|
|
61
|
+
export function epcOf(envelope: any): string | undefined {
|
|
62
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
63
|
+
return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
|
|
67
|
+
export function orderOf(envelope: any): string | undefined {
|
|
68
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
69
|
+
return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
|
|
74
|
+
* 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서
|
|
75
|
+
* 무엇을 했는지가 위치 축에서 통째로 사라진다.
|
|
76
|
+
*/
|
|
77
|
+
export function locationOf(envelope: any): string | undefined {
|
|
78
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
79
|
+
return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.
|
|
84
|
+
*
|
|
85
|
+
* EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
|
|
86
|
+
* 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
|
|
87
|
+
*/
|
|
88
|
+
export function equipmentIdOf(envelope: any): string | undefined {
|
|
89
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
90
|
+
return d.moverId ?? undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
|
|
94
|
+
export function twinEventKeys(envelope: any): TwinEventKeys {
|
|
95
|
+
return {
|
|
96
|
+
bizStep: clip(bizStepOf(envelope), 'bizStep'),
|
|
97
|
+
epc: clip(epcOf(envelope), 'epc'),
|
|
98
|
+
orderId: clip(orderOf(envelope), 'orderId'),
|
|
99
|
+
locationId: clip(locationOf(envelope), 'locationId'),
|
|
100
|
+
moverId: clip(equipmentIdOf(envelope), 'moverId')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Field, Int, ObjectType } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { TwinEvent } from './twin-event.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* 저널 목록 반환형 — things-factory 표준 `{ items, total }`(AttributeSetList·DomainList 등과 동형).
|
|
7
|
+
*
|
|
8
|
+
* `total` 이 이 타입의 존재 이유다. 기존 `twinEvents` 는 배열만 돌려줘서 **화면이 자기가 전체를 받은
|
|
9
|
+
* 건지 잘린 건지 알 방법이 없었다** — 그래서 리스트가 조용히 잘린 채로 "이게 전부" 처럼 보였다.
|
|
10
|
+
* 총건수를 함께 주면 화면은 "48 / 12,904" 라고 정직하게 말할 수 있고, 사용자는 좁혀야 한다는 걸 안다.
|
|
11
|
+
*/
|
|
12
|
+
@ObjectType({ description: 'A page of twin journal events together with the total number of matching records.' })
|
|
13
|
+
export class TwinEventList {
|
|
14
|
+
@Field(type => [TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' })
|
|
15
|
+
items: TwinEvent[]
|
|
16
|
+
|
|
17
|
+
@Field(type => Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating.' })
|
|
18
|
+
total: number
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* 다음 페이지 커서. 저널은 **머리에 계속 쌓이는 목록**이라 offset 으로 뒤를 읽으면
|
|
22
|
+
* 읽는 사이 들어온 이벤트만큼 밀려 본 행이 또 나오거나 못 본 행이 사라진다.
|
|
23
|
+
* 이 값을 그대로 다음 요청의 `pagination.after` 로 돌려주면 그 문제가 없다.
|
|
24
|
+
*/
|
|
25
|
+
@Field({ nullable: true, description: 'Opaque cursor for the next page. Pass it back as pagination.after to continue exactly where this page ended, without the duplicate or skipped rows that offset paging produces on a journal that keeps growing at the head. Null when this page is the last one.' })
|
|
26
|
+
nextCursor?: string
|
|
27
|
+
}
|
|
@@ -10,8 +10,28 @@ import { Domain, ScalarObject } from '@things-factory/shell'
|
|
|
10
10
|
* payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).
|
|
11
11
|
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
12
12
|
*/
|
|
13
|
+
/*
|
|
14
|
+
* ── 인덱스 설계 (2026-07-31) ────────────────────────────────────────────────
|
|
15
|
+
* 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로
|
|
16
|
+
* "있으면 좋을" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다.
|
|
17
|
+
*
|
|
18
|
+
* ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사)
|
|
19
|
+
* ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다
|
|
20
|
+
* ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment)
|
|
21
|
+
* ix_3 (domain, instanceId, epc) "이 물건의 이력" — Entity360 의 본질 질문
|
|
22
|
+
* ix_4 (domain, instanceId, orderId) "이 오더가 어디까지 갔나"
|
|
23
|
+
*
|
|
24
|
+
* bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가
|
|
25
|
+
* 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로
|
|
26
|
+
* 충분하고, 뜨거운 표에 인덱스를 더 얹을 값어치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가
|
|
27
|
+
* 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다.
|
|
28
|
+
*/
|
|
13
29
|
@Entity()
|
|
14
30
|
@Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false })
|
|
31
|
+
@Index('ix_twin_event_1', (e: TwinEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false })
|
|
32
|
+
@Index('ix_twin_event_2', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false })
|
|
33
|
+
@Index('ix_twin_event_3', (e: TwinEvent) => [e.domain, e.instanceId, e.epc], { unique: false })
|
|
34
|
+
@Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false })
|
|
15
35
|
@ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })
|
|
16
36
|
export class TwinEvent {
|
|
17
37
|
@PrimaryGeneratedColumn('uuid')
|
|
@@ -41,10 +61,52 @@ export class TwinEvent {
|
|
|
41
61
|
@Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' })
|
|
42
62
|
revision?: number
|
|
43
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 이 사실이 **어느 공장에서** 일어났나 — `TwinStructure.rev`.
|
|
66
|
+
*
|
|
67
|
+
* 이것이 없던 시절에는 구조가 바뀌면 저널을 통째로 지우는 것이 유일한 길이었다(안 지우면 옛
|
|
68
|
+
* 이벤트를 새 공장에 대고 접어 이력이 거짓말을 한다). 이 한 칸이 셋째 길을 연다 — 재생이
|
|
69
|
+
* 구조가 바뀌는 지점에서 갈아탄 뒤 이어 접는다.
|
|
70
|
+
*
|
|
71
|
+
* **비어 있을 수 있다**: 컬럼이 생기기 전에 쓰인 행이다. 그 행들은 **가장 오래된 리비전**에
|
|
72
|
+
* 속한다(0 을 채워 넣지 않는다 — 0 은 "모름" 이 아니라 유효한 번호처럼 보인다).
|
|
73
|
+
*/
|
|
74
|
+
@Column({ type: 'int', nullable: true })
|
|
75
|
+
@Field(type => Int, { nullable: true, description: 'Structure revision (TwinStructure.rev) in force when this event happened. Absent on rows written before structure revisions existed; those belong to the oldest revision.' })
|
|
76
|
+
structureRev?: number
|
|
77
|
+
|
|
44
78
|
@Column({ nullable: true })
|
|
45
79
|
@Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })
|
|
46
80
|
eventTime?: string
|
|
47
81
|
|
|
82
|
+
/*
|
|
83
|
+
* ── 승격된 검색 축 ────────────────────────────────────────────────────────
|
|
84
|
+
* payload 안에 있던 값을 인제스트 시점에 꺼내 실컬럼으로 둔다(`twin-event-keys.ts` 가 단독 소유).
|
|
85
|
+
* payload 가 여전히 정본이고 이것들은 **파생 색인**이다 — 새로운 진실이 아니라 찾을 수 있게 하는 장치.
|
|
86
|
+
* simple-json(TEXT) 안의 값은 5개 DB 드라이버 공통으로 거를 방법이 없어서(멀티DB 호환 규칙상
|
|
87
|
+
* DB별 JSON 연산자 금지) 승격 외의 선택지가 없다.
|
|
88
|
+
* 길이 상한 255 는 GS1 식별자 규격 대비 충분하며, 넘치는 값은 조용히 잘리지 않고 경고를 남긴다.
|
|
89
|
+
*/
|
|
90
|
+
@Column({ length: 255, nullable: true })
|
|
91
|
+
@Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' })
|
|
92
|
+
bizStep?: string
|
|
93
|
+
|
|
94
|
+
@Column({ length: 255, nullable: true })
|
|
95
|
+
@Field({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' })
|
|
96
|
+
epc?: string
|
|
97
|
+
|
|
98
|
+
@Column({ length: 255, nullable: true })
|
|
99
|
+
@Field({ nullable: true, description: 'Business transaction identifier (PO / SO), promoted from the payload for indexed lookup of one order history.' })
|
|
100
|
+
orderId?: string
|
|
101
|
+
|
|
102
|
+
@Column({ length: 255, nullable: true })
|
|
103
|
+
@Field({ nullable: true, description: 'Location identifier (read point, business location, or the plain location an operational delta carries), promoted from the payload for indexed filtering.' })
|
|
104
|
+
locationId?: string
|
|
105
|
+
|
|
106
|
+
@Column({ length: 255, nullable: true })
|
|
107
|
+
@Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' })
|
|
108
|
+
moverId?: string
|
|
109
|
+
|
|
48
110
|
@Column({ type: 'simple-json', nullable: true })
|
|
49
111
|
@Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })
|
|
50
112
|
payload?: any
|
|
@@ -3,6 +3,8 @@ import { Arg, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
|
|
|
3
3
|
import { getRepository, ScalarObject } from '@things-factory/shell'
|
|
4
4
|
|
|
5
5
|
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
import { readSpecCoverage } from '../../engine/spec-coverage.js'
|
|
7
|
+
import { describeModelBasis, modelBasisChanged, type ModelBasis } from '../../engine/model-basis.js'
|
|
6
8
|
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
7
9
|
import { GAP_SAMPLES, aggregatePoints, computeSpc, gapMetrics, learnedCalibration, type GapPoint } from './gap-analytics.js'
|
|
8
10
|
|
|
@@ -23,7 +25,7 @@ const DEFAULT_SCENARIO = {
|
|
|
23
25
|
}
|
|
24
26
|
const METRICS: Record<string, (s: any) => number> = {
|
|
25
27
|
items: s => (s.items || []).length,
|
|
26
|
-
occupancy: s => (s.
|
|
28
|
+
occupancy: s => (s.locations || []).reduce((a: number, n: any) => a + (n.occupancy || 0), 0),
|
|
27
29
|
tasks: s => (s.tasks || []).length,
|
|
28
30
|
orders: s => (s.orders || []).length,
|
|
29
31
|
// 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나").
|
|
@@ -34,7 +36,10 @@ const METRICS: Record<string, (s: any) => number> = {
|
|
|
34
36
|
/* 학습된 보정 저장(P3.2, 인메모리) — key=domain:instance:metric:horizon → {biasShift, halfWidth, …}.
|
|
35
37
|
twinLearnCalibration 이 갭에서 학습해 저장하고, twinForecast 가 있으면 예측에 상시 적용(라이브 보정).
|
|
36
38
|
데모 트윈은 재부팅 시 저널 리셋이라 세션 스코프로 충분 — DB 영속(TwinForecastScore)은 후속. */
|
|
37
|
-
const CALIBRATION = new Map<
|
|
39
|
+
const CALIBRATION = new Map<
|
|
40
|
+
string,
|
|
41
|
+
{ biasShift: number; halfWidth: number; at: string; samples: number; accBefore: number; accAfter: number; basis?: ModelBasis }
|
|
42
|
+
>()
|
|
38
43
|
const calKey = (d: string, i: string, m: string, h: number) => `${d}:${i}:${m}:${h}`
|
|
39
44
|
|
|
40
45
|
/* 갭 point 계산(공용) — N개 과거 vantage 각각 forecast-at-T vs actual-at-T+H(결정적 백테스트). gapTrend query·learn mutation 공용. */
|
|
@@ -78,12 +83,15 @@ export class TwinForecastQuery {
|
|
|
78
83
|
@Arg('at', { nullable: true }) at: string,
|
|
79
84
|
@Ctx() context: ResolverContext
|
|
80
85
|
): Promise<any> {
|
|
81
|
-
/* vantage(at) 기준 예측 — 히스토리 노브가 과거 T 면 "그때 서서 본 미래"(
|
|
82
|
-
at 있으면 T 시점
|
|
86
|
+
/* vantage(at) 기준 예측 — 히스토리 노브가 과거 T 면 "그때 서서 본 미래"(백테스트).
|
|
87
|
+
at 있으면 T 시점 커널을 저널로 재구성한다(그건 우회가 아니라 시간여행의 본질).
|
|
88
|
+
없으면 **지금 그 커널**을 쓴다 — sim 이든 live 든 인스턴스가 커널이다(통합 P2). */
|
|
83
89
|
const domainId = context.state.domain.id
|
|
84
90
|
const k = at
|
|
85
91
|
? await TwinEngine.buildForecastKernelAt(domainId, instanceId, at)
|
|
86
|
-
:
|
|
92
|
+
: TwinEngine.owns(domainId, instanceId)
|
|
93
|
+
? TwinEngine.kernel(instanceId)
|
|
94
|
+
: undefined
|
|
87
95
|
if (!k) return null
|
|
88
96
|
|
|
89
97
|
const m = METRICS[metric] ?? METRICS.items
|
|
@@ -97,7 +105,23 @@ export class TwinForecastQuery {
|
|
|
97
105
|
|
|
98
106
|
/* 학습된 보정 상시 적용(P3.2) — twinLearnCalibration 이 저장한 편향·밴드가 있으면 예측을 현실로 보정(라이브가 실제로 나아짐).
|
|
99
107
|
갭트렌드(gapPoints)는 raw 모델을 그대로 측정(학습 신호 보존) — 보정은 표시용 forecast 에만. */
|
|
100
|
-
const
|
|
108
|
+
const stored = CALIBRATION.get(calKey(domainId, instanceId, metric ?? 'items', H))
|
|
109
|
+
/*
|
|
110
|
+
* **배운 모델에만 적용한다.** 보정은 "그때 모델이 이만큼 치우쳐 있다" 를 배운 것이다. 소요시간
|
|
111
|
+
* 추정기(실측·거리)나 공정 명세가 바뀌어 모델이 달라지면 그 편향은 더 이상 사실이 아니고, 그대로
|
|
112
|
+
* 밀어 주면 이중 보정이 된다. 그래서 지문을 비교하고, 다르면 적용하지 않되 **그 사실을 응답에
|
|
113
|
+
* 싣는다** — 조용히 사라지면 사용자는 학습이 왜 안 듣는지 알 수 없다(다시 배우면 된다).
|
|
114
|
+
* 지문을 알 수 없을 때는 비교하지 않는다(모르는 것을 근거로 보정을 끊지 않는다).
|
|
115
|
+
*/
|
|
116
|
+
const basis = await TwinEngine.modelBasis(domainId, instanceId)
|
|
117
|
+
const stale = !!stored && modelBasisChanged(stored.basis, basis)
|
|
118
|
+
if (stale) {
|
|
119
|
+
console.log(
|
|
120
|
+
`[twin-forecast] "${instanceId}": calibration not applied — model changed since it was learned.` +
|
|
121
|
+
` learned on: ${describeModelBasis(stored!.basis)} / now: ${describeModelBasis(basis)}`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const cal = stored && !stale ? stored : undefined
|
|
101
125
|
if (cal && distribution) {
|
|
102
126
|
const p50c = (distribution.p50 ?? 0) + cal.biasShift
|
|
103
127
|
distribution.p50 = Math.round(p50c)
|
|
@@ -148,7 +172,21 @@ export class TwinForecastQuery {
|
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
|
|
175
|
+
/*
|
|
176
|
+
* 시뮬 명세 자기보고를 함께 낸다 — **상수로 굴린 예측과 현장 명세로 굴린 예측이 화면에서 같아
|
|
177
|
+
* 보이지 않게.** 코드·수치만 싣고 사람이 읽는 문장은 클라가 만든다(언어 중립).
|
|
178
|
+
* 커널이 이 API 를 갖지 않은 버전이면 키를 만들지 않는다(모르는 것을 "전부 기본값" 이라 단정 금지).
|
|
179
|
+
*/
|
|
180
|
+
/* 보고는 **실제로 굴린 fork** 에서 읽는다 — 부모 커널(k)은 tick 하지 않으므로(예측은 fork 에서
|
|
181
|
+
* 돈다) 부모에서 읽으면 씨앗으로 세운 예측에서는 늘 비어 있다. 숫자를 낸 주체가 보고해야 한다. */
|
|
182
|
+
const specCoverage = readSpecCoverage(fc) ?? readSpecCoverage(k)
|
|
183
|
+
return {
|
|
184
|
+
metric: metric ?? 'items', horizonMs: H, runs: R, current, distribution, trajectory, actual, scored,
|
|
185
|
+
calibrated: !!cal,
|
|
186
|
+
/* 학습해 뒀지만 모델이 바뀌어 적용하지 않았다 — 사용자가 다시 배울 수 있게 알린다. */
|
|
187
|
+
...(stale ? { calibrationStale: true } : {}),
|
|
188
|
+
...(specCoverage ? { specCoverage } : {})
|
|
189
|
+
}
|
|
152
190
|
}
|
|
153
191
|
|
|
154
192
|
/*
|
|
@@ -211,7 +249,17 @@ export class TwinForecastQuery {
|
|
|
211
249
|
const pts = await gapPoints(domainId, instanceId, m, H, GAP_SAMPLES, DEFAULT_SCENARIO)
|
|
212
250
|
if (pts.length < 3) return { ok: false, reason: 'insufficient-history' }
|
|
213
251
|
const learn = learnedCalibration(pts, gapMetrics(pts))
|
|
214
|
-
|
|
252
|
+
/* 학습한 모델의 지문을 함께 남긴다 — 이후 모델이 바뀌면 이 보정은 적용되지 않는다. */
|
|
253
|
+
const basis = await TwinEngine.modelBasis(domainId, instanceId)
|
|
254
|
+
const cal = {
|
|
255
|
+
biasShift: learn.biasShift,
|
|
256
|
+
halfWidth: learn.halfWidth,
|
|
257
|
+
at: '',
|
|
258
|
+
samples: pts.length,
|
|
259
|
+
accBefore: learn.accBefore,
|
|
260
|
+
accAfter: learn.accAfter,
|
|
261
|
+
...(basis ? { basis } : {})
|
|
262
|
+
}
|
|
215
263
|
CALIBRATION.set(calKey(domainId, instanceId, metric ?? 'items', H), cal)
|
|
216
264
|
return { ok: true, metric: metric ?? 'items', horizonMs: H, biasShift: cal.biasShift, halfWidth: cal.halfWidth, samples: pts.length, accBefore: cal.accBefore, accAfter: cal.accAfter }
|
|
217
265
|
}
|
|
@@ -46,7 +46,7 @@ export class TwinInstance {
|
|
|
46
46
|
realityMode?: string
|
|
47
47
|
|
|
48
48
|
@Column({ type: 'simple-json', nullable: true })
|
|
49
|
-
@Field(type => ScalarObject, { nullable: true, description: 'BoardDef blueprint (
|
|
49
|
+
@Field(type => ScalarObject, { nullable: true, description: 'BoardDef blueprint (locations/equipment) required to replay events into state.' })
|
|
50
50
|
board?: any
|
|
51
51
|
|
|
52
52
|
@Column({ nullable: true })
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'
|
|
2
|
-
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import { Arg, Args, Ctx, Float, Int, Query, Resolver } from 'type-graphql'
|
|
3
3
|
|
|
4
|
-
import { getRepository, ScalarObject } from '@things-factory/shell'
|
|
4
|
+
import { buildNextCursor, cursorSortings, getQueryBuilderFromListParams, getRepository, ListParam, ScalarObject } from '@things-factory/shell'
|
|
5
5
|
|
|
6
6
|
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
7
|
+
import { TwinEventList } from '../twin-event/twin-event-type.js'
|
|
7
8
|
import { TwinEngine } from '../../engine/index.js'
|
|
8
|
-
import { computeTwinKpi } from '../../engine/kpi-query.js'
|
|
9
|
+
import { computeTwinKpi, resolveTwinTargets } from '../../engine/kpi-query.js'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
* 저널 읽기 채널 — 상향 query.
|
|
@@ -39,6 +40,81 @@ export class TwinJournalQuery {
|
|
|
39
40
|
return getRepository(TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 })
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 저널 목록 — things-factory 표준 목록 계약(`ListParam` → `{ items, total }`).
|
|
45
|
+
*
|
|
46
|
+
* ── 왜 `twinEvents` 와 따로 두는가 ─────────────────────────────────────────
|
|
47
|
+
* `twinEvents` 는 배열만 돌려준다. 화면은 자기가 받은 게 전부인지 잘린 건지 알 수 없었고, 그래서
|
|
48
|
+
* 리스트가 **조용히 잘린 채 "이게 전부" 처럼** 보였다(원장 limit 120 이 대표적). 총건수를 함께
|
|
49
|
+
* 주면 "48 / 12,904" 라고 말할 수 있고, 사용자는 좁혀야 한다는 사실을 안다.
|
|
50
|
+
* 기존 호출자를 깨지 않으려고 `twinEvents` 는 그대로 두고 목록 계약을 새로 연다.
|
|
51
|
+
*
|
|
52
|
+
* ── 검색이 진짜인 이유 ─────────────────────────────────────────────────────
|
|
53
|
+
* `searchables` 는 전부 **인덱스 가능한 승격 컬럼**이다(payload JSON 안이 아니라). 프레임워크
|
|
54
|
+
* 질의 빌더는 `searchables` 에 없는 컬럼의 LIKE 를 경고 후 무시한다 — 인덱스 없는 전체 스캔을
|
|
55
|
+
* 막기 위해서다. 그 규율에 맞추려고 검색 축을 컬럼으로 승격했다(`twin-event-keys.ts`).
|
|
56
|
+
*
|
|
57
|
+
* 정렬 기본값은 revision DESC(최신순) — 저널의 자연 순서이자 ix_twin_event_0 가 그대로 타는 축.
|
|
58
|
+
*/
|
|
59
|
+
@Query(returns => TwinEventList, {
|
|
60
|
+
description:
|
|
61
|
+
'List twin journal events with the standard list contract (filters, pagination, sortings) plus the total match count. ' +
|
|
62
|
+
'Scope is either one twin (instanceId) or a whole site (spaceId, which folds in every running operational twin co-located there, the same targets twinKpi uses) — one of the two is required. ' +
|
|
63
|
+
'Searchable fields are the columns promoted out of the payload: epc (item), orderId, locationId, moverId (equipment), bizStep, eventType. ' +
|
|
64
|
+
'Returns total so a caller can show an honest "shown of total" count instead of silently truncating a long journal. ' +
|
|
65
|
+
'Defaults to newest first (revision descending) and to a page of 100; limit is capped at 500 per page.'
|
|
66
|
+
})
|
|
67
|
+
async twinEventList(
|
|
68
|
+
@Args(type => ListParam) params: ListParam,
|
|
69
|
+
@Ctx() context: ResolverContext,
|
|
70
|
+
@Arg('instanceId', { nullable: true, description: 'Read one twin instance journal.' }) instanceId?: string,
|
|
71
|
+
@Arg('spaceId', { nullable: true, description: 'Read every running operational twin co-located in this space, merged on the shared time axis.' }) spaceId?: string
|
|
72
|
+
): Promise<TwinEventList> {
|
|
73
|
+
const { domain } = context.state
|
|
74
|
+
if (!instanceId && !spaceId) throw new Error('either instanceId or spaceId is required')
|
|
75
|
+
/* 테넌트 격리 — twinKpi 와 같은 규약. 남의 트윈은 빈 결과가 아니라 명시 실패로 알린다
|
|
76
|
+
* (조용한 빈 목록은 "권한 없음" 과 "데이터 없음" 을 구분할 수 없게 만든다). */
|
|
77
|
+
if (instanceId && !(await TwinEngine.ownsRegistered(domain.id, instanceId))) {
|
|
78
|
+
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* 대상 해소는 KPI 와 같은 창구를 쓴다 — 같은 공간을 보면서 화면마다 대상이 달라지면 안 된다. */
|
|
82
|
+
const instanceIds = await resolveTwinTargets(domain.id, instanceId, spaceId)
|
|
83
|
+
if (instanceIds.length === 0) return { items: [], total: 0 }
|
|
84
|
+
|
|
85
|
+
/* 페이지 상한 — 화면이 실수로(또는 악의로) 저널 전체를 한 번에 달라고 해도 서버가 버틴다. */
|
|
86
|
+
const limit = Math.min(params.pagination?.limit ?? 100, 500)
|
|
87
|
+
const effective: ListParam = {
|
|
88
|
+
...params,
|
|
89
|
+
pagination: { page: params.pagination?.page ?? 1, limit },
|
|
90
|
+
/* 기본 정렬은 시각 내림차순 — 여러 트윈을 합칠 때 공통 축은 revision(트윈별 카운터)이 아니라
|
|
91
|
+
* **시각**이다. revision 으로 섞으면 서로 다른 트윈의 무관한 카운터가 뒤엉킨다. */
|
|
92
|
+
sortings: params.sortings?.length ? params.sortings : [{ name: 'eventTime', desc: true }]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const qb = getQueryBuilderFromListParams({
|
|
96
|
+
repository: getRepository(TwinEvent),
|
|
97
|
+
params: effective,
|
|
98
|
+
domain,
|
|
99
|
+
/* 전부 인덱스 가능한 승격 컬럼 — 자유 검색어는 서버가 이 축들로 펼친다. */
|
|
100
|
+
searchables: ['epc', 'orderId', 'locationId', 'moverId', 'bizStep', 'eventType'],
|
|
101
|
+
/* 정렬은 인덱스가 받쳐 주는 축으로만 — 큰 저널에서 임의 컬럼 정렬은 전체 정렬 스캔이다. */
|
|
102
|
+
sortables: ['eventTime', 'revision', 'eventType', 'bizStep'],
|
|
103
|
+
defaultLimit: 100,
|
|
104
|
+
maxLimit: 500
|
|
105
|
+
})
|
|
106
|
+
/* 대상 범위는 호출자가 필터로 빼먹을 수 있는 값이 아니다 — 계약상 필수라 여기서 강제한다. */
|
|
107
|
+
qb.andWhere(`${qb.alias}.instanceId IN (:...instanceIds)`, { instanceIds })
|
|
108
|
+
|
|
109
|
+
const [items, total] = await qb.getManyAndCount()
|
|
110
|
+
|
|
111
|
+
/* 다음 커서는 **이번 페이지의 마지막 행**에서 만든다. 페이지가 상한보다 짧으면 뒤가 없다. */
|
|
112
|
+
const axes = cursorSortings(effective.sortings, 'id')
|
|
113
|
+
const nextCursor = items.length === limit ? buildNextCursor(items[items.length - 1], axes) : undefined
|
|
114
|
+
|
|
115
|
+
return { items, total, nextCursor }
|
|
116
|
+
}
|
|
117
|
+
|
|
42
118
|
/**
|
|
43
119
|
* 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
|
|
44
120
|
*
|
|
@@ -50,7 +126,7 @@ export class TwinJournalQuery {
|
|
|
50
126
|
*/
|
|
51
127
|
@Query(returns => ScalarObject, {
|
|
52
128
|
description:
|
|
53
|
-
'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind |
|
|
129
|
+
'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind | location | order | area | shift) to break the same window down by that perspective. The shift axis is **computed, not stored**: the journal records only times, and the shift is derived from each completion\'s time plus the declared site shifts and time zone — so correcting a shift definition re-counts history correctly instead of leaving it frozen under the old boundaries. Sites that declare no shifts report every group as unknown rather than inventing shift names — per-group numbers use the same rules as the totals, so group counts sum to the total, and groups beyond groupLimit are reported as groups.truncated (how many groups) and groups.truncatedTasks (how much work those groups held) rather than silently dropped. Pass compareTo (previous | yesterday) to fold the same-length window shifted back and get comparison.delta (current minus that window); when the comparison window has no records, comparison.measured is false and no delta is produced rather than reporting zero change. Passing groupBy together with compareTo crosses the two: each group carries prevTasks / deltaTasks / deltaWorkP50Ms, groups absent from the comparison window are flagged isNew (no delta, so growth is not implied), and groups that had work before but none now are returned in groups.disappeared so a stopped area is never silently missing from the table. Area breakdown needs board locations to carry a parent area; when they do not, groups.note says so instead of implying there was no work. When the journal read hits its row cap, eventsCapped is true and every number is a lower bound — narrow the window and ask again rather than reading the smaller figures as a drop in performance. When operational targets are set for the space or instance, `targets` carries the verdicts [{metric, actual, target, direction, met, ratio}] — ratio is normalised so that 1 or more is always good, letting throughput and duration metrics be compared on one scale. A metric with no target, or with no measurement in the window, is absent from `targets` rather than reported as a miss, because a period with no work would otherwise read as a perfect score on duration metrics. Completions are counted in the window they complete; starts are looked up before the window, and any completion whose start was not found is reported in `unpaired` rather than silently dropped.'
|
|
54
130
|
})
|
|
55
131
|
async twinKpi(
|
|
56
132
|
@Ctx() context: ResolverContext,
|
|
@@ -75,7 +151,7 @@ export class TwinJournalQuery {
|
|
|
75
151
|
): Promise<any> {
|
|
76
152
|
/* 테넌트 격리 — 트윈 지정이면 소속을 확인한다. 공간 지정이면 대상 해소가 도메인 목록에서
|
|
77
153
|
* 이뤄지므로(computeTwinKpi) 남의 트윈이 섞이지 않는다. 조용한 빈 결과가 아니라 명시 실패. */
|
|
78
|
-
if (instanceId && !TwinEngine.
|
|
154
|
+
if (instanceId && !(await TwinEngine.ownsRegistered(context.state.domain.id, instanceId))) {
|
|
79
155
|
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
80
156
|
}
|
|
81
157
|
if (!instanceId && !spaceId) throw new Error('either spaceId or instanceId is required')
|
|
@@ -109,6 +185,54 @@ export class TwinJournalQuery {
|
|
|
109
185
|
return TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined, untilTime ?? undefined)
|
|
110
186
|
}
|
|
111
187
|
|
|
188
|
+
@Query(returns => ScalarObject, {
|
|
189
|
+
nullable: true,
|
|
190
|
+
description:
|
|
191
|
+
'Steady-state capacity of a twin against a declared demand: per-operation supply vs required across equipment, personnel, physical assets and locations, plus the binding constraint and the daily ceiling. Answers "can this plant meet the target?" without running a simulation. Null when the twin is not running.'
|
|
192
|
+
})
|
|
193
|
+
async twinCapacity(
|
|
194
|
+
@Arg('instanceId', { nullable: true }) instanceId: string,
|
|
195
|
+
@Arg('spaceId', { nullable: true }) spaceId: string,
|
|
196
|
+
/** 하루 몇 대를 낼 것인가 — 화면이 보고 있는 목표에서 나온다(선언된 수요). */
|
|
197
|
+
@Arg('unitsPerDay', type => Float) unitsPerDay: number,
|
|
198
|
+
/**
|
|
199
|
+
* 가용 시간을 잴 기준 주의 시작(ISO, 월요일). 공휴일이 없는 평상주여야 한다 —
|
|
200
|
+
* 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시 천장을 정하지 않는다.
|
|
201
|
+
*/
|
|
202
|
+
@Arg('sampleWeekStart', { nullable: true }) sampleWeekStart: string,
|
|
203
|
+
@Ctx() context: ResolverContext
|
|
204
|
+
): Promise<any> {
|
|
205
|
+
if (instanceId && !(await TwinEngine.ownsRegistered(context.state.domain.id, instanceId))) {
|
|
206
|
+
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
207
|
+
}
|
|
208
|
+
if (!instanceId && !spaceId) throw new Error('either spaceId or instanceId is required')
|
|
209
|
+
if (!(unitsPerDay > 0)) throw new Error('unitsPerDay must be greater than zero')
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* 기준 주를 안 주면 **이번 주 월요일**을 쓴다. 그 주에 공휴일이 끼면 천장이 조금 낮게 나오는데,
|
|
213
|
+
* 그것은 조용한 오류가 아니라 **이번 주의 실제 가용 시간**이라 답으로서 정직하다. 평상시 천장을
|
|
214
|
+
* 보려는 호출자는 평상주를 지정한다.
|
|
215
|
+
*/
|
|
216
|
+
const base = sampleWeekStart ? Date.parse(sampleWeekStart) : Date.now()
|
|
217
|
+
if (Number.isNaN(base)) throw new Error(`sampleWeekStart is not a valid ISO time: ${sampleWeekStart}`)
|
|
218
|
+
const d = new Date(base)
|
|
219
|
+
const monday = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - ((d.getUTCDay() + 6) % 7))
|
|
220
|
+
|
|
221
|
+
return TwinEngine.capacity(context.state.domain.id, { instanceId, spaceId }, unitsPerDay, monday)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Query(returns => ScalarObject, {
|
|
225
|
+
description:
|
|
226
|
+
"Structural revisions a twin has been through, newest last: when each took effect, how many journal events happened under it, and what changed from the previous one (counts by location type and equipment kind). The board itself is not returned — a revision holds the whole plant, but what a person wants is the difference."
|
|
227
|
+
})
|
|
228
|
+
async twinStructures(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any> {
|
|
229
|
+
/* 이력 질문이므로 **등록부**에 소유를 묻는다 — 꺼진 트윈의 이력도 읽을 수 있어야 한다. */
|
|
230
|
+
if (!(await TwinEngine.ownsRegistered(context.state.domain.id, instanceId))) {
|
|
231
|
+
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
232
|
+
}
|
|
233
|
+
return TwinEngine.structureHistory(context.state.domain.id, instanceId)
|
|
234
|
+
}
|
|
235
|
+
|
|
112
236
|
@Query(returns => ScalarObject, {
|
|
113
237
|
description: 'Event time range (min/max ISO) across all co-located twins of a space — the history scrubber time axis.'
|
|
114
238
|
})
|
|
@@ -43,7 +43,8 @@ export class TwinLifecycleMutation {
|
|
|
43
43
|
@Arg('board', type => ScalarObject, { nullable: true }) board?: any
|
|
44
44
|
): Promise<boolean> {
|
|
45
45
|
const domainId = context.state.domain.id
|
|
46
|
-
|
|
46
|
+
// 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.
|
|
47
|
+
if (board) TwinEngine.start(instanceId, domainId, kind ?? 'wms', await TwinEngine.withSpaceTimeBase(board as BoardDef, domainId))
|
|
47
48
|
else await TwinEngine.startFromRegistry(domainId, instanceId)
|
|
48
49
|
return true
|
|
49
50
|
}
|
|
@@ -86,10 +87,10 @@ export class TwinLifecycleMutation {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/**
|
|
89
|
-
* 도메인 어휘 카탈로그 — 커널(레퍼런스) SSOT 를 그대로 노출. { wms:{label,
|
|
90
|
+
* 도메인 어휘 카탈로그 — 커널(레퍼런스) SSOT 를 그대로 노출. { wms:{label,locationTypes[]}, yms:…, mes:… }.
|
|
90
91
|
* 프로비저닝 UI 는 이걸 소싱해 노드 타입/시스템을 채운다(앱 내 하드코딩 금지).
|
|
91
92
|
*/
|
|
92
|
-
@Query(returns => ScalarObject, { description: 'Domain vocabulary catalog (per-system location
|
|
93
|
+
@Query(returns => ScalarObject, { description: 'Domain vocabulary catalog (per-system location location types) sourced from the twin kernel — SSOT for provisioning UI.' })
|
|
93
94
|
twinDomainCatalog(): any {
|
|
94
95
|
return domainCatalog()
|
|
95
96
|
}
|
|
@@ -49,7 +49,7 @@ export class TwinSpaceArea {
|
|
|
49
49
|
drillTo?: any
|
|
50
50
|
|
|
51
51
|
@Column({ type: 'simple-json', nullable: true })
|
|
52
|
-
@Field(type => ScalarObject, { nullable: true, description: 'Twin binding {
|
|
52
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Twin binding { locationId?|entityId?|tag? } for live state.' })
|
|
53
53
|
binding?: any
|
|
54
54
|
|
|
55
55
|
@Column({ type: 'simple-json', nullable: true })
|