@operato/twin-kernel 0.6.4 → 0.6.6

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.
@@ -149,6 +149,81 @@ export interface ResourceProperty {
149
149
  * 자리가 없으면 사실이 들어오지 못한다.
150
150
  */
151
151
  export type TestSpecificationRefs = string[];
152
+ /**
153
+ * 시험 명세 — **참조가 가리키는 대상.**
154
+ *
155
+ * ── 왜 최소 모양인가 ──────────────────────────────────────────────────────────
156
+ * 자원 아홉 종류가 이미 이것을 **가리키고 있었다**(`TestSpecificationRefs`). 그런데 가리켜지는 것이
157
+ * 모델에 없어서 그 참조는 **허공을 가리켰다** — 값을 실을 수는 있어도 그것이 무엇인지 아무도 답할 수
158
+ * 없었고, 화면은 그 사실을 조용히 넘겼다. 대상을 세우는 것이 이 선언의 일이다.
159
+ *
160
+ * 여기 담는 것은 **셋뿐이다**: 식별자·설명·판. 표준의 명세 타입(`B2MML-OperationsTest.xsd`)에는 재는
161
+ * 속성과 합격 기준이 더 있는데, 그 필드 이름을 **아직 1차 출처와 대조하지 않았다.** 짐작해 넣으면
162
+ * 표준 이름을 발명하는 것이고 이 저장소의 가드가 그것을 잡는다 — 대조한 뒤에 늘린다.
163
+ *
164
+ * 그래서 지금 이 축은 **"무엇으로 검증했다고 하는지"를 이름으로 답한다.** 그 이상을 주장하지 않는다.
165
+ *
166
+ * ── 결과는 여기 담지 않는다 ───────────────────────────────────────────────────
167
+ * 명세는 **선언**이고 시험 결과는 **관측**이다(누가 언제 통과했나). 결과의 집은 저널이고, 축이
168
+ * 필요해지면 일정·실적과 같은 방식(`source: 'state'|'journal'`)으로 따로 낸다. 한 축에 둘을 담으면
169
+ * 선언과 관측을 가르는 이 트윈의 뼈대가 무너진다.
170
+ */
171
+ /**
172
+ * 시험 결과 — **"이 개체가 그 시험을 통과했다"** 는 기록.
173
+ *
174
+ * ── 이것은 관측인데 왜 선언 경로로 오나 ───────────────────────────────────────
175
+ * 결과는 사실이 일어난 것이므로 관측이다. 그런데 그 사실을 **낳는 곳이 트윈이 아니다** — 자격 시험은
176
+ * 인사 시스템이, 설비 검사는 정비 시스템이 기록한다. 트윈은 그 기록을 **원본에서 받아** 안다.
177
+ * 그래서 이 값은 자원에 실려 인제스트로 들어온다(저널이 낳는 관측과 다른 길이다).
178
+ *
179
+ * ── 없으면 판정하지 않는다 ────────────────────────────────────────────────────
180
+ * 등급이 시험을 요구하는데(`ResourceClassDef.testSpecificationIds`) 그 사람에게 **결과가 아예 없으면
181
+ * 막지 않는다.** 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히 굶고, 그건 "선언한 것만 제약이
182
+ * 된다" 는 이 커널의 규율에도 어긋난다 — 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은
183
+ * 것이다.
184
+ *
185
+ * **결과가 선언돼 있으면 그때부터 제약이 된다**: 불합격이거나 유효기간이 지났으면 그 등급으로 자격이
186
+ * 성립하지 않는다. 그것이 이 값을 싣는 이유다(싣지 않으면 아무 제약도 없다).
187
+ *
188
+ * 필드 이름은 최소로 둔다 — 표준 결과 타입(`B2MML-OperationsTest.xsd`)과 아직 대조하지 않았다.
189
+ */
190
+ export interface TestResult {
191
+ /** 어느 시험인가 — `TestSpecification.id` 를 가리킨다. */
192
+ specId: string;
193
+ /** 합격 여부. 표준의 판정 어휘를 대조하기 전이므로 둘만 둔다(모르는 값을 만들지 않는다). */
194
+ result: 'pass' | 'fail';
195
+ /** 언제 통과·불합격했나(ISO). */
196
+ at?: ISOTime;
197
+ /**
198
+ * 언제까지 유효한가(ISO) — 자격에는 대개 유효기간이 있다.
199
+ *
200
+ * **없으면 무기한으로 본다.** 여기서 짐작으로 기한을 만들면 멀쩡한 자격자가 조용히 사라진다
201
+ * (유효기간을 모르는 것과 지난 것은 다르다).
202
+ */
203
+ expiresAt?: ISOTime;
204
+ }
205
+ /**
206
+ * 이 시험 결과가 **이 시각에 유효한 합격인가.**
207
+ *
208
+ * 판정을 한 곳에 둔다 — 자리마다 `result === 'pass'` 와 날짜 비교를 다시 적으면 한 곳이 빠지고,
209
+ * 빠진 쪽은 만료된 자격을 통과시킨다(조용한 결함).
210
+ */
211
+ export declare function testPassedAt(r: TestResult, at?: ISOTime): boolean;
212
+ /**
213
+ * 이 개체가 **요구된 시험들을 만족하나** — 등급이 요구하고 개체가 기록을 든다.
214
+ *
215
+ * `required` 가 비면 요구가 없으므로 참이다. 요구된 시험에 **결과가 없으면 참**이다(위 머리말의 규율:
216
+ * 없는 것으로 막지 않는다). 결과가 있으면 그것이 유효한 합격이어야 한다.
217
+ */
218
+ export declare function meetsTests(required: readonly string[], results: readonly TestResult[] | undefined, at?: ISOTime): boolean;
219
+ export interface TestSpecification {
220
+ /** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
221
+ id: string;
222
+ /** 표준 `Description`. i18n 키일 수 있다(사람 언어는 표현 계층이 렌더한다). */
223
+ description?: string;
224
+ /** 표준 `Version` — 같은 시험의 판이 바뀌면 자격의 뜻도 바뀐다(어느 판으로 검증했나). */
225
+ version?: string;
226
+ }
152
227
  /**
153
228
  * 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
154
229
  *
@@ -321,6 +396,13 @@ export interface MaterialDefinition extends EffectivePeriod {
321
396
  properties?: ResourceProperty[];
322
397
  /** 적격을 검증한 시험 명세들 — 표준 `MaterialDefinition.TestSpecificationID`. */
323
398
  testSpecificationIds?: TestSpecificationRefs;
399
+ /**
400
+ * 그 시험들의 **결과** — "언제 통과했고 언제까지 유효한가"(§TestResult).
401
+ *
402
+ * 참조만 있으면 "무엇으로 검증한다" 까지고, 결과가 있어야 **자격이 성립하는지**를 말할 수 있다.
403
+ * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
404
+ */
405
+ testResults?: TestResult[];
324
406
  }
