@operato/twin-kernel 0.1.0 → 0.2.1

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.
@@ -5,6 +5,14 @@ export interface ForecastTwin {
5
5
  tick(dtMs: number): void;
6
6
  scenario: ScenarioControl;
7
7
  fork(): ForecastTwin;
8
+ /**
9
+ * 시뮬 시각(ms) — **구동 루프가 시각만 읽을 때 쓰는 값.**
10
+ *
11
+ * 없으면 `getSnapshot().simClockMs` 로 떨어지지만, 그 경로는 전 노드·물품·자원·작업·오더를 새로
12
+ * 재료화하고 주목 신호까지 계산한 뒤 숫자 하나만 꺼내 버린다 — tick 마다 그러면 상태 크기 × 지평선
13
+ * 길이만큼 낭비가 쌓인다(물품 1만 건·30분 지평선에서 측정: 루프 조건에만 149ms vs 0ms).
14
+ */
15
+ clockMs?: number;
8
16
  }
9
17
  export interface MonteCarloResult {
10
18
  runs: number;
package/dist/forecast.js CHANGED
@@ -5,12 +5,19 @@
5
5
  * 관심 지표를 분포로 준다: "언제 끝나?"가 아니라 "P50/P90 완료시각·재고소진 확률".
6
6
  * fork(현재예측) + 시나리오 재시드로 미래만 변주 — 현재 상태(재고·오더·태스크)는 보존.
7
7
  */
8
+ /**
9
+ * 구동 루프용 시각 읽기 — **숫자 하나 때문에 전체 상태를 만들지 않는다.**
10
+ * 커널은 `clockMs` 를 공개하므로 그것을 쓰고, 그것이 없는 외부 구현만 스냅샷으로 떨어진다.
11
+ */
12
+ function clockOf(twin) {
13
+ return typeof twin.clockMs === 'number' ? twin.clockMs : twin.getSnapshot().simClockMs;
14
+ }
8
15
  /**
9
16
  * 현재 상태에서 N개 확률적 미래를 표본화 → 지표 분포.
10
17
  * run 마다 fork(현재 보존) + scenario.load(seed+i)(미래 변주) + horizon 까지 구동. 원본 무간섭.
11
18
  */
12
19
  export function monteCarloForecast(twin, opts) {
13
- const now = twin.getSnapshot().simClockMs;
20
+ const now = clockOf(twin);
14
21
  const step = opts.tickMs ?? 1000;
15
22
  const baseSeed = opts.scenario.seed ?? 1;
16
23
  const samples = [];
@@ -20,7 +27,7 @@ export function monteCarloForecast(twin, opts) {
20
27
  fc.scenario.start();
21
28
  const target = now + opts.horizonMs;
22
29
  let guard = 0;
23
- while (fc.getSnapshot().simClockMs < target && guard++ < 1_000_000)
30
+ while (clockOf(fc) < target && guard++ < 1_000_000)
24
31
  fc.tick(step);
25
32
  samples.push(opts.metric(fc.getSnapshot()));
26
33
  }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export * from './capability.ts';
11
11
  export * from './domain-catalog.ts';
12
12
  export * from './allocation-policy.ts';
13
13
  export * from './duration-estimator.ts';
14
+ export * from './iso-duration.ts';
15
+ export * from './observed-reducer.ts';
14
16
  export * from './state-projector.ts';
15
17
  export * from './task-fold.ts';
16
18
  export * from './face2-adapter.ts';
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ export * from "./capability.js";
11
11
  export * from "./domain-catalog.js";
12
12
  export * from "./allocation-policy.js";
13
13
  export * from "./duration-estimator.js";
14
+ export * from "./iso-duration.js";
15
+ export * from "./observed-reducer.js";
14
16
  export * from "./state-projector.js";
15
17
  export * from "./task-fold.js";
16
18
  export * from "./face2-adapter.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ISO 8601 기간 → 밀리초. 해석할 수 없으면 **undefined**(0 이 아니다 — "0초" 와 "모른다" 는 다르다).
3
+ * 연·월(`Y`·`P…M` 의 날짜부 M)은 달력 의존이라 거부한다.
4
+ */
5
+ export declare function parseIsoDuration(text?: string): number | undefined;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * ISO 8601 기간 파서 — **표준 표기를 그대로 받기 위한 최소 도구.**
3
+ *
4
+ * ISA-95 의 `OperationsSegment.Duration` 은 `xsd:duration` 이다(B2MML `DurationType` =
5
+ * `<xsd:restriction base="xsd:duration"/>`). 그래서 명세가 말하는 소요시간은 `PT12M`·`PT1H15M`
6
+ * 같은 문자열로 온다 — 우리가 숫자 밀리초로 바꿔 부르는 순간 표준과 어긋나므로, **계약은 표준 표기로
7
+ * 받고 여기서 한 번만 해석한다**(소비처가 문자열을 자르지 않게).
8
+ *
9
+ * 지원 범위: `PnYnMnWnDTnHnMnS` 의 주·일·시·분·초(소수 초 포함). **연·월은 거부한다** —
10
+ * 길이가 달력에 따라 달라져 밀리초로 확정할 수 없다(28~31일). 모르면 꾸미지 않고 undefined 를 낸다.
11
+ */
12
+ const RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
13
+ /**
14
+ * ISO 8601 기간 → 밀리초. 해석할 수 없으면 **undefined**(0 이 아니다 — "0초" 와 "모른다" 는 다르다).
15
+ * 연·월(`Y`·`P…M` 의 날짜부 M)은 달력 의존이라 거부한다.
16
+ */
17
+ export function parseIsoDuration(text) {
18
+ if (typeof text !== 'string')
19
+ return undefined;
20
+ const s = text.trim();
21
+ if (!s || s === 'P' || s === 'PT')
22
+ return undefined;
23
+ if (/\d+Y/.test(s))
24
+ return undefined; // 연 — 달력 의존
25
+ /* 날짜부의 M(월)과 시간부의 M(분)을 구별한다: T 앞의 M 은 월이므로 거부. */
26
+ const tIdx = s.indexOf('T');
27
+ const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
28
+ if (/\d+M/.test(datePart))
29
+ return undefined; // 월 — 달력 의존
30
+ const m = RE.exec(s);
31
+ if (!m)
32
+ return undefined;
33
+ const [, sign, w, d, h, min, sec] = m;
34
+ if (!w && !d && !h && !min && !sec)
35
+ return undefined;
36
+ const ms = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 86_400_000 +
37
+ Number(h ?? 0) * 3_600_000 +
38
+ Number(min ?? 0) * 60_000 +
39
+ Number(sec ?? 0) * 1000;
40
+ if (!Number.isFinite(ms))
41
+ return undefined;
42
+ return sign ? -ms : ms;
43
+ }
package/dist/kernel.js CHANGED
@@ -9,7 +9,7 @@ import { CMD } from "./contract.js";
9
9
  import { firstFitPolicy } from "./allocation-policy.js";
10
10
  import { FlowEngine } from "./flow-engine.js";
11
11
  import { BIZSTEP, BTT } from "./wms-profile.js";
12
- import { DISP, aggregationEvent, gdtiUri, objectEvent, ssccUri, transactionEvent } from "./epcis.js";
12
+ import { DISP, ILMD_ATTR, aggregationEvent, gdtiUri, objectEvent, ssccUri, transactionEvent } from "./epcis.js";
13
13
  const TRAVEL_MS = 30_000;
14
14
  const COMPANY_PREFIX = '0614141';
15
15
  const SHELF_MS = 30 * 24 * 3_600_000; // 기본 유통기한(30일)
@@ -33,11 +33,20 @@ export class WmsKernel extends FlowEngine {
33
33
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
34
34
  // 로트 만료(FEFO 용, 결정적 — rng 무소비로 byte-identical 유지). 편차로 도착순≠만료순 → FEFO 가 유의미.
35
35
  const expiry = this.clockMs + SHELF_MS - (this.epcSeq % 5) * SHELF_JITTER_MS;
36
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry });
36
+ /* 방출한 마스터데이터를 **상태에도 들고 있는다** 자기가 선언한 것을 자기가 모르면, 미러는 알고
37
+ * 시뮬은 모르는 비대칭이 생긴다(파리티 테스트가 잡은 바로 그 종류). */
38
+ const ilmd = { [ILMD_ATTR.expiry]: expiry };
39
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
37
40
  dock.occupancy++;
38
41
  this.emit(transactionEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
39
42
  this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
40
- this.emit(objectEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, disposition: DISP.in_progress, epcList: [epc], quantityList: qtyList, readPoint: dock.id, bizLocation: dock.id, bizTransactionList: poTxn }));
43
+ /* 만료는 팔레트가 **생겨나는 순간** 정해지는 태생 속성이라 개체·로트 마스터데이터로 싣는다
44
+ * (§7.3.8: ObjectEvent action=ADD 에만 허용). 예전에는 커널 내부 상태에만 두어 미러가 알 수 없었다. */
45
+ this.emit(objectEvent({
46
+ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, disposition: DISP.in_progress,
47
+ epcList: [epc], quantityList: qtyList, readPoint: dock.id, bizLocation: dock.id, bizTransactionList: poTxn,
48
+ ilmd
49
+ }));
41
50
  const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews('storage') });
