@operato/twin-kernel 0.3.0 → 0.4.0

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/index.d.ts CHANGED
@@ -15,11 +15,13 @@ export * from './iso-duration.ts';
15
15
  export * from './observed-reducer.ts';
16
16
  export * from './state-projector.ts';
17
17
  export * from './task-fold.ts';
18
+ export * from './job-response.ts';
18
19
  export * from './face2-adapter.ts';
19
20
  export * from './runtime.ts';
20
21
  export * from './yms-profile.ts';
21
22
  export * from './mes-profile.ts';
22
23
  export * from './flow-engine.ts';
24
+ export * from './capacity.ts';
23
25
  export { WmsKernel } from './kernel.ts';
24
26
  export { YmsKernel } from './yms-kernel.ts';
25
27
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
package/dist/index.js CHANGED
@@ -15,11 +15,13 @@ export * from "./iso-duration.js";
15
15
  export * from "./observed-reducer.js";
16
16
  export * from "./state-projector.js";
17
17
  export * from "./task-fold.js";
18
+ export * from "./job-response.js";
18
19
  export * from "./face2-adapter.js";
19
20
  export * from "./runtime.js";
20
21
  export * from "./yms-profile.js";
21
22
  export * from "./mes-profile.js";
22
23
  export * from "./flow-engine.js";
24
+ export * from "./capacity.js";
23
25
  export { WmsKernel } from "./kernel.js";
24
26
  export { YmsKernel } from "./yms-kernel.js";
