@operato/twin-kernel 0.11.1 → 0.11.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.
@@ -1,4 +1,5 @@
1
1
  import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from '@operato/ops-contract';
2
+ import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
2
3
  import type { VocabularyElement } from '@operato/ops-contract';
3
4
  import type { ReducerCheckpoint } from './observed-reducer.ts';
4
5
  import type { EpcisEvent, BizTransactionElement } from '@operato/ops-contract';
@@ -51,6 +52,10 @@ export interface FlowAsset extends EffectivePeriod {
51
52
  id: string;
52
53
  /** 속한 등급들 — 표준 `PhysicalAssetClassID`(복수). */
53
54
  assetClassIds?: string[];
55
+ /** 정격 사용량 — 선언(§RatedUsage). 이 자산이 얼마를 쓰면 교체되나. */
56
+ ratedUsage?: RatedUsage;
57
+ /** 지금까지 쓴 양 — 시뮬은 작업이 끝날 때 올리고, 미러는 원천이 말한 값을 든다. */
58
+ usedUsage?: UsedUsage;
54
59
  location?: string;
55
60
  status: string;
56
61
  taskId: string | null;
@@ -109,6 +114,14 @@ export interface FlowEquipment extends EffectivePeriod {
109
114
  location: string;
110
115
  status: string;
111
116
  taskId: string | null;
117
+ /**
118
+ * 속한 **등급들** — 표준 `EquipmentClassID`(복수). 사람·자산의 `personnelClassIds`·`assetClassIds` 와
119
+ * 같은 뜻이다.
120
+ *
121
+ * **판정에 이 필드를 직접 쓰지 않는다** — `equipmentClassMembership(eq)` 를 거친다. 소속은 이 선언과
122
+ * `kind` 를 합친 것이고, 합치는 규칙이 계약 한 자리에 있다(여섯 자리가 같은 답을 내야 한다).
123
+ */
124
+ equipmentClassIds?: string[];
112
125
  /** 붙박인 자리 — 소속. `location` 은 지금 위치. 고정 설비는 둘이 항상 같다(EquipmentState.homeLocation 참조). */
113
126
  homeLocation?: string;
114
127
  /** 자원 속성 — 표준 `EquipmentProperty`(§ResourceProperty). */
@@ -128,7 +141,7 @@ export interface FlowEquipment extends EffectivePeriod {
128
141
  setupMs: number;
129
142
  downMs: number;
130
143
  goodCount: number;
131
- scrapCount: number;
144
+ nonconformingCount: number;
132
145
  lastChangeoverKey?: string;
133
146
  mtbfMs?: number;
134
147
  mttrMs?: number;
@@ -204,7 +217,7 @@ export interface FlowTask {
204
217
  * 이 작업의 **품질 판정 결과** — 판정이 있었을 때만 채운다 (2026-08-19).
205
218
  *
206
219
  * ── 왜 작업에 적나 ────────────────────────────────────────────────────────
207
- * 양품/불량은 지금까지 두 곳에만 남았다: 설비 누적 카운터(`goodCount`·`scrapCount` — 종류를 모른다)와
220
+ * 양품/불량은 지금까지 두 곳에만 남았다: 설비 누적 카운터(`goodCount`·`nonconformingCount` — 종류를 모른다)와
208
221
  * EPCIS disposition(`sellable`/`non_sellable` — 종류를 모른다). 그래서 **「이 공정의 양품률이 얼마였나」**
209
222
  * 를 되짚을 방법이 저널에 없었다 — 재기동하면 종류별 품질 이력이 통째로 사라진다.
210
223
  *
@@ -359,7 +372,7 @@ export declare function deriveAttentions(view: {
359
372
  status?: string;
360
373
  location?: string;
361
374
  goodCount?: number;
362
- scrapCount?: number;
375
+ nonconformingCount?: number;
363
376
  offShift?: boolean;
364
377
  /**
365
378
  * 이 설비 소식이 끊긴 시간, 그리고 그때까지 가장 오래 끊겼던 시간(밀리초).
@@ -1298,7 +1311,44 @@ export declare abstract class FlowEngine implements TwinKernel {
1298
1311
  subject: string;
1299
1312
  basis: 'declared' | 'twin-local';
1300
1313
  };
1301
- protected emitOp(eventType: string, data: unknown): void;
1314
+ /**
1315
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
1316
+ *
1317
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
1318
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
1319
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
1320
+ * **바뀐 것이 없어도 방출**했다.
1321
+ *
1322
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
1323
+ * 다른 트윈 오더당 1~2건
1324
+ * 틱 로그 「journal 706.92ms(88%)」
1325
+ *
1326
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
1327
+ *
1328
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
1329
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
1330
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
1331
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
1332
+ * 지표 오더 사건을 세는 모든 것이 부푼다
1333
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
1334
+ *
1335
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
1336
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
1337
+ *
1338
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
1339
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
1340
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
1341
+ *
1342
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
1343
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
1344
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
1345
+ *
1346
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
1347
+ *
1348
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
1349
+ */
1350
+ private lastEmitted;
1351
+ protected emitOp(eventType: string, data: unknown, subject?: string): void;
1302
1352
  /**
1303
1353
  * 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
1304
1354
  *
@@ -1354,6 +1404,25 @@ export declare abstract class FlowEngine implements TwinKernel {
1354
1404
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
1355
1405
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
1356
1406
  */
1407
+ /**
1408
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
1409
+ *
1410
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
1411
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
1412
+ *
1413
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
1414
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
1415
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
1416
+ * 말하게 된다.
1417
+ *
1418
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
1419
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
1420
+ *
1421
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
1422
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
1423
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
1424
+ */
1425
+ private countOneUse;
1357
1426
  private releaseAssets;
1358
1427
  /**
1359
1428
  * 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit, computeOee } from '@operato/ops-contract';
12
+ import { OP_EVENT, CMD, USE_UOM, locationStatusOf, readBoardEquipment, equipmentClassMembership, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit, computeOee } from '@operato/ops-contract';
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  /* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
15
15
  import { resolveSubject } from '@operato/ops-contract';
@@ -270,11 +270,11 @@ thresholds) {
270
270
  }
271
271
  }
272
272
  for (const m of view.equipment) {
273
- const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
273
+ const total = (m.goodCount ?? 0) + (m.nonconformingCount ?? 0);
274
274
  const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
275
275
  const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
276
276
  if (total >= minSamples) {
277
- const rate = (m.scrapCount ?? 0) / total;
277
+ const rate = (m.nonconformingCount ?? 0) / total;
278
278
  const declaredPct = thresholds?.scrapPctOf?.(m.id);
279
279
  const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
280
280
  /* 「높음」은 기준의 두 배에서 — 기준이 현장의 것이면 그 두 배도 현장의 것이다(상수 두 벌을 두지 않는다). */
@@ -283,7 +283,7 @@ thresholds) {
283
283
  id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate * 100 >= thresholdPct * 2 ? 'high' : 'medium',
284
284
  anchor: { moverId: m.id, locationId: m.location },
285
285
  params: {
286
- moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100),
286
+ moverId: m.id, goodCount: m.goodCount ?? 0, nonconformingCount: m.nonconformingCount ?? 0, ratePct: Math.round(rate * 100),
287
287
  thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
288
288
  },
289
289
  recommendedActions: [
@@ -817,7 +817,7 @@ export class FlowEngine {
817
817
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOnly(p) };
818
818
  }
819
819
  buildAsset(a) {
820
- return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}),
820
+ return { id: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}),
821
821
  ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOnly(a) };
822
822
  }
823
823
  /**
@@ -900,7 +900,7 @@ export class FlowEngine {
900
900
  buildEquipment(m) {
901
901
  /* **계측은 이 자원이 모델에 들어온 순간부터** 잰다(`metricsSinceMs`). 빠뜨리면 공식이 창을 모르고,
902
902
  예전에는 그것을 epoch 로 메워 갓 뜬 설비가 56,000년 유휴로 보였다(§computeOee). */
903
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, metricsSinceMs: this.clockMs, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
903
+ const eq = { id: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0, metricsSinceMs: this.clockMs, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
904
904
  if (m.mtbfMs !== undefined) {
905
905
  eq.mtbfMs = m.mtbfMs;
906
906
  eq.mttrMs = m.mttrMs;
@@ -915,7 +915,7 @@ export class FlowEngine {
915
915
  addEquipment(m) {
916
916
  if (this.equipment.has(m.id))
917
917
  return;
918
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, metricsSinceMs: this.clockMs };
918
+ const eq = { id: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0, metricsSinceMs: this.clockMs };
919
919
  if (m.mtbfMs !== undefined) {
920
920
  eq.mtbfMs = m.mtbfMs;
921
921
  eq.mttrMs = m.mttrMs;
@@ -1040,7 +1040,7 @@ export class FlowEngine {
1040
1040
  for (const m of snap.equipment) {
1041
1041
  /*
1042
1042
  * 관측된 계측을 이어받는다 — **없어서 0 이었던 게 아니라 잘못 읽어서 0 이었다.**
1043
- * `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`scrapCount` 인데 예전 코드가
1043
+ * `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`nonconformingCount` 인데 예전 코드가
1044
1044
  * `oee.good`·`oee.scrap`(존재하지 않는 키)을 읽어 항상 0 으로 떨어졌고, 시간 누적은 아예 0 으로
1045
1045
  * 시작했다. 그래서 씨앗이 "가동 이력이 전혀 없는 설비" 로 출발해 예측의 OEE 가 리셋됐다.
1046
1046
  *
@@ -1056,8 +1056,13 @@ export class FlowEngine {
1056
1056
  * 근무 시간을 모르고(항상 가용), 폐기를 모르고, 속도를 몰라 이동시간을 상수로 떨어뜨렸다.
1057
1057
  * 관측이 말하는 것(위치·상태·계측)만 덮고 **선언은 지킨다**. */
1058
1058
  const prev = this.equipment.get(m.id);
1059
+ /* 등급 소속은 **선언**이다 — 위 규율대로 마스터가 말한 것이 이긴다. 스냅샷의 값은 마스터가 아직
1060
+ 실리지 않았을 때(관측만으로 되살리는 경로) 소속을 채운다. 둘 다 없으면 키를 만들지 않는다 —
1061
+ 빈 배열을 심으면 "등급이 없는 설비" 와 "모르는 설비" 가 같아진다. */
1062
+ const classIds = prev?.equipmentClassIds ?? m.equipmentClassIds;
1059
1063
  this.equipment.set(m.id, {
1060
1064
  ...(prev ?? {}),
1065
+ ...(classIds ? { equipmentClassIds: classIds } : {}),
1061
1066
  /*
1062
1067
  * `taskId` — **관측이면 원본이 말한 값이다.**
1063
1068
  *
@@ -1068,7 +1073,7 @@ export class FlowEngine {
1068
1073
  */
1069
1074
  id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: purpose === 'observe' ? (m.taskId ?? null) : null,
1070
1075
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
1071
- goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
1076
+ goodCount: oee?.goodCount ?? 0, nonconformingCount: oee?.nonconformingCount ?? 0,
1072
1077
  /*
1073
1078
  * **잰 값을 이어받으면 잰 구간도 이어받아야 한다.**
1074
1079
  *
@@ -1140,7 +1145,12 @@ export class FlowEngine {
1140
1145
  this.assets.set(a.id, {
1141
1146
  ...(prev ?? {}),
1142
1147
  /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
1143
- id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location,
1148
+ id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
1149
+ /* 정격은 선언이라 마스터가 이긴다. 쓴 양은 **관측이 이긴다** — 원천이 센 값이 우리 추정보다
1150
+ 참이고, 되살릴 때 우리 것을 쓰면 그 사이 실제로 쓴 만큼이 사라진다. */
1151
+ ...(prev?.ratedUsage ?? a.ratedUsage ? { ratedUsage: prev?.ratedUsage ?? a.ratedUsage } : {}),
1152
+ ...(a.usedUsage ?? prev?.usedUsage ? { usedUsage: a.usedUsage ?? prev?.usedUsage } : {}),
1153
+ location: a.location,
1144
1154
  status: purpose === 'observe' ? (a.status ?? 'idle') : 'idle',
1145
1155
  taskId: purpose === 'observe' ? (a.taskId ?? null) : null, carrying: a.carrying,
1146
1156
  ...(a.properties ? { properties: a.properties } : {}),
@@ -1526,7 +1536,7 @@ export class FlowEngine {
1526
1536
  m.setupMs = 0;
1527
1537
  m.downMs = 0;
1528
1538
  m.goodCount = 0;
1529
- m.scrapCount = 0;
1539
+ m.nonconformingCount = 0;
1530
1540
  m.holdMs = 0;
1531
1541
  m.metricsSinceMs = this.clockMs; // 계측 창을 지금부터 재시작
1532
1542
  this.emitEquipment(m);
@@ -1634,7 +1644,7 @@ export class FlowEngine {
1634
1644
  }),
1635
1645
  items: [...this.items.values()].map(i => this.itemState(i)),
1636
1646
  equipment: [...this.equipment.values()].map(m => {
1637
- const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
1647
+ const s = { id: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
1638
1648
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
1639
1649
  /*
1640
1650
  * 이동 구간은 **소요를 알 때만** 낸다. 모르면 아래 산수가 전부 0 이나 음수가 되고, 화면은 「제자리에서
@@ -1646,7 +1656,7 @@ export class FlowEngine {
1646
1656
  return s;
1647
1657
  }),
1648
1658
  assets: [...this.assets.values()].map(a => {
1649
- const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
1659
+ const st = { id: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), location: a.location, status: a.status, taskId: a.taskId ?? undefined };
1650
1660
  if (a.carrying)
1651
1661
  st.carrying = a.carrying;
1652
1662
  if (a.properties)
@@ -2308,7 +2318,7 @@ export class FlowEngine {
2308
2318
  /* 판정 시각은 **구간의 시작**이다 — 능력은 그 구간을 두고 묻는 것이므로, 지금 고장 난 설비가 아니라
2309
2319
  그 구간에 못 쓰는 설비를 세야 한다(같은 판정 함수에 다른 시각을 준다). */
2310
2320
  const at = opts.window.fromTime;
2311
- const usableEquipment = [...this.equipment.values()].filter(m => this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment, at).available);
2321
+ const usableEquipment = [...this.equipment.values()].filter(m => this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment, at).available);
2312
2322
  const usablePersons = [...this.persons.values()].filter(p => this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel, at).available);
2313
2323
  const usableAssets = [...this.assets.values()].filter(a => this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset, at).available);
2314
2324
  const base = {
@@ -2725,9 +2735,9 @@ export class FlowEngine {
2725
2735
  if (good)
2726
2736
  m.goodCount++;
2727
2737
  else
2728
- m.scrapCount++;
2738
+ m.nonconformingCount++;
2729
2739
  // 품질 델타 방출 — live OEE 누적기가 good/scrap 을 정확 추적(equipment.status 는 quality 미포함). WMS/YMS 는 미호출→무영향.
2730
- this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
2740
+ this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, nonconformingCount: m.nonconformingCount });
2731
2741
  }
2732
2742
  /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
2733
2743
  oeeOf(m) {
@@ -2810,7 +2820,51 @@ export class FlowEngine {
2810
2820
  subjectOf(kind, localId) {
2811
2821
  return resolveSubject(kind, localId, { tenantId: this.tenantId, scopeId: this.scopeId, identityOf: (k, id) => (k === 'equipment' ? this.equipmentIdentity(id) : undefined) });
2812
2822
  }
2813
- emitOp(eventType, data) {
2823
+ /**
2824
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
2825
+ *
2826
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
2827
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
2828
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
2829
+ * **바뀐 것이 없어도 방출**했다.
2830
+ *
2831
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
2832
+ * 다른 트윈 오더당 1~2건
2833
+ * 틱 로그 「journal 706.92ms(88%)」
2834
+ *
2835
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
2836
+ *
2837
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
2838
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
2839
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
2840
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
2841
+ * 지표 오더 사건을 세는 모든 것이 부푼다
2842
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
2843
+ *
2844
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
2845
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
2846
+ *
2847
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
2848
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
2849
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
2850
+ *
2851
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
2852
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
2853
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
2854
+ *
2855
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
2856
+ *
2857
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
2858
+ */
2859
+ lastEmitted = new Map();
2860
+ emitOp(eventType, data, subject) {
2861
+ if (subject !== undefined) {
2862
+ const key = `${eventType}\u0000${subject}`;
2863
+ const json = JSON.stringify(data);
2864
+ if (this.lastEmitted.get(key) === json)
2865
+ return;
2866
+ this.lastEmitted.set(key, json);
2867
+ }
2814
2868
  this.revision++;
2815
2869
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
2816
2870
  for (const h of this.handlers)
@@ -2842,14 +2896,14 @@ export class FlowEngine {
2842
2896
  ...(t.startTime ? { startTime: t.startTime } : {}),
2843
2897
  ...(t.endTime ? { endTime: t.endTime } : {}),
2844
2898
  ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
2845
- });
2899
+ }, t.id);
2846
2900
  }
2847
2901
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
2848
2902
  emitAsset(a) {
2849
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) });
2903
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) }, a.id);
2850
2904
  }
2851
2905
  emitPerson(p) {
2852
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) });
2906
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) }, p.id);
2853
2907
  /* `offShift` 를 더 이상 실어 보내지 않는다 — 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로
2854
2908
  전이 순간의 판정을 보내면 유휴로 경계를 지난 사람이 옛 값에 머문다. 미러는 같은 함수
2855
2909
  (`offCalendarAt`)를 자기 시각으로 부른다(유효 기간과 같은 규율). */
