@things-factory/headless-twin 10.0.6 → 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 +21 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +9 -0
- package/dist-server/engine/kpi-query.js +118 -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 +153 -17
- package/dist-server/engine/twin-engine.js +473 -81
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +5 -5
- package/dist-server/engine/warm-start.js +4 -4
- package/dist-server/engine/warm-start.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/twin-event-keys.d.ts +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js +3 -3
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +11 -0
- package/dist-server/service/twin-event/twin-event.js +6 -1
- 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 +9 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
- 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 +40 -8
- package/server/engine/kpi-query.ts +129 -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 +498 -84
- package/server/engine/warm-start.ts +8 -8
- 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/twin-event-keys.ts +2 -2
- package/server/service/twin-event/twin-event.ts +15 -1
- 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 +52 -4
- 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 +232 -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 +2 -2
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +9 -9
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'
|
|
2
|
-
import { Arg, Args, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import { Arg, Args, Ctx, Float, Int, Query, Resolver } from 'type-graphql'
|
|
3
3
|
|
|
4
4
|
import { buildNextCursor, cursorSortings, getQueryBuilderFromListParams, getRepository, ListParam, ScalarObject } from '@things-factory/shell'
|
|
5
5
|
|
|
@@ -74,7 +74,7 @@ export class TwinJournalQuery {
|
|
|
74
74
|
if (!instanceId && !spaceId) throw new Error('either instanceId or spaceId is required')
|
|
75
75
|
/* 테넌트 격리 — twinKpi 와 같은 규약. 남의 트윈은 빈 결과가 아니라 명시 실패로 알린다
|
|
76
76
|
* (조용한 빈 목록은 "권한 없음" 과 "데이터 없음" 을 구분할 수 없게 만든다). */
|
|
77
|
-
if (instanceId && !TwinEngine.
|
|
77
|
+
if (instanceId && !(await TwinEngine.ownsRegistered(domain.id, instanceId))) {
|
|
78
78
|
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -126,7 +126,7 @@ export class TwinJournalQuery {
|
|
|
126
126
|
*/
|
|
127
127
|
@Query(returns => ScalarObject, {
|
|
128
128
|
description:
|
|
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 |
|
|
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.'
|
|
130
130
|
})
|
|
131
131
|
async twinKpi(
|
|
132
132
|
@Ctx() context: ResolverContext,
|
|
@@ -151,7 +151,7 @@ export class TwinJournalQuery {
|
|
|
151
151
|
): Promise<any> {
|
|
152
152
|
/* 테넌트 격리 — 트윈 지정이면 소속을 확인한다. 공간 지정이면 대상 해소가 도메인 목록에서
|
|
153
153
|
* 이뤄지므로(computeTwinKpi) 남의 트윈이 섞이지 않는다. 조용한 빈 결과가 아니라 명시 실패. */
|
|
154
|
-
if (instanceId && !TwinEngine.
|
|
154
|
+
if (instanceId && !(await TwinEngine.ownsRegistered(context.state.domain.id, instanceId))) {
|
|
155
155
|
throw new Error(`twin instance not found in this tenant: ${instanceId}`)
|
|
156
156
|
}
|
|
157
157
|
if (!instanceId && !spaceId) throw new Error('either spaceId or instanceId is required')
|
|
@@ -185,6 +185,54 @@ export class TwinJournalQuery {
|
|
|
185
185
|
return TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined, untilTime ?? undefined)
|
|
186
186
|
}
|
|
187
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
|
+
|
|
188
236
|
@Query(returns => ScalarObject, {
|
|
189
237
|
description: 'Event time range (min/max ISO) across all co-located twins of a space — the history scrubber time axis.'
|
|
190
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 })
|
|
@@ -27,7 +27,7 @@ export class TwinSpaceResolver {
|
|
|
27
27
|
const benchInst = insts.find(i => (i as any).purpose === 'bench')
|
|
28
28
|
const bench = !!benchInst
|
|
29
29
|
const copyOf = benchInst?.copyOf
|
|
30
|
-
out.push({ spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, primaryRepresentationId: r.primaryRepresentationId, content: r.content, representationCount, bench, copyOf })
|
|
30
|
+
out.push({ spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, timezone: r.timezone, primaryRepresentationId: r.primaryRepresentationId, content: r.content, representationCount, bench, copyOf })
|
|
31
31
|
}
|
|
32
32
|
return out
|
|
33
33
|
}
|
|
@@ -44,10 +44,10 @@ export class TwinSpaceResolver {
|
|
|
44
44
|
const areas = await getRepository(TwinSpaceArea).find({ where: { domain: { id: domainId }, representation: { id: rep.id } }, order: { createdAt: 'ASC' } })
|
|
45
45
|
representations.push({ id: rep.id, type: rep.type, name: rep.name, seq: rep.seq, isPrimary: rep.isPrimary, config: rep.config, areas: areas.map(a => ({ id: a.id, name: a.name, type: a.type, geometry: a.geometry, drillTo: a.drillTo, binding: a.binding, style: a.style })) })
|
|
46
46
|
}
|
|
47
|
-
return { spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, primaryRepresentationId: r.primaryRepresentationId, content: r.content, representations }
|
|
47
|
+
return { spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, timezone: r.timezone, primaryRepresentationId: r.primaryRepresentationId, content: r.content, representations }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
@Mutation(returns => Boolean, { description: 'Create or update a Space (name/description/geo/primaryRepresentationId).' })
|
|
50
|
+
@Mutation(returns => Boolean, { description: 'Create or update a Space (name/description/geo/timezone/primaryRepresentationId).' })
|
|
51
51
|
async saveTwinSpace(
|
|
52
52
|
@Arg('spaceId') spaceId: string,
|
|
53
53
|
@Arg('patch', type => ScalarObject) patch: any,
|
|
@@ -42,6 +42,20 @@ export class TwinSpace {
|
|
|
42
42
|
@Field(type => ScalarObject, { nullable: true, description: 'Site georeference { lat, lon, rotation, unit } — aligns representations.' })
|
|
43
43
|
geo?: any
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 이 현장의 시각 기준 — IANA 이름(`'America/Tijuana'`).
|
|
47
|
+
*
|
|
48
|
+
* **테넌트가 아니라 공간이 갖는다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고,
|
|
49
|
+
* 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 교대가 "06시" 라고 할 때 그것이
|
|
50
|
+
* 어느 06시인가는 **물리 현장의 성질**이다.
|
|
51
|
+
*
|
|
52
|
+
* 오프셋 숫자가 아니라 이름으로 둔다: 숫자로는 일광절약시간을 표현할 수 없고, 나중에 절대 구간
|
|
53
|
+
* (표준 `StartDateTime`)으로 옮길 때도 이름이 있어야 계산할 수 있다.
|
|
54
|
+
*/
|
|
55
|
+
@Column({ nullable: true })
|
|
56
|
+
@Field({ nullable: true, description: 'IANA time zone of this physical site (e.g. America/Tijuana) — the base for reading shift times. Belongs to the space, not the tenant, because one tenant can run plants in different zones.' })
|
|
57
|
+
timezone?: string
|
|
58
|
+
|
|
45
59
|
@Column({ nullable: true })
|
|
46
60
|
@Field({ nullable: true, description: 'Id of the primary representation shown on the home/monitor.' })
|
|
47
61
|
primaryRepresentationId?: string
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
|
|
2
|
+
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
import { User } from '@things-factory/auth-base'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* TwinStructure — **한 트윈이 거쳐 온 공장 구조들.** 리비전 하나가 그 시절의 공장이다.
|
|
9
|
+
*
|
|
10
|
+
* ── 왜 필요한가: 역사를 잃거나 거짓말을 하거나 ──────────────────────────────
|
|
11
|
+
* 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조 서명이 바뀌면
|
|
12
|
+
* **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다. 안 지우면 옛 이벤트를 새 공장에 대고
|
|
13
|
+
* 접게 되어 이력이 거짓말을 하기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에 접으면
|
|
14
|
+
* 그때 없던 부스에서 일이 있었던 것처럼 보인다.
|
|
15
|
+
*
|
|
16
|
+
* 그래서 선택지가 둘뿐이었다: **역사를 잃거나, 거짓말을 하거나.**
|
|
17
|
+
*
|
|
18
|
+
* 셋째 길이 이 표다. 이벤트가 `structureRev` 로 자기 구조를 달고 다니고, 재생은 구조가 바뀌는
|
|
19
|
+
* 지점에서 갈아탄 뒤 이어 접는다(`replaySegments`). 이력은 "그때 그 공장의 사실" 로 계속 읽힌다.
|
|
20
|
+
*
|
|
21
|
+
* ── 왜 board 를 통째로 담나 ─────────────────────────────────────────────────
|
|
22
|
+
* 재생하려면 **그때의 토폴로지 전체**가 필요하다. 차이(diff)만 담으면 리비전 1부터 되짚어 쌓아야
|
|
23
|
+
* 하고, 그 되짚기 자체가 또 하나의 규칙이 되어 갈라진다. 구조는 이벤트에 비해 드물게 바뀌므로
|
|
24
|
+
* 스냅샷을 통째로 두는 편이 싸고 정직하다(보드 이력 `BoardHistory` 와 같은 선택).
|
|
25
|
+
*
|
|
26
|
+
* ── audit 컬럼을 다 두지 않는 이유 ──────────────────────────────────────────
|
|
27
|
+
* 리비전은 **한 번 쓰이면 바뀌지 않는다** — 그 시절의 사실이라 고칠 수가 없다. 그래서 updater·
|
|
28
|
+
* deletedAt 이 없다(TwinEvent 와 같은 규율). 대신 **누가 구조를 바꿨는지**는 남긴다.
|
|
29
|
+
*
|
|
30
|
+
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
31
|
+
*/
|
|
32
|
+
@Entity()
|
|
33
|
+
@Index('ix_twin_structure_0', (e: TwinStructure) => [e.domain, e.instanceId, e.rev], { unique: true })
|
|
34
|
+
@ObjectType({
|
|
35
|
+
description:
|
|
36
|
+
'One structural revision of a twin — the plant as it stood for a stretch of its journal. Events carry the revision they were produced under, so history can be replayed against the structure that was actually in force instead of being deleted when the plant changes.'
|
|
37
|
+
})
|
|
38
|
+
export class TwinStructure {
|
|
39
|
+
@PrimaryGeneratedColumn('uuid')
|
|
40
|
+
@Field(type => ID, { description: 'Unique identifier of the structure revision record.' })
|
|
41
|
+
readonly id: string
|
|
42
|
+
|
|
43
|
+
@ManyToOne(type => Domain)
|
|
44
|
+
@Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })
|
|
45
|
+
domain?: Domain
|
|
46
|
+
|
|
47
|
+
@RelationId((e: TwinStructure) => e.domain)
|
|
48
|
+
domainId?: string
|
|
49
|
+
|
|
50
|
+
@Column()
|
|
51
|
+
@Field({ description: 'Twin runtime instance this structure belongs to.' })
|
|
52
|
+
instanceId: string
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 리비전 번호 — 1부터. 이벤트의 `structureRev` 가 이것을 가리킨다.
|
|
56
|
+
*
|
|
57
|
+
* 0 을 안 쓰는 이유: 컬럼이 없던 시절의 행은 `structureRev` 가 비어 있고, 그 행들은 **가장 오래된
|
|
58
|
+
* 리비전**에 속한다. 0 을 유효 번호로 쓰면 "모름" 과 "1번 구조" 가 같은 값이 된다.
|
|
59
|
+
*/
|
|
60
|
+
@Column({ type: 'int' })
|
|
61
|
+
@Field(type => Int, { description: 'Revision number, starting at 1. Events reference it via structureRev; absent on rows written before structure revisions existed, which belong to the oldest revision.' })
|
|
62
|
+
rev: number
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 구조 서명 — 이 리비전이 앞 리비전과 **정말 다른지** 판정한 근거.
|
|
66
|
+
*
|
|
67
|
+
* 서명이 같으면 새 리비전을 만들지 않는다. 프로비저닝은 자주 다시 도는데(부팅마다) 그때마다
|
|
68
|
+
* 리비전이 하나씩 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다.
|
|
69
|
+
*/
|
|
70
|
+
@Column({ length: 64 })
|
|
71
|
+
@Field({ description: 'Short hash of the structural signature (locations + equipment). A re-provision with the same signature does not create a new revision — otherwise every reboot would slice the history into meaningless segments.' })
|
|
72
|
+
signature: string
|
|
73
|
+
|
|
74
|
+
@Column({ type: 'simple-json' })
|
|
75
|
+
@Field(type => ScalarObject, { description: 'The full BoardDef as it stood for this revision. Stored whole rather than as a diff: replay needs the entire topology, and rebuilding it from diffs would be a second rule that can drift.' })
|
|
76
|
+
board: any
|
|
77
|
+
|
|
78
|
+
/** 왜 바뀌었나 — 사람이 남기는 한 줄. 없을 수 있다(자동 프로비저닝은 이유를 모른다). */
|
|
79
|
+
@Column({ nullable: true })
|
|
80
|
+
@Field({ nullable: true, description: 'Why the structure changed — a human note, absent for automatic provisioning.' })
|
|
81
|
+
comment?: string
|
|
82
|
+
|
|
83
|
+
@ManyToOne(type => User, { nullable: true })
|
|
84
|
+
@Field(type => User, { nullable: true, description: 'Who changed the structure, when known.' })
|
|
85
|
+
creator?: User
|
|
86
|
+
|
|
87
|
+
@RelationId((e: TwinStructure) => e.creator)
|
|
88
|
+
creatorId?: string
|
|
89
|
+
|
|
90
|
+
@CreateDateColumn()
|
|
91
|
+
@Field({ nullable: true, description: 'When this revision took effect.' })
|
|
92
|
+
createdAt?: Date
|
|
93
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Arg, Ctx, Directive, Float, Mutation, Query, Resolver } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { getRepository } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinTarget } from './twin-target.js'
|
|
6
|
+
import { TWIN_METRIC, isTwinMetric } from '../../engine/kpi-target.js'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* 운영 목표 조회·편집 — **현장 관리자가 트윈 화면에서** 고친다(사용자 결정 2026-08-02).
|
|
10
|
+
*
|
|
11
|
+
* 경영 KPI 관리메뉴가 아니라 여기인 이유: 목표가 숫자 옆에 있어야 맥락이 살고, 운영 임계는 자주
|
|
12
|
+
* 조정된다. 조직 단위 기간 평가는 `@things-factory/kpi` 의 일이고 이쪽으로 베껴 오지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
@Resolver()
|
|
15
|
+
export class TwinTargetResolver {
|
|
16
|
+
@Directive('@privilege(category: "twin", privilege: "query")')
|
|
17
|
+
@Query(returns => [String], {
|
|
18
|
+
description:
|
|
19
|
+
'The metric ids a twin target may refer to. Fixed in one place so a target and the fact it judges can never drift into two names.'
|
|
20
|
+
})
|
|
21
|
+
async twinTargetMetrics(): Promise<string[]> {
|
|
22
|
+
return Object.keys(TWIN_METRIC)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Directive('@privilege(category: "twin", privilege: "query")')
|
|
26
|
+
@Query(returns => [TwinTarget], {
|
|
27
|
+
description:
|
|
28
|
+
'Operational targets in scope — site-wide targets for the space plus targets bound to the given instance. Callers judge with the narrower one when both exist.'
|
|
29
|
+
})
|
|
30
|
+
async twinTargets(
|
|
31
|
+
@Ctx() context: ResolverContext,
|
|
32
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string,
|
|
33
|
+
@Arg('instanceId', { nullable: true }) instanceId?: string
|
|
34
|
+
): Promise<TwinTarget[]> {
|
|
35
|
+
const domainId = context.state.domain.id
|
|
36
|
+
const rows = await getRepository(TwinTarget).find({ where: { domain: { id: domainId } } })
|
|
37
|
+
return rows.filter(r => (spaceId && r.spaceId === spaceId) || (instanceId && r.instanceId === instanceId))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Directive('@privilege(category: "twin", privilege: "mutation")')
|
|
41
|
+
@Mutation(returns => Boolean, {
|
|
42
|
+
description:
|
|
43
|
+
'Create or update one operational target. The metric must be one of twinTargetMetrics — an unknown name is rejected rather than stored, because a target nobody can evaluate reads as "no target" and hides the gap.'
|
|
44
|
+
})
|
|
45
|
+
async saveTwinTarget(
|
|
46
|
+
@Arg('metric') metric: string,
|
|
47
|
+
@Arg('target', type => Float) target: number,
|
|
48
|
+
@Ctx() context: ResolverContext,
|
|
49
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string,
|
|
50
|
+
@Arg('instanceId', { nullable: true }) instanceId?: string,
|
|
51
|
+
@Arg('axis', { nullable: true }) axis?: string,
|
|
52
|
+
@Arg('axisKey', { nullable: true }) axisKey?: string
|
|
53
|
+
): Promise<boolean> {
|
|
54
|
+
if (!isTwinMetric(metric)) throw new Error(`unknown twin metric "${metric}" — see twinTargetMetrics`)
|
|
55
|
+
if (!Number.isFinite(target)) throw new Error('target must be a finite number')
|
|
56
|
+
if (!spaceId && !instanceId) throw new Error('a target must be scoped to a space or an instance')
|
|
57
|
+
/* 축만 있고 값이 없으면 **어디에 거는 목표인지 알 수 없다** — 조용히 전체 목표로 만들지 않는다
|
|
58
|
+
(그러면 사용자는 구역 목표를 걸었다고 믿는데 전체가 바뀐다). */
|
|
59
|
+
if (axis && !axisKey) throw new Error('axisKey is required when axis is given')
|
|
60
|
+
if (!axis && axisKey) throw new Error('axis is required when axisKey is given')
|
|
61
|
+
const domainId = context.state.domain.id
|
|
62
|
+
const repo = getRepository(TwinTarget)
|
|
63
|
+
const existing = await repo.findOne({
|
|
64
|
+
where: { domain: { id: domainId }, spaceId: spaceId ?? null, instanceId: instanceId ?? null, metric, axis: axis ?? null, axisKey: axisKey ?? null } as any
|
|
65
|
+
})
|
|
66
|
+
await repo.save(
|
|
67
|
+
repo.create({
|
|
68
|
+
...(existing ?? {}),
|
|
69
|
+
domain: { id: domainId } as any,
|
|
70
|
+
spaceId: spaceId ?? null as any,
|
|
71
|
+
instanceId: instanceId ?? null as any,
|
|
72
|
+
axis: axis ?? null as any,
|
|
73
|
+
axisKey: axisKey ?? null as any,
|
|
74
|
+
metric,
|
|
75
|
+
target,
|
|
76
|
+
...(existing ? { updater: context.state.user } : { creator: context.state.user, updater: context.state.user })
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Directive('@privilege(category: "twin", privilege: "mutation")')
|
|
83
|
+
@Mutation(returns => Boolean, { description: 'Remove an operational target. Removing a target removes the judgement, not the fact — the metric keeps being reported without a verdict.' })
|
|
84
|
+
async deleteTwinTarget(
|
|
85
|
+
@Arg('metric') metric: string,
|
|
86
|
+
@Ctx() context: ResolverContext,
|
|
87
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string,
|
|
88
|
+
@Arg('instanceId', { nullable: true }) instanceId?: string,
|
|
89
|
+
@Arg('axis', { nullable: true }) axis?: string,
|
|
90
|
+
@Arg('axisKey', { nullable: true }) axisKey?: string
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
const domainId = context.state.domain.id
|
|
93
|
+
const repo = getRepository(TwinTarget)
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* **찾아서 그 행만 지운다.** 예전에는 조건 객체를 `delete()` 에 그대로 넘겼는데 **아무 행도
|
|
97
|
+
* 안 지워지면서 `true` 를 돌려줬다.** 사용자는 목표를 지웠다고 믿고 화면을 떠나는데 판정은 그대로
|
|
98
|
+
* 남는다(실화면에서 그렇게 당했다).
|
|
99
|
+
*
|
|
100
|
+
* 무엇이 조건을 깨뜨렸는지는 단정하지 않는다 — 이 조건에는 `domain` 관계 객체와 `null` 값들이
|
|
101
|
+
* 함께 들어 있었고(`instanceId`·`axis`·`axisKey`), 둘 다 `delete()` 의 조건 해석에서 흔한 함정이다.
|
|
102
|
+
* 같은 관계-객체 패턴을 쓰는 `resetJournal` 은 실제로 잘 지우므로 관계 객체 하나만으로 설명되지
|
|
103
|
+
* 않는다. `null` 조건이 `IS NULL` 이 아니라 `= NULL` 로 나갔을 가능성이 크다.
|
|
104
|
+
*
|
|
105
|
+
* 그래서 조건을 고쳐 맞추는 대신 **행을 찾아 id 로 지운다**: 저장 경로와 **같은 조회 규칙**을
|
|
106
|
+
* 쓰므로 규칙이 갈라지지 않고(`findOne` 은 `null` 을 제대로 다룬다), 지우는 대상이 정확히 한
|
|
107
|
+
* 행이라 넓게 지울 위험도 없다.
|
|
108
|
+
*/
|
|
109
|
+
const row = await repo.findOne({
|
|
110
|
+
where: {
|
|
111
|
+
domain: { id: domainId },
|
|
112
|
+
spaceId: spaceId ?? null,
|
|
113
|
+
instanceId: instanceId ?? null,
|
|
114
|
+
axis: axis ?? null,
|
|
115
|
+
axisKey: axisKey ?? null,
|
|
116
|
+
metric
|
|
117
|
+
} as any
|
|
118
|
+
})
|
|
119
|
+
/* 지울 것이 없으면 **없었다고 답한다.** 지우지 않고 true 를 주면 위와 같은 거짓말이 된다. */
|
|
120
|
+
if (!row) return false
|
|
121
|
+
await repo.delete(row.id)
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
|
|
2
|
+
import { ObjectType, Field, ID, Float } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain } from '@things-factory/shell'
|
|
5
|
+
import { User } from '@things-factory/auth-base'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* TwinTarget — **운영 목표.** "이 구간의 처리량이 목표에 닿았나" 를 판단하는 기준선.
|
|
9
|
+
*
|
|
10
|
+
* ── 왜 트윈이 갖나 (사용자 결정 2026-08-02) ─────────────────────────────────
|
|
11
|
+
* `@things-factory/kpi` 와 **경계를 나눈다**:
|
|
12
|
+
* 트윈(여기) 이 구간·이 교대·이 공간의 **즉시 판단**. 현장 관리자가 트윈 화면에서 고친다.
|
|
13
|
+
* 값은 저널 폴드에서 그때그때 계산된다(저장된 값을 읽지 않는다).
|
|
14
|
+
* packages/kpi **기간 평가**(조직·분기·점수·가중치·알림). 값이 저장돼 있어야 도는 체계다.
|
|
15
|
+
*
|
|
16
|
+
* 지키는 규칙 하나: **트윈은 경영 목표를 복제하지 않는다.** 조직 단위 평가가 필요하면 트윈의 사실을
|
|
17
|
+
* 내보내 그쪽이 소비하고, 그쪽 목표를 여기로 베껴 오지 않는다 — 베끼는 순간 두 개의 진실이 된다.
|
|
18
|
+
*
|
|
19
|
+
* ── 범위 (scope) ───────────────────────────────────────────────────────────
|
|
20
|
+
* `spaceId` 로 현장 전체에, `instanceId` 로 트윈 하나에 건다. 둘 다 있으면 **인스턴스가 이긴다**
|
|
21
|
+
* (좁은 선언이 넓은 선언을 덮는다 — 예외를 표현할 수 있어야 한다).
|
|
22
|
+
*
|
|
23
|
+
* 관점 축별 목표(구역마다 다른 목표)는 **아직 아니다** — 축 교차는 목표가 화면에서 쓰이기 시작한 뒤의
|
|
24
|
+
* 일이고, 처음부터 넣으면 쓰이지 않는 구조가 먼저 굳는다(performance.md §5.4-5).
|
|
25
|
+
*
|
|
26
|
+
* (CLAUDE.md: 모든 @ObjectType/@Field 영문 description 필수.)
|
|
27
|
+
*/
|
|
28
|
+
@Entity()
|
|
29
|
+
@Index('ix_twin_target_0', (e: TwinTarget) => [e.domain, e.spaceId, e.instanceId, e.metric, e.axis, e.axisKey], { unique: true })
|
|
30
|
+
@ObjectType({
|
|
31
|
+
description:
|
|
32
|
+
'An operational performance target for a twin metric — the line that turns a measured number into a judgement. Scoped to a space or to a single instance (instance wins over space, so an exception can be expressed). Distinct from @things-factory/kpi targets, which evaluate organizations over periods; twin targets are for immediate, on-site judgement and are never copied from the management KPI system.'
|
|
33
|
+
})
|
|
34
|
+
export class TwinTarget {
|
|
35
|
+
@PrimaryGeneratedColumn('uuid')
|
|
36
|
+
@Field(type => ID, { description: 'Unique identifier of the target record.' })
|
|
37
|
+
readonly id: string
|
|
38
|
+
|
|
39
|
+
@ManyToOne(type => Domain)
|
|
40
|
+
@Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })
|
|
41
|
+
domain?: Domain
|
|
42
|
+
|
|
43
|
+
@RelationId((e: TwinTarget) => e.domain)
|
|
44
|
+
domainId?: string
|
|
45
|
+
|
|
46
|
+
@Column({ nullable: true })
|
|
47
|
+
@Field({ nullable: true, description: 'Space this target applies to (site-wide). Empty when the target is bound to a single instance.' })
|
|
48
|
+
spaceId?: string
|
|
49
|
+
|
|
50
|
+
@Column({ nullable: true })
|
|
51
|
+
@Field({ nullable: true, description: 'Instance this target applies to. When both space and instance match, the instance target wins so an exception can be expressed.' })
|
|
52
|
+
instanceId?: string
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 관점 축과 그 값 — **"이 구역만", "이 교대만" 목표가 다르다.**
|
|
56
|
+
*
|
|
57
|
+
* 둘 다 비어 있으면 **전체 목표**다(창 전체의 집계를 판정한다). 축 목표는 전체 목표를 덮지 않는다 —
|
|
58
|
+
* 다른 질문이기 때문이다: "현장 전체가 100건" 과 "용접 구역이 30건" 은 동시에 참일 수 있다.
|
|
59
|
+
*/
|
|
60
|
+
@Column({ nullable: true })
|
|
61
|
+
@Field({ nullable: true, description: 'Perspective axis this target applies to (area, shift, resource, taskKind, location, order). Empty means the target judges the window total. An axis target does not override the total — "the site does 100" and "the weld area does 30" are both true at once.' })
|
|
62
|
+
axis?: string
|
|
63
|
+
|
|
64
|
+
@Column({ nullable: true })
|
|
65
|
+
@Field({ nullable: true, description: 'The value on that axis (an area id, a shift name…). Required when axis is set.' })
|
|
66
|
+
axisKey?: string
|
|
67
|
+
|
|
68
|
+
@Column()
|
|
69
|
+
@Field({ description: 'Metric id from the twin metric vocabulary (throughput.tasks, workTime.p50Ms, …). Names are fixed in one place so target and fact never drift into two dialects.' })
|
|
70
|
+
metric: string
|
|
71
|
+
|
|
72
|
+
@Column({ type: 'float' })
|
|
73
|
+
@Field(type => Float, { description: 'The target value, in the metric\'s own unit (count, milliseconds, ratio). Whether higher or lower is better is a property of the metric, not of this record — so a target can never be declared in the wrong direction.' })
|
|
74
|
+
target: number
|
|
75
|
+
|
|
76
|
+
@ManyToOne(type => User, { nullable: true })
|
|
77
|
+
@Field(type => User, { nullable: true, description: 'User who created this target.' })
|
|
78
|
+
creator?: User
|
|
79
|
+
|
|
80
|
+
@RelationId((e: TwinTarget) => e.creator)
|
|
81
|
+
creatorId?: string
|
|
82
|
+
|
|
83
|
+
@ManyToOne(type => User, { nullable: true })
|
|
84
|
+
@Field(type => User, { nullable: true, description: 'User who last updated this target.' })
|
|
85
|
+
updater?: User
|
|
86
|
+
|
|
87
|
+
@RelationId((e: TwinTarget) => e.updater)
|
|
88
|
+
updaterId?: string
|
|
89
|
+
|
|
90
|
+
@CreateDateColumn()
|
|
91
|
+
@Field({ nullable: true, description: 'Timestamp when the target was created.' })
|
|
92
|
+
createdAt?: Date
|
|
93
|
+
|
|
94
|
+
@UpdateDateColumn()
|
|
95
|
+
@Field({ nullable: true, description: 'Timestamp when the target was last updated.' })
|
|
96
|
+
updatedAt?: Date
|
|
97
|
+
}
|