325
407
  /** 품목 정의 색인 — id(GTIN) → 정의. 소비처가 매번 배열을 훑지 않게. */
326
408
  export type MaterialDefinitionIndex = ReadonlyMap<string, MaterialDefinition>;
@@ -707,6 +789,13 @@ export interface EquipmentState extends EffectivePeriod {
707
789
  workCalendar?: WorkCalendarEntry[];
708
790
  /** 적격을 검증한 시험 명세들 — 표준 `Equipment.TestSpecificationID`(§TestSpecificationRefs). */
709
791
  testSpecificationIds?: TestSpecificationRefs;
792
+ /**
793
+ * 그 시험들의 **결과** — "언제 통과했고 언제까지 유효한가"(§TestResult).
794
+ *
795
+ * 참조만 있으면 "무엇으로 검증한다" 까지고, 결과가 있어야 **자격이 성립하는지**를 말할 수 있다.
796
+ * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
797
+ */
798
+ testResults?: TestResult[];
710
799
  }
711
800
  /**
712
801
  * 사람 — **ISA-95 `Person`.** 설비와 다른 자원 종류다.
@@ -765,6 +854,13 @@ export interface PersonState extends EffectivePeriod {
765
854
  workCalendar?: WorkCalendarEntry[];
766
855
  /** 자격을 검증한 시험 명세들 — 표준 `Person.TestSpecificationID`(§TestSpecificationRefs). */
