@operato/twin-kernel 0.6.7 → 0.6.8

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.
@@ -617,6 +617,14 @@ export declare function offCalendarAt(r: {
617
617
  };
618
618
  workCalendar?: WorkCalendarEntry[];
619
619
  }, ms: number, utcOffsetMinutes?: number): boolean;
620
+ /**
621
+ * 이 자원에게 **필수인 시험 목록** — 등급 상속을 타고 닫아 모은다.
622
+ *
623
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 개체가 든다(`testResults`).
624
+ * 그 사이를 잇는 이 수집이 계약에 있는 이유: **두 구동이 같은 답을 내야 한다.** 시뮬과 미러가 각자
625
+ * 모으면 "이 사람에게 무엇이 필수인가" 가 갈리고, 같은 자원을 한쪽은 막고 다른 쪽은 통과시킨다.
626
+ */
627
+ export declare function requiredTestsFor(directIds: readonly string[] | undefined, defs: readonly ResourceClassDef[] | undefined, at?: ISOTime): string[];
620
628
  /**
621
629
  * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
622
630
  *
@@ -857,6 +865,18 @@ export interface EquipmentState extends EffectivePeriod {
857
865
  * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
858
866
  */
859
867
  testResults?: TestResult[];
868
+ /**
869
+ * **왜 지금 쓰일 수 없나** — 판정과 사유를 계약이 낸다(`capabilityOf` · `CapabilityReason`).
870
+ *
871
+ * 상태에 실어 보내는 이유: 이 판정은 **시각과 등급 정의를 함께 알아야** 나온다(유효기간·교대·자격
872
+ * 만료). 소비처는 그 둘을 갖고 있지 않다 — 화면은 시뮬 시각을 모르고, 호스트는 등급 상속을 타고
873
+ * 필수 시험을 모으는 규칙을 모른다. 그래서 예전에는 소비처가 플래그를 보고 짐작했고, 자격이 만료된
874
+ * 사람이 `대기` 로 보였다. 놀고 있는 것은 맞지만 **쓸 수 있는 것은 아니다** — "대기 7명" 이 조용히
875
+ * 틀린 숫자가 됐다.
876
+ *
877
+ * 배정이 쓰는 판정과 **같은 함수의 결과**다(규칙 한 벌). 사유의 순서도 계약이 정한다.
878
+ */
879
+ capability?: Capability;
860
880
  }
861
881
  /**
862
882
  * 사람 — **ISA-95 `Person`.** 설비와 다른 자원 종류다.
@@ -922,6 +942,18 @@ export interface PersonState extends EffectivePeriod {
922
942
  * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
923
943
  */
924
944
  testResults?: TestResult[];
945
+ /**
946
+ * **왜 지금 쓰일 수 없나** — 판정과 사유를 계약이 낸다(`capabilityOf` · `CapabilityReason`).
947
+ *
948
+ * 상태에 실어 보내는 이유: 이 판정은 **시각과 등급 정의를 함께 알아야** 나온다(유효기간·교대·자격
949
+ * 만료). 소비처는 그 둘을 갖고 있지 않다 — 화면은 시뮬 시각을 모르고, 호스트는 등급 상속을 타고
950
+ * 필수 시험을 모으는 규칙을 모른다. 그래서 예전에는 소비처가 플래그를 보고 짐작했고, 자격이 만료된
951
+ * 사람이 `대기` 로 보였다. 놀고 있는 것은 맞지만 **쓸 수 있는 것은 아니다** — "대기 7명" 이 조용히
952
+ * 틀린 숫자가 됐다.
953
+ *
954
+ * 배정이 쓰는 판정과 **같은 함수의 결과**다(규칙 한 벌). 사유의 순서도 계약이 정한다.
955
+ */
956
+ capability?: Capability;
925
957
  }
926
958
  /**
927
959
  * 물리 자산 — **ISA-95 `PhysicalAsset`, GS1 `GRAI`(반복사용 자산).**
@@ -962,6 +994,10 @@ export interface AssetState extends EffectivePeriod {
962
994
  * 빠뜨리면 회수 대상 수가 실제보다 많게 잡히고, 빈 팔레트 부족이 보이지 않는다.
963
995
  */
964
996
  effectivity?: Effectivity;
