@operato/twin-kernel 0.11.1 → 0.11.2

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
  * 이 설비 소식이 끊긴 시간, 그리고 그때까지 가장 오래 끊겼던 시간(밀리초).
@@ -1354,6 +1367,25 @@ export declare abstract class FlowEngine implements TwinKernel {
1354
1367
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
1355
1368
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
1356
1369
  */
1370
+ /**
1371
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
1372
+ *
1373
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
1374
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
1375
+ *
1376
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
1377
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
1378
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
1379
+ * 말하게 된다.
1380
+ *
1381
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
1382
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
1383
+ *
1384
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
1385
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
1386
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
1387
+ */
1388
+ private countOneUse;
1357
1389
  private releaseAssets;
1358
1390
  /**
1359
1391
  * 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
@@ -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) {
@@ -2846,7 +2856,7 @@ export class FlowEngine {
2846
2856
  }
2847
2857
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
2848
2858
  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) });
2859
+ 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) });
2850
2860
  }
2851
2861
  emitPerson(p) {
2852
2862
  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) });
@@ -2857,7 +2867,7 @@ export class FlowEngine {
2857
2867
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2858
2868
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
2859
2869
  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) });
2870
+ 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) });
2861
2871
  }
2862
2872
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
2863
2873
  emitOrder(o) {
@@ -2990,11 +3000,40 @@ export class FlowEngine {
2990
3000
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
2991
3001
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
2992
3002
  */
3003
+ /**
3004
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
3005
+ *
3006
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
3007
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
3008
+ *
3009
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
3010
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
3011
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
3012
+ * 말하게 된다.
3013
+ *
3014
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
3015
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
3016
+ *
3017
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
3018
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
3019
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
3020
+ */
3021
+ countOneUse(a) {
3022
+ const rated = a.ratedUsage;
3023
+ if (!rated || rated.uom !== USE_UOM)
3024
+ return;
3025
+ const prev = a.usedUsage;
3026
+ /* 단위가 어긋난 값에 더하지 않는다 — 더하면 두 단위가 한 수에 섞인다. */
3027
+ if (prev && prev.uom !== USE_UOM)
3028
+ return;
3029
+ a.usedUsage = { value: (prev?.value ?? 0) + 1, uom: USE_UOM, ...(prev?.since ? { since: prev.since } : {}) };
3030
+ }
2993
3031
  releaseAssets(t) {
2994
3032
  for (const id of t.assets ?? []) {
2995
3033
  const a = this.assets.get(id);
2996
3034
  if (!a)
2997
3035
  continue;
3036
+ this.countOneUse(a);
2998
3037
  a.status = 'idle';
2999
3038
  a.taskId = null;
3000
3039
  a.location = t.toNode || a.location;
@@ -3557,12 +3596,12 @@ export class FlowEngine {
3557
3596
  const free = (kind, picked = []) => [...this.equipment.values()].filter(m =>
3558
3597
  /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 이 자리에 조건을 늘어놓아서
3559
3598
  설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
3560
- this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment).available &&
3599
+ this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment).available &&
3561
3600
  !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)));
3601
+ /* 소속은 **선언한 등급들과 `kind` 를 합친 것**이다(§equipmentClassMembership). 상속도 탄다:
3602
+ 'welder-6axis' 가 'welder' 를 상속하면 'welder' 요구를 만족한다. 예전에는 이 자리가
3603
+ `kind` 하나만 봤고, 그래서 `equipmentClasses` 정의표를 아무도 가리킬 수 없었다. */
3604
+ (kind === undefined || classClosure(equipmentClassMembership(m), this.classDefs.equipment, this.now()).has(kind)));
3566
3605
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
3567
3606
  if (!need?.length) {
3568
3607
  const one = free(t.resourceType)[0];
@@ -3913,10 +3952,28 @@ export class FlowEngine {
3913
3952
  this.consumeMaterials(t, stock);
3914
3953
  this.emitTask(t);
3915
3954
  // 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 });
3955
+ const motion = t.intent === 'process'
3956
+ ? undefined
3957
+ : { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 };
3958
+ this.emitEquipment(eq, motion);
3959
+ /*
3960
+ * 함께 잡힌 설비도 이벤트를 낸다 — **해제 쪽과 대칭**(§advanceTasks: "함께 잡힌 설비도 같은
3961
+ * 규칙으로 놓아 준다").
3962
+ *
3963
+ * 이 자리가 대표 한 대만 냈다. 위에서 `rigs` 전부를 `busy` 로 바꾸는데 이벤트는 하나였으니
3964
+ * 미러는 나머지가 **잡힌 것을 못 보고 놓이는 것만 봤다** — 상태⊆이벤트 위반이고, 미러에서 그
3965
+ * 설비는 계속 유휴로 보인다(오류는 없다).
3966
+ *
3967
+ * 예전에는 등급 요구 여러 대를 잡는 일이 드물었다: 소속이 `kind` 하나였으니 "한 등급 2대" 를
3968
+ * 종류가 다른 설비로 채울 수 없었다. 소속을 복수로 열면서 이 경로가 흔해진다.
3969
+ */
3970
+ for (const id of rigs) {
3971
+ if (id === eq.id)
3972
+ continue;
3973
+ const m = this.equipment.get(id);
3974
+ if (m)
3975
+ this.emitEquipment(m, motion);
3976
+ }
3920
3977
  }
3921
3978
  }