42
51
  if (!binId)
43
52
  return; // 수용 불가 → 도크 대기
@@ -109,13 +118,15 @@ export class WmsKernel extends FlowEngine {
109
118
  .map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
110
119
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
111
120
  for (const epc of chosen) {
112
- this.items.get(epc).disposition = DISP.reserved;
113
121
  o.allocated.push(epc);
114
122
  chosenAll.push(epc);
115
123
  }
116
124
  }
117
125
  if (chosenAll.length === 0)
118
126
  return;
127
+ /* 할당은 아직 집은 것이 아니다 — 물건은 보관 자리에 있고 처분만 '예약' 으로 바뀐다.
128
+ * 그래서 단계는 storing 이다(피킹 관측은 실제로 집을 때 따로 난다). */
129
+ this.reserve(chosenAll, BIZSTEP.storing);
119
130
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
120
131
  for (const epc of chosenAll) {
121
132
  const it = this.items.get(epc);
@@ -2,7 +2,7 @@ import type { GeneratorSpec, Command, CommandAck } from './contract.ts';
2
2
  import type { AllocationPolicy } from './allocation-policy.ts';
3
3
  import { FlowEngine } from './flow-engine.ts';
4
4
  import type { FlowOrder, FlowTask } from './flow-engine.ts';
5
- import type { DomainDefinition } from './domain-definition.ts';
5
+ import { type DomainDefinition } from './domain-definition.ts';
6
6
  /** 편의 — 시나리오 skuMix 로 쓸 부품 클래스. */
7
7
  export declare const MES_PART_GTINS: {
8
8
  partA: string;
@@ -40,7 +40,7 @@ export declare class MesKernel extends FlowEngine {
40
40
  /**
41
41
  * MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
42
42
  * 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
43
- * 이미 그 제품이면 no-op, 아니면 셋업(SETUP_MS, OEE 가용성 손실) + lastChangeoverKey 각인
43
+ * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
44
44
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
45
45
  */
46
46
  protected handleCommand(cmd: Command): CommandAck;
@@ -12,14 +12,18 @@ import { firstFitPolicy } from "./allocation-policy.js";
12
12
  import { FlowEngine } from "./flow-engine.js";
13
13
  import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass } from "./epcis.js";
14
14
  import { MES_BIZSTEP, BTT_PRODORDER, sgtinUri } from "./mes-profile.js";
15
- const CYCLE_MS = 40_000;
16
- const SETUP_MS = 15_000; // 체인지오버(제품 전환) 셋업 OEE 가용성 손실
15
+ import { OP_PARAM } from "./domain-definition.js";
16
+ /* 아래 셋은 **기본값**이다 명세(OperationDef.duration·parameters) 있으면 값이 이긴다.
17
+ * 이름에 DEFAULT 를 붙인 이유: 예전에는 이것이 유일한 값이어서 "현장 명세" 와 구별되지 않았고,
18
+ * 그 상수가 수율 판단(주목 신호)까지 만들어 냈다. 무엇을 기본값으로 굴렸는지는 specCoverage() 가 밝힌다. */
19
+ const DEFAULT_CYCLE_MS = 40_000;
20
+ const DEFAULT_SETUP_MS = 15_000; // 체인지오버(제품 전환) 셋업 — OEE 가용성 손실
17
21
  // MES 도메인 커맨드(Tier 2, 무방언 — MES 어휘는 MES 커널 소유). handleCommand 로 처리.
18
22
  const MES_CMD = { changeover: 'mes.changeover' };
19
23
  const CP = '0614141';
20
24
  const WIP_ITEMREF = '066666';
21
25
  const WIP_GTIN = sgtinClass(CP, WIP_ITEMREF);
22
- const YIELD = 0.8; // 양품률 — 나머지는 불량(non_sellable) OEE 품질 손실
26
+ const DEFAULT_YIELD = 0.8; // 양품률 기본값 현장 값은 OperationDef.parameters(OP_PARAM.yield) 온다
23
27
  // 부품(공용) — BOM 라인의 품목 클래스.
24
28
  const PART_A = { itemRef: '055551', gtin: sgtinClass(CP, '055551') };
25
29
  const PART_B = { itemRef: '055552', gtin: sgtinClass(CP, '055552') };
@@ -53,12 +57,15 @@ export class MesKernel extends FlowEngine {
53
57
  constructor(tenantId, policy = firstFitPolicy, mesSpec) {
54
58
  super(tenantId, policy);
55
59
  this.mesSpec = mesSpec;
60
+ /* 정의가 있으면 그 안의 오퍼레이션 명세(소요·변동·모수)를 커널이 소비한다 — 없으면 기본값 경로. */
61
+ if (mesSpec?.definition?.operations)
62
+ this.loadOperations(mesSpec.definition.operations);
56
63
  }
57
64
  productOf(gtin) { return PRODUCTS.find(p => p.gtin === gtin); }
58
65
  /**
59
66
  * MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
60
67
  * 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
61
- * 이미 그 제품이면 no-op, 아니면 셋업(SETUP_MS, OEE 가용성 손실) + lastChangeoverKey 각인
68
+ * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
62
69
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
63
70
  */
64
71
  handleCommand(cmd) {
@@ -70,7 +77,9 @@ export class MesKernel extends FlowEngine {
70
77
  if (!m)
71
78
  return { commandId: cmd.commandId, accepted: false, errorCode: 'resource-not-found', errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
72
79
  if (m.lastChangeoverKey !== a.gtin) {
73
- m.setupMs += SETUP_MS; // 셋업 = OEE 가용성 손실
80
+ /* 수동 전환은 특정 오퍼레이션이 아니라 설비 대상이라 작업별 명세를 고를 수 없다 → 기본값.
81
+ * 오퍼레이션별 셋업 명세는 작업 생성 경로(emitStation*)가 소비한다. */
82
+ m.setupMs += DEFAULT_SETUP_MS; // 셋업 = OEE 가용성 손실
74
83
  m.lastChangeoverKey = a.gtin;
75
84
  this.emitMover(m);
76
85
  }
@@ -125,10 +134,9 @@ export class MesKernel extends FlowEngine {
125
134
  return; // 부품 부족 → 대기
126
135
  picks.push(...chosen);
127
136
  }
128
- for (const epc of picks) {
129
- this.items.get(epc).disposition = DISP.reserved;
137
+ for (const epc of picks)
130
138
  o.allocated.push(epc);
131
- }
139
+ this.reserve(picks, MES_BIZSTEP.producing);
132
140
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
133
141
  this.emitStation(o, s0, o.allocated[0], product.gtin);
134
142
  o.status = 'op-' + s0.kind;
@@ -137,7 +145,7 @@ export class MesKernel extends FlowEngine {
137
145
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
138
146
  emitStation(o, stage, itemEpc, changeoverKey) {
139
147
  const node = this.nodeByType(stage.node);
140
- const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.id, resourceKind: stage.resource }, CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: SETUP_MS, intent: 'process' };
148
+ const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.id, resourceKind: stage.resource }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: this.paramDuration(stage.kind, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: 'process' };
141
149
  this.tasks.set(task.id, task);
142
150
  this.emitTask(task);
143
151
  }
@@ -165,7 +173,7 @@ export class MesKernel extends FlowEngine {
165
173
  // 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
166
174
  const fgStore = this.nodeByType('fg-store');
167
175
  const wip = order.allocated[0];
168
- const good = this.rng() < YIELD;
176
+ const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
169
177
  this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
170
178
  const disp = good ? DISP.sellable : DISP.non_sellable;
171
179
  const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
@@ -234,10 +242,9 @@ export class MesKernel extends FlowEngine {
234
242
  return; // 자재 부족 → 대기
235
243
  picks.push(...chosen);
236
244
  }
237
- for (const epc of picks) {
238
- this.items.get(epc).disposition = DISP.reserved;
245
+ for (const epc of picks)
239
246
  o.allocated.push(epc);
240
- }
247
+ this.reserve(picks, MES_BIZSTEP.producing);
241
248
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
242
249
  this.emitStationDef(o, ops[0], o.allocated[0]);
243
250
  o.status = 'op-' + ops[0].key;
@@ -245,7 +252,7 @@ export class MesKernel extends FlowEngine {
245
252
  }
246
253
  emitStationDef(o, op, itemEpc) {
247
254
  const node = this.nodeByType(op.nodeType);
248
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: SETUP_MS, intent: op.intent };
255
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
249
256
  this.tasks.set(task.id, task);
250
257
  this.emitTask(task);
251
258
  }
@@ -272,7 +279,7 @@ export class MesKernel extends FlowEngine {
272
279
  }
273
280
  const fgStore = this.nodeByType('fg-store');
274
281
  const wip = order.allocated[0];
275
- const good = this.rng() < YIELD;
282
+ const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
276
283
  this.recordOutput(t.resource, good);
277
284
  const disp = good ? DISP.sellable : DISP.non_sellable;
278
285
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
@@ -0,0 +1,103 @@
1
+ import type { AssetState, BoardDef, CanonicalEnvelope, NodeState, ItemState, MoverState, PersonState, TaskState, OrderState } from './contract.ts';
2
+ /**
3
+ * 마스터 동기 — 선언적 로케이션 upsert/remove.
4
+ *
5
+ * 구조(로케이션·용량·구역 소속)는 이벤트가 말해 주는 것이 아니라 **원 시스템의 마스터**에서 온다.
6
+ * 최초 생성은 Face2 마스터 인제스트(host `ingestMaster`)가 하고, 그 뒤 현장이 바뀐 것(랙 증설·구역
7
+ * 재편·용량 변경)은 이 경로로 **기동 중에도** 반영된다 — 없으면 재기동해야 구조가 갱신된다.
8
+ */
9
+ export interface MasterUpdate {
10
+ op: 'upsert' | 'remove';
11
+ node: {
12
+ id: string;
13
+ type?: string;
14
+ capacity?: number;
15
+ parallelism?: number;
16
+ parentId?: string;
17
+ };
18
+ }
19
+ export interface ProjectedState {
20
+ revision: number;
21
+ /**
22
+ * 받은 정정 선언들 — 상태에 반영하지 않은 것을 **밝힌다**.
23
+ * 비어 있지 않으면 "원 시스템이 정정을 보냈고 우리는 아직 반영하지 못했다" 는 뜻이다(조용한 무시 금지).
24
+ */
25
+ corrections?: {
26
+ declaredAt: string;
27
+ reason?: string;
28
+ correctiveEventIDs: string[];
29
+ eventID?: string;
30
+ }[];
31
+ nodes: NodeState[];
32
+ items: ItemState[];
33
+ /** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
34
+ persons: PersonState[];
35
+ /** 물리 자산(반복사용) — 자산을 선언하지 않은 트윈에서는 빈 배열. */
36
+ assets: AssetState[];
37
+ tasks: TaskState[];
38
+ movers: MoverState[];
39
+ orders: OrderState[];
40
+ }
41
+ export declare class ObservedReducer {
42
+ /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
43
+ private master;
44
+ private items;
45
+ private aggregation;
46
+ /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
47
+ private pendingParent;
48
+ private tasks;
49
+ private movers;
50
+ private persons;
51
+ private assets;
52
+ private orders;
53
+ revision: number;
54
+ /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
55
+ private corrections;
56
+ constructor(board: BoardDef);
57
+ /** 마스터 동기 — 로케이션 추가/변경/제거. */
58
+ applyMaster(u: MasterUpdate): void;
59
+ /**
60
+ * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
61
+ *
62
+ * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
63
+ * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
64
+ * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
65
+ * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
66
+ *
67
+ * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
68
+ * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
69
+ */
70
+ private touchLocation;
71
+ /**
72
+ * 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
73
+ *
74
+ * 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
75
+ * 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
76
+ * 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
77
+ *
78
+ * 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
79
+ * 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
80
+ */
81
+ private stale;
82
+ /** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
83
+ private lastAt;
84
+ /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
85
+ apply(e: CanonicalEnvelope): void;
86
+ private applyEpcis;
87
+ /**
88
+ * 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
89
+ * 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
90
+ */
91
+ /**
92
+ * 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
93
+ *
94
+ * 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
95
+ * `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
96
+ */
97
+ private expiryOf;
98
+ private mergeItem;
99
+ /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
100
+ private remove;
101
+ /** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
102
+ snapshot(): ProjectedState;
103
+ }