997
+ /** 그 시험들의 결과 — 사람과 같은 뜻(§TestResult). 검사에서 떨어진 팔레트는 배정에서 빠진다. */
998
+ testResults?: TestResult[];
999
+ /** 왜 지금 쓰일 수 없나 — 사람·설비와 **같은 판정**(§PersonState.capability). */
1000
+ capability?: Capability;
965
1001
  }
966
1002
  /**
967
1003
  * 작업 하나의 관측 상태 — **ISA-95 `SegmentResponse`**(Part 4, 실적).
package/dist/contract.js CHANGED
@@ -517,6 +517,23 @@ export function offCalendarAt(r, ms, utcOffsetMinutes) {
517
517
  const h = Math.floor(minute / 60);
518
518
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
519
519
  }
520
+ /**
521
+ * 이 자원에게 **필수인 시험 목록** — 등급 상속을 타고 닫아 모은다.
522
+ *
523
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 개체가 든다(`testResults`).
524
+ * 그 사이를 잇는 이 수집이 계약에 있는 이유: **두 구동이 같은 답을 내야 한다.** 시뮬과 미러가 각자
525
+ * 모으면 "이 사람에게 무엇이 필수인가" 가 갈리고, 같은 자원을 한쪽은 막고 다른 쪽은 통과시킨다.
526
+ */
527
+ export function requiredTestsFor(directIds, defs, at) {
528
+ if (!defs?.length)
529
+ return [];
530
+ const closure = classClosure(directIds, defs, at);
531
+ const required = [];
532
+ for (const d of defs)
533
+ if (closure.has(d.id))
534
+ required.push(...(d.testSpecificationIds ?? []));
535
+ return required;
536
+ }
520
537
  /**
521
538
  * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
522
539
  *
@@ -56,6 +56,13 @@ export interface FlowAsset extends EffectivePeriod {
56
56
  properties?: ResourceProperty[];
57
57
  /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`. */
58
58
  testSpecificationIds?: string[];
59
+ /**
60
+ * 그 시험들의 **결과** — 자격이 성립하는지는 이것이 말한다(§TestResult).
61
+ *
62
+ * 사람에게만 두었다가 **설비·자산의 결과는 커널이 받아서 버렸다** — 선언은 들어오는데 자리가
63
+ * 없으니 배정 판정에서 아예 읽히지 않았다(검사에서 떨어진 도장 부스가 계속 배정됐다).
64
+ */
65
+ testResults?: TestResult[];
59
66
  }
60
67
  /** 사람 — 설비와 별개 자원(고장·OEE 가 아니라 등급·교대로 산다). */
