@operato/ops-contract 0.9.11 → 0.9.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.
@@ -1,3 +1,4 @@
1
+ import type { BizTransactionElement } from './epcis.ts';
1
2
  /** 개체 관측 — 물건 하나가 어디서 무엇이 됐나. */
2
3
  export interface CanonicalRecord {
3
4
  /**
@@ -21,6 +22,30 @@ export interface CanonicalRecord {
21
22
  action: string;
22
23
  bizStep: string;
23
24
  disposition?: string;
25
+ /**
26
+ * 오더 참조 — 「이 사실은 어느 오더 것인가」(표준 §7.3.5.4).
27
+ *
28
+ * ── 왜 뒤늦게 생겼나 (2026-09-07) ─────────────────────────────────────────
29
+ * 이 칸이 **다섯째 층**이었다. 같은 필드가 층마다 따로 막혀 있었고, 아래를 열어도 위에서 계속 사라졌다.
30
+ *
31
+ * ```
32
+ * 1 EpcisHeaderOptions objectEvent 만 자기 인자로 갖고 있었다
33
+ * 2 매핑 스펙 커넥터가 어느 칸을 읽을지 선언할 자리가 없었다
34
+ * 3 유입 규칙 셋 선언해도 트윈이 그 스펙을 안 읽었다
35
+ * 4 정규 레코드 ← 여기. 내는 쪽이 값을 담을 칸이 없었다
36
+ * 5 원본 봉투 담을 값을 원본이 안 실었다
37
+ * ```
38
+ *
39
+ * 받는 쪽은 처음부터 다 서 있었다 — 저널의 `biz_transaction` 칸과 인덱스, 이력추적 질의, 커널이 그
40
+ * 값을 채우는 네 자리. 그래서 커널 안에서 나는 사실만 그 칸을 갖고 실 시스템이 먹이는 트윈은 비었다
41
+ * (시뮬 2,492건 대 `mes-line-a` 0건).
42
+ *
43
+ * ⚠ `type` 은 **거래의 종류**다. 생산 오더는 `BTT_PRODORDER`(`btt:prodorder`)이고 구매 오더의
44
+ * `btt:po` 와 다르다. 문자열을 손으로 적지 말고 프로파일의 상수를 부른다 — 나는 이 자리에서 `btt:po`
45
+ * 를 잘못 적어 전달했고, 인티그레이션 레인이 잡았다. 진짜 표준 URI 를 틀린 뜻으로 쓰는 것은 지어낸
46
+ * 이름보다 나쁘다: 읽는 사람이 그 뜻인 줄 배운다.
47
+ */
48
+ bizTransactionList?: BizTransactionElement[];
24
49
  readPoint?: string;
25
50
  bizLocation?: string;
26
51
  }
@@ -65,6 +90,30 @@ export interface CanonicalTransformationRecord {
65
90
  transformationID?: string;
66
91
  bizStep: string;
67
92
  disposition?: string;
93
+ /**
94
+ * 오더 참조 — 「이 사실은 어느 오더 것인가」(표준 §7.3.5.4).
95
+ *
96
+ * ── 왜 뒤늦게 생겼나 (2026-09-07) ─────────────────────────────────────────
97
+ * 이 칸이 **다섯째 층**이었다. 같은 필드가 층마다 따로 막혀 있었고, 아래를 열어도 위에서 계속 사라졌다.
98
+ *
99
+ * ```
100
+ * 1 EpcisHeaderOptions objectEvent 만 자기 인자로 갖고 있었다
101
+ * 2 매핑 스펙 커넥터가 어느 칸을 읽을지 선언할 자리가 없었다
102
+ * 3 유입 규칙 셋 선언해도 트윈이 그 스펙을 안 읽었다
103
+ * 4 정규 레코드 ← 여기. 내는 쪽이 값을 담을 칸이 없었다
104
+ * 5 원본 봉투 담을 값을 원본이 안 실었다
105
+ * ```
106
+ *
107
+ * 받는 쪽은 처음부터 다 서 있었다 — 저널의 `biz_transaction` 칸과 인덱스, 이력추적 질의, 커널이 그
108
+ * 값을 채우는 네 자리. 그래서 커널 안에서 나는 사실만 그 칸을 갖고 실 시스템이 먹이는 트윈은 비었다
109
+ * (시뮬 2,492건 대 `mes-line-a` 0건).
110
+ *
111
+ * ⚠ `type` 은 **거래의 종류**다. 생산 오더는 `BTT_PRODORDER`(`btt:prodorder`)이고 구매 오더의
112
+ * `btt:po` 와 다르다. 문자열을 손으로 적지 말고 프로파일의 상수를 부른다 — 나는 이 자리에서 `btt:po`
113
+ * 를 잘못 적어 전달했고, 인티그레이션 레인이 잡았다. 진짜 표준 URI 를 틀린 뜻으로 쓰는 것은 지어낸
114
+ * 이름보다 나쁘다: 읽는 사람이 그 뜻인 줄 배운다.
115
+ */
116
+ bizTransactionList?: BizTransactionElement[];
68
117
  readPoint?: string;
69
118
  bizLocation?: string;
70
119
  }
@@ -81,6 +130,30 @@ export interface CanonicalAggregationRecord {
81
130
  action: string;
82
131
  bizStep: string;
83
132
  disposition?: string;
133
+ /**
134
+ * 오더 참조 — 「이 사실은 어느 오더 것인가」(표준 §7.3.5.4).
135
+ *
136
+ * ── 왜 뒤늦게 생겼나 (2026-09-07) ─────────────────────────────────────────
137
+ * 이 칸이 **다섯째 층**이었다. 같은 필드가 층마다 따로 막혀 있었고, 아래를 열어도 위에서 계속 사라졌다.
138
+ *
139
+ * ```
140
+ * 1 EpcisHeaderOptions objectEvent 만 자기 인자로 갖고 있었다
141
+ * 2 매핑 스펙 커넥터가 어느 칸을 읽을지 선언할 자리가 없었다
142
+ * 3 유입 규칙 셋 선언해도 트윈이 그 스펙을 안 읽었다
143
+ * 4 정규 레코드 ← 여기. 내는 쪽이 값을 담을 칸이 없었다
144
+ * 5 원본 봉투 담을 값을 원본이 안 실었다
145
+ * ```
146
+ *
147
+ * 받는 쪽은 처음부터 다 서 있었다 — 저널의 `biz_transaction` 칸과 인덱스, 이력추적 질의, 커널이 그
148
+ * 값을 채우는 네 자리. 그래서 커널 안에서 나는 사실만 그 칸을 갖고 실 시스템이 먹이는 트윈은 비었다
149
+ * (시뮬 2,492건 대 `mes-line-a` 0건).
150
+ *
151
+ * ⚠ `type` 은 **거래의 종류**다. 생산 오더는 `BTT_PRODORDER`(`btt:prodorder`)이고 구매 오더의
152
+ * `btt:po` 와 다르다. 문자열을 손으로 적지 말고 프로파일의 상수를 부른다 — 나는 이 자리에서 `btt:po`
153
+ * 를 잘못 적어 전달했고, 인티그레이션 레인이 잡았다. 진짜 표준 URI 를 틀린 뜻으로 쓰는 것은 지어낸
154
+ * 이름보다 나쁘다: 읽는 사람이 그 뜻인 줄 배운다.
155
+ */
156
+ bizTransactionList?: BizTransactionElement[];
84
157
  readPoint?: string;
85
158
  bizLocation?: string;
86
159
  }