3922
3979
  /** 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)) {
@@ -1842,7 +1881,7 @@ var SPECS = {
1842
1881
  identity: "moverId",
1843
1882
  // vocabulary-guard: allow 저널 와이어 필드
1844
1883
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
1845
- required: ["moverId", "good", "goodCount", "scrapCount"],
1884
+ required: ["moverId", "good", "goodCount", "nonconformingCount"],
1846
1885
  // vocabulary-guard: allow
1847
1886
  /*
1848
1887
  * `taskId` 는 **귀속**이다 — 이 산출이 어느 작업의 것인가. 수량의 뜻은 바뀌지 않는다(설비 누적).
@@ -1853,7 +1892,7 @@ var SPECS = {
1853
1892
  * 정체를 `taskId` 로 바꾸지 않는 이유는 OEE 가 설비 단위여서다. 정체를 옮기면 설비 누적을 셀 수
1854
1893
  * 없어진다.
1855
1894
  */
1856
- fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", taskId: "string", recordTime: "string" }
1895
+ fields: { moverId: "string", good: "boolean", goodCount: "number", nonconformingCount: "number", taskId: "string", recordTime: "string" }
1857
1896
  // vocabulary-guard: allow
1858
1897
  },
1859
1898
  /*
@@ -2090,7 +2129,7 @@ function computeOee(c, nowMs) {
2090
2129
  missing.push("planned-busy-time");
2091
2130
  const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
2092
2131
  const availability = planned > 0 ? Math.min(1, c.runMs / planned) : void 0;
2093
- const produced = c.goodCount + c.scrapCount;
2132
+ const produced = c.goodCount + c.nonconformingCount;
2094
2133
  if (produced <= 0)
2095
2134
  missing.push("produced-quantity");
2096
2135
  const quality = produced > 0 ? c.goodCount / produced : void 0;
@@ -2111,7 +2150,7 @@ function computeOee(c, nowMs) {
2111
2150
  downMs: c.downMs,
2112
2151
  delayMs,
2113
2152
  goodCount: c.goodCount,
2114
- scrapCount: c.scrapCount
2153
+ nonconformingCount: c.nonconformingCount
2115
2154
  };
2116
2155
  }
2117
2156
 
@@ -2352,7 +2391,7 @@ function effectiveOf(r) {
2352
2391
  };
2353
2392
  }
2354
2393
  function emptyOeeCounters() {
2355
- return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
2394
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, nonconformingCount: 0 };
2356
2395
  }
2357
2396
  var ObservedReducer = class {
2358
2397
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
@@ -2433,11 +2472,11 @@ var ObservedReducer = class {
2433
2472
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
2434
2473
  for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
2435
2474
  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" });
2475
+ 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
2476
  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
2477
  for (const m of readBoardEquipment(model)) this.rememberShift(`eq:${m.id}`, m);
2439
2478
  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) });
2479
+ 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
2480
  }
2442
2481
  /**
2443
2482
  * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
@@ -2489,7 +2528,7 @@ var ObservedReducer = class {
2489
2528
  const assetsDropped = dropMissing(this.assets, assets);
2490
2529
  for (const m of equipment) {
2491
2530
  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" });
2531
+ 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
2532
  this.rememberShift(`eq:${m.id}`, m);
2494
2533
  }
2495
2534
  for (const p of persons) {
@@ -2499,7 +2538,7 @@ var ObservedReducer = class {
2499
2538
  }
2500
2539
  for (const a of assets)
2501
2540
  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) });
2541
+ 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
2542
  return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
2504
2543
  }
2505
2544
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
@@ -2662,7 +2701,20 @@ var ObservedReducer = class {
2662
2701
  const homeLocation = d.homeLocation ?? known?.homeLocation;
2663
2702
  const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
2664
2703
  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 } : {} });
2704
+ this.equipment.set(d.moverId, {
2705
+ id: d.moverId,
2706
+ kind: d.kind,
2707
+ /* 소속은 선언이라 관측이 지우지 않는다 — 이벤트가 실어 온 것이 있으면 그것, 없으면 이미 알던 것. */
2708
+ ...d.equipmentClassIds ?? known?.equipmentClassIds ? { equipmentClassIds: d.equipmentClassIds ?? known?.equipmentClassIds } : {},
2709
+ status: d.status,
2710
+ location: d.location,
2711
+ ...homeLocation ? { homeLocation } : {},
2712
+ taskId: d.taskId,
2713
+ motion: d.motion,
2714
+ origin: known?.origin ?? "observed",
2715
+ ...eqPeriod,
2716
+ ...d.held ? { held: true } : {}
2717
+ });
2666
2718
  break;
