@operato/twin-kernel 0.7.41 → 0.7.43
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/allocation-policy.d.ts +12 -1
- package/dist/allocation-policy.js +56 -12
- package/dist/contract.d.ts +12 -0
- package/dist/face2-adapter.d.ts +42 -1
- package/dist/face2-adapter.js +57 -2
- package/dist/flow-engine.d.ts +14 -0
- package/dist/flow-engine.js +14 -0
- package/dist/mes-kernel.d.ts +15 -1
- package/dist/mes-kernel.js +66 -2
- package/dist-cjs/index.cjs +131 -21
- package/package.json +1 -1
|
@@ -23,7 +23,18 @@ export interface PlacementContext {
|
|
|
23
23
|
export interface StockRequest {
|
|
24
24
|
gtin: string;
|
|
25
25
|
qty: number;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 후보 재고 — **한 번만 순회할 수 있는 것으로 본다.**
|
|
28
|
+
*
|
|
29
|
+
* ── 왜 배열이 아닌가 (2026-08-21 프로파일) ────────────────────────────────
|
|
30
|
+
* 예전에는 배열이었다. 그래서 호출부가 자리의 물품 전체를 배열로 만들어 넘겼고, 그것이 틱 시간의
|
|
31
|
+
* 25.1% 였다(물품 16,000 규모). 오더 하나가 요구하는 수는 보통 한 자리인데 후보 전체를 실물로
|
|
32
|
+
* 만들어 낸 것이다.
|
|
33
|
+
*
|
|
34
|
+
* 지연 순회로 바꾸면 기본 정책은 만들지 않고 고를 수 있다. 전체가 필요한 정책(FEFO 는 만료 순으로
|
|
35
|
+
* 정렬해야 한다)은 `[...available]` 로 펼치면 된다 — 그 비용은 그 정책이 실제로 필요해서 치르는 것이다.
|
|
36
|
+
*/
|
|
37
|
+
available: Iterable<StockView>;
|
|
27
38
|
}
|
|
28
39
|
export interface AllocationPolicy {
|
|
29
40
|
/** 배치 목적지 슬롯 id. 수용 불가면 null(도크 대기 → 다음 tick 재시도). */
|
|
@@ -5,15 +5,55 @@
|
|
|
5
5
|
* 결정은 정책에 위임한다. 고객은 FIFO/FEFO/nearest/zone·부분할당 등을 정책 교체로만
|
|
6
6
|
* 확장 — 코어 수정 없이. (설계 gap #5 "할당 정책 플러그")
|
|
7
7
|
*/
|
|
8
|
+
/*
|
|
9
|
+
* ── 식별자 비교에 `localeCompare` 를 쓰지 않는다 (2026-08-21 프로파일) ───────
|
|
10
|
+
*
|
|
11
|
+
* 정렬 비용이 틱 시간의 17.2% 였다(`sortByEpc` 와 그 비교 함수 합). 원인이 둘이다.
|
|
12
|
+
*
|
|
13
|
+
* ① `localeCompare` 는 **로케일 대조**다. 사람이 읽는 목록을 그 언어의 관행대로 늘어놓기 위한 것이고,
|
|
14
|
+
* 그래서 비교 한 번이 문자열 비교보다 훨씬 비싸다. 그런데 여기서 비교하는 것은 **식별자**다 —
|
|
15
|
+
* 사람에게 보이지 않고, 필요한 것은 「언제 물어도 같은 순서」뿐이다. 부호 순서로 충분하다.
|
|
16
|
+
*
|
|
17
|
+
* ② 전량을 정렬한 뒤 앞의 `qty` 개만 취했다. 오더 하나가 요구하는 수는 보통 한 자리인데 재고는
|
|
18
|
+
* 수만 개가 될 수 있다 — 목표 규모는 물품 10만이다. **가장 작은 k 개**만 찾으면 된다.
|
|
19
|
+
*
|
|
20
|
+
* 결과는 바뀌지 않는다: 같은 순서에서 가장 작은 k 개는 정렬한 뒤 앞의 k 개와 같은 것들이고 순서도 같다.
|
|
21
|
+
*/
|
|
22
|
+
const byId = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
8
23
|
function freeBinsFirstFit(slots) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
/* 정렬하지 않는다 — 가장 작은 id 하나만 찾으면 되므로 한 번 순회로 답한다. */
|
|
25
|
+
let best;
|
|
26
|
+
for (const b of slots) {
|
|
27
|
+
if (b.occupancy + b.reserved >= b.capacity)
|
|
28
|
+
continue;
|
|
29
|
+
if (!best || byId(b.id, best.id) < 0)
|
|
30
|
+
best = b;
|
|
31
|
+
}
|
|
32
|
+
return best?.id ?? null;
|
|
14
33
|
}
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `epc` 가 가장 작은 **k 개** — 전량 정렬 없이.
|
|
36
|
+
*
|
|
37
|
+
* 작은 정렬 버퍼를 들고 한 번 순회한다. k 가 작을 때(오더가 요구하는 수는 보통 한 자리다) 전량 정렬보다
|
|
38
|
+
* 훨씬 싸고, 결과는 정렬한 뒤 앞의 k 개와 **같다**.
|
|
39
|
+
*/
|
|
40
|
+
function smallestByEpc(available, k) {
|
|
41
|
+
if (k <= 0)
|
|
42
|
+
return { picked: [], total: 0 };
|
|
43
|
+
const out = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (const s of available) {
|
|
46
|
+
total++;
|
|
47
|
+
if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0)
|
|
48
|
+
continue;
|
|
49
|
+
let i = out.length;
|
|
50
|
+
while (i > 0 && byId(out[i - 1].epc, s.epc) > 0)
|
|
51
|
+
i--;
|
|
52
|
+
out.splice(i, 0, s);
|
|
53
|
+
if (out.length > k)
|
|
54
|
+
out.pop();
|
|
55
|
+
}
|
|
56
|
+
return { picked: out, total };
|
|
17
57
|
}
|
|
18
58
|
/**
|
|
19
59
|
* 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
|
|
@@ -22,9 +62,11 @@ function sortByEpc(available) {
|
|
|
22
62
|
export const firstFitPolicy = {
|
|
23
63
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
24
64
|
selectStock: ({ qty, available }) => {
|
|
25
|
-
|
|
65
|
+
/* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에 개수가 필요하다. */
|
|
66
|
+
const { picked, total } = smallestByEpc(available, qty);
|
|
67
|
+
if (total < qty)
|
|
26
68
|
return []; // 전량 확보 전엔 대기
|
|
27
|
-
return
|
|
69
|
+
return picked.map(s => s.epc);
|
|
28
70
|
}
|
|
29
71
|
};
|
|
30
72
|
/**
|
|
@@ -33,7 +75,7 @@ export const firstFitPolicy = {
|
|
|
33
75
|
*/
|
|
34
76
|
export const partialFitPolicy = {
|
|
35
77
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
36
|
-
selectStock: ({ qty, available }) =>
|
|
78
|
+
selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map(s => s.epc)
|
|
37
79
|
};
|
|
38
80
|
/**
|
|
39
81
|
* FEFO 정책 — First-Expired-First-Out. 만료 임박 로트를 먼저 출고(신선/제약 도메인).
|
|
@@ -42,9 +84,11 @@ export const partialFitPolicy = {
|
|
|
42
84
|
export const fefoPolicy = {
|
|
43
85
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
44
86
|
selectStock: ({ qty, available }) => {
|
|
45
|
-
|
|
87
|
+
/* 만료 순이 필요하므로 전체를 펼친다 — 이 정책이 실제로 필요해서 치르는 비용이다. */
|
|
88
|
+
const all = [...available];
|
|
89
|
+
if (all.length < qty)
|
|
46
90
|
return []; // 전량 확보 전엔 대기
|
|
47
|
-
const byExpiry =
|
|
91
|
+
const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
|
|
48
92
|
return byExpiry.slice(0, qty).map(s => s.epc);
|
|
49
93
|
}
|
|
50
94
|
};
|
package/dist/contract.d.ts
CHANGED
|
@@ -1163,6 +1163,18 @@ export interface OrderState {
|
|
|
1163
1163
|
recipeKey?: string;
|
|
1164
1164
|
/** 씨앗이 이 오더의 확보분을 다 심지 못했다 — 이 오더의 답은 부족한 씨앗 위에 있다. */
|
|
1165
1165
|
seedIncomplete?: boolean;
|
|
1166
|
+
/**
|
|
1167
|
+
* **이 오더가 왜 대기하는가** — 모자란 투입 줄(자재 키 · 필요 · 확보 가능).
|
|
1168
|
+
*
|
|
1169
|
+
* 확보는 전량 아니면 대기다. 그 거동은 옳은데 이유를 말하지 않으면 화면은 「running」만 보여 주고,
|
|
1170
|
+
* 사람은 영구히 대기하는 오더를 정상으로 읽는다. 모자란 줄을 **전부** 싣는다 — 하나만 알려 주면
|
|
1171
|
+
* 채운 뒤 다음 줄에서 또 막히고 같은 진단을 반복하게 된다.
|
|
1172
|
+
*/
|
|
1173
|
+
shortage?: {
|
|
1174
|
+
material: string;
|
|
1175
|
+
need: number;
|
|
1176
|
+
have: number;
|
|
1177
|
+
}[];
|
|
1166
1178
|
progress?: number;
|
|
1167
1179
|
held?: boolean;
|
|
1168
1180
|
/**
|
package/dist/face2-adapter.d.ts
CHANGED
|
@@ -59,7 +59,37 @@ export interface AggregationEventMapping {
|
|
|
59
59
|
readPoint?: MapValue;
|
|
60
60
|
bizLocation?: MapValue;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* **변환의 사실** — 무엇이 들어가 무엇이 나왔나(EPCIS `TransformationEvent`).
|
|
64
|
+
*
|
|
65
|
+
* ── 왜 이 갈래가 필요한가 (2026-08-21) ─────────────────────────────────────
|
|
66
|
+
* 정규 레코드 계약이 `ObjectEvent`·`AggregationEvent` 둘만 다뤄서, 원본이 「이 로트를 소비해 이 제품을
|
|
67
|
+
* 만들었다」고 말해도 실을 자리가 없었다. 그 사실이 문 앞에서 사라진다.
|
|
68
|
+
*
|
|
69
|
+
* **HACCP 회수 범위 판정이 정확히 이 자리를 요구한다** — 「이 로트가 어느 제품에 들어갔나」다. 로트를
|
|
70
|
+
* 개체로 식별할 수 없는 현장(연속량·분할 로트)에서는 소비를 `inputQuantityList` 의 **LGTIN 클래스**
|
|
71
|
+
* (`urn:epc:class:lgtin:<프리픽스>.<품번>.<로트>`)로 말한다 — 표준이 로트 단위 클래스를 그렇게 정한다.
|
|
72
|
+
*
|
|
73
|
+
* `action` 이 없다: 표준이 `TransformationEvent` 에 `action` 을 두지 않는다(들어감과 나옴이 곧 뜻이다).
|
|
74
|
+
*/
|
|
75
|
+
export interface TransformationEventMapping {
|
|
76
|
+
type: 'TransformationEvent';
|
|
77
|
+
bizStep: MapValue;
|
|
78
|
+
disposition?: MapValue;
|
|
79
|
+
/** 소비된 개체들("$.inputEPCList") — 문자열 배열. */
|
|
80
|
+
inputEPCList?: MapValue;
|
|
81
|
+
/** 소비된 클래스+수량("$.inputQuantityList") — 로트 단위 소비가 이 자리다. */
|
|
82
|
+
inputQuantityList?: MapValue;
|
|
83
|
+
/** 산출된 개체들("$.outputEPCList"). */
|
|
84
|
+
outputEPCList?: MapValue;
|
|
85
|
+
/** 산출된 클래스+수량("$.outputQuantityList"). */
|
|
86
|
+
outputQuantityList?: MapValue;
|
|
87
|
+
/** 여러 이벤트를 한 변환으로 잇는 식별자("$.transformationID") — 한 오더의 여러 단계가 이것으로 묶인다. */
|
|
88
|
+
transformationID?: MapValue;
|
|
89
|
+
readPoint?: MapValue;
|
|
90
|
+
bizLocation?: MapValue;
|
|
91
|
+
}
|
|
92
|
+
export type EventMapping = ObjectEventMapping | AggregationEventMapping | TransformationEventMapping;
|
|
63
93
|
/**
|
|
64
94
|
* 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
|
|
65
95
|
*
|
|
@@ -67,6 +97,17 @@ export type EventMapping = ObjectEventMapping | AggregationEventMapping;
|
|
|
67
97
|
* 있으면 담김으로 본다 — 둘 다 있으면 커넥터가 무엇을 말하려는지 알 수 없으므로 담김으로 받지 않는다.
|
|
68
98
|
*/
|
|
69
99
|
export declare function isAggregationRecord(record: unknown): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
|
|
102
|
+
*
|
|
103
|
+
* 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
|
|
104
|
+
* 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
|
|
105
|
+
*
|
|
106
|
+
* 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
|
|
107
|
+
* 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
|
|
108
|
+
* 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
|
|
109
|
+
*/
|
|
110
|
+
export declare function isTransformationRecord(record: unknown): boolean;
|
|
70
111
|
/** 룰: 소스 레코드의 판별자(sourceType)로 매핑을 고른다. */
|
|
71
112
|
export interface AdapterRule {
|
|
72
113
|
sourceType: string;
|
package/dist/face2-adapter.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 매핑 엔진은 walking-skeleton 범위에서 무의존 declarative spec 사용.
|
|
9
9
|
* (jsonata/템플릿 엔진 최종 선택은 열린 결정 — face2-adapters.md. 커널 zero-dep 유지 위해 여기선 미도입.)
|
|
10
10
|
*/
|
|
11
|
-
import { aggregationEvent, objectEvent, validateEpcisEvent } from "./epcis.js";
|
|
11
|
+
import { aggregationEvent, objectEvent, transformationEvent, validateEpcisEvent } from "./epcis.js";
|
|
12
12
|
/**
|
|
13
13
|
* 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
|
|
14
14
|
*
|
|
@@ -21,6 +21,25 @@ export function isAggregationRecord(record) {
|
|
|
21
21
|
const r = record;
|
|
22
22
|
return typeof r.parentID === 'string' && r.parentID.trim().length > 0 && r.epc === undefined;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
|
|
26
|
+
*
|
|
27
|
+
* 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
|
|
28
|
+
* 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
|
|
29
|
+
*
|
|
30
|
+
* 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
|
|
31
|
+
* 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
|
|
32
|
+
* 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
|
|
33
|
+
*/
|
|
34
|
+
export function isTransformationRecord(record) {
|
|
35
|
+
if (!record || typeof record !== 'object')
|
|
36
|
+
return false;
|
|
37
|
+
const r = record;
|
|
38
|
+
if (r.epc !== undefined || r.parentID !== undefined)
|
|
39
|
+
return false;
|
|
40
|
+
const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
|
|
41
|
+
return has('inputEPCList') || has('inputQuantityList') || has('outputEPCList') || has('outputQuantityList');
|
|
42
|
+
}
|
|
24
43
|
function get(obj, path) {
|
|
25
44
|
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
|
|
26
45
|
}
|
|
@@ -83,12 +102,47 @@ function resolveQuantityList(v, record, field, errors) {
|
|
|
83
102
|
/** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
|
|
84
103
|
export function mapRecordChecked(record, mapping, eventTime) {
|
|
85
104
|
const errors = [];
|
|
86
|
-
const action = (resolve(mapping.action, record, 'action', errors) ?? '');
|
|
87
105
|
const bizStep = resolve(mapping.bizStep, record, 'bizStep', errors) ?? '';
|
|
88
106
|
const disposition = resolve(mapping.disposition, record, 'disposition', errors);
|
|
89
107
|
const readPoint = resolve(mapping.readPoint, record, 'readPoint', errors);
|
|
90
108
|
const bizLocation = resolve(mapping.bizLocation, record, 'bizLocation', errors);
|
|
109
|
+
if (mapping.type === 'TransformationEvent') {
|
|
110
|
+
const inputEPCList = resolveList(mapping.inputEPCList, record, 'inputEPCList', errors);
|
|
111
|
+
const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, 'inputQuantityList', errors);
|
|
112
|
+
const outputEPCList = resolveList(mapping.outputEPCList, record, 'outputEPCList', errors);
|
|
113
|
+
const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, 'outputQuantityList', errors);
|
|
114
|
+
const transformationID = resolve(mapping.transformationID, record, 'transformationID', errors);
|
|
115
|
+
/*
|
|
116
|
+
* **양쪽이 다 있어야 한다** — EPCIS 2.0 §7.4.5 의 정의다: 「objects … are fully or partially
|
|
117
|
+
* consumed as inputs **and** one or more objects … are produced as outputs」. 네 목록 필드가
|
|
118
|
+
* 각각 선택인 것은 개체로 말하든 수량으로 말하든 되기 때문이고, 한쪽을 빼도 된다는 뜻이 아니다.
|
|
119
|
+
*
|
|
120
|
+
* 검증기도 같은 것을 본다. 여기서 함께 말하는 이유는 **가리키는 곳이 다르기** 때문이다 — 검증
|
|
121
|
+
* 메시지는 이벤트를 가리키고 이 메시지는 매핑을 가리킨다. 커넥터가 고칠 자리가 다르다.
|
|
122
|
+
*/
|
|
123
|
+
if (!inputEPCList.length && !inputQuantityList.length) {
|
|
124
|
+
errors.push('입력이 비었다 — 변환은 무엇이 들어갔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
|
|
125
|
+
}
|
|
126
|
+
if (!outputEPCList.length && !outputQuantityList.length) {
|
|
127
|
+
errors.push('출력이 비었다 — 변환은 무엇이 나왔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
event: transformationEvent({
|
|
131
|
+
eventTime, bizStep, disposition,
|
|
132
|
+
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
|
|
133
|
+
개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
|
|
134
|
+
...(inputEPCList.length ? { inputEPCList } : {}),
|
|
135
|
+
...(inputQuantityList.length ? { inputQuantityList } : {}),
|
|
136
|
+
...(outputEPCList.length ? { outputEPCList } : {}),
|
|
137
|
+
...(outputQuantityList.length ? { outputQuantityList } : {}),
|
|
138
|
+
...(transformationID ? { transformationID } : {}),
|
|
139
|
+
readPoint, bizLocation
|
|
140
|
+
}),
|
|
141
|
+
errors
|
|
142
|
+
};
|
|
143
|
+
}
|
|
91
144
|
if (mapping.type === 'AggregationEvent') {
|
|
145
|
+
const action = (resolve(mapping.action, record, 'action', errors) ?? '');
|
|
92
146
|
const parentID = resolve(mapping.parentID, record, 'parentID', errors) ?? '';
|
|
93
147
|
const childEPCs = resolveList(mapping.childEPCs, record, 'childEPCs', errors);
|
|
94
148
|
const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, 'childQuantityList', errors);
|
|
@@ -103,6 +157,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
103
157
|
errors
|
|
104
158
|
};
|
|
105
159
|
}
|
|
160
|
+
const action = (resolve(mapping.action, record, 'action', errors) ?? '');
|
|
106
161
|
const epc = resolve(mapping.epc, record, 'epc', errors);
|
|
107
162
|
const quantityList = resolveQuantityList(mapping.quantityList, record, 'quantityList', errors);
|
|
108
163
|
/* 개체도 수량도 없으면 무엇을 관측했는지 말하지 않은 것이다 — 검증에 넘기기 전에 여기서 말해 준다
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -233,6 +233,20 @@ export interface FlowOrder {
|
|
|
233
233
|
* 말한다.** 이 표시가 없으면 그 상황과 「우리 계산의 결함」을 구별할 수 없다.
|
|
234
234
|
*/
|
|
235
235
|
seedIncomplete?: boolean;
|
|
236
|
+
/**
|
|
237
|
+
* **이 오더가 왜 대기하는가** — 마지막 확보 시도에서 모자랐던 투입 줄들.
|
|
238
|
+
*
|
|
239
|
+
* 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳은데 **조용했다**:
|
|
240
|
+
* 실측에서 투입 18줄 중 6줄이 입고되지 않아 오더 다섯이 영구히 대기했고 화면에는 「running」이라
|
|
241
|
+
* 적혔다. 어느 자리도 이유를 말하지 않았다.
|
|
242
|
+
*
|
|
243
|
+
* 매 틱 새로 쓰인다(확보를 다시 시도하므로). 채워지면 지운다 — 남겨 두면 해결된 것을 계속 말한다.
|
|
244
|
+
*/
|
|
245
|
+
shortage?: {
|
|
246
|
+
material: string;
|
|
247
|
+
need: number;
|
|
248
|
+
have: number;
|
|
249
|
+
}[];
|
|
236
250
|
/** 우선순위 — 표준 `OperationsRequest.Priority`(작은 값이 급하다). 할당 순서를 정한다. */
|
|
237
251
|
priority?: number;
|
|
238
252
|
/** 예정 창 — 표준 `StartTime`/`EndTime`. 납기가 있어야 "늦었나" 를 물을 수 있다. */
|
package/dist/flow-engine.js
CHANGED
|
@@ -373,6 +373,11 @@ export class ItemStore {
|
|
|
373
373
|
out.set(k, structuredClone(it));
|
|
374
374
|
return out;
|
|
375
375
|
}
|
|
376
|
+
/*
|
|
377
|
+
* 지연 순회(`*iterAt`)를 만들어 배열을 없애 보았으나 **더 느렸다** — 물품 16,000 규모에서 틱 합계가
|
|
378
|
+
* 1562ms 에서 2723ms 로 늘었다. 항목마다 생성기 규약을 지나는 비용이 배열 하나를 만드는 비용보다
|
|
379
|
+
* 크다. 측정이 그렇게 답했으므로 배열을 유지한다.
|
|
380
|
+
*/
|
|
376
381
|
/** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
|
|
377
382
|
at(location) {
|
|
378
383
|
const keys = this.byLocation.get(location);
|
|
@@ -1325,6 +1330,8 @@ export class FlowEngine {
|
|
|
1325
1330
|
...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
|
|
1326
1331
|
/* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
|
|
1327
1332
|
...(o.seedIncomplete ? { seedIncomplete: true } : {}),
|
|
1333
|
+
/* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
|
|
1334
|
+
...(o.shortage?.length ? { shortage: o.shortage.map(x => ({ ...x })) } : {}),
|
|
1328
1335
|
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
|
|
1329
1336
|
...(o.priority !== undefined ? { priority: o.priority } : {}),
|
|
1330
1337
|
...(o.startTime ? { startTime: o.startTime } : {}),
|
|
@@ -2084,6 +2091,13 @@ export class FlowEngine {
|
|
|
2084
2091
|
* 시뮬레이션에서 이것은 **우리 계산의 결함**이다(우리가 없는 물건을 소비하라고 요구했다). 그래서
|
|
2085
2092
|
* 멈춘다. 미러는 처방이 다르다 — 원본이 진실이므로 받아들이고 어긋난 횟수를 남긴다.
|
|
2086
2093
|
*
|
|
2094
|
+
* ── 자리 규칙은 **여기서 확인하지 않는다** (2026-08-21에 호출부를 확인한 결과) ────
|
|
2095
|
+
* 「자재는 작업이 일어나는 자리에 있어야 소비된다」는 규칙이 있고, 그 확인은 **소비 경로**
|
|
2096
|
+
* (`claimMaterials`)가 이미 한다(그 자리에 있는 것만 선택한다). 이 원시에 같은 확인을 추가하면
|
|
2097
|
+
* 정상 경로를 막는다: MES 선언 경로는 자재를 **라인사이드 보관 자리**에서 선택하고 변환은
|
|
2098
|
+
* **공정 자리**에서 일어난다(`mes-kernel` 의 라우트 단계 — 두 자리가 다른 것이 그 경로의 설계다).
|
|
2099
|
+
* 추가하려면 먼저 그 경로가 물건을 공정 자리로 이동시키게 만들어야 하고, 그것은 별개의 결정이다.
|
|
2100
|
+
*
|
|
2087
2101
|
* ── 미러를 멈추면 안 되는 이유 (2026-08-20, 시험으로 확인) ─────────────────
|
|
2088
2102
|
* 씨앗으로 세운 커널은 그대로 `tick` 한다(§`hydrateObserved`). 원본이 확보분 하나를 누락한
|
|
2089
2103
|
* 스냅샷을 주면 — 실 연동에서 흔한 빈틈이다 — 그 커널은 **첫 틱에 멈춘다.** 원본의 한 건이
|
package/dist/mes-kernel.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GeneratorSpec, Command, CommandAck, ProductionSpec, TwinModelDef, IdentityGroundingView } from './contract.ts';
|
|
1
|
+
import type { GeneratorSpec, Command, CommandAck, ProductionSpec, TwinModelDef, IdentityGroundingView, Attention } from './contract.ts';
|
|
2
2
|
import type { AllocationPolicy } from './allocation-policy.ts';
|
|
3
3
|
import { FlowEngine } from './flow-engine.ts';
|
|
4
4
|
import type { FlowOrder, FlowTask } from './flow-engine.ts';
|
|
@@ -60,6 +60,20 @@ export declare class MesKernel extends FlowEngine {
|
|
|
60
60
|
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
61
61
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
|
|
65
|
+
*
|
|
66
|
+
* ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
|
|
67
|
+
* 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
|
|
68
|
+
* 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
|
|
69
|
+
* 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
|
|
70
|
+
*
|
|
71
|
+
* 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
|
|
72
|
+
* 한 곳에서 맡는다(확장점 규약).
|
|
73
|
+
*
|
|
74
|
+
* 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
|
|
75
|
+
*/
|
|
76
|
+
protected collectAttentions(): Attention[];
|
|
63
77
|
protected handleCommand(cmd: Command): CommandAck;
|
|
64
78
|
/** 부품 수령 — 도착한 품목이 선언된 입력 자재면 그 자재가 선언한 자리에 생성. */
|
|
65
79
|
protected onArrival(spec: GeneratorSpec): void;
|
package/dist/mes-kernel.js
CHANGED
|
@@ -149,6 +149,43 @@ export class MesKernel extends FlowEngine {
|
|
|
149
149
|
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
150
150
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
151
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
|
|
154
|
+
*
|
|
155
|
+
* ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
|
|
156
|
+
* 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
|
|
157
|
+
* 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
|
|
158
|
+
* 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
|
|
159
|
+
*
|
|
160
|
+
* 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
|
|
161
|
+
* 한 곳에서 맡는다(확장점 규약).
|
|
162
|
+
*
|
|
163
|
+
* 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
|
|
164
|
+
*/
|
|
165
|
+
collectAttentions() {
|
|
166
|
+
const out = super.collectAttentions();
|
|
167
|
+
for (const o of this.orders.values()) {
|
|
168
|
+
const short = o.shortage;
|
|
169
|
+
if (!short?.length)
|
|
170
|
+
continue;
|
|
171
|
+
out.push({
|
|
172
|
+
id: `material-short:${o.id}`,
|
|
173
|
+
kind: 'material-short',
|
|
174
|
+
/* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
|
|
175
|
+
severity: short.length > 1 ? 'high' : 'medium',
|
|
176
|
+
anchor: { orderId: o.id },
|
|
177
|
+
params: {
|
|
178
|
+
/* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
|
|
179
|
+
shortLines: short.length,
|
|
180
|
+
/* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
|
|
181
|
+
materials: short.map(x => x.material).join(', '),
|
|
182
|
+
need: short.reduce((n, x) => n + x.need, 0),
|
|
183
|
+
have: short.reduce((n, x) => n + x.have, 0)
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
152
189
|
handleCommand(cmd) {
|
|
153
190
|
if (cmd.type === MES_CMD.changeover) {
|
|
154
191
|
const a = cmd.args;
|
|
@@ -417,6 +454,7 @@ export class MesKernel extends FlowEngine {
|
|
|
417
454
|
}
|
|
418
455
|
const rc = this.recipeDef(o);
|
|
419
456
|
const picks = [];
|
|
457
|
+
const short = [];
|
|
420
458
|
/*
|
|
421
459
|
* ── 자리별 색인을 사용한다 (2026-08-21) ────────────────────────────────────
|
|
422
460
|
* 예전에는 투입 줄마다 `[...this.items.values()].filter(...)` 로 **물품 전체를 순회**했다. 오더 하나에
|
|
@@ -438,6 +476,11 @@ export class MesKernel extends FlowEngine {
|
|
|
438
476
|
const g = this.classOf(line.material);
|
|
439
477
|
/* 자리 필터는 남는다 — 완제품도 `sellable` 이라, 반제품→완제품 체인에서 이것이 없으면 산출물을 자재로 소비한다. */
|
|
440
478
|
const fromType = this.locationTypeOfMaterial(line.material);
|
|
479
|
+
/*
|
|
480
|
+
* 후보를 배열로 모은다. 지연 순회(생성기)로 바꿔 보았으나 **더 느렸다** — 물품 16,000 규모에서
|
|
481
|
+
* 1562ms → 2723ms 였다. 항목마다 생성기 규약을 지나는 비용이 배열을 만드는 비용보다 크다.
|
|
482
|
+
* 측정이 그렇게 답했으므로 배열을 유지한다(§StockRequest.available 은 지연도 받는다).
|
|
483
|
+
*/
|
|
441
484
|
const available = [];
|
|
442
485
|
for (const n of locsOfType.get(fromType) ?? []) {
|
|
443
486
|
for (const i of this.items.at(n.id)) {
|
|
@@ -446,10 +489,31 @@ export class MesKernel extends FlowEngine {
|
|
|
446
489
|
}
|
|
447
490
|
}
|
|
448
491
|
const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
|
|
449
|
-
if (chosen.length < line.qty)
|
|
450
|
-
|
|
492
|
+
if (chosen.length < line.qty) {
|
|
493
|
+
/*
|
|
494
|
+
* **왜 대기하는지 남긴다** (2026-08-21).
|
|
495
|
+
*
|
|
496
|
+
* 예전에는 첫 부족한 줄에서 그대로 반환했다. 거동은 옳다(전량 확보 전에는 만들지 않는다) —
|
|
497
|
+
* 문제는 **조용한 것**이었다. 실측: Rosarito MES 는 투입 18줄 중 6줄이 한 번도 입고되지 않아
|
|
498
|
+
* 오더 다섯이 영구히 대기했고, 화면에는 「running」이라 적혔다. 어느 자리도 이유를 말하지 않았다.
|
|
499
|
+
*
|
|
500
|
+
* 첫 줄에서 멈추지 않고 **모자란 줄을 다 센다.** 하나만 알려 주면 그것을 채운 뒤 다음 줄에서
|
|
501
|
+
* 또 막히고, 사람은 같은 진단을 여섯 번 반복한다.
|
|
502
|
+
*/
|
|
503
|
+
short.push({ material: line.material, need: line.qty, have: chosen.length });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
451
506
|
picks.push(...chosen);
|
|
452
507
|
}
|
|
508
|
+
if (short.length) {
|
|
509
|
+
/* 확보는 하지 않는다 — 사실만 남기고 다음 틱에 다시 본다(값은 매 틱 새로 쓰인다). */
|
|
510
|
+
o.shortage = short;
|
|
511
|
+
this.emitOrder(o);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
/* 채워졌으면 표시를 지운다 — 남겨 두면 화면이 이미 해결된 것을 계속 말한다. */
|
|
515
|
+
if (o.shortage)
|
|
516
|
+
delete o.shortage;
|
|
453
517
|
for (const epc of picks)
|
|
454
518
|
o.allocated.push(epc);
|
|
455
519
|
this.reserve(picks, MES_BIZSTEP.producing);
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -125,6 +125,7 @@ __export(index_exports, {
|
|
|
125
125
|
isEnergyRecord: () => isEnergyRecord,
|
|
126
126
|
isEquipmentLevel: () => isEquipmentLevel,
|
|
127
127
|
isOperationalRecord: () => isOperationalRecord,
|
|
128
|
+
isTransformationRecord: () => isTransformationRecord,
|
|
128
129
|
isoDurationHours: () => isoDurationHours,
|
|
129
130
|
itemKeyOf: () => itemKeyOf,
|
|
130
131
|
levelOfLocationType: () => levelOfLocationType,
|
|
@@ -306,14 +307,14 @@ function classIndex(defs) {
|
|
|
306
307
|
}
|
|
307
308
|
var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
|
|
308
309
|
function classClosure(directIds, defs, at) {
|
|
309
|
-
const
|
|
310
|
+
const byId2 = classIndex(defs);
|
|
310
311
|
const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
|
|
311
312
|
const out = /* @__PURE__ */ new Set();
|
|
312
313
|
const stack = [...directIds ?? []];
|
|
313
314
|
while (stack.length) {
|
|
314
315
|
const id = stack.pop();
|
|
315
316
|
if (out.has(id)) continue;
|
|
316
|
-
const def =
|
|
317
|
+
const def = byId2.get(id);
|
|
317
318
|
if (!inWindow(def)) continue;
|
|
318
319
|
out.add(id);
|
|
319
320
|
for (const b of def?.baseIds ?? []) if (!out.has(b)) stack.push(b);
|
|
@@ -2121,11 +2122,11 @@ var EMS_TYPES = [
|
|
|
2121
2122
|
}
|
|
2122
2123
|
];
|
|
2123
2124
|
function electricalUpstreamOf(locations, id) {
|
|
2124
|
-
const
|
|
2125
|
-
const self =
|
|
2125
|
+
const byId2 = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
|
|
2126
|
+
const self = byId2.get(String(id));
|
|
2126
2127
|
if (!self) return void 0;
|
|
2127
2128
|
if (self.upstreamId) return String(self.upstreamId);
|
|
2128
|
-
const parent = self.parentId ?
|
|
2129
|
+
const parent = self.parentId ? byId2.get(String(self.parentId)) : void 0;
|
|
2129
2130
|
if (!parent) return void 0;
|
|
2130
2131
|
return isElectricalLocationType(String(parent.type ?? "")) ? String(parent.id) : void 0;
|
|
2131
2132
|
}
|
|
@@ -2670,30 +2671,47 @@ function capabilitiesForType(system, typeKey) {
|
|
|
2670
2671
|
}
|
|
2671
2672
|
|
|
2672
2673
|
// src/allocation-policy.ts
|
|
2674
|
+
var byId = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
2673
2675
|
function freeBinsFirstFit(slots) {
|
|
2674
|
-
|
|
2675
|
-
for (const b of
|
|
2676
|
-
|
|
2676
|
+
let best;
|
|
2677
|
+
for (const b of slots) {
|
|
2678
|
+
if (b.occupancy + b.reserved >= b.capacity) continue;
|
|
2679
|
+
if (!best || byId(b.id, best.id) < 0) best = b;
|
|
2680
|
+
}
|
|
2681
|
+
return best?.id ?? null;
|
|
2677
2682
|
}
|
|
2678
|
-
function
|
|
2679
|
-
return [
|
|
2683
|
+
function smallestByEpc(available, k) {
|
|
2684
|
+
if (k <= 0) return { picked: [], total: 0 };
|
|
2685
|
+
const out = [];
|
|
2686
|
+
let total = 0;
|
|
2687
|
+
for (const s of available) {
|
|
2688
|
+
total++;
|
|
2689
|
+
if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
|
|
2690
|
+
let i = out.length;
|
|
2691
|
+
while (i > 0 && byId(out[i - 1].epc, s.epc) > 0) i--;
|
|
2692
|
+
out.splice(i, 0, s);
|
|
2693
|
+
if (out.length > k) out.pop();
|
|
2694
|
+
}
|
|
2695
|
+
return { picked: out, total };
|
|
2680
2696
|
}
|
|
2681
2697
|
var firstFitPolicy = {
|
|
2682
2698
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
2683
2699
|
selectStock: ({ qty, available }) => {
|
|
2684
|
-
|
|
2685
|
-
|
|
2700
|
+
const { picked, total } = smallestByEpc(available, qty);
|
|
2701
|
+
if (total < qty) return [];
|
|
2702
|
+
return picked.map((s) => s.epc);
|
|
2686
2703
|
}
|
|
2687
2704
|
};
|
|
2688
2705
|
var partialFitPolicy = {
|
|
2689
2706
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
2690
|
-
selectStock: ({ qty, available }) =>
|
|
2707
|
+
selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map((s) => s.epc)
|
|
2691
2708
|
};
|
|
2692
2709
|
var fefoPolicy = {
|
|
2693
2710
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
2694
2711
|
selectStock: ({ qty, available }) => {
|
|
2695
|
-
|
|
2696
|
-
|
|
2712
|
+
const all = [...available];
|
|
2713
|
+
if (all.length < qty) return [];
|
|
2714
|
+
const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
|
|
2697
2715
|
return byExpiry.slice(0, qty).map((s) => s.epc);
|
|
2698
2716
|
}
|
|
2699
2717
|
};
|
|
@@ -2807,6 +2825,13 @@ function isAggregationRecord(record) {
|
|
|
2807
2825
|
const r = record;
|
|
2808
2826
|
return typeof r.parentID === "string" && r.parentID.trim().length > 0 && r.epc === void 0;
|
|
2809
2827
|
}
|
|
2828
|
+
function isTransformationRecord(record) {
|
|
2829
|
+
if (!record || typeof record !== "object") return false;
|
|
2830
|
+
const r = record;
|
|
2831
|
+
if (r.epc !== void 0 || r.parentID !== void 0) return false;
|
|
2832
|
+
const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
|
|
2833
|
+
return has("inputEPCList") || has("inputQuantityList") || has("outputEPCList") || has("outputQuantityList");
|
|
2834
|
+
}
|
|
2810
2835
|
function get(obj, path) {
|
|
2811
2836
|
return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
|
|
2812
2837
|
}
|
|
@@ -2843,19 +2868,49 @@ function resolveQuantityList(v, record, field, errors) {
|
|
|
2843
2868
|
}
|
|
2844
2869
|
function mapRecordChecked(record, mapping, eventTime) {
|
|
2845
2870
|
const errors = [];
|
|
2846
|
-
const action = resolve(mapping.action, record, "action", errors) ?? "";
|
|
2847
2871
|
const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
|
|
2848
2872
|
const disposition = resolve(mapping.disposition, record, "disposition", errors);
|
|
2849
2873
|
const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
|
|
2850
2874
|
const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
|
|
2875
|
+
if (mapping.type === "TransformationEvent") {
|
|
2876
|
+
const inputEPCList = resolveList(mapping.inputEPCList, record, "inputEPCList", errors);
|
|
2877
|
+
const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, "inputQuantityList", errors);
|
|
2878
|
+
const outputEPCList = resolveList(mapping.outputEPCList, record, "outputEPCList", errors);
|
|
2879
|
+
const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, "outputQuantityList", errors);
|
|
2880
|
+
const transformationID = resolve(mapping.transformationID, record, "transformationID", errors);
|
|
2881
|
+
if (!inputEPCList.length && !inputQuantityList.length) {
|
|
2882
|
+
errors.push("\uC785\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB4E4\uC5B4\uAC14\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
|
|
2883
|
+
}
|
|
2884
|
+
if (!outputEPCList.length && !outputQuantityList.length) {
|
|
2885
|
+
errors.push("\uCD9C\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB098\uC654\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
|
|
2886
|
+
}
|
|
2887
|
+
return {
|
|
2888
|
+
event: transformationEvent({
|
|
2889
|
+
eventTime,
|
|
2890
|
+
bizStep,
|
|
2891
|
+
disposition,
|
|
2892
|
+
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
|
|
2893
|
+
개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
|
|
2894
|
+
...inputEPCList.length ? { inputEPCList } : {},
|
|
2895
|
+
...inputQuantityList.length ? { inputQuantityList } : {},
|
|
2896
|
+
...outputEPCList.length ? { outputEPCList } : {},
|
|
2897
|
+
...outputQuantityList.length ? { outputQuantityList } : {},
|
|
2898
|
+
...transformationID ? { transformationID } : {},
|
|
2899
|
+
readPoint,
|
|
2900
|
+
bizLocation
|
|
2901
|
+
}),
|
|
2902
|
+
errors
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2851
2905
|
if (mapping.type === "AggregationEvent") {
|
|
2906
|
+
const action2 = resolve(mapping.action, record, "action", errors) ?? "";
|
|
2852
2907
|
const parentID = resolve(mapping.parentID, record, "parentID", errors) ?? "";
|
|
2853
2908
|
const childEPCs = resolveList(mapping.childEPCs, record, "childEPCs", errors);
|
|
2854
2909
|
const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, "childQuantityList", errors);
|
|
2855
2910
|
return {
|
|
2856
2911
|
event: aggregationEvent({
|
|
2857
2912
|
eventTime,
|
|
2858
|
-
action,
|
|
2913
|
+
action: action2,
|
|
2859
2914
|
bizStep,
|
|
2860
2915
|
disposition,
|
|
2861
2916
|
parentID,
|
|
@@ -2868,6 +2923,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
2868
2923
|
errors
|
|
2869
2924
|
};
|
|
2870
2925
|
}
|
|
2926
|
+
const action = resolve(mapping.action, record, "action", errors) ?? "";
|
|
2871
2927
|
const epc = resolve(mapping.epc, record, "epc", errors);
|
|
2872
2928
|
const quantityList = resolveQuantityList(mapping.quantityList, record, "quantityList", errors);
|
|
2873
2929
|
if (!epc && !quantityList.length) {
|
|
@@ -3393,6 +3449,11 @@ var ItemStore = class _ItemStore {
|
|
|
3393
3449
|
for (const [k, it] of this.map) out.set(k, structuredClone(it));
|
|
3394
3450
|
return out;
|
|
3395
3451
|
}
|
|
3452
|
+
/*
|
|
3453
|
+
* 지연 순회(`*iterAt`)를 만들어 배열을 없애 보았으나 **더 느렸다** — 물품 16,000 규모에서 틱 합계가
|
|
3454
|
+
* 1562ms 에서 2723ms 로 늘었다. 항목마다 생성기 규약을 지나는 비용이 배열 하나를 만드는 비용보다
|
|
3455
|
+
* 크다. 측정이 그렇게 답했으므로 배열을 유지한다.
|
|
3456
|
+
*/
|
|
3396
3457
|
/** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
|
|
3397
3458
|
at(location) {
|
|
3398
3459
|
const keys = this.byLocation.get(location);
|
|
@@ -4250,6 +4311,8 @@ var FlowEngine = class {
|
|
|
4250
4311
|
...o.recipeKey ? { recipeKey: o.recipeKey } : {},
|
|
4251
4312
|
/* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
|
|
4252
4313
|
...o.seedIncomplete ? { seedIncomplete: true } : {},
|
|
4314
|
+
/* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
|
|
4315
|
+
...o.shortage?.length ? { shortage: o.shortage.map((x) => ({ ...x })) } : {},
|
|
4253
4316
|
...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
|
|
4254
4317
|
...o.priority !== void 0 ? { priority: o.priority } : {},
|
|
4255
4318
|
...o.startTime ? { startTime: o.startTime } : {},
|
|
@@ -6584,6 +6647,42 @@ var MesKernel = class extends FlowEngine {
|
|
|
6584
6647
|
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
6585
6648
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
6586
6649
|
*/
|
|
6650
|
+
/**
|
|
6651
|
+
* **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
|
|
6652
|
+
*
|
|
6653
|
+
* ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
|
|
6654
|
+
* 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
|
|
6655
|
+
* 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
|
|
6656
|
+
* 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
|
|
6657
|
+
*
|
|
6658
|
+
* 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
|
|
6659
|
+
* 한 곳에서 맡는다(확장점 규약).
|
|
6660
|
+
*
|
|
6661
|
+
* 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
|
|
6662
|
+
*/
|
|
6663
|
+
collectAttentions() {
|
|
6664
|
+
const out = super.collectAttentions();
|
|
6665
|
+
for (const o of this.orders.values()) {
|
|
6666
|
+
const short = o.shortage;
|
|
6667
|
+
if (!short?.length) continue;
|
|
6668
|
+
out.push({
|
|
6669
|
+
id: `material-short:${o.id}`,
|
|
6670
|
+
kind: "material-short",
|
|
6671
|
+
/* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
|
|
6672
|
+
severity: short.length > 1 ? "high" : "medium",
|
|
6673
|
+
anchor: { orderId: o.id },
|
|
6674
|
+
params: {
|
|
6675
|
+
/* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
|
|
6676
|
+
shortLines: short.length,
|
|
6677
|
+
/* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
|
|
6678
|
+
materials: short.map((x) => x.material).join(", "),
|
|
6679
|
+
need: short.reduce((n, x) => n + x.need, 0),
|
|
6680
|
+
have: short.reduce((n, x) => n + x.have, 0)
|
|
6681
|
+
}
|
|
6682
|
+
});
|
|
6683
|
+
}
|
|
6684
|
+
return out;
|
|
6685
|
+
}
|
|
6587
6686
|
handleCommand(cmd) {
|
|
6588
6687
|
if (cmd.type === MES_CMD.changeover) {
|
|
6589
6688
|
const a = cmd.args;
|
|
@@ -6826,6 +6925,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
6826
6925
|
}
|
|
6827
6926
|
const rc = this.recipeDef(o);
|
|
6828
6927
|
const picks = [];
|
|
6928
|
+
const short = [];
|
|
6829
6929
|
const locsOfType = /* @__PURE__ */ new Map();
|
|
6830
6930
|
for (const n of this.locations.values()) {
|
|
6831
6931
|
const bin = locsOfType.get(n.type);
|
|
@@ -6842,9 +6942,18 @@ var MesKernel = class extends FlowEngine {
|
|
|
6842
6942
|
}
|
|
6843
6943
|
}
|
|
6844
6944
|
const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
|
|
6845
|
-
if (chosen.length < line.qty)
|
|
6945
|
+
if (chosen.length < line.qty) {
|
|
6946
|
+
short.push({ material: line.material, need: line.qty, have: chosen.length });
|
|
6947
|
+
continue;
|
|
6948
|
+
}
|
|
6846
6949
|
picks.push(...chosen);
|
|
6847
6950
|
}
|
|
6951
|
+
if (short.length) {
|
|
6952
|
+
o.shortage = short;
|
|
6953
|
+
this.emitOrder(o);
|
|
6954
|
+
return;
|
|
6955
|
+
}
|
|
6956
|
+
if (o.shortage) delete o.shortage;
|
|
6848
6957
|
for (const epc of picks) o.allocated.push(epc);
|
|
6849
6958
|
this.reserve(picks, MES_BIZSTEP.producing);
|
|
6850
6959
|
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
@@ -8037,7 +8146,7 @@ function ingestOperationalRecords(records, opts) {
|
|
|
8037
8146
|
// src/energy-attribution.ts
|
|
8038
8147
|
var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
|
8039
8148
|
function attributeEnergy(opts) {
|
|
8040
|
-
const
|
|
8149
|
+
const byId2 = new Map(opts.consumers.map((c) => [c.id, c]));
|
|
8041
8150
|
const dedicated = /* @__PURE__ */ new Map();
|
|
8042
8151
|
for (const c of opts.consumers) if (c.meterId) dedicated.set(c.meterId, c);
|
|
8043
8152
|
const shares = [];
|
|
@@ -8054,7 +8163,7 @@ function attributeEnergy(opts) {
|
|
|
8054
8163
|
}
|
|
8055
8164
|
if (pool.overhead) {
|
|
8056
8165
|
const alloc = opts.overheadAllocation;
|
|
8057
|
-
const targets = (alloc?.processConsumerIds ?? []).map((id) =>
|
|
8166
|
+
const targets = (alloc?.processConsumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
|
|
8058
8167
|
const wOf = (c) => alloc?.weightKind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
|
|
8059
8168
|
const sharing2 = alloc?.weightKind === "equal" ? targets : targets.filter((c) => wOf(c) > 0);
|
|
8060
8169
|
const ws = sharing2.map(wOf);
|
|
@@ -8080,7 +8189,7 @@ function attributeEnergy(opts) {
|
|
|
8080
8189
|
});
|
|
8081
8190
|
continue;
|
|
8082
8191
|
}
|
|
8083
|
-
const covered = (pool.consumerIds ?? []).map((id) =>
|
|
8192
|
+
const covered = (pool.consumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
|
|
8084
8193
|
if (!covered.length) {
|
|
8085
8194
|
unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? "not-submetered" : "no-consumers" });
|
|
8086
8195
|
continue;
|
|
@@ -8367,6 +8476,7 @@ function retiredVocabularyIn(line) {
|
|
|
8367
8476
|
isEnergyRecord,
|
|
8368
8477
|
isEquipmentLevel,
|
|
8369
8478
|
isOperationalRecord,
|
|
8479
|
+
isTransformationRecord,
|
|
8370
8480
|
isoDurationHours,
|
|
8371
8481
|
itemKeyOf,
|
|
8372
8482
|
levelOfLocationType,
|
package/package.json
CHANGED