@operato/twin-kernel 0.7.55 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/epcis.js CHANGED
@@ -16,7 +16,59 @@ export const DISP = {
16
16
  sellable: 'urn:epcglobal:cbv:disp:sellable_accessible',
17
17
  reserved: 'urn:epcglobal:cbv:disp:reserved',
18
18
  in_transit: 'urn:epcglobal:cbv:disp:in_transit',
19
- non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other' // 불량/scrap
19
+ non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other', // 불량/scrap
20
+ /**
21
+ * **기한이 지났다** — CBV `expired`.
22
+ *
23
+ * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
24
+ * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
25
+ * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
26
+ * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
27
+ *
28
+ * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
29
+ *
30
+ * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
31
+ * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
32
+ * 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
33
+ *
34
+ * 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
35
+ * 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
36
+ *
37
+ * ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
38
+ * 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
39
+ * 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
40
+ * 그 표에 있다.
41
+ *
42
+ * **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
43
+ * 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
44
+ * CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
45
+ *
46
+ * 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
47
+ * 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
48
+ */
49
+ expired: 'urn:epcglobal:cbv:disp:expired',
50
+ /**
51
+ * **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
52
+ *
53
+ * 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
54
+ *
55
+ * conformant Outcome of a successful/passed inspection in an inspecting or repairing step
56
+ * non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
57
+ *
58
+ * ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
59
+ * 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
60
+ * 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
61
+ *
62
+ * 처분을 쓰면 두 가지가 공짜로 성립한다.
63
+ * ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
64
+ * ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
65
+ *
66
+ * 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
67
+ * 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
68
+ * 그때 열되, **판정 자체는 여기서 끝난다.**
69
+ */
70
+ conformant: 'urn:epcglobal:cbv:disp:conformant',
71
+ non_conformant: 'urn:epcglobal:cbv:disp:non_conformant'
20
72
  };
21
73
  /**
22
74
  * **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
@@ -28,7 +80,16 @@ export const CBV_BIZSTEP = {
28
80
  /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
29
81
  consuming: 'urn:epcglobal:cbv:bizstep:consuming',
30
82
  /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
31
- commissioning: 'urn:epcglobal:cbv:bizstep:commissioning'
83
+ commissioning: 'urn:epcglobal:cbv:bizstep:commissioning',
84
+ /**
85
+ * **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
86
+ * potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
87
+ * 이어진다(즉 검사는 물건을 소비하지 않는다).
88
+ *
89
+ * 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
90
+ * 공정 중 검사가 그 모양이다.
91
+ */
92
+ inspecting: 'urn:epcglobal:cbv:bizstep:inspecting'
32
93
  };
33
94
  // ── GS1 EPC URI 헬퍼 (표준) ────────────────────────────────────────────────
34
95
  /** SSCC (물류단위: 팔레트/화물/트레일러) — 결정적 카운터 기반. */