61
68
  export interface FlowPerson extends EffectivePeriod {
@@ -96,6 +103,8 @@ export interface FlowEquipment extends EffectivePeriod {
96
103
  properties?: ResourceProperty[];
97
104
  /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`. */
98
105
  testSpecificationIds?: string[];
106
+ /** 그 시험들의 **결과** — 사람과 같은 뜻(§FlowAsset.testResults). 없으면 판정하지 않는다. */
107
+ testResults?: TestResult[];
99
108
  /** 교대(가동시간) — 지정 시 이 시간대에만 배정된다. 미지정=24시간 가용. */
100
109
  window?: {
101
110
  startHour: number;
@@ -814,7 +823,14 @@ export declare abstract class FlowEngine implements TwinKernel {
814
823
  *
815
824
  * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
816
825
  */
817
- private requiredTestsOf;
826
+ /**
827
+ * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
828
+ *
829
+ * 배정도 스냅샷도 이 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
830
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
831
+ * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
832
+ */
833
+ private capabilityOfResource;
818
834
  private claimPersonnel;
819
835
  /**
820
836
  * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
@@ -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, capabilityOf, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, 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";
@@ -285,11 +285,11 @@ export class FlowEngine {
285
285
  }
286
286
  buildPerson(p) {
287
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
+ ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOnly(p) };
289
289
  }
290
290
  buildAsset(a) {
291
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) };
292
+ ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOnly(a) };
293
293
  }
294
294
  /**
295
295
  * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
@@ -369,7 +369,7 @@ export class FlowEngine {
369
369
  };
370
370
  }
371
371
  buildEquipment(m) {
372
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
372
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
373
373
  if (m.mtbfMs !== undefined) {
374
374
  eq.mtbfMs = m.mtbfMs;
375
375
  eq.mttrMs = m.mttrMs;
@@ -384,7 +384,7 @@ export class FlowEngine {
384
384
  addEquipment(m) {
385
385
  if (this.equipment.has(m.id))
386
386
  return;
387
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
387
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
388
388
  if (m.mtbfMs !== undefined) {
389
389
  eq.mtbfMs = m.mtbfMs;
390
390
  eq.mttrMs = m.mttrMs;
@@ -469,6 +469,8 @@ export class FlowEngine {
469
469
  /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
470
470
  ...(m.properties ? { properties: m.properties } : {}),
471
471
  ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
472
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
473
+ ...(m.testResults ? { testResults: m.testResults } : {}),
472
474
  ...effectiveOnly(m)
473
475
  });
474
476
  }
@@ -483,6 +485,8 @@ export class FlowEngine {
483
485
  ...(p.location ? { location: p.location } : {}),
484
486
  ...(p.properties ? { properties: p.properties } : {}),
485
487
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
488
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
489
+ ...(p.testResults ? { testResults: p.testResults } : {}),
486
490
  ...effectiveOnly(p)
487
491
  });
488
492
  }
@@ -494,6 +498,8 @@ export class FlowEngine {
494
498
  id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
495
499
  ...(a.properties ? { properties: a.properties } : {}),
496
500
  ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
501
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
502
+ ...(a.testResults ? { testResults: a.testResults } : {}),
497
503
  ...effectiveOnly(a)
498
504
  });
499
505
  }
@@ -812,7 +818,7 @@ export class FlowEngine {
812
818
  }),
813
819
  items: [...this.items.values()].map(i => this.itemState(i)),
814
820
  equipment: [...this.equipment.values()].map(m => {
815
- const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
821
+ const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
816
822
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
817
823
  if (t && t.status === 'in-progress' && t.intent !== 'process')
818
824
  s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
@@ -826,7 +832,10 @@ export class FlowEngine {
826
832
  st.properties = a.properties;
827
833
  if (a.testSpecificationIds)
828
834
  st.testSpecificationIds = a.testSpecificationIds;
835
+ if (a.testResults)
836
+ st.testResults = a.testResults;
829
837
  Object.assign(st, this.effectivePart(a));
838
+ st.capability = this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset);
830
839
  return st;
831
840
  }),
832
841
  persons: [...this.persons.values()].map(p => {
@@ -837,6 +846,10 @@ export class FlowEngine {
837
846
  st.properties = p.properties;
838
847
  if (p.testSpecificationIds)
839
848
  st.testSpecificationIds = p.testSpecificationIds;
849
+ /* 결과도 함께 낸다 — 참조만 보내면 소비처는 "무엇으로 검증하는가" 까지만 알고 "지금 유효한가" 를
850
+ 물을 수 없다(사실은 자기 집에서 나온다). 판정은 아래 `capability` 가 이미 답한다. */
851
+ if (p.testResults)
852
+ st.testResults = p.testResults;
840
853
  if (this.personOffShift(p)) {
841
854
  st.offShift = true;
842
855
  Object.assign(st, this.offReason(p));
@@ -845,6 +858,7 @@ export class FlowEngine {
845
858
  if (sh)
846
859
  st.shift = sh;
847
860
  Object.assign(st, this.effectivePart(p));
861
+ st.capability = this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel);
848
862
  return st;
849
863
  }),
850
864
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
@@ -1567,9 +1581,8 @@ export class FlowEngine {
1567
1581
  continue;
1568
1582
  const avail = [...this.assets.values()].filter(
1569
1583
  /* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
1570
- a => a.status === 'idle' &&
1584
+ a => this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset).available &&
1571
1585
  !picked.includes(a.id) &&
1572
- !this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
1573
1586
  (req.assetClass === undefined ||
1574
1587
  classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass)));
1575
1588
  if (avail.length < want)
@@ -1859,16 +1872,19 @@ export class FlowEngine {
1859
1872
  *
1860
1873
  * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
1861
1874
  */
1862
- requiredTestsOf(p) {
1863
- const defs = this.classDefs.personnel;
1864
- if (!defs?.length)
1865
- return [];
1866
- const closure = classClosure(p.personnelClassIds, defs, this.now());
1867
- const required = [];
1868
- for (const d of defs)
1869
- if (closure.has(d.id))
1870
- required.push(...(d.testSpecificationIds ?? []));
1871
- return required;
1875
+ /**
1876
+ * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
1877
+ *
1878
+ * 배정도 스냅샷도 이 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
1879
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
1880
+ * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
1881
+ */
1882
+ capabilityOfResource(r, directClassIds, defs) {
1883
+ return capabilityOf(r, {
1884
+ at: this.now(),
1885
+ utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
1886
+ requiredTests: requiredTestsFor(directClassIds, defs, this.now())
1887
+ });
1872
1888
  }
1873
1889
  claimPersonnel(t) {
1874
1890
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -1887,11 +1903,7 @@ export class FlowEngine {
1887
1903
  *
1888
1904
  * 요구된 시험은 등급에서 모아 넘긴다 — 등급 정의를 아는 것은 이쪽이다.
1889
1905
  */
1890
- capabilityOf(p, {
1891
- at: this.now(),
1892
- utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
1893
- requiredTests: this.requiredTestsOf(p)
1894
- }).available &&
1906
+ this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel).available &&
1895
1907
  /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1896
1908
  다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
1897
1909
  (req.personnelClass === undefined ||
@@ -1909,10 +1921,10 @@ export class FlowEngine {
1909
1921
  * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1910
1922
  */
1911
1923
  claimEquipment(t) {
1912
- const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
1913
- !m.held &&
1914
- !this.offShift(m) &&
1915
- !this.outOfEffect(m) && // 도입 전·폐기 후는 시각의 모델에 없다
1924
+ const free = (kind, picked = []) => [...this.equipment.values()].filter(m =>
1925
+ /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 이 자리에 조건을 늘어놓아서
1926
+ 설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
1927
+ this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment).available &&
1916
1928
  !picked.includes(m.id) &&
1917
1929
  /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
1918
1930
  다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
@@ -70,6 +70,13 @@ export declare class ObservedReducer {
70
70
  * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
71
71
  */
72
72
  private shifts;
73
+ /**
74
+ * 자원 **등급 정의** — 교대 선언과 같은 성격의 *판정 입력*이다(상태로 내보내지 않는다).
75
+ *
76
+ * 이것이 없으면 미러는 "이 사람에게 어떤 시험이 필수인가" 를 모른다. 시뮬은 막는데 미러는
77
+ * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
78
+ */
79
+ private classDefs;
73
80
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
74
81
  private utcOffsetMinutes?;
75
82
  constructor(board: TwinModelDef);
@@ -164,5 +171,12 @@ export declare class ObservedReducer {
164
171
  private offShiftPart;
165
172
  /** 유효 기간 밖이면 그 이유를 붙인다 — 미러의 시각 기준은 `observedAtMs`(마지막으로 들은 시점). */
166
173
  private effectivityPart;
174
+ /**
175
+ * **왜 지금 쓰일 수 없나** — 시뮬과 **같은 함수**로 낸다(`capabilityOf`).
176
+ *
177
+ * 델타로 받지 않고 여기서 파생하는 이유는 `effectivity`·`shift` 와 같다: 자격 만료와 교대 경계는
178
+ * **이벤트 없이 시각만으로** 넘어간다. 델타로 받아 두면 만료된 자격이 영원히 유효하게 남는다.
179
+ */
180
+ private capabilityPart;
167
181
  snapshot(): ProjectedState;
168
182
  }
@@ -19,7 +19,7 @@
19
19
  * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
- import { OP_EVENT, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
22
+ import { OP_EVENT, capabilityOf, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
23
23
  import { ILMD_ATTR, parseEpc } from "./epcis.js";
24
24
  /**
25
25
  * 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
@@ -62,18 +62,26 @@ export class ObservedReducer {
62
62
  * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
63
63
  */
64
64
  shifts = new Map();
65
+ /**
66
+ * 자원 **등급 정의** — 교대 선언과 같은 성격의 *판정 입력*이다(상태로 내보내지 않는다).
67
+ *
68
+ * 이것이 없으면 미러는 "이 사람에게 어떤 시험이 필수인가" 를 모른다. 시뮬은 막는데 미러는
69
+ * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
70
+ */
71
+ classDefs = {};
65
72
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
66
73
  utcOffsetMinutes;
67
74
  constructor(board) {
68
75
  this.utcOffsetMinutes = board.utcOffsetMinutes;
76
+ this.classDefs = { personnel: board.personnelClasses, equipment: board.equipmentClasses, asset: board.assetClasses };
69
77
  for (const n of readBoardLocations(board))
70
78
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
71
79
  // 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
72
80
  for (const m of readBoardEquipment(board))
73
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...effectiveOf(m), origin: 'master' });
81
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
74
82
  // 사람 기준선(마스터) — person.status 델타로 갱신됨.
75
83
  for (const p of board.persons ?? [])
76
- this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOf(p) });
84
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOf(p) });
77
85
  /* 교대 선언은 **미러도 들고 있어야 한다** — 설비 델타에는 `offShift` 자리조차 없어서, 그것 없이는
78
86
  미러의 설비가 점심 휴게 중에도 "대기" 로 보였다. */
79
87
  for (const m of readBoardEquipment(board))
@@ -81,7 +89,7 @@ export class ObservedReducer {
81
89
  for (const p of board.persons ?? [])
82
90
  this.rememberShift(`person:${p.id}`, p);
83
91
  for (const a of readBoardAssets(board))
84
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOf(a) });
92
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
85
93
  }
86
94
  /**
87
95
  * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -138,17 +146,17 @@ export class ObservedReducer {
138
146
  const assetsDropped = dropMissing(this.assets, assets);
139
147
  for (const m of equipment) {
140
148
  if (!this.equipment.has(m.id))
141
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...effectiveOf(m), origin: 'master' });
149
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
142
150
  this.rememberShift(`eq:${m.id}`, m);
143
151
  }
144
152
  for (const p of persons) {
145
153
  if (!this.persons.has(p.id))
146
- this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOf(p) });
154
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOf(p) });
147
155
  this.rememberShift(`person:${p.id}`, p);
148
156
  }
149
157
  for (const a of assets)
150
158
  if (!this.assets.has(a.id))
151
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOf(a) });
159
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
152
160
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
153
161
  }
154
162
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -571,6 +579,19 @@ export class ObservedReducer {
571
579
  const e = effectivityAt(r, new Date(this.observedAtMs).toISOString());
572
580
  return e ? { effectivity: e } : {};
573
581
  }
582
+ /**
583
+ * **왜 지금 쓰일 수 없나** — 시뮬과 **같은 함수**로 낸다(`capabilityOf`).
584
+ *
585
+ * 델타로 받지 않고 여기서 파생하는 이유는 `effectivity`·`shift` 와 같다: 자격 만료와 교대 경계는
586
+ * **이벤트 없이 시각만으로** 넘어간다. 델타로 받아 두면 만료된 자격이 영원히 유효하게 남는다.
587
+ */
588
+ capabilityPart(r, key, directClassIds, defs) {
589
+ const at = this.observedAtMs === undefined ? undefined : new Date(this.observedAtMs).toISOString();
590
+ const decl = this.shifts.get(key);
591
+ return {
592
+ capability: capabilityOf({ ...r, ...(decl?.window ? { window: decl.window } : {}), ...(decl?.workCalendar ? { workCalendar: decl.workCalendar } : {}) }, { at, utcOffsetMinutes: this.utcOffsetMinutes, requiredTests: requiredTestsFor(directClassIds, defs, at) })
593
+ };
594
+ }
574
595
  snapshot() {
575
596
  const occ = new Map();
576
597
  for (const it of this.items.values())
@@ -595,10 +616,10 @@ export class ObservedReducer {
595
616
  items: [...this.items.values()].map(i => ({ ...i })),
596
617
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
597
618
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
598
- persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`) })),
599
- assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a) })),
619
+ persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
620
+ assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
600
621
  tasks: [...this.tasks.values()].map(t => ({ ...t })),
