@things-factory/headless-twin 10.0.5 → 10.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
@@ -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,3 @@
1
+ import { TwinStructure } from './twin-structure.js'
2
+
3
+ export const entities = [TwinStructure]
@@ -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,5 @@
1
+ import { TwinTarget } from './twin-target.js'
2
+ import { TwinTargetResolver } from './twin-target-resolver.js'
3
+
4
+ export const entities = [TwinTarget]
5
+ export const resolvers = [TwinTargetResolver]
@@ -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
+ }
@@ -63,14 +63,14 @@ test('능력 소싱 구조: 4 자원/위치 능력은 카탈로그 타입에서,
63
63
 
64
64
  test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload 에 실림 (커널→보드 도달)', () => {
65
65
  // storable 노드 → occupancy/capacity (+ operable status)
66
- const nodeD: any = buildEntityDeltas({ nodes: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
66
+ const nodeD: any = buildEntityDeltas({ locations: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
67
67
  for (const f of stateFieldsOf(['storable'])) assert.ok(f in nodeD, `storable payload.${f}`)
68
68
  assert.ok('status' in nodeD, 'operable(노드 status) payload')
69
69
 
70
70
  // mobile+operable 무버 → location/motion/status
71
- const moverD: any = buildEntityDeltas({ movers: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
72
- for (const f of stateFieldsOf(['mobile'])) assert.ok(f in moverD, `mobile payload.${f}`)
73
- for (const f of stateFieldsOf(['operable'])) assert.ok(f in moverD, `operable payload.${f}`)
71
+ const equipmentD: any = buildEntityDeltas({ equipment: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
72
+ for (const f of stateFieldsOf(['mobile'])) assert.ok(f in equipmentD, `mobile payload.${f}`)
73
+ for (const f of stateFieldsOf(['operable'])) assert.ok(f in equipmentD, `operable payload.${f}`)
74
74
 
75
75
  // trackable 오더 → progress/held (lifecycle 은 payload 에서 'status' 로 표면화 — 이름-매핑 시임)
76
76
  const orderD: any = buildEntityDeltas({ orders: [{ id: 'o', status: 'picking', progress: 0.5, held: false }] }).find(x => x.tag === 'o')!.data
@@ -81,7 +81,7 @@ test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload
81
81
  test('시임 문서화 · processable output(양품/불량)은 payload oee.good/scrap 으로 표면화', () => {
82
82
  // 계약 stateField = 'output' 이나, 라이브 payload 는 이를 oee(정수%+good/scrap)로 집약해 표면화.
83
83
  const d: any = buildEntityDeltas({
84
- movers: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
84
+ equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
85
85
  }).find(x => x.tag === 'c')!.data
86
86
  assert.ok(d.oee, 'processable → payload.oee 존재')
87
87
  assert.equal(d.oee.good, 10, 'output.good = oee.good')
@@ -0,0 +1,144 @@
1
+ /*
2
+ * 소요시간 추정기 — **상수를 데이터로 바꾸는 두 층(실측 · 거리×속도).**
3
+ *
4
+ * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 호스트가 넣는 두 가지를
5
+ * 여기서 고정한다. 핵심 규율은 하나다 — **모르면 답하지 않는다**(undefined → 커널이 명세·상수로).
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { EQUIPMENT_PROPERTY, buildTravelEstimator, chainEstimators } from '../server/engine/travel-estimator.ts'
11
+ import { DEFAULT_MIN_SAMPLES, buildMeasuredEstimator } from '../server/engine/measured-estimator.ts'
12
+
13
+ const layout = { dock: { x: 0, y: 0 }, rack: { x: 30, y: 40 } } // 거리 50m
14
+ const equipment = (uom?: string, value = '2') => [
15
+ { id: 'fk-1', kind: 'forklift', properties: [{ id: EQUIPMENT_PROPERTY.speed, value, ...(uom ? { uom } : {}) }] }
16
+ ]
17
+
18
+ /* ── 거리 × 속도 ────────────────────────────────────────────────────────── */
19
+
20
+ test('거리와 속도가 있으면 이동시간을 계산한다 — 상수 30초가 아니라 50m/2(m/s)=25초', () => {
21
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
22
+ assert.deepEqual(reasons, [])
23
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
24
+ })
25
+
26
+ test('단위 코드를 표준대로 환산한다 — KMH 는 m/s 가 아니다', () => {
27
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('KMH', '7.2'), unit: 'm' })
28
+ // 7.2 km/h = 2 m/s → 50m = 25초
29
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
30
+ })
31
+
32
+ test('단위 없는 속도는 m/s 라고 가정하지 않는다 — 3 이 초속 3m 인지 시속 3km 인지 뜻이 다르다', () => {
33
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment(undefined), unit: 'm' })
34
+ assert.equal(estimator, undefined)
35
+ assert.ok(reasons.some(r => r.includes('unit of measure')))
36
+ })
37
+
38
+ test('모르는 단위 코드는 받지 않는다', () => {
39
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('WAT'), unit: 'm' })
40
+ assert.equal(estimator, undefined)
41
+ assert.ok(reasons.some(r => r.includes('unknown speed unit')))
42
+ })
43
+
44
+ test('좌표가 없으면 만들지 않고 이유를 남긴다', () => {
45
+ const { estimator, reasons } = buildTravelEstimator({ layout: {}, equipment: equipment('MTS'), unit: 'm' })
46
+ assert.equal(estimator, undefined)
47
+ assert.ok(reasons.some(r => r.includes('no location coordinates')))
48
+ })
49
+
50
+ test('속도를 아무도 선언하지 않으면 거리만으로 시간을 만들지 않는다', () => {
51
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: [{ id: 'fk-1', kind: 'forklift' }], unit: 'm' })
52
+ assert.equal(estimator, undefined)
53
+ assert.ok(reasons.some(r => r.includes('speed')))
54
+ })
55
+
56
+ test('좌표 단위가 미터가 아니면 환산을 추측하지 않는다', () => {
57
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'ft' })
58
+ assert.equal(estimator, undefined)
59
+ assert.ok(reasons.some(r => r.includes('not metres')))
60
+ })
61
+
62
+ test('같은 자리의 작업(처리·체류)은 다루지 않는다 — 거리 0 을 0 초로 만들면 공정 시간이 사라진다', () => {
63
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
64
+ assert.equal(estimator!.estimate({ kind: 'cut', fromNode: 'rack', toNode: 'rack', resourceKind: 'forklift' }), undefined)
65
+ })
66
+
67
+ test('좌표 모르는 노드·속도 모르는 자원종류는 넘긴다(부분 적용)', () => {
68
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
69
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack-99', resourceKind: 'forklift' }), undefined)
70
+ assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack', resourceKind: 'hostler' }), undefined)
71
+ assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack' }), undefined, '자원 종류 미지')
72
+ })
73
+
74
+ /* ── 실측(이력) ─────────────────────────────────────────────────────────── */
75
+
76
+ test('작업 종류별 작업시간 p50 을 소요로 배운다 — 리드타임(대기 포함)이 아니라 작업시간', () => {
77
+ const r = buildMeasuredEstimator([
78
+ { key: 'putaway', workTime: { count: 40, p50Ms: 47_000 } },
79
+ { key: 'pick', workTime: { count: 12, p50Ms: 22_500 } }
80
+ ])
81
+ assert.deepEqual(r.learned, { putaway: 47_000, pick: 22_500 })
82
+ assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), { meanMs: 47_000 })
83
+ assert.equal(r.estimator!.estimate({ kind: 'unseen', fromNode: 'a', toNode: 'b' }), undefined, '못 배운 종류는 넘긴다')
84
+ })
85
+
86
+ /* ── 관측된 퍼짐 ────────────────────────────────────────────────────────── */
87
+
88
+ test('두 꼬리가 있으면 퍼짐까지 배운다 — 평균만 배우면 줄이 생기지 않는다', () => {
89
+ const r = buildMeasuredEstimator([{ key: 'putaway', workTime: { count: 40, p10Ms: 30_000, p50Ms: 47_000, p90Ms: 80_000 } }])
90
+ assert.deepEqual(r.spreads, { putaway: { minMs: 30_000, maxMs: 80_000 } })
91
+ assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), {
92
+ meanMs: 47_000,
93
+ spread: { distribution: 'triangular', minMs: 30_000, maxMs: 80_000, modeMs: 47_000 }
94
+ })
95
+ })
96
+
97
+ test('한쪽 꼬리만 있으면 퍼짐을 만들지 않는다 — 아래쪽을 위쪽에서 베끼는 것은 발명이다', () => {
98
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p50Ms: 20_000, p90Ms: 40_000 } }])
99
+ assert.deepEqual(r.spreads, {})
100
+ assert.deepEqual(r.estimator!.estimate({ kind: 'pick', fromNode: 'a', toNode: 'b' }), { meanMs: 20_000 })
101
+ })
102
+
103
+ test('구간이 성립하지 않으면 퍼짐 없이 평균만 — 없는 흔들림을 만들지 않는다', () => {
104
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p10Ms: 50_000, p50Ms: 20_000, p90Ms: 20_000 } }])
105
+ assert.deepEqual(r.spreads, {}, 'p10 >= p90 이면 구간이 아니다')
106
+ })
107
+
108
+ test('표본이 적으면 배우지 않고, 무엇을 버렸는지 밝힌다 — 두 건의 p50 은 현장 값이 아니다', () => {
109
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 2, p50Ms: 9_000 } }])
110
+ assert.equal(r.estimator, undefined)
111
+ assert.deepEqual(r.skipped, [{ kind: 'pick', count: 2 }])
112
+ assert.equal(r.minSamples, DEFAULT_MIN_SAMPLES)
113
+ })
114
+
115
+ test("'unknown' 축은 배우지 않는다 — 종류 모르는 기록을 특정 종류의 근거로 쓰지 않는다", () => {
116
+ const r = buildMeasuredEstimator([{ key: 'unknown', workTime: { count: 99, p50Ms: 30_000 } }])
117
+ assert.deepEqual(r.learned, {})
118
+ assert.equal(r.estimator, undefined)
119
+ })
120
+
121
+ test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', () => {
122
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 50, p50Ms: 0 } }])
123
+ assert.deepEqual(r.learned, {})
124
+ })
125
+
126
+ test('그룹이 없으면 빈 결과 — 던지지 않는다', () => {
127
+ assert.equal(buildMeasuredEstimator(undefined).estimator, undefined)
128
+ assert.equal(buildMeasuredEstimator([]).estimator, undefined)
129
+ })
130
+
131
+ /* ── 사슬 — 실측이 계산을 이긴다 ────────────────────────────────────────── */
132
+
133
+ test('실측이 먼저, 없으면 거리 계산 — 강한 근거가 앞이다', () => {
134
+ const measured = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 30, p50Ms: 11_000 } }])
135
+ const travel = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
136
+ const chained = chainEstimators([measured.estimator, travel.estimator])!
137
+ assert.deepEqual(chained.estimate({ kind: 'pick', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), { meanMs: 11_000 }, '실측 우선')
138
+ assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000, '실측 없으면 거리')
139
+ assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'dock', resourceKind: 'forklift' }), undefined, '둘 다 모르면 넘긴다')
140
+ })
141
+
142
+ test('빈 사슬은 undefined — 주입하지 않는다(상수로 굴러가는 것이 정직하다)', () => {
143
+ assert.equal(chainEstimators([undefined, undefined]), undefined)
144
+ })