@operato/twin-kernel 0.4.1 → 0.4.3

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.
@@ -11,25 +11,30 @@
11
11
  * 이 의도를 *실현*한다 — 공유 계약은 관측(계층 B)뿐, 기제 methods(계층 A)는 공유 안 함.
12
12
  * ⚠ Mobile ≠ Transferable: Mobile=자원 자신이 이동, Transferable=자리가 아이템 이동(씬 기제).
13
13
  */
14
+ /*
15
+ * `label` 은 **언어 중립 i18n 키**(`twin.capability.<key>`) — 사람 언어는 표현계층이 렌더한다(L2).
16
+ * 타입/시스템 라벨(domain-catalog)이 먼저 이 규약으로 옮겨졌고, 능력 라벨만 사람 말로 남아
17
+ * 영어 화면에 한글이 새고 있었다. `semantics` 는 설계 문서용 산문이므로 대상이 아니다.
18
+ */
14
19
  export const CAPABILITIES = {
15
20
  operable: {
16
- key: 'operable', label: '운영', semantics: '능동 자원의 운영 상태(유휴/가동/고장). status 교차 관심사를 여기 하나로.',
21
+ key: 'operable', label: 'twin.capability.operable', semantics: '능동 자원의 운영 상태(유휴/가동/고장). status 교차 관심사를 여기 하나로.',
17
22
  stateFields: ['status'], results: ['statusChanged']
18
23
  },
19
24
  storable: {
20
- key: 'storable', label: '저장', semantics: '아이템을 보유하는 위치 — 점유/용량. (씬 기제: Capacity)',
25
+ key: 'storable', label: 'twin.capability.storable', semantics: '아이템을 보유하는 위치 — 점유/용량. (씬 기제: Capacity)',
21
26
  stateFields: ['occupancy', 'capacity'], invariants: ['0 <= occupancy <= capacity (capacity>0)'], results: ['occupancyChanged']
22
27
  },
23
28
  mobile: {
24
- key: 'mobile', label: '이동', semantics: '자원 자신이 자리 간 이동. Transferable(아이템 이동)과 다름. (씬 기제: CarrierLine)',
29
+ key: 'mobile', label: 'twin.capability.mobile', semantics: '자원 자신이 자리 간 이동. Transferable(아이템 이동)과 다름. (씬 기제: CarrierLine)',
25
30
  stateFields: ['location', 'motion'], models: ['Motion'], results: ['moved', 'motionTick']
26
31
  },
27
32
  processable: {
28
- key: 'processable', label: '가공', semantics: '변환/가공 수행 — 산출(양품/불량). 운영 status 는 Operable 조합. progress 방출은 후속.',
33
+ key: 'processable', label: 'twin.capability.processable', semantics: '변환/가공 수행 — 산출(양품/불량). 운영 status 는 Operable 조합. progress 방출은 후속.',
29
34
  stateFields: ['output'], results: ['completed']
30
35
  },
31
36
  trackable: {
32
- key: 'trackable', label: '추적', semantics: '오더/아이템 생애 추적 — 생애단계(도메인 라벨, 무방언)·진행·보류.',
37
+ key: 'trackable', label: 'twin.capability.trackable', semantics: '오더/아이템 생애 추적 — 생애단계(도메인 라벨, 무방언)·진행·보류.',
33
38
  stateFields: ['lifecycle', 'progress', 'held'], results: ['lifecycleChanged']
34
39
  }
35
40
  };
@@ -971,6 +971,13 @@ export interface StateSnapshot {
971
971
  tasks: TaskState[];
972
972
  orders: OrderState[];
973
973
  attentions?: Attention[];
974
+ /**
975
+ * 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
976
+ *
977
+ * 신호 자체는 상태에서 다시 계산되지만 "사람이 봤다" 는 계산으로 되살릴 수 없다. 스냅샷으로
978
+ * 왕복시켜야 재기동·되짚기에서 확인 상태가 유지된다.
979
+ */
980
+ acked?: string[];
974
981
  }