601
- equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
622
+ equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
602
623
  orders: [...this.orders.values()].map(o => ({ ...o })),
603
624
  acked: [...this.acked]
604
625
  };
@@ -119,6 +119,7 @@ __export(index_exports, {
119
119
  relationsTo: () => relationsTo,
120
120
  replay: () => replay,
121
121
  replaySegments: () => replaySegments,
122
+ requiredTestsFor: () => requiredTestsFor,
122
123
  retiredVocabularyIn: () => retiredVocabularyIn,
123
124
  sgtinClass: () => sgtinClass,
124
125
  sgtinUri: () => sgtinUri,
@@ -410,6 +411,13 @@ function offCalendarAt(r, ms2, utcOffsetMinutes) {
410
411
  const h = Math.floor(minute / 60);
411
412
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
412
413
  }
414
+ function requiredTestsFor(directIds, defs, at) {
415
+ if (!defs?.length) return [];
416
+ const closure = classClosure(directIds, defs, at);
417
+ const required = [];
418
+ for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
419
+ return required;
420
+ }
413
421
  function capabilityOf(r, ctx) {
414
422
  const at = ctx?.at;
415
423
  const eff = effectivityAt(r, at);
@@ -975,16 +983,24 @@ var ObservedReducer = class {
975
983
  * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
976
984
  */
977
985
  shifts = /* @__PURE__ */ new Map();
986
+ /**
987
+ * 자원 **등급 정의** — 교대 선언과 같은 성격의 *판정 입력*이다(상태로 내보내지 않는다).
988
+ *
989
+ * 이것이 없으면 미러는 "이 사람에게 어떤 시험이 필수인가" 를 모른다. 시뮬은 막는데 미러는
990
+ * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
991
+ */
992
+ classDefs = {};
978
993
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
979
994
  utcOffsetMinutes;
980
995
  constructor(board) {
981
996
  this.utcOffsetMinutes = board.utcOffsetMinutes;
997
+ this.classDefs = { personnel: board.personnelClasses, equipment: board.equipmentClasses, asset: board.assetClasses };
982
998
  for (const n of readBoardLocations(board)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
983
- for (const m of readBoardEquipment(board)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...effectiveOf(m), origin: "master" });
984
- for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOf(p) });
999
+ for (const m of readBoardEquipment(board)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
1000
+ for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
985
1001
  for (const m of readBoardEquipment(board)) this.rememberShift(`eq:${m.id}`, m);
986
1002
  for (const p of board.persons ?? []) this.rememberShift(`person:${p.id}`, p);
987
- for (const a of readBoardAssets(board)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOf(a) });
1003
+ for (const a of readBoardAssets(board)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
988
1004
  }
989
1005
  /**
990
1006
  * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -1036,17 +1052,17 @@ var ObservedReducer = class {
1036
1052
  const assetsDropped = dropMissing(this.assets, assets);
1037
1053
  for (const m of equipment) {
1038
1054
  if (!this.equipment.has(m.id))
1039
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...effectiveOf(m), origin: "master" });
1055
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
1040
1056
  this.rememberShift(`eq:${m.id}`, m);
1041
1057
  }
1042
1058
  for (const p of persons) {
1043
1059
  if (!this.persons.has(p.id))
1044
- this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOf(p) });
1060
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
1045
1061
  this.rememberShift(`person:${p.id}`, p);
1046
1062
  }
1047
1063
  for (const a of assets)
1048
1064
  if (!this.assets.has(a.id))
1049
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOf(a) });
1065
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
1050
1066
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
1051
1067
  }
1052
1068
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -1399,6 +1415,22 @@ var ObservedReducer = class {
1399
1415
  const e = effectivityAt(r, new Date(this.observedAtMs).toISOString());
1400
1416
  return e ? { effectivity: e } : {};
1401
1417
  }
1418
+ /**
1419
+ * **왜 지금 쓰일 수 없나** — 시뮬과 **같은 함수**로 낸다(`capabilityOf`).
1420
+ *
1421
+ * 델타로 받지 않고 여기서 파생하는 이유는 `effectivity`·`shift` 와 같다: 자격 만료와 교대 경계는
1422
+ * **이벤트 없이 시각만으로** 넘어간다. 델타로 받아 두면 만료된 자격이 영원히 유효하게 남는다.
1423
+ */
1424
+ capabilityPart(r, key, directClassIds, defs) {
1425
+ const at = this.observedAtMs === void 0 ? void 0 : new Date(this.observedAtMs).toISOString();
1426
+ const decl = this.shifts.get(key);
1427
+ return {
1428
+ capability: capabilityOf(
1429
+ { ...r, ...decl?.window ? { window: decl.window } : {}, ...decl?.workCalendar ? { workCalendar: decl.workCalendar } : {} },
1430
+ { at, utcOffsetMinutes: this.utcOffsetMinutes, requiredTests: requiredTestsFor(directClassIds, defs, at) }
1431
+ )
1432
+ };
1433
+ }
1402
1434
  snapshot() {
1403
1435
  const occ = /* @__PURE__ */ new Map();
1404
1436
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
@@ -1424,10 +1456,10 @@ var ObservedReducer = class {
1424
1456
  items: [...this.items.values()].map((i) => ({ ...i })),
1425
1457
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
1426
1458
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
1427
- persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`) })),
1428
- assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a) })),
1459
+ persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
1460
+ assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
1429
1461
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
1430
- equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
1462
+ equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
1431
1463
  orders: [...this.orders.values()].map((o) => ({ ...o })),
1432
1464
  acked: [...this.acked]
1433
1465
  };
@@ -2441,7 +2473,6 @@ var FlowEngine = class {
2441
2473
  ...p.workCalendar ? { workCalendar: p.workCalendar } : {},
2442
2474
  ...p.homeLocation ? { location: p.homeLocation } : {},
2443
2475
  ...p.properties ? { properties: p.properties } : {},
2444
- ...p.testResults ? { testResults: p.testResults } : {},
2445
2476
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2446
2477
  ...p.testResults ? { testResults: p.testResults } : {},
2447
2478
  ...effectiveOnly(p)
@@ -2455,7 +2486,6 @@ var FlowEngine = class {
2455
2486
  status: "idle",
2456
2487
  taskId: null,
2457
2488
  ...a.properties ? { properties: a.properties } : {},
2458
- ...a.testResults ? { testResults: a.testResults } : {},
2459
2489
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2460
2490
  ...a.testResults ? { testResults: a.testResults } : {},
2461
2491
  ...effectiveOnly(a)
@@ -2525,7 +2555,7 @@ var FlowEngine = class {
2525
2555
  };
2526
2556
  }
2527
2557
  buildEquipment(m) {
2528
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
2558
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
2529
2559
  if (m.mtbfMs !== void 0) {
2530
2560
  eq.mtbfMs = m.mtbfMs;
2531
2561
  eq.mttrMs = m.mttrMs;
@@ -2539,7 +2569,7 @@ var FlowEngine = class {
2539
2569
  */
2540
2570
  addEquipment(m) {
2541
2571
  if (this.equipment.has(m.id)) return;
2542
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
2572
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
2543
2573
  if (m.mtbfMs !== void 0) {
2544
2574
  eq.mtbfMs = m.mtbfMs;
2545
2575
  eq.mttrMs = m.mttrMs;
@@ -2615,6 +2645,8 @@ var FlowEngine = class {
2615
2645
  /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
2616
2646
  ...m.properties ? { properties: m.properties } : {},
2617
2647
  ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {},
2648
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2649
+ ...m.testResults ? { testResults: m.testResults } : {},
2618
2650
  ...effectiveOnly(m)
2619
2651
  });
2620
2652
  }
@@ -2629,6 +2661,8 @@ var FlowEngine = class {
2629
2661
  ...p.location ? { location: p.location } : {},
2630
2662
  ...p.properties ? { properties: p.properties } : {},
2631
2663
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2664
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2665
+ ...p.testResults ? { testResults: p.testResults } : {},
2632
2666
  ...effectiveOnly(p)
2633
2667
  });
2634
2668
  }
@@ -2644,6 +2678,8 @@ var FlowEngine = class {
2644
2678
  carrying: a.carrying,
2645
2679
  ...a.properties ? { properties: a.properties } : {},
2646
2680
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2681
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2682
+ ...a.testResults ? { testResults: a.testResults } : {},
2647
2683
  ...effectiveOnly(a)
2648
2684
  });
2649
2685
  }
@@ -2929,7 +2965,7 @@ var FlowEngine = class {
2929
2965
  }),
2930
2966
  items: [...this.items.values()].map((i) => this.itemState(i)),
2931
2967
  equipment: [...this.equipment.values()].map((m) => {
2932
- const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {} };
2968
+ const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {} };
2933
2969
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
2934
2970
  if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
2935
2971
  return s;
@@ -2939,7 +2975,9 @@ var FlowEngine = class {
2939
2975
  if (a.carrying) st.carrying = a.carrying;
2940
2976
  if (a.properties) st.properties = a.properties;
2941
2977
  if (a.testSpecificationIds) st.testSpecificationIds = a.testSpecificationIds;
2978
+ if (a.testResults) st.testResults = a.testResults;
2942
2979
  Object.assign(st, this.effectivePart(a));
2980
+ st.capability = this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset);
2943
2981
  return st;
2944
2982
  }),
2945
2983
  persons: [...this.persons.values()].map((p) => {
@@ -2947,6 +2985,7 @@ var FlowEngine = class {
2947
2985
  if (p.location) st.location = p.location;
2948
2986
  if (p.properties) st.properties = p.properties;
2949
2987
  if (p.testSpecificationIds) st.testSpecificationIds = p.testSpecificationIds;
2988
+ if (p.testResults) st.testResults = p.testResults;
2950
2989
  if (this.personOffShift(p)) {
2951
2990
  st.offShift = true;
2952
2991
  Object.assign(st, this.offReason(p));
@@ -2954,6 +2993,7 @@ var FlowEngine = class {
2954
2993
  const sh = this.shiftOf(p);
2955
2994
  if (sh) st.shift = sh;
2956
2995
  Object.assign(st, this.effectivePart(p));
2996
+ st.capability = this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel);
2957
2997
  return st;
2958
2998
  }),
2959
2999
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
@@ -3623,8 +3663,7 @@ var FlowEngine = class {
3623
3663
  if (!want) continue;
3624
3664
  const avail = [...this.assets.values()].filter(
3625
3665
  /* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
3626
- (a) => a.status === "idle" && !picked.includes(a.id) && !this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
3627
- (req.assetClass === void 0 || classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass))
3666
+ (a) => this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset).available && !picked.includes(a.id) && (req.assetClass === void 0 || classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass))
3628
3667
  );
3629
3668
  if (avail.length < want) return null;
3630
3669
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -3875,13 +3914,19 @@ var FlowEngine = class {
3875
3914
  *
3876
3915
  * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
3877
3916
  */
3878
- requiredTestsOf(p) {
3879
- const defs = this.classDefs.personnel;
3880
- if (!defs?.length) return [];
3881
- const closure = classClosure(p.personnelClassIds, defs, this.now());
3882
- const required = [];
3883
- for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
3884
- return required;
3917
+ /**
3918
+ * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
3919
+ *
3920
+ * 배정도 스냅샷도 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
3921
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
3922
+ * 판정에 필요한 둘(시각·등급 상속) 아는 것은 커널뿐이므로, 답도 커널이 낸다.
3923
+ */
3924
+ capabilityOfResource(r, directClassIds, defs) {
3925
+ return capabilityOf(r, {
3926
+ at: this.now(),
3927
+ utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
3928
+ requiredTests: requiredTestsFor(directClassIds, defs, this.now())
3929
+ });
3885
3930
  }
3886
3931
  claimPersonnel(t) {
3887
3932
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -3898,11 +3943,7 @@ var FlowEngine = class {
3898
3943
  *
3899
3944
  * 요구된 시험은 등급에서 모아 넘긴다 — 등급 정의를 아는 것은 이쪽이다.
3900
3945
  */
3901
- capabilityOf(p, {
3902
- at: this.now(),
3903
- utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
3904
- requiredTests: this.requiredTestsOf(p)
3905
- }).available && /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3946
+ this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel).available && /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3906
3947
  다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
3907
3948
  (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass))
3908
3949
  );
@@ -3918,11 +3959,14 @@ var FlowEngine = class {
3918
3959
  */
3919
3960
  claimEquipment(t) {
3920
3961
  const free = (kind, picked2 = []) => [...this.equipment.values()].filter(
3921
- (m) => m.status === "idle" && !m.held && !this.offShift(m) && !this.outOfEffect(m) && // 도입 전·폐기 후는 이 시각의 모델에 없다
3922
- !picked2.includes(m.id) && /* 설비의 소속은 아직 단수(`kind`)저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
3923
- 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 'welder' 를 상속하면
3924
- 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
3925
- (kind === void 0 || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind))
3962
+ (m) => (
3963
+ /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 자리에 조건을 늘어놓아서
3964
+ 설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
3965
+ this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment).available && !picked2.includes(m.id) && /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
3966
+ 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 'welder' 상속하면
3967
+ 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
3968
+ (kind === void 0 || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind))
3969
+ )
3926
3970
  );
3927
3971
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
3928
3972
  if (!need?.length) {
@@ -5232,6 +5276,7 @@ function retiredVocabularyIn(line) {
5232
5276
  relationsTo,
5233
5277
  replay,
5234
5278
  replaySegments,
5279
+ requiredTestsFor,
5235
5280
  retiredVocabularyIn,
5236
5281
  sgtinClass,
5237
5282
  sgtinUri,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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": {