@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.
- package/dist/canonical-record.d.ts +73 -0
- package/dist/capacity.d.ts +22 -0
- package/dist/capacity.js +34 -0
- package/dist/contract.d.ts +51 -0
- package/dist/contract.js +20 -1
- package/dist/domain-catalog.d.ts +24 -1
- package/dist/epcis.d.ts +11 -0
- package/dist/epcis.js +2 -0
- package/dist/face2-adapter.d.ts +93 -0
- package/dist/face2-adapter.js +30 -0
- package/dist/oee.js +12 -1
- package/dist/operational-ingest.js +18 -1
- package/dist/webhook.d.ts +129 -0
- package/dist-cjs/index.cjs +52 -2
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/capacity.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/contract.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/domain-catalog.d.ts
CHANGED
|
@@ -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:
|
|
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)
|
package/dist/face2-adapter.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/face2-adapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -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 ?
|
|
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.
|
|
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",
|