975
982
  export interface Command<T = unknown> {
976
983
  commandId: string;
@@ -1084,7 +1091,25 @@ export declare const OP_EVENT: {
1084
1091
  readonly asset: "asset.status";
1085
1092
  readonly order: "order.status";
1086
1093
  readonly quality: "quality.output";
1094
+ /**
1095
+ * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
1096
+ *
1097
+ * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
1098
+ * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
1099
+ * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
1100
+ */
1101
+ readonly attentionAck: "attention.acked";
1087
1102
  };
1103
+ /**
1104
+ * 주목 신호 확인 델타 — 확인한 신호의 id 와 시각.
1105
+ *
1106
+ * 신호의 내용은 싣지 않는다(상태에서 다시 계산된다). 여기 남기는 것은 **사람이 확인했다는 사실** 하나다.
1107
+ */
1108
+ export interface AttentionAckDelta {
1109
+ id: string;
1110
+ /** 확인한 시각 — 없으면 이벤트 시각을 쓴다. */
1111
+ at?: ISOTime;
1112
+ }
1088
1113
  /** 사람 상태 델타 — 배정·해제·교대 전이 시 방출. 미러가 인원 가용을 비추는 근거. */
1089
1114
  export interface PersonStatusDelta extends EffectivePeriod {
1090
1115
  personId: string;
@@ -1328,6 +1353,59 @@ export interface BoardDef {
1328
1353
  personnelClasses?: ResourceClassDef[];
1329
1354
  equipmentClasses?: ResourceClassDef[];
1330
1355
  assetClasses?: ResourceClassDef[];
1356
+ /**
1357
+ * **생산 선언** — 이 트윈이 무엇을 어떻게 만드는가(ISA-95 `OperationsSegment` + BOM).
1358
+ *
1359
+ * ── 이름을 `productionSpec` 으로 정한 이유 (2026-08-05, 되돌리지 말 것) ─────────────────────────
1360
+ * 이 자리는 예전에 **`mesSpec`** 이라는 이름으로, 그것도 **계약에 선언되지 않은 채**
1361
+ * (`(board as any).mesSpec`) 타고 있었다.
1362
+ *
1363
+ * 그 이름의 유래는 자재가 아니라 **소비자**다: `MesKernel` 의 생성자 인자 이름이
1364
+ * `mesSpec: MesDefinitionSpec` 이고(정의-구동 모드를 MES 에만 도입한 커밋 `ce4fbeb`), 호스트가
1365
+ * 보드에 얹을 때 그 인자 이름을 그대로 가져왔다. 소비자가 하나일 때는 어색하지 않았다.
1366
+ *
1367
+ * `materialSpec` 도 답이 아니다. ① 담긴 것이 자재가 아니다 — 타입·오퍼레이션(소요·변동·모수·
1368
+ * 인원/설비/자산/자재 명세)·라우트·레시피이고 자재는 그중 한 항목의 한 필드다. ② 그 이름은 이미
1369
+ * 표준 자리로 쓰인다 — `OperationDef.materialSpecification` 이 ISA-95 `OpMaterialSpecificationType`
1370
+ * 1:1 이다. 보드 수준에서 같은 이름을 쓰면 "어느 쪽 자재 명세냐" 가 매번 헷갈린다.
1371
+ *
1372
+ * `productionSpec` = ISA-95 의 생산 영역 어휘이고, 담긴 것("이 트윈이 무엇을 어떻게 만드는가")
1373
+ * 그대로다. 특정 시스템(MES/WMS)에도, 특정 자원(자재)에도 기울지 않는다.
1374
+ *
1375
+ * 이름이 MES 였던 결과로 두 가지가 굳었다.
1376
+ *
1377
+ * ① **일반 기제에 한 시스템 이름이 붙었다.** 담고 있는 것은 ISA-95 `OperationsSegment`(소요·모수·
1378
+ * 자재 명세)와 BOM 이고, 그것은 MES 만의 것이 아니다 — 창고의 유통가공(키팅·세트조립)도 같은
1379
+ * "자재를 소비해 자재를 산출하는 공정" 이다. 이름이 MES 였기 때문에 **WMS 트윈은 이 선언을 실을
1380
+ * 생각조차 하지 못했고**, 유통가공 창고 템플릿은 BOM 을 설명 문서(`detail`)에만 적어 둔 채
1381
+ * 키트를 만들지 못하는 트윈을 만들어 냈다(랙이 꽉 차고 출고가 0건이었다).
1382
+ * ② 계약에 없으니 **아무도 이 자리를 발견할 수 없었다.** 타입이 말해 주지 않는 필드는 없는 필드다.
1383
+ *
1384
+ * 그래서 표준 어휘를 따르는 일반 이름으로 정식 선언한다. `mesSpec` 은 기존 트윈이 그대로 돌도록
1385
+ * **읽기 호환으로만** 남긴다(새로 쓰지 않는다).
1386
+ *
1387
+ * ── 시스템별 생산의 경계 (여기서도 되돌리지 말 것) ────────────────────────────────────────────
1388
+ * MES 의 생산은 **직렬번호·수율·생산오더 연결**을 갖는 레시피 경로다. 코어의 일반 자재 명세
1389
+ * (`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 셋을 표현하지 못한다 — 그래서 둘을 합치지
1390
+ * 않는다. 겹치면 같은 산출이 두 번 생기므로 MES 커널이 기동 시점에 거부한다(`assertNoDoubleProduction`).
1391
+ * 유통가공(VAS)은 비직렬 클래스+수량이 맞는 모양이므로 코어의 일반 경로를 쓴다.
1392
+ */
1393
+ productionSpec?: ProductionSpec;
1394
+ }
1395
+ /**
1396
+ * 생산 선언 — 도메인 정의(ISA-95 operations + BOM)와, 그 추상 자재 키를 실제 GS1 식별자로 잇는 바인딩.
1397
+ *
1398
+ * 정의는 **gtin 을 모른다**(어느 회사의 물건인지는 현장의 사실이다). 그래서 키→item reference 바인딩과
1399
+ * 회사 프리픽스를 여기서 받아 `urn:epc:idpat:sgtin:<prefix>.<ref>.*` 를 만든다.
1400
+ */
1401
+ export interface ProductionSpec {
1402
+ /** 무엇이 있고 어떻게 흐르나 — `DomainDefinition`(자기 타입, zero-dep). */
1403
+ definition: import('./domain-definition.ts').DomainDefinition;
1404
+ /** 자재 키 → GS1 item reference. */
1405
+ binding?: Record<string, string>;
1406
+ companyPrefix?: string;
1407
+ /** 쓸 레시피 키(미지정 시 첫 레시피) — 직렬 생산 경로(MES)만 쓴다. */
1408
+ recipeKey?: string;
1331
1409
  }
1332
1410
  export interface TwinKernel {
1333
1411
  loadBoard(def: BoardDef): void;
package/dist/contract.js CHANGED
@@ -497,7 +497,20 @@ export const OP_EVENT = {
497
497
  /** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
498
498
  asset: 'asset.status',
499
499
  order: 'order.status',
500
- quality: 'quality.output' // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
500
+ quality: 'quality.output', // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
501
+ /**
502
+ * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
503
+ *
504
+ * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
505
+ * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
506
+ * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
507
+ */
508
+ /*
509
+ * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
510
+ * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
511
+ * 구별되지 않는다.
512
+ */
513
+ attentionAck: 'attention.acked'
501
514
  };
502
515
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
503
516
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
@@ -348,6 +348,8 @@ export declare abstract class FlowEngine implements TwinKernel {
348
348
  assets?: AssetState[];
349
349
  tasks?: TaskState[];
350
350
  orders?: OrderState[];
351
+ /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없는 유일한 축이라 스냅샷에서 이어받는다. */
352
+ acked?: string[];
351
353
  }, orders?: OrderStatusDelta[]): void;
352
354
  /** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
353
355
  setLocationCapacity(locationId: string, capacity: number): boolean;
@@ -374,6 +376,7 @@ export declare abstract class FlowEngine implements TwinKernel {
374
376
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
375
377
  private _attentionSince;
376
378
  dispatch(cmd: Command): CommandAck;
379
+ private dispatchInner;
377
380
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
378
381
  protected handleCommand(cmd: Command): CommandAck;
379
382
  readonly scenario: ScenarioControl;
@@ -448,6 +451,13 @@ export declare abstract class FlowEngine implements TwinKernel {
448
451
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
449
452
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
450
453
  */
454
+ /**
455
+ * 선언된 오퍼레이션들 — 도메인 커널이 "무엇을 만들 수 있나" 를 물을 수 있게.
456
+ *
457
+ * `operationSpecs` 를 도메인이 직접 뒤지지 않게 읽기 창구를 둔다: 저장 형태(맵)가 바뀌어도
458
+ * 도메인은 몰라야 하고, 도메인이 그 맵에 쓰는 일이 생기면 정의가 권위라는 규약이 깨진다.
459
+ */
460
+ protected declaredOperations(): readonly OperationDef[];
451
461
  loadOperations(ops?: OperationDef[]): void;
452
462
  /**
453
463
  * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
@@ -608,6 +618,17 @@ export declare abstract class FlowEngine implements TwinKernel {
608
618
  private oeeOf;
609
619
  /** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
610
620
  protected slotViews(locationType: string): SlotView[];
621
+ /**
622
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
623
+ *
624
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
625
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
626
+ *
627
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
628
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
629
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
630
+ */
631
+ private _correlationId?;
611
632
  protected emit(event: EpcisEvent): void;
612
633
  protected emitOp(eventType: string, data: unknown): void;
613
634
  /**
@@ -255,6 +255,15 @@ export class FlowEngine {
255
255
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
256
256
  loadBoard(def) {
257
257
  this.boardDef = def;
258
+ /*
259
+ * **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
260
+ * 이름은 `mesSpec` 이었다), 그래서 창고 트윈은 "자재를 소비해 자재를 산출하는 공정" 을 선언할
261
+ * 방법이 없었다 — 유통가공 창고 템플릿이 BOM 을 설명 문서에만 적어 둔 채 키트를 못 만드는
262
+ * 트윈을 만들어 낸 원인이다. 담긴 것은 ISA-95 `OperationsSegment` 이고 시스템에 종속되지 않으므로
263
+ * 여기(공통)에서 싣는다. MES 는 그 위에 직렬·수율·오더연결 레시피 경로를 더 갖는다.
264
+ */
265
+ if (def.productionSpec?.definition?.operations?.length)
266
+ this.loadOperations(def.productionSpec.definition.operations);
258
267
  for (const n of readBoardLocations(def))
259
268
  this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
260
269
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
@@ -313,6 +322,10 @@ export class FlowEngine {
313
322
  this.revision = from;
314
323
  }
315
324
  hydrateObserved(snap, orders = []) {
325
+ /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 심으면 곧바로 주목 신호가 계산되므로, 늦게
326
+ * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
327
+ for (const id of snap.acked ?? [])
328
+ this._acked.add(id);
316
329
  /* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
317
330
  * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
318
331
  * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 굴리면 답이 낙관 쪽으로 치우친다. */
@@ -533,6 +546,20 @@ export class FlowEngine {
533
546
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
534
547
  _attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
535
548
  dispatch(cmd) {
549
+ /*
550
+ * 이 커맨드가 낳는 이벤트에 상관값을 단다. 명시값이 없으면 **commandId 를 쓴다** — 호출자가
551
+ * 따로 챙기지 않아도 모든 지시가 자기 결과와 이어진다(감사 기록이 commandId 를 이미 갖고 있다).
552
+ * finally 로 반드시 걷는다: 남겨 두면 그 뒤 틱에서 일어난 무관한 일까지 이 커맨드 탓이 된다.
553
+ */
554
+ this._correlationId = cmd.correlationId ?? cmd.commandId;
555
+ try {
556
+ return this.dispatchInner(cmd);
557
+ }
558
+ finally {
559
+ this._correlationId = undefined;
560
+ }
561
+ }
562
+ dispatchInner(cmd) {
536
563
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
537
564
  // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
538
565
  const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
@@ -549,8 +576,15 @@ export class FlowEngine {
549
576
  }
550
577
  case CMD.attentionAck: {
551
578
  const id = cmd.args?.id;
552
- if (id)
579
+ if (id) {
553
580
  this._acked.add(id);
581
+ /*
582
+ * **사실로 남긴다.** 확인 처리는 사람이 한 행위라 상태에서 다시 계산될 수 없다. 저널에
583
+ * 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고, 과거를 되짚어도 그때 무엇을
584
+ * 확인했는지 알 수 없다. 다른 커맨드(보류·재개)가 델타를 내는 것과 같은 길이다.
585
+ */
586
+ this.emitOp(OP_EVENT.attentionAck, { id, at: this.now() });
587
+ }
554
588
  return ok();
555
589
  }
556
590
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
@@ -735,7 +769,9 @@ export class FlowEngine {
735
769
  ...(o.endTime ? { endTime: o.endTime } : {}),
736
770
  held: o.held
737
771
  })),
738
- attentions: this.computeAttentions()
772
+ attentions: this.computeAttentions(),
773
+ /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
774
+ acked: [...this._acked]
739
775
  };
740
776
  }
741
777
  /*
@@ -896,6 +932,15 @@ export class FlowEngine {
896
932
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
897
933
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
898
934
  */
935
+ /**
936
+ * 선언된 오퍼레이션들 — 도메인 커널이 "무엇을 만들 수 있나" 를 물을 수 있게.
937
+ *
938
+ * `operationSpecs` 를 도메인이 직접 뒤지지 않게 읽기 창구를 둔다: 저장 형태(맵)가 바뀌어도
939
+ * 도메인은 몰라야 하고, 도메인이 그 맵에 쓰는 일이 생기면 정의가 권위라는 규약이 깨진다.
940
+ */
941
+ declaredOperations() {
942
+ return [...this.operationSpecs.values()];
943
+ }
899
944
  loadOperations(ops = []) {
900
945
  for (const o of ops)
901
946
  if (o?.key)
@@ -1251,15 +1296,26 @@ export class FlowEngine {
1251
1296
  views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
1252
1297
  return views;
1253
1298
  }
1299
+ /**
1300
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
1301
+ *
1302
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
1303
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
1304
+ *
1305
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
1306
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
1307
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
1308
+ */
1309
+ _correlationId;
1254
1310
  emit(event) {
1255
1311
  this.revision++;
1256
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, data: event };
1312
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data: event };
1257
1313
  for (const h of this.handlers)
1258
1314
  h(e);
1259
1315
  }