2667
2719
  }
2668
2720
  case OP_EVENT.person: {
@@ -2704,10 +2756,11 @@ var ObservedReducer = class {
2704
2756
  case OP_EVENT.quality: {
2705
2757
  const d = e.data;
2706
2758
  if (!d?.moverId) break;
2759
+ if (!Number.isFinite(d.goodCount) || !Number.isFinite(d.nonconformingCount)) break;
2707
2760
  if (this.stale(`quality:${d.moverId}`, e)) return;
2708
2761
  const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
2709
2762
  c.goodCount = d.goodCount;
2710
- c.scrapCount = d.scrapCount;
2763
+ c.nonconformingCount = d.nonconformingCount;
2711
2764
  this.oeeCounters.set(d.moverId, c);
2712
2765
  this.markHeard(d.moverId, e);
2713
2766
  break;
@@ -3513,11 +3566,11 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3513
3566
  }
3514
3567
  }
3515
3568
  for (const m of view.equipment) {
3516
- const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
3569
+ const total = (m.goodCount ?? 0) + (m.nonconformingCount ?? 0);
3517
3570
  const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
3518
3571
  const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
3519
3572
  if (total >= minSamples) {
3520
- const rate = (m.scrapCount ?? 0) / total;
3573
+ const rate = (m.nonconformingCount ?? 0) / total;
3521
3574
  const declaredPct = thresholds?.scrapPctOf?.(m.id);
3522
3575
  const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
3523
3576
  if (rate * 100 >= thresholdPct) out.push({
@@ -3528,7 +3581,7 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3528
3581
  params: {
3529
3582
  moverId: m.id,
3530
3583
  goodCount: m.goodCount ?? 0,
3531
- scrapCount: m.scrapCount ?? 0,
3584
+ nonconformingCount: m.nonconformingCount ?? 0,
3532
3585
  ratePct: Math.round(rate * 100),
3533
3586
  thresholdPct,
3534
3587
  basis: declaredPct === void 0 ? "default" : "declared"
@@ -3983,6 +4036,8 @@ var FlowEngine = class {
3983
4036
  return {
3984
4037
  id: a.id,
3985
4038
  assetClassIds: a.assetClassIds,
4039
+ ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {},
4040
+ ...a.usedUsage ? { usedUsage: a.usedUsage } : {},
3986
4041
  location: a.homeLocation,
3987
4042
  status: "idle",
3988
4043
  taskId: null,
@@ -4056,7 +4111,7 @@ var FlowEngine = class {
4056
4111
  };
4057
4112
  }
4058
4113
  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) };
4114
+ 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
4115
  if (m.mtbfMs !== void 0) {
4061
4116
  eq.mtbfMs = m.mtbfMs;
4062
4117
  eq.mttrMs = m.mttrMs;
@@ -4070,7 +4125,7 @@ var FlowEngine = class {
4070
4125
  */
4071
4126
  addEquipment(m) {
4072
4127
  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 };
4128
+ 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
4129
  if (m.mtbfMs !== void 0) {
4075
4130
  eq.mtbfMs = m.mtbfMs;
4076
4131
  eq.mttrMs = m.mttrMs;
@@ -4166,8 +4221,10 @@ var FlowEngine = class {
4166
4221
  for (const m of snap.equipment) {
4167
4222
  const oee = m.oee;
4168
4223
  const prev = this.equipment.get(m.id);
4224
+ const classIds = prev?.equipmentClassIds ?? m.equipmentClassIds;
4169
4225
  this.equipment.set(m.id, {
4170
4226
  ...prev ?? {},
4227
+ ...classIds ? { equipmentClassIds: classIds } : {},
4171
4228
  /*
4172
4229
  * `taskId` — **관측이면 원본이 말한 값이다.**
4173
4230
  *
@@ -4186,7 +4243,7 @@ var FlowEngine = class {
4186
4243
  setupMs: oee?.setupMs ?? 0,
4187
4244
  downMs: oee?.downMs ?? 0,
4188
4245
  goodCount: oee?.goodCount ?? 0,
4189
- scrapCount: oee?.scrapCount ?? 0,
4246
+ nonconformingCount: oee?.nonconformingCount ?? 0,
4190
4247
  /*
4191
4248
  * **잰 값을 이어받으면 잰 구간도 이어받아야 한다.**
4192
4249
  *
@@ -4255,6 +4312,10 @@ var FlowEngine = class {
4255
4312
  /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
4256
4313
  id: a.id,
4257
4314
  assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
4315
+ /* 정격은 선언이라 마스터가 이긴다. 쓴 양은 **관측이 이긴다** — 원천이 센 값이 우리 추정보다
4316
+ 참이고, 되살릴 때 우리 것을 쓰면 그 사이 실제로 쓴 만큼이 사라진다. */
4317
+ ...prev?.ratedUsage ?? a.ratedUsage ? { ratedUsage: prev?.ratedUsage ?? a.ratedUsage } : {},
4318
+ ...a.usedUsage ?? prev?.usedUsage ? { usedUsage: a.usedUsage ?? prev?.usedUsage } : {},
4258
4319
  location: a.location,
4259
4320
  status: purpose === "observe" ? a.status ?? "idle" : "idle",
4260
4321
  taskId: purpose === "observe" ? a.taskId ?? null : null,
@@ -4542,7 +4603,7 @@ var FlowEngine = class {
4542
4603
  m.setupMs = 0;
4543
4604
  m.downMs = 0;
4544
4605
  m.goodCount = 0;
4545
- m.scrapCount = 0;
4606
+ m.nonconformingCount = 0;
4546
4607
  m.holdMs = 0;
4547
4608
  m.metricsSinceMs = this.clockMs;
4548
4609
  this.emitEquipment(m);
@@ -4638,14 +4699,14 @@ var FlowEngine = class {
4638
4699
  }),
4639
4700
  items: [...this.items.values()].map((i) => this.itemState(i)),
4640
4701
  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) };
4702
+ 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
4703
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
4643
4704
  const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
4644
4705
  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
4706
  return s;
4646
4707
  }),
4647
4708
  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 };
4709
+ 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
4710
  if (a.carrying) st.carrying = a.carrying;
4650
4711
  if (a.properties) st.properties = a.properties;
4651
4712
  if (a.testSpecificationIds) st.testSpecificationIds = a.testSpecificationIds;
@@ -5220,7 +5281,7 @@ var FlowEngine = class {
5220
5281
  const calendar = calendars.size === 1 ? [...calendars.values()][0] : void 0;
5221
5282
  const at = opts.window.fromTime;
5222
5283
  const usableEquipment = [...this.equipment.values()].filter(
5223
- (m) => this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment, at).available
5284
+ (m) => this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment, at).available
5224
5285
  );
5225
5286
  const usablePersons = [...this.persons.values()].filter(
5226
5287
  (p) => this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel, at).available
@@ -5581,8 +5642,8 @@ var FlowEngine = class {
5581
5642
  const m = moverId ? this.equipment.get(moverId) : void 0;
5582
5643
  if (!m) return;
5583
5644
  if (good) m.goodCount++;
5584
- else m.scrapCount++;
5585
- this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
5645
+ else m.nonconformingCount++;
5646
+ this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, nonconformingCount: m.nonconformingCount });
5586
5647
  }
5587
5648
  /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
5588
5649
  oeeOf(m) {
@@ -5698,7 +5759,7 @@ var FlowEngine = class {
5698
5759
  }
5699
5760
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
5700
5761
  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) });
5762
+ 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) });
5702
5763
  }
