@operato/twin-kernel 0.6.11 → 0.6.12

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.
@@ -4,6 +4,7 @@ import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
5
5
  import type { OperationDef } from './domain-definition.ts';
6
6
  import { type CapacityAnalysis } from './capacity.ts';
7
+ import { type CommittedDemand, type OperationsCapabilityReport } from './operations-capability.ts';
7
8
  export interface FlowLocation {
8
9
  id: string;
9
10
  type: string;
@@ -527,6 +528,37 @@ export declare abstract class FlowEngine implements TwinKernel {
527
528
  }): CapacityAnalysis & {
528
529
  mixedCalendars: boolean;
529
530
  };
531
+ /**
532
+ * **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`(§operations-capability).
533
+ *
534
+ * `capacity()` 는 비율(시간당)을 낸다. 이것은 **구간의 양을 종류로 갈라** 낸다: 약정·가용·달성불가.
535
+ * 종류가 없으면 "지금 주문을 더 받을 수 있나" 에 답할 수 없다 — 이 개념을 쓰는 가장 흔한 질문이다.
536
+ *
537
+ * 커널이 답하는 이유는 `capacity()` 와 같다(사실이 전부 여기 있다). 여기서 더한 것은 둘이다:
538
+ *
539
+ * 1. **지금 쓸 수 없는 자원을 걸러 같은 계산을 한 번 더** 돌린다 — 그 차이가 달성불가다. 판정은
540
+ * 다시 만들지 않고 `capabilityOf` 를 부른다(규칙 한 벌). 표준의 `Unattainable` 정의가 우리
541
+ * 판정과 정확히 겹친다: 설비 상태·점유·인원 가용성 때문에 못 쓰는 용량.
542
+ * 2. **약정**은 오더에서 온다 — 이 구간에 납기가 걸린 미완 오더의 잔량.
543
+ */
544
+ operationsCapability(opts: {
545
+ unitsPerDay: number;
546
+ sampleWeekStartMs: number;
547
+ window: {
548
+ fromTime: string;
549
+ toTime: string;
550
+ };
551
+ }): OperationsCapabilityReport & {
552
+ mixedCalendars: boolean;
553
+ };
554
+ /**
555
+ * 약정의 재료 — **이 구간에 납기가 걸린 미완 오더의 잔량.**
556
+ *
557
+ * 이행분을 뺀다(`requested − fulfilled`): 이미 만들어 보낸 것은 능력을 더 쓰지 않는다. 납기를 모르는
558
+ * 오더는 **구간에 넣지 않는다** — 짐작해 넣으면 남은 능력이 실제보다 적게 보이고, 그 보수성은
559
+ * "받을 수 있는데 못 받는다" 는 잘못된 판단을 만든다.
560
+ */
561
+ protected committedDemands(): CommittedDemand[];
530
562
  /**
531
563
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
532
564
  *
@@ -14,6 +14,7 @@ import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
15
15
  import { parseIsoDuration } from "./iso-duration.js";
16
16
  import { analyzeCapacity } from "./capacity.js";
17
+ import { operationsCapabilityOf } from "./operations-capability.js";
17
18
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
18
19
  /**
19
20
  * 선언한 유효 기간만 뽑는다 — 마스터가 말하지 않으면 필드를 만들지 않는다.
@@ -1140,6 +1141,71 @@ export class FlowEngine {
1140
1141
  });
1141
1142
  return { ...analysis, mixedCalendars: calendars.size > 1 };
1142
1143
  }
1144
+ /**
1145
+ * **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`(§operations-capability).
1146
+ *
1147
+ * `capacity()` 는 비율(시간당)을 낸다. 이것은 **구간의 양을 종류로 갈라** 낸다: 약정·가용·달성불가.
1148
+ * 종류가 없으면 "지금 주문을 더 받을 수 있나" 에 답할 수 없다 — 이 개념을 쓰는 가장 흔한 질문이다.
1149
+ *
1150
+ * 커널이 답하는 이유는 `capacity()` 와 같다(사실이 전부 여기 있다). 여기서 더한 것은 둘이다:
1151
+ *
1152
+ * 1. **지금 쓸 수 없는 자원을 걸러 같은 계산을 한 번 더** 돌린다 — 그 차이가 달성불가다. 판정은
1153
+ * 다시 만들지 않고 `capabilityOf` 를 부른다(규칙 한 벌). 표준의 `Unattainable` 정의가 우리
1154
+ * 판정과 정확히 겹친다: 설비 상태·점유·인원 가용성 때문에 못 쓰는 용량.
1155
+ * 2. **약정**은 오더에서 온다 — 이 구간에 납기가 걸린 미완 오더의 잔량.
1156
+ */
1157
+ operationsCapability(opts) {
1158
+ const calendars = new Map();
1159
+ for (const m of this.equipment.values())
1160
+ if (m.workCalendar?.length)
1161
+ calendars.set(JSON.stringify(m.workCalendar), m.workCalendar);
1162
+ const calendar = calendars.size === 1 ? [...calendars.values()][0] : undefined;
1163
+ /* 판정 시각은 **구간의 시작**이다 — 능력은 그 구간을 두고 묻는 것이므로, 지금 고장 난 설비가 아니라
1164
+ 그 구간에 못 쓰는 설비를 세야 한다(같은 판정 함수에 다른 시각을 준다). */
1165
+ const at = opts.window.fromTime;
1166
+ const usableEquipment = [...this.equipment.values()].filter(m => this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment, at).available);
1167
+ const usablePersons = [...this.persons.values()].filter(p => this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel, at).available);
1168
+ const usableAssets = [...this.assets.values()].filter(a => this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset, at).available);
1169
+ const base = {
1170
+ operations: [...this.operationSpecs.values()],
1171
+ ...(this.routeKeys() ? { route: this.routeKeys() } : {}),
1172
+ locations: [...this.locations.values()],
1173
+ ...(calendar ? { calendar } : {}),
1174
+ ...(this.boardDef?.utcOffsetMinutes !== undefined ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {}),
1175
+ sampleWeekStartMs: opts.sampleWeekStartMs,
1176
+ unitsPerDay: opts.unitsPerDay
1177
+ };
1178
+ const eqOf = (list) => list.map(m => ({ kind: m.kind, mtbfMs: m.mtbfMs, mttrMs: m.mttrMs }));
1179
+ const report = operationsCapabilityOf({
1180
+ capacity: { ...base, equipment: eqOf([...this.equipment.values()]), persons: [...this.persons.values()], assets: [...this.assets.values()] },
1181
+ usableCapacity: { ...base, equipment: eqOf(usableEquipment), persons: usablePersons, assets: usableAssets },
1182
+ window: opts.window,
1183
+ ...(calendar ? { calendar } : {}),
1184
+ ...(this.boardDef?.utcOffsetMinutes !== undefined ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {}),
1185
+ demands: this.committedDemands(),
1186
+ publishedDate: this.now()
1187
+ });
1188
+ return { ...report, mixedCalendars: calendars.size > 1 };
1189
+ }
1190
+ /**
1191
+ * 약정의 재료 — **이 구간에 납기가 걸린 미완 오더의 잔량.**
1192
+ *
1193
+ * 이행분을 뺀다(`requested − fulfilled`): 이미 만들어 보낸 것은 능력을 더 쓰지 않는다. 납기를 모르는
1194
+ * 오더는 **구간에 넣지 않는다** — 짐작해 넣으면 남은 능력이 실제보다 적게 보이고, 그 보수성은
1195
+ * "받을 수 있는데 못 받는다" 는 잘못된 판단을 만든다.
1196
+ */
1197
+ committedDemands() {
1198
+ const out = [];
1199
+ for (const o of this.orders.values()) {
1200
+ if (o.status === 'completed' || o.status === 'cancelled')
1201
+ continue;
1202
+ const remaining = Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
1203
+ if (!remaining)
1204
+ continue;
1205
+ out.push({ remaining, ...(o.endTime ? { dueTime: o.endTime } : {}) });
1206
+ }
1207
+ return out;
1208
+ }
1143
1209
  /**
1144
1210
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
1145
1211
  *
@@ -1903,11 +1969,14 @@ export class FlowEngine {
1903
1969
  * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
1904
1970
  * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
1905
1971
  */
1906
- capabilityOfResource(r, directClassIds, defs) {
1972
+ capabilityOfResource(r, directClassIds, defs,
1973
+ /* 판정 시각 — 기본은 지금이지만 **구간을 두고 묻는 쪽**이 다른 시각을 준다(§operationsCapability).
1974
+ 능력은 "이번 주에 쓸 수 있나" 이므로 지금 고장 난 것이 아니라 그 구간에 못 쓰는 것을 세야 한다. */
1975
+ at = this.now()) {
1907
1976
  return capabilityOf(r, {
1908
- at: this.now(),
1977
+ at,
1909
1978
  utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
1910
- requiredTests: requiredTestsFor(directClassIds, defs, this.now())
1979
+ requiredTests: requiredTestsFor(directClassIds, defs, at)
1911
1980
  });
1912
1981
  }
1913
1982
  claimPersonnel(t) {
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export * from './yms-profile.ts';
23
23
  export * from './mes-profile.ts';
24
24
  export * from './flow-engine.ts';
25
25
  export * from './capacity.ts';
26
+ export * from './operations-capability.ts';
26
27
  export { WmsKernel } from './kernel.ts';
27
28
  export { YmsKernel } from './yms-kernel.ts';
28
29
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export * from "./yms-profile.js";
23
23
  export * from "./mes-profile.js";
24
24
  export * from "./flow-engine.js";
25
25
  export * from "./capacity.js";
26
+ export * from "./operations-capability.js";
26
27
  export { WmsKernel } from "./kernel.js";
27
28
  export { YmsKernel } from "./yms-kernel.js";
28
29
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
@@ -0,0 +1,106 @@
1
+ import { type CapacityAnalysis, type CapacityInput, type CapacityRequirement } from './capacity.ts';
2
+ import { type ISOTime, type WorkCalendarEntry } from './contract.ts';
3
+ /**
4
+ * 능력의 종류 — **표준 열거형**(B2MML `CapabilityTypeType`). 우리 낱말을 새로 만들지 않는다.
5
+ *
6
+ * 표준 정의(스키마 주석 그대로 옮김):
7
+ * - `Committed` — 앞으로 쓰기로 약정된 용량
8
+ * - `Unattainable` — 설비 상태·점유·인원·자재 때문에 앞으로 쓸 수 없는 용량
9
+ * - `Available` — 앞으로 더 쓸 수 있는 용량
10
+ * - `Used` / `Unused` — **과거** 값(품질 합격분 / 안 쓰였거나 불합격분)
11
+ * - `Total` — 과거로는 Used+Unused, 미래로는 Available+Unattainable+Committed
12
+ */
13
+ export type CapabilityType = 'Used' | 'Unused' | 'Total' | 'Committed' | 'Available' | 'Unattainable' | 'Other';
14
+ /** 활동의 종류 — 표준 열거형(`OperationsTypeType`). 지금 우리는 생산만 다룬다. */
15
+ export type OperationsType = 'Production' | 'Maintenance' | 'Quality' | 'Inventory' | 'Mixed' | 'Other';
16
+ /** 한 공정의 능력 — 미래 분해. */
17
+ export interface OperationCapability {
18
+ operation: string;
19
+ /**
20
+ * 이 공정이 이 구간에 낼 수 있는 최대량 — `Committed + Available + Unattainable`.
21
+ *
22
+ * **`null` 은 "능력 없음" 이 아니라 "모른다"** 다: 공정 명세가 자원을 하나도 요구하지 않으면 상한이
23
+ * 서지 않는다. 0 으로 메우면 "이 공정은 아무것도 못 한다" 는 없는 사실이 생긴다.
24
+ */
25
+ total: number | null;
26
+ /** 이미 약정된 몫 — 이 구간에 납기가 걸린 미완 오더의 잔량. */
27
+ committed: number;
28
+ /**
29
+ * 지금 쓸 수 없는 자원 때문에 잃는 몫. 자원의 현재 사실을 받지 못했으면 **`null`**(모른다).
30
+ * 0 으로 내면 "고장도 자격 만료도 없다" 는 없는 사실이 된다.
31
+ */
32
+ unattainable: number | null;
33
+ /**
34
+ * 남은 능력 — `total − committed − unattainable`.
35
+ *
36
+ * **음수를 0 으로 깎지 않는다.** 음수는 사실이다(받을 수 없는 물량을 받았다). 가장 알아야 할 상태를
37
+ * `Math.max(0, …)` 하나로 덮는 일이 실제로 이 트윈에서 여러 번 있었다.
38
+ */
39
+ available: number | null;
40
+ /** 무엇이 묶고 있나 — 축과 등급(용량 분석이 이미 낸다). */
41
+ constraint?: CapacityRequirement;
42
+ }
43
+ export interface OperationsCapabilityReport {
44
+ /** 표준 `OperationsType` — 지금은 생산뿐이다(정비·품질 능력은 별도 보고서가 될 자리). */
45
+ operationsType: OperationsType;
46
+ /**
47
+ * 어느 구간의 능력인가 — **구간 없이 능력은 존재하지 않는다**(표준 `StartTime`·`EndTime`).
48
+ * `workingHours` 는 그 구간의 **가동 시간**이다(벽시계 시간이 아니다 — 쉬는 시간에는 안 나온다).
49
+ */
50
+ window: {
51
+ fromTime: ISOTime;
52
+ toTime: ISOTime;
53
+ workingHours: number;
54
+ };
55
+ perOperation: OperationCapability[];
56
+ /** 라인을 묶는 공정 — 용량 분석의 판정을 그대로 쓴다. */
57
+ bottleneck?: CapacityAnalysis['bottleneck'];
58
+ /** 이 보고서를 낸 시각(표준 `PublishedDate`). 부르는 쪽이 준다 — 커널은 벽시계를 읽지 않는다. */
59
+ publishedDate?: ISOTime;
60
+ }
61
+ /** 이 구간에 납기가 걸린 미완 오더 — 약정의 재료. */
62
+ export interface CommittedDemand {
63
+ /** 남은 수량(요청 − 이행). 음수는 0 으로 본다(이행 초과는 약정이 아니다). */
64
+ remaining: number;
65
+ /** 납기 — 이 구간 안이어야 이 구간의 약정이다. 없으면 구간에 속하지 않는다(짐작하지 않는다). */
66
+ dueTime?: ISOTime;
67
+ /** 어느 공정의 약정인가. 없으면 **모든 공정에** 걸린다(라인 전체가 그 물량을 통과시켜야 한다). */
68
+ operation?: string;
69
+ }
70
+ export interface OperationsCapabilityInput {
71
+ /** 전량 선언 기준 — 이 모델이 가진 모든 자원. */
72
+ capacity: CapacityInput;
73
+ /**
74
+ * **지금 쓸 수 있는 자원만** 남긴 같은 입력. 없으면 `unattainable` 은 `null` 이다.
75
+ *
76
+ * 판정을 여기서 다시 하지 않는 이유: 판정은 한 벌이어야 하고 그 한 벌은 `capabilityOf` 다
77
+ * (고장·보류·교대·휴일·자격 만료·유효기간). 부르는 쪽이 그것으로 걸러 준 자원을 넘긴다.
78
+ */
79
+ usableCapacity?: CapacityInput;
80
+ window: {
81
+ fromTime: ISOTime;
82
+ toTime: ISOTime;
83
+ };
84
+ calendar?: readonly WorkCalendarEntry[];
85
+ utcOffsetMinutes?: number;
86
+ demands?: readonly CommittedDemand[];
87
+ operationsType?: OperationsType;
88
+ publishedDate?: ISOTime;
89
+ }
90
+ /**
91
+ * 구간의 **가동 시간** — 1분 간격으로 캘린더에 묻는다.
92
+ *
93
+ * 규칙을 다시 적지 않는다(`inWorkCalendarAt` 한 곳에만 있다). 캘린더가 없으면 종일 가동으로 본다 —
94
+ * 제약이 **선언되지 않은** 것이고, 선언이 없으면 커널은 멈출 이유를 모른다(용량 분석과 같은 규율).
95
+ *
96
+ * 비용은 구간에 비례한다(1년 = 약 53만 번의 값싼 호출). 근사하지 않는 이유: 한 주를 재서 곱하면
97
+ * 공휴일이 사라지고, 그 오차는 조용하다.
98
+ */
99
+ export declare function workingHoursBetween(calendar: readonly WorkCalendarEntry[] | undefined, fromMs: number, toMs: number, utcOffsetMinutes?: number): number;
100
+ /**
101
+ * 생산 능력 보고서 — 있는 용량 분석 위에 **구간 · 종류 · 약정**을 올린다.
102
+ *
103
+ * 용량 분석을 두 번 부른다(전량 · 가용). 같은 함수를 두 번 부르는 것이 요점이다: 달성불가를 따로
104
+ * 계산하면 규칙이 두 벌이 되고, 두 벌은 갈라진다.
105
+ */
106
+ export declare function operationsCapabilityOf(input: OperationsCapabilityInput): OperationsCapabilityReport;
@@ -0,0 +1,114 @@
1
+ /*
2
+ * 생산 능력 — **ISA-95 Part 4 `OperationsCapability`**: "이 현장이 어느 구간에 얼마나 할 수 있는가."
3
+ *
4
+ * ── 왜 용량 분석만으로는 부족한가 ────────────────────────────────────────────
5
+ * `capacity.ts` 는 이미 네 축(설비·인원·자산·자리) 정상상태 상한과 병목을 낸다. 그런데 그것은 **비율**
6
+ * 이다(시간당 몇 대). 표준이 게시하라는 것은 **구간의 양**이고, 그 양을 **종류로 가른다.**
7
+ *
8
+ * 종류가 없으면 답할 수 없는 질문이 있다 — 그리고 그것이 이 개념을 쓰는 가장 흔한 질문이다:
9
+ * **"지금 주문을 더 받을 수 있나."** 상한만 있으면 "시간당 12대" 까지고, 이미 약속한 물량을 빼야
10
+ * 비로소 남은 능력이 나온다.
11
+ *
12
+ * ── 표준의 어휘를 그대로 쓴다 ────────────────────────────────────────────────
13
+ * `CapabilityType` 은 자유 문자열이 아니라 **열거형**이다(B2MML-Common.xsd 대조 완료). 그리고 표준은
14
+ * `Total` 을 **두 갈래**로 쪼갠다:
15
+ *
16
+ * · 과거 — `Total = Used + Unused` (쓴 몫 · 못 쓴 몫)
17
+ * · 미래 — `Total = Committed + Available + Unattainable` (약정 · 가용 · 달성불가)
18
+ *
19
+ * 이 보고서는 **미래 쪽**이다. 과거 쪽은 이미 두 곳에 있다(설비 OEE 의 가동/유휴, 호스트의 구간
20
+ * 가동률) — 우리가 "가동률이 두 벌이다" 로 본 것이 여기서 설명된다: **두 벌인 것이 맞고, 표준이 그
21
+ * 둘을 다른 종류로 부른다.** 합칠 일이 아니라 각자 어느 종류인지 밝힐 일이다.
22
+ *
23
+ * `Unattainable` 의 표준 정의는 우리 판정과 정확히 겹친다 — *설비 상태·설비 점유·인원 가용성·자재
24
+ * 가용성 때문에 앞으로 쓸 수 없는 용량.* 그래서 이 모듈은 그것을 **다시 판정하지 않는다**:
25
+ * 부르는 쪽이 "전량 선언 기준" 과 "지금 쓸 수 있는 자원 기준" 두 벌을 주면, 그 **차이가 곧
26
+ * 달성불가**다(§unattainableOf). 판정은 한 벌이어야 하고 그 한 벌은 `capabilityOf` 다.
27
+ */
28
+ import { analyzeCapacity } from "./capacity.js";
29
+ import { inWorkCalendarAt } from "./contract.js";
30
+ /**
31
+ * 구간의 **가동 시간** — 1분 간격으로 캘린더에 묻는다.
32
+ *
33
+ * 규칙을 다시 적지 않는다(`inWorkCalendarAt` 한 곳에만 있다). 캘린더가 없으면 종일 가동으로 본다 —
34
+ * 제약이 **선언되지 않은** 것이고, 선언이 없으면 커널은 멈출 이유를 모른다(용량 분석과 같은 규율).
35
+ *
36
+ * 비용은 구간에 비례한다(1년 = 약 53만 번의 값싼 호출). 근사하지 않는 이유: 한 주를 재서 곱하면
37
+ * 공휴일이 사라지고, 그 오차는 조용하다.
38
+ */
39
+ export function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
40
+ if (!(toMs > fromMs))
41
+ return 0;
42
+ if (!calendar?.length)
43
+ return (toMs - fromMs) / 3_600_000;
44
+ let minutes = 0;
45
+ for (let ms = fromMs; ms < toMs; ms += 60_000)
46
+ if (inWorkCalendarAt(calendar, ms, utcOffsetMinutes))
47
+ minutes++;
48
+ return minutes / 60;
49
+ }
50
+ /**
51
+ * 생산 능력 보고서 — 있는 용량 분석 위에 **구간 · 종류 · 약정**을 올린다.
52
+ *
53
+ * 용량 분석을 두 번 부른다(전량 · 가용). 같은 함수를 두 번 부르는 것이 요점이다: 달성불가를 따로
54
+ * 계산하면 규칙이 두 벌이 되고, 두 벌은 갈라진다.
55
+ */
56
+ export function operationsCapabilityOf(input) {
57
+ const fromMs = Date.parse(input.window.fromTime);
58
+ const toMs = Date.parse(input.window.toTime);
59
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
60
+ throw new Error(`능력의 구간을 읽을 수 없다: ${input.window.fromTime} ~ ${input.window.toTime}`);
61
+ }
62
+ if (toMs <= fromMs)
63
+ throw new Error(`능력의 구간이 뒤집혔다: ${input.window.fromTime} ~ ${input.window.toTime}`);
64
+ const workingHours = workingHoursBetween(input.calendar ?? input.capacity.calendar, fromMs, toMs, input.utcOffsetMinutes);
65
+ const full = analyzeCapacity(input.capacity);
66
+ const usable = input.usableCapacity ? analyzeCapacity(input.usableCapacity) : undefined;
67
+ /** 비율(시간당) → 구간의 양. 상한이 무한(요구가 없는 공정)이면 **모른다**. */
68
+ const amountOf = (perHour) => (Number.isFinite(perHour) ? perHour * workingHours : null);
69
+ const committedOf = (operation) => {
70
+ let sum = 0;
71
+ for (const d of input.demands ?? []) {
72
+ if (d.operation !== undefined && d.operation !== operation)
73
+ continue;
74
+ /* 납기가 없으면 이 구간의 약정이 아니다 — 짐작해서 넣으면 남은 능력이 실제보다 적게 보인다. */
75
+ if (!d.dueTime)
76
+ continue;
77
+ const due = Date.parse(d.dueTime);
78
+ if (!Number.isFinite(due) || due < fromMs || due >= toMs)
79
+ continue;
80
+ sum += Math.max(0, d.remaining);
81
+ }
82
+ return sum;
83
+ };
84
+ const perOperation = full.operations.map(op => {
85
+ const total = amountOf(op.perHour);
86
+ const committed = committedOf(op.operation);
87
+ /*
88
+ * 달성불가 = 전량 기준 − 가용 자원 기준. 자원의 현재 사실을 못 받았으면 **모른다**(0 이 아니다).
89
+ * 음수가 되는 일은 없어야 하지만(가용 ⊆ 전량), 되면 그것을 감추지 않고 0 으로 깎는다 — 부분집합이
90
+ * 아닌 입력을 받은 것이므로 값을 지어내는 대신 아래 `available` 이 어긋남을 드러낸다.
91
+ */
92
+ let unattainable = null;
93
+ if (usable) {
94
+ const usableAmount = amountOf(usable.operations.find(o => o.operation === op.operation)?.perHour ?? NaN);
95
+ unattainable = total != null && usableAmount != null ? Math.max(0, total - usableAmount) : null;
96
+ }
97
+ const available = total == null || unattainable == null ? null : total - committed - unattainable;
98
+ return {
99
+ operation: op.operation,
100
+ total,
101
+ committed,
102
+ unattainable,
103
+ available,
104
+ ...(op.constraint ? { constraint: op.constraint } : {})
105
+ };
106
+ });
107
+ return {
108
+ operationsType: input.operationsType ?? 'Production',
109
+ window: { fromTime: input.window.fromTime, toTime: input.window.toTime, workingHours },
110
+ perOperation,
111
+ ...(full.bottleneck ? { bottleneck: full.bottleneck } : {}),
112
+ ...(input.publishedDate ? { publishedDate: input.publishedDate } : {})
113
+ };
114
+ }
@@ -106,6 +106,7 @@ __export(index_exports, {
106
106
  objectEvent: () => objectEvent,
107
107
  offCalendarAt: () => offCalendarAt,
108
108
  offCalendarReasonAt: () => offCalendarReasonAt,
109
+ operationsCapabilityOf: () => operationsCapabilityOf,
109
110
  parseEpc: () => parseEpc,
110
111
  parseIsoDuration: () => parseIsoDuration,
111
112
  partialFitPolicy: () => partialFitPolicy,
@@ -133,6 +134,7 @@ __export(index_exports, {
133
134
  validateEpcisEvent: () => validateEpcisEvent,
134
135
  validateScenario: () => validateScenario,
135
136
  weekdayAt: () => weekdayAt,
137
+ workingHoursBetween: () => workingHoursBetween,
136
138
  workingTimeOfWeek: () => workingTimeOfWeek
137
139
  });
138
140
  module.exports = __toCommonJS(index_exports);
@@ -2240,6 +2242,63 @@ function analyzeCapacity(input) {
2240
2242
  };
2241
2243
  }
2242
2244
 
2245
+ // src/operations-capability.ts
2246
+ function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
2247
+ if (!(toMs > fromMs)) return 0;
2248
+ if (!calendar?.length) return (toMs - fromMs) / 36e5;
2249
+ let minutes = 0;
2250
+ for (let ms2 = fromMs; ms2 < toMs; ms2 += 6e4) if (inWorkCalendarAt(calendar, ms2, utcOffsetMinutes)) minutes++;
2251
+ return minutes / 60;
2252
+ }
2253
+ function operationsCapabilityOf(input) {
2254
+ const fromMs = Date.parse(input.window.fromTime);
2255
+ const toMs = Date.parse(input.window.toTime);
2256
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
2257
+ throw new Error(`\uB2A5\uB825\uC758 \uAD6C\uAC04\uC744 \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${input.window.fromTime} ~ ${input.window.toTime}`);
2258
+ }
2259
+ if (toMs <= fromMs) throw new Error(`\uB2A5\uB825\uC758 \uAD6C\uAC04\uC774 \uB4A4\uC9D1\uD614\uB2E4: ${input.window.fromTime} ~ ${input.window.toTime}`);
2260
+ const workingHours = workingHoursBetween(input.calendar ?? input.capacity.calendar, fromMs, toMs, input.utcOffsetMinutes);
2261
+ const full = analyzeCapacity(input.capacity);
2262
+ const usable = input.usableCapacity ? analyzeCapacity(input.usableCapacity) : void 0;
2263
+ const amountOf = (perHour) => Number.isFinite(perHour) ? perHour * workingHours : null;
2264
+ const committedOf = (operation) => {
2265
+ let sum = 0;
2266
+ for (const d of input.demands ?? []) {
2267
+ if (d.operation !== void 0 && d.operation !== operation) continue;
2268
+ if (!d.dueTime) continue;
2269
+ const due = Date.parse(d.dueTime);
2270
+ if (!Number.isFinite(due) || due < fromMs || due >= toMs) continue;
2271
+ sum += Math.max(0, d.remaining);
2272
+ }
2273
+ return sum;
2274
+ };
2275
+ const perOperation = full.operations.map((op) => {
2276
+ const total = amountOf(op.perHour);
2277
+ const committed = committedOf(op.operation);
2278
+ let unattainable = null;
2279
+ if (usable) {
2280
+ const usableAmount = amountOf(usable.operations.find((o) => o.operation === op.operation)?.perHour ?? NaN);
2281
+ unattainable = total != null && usableAmount != null ? Math.max(0, total - usableAmount) : null;
2282
+ }
2283
+ const available = total == null || unattainable == null ? null : total - committed - unattainable;
2284
+ return {
2285
+ operation: op.operation,
2286
+ total,
2287
+ committed,
2288
+ unattainable,
2289
+ available,
2290
+ ...op.constraint ? { constraint: op.constraint } : {}
2291
+ };
2292
+ });
2293
+ return {
2294
+ operationsType: input.operationsType ?? "Production",
2295
+ window: { fromTime: input.window.fromTime, toTime: input.window.toTime, workingHours },
2296
+ perOperation,
2297
+ ...full.bottleneck ? { bottleneck: full.bottleneck } : {},
2298
+ ...input.publishedDate ? { publishedDate: input.publishedDate } : {}
2299
+ };
2300
+ }
2301
+
2243
2302
  // src/flow-engine.ts