1260
1316
  emitOp(eventType, data) {
1261
1317
  this.revision++;
1262
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
1318
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
1263
1319
  for (const h of this.handlers)
1264
1320
  h(e);
1265
1321
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './contract.ts';
2
+ export * from './scenario-validate.ts';
2
3
  export * from './domain-definition.ts';
3
4
  export * from './counterfactual.ts';
4
5
  export * from './forecast.ts';
@@ -25,5 +26,4 @@ export * from './capacity.ts';
25
26
  export { WmsKernel } from './kernel.ts';
26
27
  export { YmsKernel } from './yms-kernel.ts';
27
28
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
28
- export type { MesDefinitionSpec } from './mes-kernel.ts';
29
29
  export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./contract.js";
2
+ export * from "./scenario-validate.js";
2
3
  export * from "./domain-definition.js";
3
4
  export * from "./counterfactual.js";
4
5
  export * from "./forecast.js";
package/dist/kernel.d.ts CHANGED
@@ -28,6 +28,41 @@ export declare class WmsKernel extends FlowEngine {
28
28
  protected allocate(o: FlowOrder): void;
29
29
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
30
30
  protected onTaskComplete(t: FlowTask): void;
31
+ /**
32
+ * 부족한 라인을 **만들어서** 채운다 — 부품 이송 → 가공 → 되돌리기 3단 사슬.
33
+ *
34
+ * 사슬인 이유는 커널의 두 규칙 때문이다(둘 다 의도된 규칙이라 우회하지 않는다):
35
+ * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
36
+ * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
37
+ *
38
+ * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
39
+ * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
40
+ */
41
+ private makeShortLines;
42
+ /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
43
+ private hasOpenMakeChain;
44
+ /** 지금 재고 — 판단 함수가 보는 형태로. */
45
+ private stockLines;
46
+ /** 부품을 작업대로 — 팔레트 이동이므로 피킹과 같은 기제다(부분 소비는 코어가 한다). */
47
+ private issueFeed;
48
+ /** 가공 — 작업 종류를 **오퍼레이션 키**로 둔다(코어가 그 키로 명세를 찾는다). */
49
+ private issueProcess;
50
+ private pushTask;
51
+ /**
52
+ * 가공 완료 — 코어가 이미 소비(시작)와 산출(완료 직전)을 실행했다. 남은 일은 만든 것을 **재고로
53
+ * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
54
+ *
55
+ * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
56
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
57
+ * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
58
+ * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
59
+ * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
60
+ *
61
+ * 그래서 만든 물건을 **입고가 하는 것과 같은 방식**으로 들여놓는다: 팔레트(SSCC)를 만들고 그 안에
62
+ * 세트 N개를 담는다(AggregationEvent). 현장에서도 가공물은 팔레트에 실려 보관으로 간다.
63
+ * 그 뒤는 기존 경로 그대로다 — `putaway` 로 보관 자리에 넣으면 판매 가능이 된다.
64
+ */
65
+ private onProcessComplete;
31
66
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
32
67
  private finalizeOrder;
33
68
  }
package/dist/kernel.js CHANGED
@@ -8,8 +8,10 @@
8
8
  import { CMD } from "./contract.js";
9
9
  import { firstFitPolicy } from "./allocation-policy.js";
10
10
  import { FlowEngine } from "./flow-engine.js";
11
+ import { planMakeToOrder } from "./make-to-order.js";
11
12
  import { BIZSTEP, BTT } from "./wms-profile.js";
12
13
  import { DISP, ILMD_ATTR, aggregationEvent, gdtiUri, objectEvent, ssccUri, transactionEvent } from "./epcis.js";
14
+ import { subLotIdOf } from "./contract.js";
13
15
  const TRAVEL_MS = 30_000;
14
16
  const COMPANY_PREFIX = '0614141';
15
17
  const SHELF_MS = 30 * 24 * 3_600_000; // 기본 유통기한(30일)
@@ -138,8 +140,13 @@ export class WmsKernel extends FlowEngine {
138
140
  chosenAll.push(epc);
139
141
  }
140
142
  }