@@ -2857,7 +2911,7 @@ export class FlowEngine {
2857
2911
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2858
2912
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
2859
2913
  emitEquipment(m, motion) {
2860
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) });
2914
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) }, m.id);
2861
2915
  }
2862
2916
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
2863
2917
  emitOrder(o) {
@@ -2878,7 +2932,7 @@ export class FlowEngine {
2878
2932
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
2879
2933
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
2880
2934
  ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
2881
- });
2935
+ }, o.id);
2882
2936
  }
2883
2937
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
2884
2938
  /**
@@ -2990,11 +3044,40 @@ export class FlowEngine {
2990
3044
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
2991
3045
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
2992
3046
  */
3047
+ /**
3048
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
3049
+ *
3050
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
3051
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
3052
+ *
3053
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
3054
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
3055
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
3056
+ * 말하게 된다.
3057
+ *
3058
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
3059
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
3060
+ *
3061
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
3062
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
3063
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
3064
+ */
3065
+ countOneUse(a) {
3066
+ const rated = a.ratedUsage;
3067
+ if (!rated || rated.uom !== USE_UOM)
3068
+ return;
3069
+ const prev = a.usedUsage;
3070
+ /* 단위가 어긋난 값에 더하지 않는다 — 더하면 두 단위가 한 수에 섞인다. */
3071
+ if (prev && prev.uom !== USE_UOM)
3072
+ return;
3073
+ a.usedUsage = { value: (prev?.value ?? 0) + 1, uom: USE_UOM, ...(prev?.since ? { since: prev.since } : {}) };
3074
+ }
2993
3075
  releaseAssets(t) {
2994
3076
  for (const id of t.assets ?? []) {
2995
3077
  const a = this.assets.get(id);
2996
3078
  if (!a)
2997
3079
  continue;
3080
+ this.countOneUse(a);
2998
3081
  a.status = 'idle';
2999
3082
  a.taskId = null;
3000
3083
  a.location = t.toNode || a.location;
@@ -3557,12 +3640,12 @@ export class FlowEngine {
3557
3640
  const free = (kind, picked = []) => [...this.equipment.values()].filter(m =>
3558
3641
  /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 이 자리에 조건을 늘어놓아서
3559
3642
  설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
3560
- this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment).available &&
3643
+ this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment).available &&
3561
3644
  !picked.includes(m.id) &&
3562
- /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
3563
- 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
3564
- 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
3565
- (kind === undefined || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind)));
3645
+ /* 소속은 **선언한 등급들과 `kind` 를 합친 것**이다(§equipmentClassMembership). 상속도 탄다:
3646
+ 'welder-6axis' 가 'welder' 를 상속하면 'welder' 요구를 만족한다. 예전에는 이 자리가
3647
+ `kind` 하나만 봤고, 그래서 `equipmentClasses` 정의표를 아무도 가리킬 수 없었다. */
3648
+ (kind === undefined || classClosure(equipmentClassMembership(m), this.classDefs.equipment, this.now()).has(kind)));
3566
3649
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
3567
3650
  if (!need?.length) {
3568
3651
  const one = free(t.resourceType)[0];
@@ -3913,10 +3996,28 @@ export class FlowEngine {
3913
3996
  this.consumeMaterials(t, stock);
3914
3997
  this.emitTask(t);
3915
3998
  // process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
3916
- if (t.intent === 'process')
3917
- this.emitEquipment(eq);
3918
- else
3919
- this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
3999
+ const motion = t.intent === 'process'
4000
+ ? undefined
4001
+ : { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 };
4002
+ this.emitEquipment(eq, motion);
4003
+ /*
4004
+ * 함께 잡힌 설비도 이벤트를 낸다 — **해제 쪽과 대칭**(§advanceTasks: "함께 잡힌 설비도 같은
4005
+ * 규칙으로 놓아 준다").
4006
+ *
4007
+ * 이 자리가 대표 한 대만 냈다. 위에서 `rigs` 전부를 `busy` 로 바꾸는데 이벤트는 하나였으니
4008
+ * 미러는 나머지가 **잡힌 것을 못 보고 놓이는 것만 봤다** — 상태⊆이벤트 위반이고, 미러에서 그
4009
+ * 설비는 계속 유휴로 보인다(오류는 없다).
4010
+ *
4011
+ * 예전에는 등급 요구 여러 대를 잡는 일이 드물었다: 소속이 `kind` 하나였으니 "한 등급 2대" 를
4012
+ * 종류가 다른 설비로 채울 수 없었다. 소속을 복수로 열면서 이 경로가 흔해진다.
4013
+ */
4014
+ for (const id of rigs) {
4015
+ if (id === eq.id)
4016
+ continue;
4017
+ const m = this.equipment.get(id);
4018
+ if (m)
4019
+ this.emitEquipment(m, motion);
4020
+ }
3920
4021
  }
3921
4022
  }
3922
4023
  /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
@@ -170,7 +170,7 @@ export interface ReducerCheckpoint {
170
170
  downMs: number;
171
171
  holdMs: number;
172
172
  goodCount: number;
173
- scrapCount: number;
173
+ nonconformingCount: number;
174
174
  }[];
175
175
  /** 자리별 · 속성별 마지막 관측 — 이어 계산가 이 축을 0부터 다시 만들지 않게. */
176
176
  observations?: {
@@ -43,7 +43,7 @@ function effectiveOf(r) {
43
43
  };
44
44
  }
45
45
  function emptyOeeCounters() {
46
- return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
46
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, nonconformingCount: 0 };
47
47
  }
48
48
  export class ObservedReducer {
49
49
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
@@ -128,7 +128,7 @@ export class ObservedReducer {
128
128
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
129
129
  // 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
130
130
  for (const m of readBoardEquipment(model))
131
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
131
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
132
132
  // 사람 기준선(마스터) — person.status 델타로 갱신됨.
133
133
  for (const p of model.persons ?? [])
134
134
  this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.testResults ? { testResults: p.testResults } : {}), ...effectiveOf(p) });
@@ -139,7 +139,7 @@ export class ObservedReducer {
139
139
  for (const p of model.persons ?? [])
140
140
  this.rememberShift(`person:${p.id}`, p);
141
141
  for (const a of readBoardAssets(model))
142
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
142
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
143
143
  }