25
27
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
@@ -0,0 +1,59 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ import { type TaskDeltaRow } from './task-fold.ts';
3
+ /** 표준 `JobState` — 우리 작업 상태를 표준 낱말로 옮긴다. */
4
+ export type JobState = 'ready' | 'running' | 'completed';
5
+ /** 자원 실적 한 줄 — 표준 `OpPersonnelActualType`/`OpEquipmentActualType` 의 우리 부분집합. */
6
+ export interface ResourceActual {
7
+ /** 표준 `PersonID`/`EquipmentID`. */
8
+ id: string;
9
+ /** 표준 `Quantity`. 우리는 개체를 지목하므로 언제나 1이다(등급 단위 집계는 소비처의 일). */
10
+ quantity: number;
11
+ }
12
+ /** 자재 실적 한 줄 — 표준 `OpMaterialActualType` 의 우리 부분집합. */
13
+ export interface MaterialActual {
14
+ /** 표준 `MaterialDefinitionID`. */
15
+ definitionId: string;
16
+ /** 표준 `MaterialUse`. */
17
+ use: 'consumed' | 'produced';
18
+ quantity: number;
19
+ uom?: string;
20
+ }
21
+ export interface JobResponse {
22
+ /** 표준 `ID` — 작업 식별자. */
23
+ id: string;
24
+ /** 표준 `JobOrderID` — 소속 오더. **없으면 없는 것이다**(다른 식별자로 대체하지 않는다). */
25
+ jobOrderId?: string;
26
+ /** 표준 `WorkType` — 작업 종류(우리 `kind`). */
27
+ workType?: string;
28
+ /** 표준 `JobState`. */
29
+ jobState: JobState;
30
+ /** 표준 `StartTime` — **실제** 착수. 착수 기록을 못 찾으면 없다(짐작하지 않는다). */
31
+ startTime?: ISOTime;
32
+ /** 표준 `EndTime` — **실제** 완료. */
33
+ endTime?: ISOTime;
34
+ /** 표준 `PersonnelActual`. */
35
+ personnelActual?: ResourceActual[];
36
+ /** 표준 `EquipmentActual`. */
37
+ equipmentActual?: ResourceActual[];
38
+ /**
39
+ * 표준 `PhysicalAssetActual` — **실제로 쓰인 반복사용 자산**(팔레트·용기 등).
40
+ *
41
+ * 작업이 이미 들고 있던 사실인데 실적에서 빠져 있었다. 빠지면 "그 출고에 팔레트 몇 장이 나갔나" 를
42
+ * 실적으로 답할 수 없고, 자산 회수 계획이 근거를 잃는다.
43
+ */
44
+ physicalAssetActual?: ResourceActual[];
45
+ /** 표준 `MaterialActual`. */
46
+ materialActual?: MaterialActual[];
47
+ }
48
+ /** 저널 한 줄에서 자원·자재 실적을 읽기 위한 최소 모양(작업 폴드가 보는 것과 같은 줄). */
49
+ type Row = TaskDeltaRow;
50
+ /**
51
+ * 저널을 접어 **작업별 실적 회신**을 만든다 — 표준 이름으로.
52
+ *
53
+ * 짝맞춤(어느 전이가 한 작업인가)은 **커널 규칙**(`foldTaskRecords`)을 그대로 쓴다. 여기서 다시 짝을
54
+ * 맞추면 같은 저널이 지표에서는 3건, 실적에서는 4건이 되는 일이 생긴다 — 실제로 겪은 부류다.
55
+ *
56
+ * 자원·자재 실적은 전이가 실어 온 값에서 모은다. **마지막에 본 값**이 사실이다(재전송이면 나중 것).
57
+ */
58
+ export declare function foldJobResponses(rows: readonly Row[]): JobResponse[];
59
+ export {};
@@ -0,0 +1,56 @@
1
+ import { foldTaskRecords } from "./task-fold.js";
2
+ const iso = (ms) => (typeof ms === 'number' ? new Date(ms).toISOString() : undefined);
3
+ const dataOf = (r) => {
4
+ const p = r.payload;
5
+ return r.data ?? p?.data ?? p ?? {};
6
+ };
7
+ /**
8
+ * 저널을 접어 **작업별 실적 회신**을 만든다 — 표준 이름으로.
9
+ *
10
+ * 짝맞춤(어느 전이가 한 작업인가)은 **커널 규칙**(`foldTaskRecords`)을 그대로 쓴다. 여기서 다시 짝을
11
+ * 맞추면 같은 저널이 지표에서는 3건, 실적에서는 4건이 되는 일이 생긴다 — 실제로 겪은 부류다.
12
+ *
13
+ * 자원·자재 실적은 전이가 실어 온 값에서 모은다. **마지막에 본 값**이 사실이다(재전송이면 나중 것).
14
+ */
15
+ export function foldJobResponses(rows) {
16
+ const { records } = foldTaskRecords(rows);
17
+ /* 자원·자재는 작업 폴드가 모으는 축(facets) 밖이라 여기서 한 번 더 훑는다 — 작업 폴드의 뜻을
18
+ 넓히지 않는다(그것은 "언제" 를 아는 모듈이고, 이것은 "무엇으로" 를 아는 모듈이다). */
19
+ const extra = new Map();
20
+ for (const r of rows) {
21
+ const d = dataOf(r);
22
+ const id = typeof d.taskId === 'string' ? d.taskId : undefined;
23
+ if (!id)
24
+ continue;
25
+ const cur = extra.get(id) ?? {};
26
+ if (Array.isArray(d.personnel))
27
+ cur.personnel = d.personnel;
28
+ /* 대표 자원 하나만 온 전이도 있다(`resourceRef`) — 함께 잡힌 목록이 있으면 그것이 더 완전하다. */
29
+ if (Array.isArray(d.resources))
30
+ cur.resources = d.resources;
31
+ else if (typeof d.resourceRef === 'string' && !cur.resources)
32
+ cur.resources = [d.resourceRef];
33
+ if (Array.isArray(d.assets))
34
+ cur.assets = d.assets;
35
+ if (Array.isArray(d.materialActual))
36
+ cur.material = d.materialActual;
37
+ extra.set(id, cur);
38
+ }
39
+ return records.map(rec => {
40
+ const x = extra.get(rec.taskId) ?? {};
41
+ const jobState = rec.completedMs !== undefined ? 'completed' : rec.startedMs !== undefined ? 'running' : 'ready';
42
+ const one = (ids) => ids?.length ? ids.map(id => ({ id, quantity: 1 })) : undefined;
43
+ return {
44
+ id: rec.taskId,
45
+ ...(rec.facets.order ? { jobOrderId: rec.facets.order } : {}),
46
+ ...(rec.facets.kind ? { workType: rec.facets.kind } : {}),
47
+ jobState,
48
+ ...(iso(rec.startedMs) ? { startTime: iso(rec.startedMs) } : {}),
49
+ ...(iso(rec.completedMs) ? { endTime: iso(rec.completedMs) } : {}),
50
+ ...(one(x.personnel) ? { personnelActual: one(x.personnel) } : {}),
51
+ ...(one(x.resources) ? { equipmentActual: one(x.resources) } : {}),
52
+ ...(one(x.assets) ? { physicalAssetActual: one(x.assets) } : {}),
53
+ ...(x.material?.length ? { materialActual: x.material.map(m => ({ ...m })) } : {})
54
+ };
55
+ });
56
+ }
package/dist/kernel.d.ts CHANGED
@@ -14,6 +14,11 @@ export declare class WmsKernel extends FlowEngine {
14
14
  protected onOrder(spec: GeneratorSpec): void;
15
15
  /** Command 채널 — 오더 즉시 투입(트랜잭션 프론트엔드/prescriptive). */
16
16
  protected handleCommand(cmd: Command): CommandAck;
17
+ /**
18
+ * 오더 생성 — 약속(`promised`)이 있으면 **납기와 우선순위를 함께 싣는다**(표준 `EndTime`·`Priority`).
19
+ * 없으면 싣지 않는다: 없는 약속을 만들면 그 뒤 모든 지연 판정이 거짓 위에 선다.
20
+ */
21
+ /** 오더 생성 — 약속(납기·우선순위)은 base `promiseOf` 가 계산한다(도메인마다 다르게 재지 않는다). */
17
22
  private createSalesOrder;
18
23
  /**
19
24
  * created 오더를 정책으로 할당 → SO TransactionEvent(라인 통합) + pick task(라인별).
package/dist/kernel.js CHANGED
@@ -26,6 +26,9 @@ export class WmsKernel extends FlowEngine {
26
26
  return;
27
27
  const epc = ssccUri(COMPANY_PREFIX, ++this.epcSeq); // 팔레트 SSCC
28
28
  const gtin = this.pickGtin(spec.content.skuMix); // SGTIN idpat = epcClass
29
+ /* 품목 구성이 비어 있으면 **도착을 만들지 않는다** — 무엇이 왔는지 말할 수 없는 입고는 사실이 아니다. */
30
+ if (!gtin)
31
+ return;
29
32
  const qty = this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max); // 케이스 수(비직렬)
30
33
  const po = gdtiUri(COMPANY_PREFIX, '401', ++this.poSeq);
31
34
  const eventTime = this.now();
@@ -62,7 +65,10 @@ export class WmsKernel extends FlowEngine {
62
65
  onOrder(spec) {
63
66
  const lpl = spec.content.linesPerOrder;
64
67
  if (!lpl) {
65
- this.createSalesOrder([{ gtin: this.pickGtin(spec.content.skuMix), qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) }]);
68
+ const only = this.pickGtin(spec.content.skuMix);
69
+ if (!only)
70
+ return; // 품목 구성이 없으면 오더도 없다(무엇을 달라는 오더인지 말할 수 없다)
71
+ this.createSalesOrder([{ gtin: only, qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) }], spec);
66
72
  return;
67
73
  }
68
74
  const n = Math.min(this.randInt(lpl.min, lpl.max), spec.content.skuMix.length);
@@ -70,10 +76,14 @@ export class WmsKernel extends FlowEngine {
70
76
  let pool = spec.content.skuMix;
71
77
  for (let i = 0; i < n && pool.length > 0; i++) {
72
78
  const gtin = this.pickGtin(pool);
79
+ if (!gtin)
80
+ break;
73
81
  lines.push({ gtin, qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) });
74
82
  pool = pool.filter(m => m.gtin !== gtin); // 라인 SKU 중복 방지(distinct)
75
83
  }
76
- this.createSalesOrder(lines);
84
+ if (!lines.length)
85
+ return; // 라인이 하나도 안 나오면 오더를 만들지 않는다
86
+ this.createSalesOrder(lines, spec);
77
87
  }
78
88
  /** Command 채널 — 오더 즉시 투입(트랜잭션 프론트엔드/prescriptive). */
79
89
  handleCommand(cmd) {
@@ -87,13 +97,19 @@ export class WmsKernel extends FlowEngine {
87
97
  }
88
98
  return super.handleCommand(cmd);
89
99
  }