143
+ /*
144
+ * **재고에 없으면 만들 수 있는지 본다** — 유통가공(키팅·세트조립) 창고가 여기서 막혀 있었다.
145
+ * 인바운드는 부품을 넣고 오더는 세트를 요구하는데 그 세트를 만드는 공정을 아무도 부르지 않아,
146
+ * 랙이 꽉 차고 출고가 한 건도 나오지 않았다. 판단은 `planMakeToOrder`(순수)가 한다.
147
+ */
141
148
  if (chosenAll.length === 0)
142
- return;
149
+ return this.makeShortLines(o);
143
150
  /* 할당은 아직 집은 것이 아니다 — 물건은 보관 자리에 있고 처분만 '예약' 으로 바뀐다.
144
151
  * 그래서 단계는 storing 이다(피킹 관측은 실제로 집을 때 따로 난다). */
145
152
  this.reserve(chosenAll, BIZSTEP.storing);
@@ -156,6 +163,13 @@ export class WmsKernel extends FlowEngine {
156
163
  }
157
164
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
158
165
  onTaskComplete(t) {
166
+ /*
167
+ * **생산 사슬의 걸음들을 먼저 가른다.** 아래 이동 처리는 "주체가 팔레트 하나" 를 전제하는데,
168
+ * 가공은 클래스+수량 소비/산출이라 주체가 없다 — 그대로 두면 물품을 찾지 못해 터진다(실제로
169
+ * 그랬다). 걸음마다 무엇이 끝난 것인지 다르므로 각각 답한다.
170
+ */
171
+ if (t.intent === 'process')
172
+ return this.onProcessComplete(t);
159
173
  const item = this.itemByRef(t.itemEpc);
160
174
  /* 여기 도달했다면 코어가 이미 물품을 확인했다(주체가 사라진 작업은 완료 전에 접힌다).
161
175
  그래도 단정(`!`)은 쓰지 않는다 — 계약이 바뀌면 조용히 틀리는 대신 분명히 멈춘다. */
@@ -166,6 +180,14 @@ export class WmsKernel extends FlowEngine {
166
180
  from.occupancy--;
167
181
  to.occupancy++;
168
182
  item.location = to.id;
183
+ /*
184
+ * 부품 이송 — 자리만 옮긴다. 처분은 예약으로 남겨 둔다(다른 오더가 집어 가면 이 가공이 굶는다).
185
+ * 관측은 `staging_outbound` 가 아니라 이동 그대로다 — 아직 아무것도 만들지 않았다.
186
+ */
187
+ if (t.kind === 'feed') {
188
+ this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: BIZSTEP.storing, disposition: item.disposition, epcList: [item.epc], quantityList: [{ epcClass: item.gtin, quantity: item.qty }], readPoint: to.id, bizLocation: to.id }));
189
+ return;
190
+ }
169
191
  if (t.kind === 'putaway') {
170
192
  item.disposition = DISP.sellable;
171
193
  this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [item.epc], quantityList: [{ epcClass: item.gtin, quantity: item.qty }], readPoint: to.id, bizLocation: to.id }));
@@ -181,6 +203,131 @@ export class WmsKernel extends FlowEngine {
181
203
  if (order.picked.length === order.allocated.length)
182
204
  this.finalizeOrder(order, to);
183
205
  }
206
+ // ── 수요가 부르는 생산(유통가공) ────────────────────────────────────────────
207
+ /**
208
+ * 부족한 라인을 **만들어서** 채운다 — 부품 이송 → 가공 → 되돌리기 3단 사슬.
209
+ *
210
+ * 사슬인 이유는 커널의 두 규칙 때문이다(둘 다 의도된 규칙이라 우회하지 않는다):
211
+ * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
212
+ * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
213
+ *
214
+ * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
215
+ * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
216
+ */
217
+ makeShortLines(o) {
218
+ if (!o.lines?.length)
219
+ return;
220
+ if (this.hasOpenMakeChain(o.id))
221
+ return; // 이미 굴러가는 중
222
+ const stock = this.stockLines();
223
+ const locations = [...this.locations.values()].map(l => ({ id: l.id, type: l.type }));
224
+ const ops = this.declaredOperations();
225
+ for (const line of o.lines) {
226
+ const have = stock.filter(s => s.gtin === line.gtin && s.sellable && this.locations.get(s.location)?.type === 'storage')
227
+ .reduce((n, s) => n + s.qty, 0);
228
+ const short = line.requested - have;
229
+ if (short <= 0)
230
+ continue;
231
+ const plan = planMakeToOrder(line.gtin, short, ops, stock, locations);
232
+ for (const step of plan.steps) {
233
+ if (step.step === 'feed')
234
+ this.issueFeed(o, step.gtin, step.from, step.to, step.qty);
235
+ else
236
+ this.issueProcess(o, step.operation, step.at);
237
+ }
238
+ if (plan.steps.length)
239
+ return; // 한 번에 한 라인씩 — 부품을 여러 라인이 동시에 다투지 않게
240
+ }
241
+ }
242
+ /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
243
+ hasOpenMakeChain(orderId) {
244
+ for (const t of this.tasks.values()) {
245
+ if (t.orderId !== orderId || t.status === 'completed')
246
+ continue;
247
+ if (t.kind === 'feed' || t.intent === 'process')
248
+ return true;
249
+ /* 만든 것을 들여놓는 적재도 사슬의 일부다 — 끝나기 전에 다시 발행하면 같은 부품을 또 끌어온다. */
250
+ if (t.kind === 'putaway' && t.orderId === orderId)
251
+ return true;
252
+ }
253
+ return false;
254
+ }
255
+ /** 지금 재고 — 판단 함수가 보는 형태로. */
256
+ stockLines() {
257
+ return [...this.items.values()]
258
+ .filter(i => i.gtin)
259
+ .map(i => ({ gtin: i.gtin, location: i.location, qty: i.qty ?? 1, sellable: i.disposition === DISP.sellable }));
260
+ }
261
+ /** 부품을 작업대로 — 팔레트 이동이므로 피킹과 같은 기제다(부분 소비는 코어가 한다). */
262
+ issueFeed(o, gtin, from, to, qty) {
263
+ const src = [...this.items.values()].find(i => i.gtin === gtin && i.location === from && i.disposition === DISP.sellable);
264
+ if (!src)
265
+ return;
266
+ /* 이송 중에 다른 오더가 같은 팔레트를 집지 않게 예약으로 바꾼다 — 코어의 자재 게이트는
267
+ 처분을 보지 않지만, 이 커널의 `allocate` 는 `sellable` 만 집으므로 이것으로 충돌을 막는다. */
268
+ src.disposition = DISP.reserved;
269
+ void qty; // 팔레트 단위로 옮긴다 — 필요량은 코어가 소비 시점에 부분 취득한다
270
+ this.pushTask(o, 'feed', src.epc, from, to);
271
+ }
272
+ /** 가공 — 작업 종류를 **오퍼레이션 키**로 둔다(코어가 그 키로 명세를 찾는다). */
273
+ issueProcess(o, operation, at) {
274
+ /* 물품을 지목하지 않는다 — 클래스+수량 소비/산출이므로 주체가 팔레트 하나가 아니다.
275
+ 코어의 작업 게이트는 빈 `itemEpc` 를 견딘다(`t.itemEpc && …` 조건부 확인). */
276
+ /* **의도를 process 로 밝힌다** — 코어가 이 표시로 이동(모션)과 변환을 가르고, 이 커널의 완료 훅도
277
+ 그것으로 판단한다. "선언된 오퍼레이션인가" 로 가르면 산출을 선언한 이동 작업까지 삼킨다. */
278
+ this.pushTask(o, operation, '', at, at, 'process');
279
+ }
280
+ pushTask(o, kind, itemEpc, from, to, intent) {
281
+ const id = `task-${++this.taskSeq}`;
282
+ const task = {
283
+ id, kind, status: 'created', itemEpc, fromNode: from, toNode: to, resource: null, remainingMs: 0,
284
+ durationMs: this.durationOf({ kind, fromNode: from, toNode: to }, TRAVEL_MS),
285
+ ...(intent ? { intent } : {}),
286
+ ...(o ? { orderId: o.id } : {})
287
+ };
288
+ this.tasks.set(id, task);
289
+ this.emitTask(task);
290
+ }
291
+ /**
292
+ * 가공 완료 — 코어가 이미 소비(시작)와 산출(완료 직전)을 실행했다. 남은 일은 만든 것을 **재고로
293
+ * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
294
+ *
295
+ * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
296
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
297
+ * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
298
+ * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
299
+ * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
300
+ *
301
+ * 그래서 만든 물건을 **입고가 하는 것과 같은 방식**으로 들여놓는다: 팔레트(SSCC)를 만들고 그 안에
302
+ * 세트 N개를 담는다(AggregationEvent). 현장에서도 가공물은 팔레트에 실려 보관으로 간다.
303
+ * 그 뒤는 기존 경로 그대로다 — `putaway` 로 보관 자리에 넣으면 판매 가능이 된다.
304
+ */
305
+ onProcessComplete(t) {
306
+ const made = (t.materialActual ?? []).filter(m => m.use === 'produced');
307
+ const order = t.orderId ? this.orders.get(t.orderId) : undefined;
308
+ const at = t.toNode;
309
+ for (const m of made) {
310
+ const key = subLotIdOf(m.definitionId, at);
311
+ const row = this.items.get(key);
312
+ if (!row)
313
+ continue; // 누가 이미 소비했다 — 없는 것을 만들어 내지 않는다
314
+ const qty = row.qty ?? 0;
315
+ if (qty <= 0)
316
+ continue;
317
+ /* 클래스 줄을 팔레트로 바꾼다 — 자리 점유는 줄 하나에서 줄 하나로(늘지 않는다). */
318
+ this.items.delete(key);
319
+ const pallet = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
320
+ const qtyList = [{ epcClass: m.definitionId, quantity: qty }];
321
+ this.items.set(pallet, { epc: pallet, gtin: m.definitionId, qty, location: at, disposition: DISP.in_progress });
322
+ const eventTime = this.now();
323
+ this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.packing, parentID: pallet, childQuantityList: qtyList, readPoint: at }));
324
+ this.emit(objectEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.packing, disposition: DISP.in_progress, epcList: [pallet], quantityList: qtyList, readPoint: at, bizLocation: at }));
325
+ /* 보관으로 — `putaway` 완료가 판매 가능으로 표시하고 storing 관측을 낸다(입고와 같은 걸음). */
326
+ const storage = this.locationByType('storage');
327
+ if (storage)
328
+ this.pushTask(order, 'putaway', pallet, at, storage.id);
329
+ }
330
+ }
184
331
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
185
332
  finalizeOrder(order, staging) {
186
333
  const shipDock = this.locationByType('dock-ship') ?? staging;
@@ -0,0 +1,58 @@
1
+ import type { OperationDef } from './domain-definition.ts';
2
+ /** 이 판단이 보는 재고 한 줄 — 무엇이 어디에 얼마나, 팔 수 있나. */
3
+ export interface StockLine {
4
+ /** 품목 식별자(GTIN). */
5
+ gtin: string;
6
+ /** 지금 있는 자리. */
7
+ location: string;
8
+ /** 수량(개수). */
9
+ qty: number;
10
+ /** 팔 수 있는 상태인가 — 아니면 예약·가공 중이다. */
11
+ sellable: boolean;
12
+ }
13
+ /** 자리 하나 — 타입으로 역할을 안다(작업대인가 보관인가). */
14
+ export interface LocationLine {
15
+ id: string;
16
+ type: string;
17
+ }
18
+ /** 발행할 걸음. */
19
+ export type MakeStep =
20
+ /** 부품을 작업대로 옮긴다 — 자재가 작업 자리에 있어야 소비되기 때문이다. */
21
+ {
22
+ step: 'feed';
23
+ gtin: string;
24
+ from: string;
25
+ to: string;
26
+ qty: number;
27
+ }
28
+ /** 가공한다 — 코어가 소비(시작)·산출(완료)을 실행한다. */
29
+ | {
30
+ step: 'process';
31
+ operation: string;
32
+ at: string;
33
+ };
34
+ export interface MakePlan {
35
+ /** 지금 발행할 걸음들. 비어 있으면 발행할 것이 없다(기다리는 중이거나 만들 수 없다). */
36
+ steps: MakeStep[];
37
+ /**
38
+ * 왜 아무것도 발행하지 않았나 — **조용히 아무 일도 없는 상태를 남기지 않는다.**
39
+ * `not-producible` 만들 방법이 선언되지 않았다 · `waiting-feed` 부품이 오는 중 ·
40
+ * `short-materials` 부품 자체가 부족하다(만들 수 없다) · `no-station` 공정 자리가 없다.
41
+ */
42
+ reason?: 'not-producible' | 'waiting-feed' | 'short-materials' | 'no-station';
43
+ }
44
+ /** 품목 → 그것을 산출하는 공정. 선언에서 파생한다(코드에 표를 두지 않는다). */
45
+ export declare function producedByIndex(ops: readonly OperationDef[]): Map<string, OperationDef>;
46
+ /**
47
+ * 부족한 품목을 만들기 위해 **지금** 발행할 걸음을 정한다.
48
+ *
49
+ * @param gtin 부족한 품목(오더가 요구하는 것).
50
+ * @param shortQty 부족 수량.
51
+ * @param ops 이 트윈에 선언된 오퍼레이션들.
52
+ * @param stock 현재 재고.
53
+ * @param locations 자리 목록(공정 자리 해소용).
54
+ *
55
+ * 한 번에 **한 걸음 종류만** 낸다: 부품이 아직 작업대에 다 오지 않았으면 이송만 내고 가공은 다음에
56
+ * 낸다. 두 걸음을 한꺼번에 내면 가공이 부품 없이 시작을 시도하고, 코어가 거절하는 작업이 큐에 쌓인다.
57
+ */
58
+ export declare function planMakeToOrder(gtin: string, shortQty: number, ops: readonly OperationDef[], stock: readonly StockLine[], locations: readonly LocationLine[]): MakePlan;