767
856
  testSpecificationIds?: TestSpecificationRefs;
857
+ /**
858
+ * 그 시험들의 **결과** — "언제 통과했고 언제까지 유효한가"(§TestResult).
859
+ *
860
+ * 참조만 있으면 "무엇으로 검증한다" 까지고, 결과가 있어야 **자격이 성립하는지**를 말할 수 있다.
861
+ * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
862
+ */
863
+ testResults?: TestResult[];
768
864
  }
769
865
  /**
770
866
  * 물리 자산 — **ISA-95 `PhysicalAsset`, GS1 `GRAI`(반복사용 자산).**
@@ -1358,6 +1454,7 @@ export interface TwinModelDef {
1358
1454
  workCalendar?: WorkCalendarEntry[];
1359
1455
  properties?: ResourceProperty[];
1360
1456
  testSpecificationIds?: TestSpecificationRefs;
1457
+ testResults?: TestResult[];
1361
1458
  })[];
1362
1459
  /**
1363
1460
  * 사람 — ISA-95 `Person`. `personnelClasses` 로 **속한 등급들**을 밝히고(복수가 표준), `window` 로
@@ -1375,6 +1472,7 @@ export interface TwinModelDef {
1375
1472
  homeLocation?: string;
1376
1473
  properties?: ResourceProperty[];
1377
1474
  testSpecificationIds?: TestSpecificationRefs;
1475
+ testResults?: TestResult[];
1378
1476
  })[];
1379
1477
  /** 물리 자산(반복사용) — ISA-95 `PhysicalAsset` / GS1 `GRAI`. 선언하지 않으면 자산 제약이 없다. */