90
- createSalesOrder(lines) {
100
+ /**
101
+ * 오더 생성 — 약속(`promised`)이 있으면 **납기와 우선순위를 함께 싣는다**(표준 `EndTime`·`Priority`).
102
+ * 없으면 싣지 않는다: 없는 약속을 만들면 그 뒤 모든 지연 판정이 거짓 위에 선다.
103
+ */
104
+ /** 오더 생성 — 약속(납기·우선순위)은 base `promiseOf` 가 계산한다(도메인마다 다르게 재지 않는다). */
105
+ createSalesOrder(lines, spec) {
91
106
  const id = `order-${++this.orderSeq}`;
92
107
  const so = gdtiUri(COMPANY_PREFIX, '402', ++this.soSeq);
93
108
  const requested = lines.reduce((s, l) => s + l.qty, 0);
94
109
  const order = {
95
110
  id, kind: 'outbound', status: 'created', requested, fulfilled: 0, bizTransaction: so,
96
- allocated: [], picked: [], shipmentEpc: null, lines: lines.map(l => ({ gtin: l.gtin, requested: l.qty }))
111
+ allocated: [], picked: [], shipmentEpc: null, lines: lines.map(l => ({ gtin: l.gtin, requested: l.qty })),
112
+ ...(spec ? this.promiseOf(spec) : {})
97
113
  };
98
114
  this.orders.set(id, order);
99
115
  this.emitOrder(order);
@@ -140,7 +156,11 @@ export class WmsKernel extends FlowEngine {
140
156
  }
141
157
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
142
158
  onTaskComplete(t) {
143
- const item = this.items.get(t.itemEpc);
159
+ const item = this.itemByRef(t.itemEpc);
160
+ /* 여기 도달했다면 코어가 이미 물품을 확인했다(주체가 사라진 작업은 완료 전에 접힌다).
161
+ 그래도 단정(`!`)은 쓰지 않는다 — 계약이 바뀌면 조용히 틀리는 대신 분명히 멈춘다. */
162
+ if (!item)
163
+ throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
144
164
  const from = this.locations.get(t.fromNode);
145
165
  const to = this.locations.get(t.toNode);
146
166
  from.occupancy--;
@@ -36,6 +36,19 @@ export declare class MesKernel extends FlowEngine {
36
36
  /** 정의-구동 모드(선택). 미지정 시 레거시 하드코딩 경로 — byte-identical. */
37
37
  private mesSpec?;
38
38
  constructor(tenantId: string, policy?: AllocationPolicy, mesSpec?: MesDefinitionSpec);
39
+ /**
40
+ * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
41
+ *
42
+ * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
43
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
44
+ * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
45
+ *
46
+ * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
47
+ * 대신 둘이 겹치면 **같은 산출을 두 번 만들게** 되므로, 그 조합을 기동 시점에 거부한다.
48
+ * 런타임에 조용히 두 배가 되는 것보다 기동이 실패하는 편이 낫다(재고가 거짓이 되면 그 위의
49
+ * 모든 계산이 거짓이 된다).
50
+ */
51
+ private assertNoDoubleProduction;
39
52
  private productOf;
40
53
  /**
41
54
  * MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
@@ -53,11 +66,26 @@ export declare class MesKernel extends FlowEngine {
53
66
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
54
67
  private emitStation;
55
68
  /** op 완료 = 변환. 라우트 인덱스로 분기: 첫=BOM 소비→WIP, 중간=WIP→WIP, 마지막(조립)=WIP→완성차(수율→OEE 품질). */
69
+ /**
70
+ * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
71
+ *
72
+ * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
73
+ * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
74
+ * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
75
+ */
76
+ protected canComplete(t: FlowTask): boolean;
56
77
  protected onTaskComplete(t: FlowTask): void;
57
78
  private recipeDef;
58
79
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
59
80
  private classOf;
60
81
  private serialOf;
82
+ /**
83
+ * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
84
+ *
85
+ * 순서를 모르면 선언 순서대로 세는데, 그러면 하류에서 잃는 몫이 엉뚱한 공정에 얹힌다(조용히
86
+ * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
87
+ */
88
+ protected routeKeys(): string[] | undefined;
61
89
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
62
90
  private routeOps;
63
91
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 raw-store 에 생성. */
@@ -58,8 +58,29 @@ export class MesKernel extends FlowEngine {
58
58
  super(tenantId, policy);
59
59
  this.mesSpec = mesSpec;
60
60
  /* 정의가 있으면 그 안의 오퍼레이션 명세(소요·변동·모수)를 커널이 소비한다 — 없으면 기본값 경로. */
61
- if (mesSpec?.definition?.operations)
61
+ if (mesSpec?.definition?.operations) {
62
62
  this.loadOperations(mesSpec.definition.operations);
63
+ this.assertNoDoubleProduction(mesSpec.definition.operations);
64
+ }
65
+ }
66
+ /**
67
+ * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
68
+ *
69
+ * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
70
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
71
+ * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
72
+ *
73
+ * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
74
+ * 대신 둘이 겹치면 **같은 산출을 두 번 만들게** 되므로, 그 조합을 기동 시점에 거부한다.
75
+ * 런타임에 조용히 두 배가 되는 것보다 기동이 실패하는 편이 낫다(재고가 거짓이 되면 그 위의
76
+ * 모든 계산이 거짓이 된다).
77
+ */
78
+ assertNoDoubleProduction(ops) {
79
+ const bad = ops.filter(o => (o.materialSpecification ?? []).some(m => m.use === 'produced')).map(o => o.key);
80
+ if (!bad.length)
81
+ return;
82
+ throw new Error(`MES recipe already produces outputs — operations [${bad.join(', ')}] must not also declare materialSpecification use:'produced' ` +
83
+ '(that would create the same output twice). Consumption specs are fine; declare production in the recipe.');
63
84
  }
64
85
  productOf(gtin) { return PRODUCTS.find(p => p.gtin === gtin); }
65
86
  /**
@@ -110,7 +131,7 @@ export class MesKernel extends FlowEngine {
110
131
  const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
111
132
  const id = `order-${++this.orderSeq}`;
112
133
  const wo = gdtiUri(CP, '403', ++this.soSeq);
113
- const order = { id, kind: 'workorder', status: 'created', gtin: product.gtin, requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [] };
134
+ const order = { id, kind: 'workorder', status: 'created', gtin: product.gtin, requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
114
135
  this.orders.set(id, order);
115
136
  this.emitOrder(order);
116
137
  }
@@ -150,11 +171,27 @@ export class MesKernel extends FlowEngine {
150
171
  this.emitTask(task);
151
172
  }
152
173
  /** op 완료 = 변환. 라우트 인덱스로 분기: 첫=BOM 소비→WIP, 중간=WIP→WIP, 마지막(조립)=WIP→완성차(수율→OEE 품질). */
174
+ /**
175
+ * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
176
+ *
177
+ * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
178
+ * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
179
+ * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
180
+ */
181
+ canComplete(t) {
182
+ if (this.mesSpec)
183
+ return !!(t.orderId && this.orders.get(t.orderId));
184
+ const order = t.orderId ? this.orders.get(t.orderId) : undefined;
185
+ return !!order && !!this.productOf(order.gtin);
186
+ }
153
187
  onTaskComplete(t) {
154
188
  if (this.mesSpec)
155
189
  return this.onTaskCompleteDef(t);
190
+ /* 여기 도달했다면 `canComplete` 가 이미 확인했다 — 그래도 단정(`!`)은 쓰지 않는다. */
156
191
  const order = this.orders.get(t.orderId);
157
- const product = this.productOf(order.gtin);
192
+ const product = order && this.productOf(order.gtin);
193
+ if (!order || !product)
194
+ throw new Error(`task ${t.id}: order/product vanished between the core check and the domain hook`);
158
195
  const i = ROUTE.findIndex(s => s.kind === t.kind);
159
196
  const loc = this.locations.get(t.toNode);
160
197
  const isLast = i === ROUTE.length - 1;
@@ -196,6 +233,18 @@ export class MesKernel extends FlowEngine {
196
233
  serialOf(materialKey, serial) {
197
234
  return sgtinUri(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey], serial);
198
235
  }
236
+ /**
237
+ * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
238
+ *
239
+ * 순서를 모르면 선언 순서대로 세는데, 그러면 하류에서 잃는 몫이 엉뚱한 공정에 얹힌다(조용히
240
+ * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
241
+ */
242
+ routeKeys() {
243
+ if (!this.mesSpec)
244
+ return undefined;
245
+ const d = this.mesSpec.definition;
246
+ return d.routes?.find(r => r.key === this.recipeDef().route)?.steps;
247
+ }
199
248
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
200
249
  routeOps() {
201
250
  const d = this.mesSpec.definition;
@@ -208,6 +257,9 @@ export class MesKernel extends FlowEngine {
208
257
  if (!rawStore)
209
258
  return;
210
259
  const gtin = this.pickGtin(spec.content.skuMix);
260
+ /* 품목 구성이 비어 있으면 도착을 만들지 않는다 — 무엇이 왔는지 말할 수 없는 입고는 사실이 아니다. */
261
+ if (!gtin)
262
+ return;
211
263
  const inputKey = this.recipeDef().inputs.map(i => i.material).find(k => this.classOf(k) === gtin);
212
264
  if (!inputKey)
213
265
  return;
@@ -221,7 +273,7 @@ export class MesKernel extends FlowEngine {
221
273
  const rc = this.recipeDef();
222
274
  const id = `order-${++this.orderSeq}`;
223
275
  const wo = gdtiUri(this.mesSpec.companyPrefix, '403', ++this.soSeq);
224
- const order = { id, kind: 'workorder', status: 'created', gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [] };
276
+ const order = { id, kind: 'workorder', status: 'created', gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
225
277
  this.orders.set(id, order);
226
278
  this.emitOrder(order);
227
279
  }
@@ -259,6 +311,8 @@ export class MesKernel extends FlowEngine {
259
311
  /** 정의 모드 완료 — 라우트 인덱스: 중간=WIP 변환+다음 스텝, 마지막=완제품(수율). */
260
312
  onTaskCompleteDef(t) {
261
313
  const order = this.orders.get(t.orderId);
314
+ if (!order)
315
+ throw new Error(`task ${t.id}: order vanished between the core check and the domain hook`);
262
316
  const rc = this.recipeDef();
263
317
  const ops = this.routeOps();
264
318
  const i = ops.findIndex(s => s.key === t.kind);
@@ -53,7 +53,41 @@ export declare class ObservedReducer {
53
53
  revision: number;
54
54
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
55
55
  private corrections;
56
+ /**
57
+ * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
58
+ *
59
+ * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
60
+ * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
61
+ */
62
+ private shifts;
63
+ /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
64
+ private utcOffsetMinutes?;
56
65
  constructor(board: BoardDef);
66
+ /**
67
+ * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
68
+ *
69
+ * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
70
+ * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
71
+ * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
72
+ *
73
+ * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
74
+ * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
75
+ *
76
+ * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
77
+ * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
78
+ * **사라진 자원은 버린다** — 없어진 설비의 상태를 계속 들고 있으면 화면이 없는 설비를 그린다.
79
+ * 다만 **몇 개를 버렸는지 돌려준다.** 조용히 사라지면 사용자는 수가 줄어든 것을 눈치채지 못한다.
80
+ *
81
+ * 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
82
+ * 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
83
+ */
84
+ adoptStructure(board: BoardDef): {
85
+ locationsAdded: number;
86
+ locationsDropped: number;
87
+ equipmentDropped: number;
88
+ personsDropped: number;
89
+ assetsDropped: number;
90
+ };
57
91
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
58
92
  applyMaster(u: MasterUpdate): void;
59
93
  /**
@@ -81,6 +115,14 @@ export declare class ObservedReducer {
81
115
  private stale;
82
116
  /** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
83
117
  private lastAt;
118
+ /**
119
+ * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
120
+ *
121
+ * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
122
+ * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
123
+ * 판정하지 않는다(모르면 단정하지 않는다).
124
+ */
125
+ private observedAtMs?;
84
126
  /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
85
127
  apply(e: CanonicalEnvelope): void;
86
128
  private applyEpcis;
@@ -99,5 +141,24 @@ export declare class ObservedReducer {
99
141
  /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
100
142
  private remove;
101
143
  /** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
144
+ /**
145
+ * 마지막으로 들은 발생 시각(ms) — **관측 구동의 "지금".**
146
+ *
147
+ * 시각으로만 바뀌는 사실(납기 초과·유효 기간·교대)을 판정하려면 기준이 필요하고, 관측 모드에는
148
+ * 굴러가는 시계가 없다. 정직한 기준은 "내가 마지막으로 들은 시점" 이다. 아무것도 못 들었으면
149
+ * `undefined` — 없는 기준으로 단정하지 않는다.
150
+ */
151
+ get lastObservedMs(): number | undefined;
152
+ /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
153
+ private rememberShift;
154
+ /**
155
+ * 지금 근무 시간 밖인가 — 시뮬과 **같은 함수**(`offCalendarAt`)를 미러의 시각으로 부른다.
156
+ *
157
+ * 판정을 델타로 받지 않는 이유는 유효 기간과 같다: 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로,
158
+ * 유휴 상태로 경계를 지난 자원은 실어 보낸 옛 판정에 머문다. 아무것도 못 들었으면 판정하지 않는다.
159
+ */
160
+ private offShiftPart;
161
+ /** 유효 기간 밖이면 그 이유를 붙인다 — 미러의 시각 기준은 `observedAtMs`(마지막으로 들은 시점). */
162
+ private effectivityPart;
102
163
  snapshot(): ProjectedState;
103
164
  }