@@ -97,3 +97,25 @@ export declare function workingTimeOfWeek(calendar: readonly WorkCalendarEntry[]
97
97
  * 실행해 보지 않고 물을 수 있다(what-if 의 가장 싼 형태).
98
98
  */
99
99
  export declare function analyzeCapacity(input: CapacityInput): CapacityAnalysis;
100
+ /**
101
+ * 이 창에서 **조업이 계획되지 않은 시간**(ms) — 없으면 `undefined`.
102
+ *
103
+ * ── 무엇에 쓰나 ─────────────────────────────────────────────────────────────
104
+ * ISO 22400 의 계획 조업 시간(PBT)에서 뺄 값이다(`OeeCounters.holdMs`). 커널의 PBT 가 「재기 시작한
105
+ * 뒤 흐른 시간 전부」였고, 그래서 주말·야간·휴일이 계획 조업으로 세어졌다.
106
+ *
107
+ * ── 선언이 없으면 `undefined` 다 ────────────────────────────────────────────
108
+ * **0 을 내지 않는다.** 0 은 「비근무 시간이 없다」는 주장이고, 그것은 24시간 365일 도는 현장에
109
+ * 대해서만 참이다. 선언이 없는 것은 **모르는 것**이고, 부르는 쪽이 그 둘을 갈라야 한다 — 0 을 받으면
110
+ * 「달력을 확인했고 쉬는 시간이 없었다」로 읽는다.
111
+ *
112
+ * `workingTimeOfWeek` 이 캘린더가 없을 때 종일 가동으로 보는 것과 방향이 반대로 보이지만, 그쪽은
113
+ * **능력**을 묻는 자리라 「최대 얼마나 돌 수 있나」에 답해야 하고, 이쪽은 **실적 지표**의 분모라
114
+ * 모르는 것을 채우면 그 수가 조용히 틀린다.
115
+ *
116
+ * ── 분 단위로 센다 ──────────────────────────────────────────────────────────
117
+ * `workingTimeOfWeek` 과 같은 방식이다(`inWorkCalendarAt` 을 분마다 묻는다). 구간 대수로 풀면
118
+ * 되풀이·요일·절대 구간이 겹칠 때의 규칙을 여기서 다시 적게 되고, 그 규칙은 이미 그 함수 안에 있다 —
119
+ * 두 곳이 다른 답을 내는 것이 이 저장소에서 여러 번 났다.
120
+ */
121
+ export declare function nonWorkingMsIn(calendar: readonly WorkCalendarEntry[] | undefined, fromMs: number, toMs: number, utcOffsetMinutes?: number): number | undefined;
package/dist/capacity.js CHANGED
@@ -170,3 +170,37 @@ export function analyzeCapacity(input) {
170
170
  ok: operations.every(o => o.ok)
171
171
  };
172
172
  }
173
+ /**
174
+ * 이 창에서 **조업이 계획되지 않은 시간**(ms) — 없으면 `undefined`.
175
+ *
176
+ * ── 무엇에 쓰나 ─────────────────────────────────────────────────────────────
177
+ * ISO 22400 의 계획 조업 시간(PBT)에서 뺄 값이다(`OeeCounters.holdMs`). 커널의 PBT 가 「재기 시작한
178
+ * 뒤 흐른 시간 전부」였고, 그래서 주말·야간·휴일이 계획 조업으로 세어졌다.
179
+ *
180
+ * ── 선언이 없으면 `undefined` 다 ────────────────────────────────────────────
181
+ * **0 을 내지 않는다.** 0 은 「비근무 시간이 없다」는 주장이고, 그것은 24시간 365일 도는 현장에
182
+ * 대해서만 참이다. 선언이 없는 것은 **모르는 것**이고, 부르는 쪽이 그 둘을 갈라야 한다 — 0 을 받으면
183
+ * 「달력을 확인했고 쉬는 시간이 없었다」로 읽는다.
184
+ *
185
+ * `workingTimeOfWeek` 이 캘린더가 없을 때 종일 가동으로 보는 것과 방향이 반대로 보이지만, 그쪽은
186
+ * **능력**을 묻는 자리라 「최대 얼마나 돌 수 있나」에 답해야 하고, 이쪽은 **실적 지표**의 분모라
187
+ * 모르는 것을 채우면 그 수가 조용히 틀린다.
188
+ *
189
+ * ── 분 단위로 센다 ──────────────────────────────────────────────────────────
190
+ * `workingTimeOfWeek` 과 같은 방식이다(`inWorkCalendarAt` 을 분마다 묻는다). 구간 대수로 풀면
191
+ * 되풀이·요일·절대 구간이 겹칠 때의 규칙을 여기서 다시 적게 되고, 그 규칙은 이미 그 함수 안에 있다 —
192
+ * 두 곳이 다른 답을 내는 것이 이 저장소에서 여러 번 났다.
193
+ */
194
+ export function nonWorkingMsIn(calendar, fromMs, toMs, utcOffsetMinutes) {
195
+ if (!calendar?.length)
196
+ return undefined;
197
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs)
198
+ return undefined;
199
+ const minutes = Math.floor((toMs - fromMs) / 60_000);
200
+ let closed = 0;
201
+ for (let m = 0; m < minutes; m++) {
202
+ if (!inWorkCalendarAt(calendar, fromMs + m * 60_000, utcOffsetMinutes))
203
+ closed++;
204
+ }
205
+ return closed * 60_000;
206
+ }
@@ -3660,6 +3660,57 @@ export interface TwinModelDef {
3660
3660
  * **절대 구간**(`StartDateTime`/`FinishDateTime`)을 계산해 넣는 것이다.
3661
3661
  */
3662
3662
  utcOffsetMinutes?: number;
3663
+ /**
3664
+ * 이 트윈이 선 곳의 **시간대 이름**(IANA — `Asia/Seoul` · `America/Tijuana`).
3665
+ *
3666
+ * ── 왜 오프셋 옆에 이름도 두나 (2026-09-07) ──────────────────────────────
3667
+ * `utcOffsetMinutes` 는 **한 순간의** 오프셋이다. 호스트가 트윈을 세울 때 한 번 풀어 넣으면 그
3668
+ * 수가 모델에 그대로 남고, 일광절약시간을 쓰는 곳에서는 계절이 바뀌는 순간부터 한 시간씩 틀린다.
3669
+ * 트윈은 몇 주씩 돌고, `America/Tijuana`(PST −480 / PDT −420)가 이미 자료에 있다.
3670
+ *
3671
+ * 더 나쁜 것은 **지난 구간을 접을 때**다. 1년치 사실을 교대별로 접으면 그 1년을 한 오프셋으로
3672
+ * 읽는데, 그 안에 전환이 두 번 들어 있다. 어긋난 시각이 오류를 내지 않고 다른 교대로 세어진다.
3673
+ *
3674
+ * 그래서 이름을 함께 든다. **커널은 이 값을 풀지 않는다** — zero-dep 이라 시간대 데이터를 들 수
3675
+ * 없고, 여전히 오프셋을 호출마다 받는다(`weekdayAt` · `activeShiftAt` · `inWorkCalendarAt`).
3676
+ * 이름은 **호스트가 매 순간마다 풀기 위한** 것이다. 그것이 이 계약이 원래 적어 둔 분담이다.
3677
+ *
3678
+ * 없으면 `utcOffsetMinutes` 로 떨어지고, 그것도 없으면 UTC 다 — 조용히 현지 시각으로 가정하지 않는다.
3679
+ *
3680
+ * ⚠ 이것으로도 완전하지 않다. 정확한 답은 여전히 호스트가 표준대로 **절대 구간**
3681
+ * (`WorkCalendarEntry.startDateTime`/`finishDateTime`)을 계산해 넣는 것이다. 이름은 그때까지
3682
+ * 「매 순간 다시 풀 수 있게」 해 주는 자리이고, 고정된 수보다는 맞다.
3683
+ */
3684
+ timezone?: string;
3685
+ /**
3686
+ * **이 현장이 언제 도나** — 표준 `WorkCalendarEntry`(복수). 휴일 · 주말 · 정기보수가 여기 든다.
3687
+ *
3688
+ * ── 왜 현장 단위인가 (2026-09-07) ────────────────────────────────────────
3689
+ * 이 칸이 없던 동안 설비와 사람만 달력을 가질 수 있었다. 그래서 **현장이 언제 쉬는지가 어디에도
3690
+ * 없었고**, 커널의 계획 조업 시간이 「재기 시작한 뒤 흐른 시간 전부」였다 — 주말도 야간도 휴일도
3691
+ * 계획 조업으로 셌다.
3692
+ *
3693
+ * [산술] 8시간 × 주5일 현장이면 168시간 중 40시간이 조업이다. PBT 가 4.2배 크고 가동률이 그만큼
3694
+ * 작다. 그리고 그 수가 **1로 잘려** 있어서(오늘 걷었다) 그럴싸하게 보였다 — 두 오류가 서로를 가렸다.
3695
+ *
3696
+ * 자리가 최상위인 이유는 `timezone` 이 이미 여기 있기 때문이다. 「이 현장의 06:00 이 언제인가」와
3697
+ * 「이 현장이 언제 도나」는 같은 축이고, 다른 층에 두면 한쪽만 채워진 상태가 생긴다.
3698
+ *
3699
+ * ── 무엇이 이것을 이기나 ─────────────────────────────────────────────────
3700
+ * ```
3701
+ * equipment[].workCalendar 이 설비의 가동 계획 — 선언했으면 이것이 이긴다(24시간 도는 로)
3702
+ * 이 칸 현장의 조업 달력
3703
+ * 아무것도 없음 제약 없음 — 커널이 멈출 이유를 모른다
3704
+ * ```
3705
+ *
3706
+ * ⚠ **설비의 달력은 사람의 근무조가 아니다.** 그 둘을 한 칸에서 읽어서, 씨앗이 사람의 근무조를 설비
3707
+ * 가동 달력에 복사한 일이 있었다 — 아무도 근무하지 않는 시간이 근무 시간으로 기록됐다. `persons[]`
3708
+ * 의 달력이 사람 쪽이다.
3709
+ *
3710
+ * 없으면 **아무것도 가정하지 않는다.** 종일 가동으로 보는 것은 「제약이 없다」가 아니라 「선언되지
3711
+ * 않았다」이고, 그 사실은 세어져야 한다(안 그러면 사람이 수를 되살리려고 아무 달력이나 채운다).
3712
+ */
3713
+ workCalendar?: WorkCalendarEntry[];
3663
3714
  /**
3664
3715
  * **시험 명세** — 표준 `TestSpecification`. 자원의 `testSpecificationIds` 가 가리키는 대상.
3665
3716
  *
package/dist/contract.js CHANGED
@@ -667,13 +667,32 @@ function convertQuantity(item, uom, definitions) {
667
667
  const factor = conversionFactorOf(def, uom);
668
668
  return factor === undefined ? undefined : item.qty * factor;
669
669
  }
670
- /** `HH:MM` → 자정 이후 분. 형식이 아니면 `undefined`(짐작해 고치지 않는다). */
670
+ /**
671
+ * `HH:MM` → 자정 이후 분. 형식이 아니면 `undefined`(짐작해 고치지 않는다).
672
+ *
673
+ * ── `24:00` 을 받는다 (2026-09-07) ──────────────────────────────────────────
674
+ * ISO 8601 이 `24:00` 을 **하루의 끝**으로 허용한다(같은 순간의 다른 이름 — 다음 날 `00:00`). 그것을
675
+ * 거절하고 있었고, 그래서 **종일 가동을 말할 방법이 없었다.**
676
+ *
677
+ * ```
678
+ * 00:00~24:00 거절됐다 → 그 항목이 아무 분도 안 덮는다 → 종일 비근무
679
+ * 00:00~00:00 (toDayOffset 1) from === to 라 길이 0 으로 읽힌다 → 종일 비근무
680
+ * ```
681
+ *
682
+ * 즉 24시간 도는 설비를 선언하면 **한 번도 안 도는 것**이 됐다. 사용자가 「aging 은 당연히 24시간
683
+ * 365일 도는 거지」라고 한 그 설비가 정확히 이 모양으로 선언된다.
684
+ *
685
+ * `24:00` 만 `1440` 으로 받는다 — `24:30` 은 여전히 거절한다(하루의 끝을 넘는 시각은 없다).
686
+ * `from === to` 는 그대로 길이 0 이다: 뜻이 갈리는 표기를 짐작하지 않고, 종일은 `24:00` 으로 적는다.
687
+ */
671
688
  function minutesOfDay(hhmm) {
672
689
  const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm ?? '');
673
690
  if (!m)
674
691
  return undefined;
675
692
  const h = Number(m[1]);
676
693
  const mi = Number(m[2]);
694
+ if (h === 24)
695
+ return mi === 0 ? 24 * 60 : undefined;
677
696
  if (h > 23 || mi > 59)
678
697
  return undefined;
679
698
  return h * 60 + mi;
@@ -1,6 +1,26 @@
1
1
  import type { CapabilityKey } from './capability.ts';
2
2
  import type { StandardClass } from './domain-definition.ts';
3
3
  import type { EquipmentLevel } from './contract.ts';
4
+ /**
5
+ * 이 트윈이 아는 개념의 **닫힌 목록** — 화면이 무엇에 초점을 맞출 수 있나가 이 목록이다.
6
+ *
7
+ * ── 왜 닫나 (2026-09-07) ────────────────────────────────────────────────────
8
+ * `axis` 가 `string` 이라 아무 값이나 지났다. 그래서 두 제품이 같은 개념을 다른 이름으로 부르는 것을
9
+ * 타입이 못 잡았다.
10
+ *
11
+ * ```
12
+ * 트윈의 초점 종류 equipment · location · operation · order · sku
13
+ * 이 목록 equipment · locations · operations · orders · materialDefinitions …
14
+ * 단수/복수가 갈리고, sku 는 이 목록에 없는 이름이다
15
+ * ```
16
+ *
17
+ * 그리고 내가 이 자리를 **「자원 종류 넷」으로 좁혀 답했다가 UX 레인에 교정받았다.** `operations` 와
18
+ * `orders` 는 자원이 아니라 축이고, 초점을 맞출 수 있는 것은 자원이 아니라 이 목록이다.
19
+ *
20
+ * ⚠ **복수형이 정본이다.** 이 이름이 `TwinModelDef` 의 키 그대로여야 하고(그 키가 복수다), 어긋나면
21
+ * 저장된 이름과 화면이 부르는 이름이 달라진다 — 그 어긋남은 오류를 내지 않고 **빈 목록**을 돌려준다.
22
+ */
23
+ export type TwinAxis = 'locations' | 'equipment' | 'persons' | 'assets' | 'materials' | 'materialDefinitions' | 'items' | 'operations' | 'orders' | 'tasks' | 'recipes' | 'routes' | 'productionSpec' | 'testSpecifications' | 'demandWindows' | 'equipmentClasses' | 'personnelClasses' | 'materialClasses' | 'assetClasses';
4
24
  /**
5
25
  * 트윈의 종류 — 한 현실을 비추는 렌즈의 갈래.
6
26
  *
@@ -59,8 +79,11 @@ export interface TwinAxisInfo {
59
79
  /**
60
80
  * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `TwinModelDef` 의 키 **그대로**다
61
81
  * (저장·계약의 이름과 어긋나면 그 순간 방언이 생긴다).
82
+ *
83
+ * **닫혀 있다**(§`TwinAxis`). 여기가 `string` 이던 동안 두 제품이 같은 개념을 다른 이름으로
84
+ * 부르는 것을 타입이 못 잡았다.
62
85
  */
63
- axis: string;
86
+ axis: TwinAxis;
64
87
  /**
65
88
  * **무엇이 이 축의 항목을 가리키나** — 없으면 소비처가 `id` 를 쓴다.
66
89
  *
package/dist/epcis.d.ts CHANGED
@@ -409,6 +409,17 @@ export interface EpcisHeaderOptions {
409
409
  eventID?: string;
410
410
  recordTime?: string;
411
411
  errorDeclaration?: ErrorDeclaration;
412
+ /**
413
+ * 오더 참조 — **표준이 헤더에 둔다**(§7.3.5.4). 어느 사건에나 붙는다.
414
+ *
415
+ * ── 왜 여기 없었나 (2026-09-07) ──────────────────────────────────────────
416
+ * `objectEvent` 만 자기 인자에 이 칸을 따로 갖고 있었다. 그래서 `aggregationEvent` ·
417
+ * `transformationEvent` 로 보낸 값은 **오류 없이 버려졌다** — 담김과 변환은 「이 물건이 어느 오더
418
+ * 것인가」를 말할 수 없었다.
419
+ *
420
+ * 헤더 필드를 한 갈래의 인자에 두면 이 일이 난다. `errorDeclaration` 이 같은 이유로 여기 있다.
421
+ */
422
+ bizTransactionList?: BizTransactionElement[];
412
423
  ilmd?: Record<string, unknown>;
413
424
  sourceList?: SourceElement[];
414
425
  destinationList?: DestinationElement[];
package/dist/epcis.js CHANGED
@@ -357,6 +357,8 @@ function header(type, eventTime, bizStep, opts) {
357
357
  h.recordTime = opts.recordTime;
358
358
  if (opts?.errorDeclaration)
359
359
  h.errorDeclaration = opts.errorDeclaration;
360
+ if (opts?.bizTransactionList?.length)
361
+ h.bizTransactionList = opts.bizTransactionList;
360
362
  if (opts?.ilmd)
361
363
  h.ilmd = opts.ilmd;
362
364
  if (opts?.sourceList)
@@ -75,6 +75,37 @@ export interface ObjectEventMapping {
75
75
  ilmd?: MapValue;
76
76
  readPoint?: MapValue;
77
77
  bizLocation?: MapValue;
78
+ /**
79
+ * **오더와 물건을 잇는 참조** → `bizTransactionList`(EPCIS 2.0 §7.3.5.4).
80
+ *
81
+ * ── 왜 뒤늦게 생겼나 (2026-09-07 실측) ─────────────────────────────────────
82
+ * `quantityList` · `ilmd` · `errorDeclaration` 과 **똑같은 모양의 결함**이다. 받는 쪽은 처음부터 다
83
+ * 서 있었다 — 저널에 `biz_transaction` 칸과 인덱스(`ix_twin_event_9`)가 있고, 계보 질의가
84
+ * `e.bizTransaction` 으로 거르고, 커널이 네 자리에서 그 값을 채운다. **문만 없었다.**
85
+ *
86
+ * 그래서 커널 안쪽에서 나는 사실만 그 칸을 갖고, 실 시스템이 먹이는 트윈은 조용히 빈다.
87
+ *
88
+ * ```
89
+ * hatio-mx2 (시뮬) epcis.TransactionEvent 2,492건 채움
90
+ * mes-line-a 47건 → 0건
91
+ * mes-miratek 37건 → 0건
92
+ * ems · ppms · chef v1 전부 0건
93
+ * ```
94
+ *
95
+ * 화면은 「이 물건이 어느 오더 것인가」에 답하지 못하는데 오류도 경고도 나지 않는다.
96
+ *
97
+ * ── 왜 `TransactionEventMapping` 을 새로 만들지 않았나 ────────────────────
98
+ * 표준이 이 필드를 **헤더**에 둔다(§7.3.5) — 어느 사건에나 붙는다. 그래서 재고 관측이 「이건 오더
99
+ * 33 것이다」라고 말하는 것은 표준 그대로이고 우회가 아니다.
100
+ *
101
+ * `TransactionEvent` 는 뜻이 다르다: **맺어졌다/풀렸다는 그 사건 자체**다(§7.4.3). 그 사실을 낼
102
+ * 원본이 생기면 그때 갈래를 넷으로 늘린다 — 지금 그것으로 관측을 대신 적으면, 일어나지 않은
103
+ * 「맺음」이 이력에 선다.
104
+ *
105
+ * 값은 **객체 배열 그대로** 받는다(`[{ type?, bizTransaction }]`). `bizTransaction` 은 URI 이고
106
+ * 커넥터가 `bizTransactionUri()` 로 만든다 — 여기서 이름 규칙을 다시 정하지 않는다.
107
+ */
108
+ bizTransactionList?: MapValue;
78
109
  /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
79
110
  errorDeclaration?: ErrorDeclarationMapping;
80
111
  }
@@ -104,6 +135,37 @@ export interface AggregationEventMapping {
104
135
  childQuantityList?: MapValue;
105
136
  readPoint?: MapValue;
106
137
  bizLocation?: MapValue;
138
+ /**
139
+ * **오더와 물건을 잇는 참조** → `bizTransactionList`(EPCIS 2.0 §7.3.5.4).
140
+ *
141
+ * ── 왜 뒤늦게 생겼나 (2026-09-07 실측) ─────────────────────────────────────
142
+ * `quantityList` · `ilmd` · `errorDeclaration` 과 **똑같은 모양의 결함**이다. 받는 쪽은 처음부터 다
143
+ * 서 있었다 — 저널에 `biz_transaction` 칸과 인덱스(`ix_twin_event_9`)가 있고, 계보 질의가
144
+ * `e.bizTransaction` 으로 거르고, 커널이 네 자리에서 그 값을 채운다. **문만 없었다.**
145
+ *
146
+ * 그래서 커널 안쪽에서 나는 사실만 그 칸을 갖고, 실 시스템이 먹이는 트윈은 조용히 빈다.
147
+ *
148
+ * ```
149
+ * hatio-mx2 (시뮬) epcis.TransactionEvent 2,492건 채움
150
+ * mes-line-a 47건 → 0건
151
+ * mes-miratek 37건 → 0건
152
+ * ems · ppms · chef v1 전부 0건
153
+ * ```
154
+ *
155
+ * 화면은 「이 물건이 어느 오더 것인가」에 답하지 못하는데 오류도 경고도 나지 않는다.
156
+ *
157
+ * ── 왜 `TransactionEventMapping` 을 새로 만들지 않았나 ────────────────────
158
+ * 표준이 이 필드를 **헤더**에 둔다(§7.3.5) — 어느 사건에나 붙는다. 그래서 재고 관측이 「이건 오더
159
+ * 33 것이다」라고 말하는 것은 표준 그대로이고 우회가 아니다.
160
+ *
161
+ * `TransactionEvent` 는 뜻이 다르다: **맺어졌다/풀렸다는 그 사건 자체**다(§7.4.3). 그 사실을 낼
162
+ * 원본이 생기면 그때 갈래를 넷으로 늘린다 — 지금 그것으로 관측을 대신 적으면, 일어나지 않은
163
+ * 「맺음」이 이력에 선다.
164
+ *
165
+ * 값은 **객체 배열 그대로** 받는다(`[{ type?, bizTransaction }]`). `bizTransaction` 은 URI 이고
166
+ * 커넥터가 `bizTransactionUri()` 로 만든다 — 여기서 이름 규칙을 다시 정하지 않는다.
167
+ */
168
+ bizTransactionList?: MapValue;
107
169
  /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
108
170
  errorDeclaration?: ErrorDeclarationMapping;
109
171
  }
@@ -136,6 +198,37 @@ export interface TransformationEventMapping {
136
198
  transformationID?: MapValue;
137
199
  readPoint?: MapValue;
138
200
  bizLocation?: MapValue;
201
+ /**
202
+ * **오더와 물건을 잇는 참조** → `bizTransactionList`(EPCIS 2.0 §7.3.5.4).
203
+ *
204
+ * ── 왜 뒤늦게 생겼나 (2026-09-07 실측) ─────────────────────────────────────
205
+ * `quantityList` · `ilmd` · `errorDeclaration` 과 **똑같은 모양의 결함**이다. 받는 쪽은 처음부터 다
206
+ * 서 있었다 — 저널에 `biz_transaction` 칸과 인덱스(`ix_twin_event_9`)가 있고, 계보 질의가
207
+ * `e.bizTransaction` 으로 거르고, 커널이 네 자리에서 그 값을 채운다. **문만 없었다.**
208
+ *
209
+ * 그래서 커널 안쪽에서 나는 사실만 그 칸을 갖고, 실 시스템이 먹이는 트윈은 조용히 빈다.
210
+ *
211
+ * ```
212
+ * hatio-mx2 (시뮬) epcis.TransactionEvent 2,492건 채움
213
+ * mes-line-a 47건 → 0건
214
+ * mes-miratek 37건 → 0건
215
+ * ems · ppms · chef v1 전부 0건
216
+ * ```
217
+ *
218
+ * 화면은 「이 물건이 어느 오더 것인가」에 답하지 못하는데 오류도 경고도 나지 않는다.
219
+ *
220
+ * ── 왜 `TransactionEventMapping` 을 새로 만들지 않았나 ────────────────────
221
+ * 표준이 이 필드를 **헤더**에 둔다(§7.3.5) — 어느 사건에나 붙는다. 그래서 재고 관측이 「이건 오더
222
+ * 33 것이다」라고 말하는 것은 표준 그대로이고 우회가 아니다.
223
+ *
224
+ * `TransactionEvent` 는 뜻이 다르다: **맺어졌다/풀렸다는 그 사건 자체**다(§7.4.3). 그 사실을 낼
225
+ * 원본이 생기면 그때 갈래를 넷으로 늘린다 — 지금 그것으로 관측을 대신 적으면, 일어나지 않은
226
+ * 「맺음」이 이력에 선다.
227
+ *
228
+ * 값은 **객체 배열 그대로** 받는다(`[{ type?, bizTransaction }]`). `bizTransaction` 은 URI 이고
229
+ * 커넥터가 `bizTransactionUri()` 로 만든다 — 여기서 이름 규칙을 다시 정하지 않는다.
230
+ */
231
+ bizTransactionList?: MapValue;
139
232
  /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
140
233
  errorDeclaration?: ErrorDeclarationMapping;
141
234
  }
@@ -99,6 +99,31 @@ function resolveQuantityList(v, record, field, errors) {
99
99
  }
100
100
  return r.map(x => ({ ...x }));
101
101
  }
102
+ /**
103
+ * 오더 참조 목록을 꺼낸다 — `[{ type?, bizTransaction }]`.
104
+ *
105
+ * `bizTransaction` 이 없는 원소는 **버리고 말한다.** 그 칸이 참조 자체이므로, 비면 그 원소는 아무것도
106
+ * 가리키지 않는다 — 그것을 통과시키면 저널에 빈 참조가 앉고 계보 질의가 그것으로 아무도 못 찾는다.
107
+ */
108
+ function resolveBizTransactionList(v, record, errors) {
109
+ if (v === undefined)
110
+ return [];
111
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
112
+ if (r === undefined || r === null)
113
+ return [];
114
+ if (!Array.isArray(r) || r.some(x => !x || typeof x !== 'object' || Array.isArray(x))) {
115
+ errors.push(`bizTransactionList 가 객체 배열이 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
116
+ return [];
117
+ }
118
+ const out = [];
119
+ for (const x of r) {
120
+ if (typeof x.bizTransaction === 'string' && x.bizTransaction.trim())
121
+ out.push({ ...x });
122
+ else
123
+ errors.push(`bizTransactionList 의 원소에 bizTransaction 이 없다: ${JSON.stringify(x).slice(0, 80)}`);
124
+ }
125
+ return out;
126
+ }
102
127
  /**
103
128
  * 객체 하나를 그대로 꺼낸다 — `ilmd` 처럼 **구조가 곧 값**인 자리.
104
129
  *
@@ -163,6 +188,8 @@ export function mapRecordChecked(record, mapping, eventTime) {
163
188
  /* 정정 선언은 표준 **헤더**의 것이므로 세 종류 모두에 담긴다(§7.5). 한 종류에만 내면 같은 결함이
164
189
  나머지 둘에 남는다 — 그것을 이미 두 번 겪었다(`quantityList` · `ilmd`). */
165
190
  const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
191
+ /* 오더 참조도 헤더의 것이라 같은 자리에서 푼다(§`bizTransactionList`). */
192
+ const bizTransactionList = resolveBizTransactionList(mapping.bizTransactionList, record, errors);
166
193
  if (mapping.type === 'TransformationEvent') {
167
194
  const inputEPCList = resolveList(mapping.inputEPCList, record, 'inputEPCList', errors);
168
195
  const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, 'inputQuantityList', errors);
@@ -194,6 +221,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
194
221
  ...(outputQuantityList.length ? { outputQuantityList } : {}),
195
222
  ...(transformationID ? { transformationID } : {}),
196
223
  ...(errorDeclaration ? { errorDeclaration } : {}),
224
+ ...(bizTransactionList.length ? { bizTransactionList } : {}),
197
225
  readPoint, bizLocation
198
226
  }),
199
227
  errors
@@ -211,6 +239,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
211
239
  ...(childEPCs.length ? { childEPCs } : {}),
212
240
  ...(childQuantityList.length ? { childQuantityList } : {}),
213
241
  ...(errorDeclaration ? { errorDeclaration } : {}),
242
+ ...(bizTransactionList.length ? { bizTransactionList } : {}),
214
243
  readPoint, bizLocation
215
244
  }),
216
245
  errors
@@ -232,6 +261,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
232
261
  ...(quantityList.length ? { quantityList } : {}),
233
262
  ...(ilmd ? { ilmd } : {}),
234
263
  ...(errorDeclaration ? { errorDeclaration } : {}),
264
+ ...(bizTransactionList.length ? { bizTransactionList } : {}),
235
265
  readPoint, bizLocation
236
266
  }),
237
267
  errors
package/dist/oee.js CHANGED
@@ -32,7 +32,18 @@ export function computeOee(c, nowMs) {
32
32
  missing.push('planned-busy-time');
33
33
  /* 대기는 남는 시간이다 — 계획 조업에서 생산·준비·고장을 뺀 것. 따로 재지 않아도 나온다. */
34
34
  const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
35
- const availability = planned > 0 ? Math.min(1, c.runMs / planned) : undefined;
35
+ /*
36
+ * ── 1 로 자르지 않는다 (2026-09-07) ────────────────────────────────────────
37
+ * 여기 `Math.min(1, …)` 이 있었다. 그러면 **계획 밖에 돌린 것이 사라진다** — 휴일에 11시간을
38
+ * 돌렸는데 가동률이 1.0 으로 보이고, 그것은 「계획대로 꽉 돌았다」와 구별되지 않는다.
39
+ *
40
+ * ISO 22400 에서 APT 는 **잰 값**이고 PBT 는 **계획한 값**이다. 둘의 비가 1을 넘는 것은 오류가
41
+ * 아니라 사실이다 — 계획보다 더 돌았다. 그것이 이 지표가 말해야 하는 것 중 하나다: 휴일 가동 ·
42
+ * 계획 없이 돌린 설비 · 계획 조업 시간을 잘못 선언한 현장이 전부 이 모양으로 드러난다.
43
+ *
44
+ * 자르면 그 셋이 「정상」과 같아진다. plant 레인이 휴일을 선언해 보다 이 자리를 찾았다.
45
+ */
46
+ const availability = planned > 0 ? c.runMs / planned : undefined;
36
47
  const produced = c.goodCount + c.nonconformingCount;
37
48
  if (produced <= 0)
38
49
  missing.push('produced-quantity');
@@ -206,7 +206,24 @@ const SPECS = {
206
206
  fields: {
207
207
  orderId: 'string', kind: 'string', status: 'string', requested: 'number', fulfilled: 'number',
208
208
  gtin: 'string', held: 'boolean', lines: 'object[]', priority: 'number', startTime: 'string', endTime: 'string',
209
- allocated: 'string[]', bizTransaction: 'string', operationsRequestId: 'string', dockDoor: 'string', windowStartMs: 'number', recordTime: 'string'
209
+ allocated: 'string[]', bizTransaction: 'string', operationsRequestId: 'string', dockDoor: 'string', windowStartMs: 'number', recordTime: 'string',
210
+ /*
211
+ * 이 오더를 어느 레시피로 돌리나 — 「이 로트를 무엇으로 만들었나」가 이 칸을 지나 답한다.
212
+ *
213
+ * ── 왜 뒤늦게 생겼나 (2026-09-07 측정) ─────────────────────────────────
214
+ * EPCIS 변환 사건에는 레시피 자리가 없다(표준이 그렇다). 그래서 이력추적이 **변환 → 거래
215
+ * 식별자 → 그 오더의 `recipeKey`** 로 건너간다(`twin-lineage-query.ts:296-315`).
216
+ *
217
+ * 변환이 오더를 가리키게 만든 뒤에 재보니, **가리켜진 오더 행에 그 칸이 없었다.** 실으면
218
+ * 레코드가 통째로 거절됐다 — `order: 계약에 없는 필드 — recipeKey`. 조용히 버리지 않은 것은
219
+ * 맞지만, 그래서 실 시스템에서는 그 물음이 계속 답이 없었다.
220
+ *
221
+ * 값은 원본에 있다: `job_orders.recipe_key` 35개 중 20개 채움
222
+ * (`WO-ECU-STD-2609A` = `RECIPE-ECU-STD`).
223
+ *
224
+ * 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
225
+ */
226
+ recipeKey: 'string'
210
227
  }
211
228
  /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
212
229
  },
package/dist/webhook.d.ts CHANGED
@@ -51,6 +51,135 @@ export type WebhookSenderAction = 'accepted' | 'partial' | 'resend-from' | 'wait
51
51
  export declare function webhookSenderAction(status: number): WebhookSenderAction;
52
52
  /** 429 를 함께 쓰는 곳이 있다 — 받는 쪽이 따라가지 못한다는 뜻이고, 행동은 503 과 같다. */
53
53
  export declare const WEBHOOK_TOO_MANY = 429;
54
+ /**
55
+ * One record the receiver would not take.
56
+ *
57
+ * Two shapes, because the sender's own row can only be found one of two ways. When the batch
58
+ * carried an envelope id, that id comes back and the sender marks exactly that row. When it did
59
+ * not, the record itself comes back, so a person can at least see what was dropped.
60
+ *
61
+ * **An empty id is never sent instead.** A sender that looks up "" finds no row and reads the
62
+ * response as "nothing was rejected", which is the failure this whole declaration exists to stop.
63
+ */
64
+ export type WebhookRejection = {
65
+ eventId: string;
66
+ errors: string[];
67
+ } | {
68
+ record: unknown;
69
+ errors: string[];
70
+ };
71
+ /**
72
+ * What the receiver replies with — **the shape the sender has to read.**
73
+ *
74
+ * ── Why this is declared at all (2026-09-07, measured by the plant and integration lanes) ──
75
+ * Three other modules in this contract pin `rejected` as `{ record, errors }[]`
76
+ * (`face2-adapter`, `energy-ingest`, `master-data`). This module — the only one a sender reads —
77
+ * had it in prose alone: `WebhookSenderAction` says "keep the response's `rejected` aside" without
78
+ * ever saying its shape.
79
+ *
80
+ * So a sender guessed. operato-plant read it as a list of envelope id strings and wrote that guess
81
+ * into its own file as if it were the contract. `Array.isArray` passes on both shapes, so no guard
82
+ * caught it: the ids matched nothing, and the whole batch was stamped delivered.
83
+ *
84
+ * ```
85
+ * mes_outbox_events seq 178 status SENT · attempts 0 · no error
86
+ * the envelope had no inputQuantityList — the kernel was right to reject it
87
+ * the twin's journal two transformations seated; 178 is not among them
88
+ * ```
89
+ *
90
+ * The sender was told nothing. Neither side could see it alone — one saw three delivered, the
91
+ * other saw two stored.
92
+ *
93
+ * **Prose that instructs without describing is the gap.** A contract that says what to do with a
94
+ * field, but not what the field looks like, sends every reader off to guess the same way.
95
+ *
96
+ * ── What is optional and why ───────────────────────────────────────────────
97
+ * Nothing here is filled in with a default when absent. `lastSeq` missing is not seq 0, and
98
+ * `rejected` missing is not an empty list — it is a receiver that had nothing to reject, which is
99
+ * a different statement from "the field arrived empty". A sender that cannot tell those apart
100
+ * marks rows on a guess.
101
+ */
102
+ export interface WebhookResponse {
103
+ /** Whether the receiver took the batch. `false` rides along with every non-200 status. */
104
+ ok: boolean;
105
+ /** Short reason, present when `ok` is false. Not for branching on — the status code decides. */
106
+ error?: string;
107
+ /** Echo of the envelope's numbering unit, so a sender with several can tell which line replied. */
108
+ scope?: string;
109
+ /**
110
+ * Whether numbering was checked at all. **`false` is not "no problem"** — it means the source
111
+ * does not number, so there was nothing to verify. Folding the two together hides which
112
+ * connections are actually keeping continuity.
113
+ */
114
+ sequenceChecked?: boolean;
115
+ /** Highest number taken. Everything up to it may be marked sent. */
116
+ lastSeq?: number;
117
+ /** With a 409: resend from here. */
118
+ expectedSeq?: number;
119
+ /**
120
+ * With a first envelope: how many numbers below it were never received (`seq - 1`).
121
+ *
122
+ * Not a loss count — a source that ran for a year before the twin was attached legitimately
123
+ * starts at seq 4,712. The contract cannot tell the two apart, so it reports the fact and
124
+ * leaves the judgement to whoever knows when the source started sending.
125
+ */
126
+ firstUnseenBefore?: number;
127
+ /** With a 503 or 429: hold the facts this long before trying again. Do not drop them. */
128
+ retryAfterMs?: number;
129
+ /**
130
+ * How many records the batch handed over — **the left side of a sum that closes.**
131
+ *
132
+ * ```
133
+ * offered === applied + duplicates + rejected.length + vocabulary
134
+ * ```
135
+ *
136
+ * A sender can check this without reading `rejected` at all, which is the point: on seq 178,
137
+ * three offered against two applied says something was dropped even while the rejection list
138
+ * itself is being misread as a list of ids.
139
+ *
140
+ * **Check it only where the terms are reported — the 200 and the 422.** A 409 says the batch
141
+ * was not taken at all and a 503 says nothing landed, so neither carries `applied`; reading an
142
+ * absent term as zero there would make every hold look like a loss. Both statuses already tell
143
+ * the sender what to do (resend from `expectedSeq`, or wait `retryAfterMs`), and neither asks it
144
+ * to reconcile. This paragraph is here because the first version of this field claimed the sum
145
+ * closes on every reply, which is a second over-broad claim in the same doc comment.
146
+ *
147
+ * **`vocabulary` belongs in the sum, and leaving it out was wrong.** This field first said the
148
+ * sum closed on `applied + duplicates` alone. It does not: vocabulary elements are counted in
149
+ * `offered` and are deliberately kept out of the other three, so every batch carrying one would
150
+ * have failed the check. A sender that is warned on ordinary traffic stops reading the warning,
151
+ * and then misses the real one — which is the whole defect this field exists to catch.
152
+ *
153
+ * The integration lane found that by looking for a counterexample instead of confirmation, and
154
+ * had a measured one: 32 distinct facts reconciled as 29 journal + 2 rejected + 1 vocabulary.
155
+ *
156
+ * This field is also why the declaration was written from a typecheck and not a grep — the
157
+ * receiver had been sending it on the 200, the 422 and the 503 for months, undeclared.
158
+ */
159
+ offered?: number;
160
+ /**
161
+ * How many records were vocabulary, not events.
162
+ *
163
+ * These set state and never reach the journal: they carry no time axis, so seating them as
164
+ * "what happened then" would date a fact that has no date. The receiver splits them by type so
165
+ * that mistake cannot be made (§`CanonicalIngestResult.masterData`).
166
+ *
167
+ * They are reported because the alternative is a sum that does not close. A sender told only
168
+ * `applied` and `duplicates` sees a vocabulary element as a loss.
169
+ */
170
+ vocabulary?: number;
171
+ /** How many records were newly applied. */
172
+ applied?: number;
173
+ /** How many were already known. Not a failure — resends are normal here. */
174
+ duplicates?: number;
175
+ /**
176
+ * What was not taken. Present only when something was rejected.
177
+ *
178
+ * A sender must set these aside rather than resend them: the shape is wrong, so the same bytes
179
+ * would be refused again, and everything that travelled with them is already in.
180
+ */
181
+ rejected?: WebhookRejection[];
182
+ }
54
183
  /**
55
184
  * 봉투 하나 — 번호와 사실을 함께 든다.
56
185
  *
@@ -173,6 +173,7 @@ __export(index_exports, {
173
173
  minuteOfDayAt: () => minuteOfDayAt,
174
174
  missingCommandPayload: () => missingCommandPayload,
175
175
  nextCommandState: () => nextCommandState,
176
+ nonWorkingMsIn: () => nonWorkingMsIn,
176
177
  normalizeEquipmentStatus: () => normalizeEquipmentStatus,
177
178
  objectEvent: () => objectEvent,
178
179
  objectUri: () => objectUri,
@@ -667,6 +668,7 @@ function minutesOfDay(hhmm) {
667
668
  if (!m) return void 0;
668
669
  const h = Number(m[1]);
669
670
  const mi = Number(m[2]);
671
+ if (h === 24) return mi === 0 ? 24 * 60 : void 0;
670
672
  if (h > 23 || mi > 59) return void 0;
671
673
  return h * 60 + mi;
672
674
  }
@@ -1201,6 +1203,16 @@ function analyzeCapacity(input) {
1201
1203
  ok: operations.every((o) => o.ok)
1202
1204
  };
1203
1205
  }
1206
+ function nonWorkingMsIn(calendar, fromMs, toMs, utcOffsetMinutes) {
1207
+ if (!calendar?.length) return void 0;
1208
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return void 0;
1209
+ const minutes = Math.floor((toMs - fromMs) / 6e4);
1210
+ let closed = 0;
1211
+ for (let m = 0; m < minutes; m++) {
1212
+ if (!inWorkCalendarAt(calendar, fromMs + m * 6e4, utcOffsetMinutes)) closed++;
1213
+ }
1214
+ return closed * 6e4;
1215
+ }
1204
1216
 
1205
1217
  // src/wms-profile.ts
1206
1218
  var BIZSTEP = {
@@ -2871,6 +2883,7 @@ function header(type, eventTime, bizStep, opts) {
2871
2883
  if (opts?.eventID) h.eventID = opts.eventID;
2872
2884
  if (opts?.recordTime) h.recordTime = opts.recordTime;
2873
2885
  if (opts?.errorDeclaration) h.errorDeclaration = opts.errorDeclaration;
2886
+ if (opts?.bizTransactionList?.length) h.bizTransactionList = opts.bizTransactionList;
2874
2887
  if (opts?.ilmd) h.ilmd = opts.ilmd;
2875
2888
  if (opts?.sourceList) h.sourceList = opts.sourceList;
2876
2889
  if (opts?.destinationList) h.destinationList = opts.destinationList;
@@ -3103,6 +3116,21 @@ function resolveQuantityList(v, record, field, errors) {
3103
3116
  }
3104
3117
  return r.map((x) => ({ ...x }));
3105
3118
  }
3119
+ function resolveBizTransactionList(v, record, errors) {
3120
+ if (v === void 0) return [];
3121
+ const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
3122
+ if (r === void 0 || r === null) return [];
3123
+ if (!Array.isArray(r) || r.some((x) => !x || typeof x !== "object" || Array.isArray(x))) {
3124
+ errors.push(`bizTransactionList \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
3125
+ return [];
3126
+ }
3127
+ const out = [];
3128
+ for (const x of r) {
3129
+ if (typeof x.bizTransaction === "string" && x.bizTransaction.trim()) out.push({ ...x });
3130
+ else errors.push(`bizTransactionList \uC758 \uC6D0\uC18C\uC5D0 bizTransaction \uC774 \uC5C6\uB2E4: ${JSON.stringify(x).slice(0, 80)}`);
3131
+ }
3132
+ return out;
3133
+ }
3106
3134
  function resolveObject(v, record, field, errors) {
3107
3135
  if (v === void 0) return void 0;
3108
3136
  const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
@@ -3138,6 +3166,7 @@ function mapRecordChecked(record, mapping, eventTime) {
3138
3166
  const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
3139
3167
  const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
3140
3168
  const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
3169
+ const bizTransactionList = resolveBizTransactionList(mapping.bizTransactionList, record, errors);
3141
3170
  if (mapping.type === "TransformationEvent") {
3142
3171
  const inputEPCList = resolveList(mapping.inputEPCList, record, "inputEPCList", errors);
3143
3172
  const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, "inputQuantityList", errors);
@@ -3163,6 +3192,7 @@ function mapRecordChecked(record, mapping, eventTime) {
3163
3192
  ...outputQuantityList.length ? { outputQuantityList } : {},
3164
3193
  ...transformationID ? { transformationID } : {},
3165
3194
  ...errorDeclaration ? { errorDeclaration } : {},
3195
+ ...bizTransactionList.length ? { bizTransactionList } : {},
3166
3196
  readPoint,
3167
3197
  bizLocation
3168
3198
  }),
@@ -3185,6 +3215,7 @@ function mapRecordChecked(record, mapping, eventTime) {
3185
3215
  ...childEPCs.length ? { childEPCs } : {},
3186
3216
  ...childQuantityList.length ? { childQuantityList } : {},
3187
3217
  ...errorDeclaration ? { errorDeclaration } : {},
3218
+ ...bizTransactionList.length ? { bizTransactionList } : {},
3188
3219
  readPoint,
3189
3220
  bizLocation
3190
3221
  }),
@@ -3208,6 +3239,7 @@ function mapRecordChecked(record, mapping, eventTime) {
3208
3239
  ...quantityList.length ? { quantityList } : {},
3209
3240
  ...ilmd ? { ilmd } : {},
3210
3241
  ...errorDeclaration ? { errorDeclaration } : {},
3242
+ ...bizTransactionList.length ? { bizTransactionList } : {},
3211
3243
  readPoint,
3212
3244
  bizLocation
3213
3245
  }),
@@ -3503,7 +3535,24 @@ var SPECS = {
3503
3535
  operationsRequestId: "string",
3504
3536
  dockDoor: "string",
3505
3537
  windowStartMs: "number",
3506
- recordTime: "string"
3538
+ recordTime: "string",
3539
+ /*
3540
+ * 이 오더를 어느 레시피로 돌리나 — 「이 로트를 무엇으로 만들었나」가 이 칸을 지나 답한다.
3541
+ *
3542
+ * ── 왜 뒤늦게 생겼나 (2026-09-07 측정) ─────────────────────────────────
3543
+ * EPCIS 변환 사건에는 레시피 자리가 없다(표준이 그렇다). 그래서 이력추적이 **변환 → 거래
3544
+ * 식별자 → 그 오더의 `recipeKey`** 로 건너간다(`twin-lineage-query.ts:296-315`).
3545
+ *
3546
+ * 변환이 오더를 가리키게 만든 뒤에 재보니, **가리켜진 오더 행에 그 칸이 없었다.** 실으면
3547
+ * 레코드가 통째로 거절됐다 — `order: 계약에 없는 필드 — recipeKey`. 조용히 버리지 않은 것은
3548
+ * 맞지만, 그래서 실 시스템에서는 그 물음이 계속 답이 없었다.
3549
+ *
3550
+ * 값은 원본에 있다: `job_orders.recipe_key` 35개 중 20개 채움
3551
+ * (`WO-ECU-STD-2609A` = `RECIPE-ECU-STD`).
3552
+ *
3553
+ * 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
3554
+ */
3555
+ recipeKey: "string"
3507
3556
  }
3508
3557
  /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
3509
3558
  },
@@ -4043,7 +4092,7 @@ function computeOee(c, nowMs) {
4043
4092
  const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
4044
4093
  if (planned <= 0) missing.push("planned-busy-time");
4045
4094
  const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
4046
- const availability = planned > 0 ? Math.min(1, c.runMs / planned) : void 0;
4095
+ const availability = planned > 0 ? c.runMs / planned : void 0;
4047
4096
  const produced = c.goodCount + c.nonconformingCount;
4048
4097
  if (produced <= 0) missing.push("produced-quantity");
4049
4098
  const quality = produced > 0 ? c.goodCount / produced : void 0;
@@ -4495,6 +4544,7 @@ function commandSpecGaps(specs, command) {
4495
4544
  minuteOfDayAt,
4496
4545
  missingCommandPayload,
4497
4546
  nextCommandState,
4547
+ nonWorkingMsIn,
4498
4548
  normalizeEquipmentStatus,
4499
4549
  objectEvent,
4500
4550
  objectUri,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",