@operato/twin-kernel 0.6.6 → 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.
@@ -216,6 +216,45 @@ export declare function testPassedAt(r: TestResult, at?: ISOTime): boolean;
216
216
  * 없는 것으로 막지 않는다). 결과가 있으면 그것이 유효한 합격이어야 한다.
217
217
  */
218
218
  export declare function meetsTests(required: readonly string[], results: readonly TestResult[] | undefined, at?: ISOTime): boolean;
219
+ /**
220
+ * 자원이 **지금 쓰일 수 있나, 아니면 왜 못 쓰이나** — ISA-95 `PersonnelCapability`/`EquipmentCapability`.
221
+ *
222
+ * ── 왜 계약이 이것을 소유해야 하나 ────────────────────────────────────────────
223
+ * 커널은 배정할 때 이미 이 판정을 한다(교대·고장·보류·유효기간·시험 만료). 그런데 그 규칙이 **엔진 안의
224
+ * 필터 조건으로만** 있어서, 화면은 같은 판정을 자기 코드로 다시 만들었다(`reasonOf`). 규칙이 두 벌이면
225
+ * 반드시 갈라진다 — 배정은 막는데 화면은 "가용" 이라 말하는 순간이 온다. 그 어긋남은 조용하다.
226
+ *
227
+ * 그래서 **이유까지 계약이 낸다.** 화면·예측·AI 가 같은 낱말로 말하고, 새 이유가 생기면(시험 만료가
228
+ * 그랬다) 한 곳만 늘어난다.
229
+ *
230
+ * ── 이유의 순서가 뜻이다 ──────────────────────────────────────────────────────
231
+ * 여러 이유가 겹칠 수 있다(폐기한 설비가 고장 상태로 남아 있는 것). **먼저 오는 것을 답한다** —
232
+ * "이미 모델 밖" 이 "고장" 보다 앞선다(폐기한 설비의 고장은 고칠 일이 아니다).
233
+ */
234
+ export type CapabilityReason =
235
+ /** 유효기간 전 — 아직 없는 자원(도입 예정). 기다릴 일이다. */
236
+ 'not-yet'
237
+ /** 유효기간 후 — 이미 없는 자원(폐기·퇴사). 지울 일이다. */
238
+ | 'retired'
239
+ /** 사람이 막았다(`held`) — 지시로 보류. */
240
+ | 'held'
241
+ /** 고장 — 설비만. 고칠 일이다. */
242
+ | 'down'
243
+ /** 근무·가동 시간 밖(교대 사이) — 기다리면 돌아온다. */
244
+ | 'off-shift'
245
+ /** 근무일이 아니다(휴일) — 하루 통째로 쉰다. `off-shift` 와 기다릴 시간이 다르다. */
246
+ | 'resting'
247
+ /** 요구된 시험의 결과가 만료·불합격 — 자격이 성립하지 않는다(§TestResult). */
248
+ | 'test-expired'
249
+ /** 지금 다른 일을 하고 있다 — 능력은 있고 여유가 없다. */
250
+ | 'working'
251
+ /** 쓸 수 있다. */
252
+ | 'available';
253
+ /** 가용 여부와 그 이유 — `available` 이면 `reason: 'available'`. */
254
+ export interface Capability {
255
+ available: boolean;
256
+ reason: CapabilityReason;
257
+ }
219
258
  export interface TestSpecification {
220
259
  /** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
221
260
  id: string;
@@ -578,6 +617,36 @@ export declare function offCalendarAt(r: {
578
617
  };
579
618
  workCalendar?: WorkCalendarEntry[];
580
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[];
628
+ /**
629
+ * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
630
+ *
631
+ * 부르는 쪽이 시각과 시간대를 준다(커널은 `now()`·`utcOffsetMinutes`, 호스트는 관측 시각). 주지 않으면
632
+ * 시각에 달린 판정(유효기간·교대·시험 만료)은 **하지 않는다** — 모르면 판단하지 않는다는 규율이다.
633
+ *
634
+ * `requiredTests` 는 부르는 쪽이 등급에서 모아 넘긴다(등급 정의를 아는 것은 부르는 쪽이다).
635
+ */
636
+ export declare function capabilityOf(r: {
637
+ status?: string;
638
+ held?: boolean;
639
+ window?: {
640
+ startHour: number;
641
+ endHour: number;
642
+ };
643
+ workCalendar?: WorkCalendarEntry[];
644
+ testResults?: TestResult[];
645
+ } & EffectivePeriod, ctx?: {
646
+ at?: ISOTime;
647
+ utcOffsetMinutes?: number;
648
+ requiredTests?: readonly string[];
649
+ }): Capability;
581
650
  export interface LocationState {
582
651
  id: string;
583
652
  type: string;
@@ -796,6 +865,18 @@ export interface EquipmentState extends EffectivePeriod {
796
865
  * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
797
866
  */
798
867
  testResults?: TestResult[];
868
+ /**
869
+ * **왜 지금 쓰일 수 없나** — 판정과 사유를 계약이 낸다(`capabilityOf` · `CapabilityReason`).
870
+ *
871
+ * 상태에 실어 보내는 이유: 이 판정은 **시각과 등급 정의를 함께 알아야** 나온다(유효기간·교대·자격
872
+ * 만료). 소비처는 그 둘을 갖고 있지 않다 — 화면은 시뮬 시각을 모르고, 호스트는 등급 상속을 타고
873
+ * 필수 시험을 모으는 규칙을 모른다. 그래서 예전에는 소비처가 플래그를 보고 짐작했고, 자격이 만료된
874
+ * 사람이 `대기` 로 보였다. 놀고 있는 것은 맞지만 **쓸 수 있는 것은 아니다** — "대기 7명" 이 조용히
875
+ * 틀린 숫자가 됐다.
876
+ *
877
+ * 배정이 쓰는 판정과 **같은 함수의 결과**다(규칙 한 벌). 사유의 순서도 계약이 정한다.
878
+ */
879
+ capability?: Capability;
799
880
  }
800
881
  /**
801
882
  * 사람 — **ISA-95 `Person`.** 설비와 다른 자원 종류다.
@@ -861,6 +942,18 @@ export interface PersonState extends EffectivePeriod {
861
942
  * 없으면 판정하지 않는다(없는 것으로 막으면 자격자가 전부 사라진다).
862
943
  */
863
944
  testResults?: TestResult[];
945
+ /**
946
+ * **왜 지금 쓰일 수 없나** — 판정과 사유를 계약이 낸다(`capabilityOf` · `CapabilityReason`).
947
+ *
948
+ * 상태에 실어 보내는 이유: 이 판정은 **시각과 등급 정의를 함께 알아야** 나온다(유효기간·교대·자격
949
+ * 만료). 소비처는 그 둘을 갖고 있지 않다 — 화면은 시뮬 시각을 모르고, 호스트는 등급 상속을 타고
950
+ * 필수 시험을 모으는 규칙을 모른다. 그래서 예전에는 소비처가 플래그를 보고 짐작했고, 자격이 만료된
951
+ * 사람이 `대기` 로 보였다. 놀고 있는 것은 맞지만 **쓸 수 있는 것은 아니다** — "대기 7명" 이 조용히
952
+ * 틀린 숫자가 됐다.
953
+ *
954
+ * 배정이 쓰는 판정과 **같은 함수의 결과**다(규칙 한 벌). 사유의 순서도 계약이 정한다.
955
+ */
956
+ capability?: Capability;
864
957
  }
865
958
  /**
866
959
  * 물리 자산 — **ISA-95 `PhysicalAsset`, GS1 `GRAI`(반복사용 자산).**
@@ -901,6 +994,10 @@ export interface AssetState extends EffectivePeriod {
901
994
  * 빠뜨리면 회수 대상 수가 실제보다 많게 잡히고, 빈 팔레트 부족이 보이지 않는다.
902
995
  */
903
996
  effectivity?: Effectivity;
997
+ /** 그 시험들의 결과 — 사람과 같은 뜻(§TestResult). 검사에서 떨어진 팔레트는 배정에서 빠진다. */
998
+ testResults?: TestResult[];
999
+ /** 왜 지금 쓰일 수 없나 — 사람·설비와 **같은 판정**(§PersonState.capability). */
1000
+ capability?: Capability;
904
1001
  }