1380
1478
  assets?: (EffectivePeriod & {
@@ -1383,6 +1481,7 @@ export interface TwinModelDef {
1383
1481
  homeLocation?: string;
1384
1482
  properties?: ResourceProperty[];
1385
1483
  testSpecificationIds?: TestSpecificationRefs;
1484
+ testResults?: TestResult[];
1386
1485
  })[];
1387
1486
  /**
1388
1487
  * 이 트윈의 **시각 해석 기준**(UTC 로부터의 분). 근무 캘린더의 `HH:MM` 이 어느 기준인지 정한다.
@@ -1394,6 +1493,14 @@ export interface TwinModelDef {
1394
1493
  * **절대 구간**(`StartDateTime`/`FinishDateTime`)을 계산해 넣는 것이다.
1395
1494
  */
1396
1495
  utcOffsetMinutes?: number;
1496
+ /**
1497
+ * **시험 명세** — 표준 `TestSpecification`. 자원의 `testSpecificationIds` 가 가리키는 대상.
1498
+ *
1499
+ * 선언하지 않아도 트윈은 돈다 — 그때 그 참조는 **가리킬 것이 없다**(화면이 끊어진 참조로 센다).
1500
+ * 커널은 이 값으로 자격을 판정하지 않는다: 판정에는 **결과**가 필요하고 결과는 아직 모델에 없다.
1501
+ * 없는 것으로 판정하면 자격자가 전부 사라져 라인이 영구히 굶는다.
1502
+ */
1503
+ testSpecifications?: TestSpecification[];
1397
1504
  /**
1398
1505
  * **등급 정의** — 표준 `PersonnelClass` · `EquipmentClass` · `PhysicalAssetClass`.
1399
1506
  *
package/dist/contract.js CHANGED
@@ -127,6 +127,38 @@ export function hierarchyOf(s) {
127
127
  }
128
128
  };
129
129
  }
130
+ /**
131
+ * 이 시험 결과가 **이 시각에 유효한 합격인가.**
132
+ *
133
+ * 판정을 한 곳에 둔다 — 자리마다 `result === 'pass'` 와 날짜 비교를 다시 적으면 한 곳이 빠지고,
134
+ * 빠진 쪽은 만료된 자격을 통과시킨다(조용한 결함).
135
+ */
136
+ export function testPassedAt(r, at) {
137
+ if (r.result !== 'pass')
138
+ return false;
139
+ if (!r.expiresAt || !at)
140
+ return r.result === 'pass';
141
+ return Date.parse(at) <= Date.parse(r.expiresAt);
142
+ }
143
+ /**
144
+ * 이 개체가 **요구된 시험들을 만족하나** — 등급이 요구하고 개체가 기록을 든다.
145
+ *
146
+ * `required` 가 비면 요구가 없으므로 참이다. 요구된 시험에 **결과가 없으면 참**이다(위 머리말의 규율:
147
+ * 없는 것으로 막지 않는다). 결과가 있으면 그것이 유효한 합격이어야 한다.
148
+ */
149
+ export function meetsTests(required, results, at) {
150
+ if (!required.length)
151
+ return true;
152
+ const bySpec = new Map((results ?? []).map(r => [r.specId, r]));
153
+ for (const specId of required) {
154
+ const r = bySpec.get(specId);
155
+ if (!r)
156
+ continue; // 결과가 선언되지 않았다 — 제약이 아니다
157
+ if (!testPassedAt(r, at))
158
+ return false;
159
+ }
160
+ return true;
161
+ }
130
162
  /**
131
163
  * 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
132
164
  *
@@ -44,6 +44,16 @@ export const TWIN_AXES = [
44
44
  standardClass: { isa95: 'OperationsSegment' } },
45
45
  { axis: 'materials', path: 'productionSpec.definition.materials', label: 'twin.axis.materials', kind: 'class',
46
46
  standardClass: { isa95: 'MaterialDefinition' } },
47
+ /*
48
+ * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
49
+ *
50
+ * 이 축이 없던 동안 `testSpecificationIds` 는 허공을 가리켰다(값은 실리는데 무엇인지 아무도 모른다).
51
+ * 축이 서면 인스펙션의 선 채움이 **끊어진 참조를 센다** — 없는 명세를 가리키면 화면이 말한다.
52
+ *
53
+ * `kind: 'spec'` 이다: 자원도 등급도 아니고 **판정의 정의**다(공정·레시피와 같은 부류).
54
+ */
55
+ { axis: 'testSpecifications', path: 'testSpecifications', label: 'twin.axis.testSpecifications', kind: 'spec',
56
+ standardClass: { isa95: 'TestSpecification' } },
47
57
  /*
48
58
  * ── ISA-95 Part 4 — 일정과 실적 ────────────────────────────────────────────────
49
59
  *
@@ -101,6 +111,21 @@ export const TWIN_RELATIONS = [
101
111
  { from: 'tasks', field: 'toNode', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at', optional: true },
102
112
  { from: 'tasks', field: 'resourceRef', target: { kind: 'axis', axis: 'equipment' }, via: 'twin.rel.by', optional: true },
103
113
  { from: 'tasks', field: 'personnel[]', target: { kind: 'axis', axis: 'persons' }, via: 'twin.rel.crew', optional: true },
114
+ /*
115
+ * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
116
+ * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
117
+ *
118
+ * **자리(`locations`)에는 없다** — 표준이 자리를 시험 대상으로 두지 않았고 그 비대칭은 표준의
119
+ * 판단이다(`contract.ts` 가 "우리도 자리에 넣지 않는다" 고 적었다).
120
+ */
121
+ { from: 'persons', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
122
+ { from: 'personnelClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
123
+ { from: 'equipment', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
124
+ { from: 'equipmentClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
125
+ { from: 'assets', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
126
+ { from: 'assetClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
127
+ { from: 'materialDefinitions', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
128
+ { from: 'materialClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
104
129
  /* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
105
130
  { from: 'operations', field: 'locationType', target: { kind: 'type', role: 'location' }, via: 'twin.rel.at', optional: true },
106
131
  { from: 'operations', field: 'resourceType', target: { kind: 'type', role: 'equipment' }, via: 'twin.rel.by', optional: true }
@@ -1,4 +1,4 @@
1
- import type { ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -76,6 +76,13 @@ export interface FlowPerson extends EffectivePeriod {
76
76
  properties?: ResourceProperty[];
77
77
  /** 자격을 검증한 시험 명세들 — 표준 `TestSpecificationID`. */
78
78
  testSpecificationIds?: string[];
79
+ /**
80
+ * 그 시험들의 **결과** — 자격이 성립하는지는 이것이 말한다(§TestResult).
81
+ *
82
+ * 참조만 있으면 "무엇으로 검증한다" 까지고, 배정 판정은 결과가 있어야 할 수 있다. 없으면 판정하지
83
+ * 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
84
+ */
85
+ testResults?: TestResult[];
79
86
  }
80
87
  export interface FlowEquipment extends EffectivePeriod {
81
88
  id: string;
@@ -793,6 +800,16 @@ export declare abstract class FlowEngine implements TwinKernel {
793
800
  private recordMaterialActual;
794
801
  /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
795
802
  private consumeMaterials;
803
+ /**
804
+ * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
805
+ *
806
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 사람이 든다. 상속을 타고 닫은
807
+ * 등급 전부의 요구를 모아 본다 — 상위 등급이 요구하는 시험도 자격의 조건이다.
808
+ *
809
+ * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
810
+ * 조용히 통과시킨다.
811
+ */
812
+ private qualifiedByTests;
796
813
  private claimPersonnel;
797
814
  /**
798
815
  * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, meetsTests, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
15
15
  import { parseIsoDuration } from "./iso-duration.js";
@@ -284,10 +284,12 @@ export class FlowEngine {
284
284
  return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId };
285
285
  }
286
286
  buildPerson(p) {
287
- return { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOnly(p) };
287
+ return { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}),
288
+ ...(p.testResults ? { testResults: p.testResults } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOnly(p) };
288
289
  }
289
290
  buildAsset(a) {
290
- return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOnly(a) };
291
+ return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}),
292
+ ...(a.testResults ? { testResults: a.testResults } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOnly(a) };
291
293
  }
292
294
  /**
293
295
  * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
@@ -1843,6 +1845,26 @@ export class FlowEngine {
1843
1845
  });
1844
1846
  }
1845
1847
  }
1848
+ /**
1849
+ * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
1850
+ *
1851
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 사람이 든다. 상속을 타고 닫은
1852
+ * 등급 전부의 요구를 모아 본다 — 상위 등급이 요구하는 시험도 자격의 조건이다.
1853
+ *
1854
+ * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
1855
+ * 조용히 통과시킨다.
1856
+ */
1857
+ qualifiedByTests(p) {
1858
+ const defs = this.classDefs.personnel;
1859
+ if (!defs?.length)
1860
+ return true;
1861
+ const closure = classClosure(p.personnelClassIds, defs, this.now());
1862
+ const required = [];
1863
+ for (const d of defs)
1864
+ if (closure.has(d.id))
1865
+ required.push(...(d.testSpecificationIds ?? []));
1866
+ return meetsTests(required, p.testResults, this.now());
1867
+ }
1846
1868
  claimPersonnel(t) {
1847
1869
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
1848
1870
  if (!need?.length)
@@ -1860,7 +1882,16 @@ export class FlowEngine {
1860
1882
  다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
1861
1883
  유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
1862
1884
  (req.personnelClass === undefined ||
1863
- classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
1885
+ classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) &&
1886
+ /*
1887
+ * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
1888
+ * (`testResults`). 만료·불합격이면 그 자격은 성립하지 않는다.
1889
+ *
1890
+ * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
1891
+ * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
1892
+ * 제약이 된다" — 이 커널의 규율).
1893
+ */
1894
+ this.qualifiedByTests(p));
1864
1895
  if (avail.length < want)
1865
1896
  return null; // 한 등급이라도 모자라면 시작하지 않는다
1866
1897
  for (let i = 0; i < want; i++)
@@ -99,6 +99,7 @@ __export(index_exports, {
99
99
  lgtinClass: () => lgtinClass,
100
100
  locationStatusOf: () => locationStatusOf,
101
101
  mapRecord: () => mapRecord,
102
+ meetsTests: () => meetsTests,
102
103
  minuteOfDayAt: () => minuteOfDayAt,
103
104
  monteCarloForecast: () => monteCarloForecast,
104
105
  objectEvent: () => objectEvent,
@@ -123,6 +124,7 @@ __export(index_exports, {
123
124
  ssccUri: () => ssccUri,
124
125
  stateFieldsOf: () => stateFieldsOf,
125
126
  subLotIdOf: () => subLotIdOf,
127
+ testPassedAt: () => testPassedAt,
126
128
  transactionEvent: () => transactionEvent,
127
129
  transformationEvent: () => transformationEvent,
128
130
  validateDomainDefinition: () => validateDomainDefinition,
@@ -218,6 +220,21 @@ function hierarchyOf(s) {
218
220
  }
219
221
  };
220
222
  }
223
+ function testPassedAt(r, at) {
224
+ if (r.result !== "pass") return false;
225
+ if (!r.expiresAt || !at) return r.result === "pass";
226
+ return Date.parse(at) <= Date.parse(r.expiresAt);
227
+ }
228
+ function meetsTests(required, results, at) {
229
+ if (!required.length) return true;
230
+ const bySpec = new Map((results ?? []).map((r) => [r.specId, r]));
231
+ for (const specId of required) {
232
+ const r = bySpec.get(specId);
233
+ if (!r) continue;
234
+ if (!testPassedAt(r, at)) return false;
235
+ }
236
+ return true;
237
+ }
221
238
  function effectivityAt(p, at) {
222
239
  if (!p || !at) return void 0;
223
240
  const atMs = Date.parse(at);
@@ -1676,6 +1693,21 @@ var TWIN_AXES = [
1676
1693
  kind: "class",
1677
1694
  standardClass: { isa95: "MaterialDefinition" }
1678
1695
  },
1696
+ /*
1697
+ * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
1698
+ *
1699
+ * 이 축이 없던 동안 `testSpecificationIds` 는 허공을 가리켰다(값은 실리는데 무엇인지 아무도 모른다).
1700
+ * 축이 서면 인스펙션의 선 채움이 **끊어진 참조를 센다** — 없는 명세를 가리키면 화면이 말한다.
1701
+ *
1702
+ * `kind: 'spec'` 이다: 자원도 등급도 아니고 **판정의 정의**다(공정·레시피와 같은 부류).
1703
+ */
1704
+ {
1705
+ axis: "testSpecifications",
1706
+ path: "testSpecifications",
1707
+ label: "twin.axis.testSpecifications",
1708
+ kind: "spec",
1709
+ standardClass: { isa95: "TestSpecification" }
1710
+ },
1679
1711
  /*
1680
1712
  * ── ISA-95 Part 4 — 일정과 실적 ────────────────────────────────────────────────
1681
1713
  *
@@ -1744,6 +1776,21 @@ var TWIN_RELATIONS = [
1744
1776
  { from: "tasks", field: "toNode", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at", optional: true },
1745
1777
  { from: "tasks", field: "resourceRef", target: { kind: "axis", axis: "equipment" }, via: "twin.rel.by", optional: true },
1746
1778
  { from: "tasks", field: "personnel[]", target: { kind: "axis", axis: "persons" }, via: "twin.rel.crew", optional: true },
1779
+ /*
1780
+ * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
1781
+ * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
1782
+ *
1783
+ * **자리(`locations`)에는 없다** — 표준이 자리를 시험 대상으로 두지 않았고 그 비대칭은 표준의
1784
+ * 판단이다(`contract.ts` 가 "우리도 자리에 넣지 않는다" 고 적었다).
1785
+ */
1786
+ { from: "persons", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1787
+ { from: "personnelClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1788
+ { from: "equipment", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1789
+ { from: "equipmentClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1790
+ { from: "assets", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1791
+ { from: "assetClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1792
+ { from: "materialDefinitions", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1793
+ { from: "materialClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
1747
1794
  /* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
1748
1795
  { from: "operations", field: "locationType", target: { kind: "type", role: "location" }, via: "twin.rel.at", optional: true },
1749
1796
  { from: "operations", field: "resourceType", target: { kind: "type", role: "equipment" }, via: "twin.rel.by", optional: true }
@@ -2364,10 +2411,34 @@ var FlowEngine = class {
2364
2411
  return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId };
2365
2412
  }
2366
2413
  buildPerson(p) {
2367
- return { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", taskId: null, window: p.window, ...p.workCalendar ? { workCalendar: p.workCalendar } : {}, ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOnly(p) };
2414
+ return {
2415
+ id: p.id,
2416
+ personnelClassIds: p.personnelClassIds,
2417
+ status: "idle",
2418
+ taskId: null,
2419
+ window: p.window,
2420
+ ...p.workCalendar ? { workCalendar: p.workCalendar } : {},
2421
+ ...p.homeLocation ? { location: p.homeLocation } : {},
2422
+ ...p.properties ? { properties: p.properties } : {},
2423
+ ...p.testResults ? { testResults: p.testResults } : {},
2424
+ ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2425
+ ...p.testResults ? { testResults: p.testResults } : {},
2426
+ ...effectiveOnly(p)
2427
+ };
2368
2428
  }
2369
2429
  buildAsset(a) {
2370
- return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", taskId: null, ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOnly(a) };
2430
+ return {
2431
+ id: a.id,
2432
+ assetClassIds: a.assetClassIds,
2433
+ location: a.homeLocation,
2434
+ status: "idle",
2435
+ taskId: null,
2436
+ ...a.properties ? { properties: a.properties } : {},
2437
+ ...a.testResults ? { testResults: a.testResults } : {},
2438
+ ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2439
+ ...a.testResults ? { testResults: a.testResults } : {},
2440
+ ...effectiveOnly(a)
2441
+ };
2371
2442
  }
2372
2443
  /**
2373
2444
  * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
@@ -3769,6 +3840,23 @@ var FlowEngine = class {
3769
3840
  });
3770
3841
  }
3771
3842
  }
3843
+ /**
3844
+ * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
3845
+ *
3846
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 사람이 든다. 상속을 타고 닫은
3847
+ * 등급 전부의 요구를 모아 본다 — 상위 등급이 요구하는 시험도 자격의 조건이다.
3848
+ *
3849
+ * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
3850
+ * 조용히 통과시킨다.
3851
+ */
3852
+ qualifiedByTests(p) {
3853
+ const defs = this.classDefs.personnel;
3854
+ if (!defs?.length) return true;
3855
+ const closure = classClosure(p.personnelClassIds, defs, this.now());
3856
+ const required = [];
3857
+ for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
3858
+ return meetsTests(required, p.testResults, this.now());
3859
+ }
3772
3860
  claimPersonnel(t) {
3773
3861
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
3774
3862
  if (!need?.length) return [];
@@ -3781,7 +3869,15 @@ var FlowEngine = class {
3781
3869
  /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3782
3870
  다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
3783
3871
  유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
3784
- (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass))
3872
+ (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) && /*
3873
+ * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
3874
+ * (`testResults`). 만료·불합격이면 그 자격은 성립하지 않는다.
3875
+ *
3876
+ * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
3877
+ * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
3878
+ * 제약이 된다" — 이 커널의 규율).
3879
+ */
3880
+ this.qualifiedByTests(p)
3785
3881
  );
3786
3882
  if (avail.length < want) return null;
3787
3883
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -5089,6 +5185,7 @@ function retiredVocabularyIn(line) {
5089
5185
  lgtinClass,
5090
5186
  locationStatusOf,
5091
5187
  mapRecord,
5188
+ meetsTests,
5092
5189
  minuteOfDayAt,
5093
5190
  monteCarloForecast,
5094
5191
  objectEvent,
@@ -5113,6 +5210,7 @@ function retiredVocabularyIn(line) {
5113
5210
  ssccUri,
5114
5211
  stateFieldsOf,
5115
5212
  subLotIdOf,
5213
+ testPassedAt,
5116
5214
  transactionEvent,
5117
5215
  transformationEvent,
5118
5216
  validateDomainDefinition,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {