@operato/twin-kernel 0.2.3 → 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.
@@ -8,7 +8,7 @@ export interface ForecastTwin {
8
8
  /**
9
9
  * 시뮬 시각(ms) — **구동 루프가 시각만 읽을 때 쓰는 값.**
10
10
  *
11
- * 없으면 `getSnapshot().simClockMs` 로 떨어지지만, 그 경로는 전 노드·물품·자원·작업·오더를 새로
11
+ * 없으면 `getSnapshot().simClockMs` 로 떨어지지만, 그 경로는 전 자리·물품·자원·작업·오더를 새로
12
12
  * 재료화하고 주목 신호까지 계산한 뒤 숫자 하나만 꺼내 버린다 — tick 마다 그러면 상태 크기 × 지평선
13
13
  * 길이만큼 낭비가 쌓인다(물품 1만 건·30분 지평선에서 측정: 루프 조건에만 149ms vs 0ms).
14
14
  */
package/dist/index.d.ts CHANGED
@@ -15,12 +15,15 @@ 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';
26
28
  export type { MesDefinitionSpec } from './mes-kernel.ts';
29
+ export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -15,11 +15,14 @@ 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";
28
+ export * from "./vocabulary.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
@@ -21,11 +21,14 @@ export class WmsKernel extends FlowEngine {
21
21
  }
22
22
  /** 입고 도착 — §4 라이프사이클: ASN(PO) → 팔레트 조립 → 수령 → putaway task. */
23
23
  onArrival(spec) {
24
- const dock = this.nodeByType('dock');
24
+ const dock = this.locationByType('dock');
25
25
  if (!dock)
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);
@@ -104,7 +120,7 @@ export class WmsKernel extends FlowEngine {
104
120
  * 멀티SKU 는 여러 라인의 팔레트를 한 오더로 모아 단일 출하(finalizeOrder 통합 화물).
105
121
  */
106
122
  allocate(o) {
107
- const staging = this.nodeByType('staging');
123
+ const staging = this.locationByType('staging');
108
124
  if (!staging || !o.lines)
109
125
  return;
110
126
  const chosenAll = [];
@@ -114,7 +130,7 @@ export class WmsKernel extends FlowEngine {
114
130
  if (need <= 0)
115
131
  continue;
116
132
  const available = [...this.items.values()]
117
- .filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'storage')
133
+ .filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'storage')
118
134
  .map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
119
135
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
120
136
  for (const epc of chosen) {
@@ -140,9 +156,13 @@ 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);
144
- const from = this.nodes.get(t.fromNode);
145
- const to = this.nodes.get(t.toNode);
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`);
164
+ const from = this.locations.get(t.fromNode);
165
+ const to = this.locations.get(t.toNode);
146
166
  from.occupancy--;
147
167
  to.occupancy++;
148
168
  item.location = to.id;
@@ -163,7 +183,7 @@ export class WmsKernel extends FlowEngine {
163
183
  }
164
184
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
165
185
  finalizeOrder(order, staging) {
166
- const shipDock = this.nodeByType('dock-ship') ?? staging;
186
+ const shipDock = this.locationByType('dock-ship') ?? staging;
167
187
  const eventTime = this.now();
168
188
  const shipment = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
169
189
  order.shipmentEpc = shipment;
@@ -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 에 생성. */
@@ -6,7 +6,7 @@
6
6
  * ① BOM = 제품별 다품목 레시피(P1=2A+1B / P2=1A+2B — 상이 → 체인지오버 유발).
7
7
  * ② 수율 = 일부 불량(non_sellable) → OEE Quality.
8
8
  * ③ 셋업/체인지오버 = work-center 가 제품 전환 시 셋업(changeoverKey=제품 gtin) → OEE Availability.
9
- * ④ OEE = base 가 무버(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
9
+ * ④ OEE = base 가 설비(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
10
10
  */
11
11
  import { firstFitPolicy } from "./allocation-policy.js";
12
12
  import { FlowEngine } from "./flow-engine.js";
@@ -44,10 +44,10 @@ export const MES_PRODUCTS = PRODUCTS.map(p => ({ gtin: p.gtin, label: p.key }));
44
44
  * 각 스테이션 = 이종 자원(resource) + 제품 전환 시 체인지오버 셋업.
45
45
  */
46
46
  const ROUTE = [
47
- { kind: 'cut', node: 'cut-station', resource: 'cutter' },
48
- { kind: 'weld', node: 'weld-station', resource: 'welder' },
49
- { kind: 'paint', node: 'paint-booth', resource: 'painter' },
50
- { kind: 'assembly', node: 'assembly-line', resource: 'assembler' }
47
+ { kind: 'cut', locationType: 'cut-station', resource: 'cutter' },
48
+ { kind: 'weld', locationType: 'weld-station', resource: 'welder' },
49
+ { kind: 'paint', locationType: 'paint-booth', resource: 'painter' },
50
+ { kind: 'assembly', locationType: 'assembly-line', resource: 'assembler' }
51
51
  ];
52
52
  export class MesKernel extends FlowEngine {
53
53
  wipSeq = 0;
@@ -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
  /**
@@ -73,7 +94,7 @@ export class MesKernel extends FlowEngine {
73
94
  const a = cmd.args;
74
95
  if (!a?.resourceId || !a?.gtin)
75
96
  return { commandId: cmd.commandId, accepted: false, errorCode: 'changeover-needs-args', error: 'mes.changeover: resourceId and gtin required' };
76
- const m = this.movers.get(a.resourceId);
97
+ const m = this.equipment.get(a.resourceId);
77
98
  if (!m)
78
99
  return { commandId: cmd.commandId, accepted: false, errorCode: 'resource-not-found', errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
79
100
  if (m.lastChangeoverKey !== a.gtin) {
@@ -81,7 +102,7 @@ export class MesKernel extends FlowEngine {
81
102
  * 오퍼레이션별 셋업 명세는 작업 생성 경로(emitStation*)가 소비한다. */
82
103
  m.setupMs += DEFAULT_SETUP_MS; // 셋업 = OEE 가용성 손실
83
104
  m.lastChangeoverKey = a.gtin;
84
- this.emitMover(m);
105
+ this.emitEquipment(m);
85
106
  }
86
107
  return { commandId: cmd.commandId, accepted: true };
87
108
  }
@@ -91,7 +112,7 @@ export class MesKernel extends FlowEngine {
91
112
  onArrival(spec) {
92
113
  if (this.mesSpec)
93
114
  return this.onArrivalDef(spec);
94
- const rawStore = this.nodeByType('raw-store');
115
+ const rawStore = this.locationByType('raw-store');
95
116
  if (!rawStore)
96
117
  return;
97
118
  const gtin = this.pickGtin(spec.content.skuMix);
@@ -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
  }
@@ -119,7 +140,7 @@ export class MesKernel extends FlowEngine {
119
140
  if (this.mesSpec)
120
141
  return this.allocateDef(o);
121
142
  const s0 = ROUTE[0];
122
- const first = this.nodeByType(s0.node);
143
+ const first = this.locationByType(s0.locationType);
123
144
  const product = this.productOf(o.gtin);
124
145
  if (!first || !product)
125
146
  return;
@@ -127,7 +148,7 @@ export class MesKernel extends FlowEngine {
127
148
  const picks = [];
128
149
  for (const line of product.bom) {
129
150
  const available = [...this.items.values()]
130
- .filter(i => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'raw-store')
151
+ .filter(i => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'raw-store')
131
152
  .map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
132
153
  const chosen = this.policy.selectStock({ gtin: line.part.gtin, qty: line.qty, available });
133
154
  if (chosen.length < line.qty)
@@ -144,25 +165,41 @@ export class MesKernel extends FlowEngine {
144
165
  }
145
166
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
146
167
  emitStation(o, stage, itemEpc, changeoverKey) {
147
- const node = this.nodeByType(stage.node);
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' };
168
+ const loc = this.locationByType(stage.locationType);
169
+ const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: loc.id, toNode: loc.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' };
149
170
  this.tasks.set(task.id, task);
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
- const node = this.nodes.get(t.toNode);
196
+ const loc = this.locations.get(t.toNode);
160
197
  const isLast = i === ROUTE.length - 1;
161
198
  if (!isLast) {
162
199
  // 중간 스테이션 — 입력(첫=BOM 전부 / 이후=이전 WIP) → 다음 WIP, 다음 스테이션 태스크.
163
200
  const inputs = order.allocated.slice();
164
201
  const wip = sgtinUri(CP, WIP_ITEMREF, ++this.wipSeq);
165
- this.transform(inputs, [{ epc: wip, gtin: WIP_GTIN, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
202
+ this.transform(inputs, [{ epc: wip, gtin: WIP_GTIN, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
166
203
  order.allocated = [wip];
167
204
  const next = ROUTE[i + 1];
168
205
  this.emitStation(order, next, wip, product.gtin);
@@ -171,13 +208,13 @@ export class MesKernel extends FlowEngine {
171
208
  return;
172
209
  }
173
210
  // 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
174
- const fgStore = this.nodeByType('fg-store');
211
+ const fgStore = this.locationByType('fg-store');
175
212
  const wip = order.allocated[0];
176
213
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
177
214
  this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
178
215
  const disp = good ? DISP.sellable : DISP.non_sellable;
179
216
  const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
180
- this.transform([wip], [{ epc: outputEpc, gtin: product.gtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep: MES_BIZSTEP.producing, disposition: disp, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
217
+ this.transform([wip], [{ epc: outputEpc, gtin: product.gtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep: MES_BIZSTEP.producing, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
181
218
  this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outputEpc], quantityList: [{ epcClass: product.gtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
182
219
  order.allocated = [];
183
220
  order.fulfilled = 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;
@@ -204,10 +253,13 @@ export class MesKernel extends FlowEngine {
204
253
  }
205
254
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 raw-store 에 생성. */
206
255
  onArrivalDef(spec) {
207
- const rawStore = this.nodeByType('raw-store');
256
+ const rawStore = this.locationByType('raw-store');
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,21 +273,21 @@ 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
  }
228
280
  /** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
229
281
  allocateDef(o) {
230
282
  const ops = this.routeOps();
231
- if (!ops.length || !ops[0].nodeType || !this.nodeByType(ops[0].nodeType))
283
+ if (!ops.length || !ops[0].locationType || !this.locationByType(ops[0].locationType))
232
284
  return;
233
285
  const rc = this.recipeDef();
234
286
  const picks = [];
235
287
  for (const line of rc.inputs) {
236
288
  const g = this.classOf(line.material);
237
289
  const available = [...this.items.values()]
238
- .filter(i => i.gtin === g && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'raw-store')
290
+ .filter(i => i.gtin === g && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'raw-store')
239
291
  .map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
240
292
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
241
293
  if (chosen.length < line.qty)
@@ -251,25 +303,27 @@ export class MesKernel extends FlowEngine {
251
303
  this.emitOrder(o);
252
304
  }
253
305
  emitStationDef(o, op, itemEpc) {
254
- const node = this.nodeByType(op.nodeType);
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 };
306
+ const loc = this.locationByType(op.locationType);
307
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.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 };
256
308
  this.tasks.set(task.id, task);
257
309
  this.emitTask(task);
258
310
  }
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);
265
- const node = this.nodes.get(t.toNode);
319
+ const loc = this.locations.get(t.toNode);
266
320
  const bizStep = ops[i]?.bizStep ?? MES_BIZSTEP.producing;
267
321
  const isLast = i === ops.length - 1;
268
322
  if (!isLast) {
269
323
  const inputs = order.allocated.slice();
270
324
  const wip = sgtinUri(this.mesSpec.companyPrefix, 'WIP', ++this.wipSeq);
271
325
  const wipGtin = sgtinClass(this.mesSpec.companyPrefix, 'WIP');
272
- this.transform(inputs, [{ epc: wip, gtin: wipGtin, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
326
+ this.transform(inputs, [{ epc: wip, gtin: wipGtin, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
273
327
  order.allocated = [wip];
274
328
  const next = ops[i + 1];
275
329
  this.emitStationDef(order, next, wip);
@@ -277,14 +331,14 @@ export class MesKernel extends FlowEngine {
277
331
  this.emitOrder(order);
278
332
  return;
279
333
  }
280
- const fgStore = this.nodeByType('fg-store');
334
+ const fgStore = this.locationByType('fg-store');
281
335
  const wip = order.allocated[0];
282
336
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
283
337
  this.recordOutput(t.resource, good);
284
338
  const disp = good ? DISP.sellable : DISP.non_sellable;
285
339
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
286
340
  const outGtin = this.classOf(rc.outputs[0].material);
287
- this.transform([wip], [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
341
+ this.transform([wip], [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
288
342
  this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outEpc], quantityList: [{ epcClass: outGtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
289
343
  order.allocated = [];
290
344
  order.fulfilled = 1;
@@ -8,7 +8,7 @@ export declare const MES_BIZSTEP: {
8
8
  export declare const BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
9
9
  /** 직렬 SGTIN URI (원자재 단위·완제품). */
10
10
  export declare function sgtinUri(companyPrefix: string, itemRef: string, serial: number): string;
11
- /** MES 노드 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(nodeByType/.type). 도메인 SSOT.
11
+ /** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
12
12
  * 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
13
- export declare const MES_NODE_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
13
+ export declare const MES_LOCATION_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
14
14
  export declare const MES_TYPES: TwinTypeInfo[];