5703
5764
  emitPerson(p) {
5704
5765
  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) });
@@ -5706,7 +5767,7 @@ var FlowEngine = class {
5706
5767
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
5707
5768
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
5708
5769
  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) });
5770
+ 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) });
5710
5771
  }
5711
5772
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
5712
5773
  emitOrder(o) {
@@ -5829,10 +5890,36 @@ var FlowEngine = class {
5829
5890
  * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
5830
5891
  * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
5831
5892
  */
5893
+ /**
5894
+ * 이 자산을 **한 번 썼다**고 센다 — 작업이 자산을 놓을 때.
5895
+ *
5896
+ * ── 셀 수 있는 것만 센다 ────────────────────────────────────────────────
5897
+ * 정격이 「번」(`USE_UOM`)이면 작업 하나가 곧 한 번이다. 그러면 올릴 수 있다.
5898
+ *
5899
+ * **정격이 「미터」면 올리지 않는다.** 그 작업이 몇 미터를 깎았는지는 공정이 말해 줘야 하는
5900
+ * 사실이고, 커널이 「한 번이니 1미터」로 놓으면 그것은 지어낸 값이다. 라우터 비트가 50미터
5901
+ * 정격인데 한 번에 0.2미터를 깎는다면, 1로 세는 순간 실제 수명의 250분의 1에서 교체하라고
5902
+ * 말하게 된다.
5903
+ *
5904
+ * 정격을 선언하지 않은 자산도 세지 않는다. 셀 이유가 없고(견줄 것이 없다), 세면 화면에 「쓴
5905
+ * 양은 있는데 정격은 없다」는 반쪽 사실이 생긴다.
5906
+ *
5907
+ * ── 미러에서는 부르지 않는다 ────────────────────────────────────────────
5908
+ * 이 함수는 시뮬레이션이 자기 우주에서 세는 것이다. 미러의 쓴 양은 **원천이 센 값**이고
5909
+ * (`AssetStatusDelta.usedUsage`), 우리가 거기에 더하면 같은 사용을 두 번 센다.
5910
+ */
5911
+ countOneUse(a) {
5912
+ const rated = a.ratedUsage;
5913
+ if (!rated || rated.uom !== USE_UOM) return;
5914
+ const prev = a.usedUsage;
5915
+ if (prev && prev.uom !== USE_UOM) return;
5916
+ a.usedUsage = { value: (prev?.value ?? 0) + 1, uom: USE_UOM, ...prev?.since ? { since: prev.since } : {} };
5917
+ }
5832
5918
  releaseAssets(t) {
5833
5919
  for (const id of t.assets ?? []) {
5834
5920
  const a = this.assets.get(id);
5835
5921
  if (!a) continue;
5922
+ this.countOneUse(a);
5836
5923
  a.status = "idle";
5837
5924
  a.taskId = null;
5838
5925
  a.location = t.toNode || a.location;
@@ -6290,10 +6377,10 @@ var FlowEngine = class {
6290
6377
  (m) => (
6291
6378
  /* 사람과 **같은 판정**(§capabilityOfResource) — 예전에는 이 자리에 조건을 늘어놓아서
6292
6379
  설비의 검사 결과는 배정에서 아예 읽히지 않았다(사람만 자격을 봤다). */
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))
6380
+ this.capabilityOfResource(m, equipmentClassMembership(m), this.classDefs.equipment).available && !picked2.includes(m.id) && /* 소속은 **선언한 등급들과 `kind` 를 합친 것**이다(§equipmentClassMembership). 상속도 탄다:
6381
+ 'welder-6axis' 가 'welder' 를 상속하면 'welder' 요구를 만족한다. 예전에는 이 자리가
6382
+ `kind` 하나만 봤고, 그래서 `equipmentClasses` 정의표를 아무도 가리킬 수 없었다. */
6383
+ (kind === void 0 || classClosure(equipmentClassMembership(m), this.classDefs.equipment, this.now()).has(kind))
6297
6384
  )
6298
6385
  );
6299
6386
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
@@ -6592,8 +6679,13 @@ var FlowEngine = class {
6592
6679
  this.assignAssets(t, gear);
6593
6680
  this.consumeMaterials(t, stock);
6594
6681
  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 });
6682
+ const motion = t.intent === "process" ? void 0 : { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 };
6683
+ this.emitEquipment(eq, motion);
6684
+ for (const id of rigs) {
6685
+ if (id === eq.id) continue;
6686
+ const m = this.equipment.get(id);
6687
+ if (m) this.emitEquipment(m, motion);
6688
+ }
6597
6689
  }
6598
6690
  }
6599
6691
  /** 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.2",
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": {