144
144
  /**
145
145
  * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -196,7 +196,7 @@ export class ObservedReducer {
196
196
  const assetsDropped = dropMissing(this.assets, assets);
197
197
  for (const m of equipment) {
198
198
  if (!this.equipment.has(m.id))
199
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
199
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), ...effectiveOf(m), origin: 'master' });
200
200
  this.rememberShift(`eq:${m.id}`, m);
201
201
  }
202
202
  for (const p of persons) {
@@ -206,7 +206,7 @@ export class ObservedReducer {
206
206
  }
207
207
  for (const a of assets)
208
208
  if (!this.assets.has(a.id))
209
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
209
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.testResults ? { testResults: a.testResults } : {}), ...effectiveOf(a) });
210
210
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
211
211
  }
212
212
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -414,7 +414,8 @@ export class ObservedReducer {
414
414
  판정은 여기서 하지 않는다 — `snapshot()` 이 관측 시각으로 `effectivityAt` 을 부른다. */
415
415
  const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
416
416
  this.markHeard(d.moverId, e);
417
- this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, ...(homeLocation ? { homeLocation } : {}), taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed', ...eqPeriod, ...(d.held ? { held: true } : {}) });
417
+ this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, /* 소속은 선언이라 관측이 지우지 않는다 — 이벤트가 실어 온 것이 있으면 그것, 없으면 이미 알던 것. */
418
+ ...(d.equipmentClassIds ?? known?.equipmentClassIds ? { equipmentClassIds: d.equipmentClassIds ?? known?.equipmentClassIds } : {}), status: d.status, location: d.location, ...(homeLocation ? { homeLocation } : {}), taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed', ...eqPeriod, ...(d.held ? { held: true } : {}) });
418
419
  break;
419
420
  }