2244
2303
  var BASE_EPOCH = Date.parse("2026-01-01T00:00:00Z");
2245
2304
  function effectiveOnly(r) {
@@ -3244,6 +3303,71 @@ var FlowEngine = class {
3244
3303
  });
3245
3304
  return { ...analysis, mixedCalendars: calendars.size > 1 };
3246
3305
  }
3306
+ /**
3307
+ * **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`(§operations-capability).
3308
+ *
3309
+ * `capacity()` 는 비율(시간당)을 낸다. 이것은 **구간의 양을 종류로 갈라** 낸다: 약정·가용·달성불가.
3310
+ * 종류가 없으면 "지금 주문을 더 받을 수 있나" 에 답할 수 없다 — 이 개념을 쓰는 가장 흔한 질문이다.
3311
+ *
3312
+ * 커널이 답하는 이유는 `capacity()` 와 같다(사실이 전부 여기 있다). 여기서 더한 것은 둘이다:
3313
+ *
3314
+ * 1. **지금 쓸 수 없는 자원을 걸러 같은 계산을 한 번 더** 돌린다 — 그 차이가 달성불가다. 판정은
3315
+ * 다시 만들지 않고 `capabilityOf` 를 부른다(규칙 한 벌). 표준의 `Unattainable` 정의가 우리
3316
+ * 판정과 정확히 겹친다: 설비 상태·점유·인원 가용성 때문에 못 쓰는 용량.
3317
+ * 2. **약정**은 오더에서 온다 — 이 구간에 납기가 걸린 미완 오더의 잔량.
3318
+ */
3319
+ operationsCapability(opts) {
3320
+ const calendars = /* @__PURE__ */ new Map();
3321
+ for (const m of this.equipment.values()) if (m.workCalendar?.length) calendars.set(JSON.stringify(m.workCalendar), m.workCalendar);
3322
+ const calendar = calendars.size === 1 ? [...calendars.values()][0] : void 0;
3323
+ const at = opts.window.fromTime;
3324
+ const usableEquipment = [...this.equipment.values()].filter(
3325
+ (m) => this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment, at).available
3326
+ );
3327
+ const usablePersons = [...this.persons.values()].filter(
3328
+ (p) => this.capabilityOfResource(p, p.personnelClassIds, this.classDefs.personnel, at).available
3329
+ );
3330
+ const usableAssets = [...this.assets.values()].filter(
3331
+ (a) => this.capabilityOfResource(a, a.assetClassIds, this.classDefs.asset, at).available
3332
+ );
3333
+ const base = {
3334
+ operations: [...this.operationSpecs.values()],
3335
+ ...this.routeKeys() ? { route: this.routeKeys() } : {},
3336
+ locations: [...this.locations.values()],
3337
+ ...calendar ? { calendar } : {},
3338
+ ...this.boardDef?.utcOffsetMinutes !== void 0 ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {},
3339
+ sampleWeekStartMs: opts.sampleWeekStartMs,
3340
+ unitsPerDay: opts.unitsPerDay
3341
+ };
3342
+ const eqOf = (list) => list.map((m) => ({ kind: m.kind, mtbfMs: m.mtbfMs, mttrMs: m.mttrMs }));
3343
+ const report = operationsCapabilityOf({
3344
+ capacity: { ...base, equipment: eqOf([...this.equipment.values()]), persons: [...this.persons.values()], assets: [...this.assets.values()] },
3345
+ usableCapacity: { ...base, equipment: eqOf(usableEquipment), persons: usablePersons, assets: usableAssets },
3346
+ window: opts.window,
3347
+ ...calendar ? { calendar } : {},
3348
+ ...this.boardDef?.utcOffsetMinutes !== void 0 ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {},
3349
+ demands: this.committedDemands(),
3350
+ publishedDate: this.now()
3351
+ });
3352
+ return { ...report, mixedCalendars: calendars.size > 1 };
3353
+ }
3354
+ /**
3355
+ * 약정의 재료 — **이 구간에 납기가 걸린 미완 오더의 잔량.**
3356
+ *
3357
+ * 이행분을 뺀다(`requested − fulfilled`): 이미 만들어 보낸 것은 능력을 더 쓰지 않는다. 납기를 모르는
3358
+ * 오더는 **구간에 넣지 않는다** — 짐작해 넣으면 남은 능력이 실제보다 적게 보이고, 그 보수성은
3359
+ * "받을 수 있는데 못 받는다" 는 잘못된 판단을 만든다.
3360
+ */
3361
+ committedDemands() {
3362
+ const out = [];
3363
+ for (const o of this.orders.values()) {
3364
+ if (o.status === "completed" || o.status === "cancelled") continue;
3365
+ const remaining = Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
3366
+ if (!remaining) continue;
3367
+ out.push({ remaining, ...o.endTime ? { dueTime: o.endTime } : {} });
3368
+ }
3369
+ return out;
3370
+ }
3247
3371
  /**
3248
3372
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
3249
3373
  *
@@ -3931,11 +4055,11 @@ var FlowEngine = class {
3931
4055
  * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
3932
4056
  * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
3933
4057
  */
3934
- capabilityOfResource(r, directClassIds, defs) {
4058
+ capabilityOfResource(r, directClassIds, defs, at = this.now()) {
3935
4059
  return capabilityOf(r, {
3936
- at: this.now(),
4060
+ at,
3937
4061
  utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
3938
- requiredTests: requiredTestsFor(directClassIds, defs, this.now())
4062
+ requiredTests: requiredTestsFor(directClassIds, defs, at)
3939
4063
  });
3940
4064
  }
3941
4065
  claimPersonnel(t) {
@@ -5273,6 +5397,7 @@ function retiredVocabularyIn(line) {
5273
5397
  objectEvent,
5274
5398
  offCalendarAt,
5275
5399
  offCalendarReasonAt,
5400
+ operationsCapabilityOf,
5276
5401
  parseEpc,
5277
5402
  parseIsoDuration,
5278
5403
  partialFitPolicy,
@@ -5300,5 +5425,6 @@ function retiredVocabularyIn(line) {
5300
5425
  validateEpcisEvent,
5301
5426
  validateScenario,
5302
5427
  weekdayAt,
5428
+ workingHoursBetween,
5303
5429
  workingTimeOfWeek
5304
5430
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
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": {