905
1002
  /**
906
1003
  * 작업 하나의 관측 상태 — **ISA-95 `SegmentResponse`**(Part 4, 실적).
package/dist/contract.js CHANGED
@@ -517,6 +517,60 @@ 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
+ }
537
+ /**
538
+ * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
539
+ *
540
+ * 부르는 쪽이 시각과 시간대를 준다(커널은 `now()`·`utcOffsetMinutes`, 호스트는 관측 시각). 주지 않으면
541
+ * 시각에 달린 판정(유효기간·교대·시험 만료)은 **하지 않는다** — 모르면 판단하지 않는다는 규율이다.
542
+ *
543
+ * `requiredTests` 는 부르는 쪽이 등급에서 모아 넘긴다(등급 정의를 아는 것은 부르는 쪽이다).
544
+ */
545
+ export function capabilityOf(r, ctx) {
546
+ const at = ctx?.at;
547
+ /* 순서가 뜻이다 — "이미 모델 밖" 이 "고장" 보다 앞선다(폐기한 설비의 고장은 고칠 일이 아니다). */
548
+ const eff = effectivityAt(r, at);
549
+ if (eff === 'not-yet')
550
+ return { available: false, reason: 'not-yet' };
551
+ if (eff === 'expired')
552
+ return { available: false, reason: 'retired' };
553
+ if (r.held)
554
+ return { available: false, reason: 'held' };
555
+ if (r.status === 'down')
556
+ return { available: false, reason: 'down' };
557
+ if (at) {
558
+ const ms = Date.parse(at);
559
+ if (Number.isFinite(ms)) {
560
+ const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
561
+ if (why === 'non-working')
562
+ return { available: false, reason: 'resting' };
563
+ if (why === 'off-hours')
564
+ return { available: false, reason: 'off-shift' };
565
+ }
566
+ }
567
+ /* 자격은 **결과가 선언됐을 때만** 제약이다(§meetsTests) — 없는 것으로 막으면 라인이 굶는다. */
568
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
569
+ return { available: false, reason: 'test-expired' };
570
+ if (r.status && r.status !== 'idle' && r.status !== 'available')
571
+ return { available: false, reason: 'working' };
572
+ return { available: true, reason: 'available' };
573
+ }
520
574
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
521
575
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
522
576
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -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;
@@ -809,7 +818,19 @@ export declare abstract class FlowEngine implements TwinKernel {
809
818
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
810
819
  * 조용히 통과시킨다.
811
820
  */
812
- private qualifiedByTests;
821
+ /**
822
+ * 이 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
823
+ *
824
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
825
+ */
826
+ /**
827
+ * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
828
+ *
829
+ * 배정도 스냅샷도 이 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
830
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
831
+ * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
832
+ */
833
+ private capabilityOfResource;
813
834
  private claimPersonnel;
814
835
  /**
815
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, meetsTests, 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)
@@ -1854,16 +1867,24 @@ export class FlowEngine {
1854
1867
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
1855
1868
  * 조용히 통과시킨다.
1856
1869
  */
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());
1870
+ /**
1871
+ * 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
1872
+ *
1873
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
1874
+ */
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
+ });
1867
1888
  }
1868
1889
  claimPersonnel(t) {
1869
1890
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -1874,24 +1895,19 @@ export class FlowEngine {
1874
1895
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
1875
1896
  if (!want)
1876
1897
  continue;
1877
- const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
1878
- !picked.includes(p.id) &&
1879
- !this.personOffShift(p) &&
1880
- !this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
1881
- /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1882
- 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
1883
- 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
1884
- (req.personnelClass === undefined ||
1885
- classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) &&
1898
+ const avail = [...this.persons.values()].filter(p => !picked.includes(p.id) &&
1886
1899
  /*
1887
- * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
1888
- * (`testResults`). 만료·불합격이면 자격은 성립하지 않는다.
1900
+ * **판정은 벌이다**(`capabilityOf`). 예전에는 자리에 조건을 늘어놓았고 화면은 같은
1901
+ * 판정을 자기 코드로 다시 만들었다 — 규칙이 두 벌이면 갈라지고, 배정은 막는데 화면은
1902
+ * "가용" 이라 말하는 순간이 온다(그 어긋남은 조용하다).
1889
1903
  *
1890
- * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
1891
- * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
1892
- * 제약이 된다" — 이 커널의 규율).
1904
+ * 요구된 시험은 등급에서 모아 넘긴다 등급 정의를 아는 것은 이쪽이다.
1893
1905
  */
1894
- this.qualifiedByTests(p));
1906
+ this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel).available &&
1907
+ /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1908
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
1909
+ (req.personnelClass === undefined ||
1910
+ classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
1895
1911
  if (avail.length < want)
1896
1912
  return null; // 한 등급이라도 모자라면 시작하지 않는다
1897
1913
  for (let i = 0; i < want; i++)
@@ -1905,10 +1921,10 @@ export class FlowEngine {
1905
1921
  * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1906
1922
  */
1907
1923
  claimEquipment(t) {
1908
- const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
1909
- !m.held &&
1910
- !this.offShift(m) &&
1911
- !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 &&
1912
1928
  !picked.includes(m.id) &&
1913
1929
  /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
1914
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
  };
@@ -73,6 +73,7 @@ __export(index_exports, {
73
73
  axisInfo: () => axisInfo,
74
74
  axisSource: () => axisSource,
75
75
  capabilitiesForType: () => capabilitiesForType,
76
+ capabilityOf: () => capabilityOf,
76
77
  classClosure: () => classClosure,
77
78
  compareStates: () => compareStates,
78
79
  computeOee: () => computeOee,
@@ -118,6 +119,7 @@ __export(index_exports, {
118
119
  relationsTo: () => relationsTo,
119
120
  replay: () => replay,
120
121
  replaySegments: () => replaySegments,
122
+ requiredTestsFor: () => requiredTestsFor,
121
123
  retiredVocabularyIn: () => retiredVocabularyIn,
122
124
  sgtinClass: () => sgtinClass,
123
125
  sgtinUri: () => sgtinUri,
@@ -409,6 +411,33 @@ function offCalendarAt(r, ms2, utcOffsetMinutes) {
409
411
  const h = Math.floor(minute / 60);
410
412
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
411
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
+ }
421
+ function capabilityOf(r, ctx) {
422
+ const at = ctx?.at;
423
+ const eff = effectivityAt(r, at);
424
+ if (eff === "not-yet") return { available: false, reason: "not-yet" };
425
+ if (eff === "expired") return { available: false, reason: "retired" };
426
+ if (r.held) return { available: false, reason: "held" };
427
+ if (r.status === "down") return { available: false, reason: "down" };
428
+ if (at) {
429
+ const ms2 = Date.parse(at);
430
+ if (Number.isFinite(ms2)) {
431
+ const why = offCalendarReasonAt(r, ms2, ctx?.utcOffsetMinutes);
432
+ if (why === "non-working") return { available: false, reason: "resting" };
433
+ if (why === "off-hours") return { available: false, reason: "off-shift" };
434
+ }
435
+ }
436
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
437
+ return { available: false, reason: "test-expired" };
438
+ if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
439
+ return { available: true, reason: "available" };
440
+ }
412
441
  var OP_EVENT = {
413
442
  task: "task.status",
414
443
  equipment: "equipment.status",
@@ -954,16 +983,24 @@ var ObservedReducer = class {
954
983
  * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
955
984
  */
956
985
  shifts = /* @__PURE__ */ new Map();
986
+ /**
987
+ * 자원 **등급 정의** — 교대 선언과 같은 성격의 *판정 입력*이다(상태로 내보내지 않는다).
988
+ *
989
+ * 이것이 없으면 미러는 "이 사람에게 어떤 시험이 필수인가" 를 모른다. 시뮬은 막는데 미러는
990
+ * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
991
+ */
992
+ classDefs = {};
957
993
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
958
994
  utcOffsetMinutes;
959
995
  constructor(board) {
960
996
  this.utcOffsetMinutes = board.utcOffsetMinutes;
997
+ this.classDefs = { personnel: board.personnelClasses, equipment: board.equipmentClasses, asset: board.assetClasses };
961
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" });
962
- 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" });
963
- 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) });
964
1001
  for (const m of readBoardEquipment(board)) this.rememberShift(`eq:${m.id}`, m);
965
1002
  for (const p of board.persons ?? []) this.rememberShift(`person:${p.id}`, p);
966
- 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) });
967
1004
  }
968
1005
  /**
969
1006
  * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -1015,17 +1052,17 @@ var ObservedReducer = class {
1015
1052
  const assetsDropped = dropMissing(this.assets, assets);
1016
1053
  for (const m of equipment) {
1017
1054
  if (!this.equipment.has(m.id))
1018
- 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" });
1019
1056
  this.rememberShift(`eq:${m.id}`, m);
1020
1057
  }
1021
1058
  for (const p of persons) {
1022
1059
  if (!this.persons.has(p.id))
1023
- 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) });
1024
1061
  this.rememberShift(`person:${p.id}`, p);
1025
1062
  }
1026
1063
  for (const a of assets)
1027
1064
  if (!this.assets.has(a.id))
1028
- 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) });
1029
1066
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
1030
1067
  }
1031
1068
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -1378,6 +1415,22 @@ var ObservedReducer = class {
1378
1415
  const e = effectivityAt(r, new Date(this.observedAtMs).toISOString());
1379
1416
  return e ? { effectivity: e } : {};
1380
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
+ }
1381
1434
  snapshot() {
1382
1435
  const occ = /* @__PURE__ */ new Map();
1383
1436
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
@@ -1403,10 +1456,10 @@ var ObservedReducer = class {
1403
1456
  items: [...this.items.values()].map((i) => ({ ...i })),
1404
1457
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
1405
1458
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
1406
- persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`) })),
1407
- 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) })),
1408
1461
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
1409
- 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) })),
1410
1463
  orders: [...this.orders.values()].map((o) => ({ ...o })),
1411
1464
  acked: [...this.acked]
1412
1465
  };
@@ -2420,7 +2473,6 @@ var FlowEngine = class {
2420
2473
  ...p.workCalendar ? { workCalendar: p.workCalendar } : {},
2421
2474
  ...p.homeLocation ? { location: p.homeLocation } : {},
2422
2475
  ...p.properties ? { properties: p.properties } : {},
2423
- ...p.testResults ? { testResults: p.testResults } : {},
2424
2476
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2425
2477
  ...p.testResults ? { testResults: p.testResults } : {},
2426
2478
  ...effectiveOnly(p)
@@ -2434,7 +2486,6 @@ var FlowEngine = class {
2434
2486
  status: "idle",
2435
2487
  taskId: null,
2436
2488
  ...a.properties ? { properties: a.properties } : {},
2437
- ...a.testResults ? { testResults: a.testResults } : {},
2438
2489
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2439
2490
  ...a.testResults ? { testResults: a.testResults } : {},
2440
2491
  ...effectiveOnly(a)
@@ -2504,7 +2555,7 @@ var FlowEngine = class {
2504
2555
  };
2505
2556
  }
2506
2557
  buildEquipment(m) {
2507
- 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) };
2508
2559
  if (m.mtbfMs !== void 0) {
2509
2560
  eq.mtbfMs = m.mtbfMs;
2510
2561
  eq.mttrMs = m.mttrMs;
@@ -2518,7 +2569,7 @@ var FlowEngine = class {
2518
2569
  */
2519
2570
  addEquipment(m) {
2520
2571
  if (this.equipment.has(m.id)) return;
2521
- 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 };
2522
2573
  if (m.mtbfMs !== void 0) {
2523
2574
  eq.mtbfMs = m.mtbfMs;
2524
2575
  eq.mttrMs = m.mttrMs;
@@ -2594,6 +2645,8 @@ var FlowEngine = class {
2594
2645
  /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
2595
2646
  ...m.properties ? { properties: m.properties } : {},
2596
2647
  ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {},
2648
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2649
+ ...m.testResults ? { testResults: m.testResults } : {},
2597
2650
  ...effectiveOnly(m)
2598
2651
  });
2599
2652
  }
@@ -2608,6 +2661,8 @@ var FlowEngine = class {
2608
2661
  ...p.location ? { location: p.location } : {},
2609
2662
  ...p.properties ? { properties: p.properties } : {},
2610
2663
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2664
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2665
+ ...p.testResults ? { testResults: p.testResults } : {},
2611
2666
  ...effectiveOnly(p)
2612
2667
  });
2613
2668
  }
@@ -2623,6 +2678,8 @@ var FlowEngine = class {
2623
2678
  carrying: a.carrying,
2624
2679
  ...a.properties ? { properties: a.properties } : {},
2625
2680
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2681
+ /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
2682
+ ...a.testResults ? { testResults: a.testResults } : {},
2626
2683
  ...effectiveOnly(a)
2627
2684
  });
2628
2685
  }
@@ -2908,7 +2965,7 @@ var FlowEngine = class {
2908
2965
  }),
2909
2966
  items: [...this.items.values()].map((i) => this.itemState(i)),
2910
2967
  equipment: [...this.equipment.values()].map((m) => {
2911
- 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) } : {} };
2912
2969
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
2913
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 };
2914
2971
  return s;
@@ -2918,7 +2975,9 @@ var FlowEngine = class {
2918
2975
  if (a.carrying) st.carrying = a.carrying;
2919
2976
  if (a.properties) st.properties = a.properties;
2920
2977
  if (a.testSpecificationIds) st.testSpecificationIds = a.testSpecificationIds;
2978
+ if (a.testResults) st.testResults = a.testResults;
2921
2979
  Object.assign(st, this.effectivePart(a));
2980
+ st.capability = this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset);
2922
2981
  return st;
2923
2982
  }),
2924
2983
  persons: [...this.persons.values()].map((p) => {
@@ -2926,6 +2985,7 @@ var FlowEngine = class {
2926
2985
  if (p.location) st.location = p.location;
2927
2986
  if (p.properties) st.properties = p.properties;
2928
2987
  if (p.testSpecificationIds) st.testSpecificationIds = p.testSpecificationIds;
2988
+ if (p.testResults) st.testResults = p.testResults;
2929
2989
  if (this.personOffShift(p)) {
2930
2990
  st.offShift = true;
2931
2991
  Object.assign(st, this.offReason(p));
@@ -2933,6 +2993,7 @@ var FlowEngine = class {
2933
2993
  const sh = this.shiftOf(p);
2934
2994
  if (sh) st.shift = sh;
2935
2995
  Object.assign(st, this.effectivePart(p));
2996
+ st.capability = this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel);
2936
2997
  return st;
2937
2998
  }),
2938
2999
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
@@ -3602,8 +3663,7 @@ var FlowEngine = class {
3602
3663
  if (!want) continue;
3603
3664
  const avail = [...this.assets.values()].filter(
3604
3665
  /* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
3605
- (a) => a.status === "idle" && !picked.includes(a.id) && !this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
3606
- (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))
3607
3667
  );
3608
3668
  if (avail.length < want) return null;
3609
3669
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -3849,13 +3909,24 @@ var FlowEngine = class {
3849
3909
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
3850
3910
  * 조용히 통과시킨다.
3851
3911
  */
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());
3912
+ /**
3913
+ * 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
3914
+ *
3915
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
3916
+ */
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
+ });
3859
3930
  }
3860
3931
  claimPersonnel(t) {
3861
3932
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -3865,19 +3936,16 @@ var FlowEngine = class {
3865
3936
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
3866
3937
  if (!want) continue;
3867
3938
  const avail = [...this.persons.values()].filter(
3868
- (p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && !this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
3869
- /* 자격은 **상속을 타고 닫아** 판정한다 사람은 여러 등급에 속할 수 있고(표준), 등급은
3870
- 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 만족해야 한다.
3871
- 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
3872
- (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) && /*
3873
- * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
3874
- * (`testResults`). 만료·불합격이면 그 자격은 성립하지 않는다.
3939
+ (p) => !picked.includes(p.id) && /*
3940
+ * **판정은 벌이다**(`capabilityOf`). 예전에는 자리에 조건을 늘어놓았고 화면은 같은
3941
+ * 판정을 자기 코드로 다시 만들었다 규칙이 벌이면 갈라지고, 배정은 막는데 화면은
3942
+ * "가용" 이라 말하는 순간이 온다( 어긋남은 조용하다).
3875
3943
  *
3876
- * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
3877
- * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
3878
- * 제약이 된다" — 이 커널의 규율).
3944
+ * 요구된 시험은 등급에서 모아 넘긴다 등급 정의를 아는 것은 이쪽이다.
3879
3945
  */
3880
- this.qualifiedByTests(p)
3946
+ this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel).available && /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3947
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
3948
+ (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass))
3881
3949
  );
3882
3950
  if (avail.length < want) return null;
3883
3951
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -3891,11 +3959,14 @@ var FlowEngine = class {
3891
3959
  */
3892
3960
  claimEquipment(t) {
3893
3961
  const free = (kind, picked2 = []) => [...this.equipment.values()].filter(
3894
- (m) => m.status === "idle" && !m.held && !this.offShift(m) && !this.outOfEffect(m) && // 도입 전·폐기 후는 이 시각의 모델에 없다
3895
- !picked2.includes(m.id) && /* 설비의 소속은 아직 단수(`kind`)저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
3896
- 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 'welder' 를 상속하면
3897
- 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
3898
- (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
+ )
3899
3970
  );
3900
3971
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
3901
3972
  if (!need?.length) {
@@ -5159,6 +5230,7 @@ function retiredVocabularyIn(line) {
5159
5230
  axisInfo,
5160
5231
  axisSource,
5161
5232
  capabilitiesForType,
5233
+ capabilityOf,
5162
5234
  classClosure,
5163
5235
  compareStates,
5164
5236
  computeOee,
@@ -5204,6 +5276,7 @@ function retiredVocabularyIn(line) {
5204
5276
  relationsTo,
5205
5277
  replay,
5206
5278
  replaySegments,
5279
+ requiredTestsFor,
5207
5280
  retiredVocabularyIn,
5208
5281
  sgtinClass,
5209
5282
  sgtinUri,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.6",
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": {