@@ -1,4 +1,5 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from './contract.ts';
2
+ import type { ReducerCheckpoint } from './observed-reducer.ts';
2
3
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
4
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
5
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -369,6 +370,10 @@ export declare function deriveAttentions(view: {
369
370
  id: string;
370
371
  capacity?: number;
371
372
  occupancy?: number;
373
+ /** 이 자리에 걸린 판정 기준들 — 없으면 관측을 판정하지 않는다. */
374
+ criteria?: TestSpecificationCriterion[];
375
+ /** 이 자리에서 관측된 물리량들(속성당 최신). */
376
+ observations?: LocationObservation[];
372
377
  }[];
373
378
  orders: {
374
379
  id: string;
@@ -560,6 +565,37 @@ export declare abstract class FlowEngine implements TwinKernel {
560
565
  protected localParams: Map<string, Map<string, string>>;
561
566
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
562
567
  private observer?;
568
+ /**
569
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
570
+ *
571
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
572
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
573
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
574
+ * 된다 — 불편이 아니라 벽이다.
575
+ *
576
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
577
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
578
+ *
579
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
580
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
581
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
582
+ *
583
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
584
+ */
585
+ observedCheckpoint(): ReducerCheckpoint | undefined;
586
+ /**
587
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
588
+ *
589
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
590
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
591
+ *
592
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
593
+ *
594
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
595
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
596
+ * 여기서는 받은 것을 그대로 세운다.
597
+ */
598
+ restoreObserved(cp: ReducerCheckpoint): void;
563
599
  /**
564
600
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
565
601
  *
@@ -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, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from "./epcis.js";
15
15
  import { OP_PARAM } from "./domain-definition.js";
@@ -108,6 +108,56 @@ thresholds) {
108
108
  }
109
109
  }
110
110
  for (const n of view.locations) {
111
+ /*
112
+ * **관측이 한계를 벗어났다** — 판정할 수 있을 때만 낸다.
113
+ *
114
+ * ── 왜 이 자리인가 ────────────────────────────────────────────────────────
115
+ * 기준과 측정값을 계약에 넣어 두고 **아무도 부르지 않았다**(`outsideLimit`). 부르지 않는 판정
116
+ * 함수는 시험만 통과하는 코드이고, 「선언만 있는 능력은 사용자에게 거짓말이 된다」와 같은 자리다.
117
+ *
118
+ * ── 커널이 하지 않는 것 ───────────────────────────────────────────────────
119
+ * 표현식을 읽지 않는다. 숫자 한계가 없으면 `outsideLimit` 이 `undefined` 를 주고, 그때는 **신호를
120
+ * 내지 않는다** — 「판정 못 했다」를 「벗어났다」로도 「괜찮다」로도 만들지 않는다.
121
+ *
122
+ * 그 「판정 못 했다」가 조용히 사라지는 것은 다른 축이 답한다(`criterionSaysNothing` ·
123
+ * `testEvidenceGaps`) — 그것은 설정의 흠이고 이것은 운영의 사실이라, 한 신호에 섞지 않는다.
124
+ *
125
+ * ── 심각도를 벗어난 폭으로 가르지 않는다 ──────────────────────────────────
126
+ * 에너지 초과는 5%·15% 로 갈랐다(계량 오차와 구별하기 어려우므로). 여기서는 그러지 않는다:
127
+ * 식품 안전 한계는 **넘으면 넘은 것**이고, 「조금 넘었다」를 낮게 부르면 그 판단을 커널이 대신
128
+ * 하는 것이 된다. 폭은 `params` 로 함께 내고 판단은 현장이 한다.
129
+ */
130
+ for (const c of n.criteria ?? []) {
131
+ const propertyId = c.evaluatedPropertyId;
132
+ if (!propertyId)
133
+ continue;
134
+ const observed = (n.observations ?? []).find(o => o.propertyId === propertyId);
135
+ if (!observed)
136
+ continue;
137
+ if (outsideLimit(c, observed) !== true)
138
+ continue;
139
+ out.push({
140
+ /* 자리·기준마다 하나 — 같은 방의 온도와 습도가 한 신호로 뭉치지 않는다. */
141
+ id: `observation-out-of-limit:${n.id}:${c.id}`,
142
+ kind: 'observation-out-of-limit',
143
+ severity: 'high',
144
+ anchor: { locationId: n.id },
145
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다. */
146
+ params: {
147
+ locationId: n.id,
148
+ criterionId: c.id,
149
+ propertyId,
150
+ /* 값이 없으면 판정이 서지 않으므로 여기 오지 않는다 — 그래도 타입을 좁혀 둔다. */
151
+ value: observed.value ?? '',
152
+ ...(observed.uom ? { uom: observed.uom } : {}),
153
+ ...(c.limit?.minimum !== undefined ? { minimum: c.limit.minimum } : {}),
154
+ ...(c.limit?.maximum !== undefined ? { maximum: c.limit.maximum } : {}),
155
+ /* **언제의 값인가** — 4개월 전 값으로 지금을 판정한 것인지 소비처가 알아야 한다. */
156
+ effectiveTime: observed.effectiveTime,
157
+ ...(observed.derived ? { derived: 'true' } : {})
158
+ }
159
+ });
160
+ }
111
161
  if ((n.capacity ?? 0) > 0) {
112
162
  const r = (n.occupancy ?? 0) / n.capacity;
113
163
  /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
@@ -573,6 +623,47 @@ export class FlowEngine {
573
623
  localParams = new Map();
574
624
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
625
  observer;
626
+ /**
627
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
628
+ *
629
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
630
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
631
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
632
+ * 된다 — 불편이 아니라 벽이다.
633
+ *
634
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
635
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
636
+ *
637
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
638
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
639
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
640
+ *
641
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
642
+ */
643
+ observedCheckpoint() {
644
+ return this.observer?.serialize();
645
+ }
646
+ /**
647
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
648
+ *
649
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
650
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
651
+ *
652
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
653
+ *
654
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
655
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
656
+ * 여기서는 받은 것을 그대로 세운다.
657
+ */
658
+ restoreObserved(cp) {
659
+ if (!this.observer) {
660
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
661
+ this.observeMode = true;
662
+ }
663
+ this.observer.restore(cp);
664
+ this.observedDirty = true;
665
+ this.settleObserved();
666
+ }
576
667
  /**
577
668
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
578
669
  *
@@ -1663,9 +1754,31 @@ export class FlowEngine {
1663
1754
  collectAttentions() {
1664
1755
  // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
1665
1756
  const now = this.now();
1757
+ /*
1758
+ * **자리에 걸린 기준을 여기서 푼다** — 순수 함수는 모델을 읽지 않는다(그 규율은 `attentionThresholds`
1759
+ * 와 같다). 종류 선언(`LocationTypeDef.testSpecificationIds`) → 명세 → 기준으로 풀어 넘긴다.
1760
+ *
1761
+ * 선언이 없으면 빈 배열이고, 그러면 그 자리의 관측은 판정되지 않는다 — 관측은 남고 신호만 서지
1762
+ * 않는다(「선언한 것만 제약이 된다」).
1763
+ */
1764
+ const specById = new Map((this.boardDef?.testSpecifications ?? []).map(sp => [sp.id, sp]));
1765
+ const criteriaOf = new Map();
1766
+ if (specById.size) {
1767
+ for (const n of readBoardLocations(this.boardDef ?? {})) {
1768
+ const ids = n.testSpecificationIds;
1769
+ if (!ids?.length)
1770
+ continue;
1771
+ const cs = ids.flatMap(id => specById.get(id)?.criteria ?? []);
1772
+ if (cs.length)
1773
+ criteriaOf.set(n.id, cs);
1774
+ }
1775
+ }
1666
1776
  const out = deriveAttentions({
1667
1777
  equipment: [...this.equipment.values()],
1668
- locations: [...this.locations.values()],
1778
+ locations: [...this.locations.values()].map(n => {
1779
+ const criteria = criteriaOf.get(n.id);
1780
+ return criteria?.length ? { ...n, criteria } : n;
1781
+ }),
1669
1782
  orders: [...this.orders.values()],
1670
1783
  tasks: [...this.tasks.values()]
1671
1784
  }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
@@ -1,4 +1,4 @@
1
- import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
1
+ import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
2
  interface ProjItem {
3
3
  epc: string;
4
4
  /** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
@@ -16,6 +16,8 @@ interface ProjItem {
16
16
  /** 받은 개체·로트 마스터데이터 원문 — 이름을 모르는 속성도 잃지 않는다. */
17
17
  ilmd?: Record<string, unknown>;
18
18
  expiry?: number;
19
+ /** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
20
+ testResults?: TestResult[];
19
21
  }
20
22
  /**
21
23
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
@@ -141,10 +143,17 @@ export interface ReducerCheckpoint {
141
143
  firstAtMs?: number;
142
144
  lastAtMs?: number;
143
145
  }[];
146
+ /** 자리별 · 속성별 마지막 관측 — 이어 접기가 이 축을 0부터 다시 만들지 않게. */
147
+ observations?: {
148
+ id: string;
149
+ values: LocationObservation[];
150
+ }[];
144
151
  }
145
152
  export declare class ObservedReducer {
146
153
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
147
154
  private master;
155
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
156
+ private observations;
148
157
  private items;
149
158
  private aggregation;
150
159
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -170,6 +179,8 @@ export declare class ObservedReducer {
170
179
  private corrections;
171
180
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
172
181
  private unhandled;
182
+ /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
183
+ private testSpecs;
173
184
  /**
174
185
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
175
186
  *
@@ -19,7 +19,7 @@
19
19
  * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
- import { OP_EVENT, capabilityOf, itemKeyOf, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
22
+ import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, 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)을 축소하지 않는다.**
@@ -41,6 +41,8 @@ function effectiveOf(r) {
41
41
  export class ObservedReducer {
42
42
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
43
43
  master = new Map();
44
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
45
+ observations = new Map();
44
46
  items = new Map();
45
47
  aggregation = new Map();
46
48
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -66,6 +68,8 @@ export class ObservedReducer {
66
68
  corrections = [];
67
69
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
68
70
  unhandled = new Map();
71
+ /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
72
+ testSpecs = new Map();
69
73
  /**
70
74
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
71
75
  *
@@ -85,6 +89,9 @@ export class ObservedReducer {
85
89
  constructor(model) {
86
90
  this.utcOffsetMinutes = model.utcOffsetMinutes;
87
91
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
92
+ /* 선언된 판정 기준 — 원천이 판정하지 않은 결과를 커널이 판정할 때 쓴다(§`judgeAgainstSpec`). */
93
+ for (const sp of model.testSpecifications ?? [])
94
+ this.testSpecs.set(sp.id, sp);
88
95
  for (const n of readBoardLocations(model))
89
96
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
90
97
  // 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
@@ -339,6 +346,64 @@ export class ObservedReducer {
339
346
  this.touchLocation(d.location);
340
347
  break;
341
348
  }
349
+ case OP_EVENT.observation: {
350
+ /*
351
+ * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
352
+ *
353
+ * 이력을 들지 않는 이유: 상태가 **시간에 비례해 자라지 않게**. 「그때 몇 도였나」는 저널이
354
+ * 답하는 물음이고, 상태가 답하는 것은 「지금 몇 도냐」다.
355
+ *
356
+ * 늦게 온 옛 관측이 최신을 덮지 않게 `stale` 을 지난다 — 대상 키에 속성을 넣는다(온도가 늦게
357
+ * 와도 습도의 최신은 지켜져야 한다).
358
+ */
359
+ const d = e.data;
360
+ if (!d?.locationId || !d?.propertyId)
361
+ break;
362
+ if (this.stale(`observation:${d.locationId}:${d.propertyId}`, e))
363
+ return;
364
+ this.touchLocation(d.locationId);
365
+ const bin = this.observations.get(d.locationId) ?? new Map();
366
+ bin.set(d.propertyId, { ...d });
367
+ this.observations.set(d.locationId, bin);
368
+ break;
369
+ }
370
+ case OP_EVENT.test: {
371
+ /*
372
+ * **시험 결과** — 대상을 가리켜 들어오고(표준 방향), 상태에서는 그 개체 안에 접힌다.
373
+ *
374
+ * 명세당 마지막 하나만 든다: 이력을 들면 상태가 계측 주기로 자란다(품목 100만 기준에서 벽이다).
375
+ * 늦게 온 옛 결과가 최신을 덮지 않게 `stale` 을 지나며, **대상 키에 명세를 넣는다** — 온도
376
+ * 기준의 결과가 늦게 와도 중량 기준의 최신은 지켜져야 한다.
377
+ *
378
+ * ── 물품을 지어내지 않는다 ────────────────────────────────────────────
379
+ * 자리의 관측은 모르는 자리를 세운다(`origin: 'observed'` — 자리는 있는 것이다). **물품은
380
+ * 세우지 않는다**: 없는 재고를 만드는 것이고, 그것은 사실을 잃는 것보다 나쁘다. 그래서 모르는
381
+ * 대상의 결과는 `unhandled` 로 **세어서** 낸다 — 조용히 버리지 않는다.
382
+ *
383
+ * ── 원천이 판정하지 않으면 커널이 판정한다 ──────────────────────────
384
+ * 값이 있고 기준이 선언돼 있는데 판정이 없으면 화면은 침묵하고, 그 침묵은 「이상 없음」과
385
+ * 구별되지 않는다. 그래서 `judgeAgainstSpec` 으로 채우고 **`derived` 를 세운다** — 「현장이
386
+ * 판정했다」와 「트윈이 계산했다」가 섞이면 그 기록은 사고 뒤에 쓸 수 없다.
387
+ * 판정할 수 없으면 비워 둔다(모름을 합격으로 만들지 않는다).
388
+ */
389
+ const d = e.data;
390
+ if (!d?.testableObjectId || !d?.specId)
391
+ break;
392
+ if (this.stale(`test:${d.testableObjectId}:${d.specId}`, e))
393
+ return;
394
+ const item = this.items.get(d.testableObjectId);
395
+ if (!item)
396
+ break;
397
+ const { testableObjectId: _target, ...rest } = d;
398
+ const judged = rest.result === undefined ? judgeAgainstSpec(this.testSpecs.get(d.specId), rest) : undefined;
399
+ const next = {
400
+ ...rest,
401
+ ...(judged ? { result: judged, derived: true } : {})
402
+ };
403
+ const kept = (item.testResults ?? []).filter(r => r.specId !== d.specId);
404
+ item.testResults = [...kept, next];
405
+ break;
406
+ }
342
407
  case OP_EVENT.attentionAck: {
343
408
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
344
409
  const d = e.data;
@@ -747,6 +812,8 @@ export class ObservedReducer {
747
812
  */
748
813
  serialize() {
749
814
  return {
815
+ /* 관측도 재개점에 든다 — 없으면 이어 접기가 그 축을 0부터 다시 만든다. */
816
+ observations: [...this.observations.entries()].map(([id, bin]) => ({ id, values: [...bin.values()] })),
750
817
  revision: this.revision,
751
818
  master: [...this.master.values()].map(n => ({ ...n })),
752
819
  items: [...this.items.values()].map(i => ({ ...i })),
@@ -771,6 +838,7 @@ export class ObservedReducer {
771
838
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
772
839
  */
773
840
  restore(cp) {
841
+ this.observations = new Map((cp?.observations ?? []).map(o => [o.id, new Map((o.values ?? []).map(v => [v.propertyId, { ...v }]))]));
774
842
  this.revision = cp?.revision ?? 0;
775
843
  this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
776
844
  this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
@@ -807,6 +875,11 @@ export class ObservedReducer {
807
875
  ...(n.capacity === undefined ? {} : { capacity: n.capacity }),
808
876
  ...(n.parallelism === undefined ? {} : { parallelism: n.parallelism }),
809
877
  ...(n.parentId ? { parentId: n.parentId } : {}),
878
+ /* 관측된 물리량 — 없으면 **키를 만들지 않는다**(빈 배열은 「센서가 없다」와 「못 들었다」를 같게 만든다). */
879
+ ...(() => {
880
+ const bin = this.observations.get(n.id);
881
+ return bin?.size ? { observations: [...bin.values()] } : {};
882
+ })(),
810
883
  origin: n.origin
811
884
  };
812
885
  }),