420
421
  case OP_EVENT.person: {
@@ -471,11 +472,28 @@ export class ObservedReducer {
471
472
  const d = e.data;
472
473
  if (!d?.moverId)
473
474
  break;
475
+ /*
476
+ * **두 수가 다 있어야 받는다.** 하나라도 수가 아니면 그 줄을 버린다.
477
+ *
478
+ * 예전에는 그대로 놓았다. 그러면 없는 값이 계수기에 `undefined` 로 앉고, 품질률의 분모
479
+ * `goodCount + nonconformingCount` 가 **NaN** 이 된다 — 그 뒤 모든 OEE 가 수가 아니게 되고
480
+ * 화면은 그것을 「빈 값」으로 그린다. 어느 줄이 그랬는지는 아무도 모른다.
481
+ *
482
+ * 실제로 그런 줄이 있다: `scrapCount` 시절의 저널 51행(실측). vocabulary-guard: allow
483
+ * 적힌 것이라 새 이름으로는 비어 있다. **읽어 주지 않는다** — 대개조 중에는 저장된 이름의
484
+ * 호환을 두지 않기로 했고, 두 키가 한 사실을 가리키는 것이 낡은 이름보다 나쁘다. 대신 그
485
+ * 줄을 조용히 0으로 세지 않고 **버린다**: 그 설비의 품질률은 「아직 모른다」로 남고, 다음
486
+ * 산출 보고가 오면 절대 누적이 그것을 다시 채운다.
487
+ *
488
+ * 이 검사는 커넥터가 필드를 빼먹은 경우도 같이 잡는다.
489
+ */
490
+ if (!Number.isFinite(d.goodCount) || !Number.isFinite(d.nonconformingCount))
491
+ break;
474
492
  if (this.stale(`quality:${d.moverId}`, e))
475
493
  return;
476
494
  const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
477
495
  c.goodCount = d.goodCount;
478
- c.scrapCount = d.scrapCount;
496
+ c.nonconformingCount = d.nonconformingCount;
479
497
  this.oeeCounters.set(d.moverId, c);
480
498
  this.markHeard(d.moverId, e);
481
499
  break;
@@ -197,6 +197,34 @@ function stateFieldsOf(caps) {
197
197
  return [...out];
198
198
  }
199
199
 
200
+ // ../ops-contract/dist/rated-usage.js
201
+ var USE_UOM = "C62";
202
+ var num = (n) => typeof n === "number" && Number.isFinite(n);
203
+ function ratedUsageOf(r) {
204
+ const rated = r?.ratedUsage;
205
+ const used = r?.usedUsage;
206
+ const missing = [];
207
+ const countable = rated?.uom === USE_UOM;
208
+ if (!rated)
209
+ missing.push("not-declared");
210
+ else if (!num(rated.limit) || rated.limit <= 0)
211
+ missing.push("invalid-limit");
212
+ if (!used || !num(used.value))
213
+ missing.push("usage-unknown");
214
+ if (rated && used && rated.uom !== used.uom)
215
+ missing.push("uom-mismatch");
216
+ const out = {
217
+ ...rated ? { rated } : {},
218
+ ...used ? { used } : {},
219
+ countable,
220
+ missing
221
+ };
222
+ if (missing.length)
223
+ return out;
224
+ const remaining = Math.max(0, rated.limit - used.value);
225
+ return { ...out, remaining, exhausted: used.value >= rated.limit };
226
+ }
227
+
200
228
  // ../ops-contract/dist/contract.js
201
229
  var ORDER_TERMINAL_STATUS = ["completed", "cancelled"];
202
230
  function isOrderTerminal(o) {
@@ -366,6 +394,15 @@ function classClosure(directIds, defs, at) {
366
394
  }
367
395
  return out;
368
396
  }
397
+ function equipmentClassMembership(e) {
398
+ const out = [];
399
+ for (const id of e?.equipmentClassIds ?? [])
400
+ if (id && !out.includes(id))
401
+ out.push(id);
402
+ if (e?.kind && !out.includes(e.kind))
403
+ out.push(e.kind);
404
+ return out;
405
+ }
369
406
  var PRIORITY_UNSET = Number.POSITIVE_INFINITY;
370
407
  function priorityRank(p) {
371
408
  return typeof p === "number" && Number.isFinite(p) ? p : PRIORITY_UNSET;
@@ -543,6 +580,8 @@ function capabilityOf(r, ctx) {
543
580
  return { available: false, reason: "held" };
544
581
  if (r.status === "down")
545
582
  return { available: false, reason: "down" };
583
+ if (ratedUsageOf(r).exhausted === true)
584
+ return { available: false, reason: "worn-out" };
546
585
  if (at) {
547
586
  const ms2 = parsedMs(at);
548
587
  if (Number.isFinite(ms2)) {
@@ -753,6 +792,7 @@ var ENERGY_EVENT = {
753
792
  */
754
793
  drSuggested: "energy.dr.suggested"
755
794
  };
795
+ var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
756
796
  var CMD = {
757
797
  orderHold: "order.hold",
758
798
  orderResume: "order.resume",
@@ -1535,13 +1575,23 @@ function objectUri(namespace, objId) {
1535
1575
  function bizTransactionUri(namespace, transId) {
1536
1576
  return underNamespace(namespace, "bt", transId);
1537
1577
  }
1578
+ var STRUCTURE_BREAKING = /[%/:?# ]/g;
1579
+ var PERCENT_ENCODED = {
1580
+ "%": "%25",
1581
+ "/": "%2F",
1582
+ ":": "%3A",
1583
+ "?": "%3F",
1584
+ "#": "%23",
1585
+ " ": "%20"
1586
+ };
1538
1587
  function underNamespace(namespace, marker, id) {
1539
1588
  const ns = namespace?.trim();
1540
1589
  if (!ns)
1541
1590
  return void 0;
1542
- const v = String(id);
1543
- if (!v || v.includes("/") || v.includes(":"))
1591
+ const raw = String(id);
1592
+ if (!raw)
1544
1593
  return void 0;
1594
+ const v = raw.replace(STRUCTURE_BREAKING, (c) => PERCENT_ENCODED[c]);
1545
1595
  if (/^https?:\/\/[^/\s]+/.test(ns))
1546
1596
  return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
1547
1597
  if (/^urn:epc(global)?:/.test(ns))
@@ -1731,6 +1781,27 @@ var SPECS = {
1731
1781
  materialActual: "object[]",
1732
1782
  recordTime: "string"
1733
1783
  },
1784
+ /*
1785
+ * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
1786
+ *
1787
+ * `definitionId` 를 요구하는 이유: 이 이름이 없으면 그 줄이 무엇을 가리키는지 아무도 모른다.
1788
+ * 실제로 `materialDefinitionId` 로 온 줄이 저널에 앉았고, 계약의 타입으로 읽으면 비어 있었다.
1789
+ *
1790
+ * `quantity` 를 `positive` 에 두는 이유: 「모른다」는 **비워서** 말한다. 0 으로 실으면 받는
1791
+ * 쪽에서 「0개」와 구별되지 않고, 소요량 집계가 조용히 틀린다.
1792
+ *
1793
+ * `uom` 은 요구하지 않는다 — 계약에서 선택이다(§`MaterialActual`). ERP 경로는 요구하는데
1794
+ * 그것은 **그 경로의 규칙**이다(정산에 단위 없는 수량을 쓸 수 없다). 커널이 그 규칙을 통째로
1795
+ * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
1796
+ */
1797
+ shapes: {
1798
+ materialActual: {
1799
+ required: ["definitionId", "use"],
1800
+ fields: { definitionId: "string", lotId: "string", quantity: "number", uom: "string" },
1801
+ enums: { use: MATERIAL_ACTUAL_USE },
1802
+ positive: ["quantity"]
1803
+ }
1804
+ },
1734
1805
  enums: {
1735
1806
  status: TASK_STATUS,
1736
1807
  intent: ["transport", "process", "dwell"],
@@ -1842,7 +1913,7 @@ var SPECS = {
1842
1913
  identity: "moverId",
1843
1914
  // vocabulary-guard: allow 저널 와이어 필드
1844
1915
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
1845
- required: ["moverId", "good", "goodCount", "scrapCount"],
1916
+ required: ["moverId", "good", "goodCount", "nonconformingCount"],
1846
1917
  // vocabulary-guard: allow
1847
1918
  /*
1848
1919
  * `taskId` 는 **귀속**이다 — 이 산출이 어느 작업의 것인가. 수량의 뜻은 바뀌지 않는다(설비 누적).
@@ -1853,7 +1924,7 @@ var SPECS = {
1853
1924
  * 정체를 `taskId` 로 바꾸지 않는 이유는 OEE 가 설비 단위여서다. 정체를 옮기면 설비 누적을 셀 수
1854
1925
  * 없어진다.
1855
1926
  */
1856
- fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", taskId: "string", recordTime: "string" }
1927
+ fields: { moverId: "string", good: "boolean", goodCount: "number", nonconformingCount: "number", taskId: "string", recordTime: "string" }
1857
1928
  // vocabulary-guard: allow
1858
1929
  },
1859
1930
  /*
@@ -2090,7 +2161,7 @@ function computeOee(c, nowMs) {
2090
2161
  missing.push("planned-busy-time");
2091
2162
  const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
2092
2163
  const availability = planned > 0 ? Math.min(1, c.runMs / planned) : void 0;
2093
- const produced = c.goodCount + c.scrapCount;
2164
+ const produced = c.goodCount + c.nonconformingCount;
2094
2165
  if (produced <= 0)
2095
2166
  missing.push("produced-quantity");
2096
2167
  const quality = produced > 0 ? c.goodCount / produced : void 0;
@@ -2111,7 +2182,7 @@ function computeOee(c, nowMs) {
2111
2182
  downMs: c.downMs,
2112
2183
  delayMs,
2113
2184
  goodCount: c.goodCount,
2114
- scrapCount: c.scrapCount
2185
+ nonconformingCount: c.nonconformingCount
2115
2186
  };
2116
2187
  }
2117
2188
 
@@ -2352,7 +2423,7 @@ function effectiveOf(r) {
2352
2423
  };
2353
2424
  }
2354
2425
  function emptyOeeCounters() {
2355
- return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
2426
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, nonconformingCount: 0 };
2356
2427
  }
2357
2428
  var ObservedReducer = class {
2358
2429
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
@@ -2433,11 +2504,11 @@ var ObservedReducer = class {
2433
2504
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
2434
2505
  for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
2435
2506
  for (const n of readBoardLocations(model)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
2436
- for (const m of readBoardEquipment(model)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
2507
+ for (const m of readBoardEquipment(model)) this.equipment.set(m.id, { id: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
2437
2508
  for (const p of model.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
2438
2509
  for (const m of readBoardEquipment(model)) this.rememberShift(`eq:${m.id}`, m);
2439
2510
  for (const p of model.persons ?? []) this.rememberShift(`person:${p.id}`, p);
2440
- for (const a of readBoardAssets(model)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
2511
+ for (const a of readBoardAssets(model)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
2441
2512
  }
2442
2513
  /**
2443
2514
  * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -2489,7 +2560,7 @@ var ObservedReducer = class {
2489
2560
  const assetsDropped = dropMissing(this.assets, assets);
2490
2561
  for (const m of equipment) {
2491
2562
  if (!this.equipment.has(m.id))
2492
- this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
2563
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
2493
2564
  this.rememberShift(`eq:${m.id}`, m);
2494
2565
  }
2495
2566
  for (const p of persons) {
@@ -2499,7 +2570,7 @@ var ObservedReducer = class {
2499
2570
  }
2500
2571
  for (const a of assets)
2501
2572
  if (!this.assets.has(a.id))
2502
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
2573
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
2503
2574
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
2504
2575
  }
2505
2576
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -2662,7 +2733,20 @@ var ObservedReducer = class {
2662
2733
  const homeLocation = d.homeLocation ?? known?.homeLocation;
2663
2734
  const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
2664
2735
  this.markHeard(d.moverId, e);
2665
- this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, ...homeLocation ? { homeLocation } : {}, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? "observed", ...eqPeriod, ...d.held ? { held: true } : {} });
2736
+ this.equipment.set(d.moverId, {
2737
+ id: d.moverId,
2738
+ kind: d.kind,
2739
+ /* 소속은 선언이라 관측이 지우지 않는다 — 이벤트가 실어 온 것이 있으면 그것, 없으면 이미 알던 것. */
2740
+ ...d.equipmentClassIds ?? known?.equipmentClassIds ? { equipmentClassIds: d.equipmentClassIds ?? known?.equipmentClassIds } : {},
2741
+ status: d.status,
2742
+ location: d.location,
2743
+ ...homeLocation ? { homeLocation } : {},
2744
+ taskId: d.taskId,
2745
+ motion: d.motion,
2746
+ origin: known?.origin ?? "observed",
2747
+ ...eqPeriod,
2748
+ ...d.held ? { held: true } : {}
2749
+ });
2666
2750
  break;
2667
2751
  }
2668
2752
  case OP_EVENT.person: {
@@ -2704,10 +2788,11 @@ var ObservedReducer = class {
2704
2788
  case OP_EVENT.quality: {
2705
2789
  const d = e.data;
2706
2790
  if (!d?.moverId) break;
2791
+ if (!Number.isFinite(d.goodCount) || !Number.isFinite(d.nonconformingCount)) break;
2707
2792
  if (this.stale(`quality:${d.moverId}`, e)) return;
2708
2793
  const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
2709
2794
  c.goodCount = d.goodCount;
2710
- c.scrapCount = d.scrapCount;
2795
+ c.nonconformingCount = d.nonconformingCount;
2711
2796
  this.oeeCounters.set(d.moverId, c);
2712
2797
  this.markHeard(d.moverId, e);
2713
2798
  break;
@@ -3513,11 +3598,11 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3513
3598
  }
3514
3599
  }
3515
3600
  for (const m of view.equipment) {
3516
- const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
3601
+ const total = (m.goodCount ?? 0) + (m.nonconformingCount ?? 0);
3517
3602
  const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
3518
3603
  const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
3519
3604
  if (total >= minSamples) {
3520
- const rate = (m.scrapCount ?? 0) / total;
3605
+ const rate = (m.nonconformingCount ?? 0) / total;
3521
3606
  const declaredPct = thresholds?.scrapPctOf?.(m.id);
3522
3607
  const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
3523
3608
  if (rate * 100 >= thresholdPct) out.push({
@@ -3528,7 +3613,7 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3528
3613
  params: {
3529
3614
  moverId: m.id,
3530
3615
  goodCount: m.goodCount ?? 0,
3531
- scrapCount: m.scrapCount ?? 0,
3616
+ nonconformingCount: m.nonconformingCount ?? 0,
3532
3617
  ratePct: Math.round(rate * 100),
3533
3618
  thresholdPct,
3534
3619
  basis: declaredPct === void 0 ? "default" : "declared"
@@ -3983,6 +4068,8 @@ var FlowEngine = class {
3983
4068
  return {
3984
4069
  id: a.id,
3985
4070
  assetClassIds: a.assetClassIds,
4071
+ ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {},
4072
+ ...a.usedUsage ? { usedUsage: a.usedUsage } : {},
3986
4073
  location: a.homeLocation,
3987
4074
  status: "idle",
3988
4075
  taskId: null,
@@ -4056,7 +4143,7 @@ var FlowEngine = class {
4056
4143
  };
4057
4144
  }
4058
4145
  buildEquipment(m) {
4059
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, metricsSinceMs: this.clockMs, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
4146
+ const eq = { id: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0, metricsSinceMs: this.clockMs, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
4060
4147
  if (m.mtbfMs !== void 0) {
4061
4148
  eq.mtbfMs = m.mtbfMs;
4062
4149
  eq.mttrMs = m.mttrMs;
@@ -4070,7 +4157,7 @@ var FlowEngine = class {
4070
4157
  */
4071
4158
  addEquipment(m) {
4072
4159
  if (this.equipment.has(m.id)) return;
4073
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, metricsSinceMs: this.clockMs };
4160
+ const eq = { id: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0, metricsSinceMs: this.clockMs };
4074
4161
  if (m.mtbfMs !== void 0) {
4075
4162
  eq.mtbfMs = m.mtbfMs;
4076
4163
  eq.mttrMs = m.mttrMs;
@@ -4166,8 +4253,10 @@ var FlowEngine = class {
4166
4253
  for (const m of snap.equipment) {
4167
4254
  const oee = m.oee;
4168
4255
  const prev = this.equipment.get(m.id);
4256
+ const classIds = prev?.equipmentClassIds ?? m.equipmentClassIds;
4169
4257
  this.equipment.set(m.id, {
4170
4258
  ...prev ?? {},
4259
+ ...classIds ? { equipmentClassIds: classIds } : {},
4171
4260
  /*
4172
4261
  * `taskId` — **관측이면 원본이 말한 값이다.**
4173
4262
  *
@@ -4186,7 +4275,7 @@ var FlowEngine = class {
4186
4275
  setupMs: oee?.setupMs ?? 0,
4187
4276
  downMs: oee?.downMs ?? 0,
4188
4277
  goodCount: oee?.goodCount ?? 0,
4189
- scrapCount: oee?.scrapCount ?? 0,
4278
+ nonconformingCount: oee?.nonconformingCount ?? 0,
4190
4279
  /*
4191
4280
  * **잰 값을 이어받으면 잰 구간도 이어받아야 한다.**
4192
4281
  *
@@ -4255,6 +4344,10 @@ var FlowEngine = class {
4255
4344
  /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
4256
4345
  id: a.id,
4257
4346
  assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
4347
+ /* 정격은 선언이라 마스터가 이긴다. 쓴 양은 **관측이 이긴다** — 원천이 센 값이 우리 추정보다
4348
+ 참이고, 되살릴 때 우리 것을 쓰면 그 사이 실제로 쓴 만큼이 사라진다. */
4349
+ ...prev?.ratedUsage ?? a.ratedUsage ? { ratedUsage: prev?.ratedUsage ?? a.ratedUsage } : {},
4350
+ ...a.usedUsage ?? prev?.usedUsage ? { usedUsage: a.usedUsage ?? prev?.usedUsage } : {},
4258
4351
  location: a.location,
4259
4352
  status: purpose === "observe" ? a.status ?? "idle" : "idle",
4260
4353
  taskId: purpose === "observe" ? a.taskId ?? null : null,
@@ -4542,7 +4635,7 @@ var FlowEngine = class {
4542
4635
  m.setupMs = 0;
4543
4636
  m.downMs = 0;
4544
4637
  m.goodCount = 0;
4545
- m.scrapCount = 0;
4638
+ m.nonconformingCount = 0;
4546
4639
  m.holdMs = 0;
4547
4640
  m.metricsSinceMs = this.clockMs;
4548
4641
  this.emitEquipment(m);
@@ -4638,14 +4731,14 @@ var FlowEngine = class {
4638
4731
  }),
4639
4732
  items: [...this.items.values()].map((i) => this.itemState(i)),
4640
4733
  equipment: [...this.equipment.values()].map((m) => {
4641
- const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
4734
+ const s = { id: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
4642
4735
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
4643
4736
  const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
4644
4737
  if (t && motionProgress !== void 0) s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
4645
4738
  return s;
4646
4739
  }),
4647
4740
  assets: [...this.assets.values()].map((a) => {
4648
- const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
4741
+ const st = { id: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
4649
4742
  if (a.carrying) st.carrying = a.carrying;
4650
4743
  if (a.properties) st.properties = a.properties;
4651
4744
  if (a.testSpecificationIds) st.testSpecificationIds = a.testSpecificationIds;
@@ -5220,7 +5313,7 @@ var FlowEngine = class {
5220
5313
  const calendar = calendars.size === 1 ? [...calendars.values()][0] : void 0;
5221
5314
  const at = opts.window.fromTime;
5222
5315
  const usableEquipment = [...this.equipment.values()].filter(
5223
- (m) => this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment, at).available
5316
+ (m) => this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment, at).available
5224
5317
  );
5225
5318
  const usablePersons = [...this.persons.values()].filter(
5226
5319
  (p) => this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel, at).available
@@ -5581,8 +5674,8 @@ var FlowEngine = class {
5581
5674
  const m = moverId ? this.equipment.get(moverId) : void 0;
5582
5675
  if (!m) return;
5583
5676
  if (good) m.goodCount++;
5584
- else m.scrapCount++;
5585
- this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
5677
+ else m.nonconformingCount++;
5678
+ this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, nonconformingCount: m.nonconformingCount });
5586
5679
  }
5587
5680
  /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
5588
5681
  oeeOf(m) {
@@ -5657,7 +5750,50 @@ var FlowEngine = class {
5657
5750
  subjectOf(kind, localId) {
5658
5751
  return resolveSubject(kind, localId, { tenantId: this.tenantId, scopeId: this.scopeId, identityOf: (k, id) => k === "equipment" ? this.equipmentIdentity(id) : void 0 });
5659
5752
  }
5660
- emitOp(eventType, data) {
5753
+ /**
5754
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
5755
+ *
5756
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
5757
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
5758
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
5759
+ * **바뀐 것이 없어도 방출**했다.
5760
+ *
5761
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
5762
+ * 다른 트윈 오더당 1~2건
5763
+ * 틱 로그 「journal 706.92ms(88%)」
5764
+ *
5765
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
5766
+ *
5767
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
5768
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
5769
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
5770
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
5771
+ * 지표 오더 사건을 세는 모든 것이 부푼다
5772
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
5773
+ *
5774
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
5775
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
5776
+ *
5777
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
5778
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
5779
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
5780
+ *
5781
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
5782
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
5783
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
5784
+ *
5785
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
5786
+ *
5787
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
5788
+ */
5789
+ lastEmitted = /* @__PURE__ */ new Map();
5790
+ emitOp(eventType, data, subject) {
5791
+ if (subject !== void 0) {
5792
+ const key = `${eventType}\0${subject}`;
5793
+ const json = JSON.stringify(data);
5794
+ if (this.lastEmitted.get(key) === json) return;
5795
+ this.lastEmitted.set(key, json);
5796
+ }
5661
5797
  this.revision++;
5662
5798
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
5663
5799
  for (const h of this.handlers) h(e);
@@ -5694,19 +5830,19 @@ var FlowEngine = class {
5694
5830
  ...t.startTime ? { startTime: t.startTime } : {},
5695
5831
  ...t.endTime ? { endTime: t.endTime } : {},
5696
5832
  ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
5697
- });
5833
+ }, t.id);
5698
5834
  }
5699
5835
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
5700
5836
  emitAsset(a) {
5701
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying, ...effectiveOnly(a) });
5837
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying, ...effectiveOnly(a) }, a.id);
5702
5838
  }
5703
5839
  emitPerson(p) {
5704
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0, ...p.location ? { location: p.location } : {}, ...effectiveOnly(p) });
5840
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0, ...p.location ? { location: p.location } : {}, ...effectiveOnly(p) }, p.id);
5705
5841
  }
5706
5842
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
5707
5843
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
5708
5844
  emitEquipment(m, motion) {
5709
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, taskId: m.taskId ?? void 0, motion, ...m.held ? { held: true } : {}, ...effectiveOnly(m) });
5845
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, status: m.status, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, taskId: m.taskId ?? void 0, motion, ...m.held ? { held: true } : {}, ...effectiveOnly(m) }, m.id);
5710
5846
  }
5711
5847
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
5712
5848
  emitOrder(o) {
@@ -5732,7 +5868,7 @@ var FlowEngine = class {
5732
5868
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
5733
5869
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
5734
5870
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
5735
- });
5871
+ }, o.id);
5736
5872
  }
5737
5873
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
5738
5874
  /**
@@ -5829,10 +5965,36 @@ var FlowEngine = class {
5829
5965
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
5830
5966
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
5831
5967
  */
5968
+ /**
5969
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
5970
+ *
5971
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
5972
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
5973
+ *
5974
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
5975
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
5976
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
5977
+ * 말하게 된다.
5978
+ *
5979
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
5980
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
5981
+ *
5982
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
5983
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
5984
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
5985
+ */
5986
+ countOneUse(a) {
5987
+ const rated = a.ratedUsage;
5988
+ if (!rated || rated.uom !== USE_UOM) return;
5989
+ const prev = a.usedUsage;
5990
+ if (prev && prev.uom !== USE_UOM) return;
5991
+ a.usedUsage = { value: (prev?.value ?? 0) + 1, uom: USE_UOM, ...prev?.since ? { since: prev.since } : {} };
5992
+ }
5832
5993
  releaseAssets(t) {
5833
5994
  for (const id of t.assets ?? []) {
5834
5995
  const a = this.assets.get(id);
5835
5996
  if (!a) continue;
5997
+ this.countOneUse(a);
5836
5998
  a.status = "idle";
5837
5999
  a.taskId = null;
5838
6000
  a.location = t.toNode || a.location;
@@ -6290,10 +6452,10 @@ var FlowEngine = class {
6290
6452
  (m) => (
6291
6453
  /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 이 자리에 조건을 늘어놓아서
6292
6454
  설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
6293
- this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment).available && !picked2.includes(m.id) && /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
6294
- 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
6295
- 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
6296
- (kind === void 0 || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind))
6455
+ this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment).available && !picked2.includes(m.id) && /* 소속은 **선언한 등급들과 `kind` 를 합친 것**이다(§equipmentClassMembership). 상속도 탄다:
6456
+ 'welder-6axis' 가 'welder' 를 상속하면 'welder' 요구를 만족한다. 예전에는 이 자리가
6457
+ `kind` 하나만 봤고, 그래서 `equipmentClasses` 정의표를 아무도 가리킬 수 없었다. */
6458
+ (kind === void 0 || classClosure(equipmentClassMembership(m), this.classDefs.equipment, this.now()).has(kind))
6297
6459
  )
6298
6460
  );
6299
6461
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
@@ -6592,8 +6754,13 @@ var FlowEngine = class {
6592
6754
  this.assignAssets(t, gear);
6593
6755
  this.consumeMaterials(t, stock);
6594
6756
  this.emitTask(t);
6595
- if (t.intent === "process") this.emitEquipment(eq);
6596
- else this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
6757
+ const motion = t.intent === "process" ? void 0 : { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 };
6758
+ this.emitEquipment(eq, motion);
6759
+ for (const id of rigs) {
6760
+ if (id === eq.id) continue;
6761
+ const m = this.equipment.get(id);
6762
+ if (m) this.emitEquipment(m, motion);
6763
+ }
6597
6764
  }
6598
6765
  }
6599
6766
  /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {