@operato/twin-kernel 0.2.3 → 0.4.0
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/README.md +46 -5
- package/dist/capability.js +2 -2
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +836 -45
- package/dist/contract.js +519 -7
- package/dist/divergence.d.ts +1 -1
- package/dist/divergence.js +8 -5
- package/dist/domain-catalog.d.ts +4 -4
- package/dist/domain-catalog.js +4 -4
- package/dist/domain-definition.d.ts +50 -5
- package/dist/domain-definition.js +6 -6
- package/dist/epcis.d.ts +12 -0
- package/dist/epcis.js +12 -0
- package/dist/event-journal.d.ts +31 -1
- package/dist/event-journal.js +27 -1
- package/dist/flow-engine.d.ts +323 -36
- package/dist/flow-engine.js +868 -164
- package/dist/forecast.d.ts +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/job-response.d.ts +59 -0
- package/dist/job-response.js +56 -0
- package/dist/kernel.d.ts +5 -0
- package/dist/kernel.js +31 -11
- package/dist/mes-kernel.d.ts +28 -0
- package/dist/mes-kernel.js +83 -29
- package/dist/mes-profile.d.ts +2 -2
- package/dist/mes-profile.js +11 -11
- package/dist/observed-reducer.d.ts +68 -7
- package/dist/observed-reducer.js +248 -41
- package/dist/task-fold.d.ts +2 -2
- package/dist/task-fold.js +4 -4
- package/dist/vocabulary.d.ts +19 -0
- package/dist/vocabulary.js +66 -0
- package/dist/wms-profile.d.ts +2 -2
- package/dist/wms-profile.js +6 -6
- package/dist/yms-kernel.js +36 -15
- package/dist/yms-profile.d.ts +2 -2
- package/dist/yms-profile.js +6 -6
- package/dist-cjs/index.cjs +1618 -270
- package/package.json +1 -1
package/dist/flow-engine.js
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* FlowEngine — 도메인-일반 flow 엔진 base (WMS·YMS 공유 mechanics 추출).
|
|
3
3
|
*
|
|
4
|
-
* WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick
|
|
4
|
+
* WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick 루프·설비 배정·태스크 진행·emit·snapshot·
|
|
5
5
|
* slotViews)는 동일했다 → 이 base 로 승격. 도메인 kernel 은 **flow 동사만** 구현:
|
|
6
6
|
* onArrival(자극:입고 도착) · onOrder(자극:출고 오더) · allocate(오더→태스크) · onTaskComplete(태스크 완료)
|
|
7
7
|
*
|
|
8
8
|
* TwinKernel 구현 → StateProjector·TwinRuntime 가 도메인 무관하게 소비.
|
|
9
9
|
* 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
|
|
10
|
-
* (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]]
|
|
10
|
+
* (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
|
|
11
11
|
*/
|
|
12
|
-
import { OP_EVENT, CMD,
|
|
12
|
+
import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
|
|
13
13
|
import { ObservedReducer } from "./observed-reducer.js";
|
|
14
|
-
import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
|
|
14
|
+
import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
|
|
15
15
|
import { parseIsoDuration } from "./iso-duration.js";
|
|
16
|
+
import { analyzeCapacity } from "./capacity.js";
|
|
16
17
|
const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
|
|
18
|
+
/**
|
|
19
|
+
* 선언한 유효 기간만 뽑는다 — 마스터가 말하지 않으면 필드를 만들지 않는다.
|
|
20
|
+
*
|
|
21
|
+
* `{ effectiveStart: undefined }` 를 넣으면 스냅샷 왕복·미러 비교에서 "선언했지만 비었다" 와
|
|
22
|
+
* "선언하지 않았다" 가 구별되지 않는다(적합성 하네스가 갈라짐으로 잡는다).
|
|
23
|
+
*/
|
|
24
|
+
function effectiveOnly(r) {
|
|
25
|
+
return {
|
|
26
|
+
...(r.effectiveStart ? { effectiveStart: r.effectiveStart } : {}),
|
|
27
|
+
...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
17
30
|
function mulberry32(seed) {
|
|
18
31
|
let a = seed >>> 0;
|
|
19
32
|
const fn = (() => {
|
|
@@ -26,20 +39,22 @@ function mulberry32(seed) {
|
|
|
26
39
|
return fn;
|
|
27
40
|
}
|
|
28
41
|
/**
|
|
29
|
-
* 주목신호 계산(순수) — State 스냅샷(
|
|
42
|
+
* 주목신호 계산(순수) — State 스냅샷(equipment/locations/orders)에서 attentions 파생.
|
|
30
43
|
* FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
|
|
31
44
|
* sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
|
|
32
45
|
*/
|
|
33
|
-
export function deriveAttentions(view, acked
|
|
46
|
+
export function deriveAttentions(view, acked,
|
|
47
|
+
/** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
|
|
48
|
+
nowIso) {
|
|
34
49
|
// 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
|
|
35
50
|
// 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
|
|
36
51
|
const out = [];
|
|
37
|
-
for (const m of view.
|
|
52
|
+
for (const m of view.equipment) {
|
|
38
53
|
if (m.status === 'down') {
|
|
39
54
|
out.push({
|
|
40
55
|
id: `breakdown:${m.id}`, kind: 'breakdown', severity: 'critical',
|
|
41
|
-
anchor: { moverId: m.id,
|
|
42
|
-
params: { moverId: m.id, ...(m.location ? {
|
|
56
|
+
anchor: { moverId: m.id, locationId: m.location },
|
|
57
|
+
params: { moverId: m.id, ...(m.location ? { locationId: m.location } : {}) },
|
|
43
58
|
recommendedActions: [
|
|
44
59
|
{ code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } },
|
|
45
60
|
{ code: 'act.hold-until-repair', command: CMD.resourceHold, args: { resourceId: m.id } }
|
|
@@ -48,29 +63,78 @@ export function deriveAttentions(view, acked) {
|
|
|
48
63
|
});
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
|
-
for (const n of view.
|
|
66
|
+
for (const n of view.locations) {
|
|
52
67
|
if ((n.capacity ?? 0) > 0) {
|
|
53
68
|
const r = (n.occupancy ?? 0) / n.capacity;
|
|
54
69
|
if (r >= 0.9) {
|
|
55
70
|
const saturated = r >= 1;
|
|
56
71
|
out.push({
|
|
57
72
|
id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
|
|
58
|
-
anchor: {
|
|
59
|
-
params: {
|
|
73
|
+
anchor: { locationId: n.id },
|
|
74
|
+
params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
|
|
60
75
|
recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
|
|
61
76
|
// 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
|
|
62
77
|
});
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
|
-
|
|
81
|
+
/*
|
|
82
|
+
* **일이 들어오는 속도를 처리 속도가 못 따라간다** — 자리 포화(`bottleneck`)와 다른 종류의 막힘이다.
|
|
83
|
+
*
|
|
84
|
+
* 실제로 이것을 놓쳤다. Rosarito 도장은 부스가 텅 비어 있었고(점유율 낮음) 대수가 모자랐다.
|
|
85
|
+
* 자리로 재는 규칙은 그런 공장을 "정상" 이라 답한다 — 오더는 무한히 쌓이는데 화면 어디에도
|
|
86
|
+
* 깨지는 지점이 없었고, 사람이 용량을 손으로 계산하고서야 알았다. **용량 부족은 조용하다.**
|
|
87
|
+
*
|
|
88
|
+
* 재는 방법은 스냅샷만으로 답할 수 있어야 한다(추세를 쌓아 두지 않는다). 그래서 같은 공정에서
|
|
89
|
+
* **기다리는 일과 하고 있는 일의 비**를 본다 — 처리 능력이 충분하면 대기가 길게 늘지 않는다.
|
|
90
|
+
* 공정이 아예 안 돌면(하는 일 0, 기다리는 일 여럿) 그것이 가장 강한 신호다.
|
|
91
|
+
*
|
|
92
|
+
* 공정 이름은 작업이 스스로 말하는 것(`kind`)을 쓴다. 설비 종류로 환산하지 않는다 — 그 대응은
|
|
93
|
+
* 현장마다 다르고, 커널이 그 방언을 알면 보편 계약이 깨진다.
|
|
94
|
+
*/
|
|
95
|
+
const QUEUE_FLOOR = 5; // 이보다 얕은 줄은 정상 변동이다
|
|
96
|
+
/*
|
|
97
|
+
* **공장이 쉬는 동안 쌓이는 것은 신호가 아니다.** 주말·휴일·휴게 시간에 줄이 길어지는 것은 달력이
|
|
98
|
+
* 시킨 일이고, 월요일이면 빠진다. 그때마다 사람을 부르면 매주 토요일에 같은 경고가 떠서 — 진짜
|
|
99
|
+
* 막혔을 때의 경고까지 같이 무시하게 된다.
|
|
100
|
+
*
|
|
101
|
+
* 쉬는 중인지는 **설비가 이미 말하고 있다**(`offShift`, 달력에서 파생). 여기서 달력을 다시 읽지
|
|
102
|
+
* 않는다 — 두 벌이 되면 갈라진다. 설비를 아예 모르면 판단하지 않는다(줄만 보고 단정하지 않는다).
|
|
103
|
+
*/
|
|
104
|
+
const shutdown = (view.equipment?.length ?? 0) > 0 && view.equipment.every(m => m.offShift);
|
|
105
|
+
if (view.tasks?.length && !shutdown) {
|
|
106
|
+
const waiting = new Map();
|
|
107
|
+
const working = new Map();
|
|
108
|
+
for (const t of view.tasks) {
|
|
109
|
+
const bucket = t.status === 'created' || t.status === 'assigned' ? waiting : t.status === 'in-progress' ? working : undefined;
|
|
110
|
+
if (bucket)
|
|
111
|
+
bucket.set(t.kind, (bucket.get(t.kind) ?? 0) + 1);
|
|
112
|
+
}
|
|
113
|
+
for (const [operation, queued] of waiting) {
|
|
114
|
+
if (queued < QUEUE_FLOOR)
|
|
115
|
+
continue;
|
|
116
|
+
const active = working.get(operation) ?? 0;
|
|
117
|
+
const ratio = queued / Math.max(active, 1);
|
|
118
|
+
if (active > 0 && ratio < 3)
|
|
119
|
+
continue;
|
|
120
|
+
out.push({
|
|
121
|
+
id: `work-backlog:${operation}`,
|
|
122
|
+
kind: 'work-backlog',
|
|
123
|
+
severity: active === 0 || ratio >= 6 ? 'high' : 'medium',
|
|
124
|
+
anchor: { operation },
|
|
125
|
+
params: { operation, queued, active, ratio: Math.round(ratio * 10) / 10 },
|
|
126
|
+
recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const m of view.equipment) {
|
|
67
131
|
const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
|
|
68
132
|
if (total >= 10) {
|
|
69
133
|
const rate = (m.scrapCount ?? 0) / total;
|
|
70
134
|
if (rate >= 0.15)
|
|
71
135
|
out.push({
|
|
72
136
|
id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
|
|
73
|
-
anchor: { moverId: m.id,
|
|
137
|
+
anchor: { moverId: m.id, locationId: m.location },
|
|
74
138
|
params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
|
|
75
139
|
recommendedActions: [
|
|
76
140
|
{ code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
|
|
@@ -90,6 +154,26 @@ export function deriveAttentions(view, acked) {
|
|
|
90
154
|
suggestedAction: { code: 'act.resume-order', command: CMD.orderResume, args: { orderId: o.id } }
|
|
91
155
|
});
|
|
92
156
|
}
|
|
157
|
+
/* 납기 초과 — **표준 `EndTime` 이 있어야 판정할 수 있다.** 없으면 신호를 만들지 않는다(없는 납기를
|
|
158
|
+
지연으로도 정시로도 말하지 않는다). 이미 끝난 오더는 대상이 아니다.
|
|
159
|
+
심각도는 얼마나 늦었는지로 가른다 — 방금 넘긴 것과 하루 넘긴 것을 같게 부르면 신호가 무의미해진다. */
|
|
160
|
+
for (const o of view.orders) {
|
|
161
|
+
if (o.status === 'completed' || o.status === 'fulfilled')
|
|
162
|
+
continue;
|
|
163
|
+
if (dueStatusOf(o, nowIso) !== 'late')
|
|
164
|
+
continue;
|
|
165
|
+
const overdueMs = Date.parse(nowIso) - Date.parse(o.endTime);
|
|
166
|
+
out.push({
|
|
167
|
+
id: `late:${o.id}`,
|
|
168
|
+
kind: 'order-late',
|
|
169
|
+
severity: overdueMs >= 3600_000 ? 'critical' : 'high',
|
|
170
|
+
anchor: { orderId: o.id },
|
|
171
|
+
params: { orderId: o.id, endTime: o.endTime, overdueMs, ...(o.priority !== undefined ? { priority: o.priority } : {}) },
|
|
172
|
+
/* 조치는 권고만 둔다 — 지연을 커널이 스스로 해소할 수단이 없다(자원을 늘리거나 우선순위를
|
|
173
|
+
올리는 것은 사람의 결정이다). 없는 조치를 만들어 붙이지 않는다. */
|
|
174
|
+
recommendedActions: [{ code: 'advice.raise-priority' }, { code: 'advice.add-resource' }]
|
|
175
|
+
});
|
|
176
|
+
}
|
|
93
177
|
if (acked)
|
|
94
178
|
for (const a of out)
|
|
95
179
|
if (acked.has(a.id))
|
|
@@ -115,9 +199,16 @@ export function computeOee(c, nowMs) {
|
|
|
115
199
|
}
|
|
116
200
|
export class FlowEngine {
|
|
117
201
|
tenantId;
|
|
118
|
-
|
|
202
|
+
locations = new Map();
|
|
119
203
|
items = new Map();
|
|
120
|
-
|
|
204
|
+
equipment = new Map();
|
|
205
|
+
/** 등급 정의(표준 `<X>Class`) — 상속·유효기간 판정의 재료. 선언 안 하면 비어 있고, 소속 그대로 판정한다. */
|
|
206
|
+
classDefs = {};
|
|
207
|
+
/**
|
|
208
|
+
* 품목 정의 색인(표준 `MaterialDefinition`) — 단위 환산의 **유일한 근거**.
|
|
209
|
+
* 비어 있으면 환산하지 않는다(계수를 모르는데 값을 만들지 않는다).
|
|
210
|
+
*/
|
|
211
|
+
materialDefs = new Map();
|
|
121
212
|
/** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
|
|
122
213
|
persons = new Map();
|
|
123
214
|
/** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
|
|
@@ -164,61 +255,84 @@ export class FlowEngine {
|
|
|
164
255
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
165
256
|
loadBoard(def) {
|
|
166
257
|
this.boardDef = def;
|
|
167
|
-
for (const n of def
|
|
168
|
-
this.
|
|
258
|
+
for (const n of readBoardLocations(def))
|
|
259
|
+
this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
|
|
260
|
+
this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
|
|
261
|
+
this.materialDefs = new Map((def.materialDefinitions ?? []).filter(d => d?.id).map(d => [d.id, d]));
|
|
169
262
|
for (const p of def.persons ?? [])
|
|
170
|
-
this.persons.set(p.id, { id: p.id,
|
|
171
|
-
for (const a of def
|
|
172
|
-
this.assets.set(a.id, { id: a.id,
|
|
173
|
-
for (const m of def
|
|
174
|
-
const
|
|
263
|
+
this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOnly(p) });
|
|
264
|
+
for (const a of readBoardAssets(def))
|
|
265
|
+
this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOnly(a) });
|
|
266
|
+
for (const m of readBoardEquipment(def)) {
|
|
267
|
+
const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
|
|
175
268
|
if (m.mtbfMs !== undefined) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
269
|
+
eq.mtbfMs = m.mtbfMs;
|
|
270
|
+
eq.mttrMs = m.mttrMs;
|
|
271
|
+
eq.nextFailureMs = this.sampleExp(m.mtbfMs);
|
|
179
272
|
}
|
|
180
|
-
this.
|
|
273
|
+
this.equipment.set(m.id, eq);
|
|
181
274
|
}
|
|
182
275
|
}
|
|
183
276
|
/**
|
|
184
|
-
* what-if 구성 변주 — fork(또는 실행 중) 엔진에
|
|
277
|
+
* what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
|
|
185
278
|
* 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
|
|
186
279
|
*/
|
|
187
|
-
|
|
188
|
-
if (this.
|
|
280
|
+
addEquipment(m) {
|
|
281
|
+
if (this.equipment.has(m.id))
|
|
189
282
|
return;
|
|
190
|
-
const
|
|
283
|
+
const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
|
|
191
284
|
if (m.mtbfMs !== undefined) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
285
|
+
eq.mtbfMs = m.mtbfMs;
|
|
286
|
+
eq.mttrMs = m.mttrMs;
|
|
287
|
+
eq.nextFailureMs = this.sampleExp(m.mtbfMs);
|
|
195
288
|
}
|
|
196
|
-
this.
|
|
289
|
+
this.equipment.set(m.id, eq);
|
|
197
290
|
}
|
|
198
291
|
/**
|
|
199
|
-
* 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(
|
|
292
|
+
* 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·설비·자리)과
|
|
200
293
|
* 저널 오더(원값+라인)로 이 커널의 맵을 채운다. tick 으로 만든 게 아니라 "현재 관측된 현실"을 심어
|
|
201
294
|
* 이후 fork/tick 으로 예측한다. 라이브 런타임은 여전히 projector 미러 — 이 커널은 예측용 임시본.
|
|
202
295
|
* 오더는 남은 데맨드(라인별 requested-fulfilled)를 'created' 로 복원(현재 재고에서 재계획).
|
|
203
296
|
* 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
|
|
204
297
|
*/
|
|
298
|
+
/**
|
|
299
|
+
* **이어서 센다** — 재기동 뒤 저널을 이어 쓸 때.
|
|
300
|
+
*
|
|
301
|
+
* 커널은 리비전을 0부터 센다. 새로 시작하는 우주라면 맞지만, **이어지는 현실**(sim-world)에서는
|
|
302
|
+
* 그 번호가 이미 저널에 있는 행과 겹친다 — 같은 트윈에 revision 7이 두 개 생기고, 재생 순서가
|
|
303
|
+
* 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다. 조용히 깨지는 종류다(오류가 안 난다).
|
|
304
|
+
*
|
|
305
|
+
* 그래서 이어 쓰기 전에 마지막 번호를 알려 준다. **뒤로는 못 간다** — 뒤로 가는 것은 곧 겹치는
|
|
306
|
+
* 것이고, 그것을 허용하면 이 함수가 막으려던 일이 이 함수를 통해 일어난다.
|
|
307
|
+
*/
|
|
308
|
+
resumeRevision(from) {
|
|
309
|
+
if (!Number.isFinite(from) || from < 0)
|
|
310
|
+
throw new Error(`이어 셀 리비전이 올바르지 않다: ${from}`);
|
|
311
|
+
if (from < this.revision)
|
|
312
|
+
throw new Error(`리비전을 뒤로 되돌릴 수 없다: ${this.revision} → ${from} (겹치는 번호가 생긴다)`);
|
|
313
|
+
this.revision = from;
|
|
314
|
+
}
|
|
205
315
|
hydrateObserved(snap, orders = []) {
|
|
206
|
-
/* **관측된 것을 버리지 않는다.** 예전에는
|
|
316
|
+
/* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
|
|
207
317
|
* 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
|
|
208
318
|
* 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 굴리면 답이 낙관 쪽으로 치우친다. */
|
|
209
|
-
for (const n of snap.
|
|
210
|
-
this.
|
|
319
|
+
for (const n of snap.locations) {
|
|
320
|
+
this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
|
|
211
321
|
}
|
|
212
322
|
this.items.clear();
|
|
213
323
|
for (const it of snap.items) {
|
|
214
|
-
|
|
324
|
+
/* **부분마다 한 줄로 심는다** — `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접혀
|
|
325
|
+
씨앗에서 재고가 줄어든다(§MaterialSubLot 에서 겪은 것과 같은 오류의 세 번째 자리). */
|
|
326
|
+
this.items.set(itemKeyOf(it), {
|
|
215
327
|
/* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
|
|
216
328
|
epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
|
|
217
|
-
|
|
329
|
+
...(it.subLotId ? { subLotId: it.subLotId } : {}),
|
|
330
|
+
...(it.definitionId ? { definitionId: it.definitionId } : {}),
|
|
331
|
+
gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom, ...(it.quantities?.length ? { quantities: it.quantities } : {}),
|
|
218
332
|
parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
|
|
219
333
|
});
|
|
220
334
|
}
|
|
221
|
-
for (const m of snap.
|
|
335
|
+
for (const m of snap.equipment) {
|
|
222
336
|
/*
|
|
223
337
|
* 관측된 계측을 이어받는다 — **없어서 0 이었던 게 아니라 잘못 읽어서 0 이었다.**
|
|
224
338
|
* `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`scrapCount` 인데 예전 코드가
|
|
@@ -231,32 +345,121 @@ export class FlowEngine {
|
|
|
231
345
|
* 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
|
|
232
346
|
*/
|
|
233
347
|
const oee = m.oee;
|
|
234
|
-
|
|
235
|
-
|
|
348
|
+
/* **마스터가 말한 것을 관측이 지우지 않는다.** 예전에는 이 자리에서 통째로 새 객체를 만들어
|
|
349
|
+
* 교대 캘린더·속성·유효 기간·계획정지를 **전부 버렸다.** 그 결과 라이브(관측) 커널은
|
|
350
|
+
* 근무 시간을 모르고(항상 가용), 폐기를 모르고, 속도를 몰라 이동시간을 상수로 떨어뜨렸다.
|
|
351
|
+
* 관측이 말하는 것(위치·상태·계측)만 덮고 **선언은 지킨다**. */
|
|
352
|
+
const prev = this.equipment.get(m.id);
|
|
353
|
+
this.equipment.set(m.id, {
|
|
354
|
+
...(prev ?? {}),
|
|
355
|
+
id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
|
|
236
356
|
runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
|
|
237
|
-
goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0
|
|
357
|
+
goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
|
|
358
|
+
/* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 굴린다
|
|
359
|
+
(예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
|
|
360
|
+
...(m.held ? { held: true } : {}),
|
|
361
|
+
/* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
|
|
362
|
+
...(m.properties ? { properties: m.properties } : {}),
|
|
363
|
+
...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
|
|
364
|
+
...effectiveOnly(m)
|
|
238
365
|
});
|
|
239
366
|
}
|
|
240
367
|
/* 사람을 이어 붙인다 — **없으면 인원 요구가 영원히 채워지지 않는다.** 관측 상태에 사람이 있는데
|
|
241
368
|
* 씨앗이 버리면, 인원을 요구하는 공정의 작업이 하나도 시작되지 못하고 예측이 멈춘다(0 명 < 2 명).
|
|
242
369
|
* 배정 상태(busy/taskId)는 아래 작업 복원이 다시 세우므로 여기서는 등급·교대만 살린다. */
|
|
243
370
|
for (const p of snap.persons ?? []) {
|
|
244
|
-
this.persons.
|
|
371
|
+
const prev = this.persons.get(p.id);
|
|
372
|
+
this.persons.set(p.id, {
|
|
373
|
+
...(prev ?? {}),
|
|
374
|
+
id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds, status: 'idle', taskId: null,
|
|
375
|
+
...(p.location ? { location: p.location } : {}),
|
|
376
|
+
...(p.properties ? { properties: p.properties } : {}),
|
|
377
|
+
...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
|
|
378
|
+
...effectiveOnly(p)
|
|
379
|
+
});
|
|
245
380
|
}
|
|
246
381
|
/* 자산도 같다 — 잃으면 자산을 요구하는 작업이 영원히 못 나간다(빈 팔레트 0개 < 1개). */
|
|
247
382
|
for (const a of snap.assets ?? []) {
|
|
248
|
-
this.assets.
|
|
383
|
+
const prev = this.assets.get(a.id);
|
|
384
|
+
this.assets.set(a.id, {
|
|
385
|
+
...(prev ?? {}),
|
|
386
|
+
id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
|
|
387
|
+
...(a.properties ? { properties: a.properties } : {}),
|
|
388
|
+
...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
|
|
389
|
+
...effectiveOnly(a)
|
|
390
|
+
});
|
|
249
391
|
}
|
|
250
392
|
/* 진행 중이던 작업을 이어 붙인다 — 없으면 예측이 "일이 하나도 없는 현장" 에서 출발한다.
|
|
251
393
|
* 남은 시간을 모르면 **진척을 꾸미지 않고** 미착수(created)로 되돌린다: 그 일이 남아 있다는 사실은
|
|
252
394
|
* 지키면서, 얼마나 진행됐는지는 모른다고 말하는 쪽이 정직하다(커널이 다시 배정해 굴린다). */
|
|
395
|
+
/*
|
|
396
|
+
* **주체가 없는 작업은 되살리지 않는다.**
|
|
397
|
+
*
|
|
398
|
+
* 씨앗은 진행 중이던 작업을 이어 붙이는데, 그 작업이 가리키는 물품이 상태에 없을 수 있다
|
|
399
|
+
* (이미 소비·출하됐거나, 관측이 그 물품을 담지 못했다). 그런 작업을 살려 두면 완료 시점에
|
|
400
|
+
* "없는 물품을 옮기려" 하다 **예측 전체가 죽는다** — 실제로 그렇게 죽었다(정확도 추세 서버 오류).
|
|
401
|
+
*
|
|
402
|
+
* 그렇다고 조용히 버리지도 않는다: **몇 건을 왜 뺐는지 말한다.** 조용한 누락은 "일이 없었다" 로
|
|
403
|
+
* 읽히고, 그것이 예측을 낙관 쪽으로 기울인다.
|
|
404
|
+
*/
|
|
405
|
+
/* 오더 원값은 이제 **스냅샷에 있다** — 따로 넘겨받은 것이 없으면 스냅샷에서 읽는다.
|
|
406
|
+
* (예전에는 상태가 progress 로 압축돼 호출부가 저널을 뒤져 원값을 넘겨야 했다.) */
|
|
407
|
+
const observedOrders = orders.length
|
|
408
|
+
? orders
|
|
409
|
+
: (snap.orders ?? [])
|
|
410
|
+
.filter(o => o.requested !== undefined)
|
|
411
|
+
.map(o => ({
|
|
412
|
+
orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines,
|
|
413
|
+
/* 약속한 시각과 우선순위를 함께 옮긴다 — 없으면 관측 커널이 **"늦었나" 를 판단할 재료가
|
|
414
|
+
없다.** 라이브 트윈이 납기 초과를 한 번도 보고하지 못한 원인 중 하나였다. */
|
|
415
|
+
...(o.endTime ? { endTime: o.endTime } : {}),
|
|
416
|
+
...(o.startTime ? { startTime: o.startTime } : {}),
|
|
417
|
+
...(o.priority !== undefined ? { priority: o.priority } : {})
|
|
418
|
+
}));
|
|
419
|
+
for (const o of observedOrders) {
|
|
420
|
+
const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
|
|
421
|
+
/* **라인이 없는 오더를 조용히 빼지 않는다.** 예전에는 남은 양을 라인에서만 셌으므로, 품목 라인
|
|
422
|
+
* 없이 총량만 오는 오더(실 시스템에 흔하다)가 전부 `remaining=0` 이 되어 **사라졌다** — 라이브
|
|
423
|
+
* 커널에 오더가 아예 없으니 지연도 이행 예측도 나올 수 없었다. 라인이 없으면 총량으로 센다. */
|
|
424
|
+
const remaining = lines.length
|
|
425
|
+
? lines.reduce((s, l) => s + l.requested, 0)
|
|
426
|
+
: Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
|
|
427
|
+
if (remaining <= 0)
|
|
428
|
+
continue; // 이미 이행 완료 → 예측 대상 아님
|
|
429
|
+
this.orders.set(o.orderId, {
|
|
430
|
+
id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
|
|
431
|
+
bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines,
|
|
432
|
+
/* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
|
|
433
|
+
씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
|
|
434
|
+
...(o.held ? { held: true } : {}),
|
|
435
|
+
...(o.endTime ? { endTime: o.endTime } : {}),
|
|
436
|
+
...(o.startTime ? { startTime: o.startTime } : {}),
|
|
437
|
+
...(o.priority !== undefined ? { priority: o.priority } : {})
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
|
|
441
|
+
const seededOrderIds = new Set(this.orders.keys());
|
|
442
|
+
let orphaned = 0;
|
|
253
443
|
for (const t of snap.tasks ?? []) {
|
|
254
444
|
if (t.status === 'completed')
|
|
255
445
|
continue;
|
|
446
|
+
const ref = t.itemRefs?.[0];
|
|
447
|
+
if (ref && !this.itemByRef(ref)) {
|
|
448
|
+
orphaned++;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
/* 오더도 같다 — 이미 이행된 오더는 심지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
|
|
452
|
+
남으면 완료 시점에 없는 오더를 딛는다. 심는 단계에서 함께 뺀다. */
|
|
453
|
+
if (t.orderId && !seededOrderIds.has(t.orderId)) {
|
|
454
|
+
orphaned++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
256
457
|
const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
|
|
257
458
|
this.tasks.set(t.id, {
|
|
258
459
|
id: t.id, kind: t.kind,
|
|
259
460
|
status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
|
|
461
|
+
/* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
|
|
462
|
+
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
260
463
|
itemEpc: t.itemRefs?.[0] ?? '',
|
|
261
464
|
fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
|
|
262
465
|
resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
|
|
@@ -268,7 +471,7 @@ export class FlowEngine {
|
|
|
268
471
|
});
|
|
269
472
|
/* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
|
|
270
473
|
if (known && t.status === 'in-progress' && t.resourceRef) {
|
|
271
|
-
const mv = this.
|
|
474
|
+
const mv = this.equipment.get(t.resourceRef);
|
|
272
475
|
if (mv) {
|
|
273
476
|
mv.status = 'busy';
|
|
274
477
|
mv.taskId = t.id;
|
|
@@ -288,24 +491,14 @@ export class FlowEngine {
|
|
|
288
491
|
}
|
|
289
492
|
}
|
|
290
493
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
? orders
|
|
295
|
-
: (snap.orders ?? [])
|
|
296
|
-
.filter(o => o.requested !== undefined)
|
|
297
|
-
.map(o => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
|
|
298
|
-
for (const o of observedOrders) {
|
|
299
|
-
const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
|
|
300
|
-
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
301
|
-
if (remaining <= 0)
|
|
302
|
-
continue; // 이미 이행 완료 → 예측 대상 아님
|
|
303
|
-
this.orders.set(o.orderId, { id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0, bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines });
|
|
494
|
+
if (orphaned) {
|
|
495
|
+
console.warn(`[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state — ` +
|
|
496
|
+
'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
|
|
304
497
|
}
|
|
305
498
|
}
|
|
306
|
-
/** what-if 구성 변주 —
|
|
307
|
-
|
|
308
|
-
const n = this.
|
|
499
|
+
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
500
|
+
setLocationCapacity(locationId, capacity) {
|
|
501
|
+
const n = this.locations.get(locationId);
|
|
309
502
|
if (!n)
|
|
310
503
|
return false;
|
|
311
504
|
n.capacity = Math.max(0, capacity);
|
|
@@ -329,7 +522,16 @@ export class FlowEngine {
|
|
|
329
522
|
* 도메인 커맨드(order.release 등)는 handleCommand 로 위임. 커맨드는 sim 상태를 변이하고
|
|
330
523
|
* State 델타를 유발한다(command → 행위 → 관측 폐루프).
|
|
331
524
|
*/
|
|
332
|
-
_acked = new Set();
|
|
525
|
+
_acked = new Set();
|
|
526
|
+
/**
|
|
527
|
+
* 완료 시점에 **주체가 사라져 접은 작업 수** — 조용한 누락이 되지 않게 센다.
|
|
528
|
+
*
|
|
529
|
+
* 물품이 포장·출하·소비로 없어지는 것은 정상이지만, 그 물품을 향한 작업이 남아 있었다는 것은
|
|
530
|
+
* 상류에 어긋남이 있다는 신호다. 0 이 아니면 그 사실을 소비처가 볼 수 있어야 한다.
|
|
531
|
+
*/
|
|
532
|
+
abandonedTasks = 0;
|
|
533
|
+
/** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
|
|
534
|
+
_attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
|
|
333
535
|
dispatch(cmd) {
|
|
334
536
|
const ok = () => ({ commandId: cmd.commandId, accepted: true });
|
|
335
537
|
// 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
|
|
@@ -351,30 +553,30 @@ export class FlowEngine {
|
|
|
351
553
|
this._acked.add(id);
|
|
352
554
|
return ok();
|
|
353
555
|
}
|
|
354
|
-
// Operable 코어 — 자원(
|
|
556
|
+
// Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
|
|
355
557
|
case CMD.resourceHold:
|
|
356
558
|
case CMD.resourceResume: {
|
|
357
|
-
const m = this.
|
|
559
|
+
const m = this.equipment.get(cmd.args?.resourceId ?? '');
|
|
358
560
|
if (!m)
|
|
359
561
|
return fail('resource-not-found');
|
|
360
562
|
m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
|
|
361
|
-
this.
|
|
563
|
+
this.emitEquipment(m);
|
|
362
564
|
return ok();
|
|
363
565
|
}
|
|
364
566
|
case CMD.resourceDown: {
|
|
365
567
|
const a = cmd.args;
|
|
366
|
-
const m = this.
|
|
568
|
+
const m = this.equipment.get(a?.resourceId ?? '');
|
|
367
569
|
if (!m)
|
|
368
570
|
return fail('resource-not-found');
|
|
369
571
|
if (m.status !== 'down') {
|
|
370
572
|
m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
|
|
371
573
|
m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
|
|
372
|
-
this.
|
|
574
|
+
this.emitEquipment(m);
|
|
373
575
|
}
|
|
374
576
|
return ok();
|
|
375
577
|
}
|
|
376
578
|
case CMD.resourceRepair: {
|
|
377
|
-
const m = this.
|
|
579
|
+
const m = this.equipment.get(cmd.args?.resourceId ?? '');
|
|
378
580
|
if (!m)
|
|
379
581
|
return fail('resource-not-found');
|
|
380
582
|
if (m.status === 'down') {
|
|
@@ -382,12 +584,12 @@ export class FlowEngine {
|
|
|
382
584
|
m.repairUntilMs = undefined;
|
|
383
585
|
if (m.mtbfMs !== undefined)
|
|
384
586
|
m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
|
|
385
|
-
this.
|
|
587
|
+
this.emitEquipment(m);
|
|
386
588
|
}
|
|
387
589
|
return ok();
|
|
388
590
|
}
|
|
389
591
|
case CMD.resourceResetMetrics: {
|
|
390
|
-
const m = this.
|
|
592
|
+
const m = this.equipment.get(cmd.args?.resourceId ?? '');
|
|
391
593
|
if (!m)
|
|
392
594
|
return fail('resource-not-found');
|
|
393
595
|
m.runMs = 0;
|
|
@@ -397,27 +599,27 @@ export class FlowEngine {
|
|
|
397
599
|
m.scrapCount = 0;
|
|
398
600
|
m.holdMs = 0;
|
|
399
601
|
m.metricsSinceMs = this.clockMs; // 계측 창을 지금부터 재시작
|
|
400
|
-
this.
|
|
602
|
+
this.emitEquipment(m);
|
|
401
603
|
return ok();
|
|
402
604
|
}
|
|
403
605
|
case CMD.resourceAdd: {
|
|
404
|
-
// 라이브 자원 추가(실제 act) —
|
|
405
|
-
// 새
|
|
606
|
+
// 라이브 자원 추가(실제 act) — addEquipment(what-if 와 동일 경로)로 런타임에 설비 삽입 + equipment 델타 방출.
|
|
607
|
+
// 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
|
|
406
608
|
const a = cmd.args;
|
|
407
609
|
if (!a?.kind)
|
|
408
610
|
return fail('kind-required');
|
|
409
|
-
if (!a?.
|
|
410
|
-
return fail('home-
|
|
611
|
+
if (!a?.homeLocation || !this.locations.has(a.homeLocation))
|
|
612
|
+
return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
|
|
411
613
|
const count = Math.max(1, Math.min(50, Number(a.count) || 1));
|
|
412
|
-
let seq = this.
|
|
614
|
+
let seq = this.equipment.size;
|
|
413
615
|
for (let i = 0; i < count; i++) {
|
|
414
616
|
let id = `${a.kind}-${++seq}`;
|
|
415
|
-
while (this.
|
|
617
|
+
while (this.equipment.has(id))
|
|
416
618
|
id = `${a.kind}-${++seq}`;
|
|
417
|
-
this.
|
|
418
|
-
const m = this.
|
|
619
|
+
this.addEquipment({ id, kind: a.kind, homeLocation: a.homeLocation });
|
|
620
|
+
const m = this.equipment.get(id);
|
|
419
621
|
if (m)
|
|
420
|
-
this.
|
|
622
|
+
this.emitEquipment(m); // equipment.status 델타 → 상태/저널에 새 설비 반영
|
|
421
623
|
}
|
|
422
624
|
return ok();
|
|
423
625
|
}
|
|
@@ -460,32 +662,50 @@ export class FlowEngine {
|
|
|
460
662
|
return {
|
|
461
663
|
revision: this.revision,
|
|
462
664
|
simClockMs: this.clockMs,
|
|
665
|
+
nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
|
|
463
666
|
/* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
464
667
|
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
465
|
-
|
|
668
|
+
locations: [...this.locations.values()].map(n => {
|
|
466
669
|
const { status, ...rest } = n;
|
|
467
670
|
/* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
|
|
468
|
-
const derived =
|
|
671
|
+
const derived = locationStatusOf(n);
|
|
469
672
|
return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
|
|
470
673
|
}),
|
|
471
674
|
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
472
|
-
|
|
473
|
-
const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
|
|
675
|
+
equipment: [...this.equipment.values()].map(m => {
|
|
676
|
+
const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
|
|
474
677
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
475
678
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
476
679
|
s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
|
|
477
680
|
return s;
|
|
478
681
|
}),
|
|
479
682
|
assets: [...this.assets.values()].map(a => {
|
|
480
|
-
const st = { id: a.id,
|
|
683
|
+
const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
|
|
481
684
|
if (a.carrying)
|
|
482
685
|
st.carrying = a.carrying;
|
|
686
|
+
if (a.properties)
|
|
687
|
+
st.properties = a.properties;
|
|
688
|
+
if (a.testSpecificationIds)
|
|
689
|
+
st.testSpecificationIds = a.testSpecificationIds;
|
|
690
|
+
Object.assign(st, this.effectivePart(a));
|
|
483
691
|
return st;
|
|
484
692
|
}),
|
|
485
693
|
persons: [...this.persons.values()].map(p => {
|
|
486
|
-
const st = { id: p.id,
|
|
487
|
-
if (
|
|
694
|
+
const st = { id: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined };
|
|
695
|
+
if (p.location)
|
|
696
|
+
st.location = p.location;
|
|
697
|
+
if (p.properties)
|
|
698
|
+
st.properties = p.properties;
|
|
699
|
+
if (p.testSpecificationIds)
|
|
700
|
+
st.testSpecificationIds = p.testSpecificationIds;
|
|
701
|
+
if (this.personOffShift(p)) {
|
|
488
702
|
st.offShift = true;
|
|
703
|
+
Object.assign(st, this.offReason(p));
|
|
704
|
+
}
|
|
705
|
+
const sh = this.shiftOf(p);
|
|
706
|
+
if (sh)
|
|
707
|
+
st.shift = sh;
|
|
708
|
+
Object.assign(st, this.effectivePart(p));
|
|
489
709
|
return st;
|
|
490
710
|
}),
|
|
491
711
|
/* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
|
|
@@ -499,31 +719,60 @@ export class FlowEngine {
|
|
|
499
719
|
...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
|
|
500
720
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
501
721
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
502
|
-
...(t.resources?.length ? { resources: t.resources.slice() } : {})
|
|
722
|
+
...(t.resources?.length ? { resources: t.resources.slice() } : {}),
|
|
723
|
+
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
724
|
+
...(t.priority !== undefined ? { priority: t.priority } : {}),
|
|
725
|
+
...(t.startTime ? { startTime: t.startTime } : {}),
|
|
726
|
+
...(t.endTime ? { endTime: t.endTime } : {})
|
|
503
727
|
})),
|
|
504
728
|
orders: [...this.orders.values()].map(o => ({
|
|
505
729
|
id: o.id, kind: o.kind, status: o.status,
|
|
506
730
|
progress: o.requested ? o.fulfilled / o.requested : 0,
|
|
507
731
|
requested: o.requested, fulfilled: o.fulfilled,
|
|
508
732
|
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
|
|
733
|
+
...(o.priority !== undefined ? { priority: o.priority } : {}),
|
|
734
|
+
...(o.startTime ? { startTime: o.startTime } : {}),
|
|
735
|
+
...(o.endTime ? { endTime: o.endTime } : {}),
|
|
509
736
|
held: o.held
|
|
510
737
|
})),
|
|
511
738
|
attentions: this.computeAttentions()
|
|
512
739
|
};
|
|
513
740
|
}
|
|
514
741
|
/*
|
|
515
|
-
* 주목 신호 판단 — 상태(
|
|
742
|
+
* 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
|
|
516
743
|
* severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
|
|
517
744
|
* 도메인별 추가 판단은 서브클래스가 override 로 확장(super.computeAttentions() 합성).
|
|
518
745
|
*/
|
|
519
746
|
computeAttentions() {
|
|
520
747
|
// 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
|
|
521
|
-
const
|
|
522
|
-
|
|
748
|
+
const now = this.now();
|
|
749
|
+
const out = deriveAttentions({
|
|
750
|
+
equipment: [...this.equipment.values()],
|
|
751
|
+
locations: [...this.locations.values()],
|
|
752
|
+
orders: [...this.orders.values()],
|
|
753
|
+
tasks: [...this.tasks.values()]
|
|
754
|
+
}, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
|
|
755
|
+
);
|
|
523
756
|
const present = new Set(out.map(a => a.id));
|
|
757
|
+
/*
|
|
758
|
+
* **조건이 처음 성립한 시각**을 기억해 붙인다(표준 `WorkAlert.TimeStamp`).
|
|
759
|
+
*
|
|
760
|
+
* 주목 신호는 매 스냅샷 다시 계산되는 파생값이라, 여기서 "지금" 을 찍으면 모든 신호가 언제나
|
|
761
|
+
* 방금 생긴 것으로 보인다(스냅샷 시각과 같은 값 = 정보 0). 처음 본 시각만이 "얼마나 오래됐나" 에
|
|
762
|
+
* 답한다 — 방금 찬 자리와 세 시간째 막힌 자리는 할 일이 다르다.
|
|
763
|
+
*/
|
|
764
|
+
for (const a of out) {
|
|
765
|
+
const first = this._attentionSince.get(a.id) ?? now;
|
|
766
|
+
this._attentionSince.set(a.id, first);
|
|
767
|
+
a.timeStamp = first;
|
|
768
|
+
}
|
|
769
|
+
// 확인(ack)·시작 시각 프루닝 — 사라진 조건은 함께 지운다(재발하면 그때가 새 시작이다).
|
|
524
770
|
for (const id of [...this._acked])
|
|
525
771
|
if (!present.has(id))
|
|
526
772
|
this._acked.delete(id);
|
|
773
|
+
for (const id of [...this._attentionSince.keys()])
|
|
774
|
+
if (!present.has(id))
|
|
775
|
+
this._attentionSince.delete(id);
|
|
527
776
|
return out;
|
|
528
777
|
}
|
|
529
778
|
/**
|
|
@@ -557,7 +806,43 @@ export class FlowEngine {
|
|
|
557
806
|
return clone;
|
|
558
807
|
}
|
|
559
808
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
560
|
-
|
|
809
|
+
/**
|
|
810
|
+
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
|
811
|
+
*
|
|
812
|
+
* ── 관측 모드에서 시계가 멈춰 있었다 ────────────────────────────────────
|
|
813
|
+
* `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 굴리지 않는다). 그래서 라이브 트윈의 "지금" 이
|
|
814
|
+
* **BASE_EPOCH(2026-01-01)에 얼어 있었다.** 실 이벤트는 실제 시각을 달고 오므로 결과가 이렇게 된다:
|
|
815
|
+
* · **납기 초과를 영원히 보고하지 않는다** — 실 납기가 항상 미래로 보인다
|
|
816
|
+
* · 폐기·도입예정 판정이 틀린다(§EffectivePeriod)
|
|
817
|
+
* · 교대 판정이 틀린다(§offCalendarAt)
|
|
818
|
+
* 셋 다 "시각으로만 바뀌는 사실" 이라 같은 결함이었다. 그래서 기준을 **한 곳**으로 모은다.
|
|
819
|
+
*
|
|
820
|
+
* 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
|
|
821
|
+
* 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
|
|
822
|
+
*/
|
|
823
|
+
nowMs() {
|
|
824
|
+
const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
|
|
825
|
+
return observed ?? BASE_EPOCH + this.clockMs;
|
|
826
|
+
}
|
|
827
|
+
now() { return new Date(this.nowMs()).toISOString(); }
|
|
828
|
+
/**
|
|
829
|
+
* 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
|
|
830
|
+
*
|
|
831
|
+
* **base 에 한 곳만 둔다.** 오더를 만드는 곳이 도메인마다 있어(WMS 판매오더·MES 작업지시·YMS
|
|
832
|
+
* 어포인트먼트) 각자 계산하면 시간축이나 규약이 갈린다 — 실제로 한 번 갈렸다(납기를 `clockMs` 로
|
|
833
|
+
* 재고 `now()` 와 비교해 갓 만든 오더가 56년 늦은 것으로 판정됐다).
|
|
834
|
+
*
|
|
835
|
+
* 선언이 없으면 **빈 객체**를 준다 — 없는 약속을 만들지 않는다.
|
|
836
|
+
*/
|
|
837
|
+
promiseOf(spec) {
|
|
838
|
+
const at = this.now();
|
|
839
|
+
return {
|
|
840
|
+
...(spec.priority !== undefined ? { priority: spec.priority } : {}),
|
|
841
|
+
...(spec.promisedLeadMinutes !== undefined
|
|
842
|
+
? { startTime: at, endTime: new Date(Date.parse(at) + spec.promisedLeadMinutes * 60_000).toISOString() }
|
|
843
|
+
: {})
|
|
844
|
+
};
|
|
845
|
+
}
|
|
561
846
|
randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
|
|
562
847
|
/**
|
|
563
848
|
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
@@ -577,7 +862,7 @@ export class FlowEngine {
|
|
|
577
862
|
*/
|
|
578
863
|
apply(envelope) {
|
|
579
864
|
if (!this.observer) {
|
|
580
|
-
this.observer = new ObservedReducer(this.boardDef ?? {
|
|
865
|
+
this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
|
|
581
866
|
this.observeMode = true;
|
|
582
867
|
}
|
|
583
868
|
this.observer.apply(envelope);
|
|
@@ -616,6 +901,43 @@ export class FlowEngine {
|
|
|
616
901
|
if (o?.key)
|
|
617
902
|
this.operationSpecs.set(o.key, o);
|
|
618
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
|
|
906
|
+
* 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
|
|
907
|
+
*/
|
|
908
|
+
routeKeys() {
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* **이 공장이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
|
|
913
|
+
*
|
|
914
|
+
* 커널이 직접 답하는 이유: 필요한 사실이 전부 여기 있다(공정 명세·설비와 신뢰도·인원·물리자산·
|
|
915
|
+
* 자리·근무 달력·시각 기준). 밖에서 모으면 그 값을 옮겨 적게 되고, 한쪽만 바뀌는 순간 "충분하다"
|
|
916
|
+
* 가 조용히 거짓이 된다. 계산 자체는 순수 함수(`analyzeCapacity`)에 맡긴다.
|
|
917
|
+
*
|
|
918
|
+
* 달력은 **자원이 선언한 것**을 쓴다. 자원마다 다른 달력을 쓰는 현장이면 대표를 고를 수 없으므로
|
|
919
|
+
* 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 천장이 틀린 채로
|
|
920
|
+
* 그럴듯해 보인다.
|
|
921
|
+
*/
|
|
922
|
+
capacity(opts) {
|
|
923
|
+
const calendars = new Map();
|
|
924
|
+
for (const m of this.equipment.values())
|
|
925
|
+
if (m.workCalendar?.length)
|
|
926
|
+
calendars.set(JSON.stringify(m.workCalendar), m.workCalendar);
|
|
927
|
+
const analysis = analyzeCapacity({
|
|
928
|
+
operations: [...this.operationSpecs.values()],
|
|
929
|
+
...(this.routeKeys() ? { route: this.routeKeys() } : {}),
|
|
930
|
+
equipment: [...this.equipment.values()].map(m => ({ kind: m.kind, mtbfMs: m.mtbfMs, mttrMs: m.mttrMs })),
|
|
931
|
+
persons: [...this.persons.values()],
|
|
932
|
+
assets: [...this.assets.values()],
|
|
933
|
+
locations: [...this.locations.values()],
|
|
934
|
+
...(calendars.size === 1 ? { calendar: [...calendars.values()][0] } : {}),
|
|
935
|
+
...(this.boardDef?.utcOffsetMinutes !== undefined ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {}),
|
|
936
|
+
sampleWeekStartMs: opts.sampleWeekStartMs,
|
|
937
|
+
unitsPerDay: opts.unitsPerDay
|
|
938
|
+
});
|
|
939
|
+
return { ...analysis, mixedCalendars: calendars.size > 1 };
|
|
940
|
+
}
|
|
619
941
|
/**
|
|
620
942
|
* task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
|
|
621
943
|
*
|
|
@@ -743,8 +1065,19 @@ export class FlowEngine {
|
|
|
743
1065
|
defaultDurations: operations.filter(o => o.duration === 'default').length
|
|
744
1066
|
};
|
|
745
1067
|
}
|
|
746
|
-
/**
|
|
1068
|
+
/**
|
|
1069
|
+
* `skuMix` 에서 weight 로 gtin 선택(rng) — 도착·오더 자극의 품목 결정.
|
|
1070
|
+
*
|
|
1071
|
+
* **빈 목록이면 고르지 않는다**(`undefined`). 예전에는 `mix[mix.length - 1].gtin` 으로 떨어져
|
|
1072
|
+
* `mix[-1]` 이 undefined 가 되고 거기서 던졌다 — 그 예외가 서버까지 올라가 **정확도 추세 전체를
|
|
1073
|
+
* 죽였다**(품목 구성을 선언하지 않은 자극 하나가 예측 전체를 껐다).
|
|
1074
|
+
*
|
|
1075
|
+
* 없는 품목을 지어내지 않는다: 무엇을 만들지 모르면 **만들지 않는 것**이 맞고, 부르는 쪽이
|
|
1076
|
+
* 그 사실을 알고 건너뛴다.
|
|
1077
|
+
*/
|
|
747
1078
|
pickGtin(mix) {
|
|
1079
|
+
if (!mix?.length)
|
|
1080
|
+
return undefined;
|
|
748
1081
|
const total = mix.reduce((s, m) => s + m.weight, 0);
|
|
749
1082
|
let r = this.rng() * total;
|
|
750
1083
|
for (const m of mix) {
|
|
@@ -754,8 +1087,8 @@ export class FlowEngine {
|
|
|
754
1087
|
}
|
|
755
1088
|
return mix[mix.length - 1].gtin;
|
|
756
1089
|
}
|
|
757
|
-
|
|
758
|
-
for (const n of this.
|
|
1090
|
+
locationByType(type) {
|
|
1091
|
+
for (const n of this.locations.values())
|
|
759
1092
|
if (n.type === type)
|
|
760
1093
|
return n;
|
|
761
1094
|
return undefined;
|
|
@@ -764,7 +1097,7 @@ export class FlowEngine {
|
|
|
764
1097
|
* process 변화 대수(EPCIS TransformationEvent · ISA-95 Material Consumed/Produced · 씬 Processable.transform).
|
|
765
1098
|
* inputs 소비 → outputs 생산. 입출력 arity 가 곧 대수:
|
|
766
1099
|
* merge N→1(조립) · split 1→N(분해) · transform 1→1(타입변경) · loss N→0(소실) · gain 0→N(부산물·생성).
|
|
767
|
-
* 아이템 상태(소비/생산)와
|
|
1100
|
+
* 아이템 상태(소비/생산)와 자리 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
|
|
768
1101
|
* 도메인은 이 원시만 호출 — onTaskComplete 의 소비/생산/EPCIS 손코딩을 대체.
|
|
769
1102
|
*/
|
|
770
1103
|
transform(inputs, outputs, opts) {
|
|
@@ -772,14 +1105,14 @@ export class FlowEngine {
|
|
|
772
1105
|
const it = this.items.get(epc);
|
|
773
1106
|
if (!it)
|
|
774
1107
|
continue;
|
|
775
|
-
const n = this.
|
|
1108
|
+
const n = this.locations.get(it.location);
|
|
776
1109
|
if (n)
|
|
777
1110
|
n.occupancy--;
|
|
778
1111
|
this.items.delete(epc);
|
|
779
1112
|
}
|
|
780
1113
|
for (const o of outputs) {
|
|
781
1114
|
this.items.set(o.epc, { epc: o.epc, gtin: o.gtin, qty: o.qty, location: o.location, disposition: o.disposition });
|
|
782
|
-
const n = this.
|
|
1115
|
+
const n = this.locations.get(o.location);
|
|
783
1116
|
if (n)
|
|
784
1117
|
n.occupancy++;
|
|
785
1118
|
}
|
|
@@ -849,7 +1182,7 @@ export class FlowEngine {
|
|
|
849
1182
|
for (const c of children) {
|
|
850
1183
|
const it = this.items.get(c);
|
|
851
1184
|
if (it) {
|
|
852
|
-
const n = this.
|
|
1185
|
+
const n = this.locations.get(it.location);
|
|
853
1186
|
if (n)
|
|
854
1187
|
n.occupancy--;
|
|
855
1188
|
this.items.delete(c);
|
|
@@ -867,7 +1200,7 @@ export class FlowEngine {
|
|
|
867
1200
|
}
|
|
868
1201
|
/**
|
|
869
1202
|
* containment 분해(EPCIS AggregationEvent DELETE) — 부모(용기)에서 자식들을 풀어냄.
|
|
870
|
-
* materialize 지정 시 자식이 독립 아이템으로 등장(materialize:
|
|
1203
|
+
* materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 자리 배치 + occupancy + ObjectEvent ADD).
|
|
871
1204
|
* 미지정 시 이벤트만.
|
|
872
1205
|
*/
|
|
873
1206
|
disaggregate(parent, children, opts) {
|
|
@@ -882,7 +1215,7 @@ export class FlowEngine {
|
|
|
882
1215
|
const m = opts.materialize;
|
|
883
1216
|
for (const c of children) {
|
|
884
1217
|
this.items.set(c, { epc: c, location: m.location, disposition: m.disposition });
|
|
885
|
-
const n = this.
|
|
1218
|
+
const n = this.locations.get(m.location);
|
|
886
1219
|
if (n)
|
|
887
1220
|
n.occupancy++;
|
|
888
1221
|
this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: opts.bizStep, disposition: m.disposition, epcList: [c], readPoint: m.location, bizLocation: m.location }));
|
|
@@ -891,7 +1224,7 @@ export class FlowEngine {
|
|
|
891
1224
|
}
|
|
892
1225
|
/** 품질 보고(OEE Quality) — 도메인이 완료 시 자원별 양품/불량 1건 계상(예: 용접 수율). */
|
|
893
1226
|
recordOutput(moverId, good) {
|
|
894
|
-
const m = moverId ? this.
|
|
1227
|
+
const m = moverId ? this.equipment.get(moverId) : undefined;
|
|
895
1228
|
if (!m)
|
|
896
1229
|
return;
|
|
897
1230
|
if (good)
|
|
@@ -901,20 +1234,20 @@ export class FlowEngine {
|
|
|
901
1234
|
// 품질 델타 방출 — live OEE 누적기가 good/scrap 을 정확 추적(equipment.status 는 quality 미포함). WMS/YMS 는 미호출→무영향.
|
|
902
1235
|
this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
|
|
903
1236
|
}
|
|
904
|
-
/**
|
|
1237
|
+
/** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
|
|
905
1238
|
oeeOf(m) {
|
|
906
1239
|
// 순수 공식 computeOee 로 위임 — sim(여기)과 live 가 같은 계산 층 공유. planned 는 hold 제외(계획정지 OEE 무영향).
|
|
907
1240
|
return computeOee(m, this.clockMs);
|
|
908
1241
|
}
|
|
909
|
-
/** 정책에 넘길 특정 타입
|
|
910
|
-
slotViews(
|
|
1242
|
+
/** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
|
|
1243
|
+
slotViews(locationType) {
|
|
911
1244
|
const reserved = new Map();
|
|
912
1245
|
for (const t of this.tasks.values())
|
|
913
1246
|
if (t.status !== 'completed')
|
|
914
1247
|
reserved.set(t.toNode, (reserved.get(t.toNode) ?? 0) + 1);
|
|
915
1248
|
const views = [];
|
|
916
|
-
for (const n of this.
|
|
917
|
-
if (n.type ===
|
|
1249
|
+
for (const n of this.locations.values())
|
|
1250
|
+
if (n.type === locationType)
|
|
918
1251
|
views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
|
|
919
1252
|
return views;
|
|
920
1253
|
}
|
|
@@ -947,26 +1280,37 @@ export class FlowEngine {
|
|
|
947
1280
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
948
1281
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
949
1282
|
...(t.resources?.length ? { resources: t.resources.slice() } : {}),
|
|
1283
|
+
/* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
|
|
1284
|
+
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
950
1285
|
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
1286
|
+
...(t.priority !== undefined ? { priority: t.priority } : {}),
|
|
1287
|
+
...(t.startTime ? { startTime: t.startTime } : {}),
|
|
1288
|
+
...(t.endTime ? { endTime: t.endTime } : {}),
|
|
951
1289
|
...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
952
1290
|
});
|
|
953
1291
|
}
|
|
954
1292
|
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
955
1293
|
emitAsset(a) {
|
|
956
|
-
this.emitOp(OP_EVENT.asset, { assetId: a.id,
|
|
1294
|
+
this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) });
|
|
957
1295
|
}
|
|
958
1296
|
emitPerson(p) {
|
|
959
|
-
this.emitOp(OP_EVENT.person, { personId: p.id,
|
|
1297
|
+
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) });
|
|
1298
|
+
/* `offShift` 를 더 이상 실어 보내지 않는다 — 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로
|
|
1299
|
+
전이 순간의 판정을 보내면 유휴로 경계를 지난 사람이 옛 값에 머문다. 미러는 같은 함수
|
|
1300
|
+
(`offCalendarAt`)를 자기 시각으로 부른다(유효 기간과 같은 규율). */
|
|
960
1301
|
}
|
|
961
1302
|
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
962
1303
|
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
963
|
-
|
|
964
|
-
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
|
|
1304
|
+
emitEquipment(m, motion) {
|
|
1305
|
+
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) });
|
|
965
1306
|
}
|
|
966
1307
|
/** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
|
|
967
1308
|
emitOrder(o) {
|
|
968
1309
|
this.emitOp(OP_EVENT.order, {
|
|
969
1310
|
orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
|
|
1311
|
+
...(o.priority !== undefined ? { priority: o.priority } : {}),
|
|
1312
|
+
...(o.startTime ? { startTime: o.startTime } : {}),
|
|
1313
|
+
...(o.endTime ? { endTime: o.endTime } : {}),
|
|
970
1314
|
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
|
|
971
1315
|
});
|
|
972
1316
|
}
|
|
@@ -1041,7 +1385,13 @@ export class FlowEngine {
|
|
|
1041
1385
|
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
1042
1386
|
if (!want)
|
|
1043
1387
|
continue;
|
|
1044
|
-
const avail = [...this.assets.values()].filter(
|
|
1388
|
+
const avail = [...this.assets.values()].filter(
|
|
1389
|
+
/* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
|
|
1390
|
+
a => a.status === 'idle' &&
|
|
1391
|
+
!picked.includes(a.id) &&
|
|
1392
|
+
!this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
|
|
1393
|
+
(req.assetClass === undefined ||
|
|
1394
|
+
classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass)));
|
|
1045
1395
|
if (avail.length < want)
|
|
1046
1396
|
return null;
|
|
1047
1397
|
for (let i = 0; i < want; i++)
|
|
@@ -1064,7 +1414,7 @@ export class FlowEngine {
|
|
|
1064
1414
|
a.carrying = t.itemEpc;
|
|
1065
1415
|
/* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
|
|
1066
1416
|
* 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
|
|
1067
|
-
const it = this.
|
|
1417
|
+
const it = this.itemByRef(t.itemEpc);
|
|
1068
1418
|
if (it)
|
|
1069
1419
|
it.carriedBy = a.id;
|
|
1070
1420
|
}
|
|
@@ -1098,6 +1448,223 @@ export class FlowEngine {
|
|
|
1098
1448
|
* 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
|
|
1099
1449
|
* (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
|
|
1100
1450
|
*/
|
|
1451
|
+
/**
|
|
1452
|
+
* 필요 자재를 확보한다 — **ISA-95 `OperationsSegment.MaterialSpecification`**(`use: 'consumed'`).
|
|
1453
|
+
*
|
|
1454
|
+
* ── 4대 자원 중 자재만 게이트가 없었다 ────────────────────────────────────
|
|
1455
|
+
* 인원·자산·설비는 모자라면 기다리는데 **자재는 없어도 작업이 시작됐다.** 그래서 부품이 떨어져
|
|
1456
|
+
* 라인이 서는 상황이 예측에 아예 나타나지 않았다 — 트레일러 조립 공장에서 그것은 가장 흔한 정지다.
|
|
1457
|
+
*
|
|
1458
|
+
* 인원·자산과 **같은 규칙**이다: 등급(또는 품목)으로 요구하고, **부분 확보 없이 전량 아니면 대기.**
|
|
1459
|
+
* 자재는 **작업이 일어나는 자리에 있어야** 한다(다른 창고의 부품은 지금 쓸 수 없다).
|
|
1460
|
+
*
|
|
1461
|
+
* 소비 시점은 **작업 시작**이다 — 자재는 들어가는 순간 없어진다(완료 시점에 빼면 그 사이 다른
|
|
1462
|
+
* 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
|
|
1463
|
+
*/
|
|
1464
|
+
claimMaterials(t) {
|
|
1465
|
+
const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'consumed');
|
|
1466
|
+
if (!need.length)
|
|
1467
|
+
return [];
|
|
1468
|
+
const at = t.toNode;
|
|
1469
|
+
const picked = [];
|
|
1470
|
+
const takenSoFar = new Map();
|
|
1471
|
+
for (const req of need) {
|
|
1472
|
+
let remaining = Math.max(0, req.quantity ?? 0);
|
|
1473
|
+
if (!remaining)
|
|
1474
|
+
continue;
|
|
1475
|
+
for (const it of this.items.values()) {
|
|
1476
|
+
if (remaining <= 0)
|
|
1477
|
+
break;
|
|
1478
|
+
if (it.location !== at)
|
|
1479
|
+
continue; // 그 자리에 있는 것만 — 다른 창고의 부품은 지금 쓸 수 없다
|
|
1480
|
+
if (!this.materialMatches(it, req))
|
|
1481
|
+
continue;
|
|
1482
|
+
const already = takenSoFar.get(it.epc) ?? 0;
|
|
1483
|
+
const avail = Math.max(0, (it.qty ?? 1) - already);
|
|
1484
|
+
if (avail <= 0)
|
|
1485
|
+
continue;
|
|
1486
|
+
const take = Math.min(avail, remaining);
|
|
1487
|
+
picked.push({ epc: it.epc, take });
|
|
1488
|
+
takenSoFar.set(it.epc, already + take);
|
|
1489
|
+
remaining -= take;
|
|
1490
|
+
}
|
|
1491
|
+
if (remaining > 0)
|
|
1492
|
+
return null; // 한 줄이라도 모자라면 시작하지 않는다(부분 투입 없음)
|
|
1493
|
+
}
|
|
1494
|
+
return picked;
|
|
1495
|
+
}
|
|
1496
|
+
/** 이 물품이 명세를 만족하나 — 품목 지목이 우선, 없으면 등급(상속을 타고 닫는다). */
|
|
1497
|
+
materialMatches(it, req) {
|
|
1498
|
+
const defId = it.definitionId ?? it.gtin;
|
|
1499
|
+
if (req.materialDefinition)
|
|
1500
|
+
return defId === req.materialDefinition;
|
|
1501
|
+
if (req.materialClass) {
|
|
1502
|
+
const def = defId ? this.materialDefs.get(defId) : undefined;
|
|
1503
|
+
return classClosure(def?.materialClassIds, this.classDefs.material, this.now()).has(req.materialClass);
|
|
1504
|
+
}
|
|
1505
|
+
return true; // 지목도 등급도 없으면 아무 자재나(명세가 그렇게 느슨하면 그대로 따른다)
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* 산출 자재를 만든다 — ISA-95 `MaterialSpecification`(`use: 'produced'`).
|
|
1509
|
+
*
|
|
1510
|
+
* **소비의 짝이다**: 자재는 작업이 시작될 때 들어가고 끝날 때 나온다. 산출이 없으면 조립 공정이
|
|
1511
|
+
* 부품만 먹고 아무것도 내놓지 않아, 다음 공정이 영원히 재료를 기다린다(라인 전체가 한 칸에서 멈춘다).
|
|
1512
|
+
*
|
|
1513
|
+
* 산출물의 자리는 **작업이 끝난 자리**(`toNode`)다. 식별자는 품목 식별자에 작업 id 를 붙여 만든다 —
|
|
1514
|
+
* **EPC 처럼 보이게 꾸미지 않는다**(상류가 준 직렬번호와 구별돼야 한다). 상류가 진짜 식별자를
|
|
1515
|
+
* 말해 주면 그것이 들어올 자리는 관측 경로다.
|
|
1516
|
+
*
|
|
1517
|
+
* 같은 품목이 그 자리에 이미 있으면 **수량을 더한다** — 새 줄을 만들면 같은 자리의 같은 로트가
|
|
1518
|
+
* 둘로 갈려 재고가 부푼다(§MaterialSubLot 에서 겪은 것과 반대 방향의 같은 오류).
|
|
1519
|
+
*/
|
|
1520
|
+
produceMaterials(t) {
|
|
1521
|
+
const made = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'produced');
|
|
1522
|
+
if (!made.length)
|
|
1523
|
+
return;
|
|
1524
|
+
for (const spec of made) {
|
|
1525
|
+
const qty = Math.max(0, spec.quantity ?? 0);
|
|
1526
|
+
if (!qty)
|
|
1527
|
+
continue;
|
|
1528
|
+
/* 무엇을 만드는지 말하지 않으면 만들지 않는다 — 이름 없는 물품을 재고에 올리면
|
|
1529
|
+
그 뒤 누구도 그것이 무엇인지 답할 수 없다(등급만으로는 품목이 정해지지 않는다). */
|
|
1530
|
+
const gtin = spec.materialDefinition;
|
|
1531
|
+
if (!gtin)
|
|
1532
|
+
continue;
|
|
1533
|
+
const at = t.toNode;
|
|
1534
|
+
/*
|
|
1535
|
+
* **클래스 + 수량으로 낸다 — 일련번호를 지어내지 않는다.**
|
|
1536
|
+
*
|
|
1537
|
+
* 처음에는 산출물마다 `<gtin>#made-<taskId>` 같은 EPC 를 만들었다. 적합성 하네스가 잡았다:
|
|
1538
|
+
* 미러는 그 문자열을 파싱해 품목을 알아내려는데 EPC 가 아니라서 **품번이 갈렸다.** 애초에
|
|
1539
|
+
* 우리는 만든 물건의 일련번호를 **모른다** — 상류가 말해 줄 때만 안다. 모르는 것을 지어내면
|
|
1540
|
+
* 그 뒤 추적이 전부 가짜 식별자 위에 선다.
|
|
1541
|
+
*
|
|
1542
|
+
* 그래서 비직렬 생산으로 낸다(표준이 허용하는 모양): 그 자리의 **새 총량**을 관측으로 알린다.
|
|
1543
|
+
* 미러도 같은 규칙으로 읽으므로(자리마다 한 줄 — §MaterialSubLot) 두 구동이 같은 답을 낸다.
|
|
1544
|
+
*/
|
|
1545
|
+
const key = subLotIdOf(gtin, at);
|
|
1546
|
+
const existing = this.items.get(key);
|
|
1547
|
+
const total = (existing?.qty ?? 0) + qty;
|
|
1548
|
+
if (existing)
|
|
1549
|
+
existing.qty = total;
|
|
1550
|
+
else {
|
|
1551
|
+
this.items.set(key, { epc: gtin, subLotId: key, gtin, qty: total, location: at, disposition: DISP.in_progress, ...(spec.uom ? { uom: spec.uom } : {}) });
|
|
1552
|
+
const n = this.locations.get(at);
|
|
1553
|
+
if (n)
|
|
1554
|
+
n.occupancy++;
|
|
1555
|
+
}
|
|
1556
|
+
this.recordMaterialActual(t, gtin, 'produced', qty, spec.uom);
|
|
1557
|
+
this.emit(objectEvent({
|
|
1558
|
+
eventTime: this.now(), action: 'ADD', bizStep: CBV_BIZSTEP.commissioning, disposition: DISP.in_progress,
|
|
1559
|
+
epcList: [], quantityList: [{ epcClass: gtin, quantity: total, ...(spec.uom ? { uom: spec.uom } : {}) }],
|
|
1560
|
+
readPoint: at, bizLocation: at
|
|
1561
|
+
}));
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
|
|
1566
|
+
*
|
|
1567
|
+
* 작업은 혼자 서지 못한다: 옮길 **물품**과, (있다면) 그것을 시킨 **오더** 위에 선다. 진행 중에
|
|
1568
|
+
* 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 심지 않아서.
|
|
1569
|
+
*
|
|
1570
|
+
* 그때 도메인 훅은 없는 것을 딛으려다 던진다(`order.gtin` · `item.location`). 그 예외 하나가
|
|
1571
|
+
* **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
|
|
1572
|
+
* 여기서 묻고, 없으면 그 작업만 접는다.
|
|
1573
|
+
*/
|
|
1574
|
+
missingContextOf(t) {
|
|
1575
|
+
if (t.itemEpc && !this.itemByRef(t.itemEpc))
|
|
1576
|
+
return 'item';
|
|
1577
|
+
if (t.orderId && !this.orders.has(t.orderId))
|
|
1578
|
+
return 'order';
|
|
1579
|
+
if (!this.canComplete(t))
|
|
1580
|
+
return 'domain';
|
|
1581
|
+
return undefined;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* 이 도메인이 이 작업을 **끝맺을 수 있나** — 코어가 모르는 조건은 도메인이 답한다.
|
|
1585
|
+
*
|
|
1586
|
+
* 코어는 물품과 오더까지만 안다. 그런데 도메인은 더 필요할 수 있다 — MES 는 완료 시점에 **오더와
|
|
1587
|
+
* 제품 정의**를 딛고 서고, 그 중 하나만 없어도 던진다. 코어가 그 조건을 추측하면 도메인마다 다른
|
|
1588
|
+
* 가정을 코어에 박게 되므로(방언), **묻는다.**
|
|
1589
|
+
*
|
|
1590
|
+
* 기본은 `true` — 대부분의 작업은 코어가 확인한 것으로 충분하다.
|
|
1591
|
+
*/
|
|
1592
|
+
canComplete(_t) {
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* 작업이 가리키는 물품을 찾는다 — **참조와 키가 다를 수 있다.**
|
|
1597
|
+
*
|
|
1598
|
+
* 물품 맵의 키는 `itemKeyOf`(직렬 물품은 `epc`, 로트의 부분은 `subLotId`)인데, 작업은 로트 식별자
|
|
1599
|
+
* (`itemEpc`)로 가리킨다. 비직렬 로트에서는 둘이 **다르다** — 그래서 그냥 `get` 하면 못 찾는다.
|
|
1600
|
+
* 실제로 그 회귀를 냈다: 예측(씨앗) 경로에서 `item.location = …` 이 `undefined` 위에서 던져
|
|
1601
|
+
* **정확도 추세 전체가 서버 오류로 죽었다.**
|
|
1602
|
+
*
|
|
1603
|
+
* **부분이 여럿이면 풀지 않는다** — 어느 부분을 가리키는지 알 수 없고, 아무거나 고르면 그 뒤
|
|
1604
|
+
* 이동·처분이 엉뚱한 자리에 적힌다. 그때는 참조가 부족한 것이고, 부르는 쪽이 그 사실을 말해야 한다.
|
|
1605
|
+
*/
|
|
1606
|
+
itemByRef(ref) {
|
|
1607
|
+
const exact = this.items.get(ref);
|
|
1608
|
+
if (exact)
|
|
1609
|
+
return exact;
|
|
1610
|
+
let hit;
|
|
1611
|
+
for (const it of this.items.values()) {
|
|
1612
|
+
if (it.epc !== ref)
|
|
1613
|
+
continue;
|
|
1614
|
+
if (hit)
|
|
1615
|
+
return undefined; // 부분이 여럿 — 어느 것인지 알 수 없다
|
|
1616
|
+
hit = it;
|
|
1617
|
+
}
|
|
1618
|
+
return hit;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* 실제 자재 이동을 작업에 적어 둔다 — ISA-95 `JobResponse.MaterialActual`.
|
|
1622
|
+
* 같은 품목·같은 쓰임은 **한 줄로 합친다**(줄을 늘리면 실적을 세는 쪽이 중복을 걷어내야 한다).
|
|
1623
|
+
*/
|
|
1624
|
+
recordMaterialActual(t, definitionId, use, quantity, uom) {
|
|
1625
|
+
if (!definitionId || !(quantity > 0))
|
|
1626
|
+
return;
|
|
1627
|
+
const rows = (t.materialActual ??= []);
|
|
1628
|
+
const hit = rows.find(r => r.definitionId === definitionId && r.use === use && r.uom === uom);
|
|
1629
|
+
if (hit)
|
|
1630
|
+
hit.quantity += quantity;
|
|
1631
|
+
else
|
|
1632
|
+
rows.push({ definitionId, use, quantity, ...(uom ? { uom } : {}) });
|
|
1633
|
+
}
|
|
1634
|
+
/** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
|
|
1635
|
+
consumeMaterials(t, taken) {
|
|
1636
|
+
if (!taken.length)
|
|
1637
|
+
return;
|
|
1638
|
+
/* 통째로 없어지는 것은 **원시로** 내보낸다(계보가 남고 미러가 배운다). 부분 소비는 수량만 줄고
|
|
1639
|
+
물품이 남으므로 `transform` 의 입력(전량 소비)으로 표현할 수 없다 — 그 경우 수량 변화를
|
|
1640
|
+
관측 이벤트로 낸다. 어느 쪽이든 **상태만 바뀌는 일은 없다.** */
|
|
1641
|
+
const whole = [];
|
|
1642
|
+
for (const { epc, take } of taken) {
|
|
1643
|
+
const it = this.items.get(epc);
|
|
1644
|
+
if (!it)
|
|
1645
|
+
continue;
|
|
1646
|
+
const left = (it.qty ?? 1) - take;
|
|
1647
|
+
if (left > 0) {
|
|
1648
|
+
it.qty = left;
|
|
1649
|
+
this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
|
|
1650
|
+
/* 남은 수량을 그대로 알린다 — 관측 경로가 그 자리의 사실을 갱신한다(§quantityList). */
|
|
1651
|
+
this.emit(objectEvent({
|
|
1652
|
+
eventTime: this.now(), action: 'OBSERVE', bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress,
|
|
1653
|
+
epcList: [], quantityList: [{ epcClass: it.gtin ?? it.epc, quantity: left, uom: it.uom }],
|
|
1654
|
+
readPoint: it.location, bizLocation: it.location
|
|
1655
|
+
}));
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
|
|
1659
|
+
whole.push(epc);
|
|
1660
|
+
}
|
|
1661
|
+
if (whole.length) {
|
|
1662
|
+
this.transform(whole, [], {
|
|
1663
|
+
bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress, transformationId: t.id,
|
|
1664
|
+
readPoint: this.items.get(whole[0])?.location ?? t.toNode
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1101
1668
|
claimPersonnel(t) {
|
|
1102
1669
|
const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
|
|
1103
1670
|
if (!need?.length)
|
|
@@ -1110,7 +1677,12 @@ export class FlowEngine {
|
|
|
1110
1677
|
const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
|
|
1111
1678
|
!picked.includes(p.id) &&
|
|
1112
1679
|
!this.personOffShift(p) &&
|
|
1113
|
-
(
|
|
1680
|
+
!this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
|
|
1681
|
+
/* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
|
|
1682
|
+
다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
|
|
1683
|
+
유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
|
|
1684
|
+
(req.personnelClass === undefined ||
|
|
1685
|
+
classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
|
|
1114
1686
|
if (avail.length < want)
|
|
1115
1687
|
return null; // 한 등급이라도 모자라면 시작하지 않는다
|
|
1116
1688
|
for (let i = 0; i < want; i++)
|
|
@@ -1124,7 +1696,15 @@ export class FlowEngine {
|
|
|
1124
1696
|
* 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
|
|
1125
1697
|
*/
|
|
1126
1698
|
claimEquipment(t) {
|
|
1127
|
-
const free = (kind, picked = []) => [...this.
|
|
1699
|
+
const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
|
|
1700
|
+
!m.held &&
|
|
1701
|
+
!this.offShift(m) &&
|
|
1702
|
+
!this.outOfEffect(m) && // 도입 전·폐기 후는 이 시각의 모델에 없다
|
|
1703
|
+
!picked.includes(m.id) &&
|
|
1704
|
+
/* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
|
|
1705
|
+
다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
|
|
1706
|
+
'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
|
|
1707
|
+
(kind === undefined || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind)));
|
|
1128
1708
|
const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
|
|
1129
1709
|
if (!need?.length) {
|
|
1130
1710
|
const one = free(t.resourceType)[0];
|
|
@@ -1170,39 +1750,95 @@ export class FlowEngine {
|
|
|
1170
1750
|
}
|
|
1171
1751
|
/** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
|
|
1172
1752
|
personOffShift(p) {
|
|
1173
|
-
|
|
1174
|
-
if (!w)
|
|
1175
|
-
return false;
|
|
1176
|
-
const h = this.hourOfDay();
|
|
1177
|
-
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
1753
|
+
return this.offCalendar(p);
|
|
1178
1754
|
}
|
|
1179
1755
|
/**
|
|
1180
1756
|
* 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
|
|
1181
1757
|
* 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
|
|
1182
1758
|
*/
|
|
1183
|
-
stationFull(
|
|
1184
|
-
if (!
|
|
1759
|
+
stationFull(locationId) {
|
|
1760
|
+
if (!locationId)
|
|
1185
1761
|
return false;
|
|
1186
|
-
const limit = this.
|
|
1762
|
+
const limit = this.locations.get(locationId)?.parallelism;
|
|
1187
1763
|
if (!(typeof limit === 'number' && limit > 0))
|
|
1188
1764
|
return false;
|
|
1189
1765
|
let running = 0;
|
|
1190
1766
|
for (const t of this.tasks.values())
|
|
1191
|
-
if (t.status === 'in-progress' && t.toNode ===
|
|
1767
|
+
if (t.status === 'in-progress' && t.toNode === locationId)
|
|
1192
1768
|
running++;
|
|
1193
1769
|
return running >= limit;
|
|
1194
1770
|
}
|
|
1195
1771
|
/** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
|
|
1196
1772
|
offShift(m) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1773
|
+
return this.offCalendar(m);
|
|
1774
|
+
}
|
|
1775
|
+
/** 왜 쉬는가 — 휴일·휴게(non-working)인지 주말·교대 사이(off-hours)인지(§OffCalendarReason). */
|
|
1776
|
+
offReason(r) {
|
|
1777
|
+
const why = offCalendarReasonAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
|
|
1778
|
+
return why ? { offShiftReason: why } : {};
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* 유효 기간 밖인가 — **네 번째 이유**(§Effectivity). 설비·사람·자산이 같은 규칙을 쓴다.
|
|
1782
|
+
*
|
|
1783
|
+
* 시뮬 시각을 ISO 로 풀어 계약의 `effectivityAt` 에 넘긴다. **판정은 커널에 두지 않는다** —
|
|
1784
|
+
* 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 갈라진다.
|
|
1785
|
+
*/
|
|
1786
|
+
effectivityOf(r) {
|
|
1787
|
+
if (!r.effectiveStart && !r.effectiveEnd)
|
|
1788
|
+
return undefined; // 흔한 경우에 Date 를 만들지 않는다
|
|
1789
|
+
return effectivityAt(r, this.now());
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* 지금 어느 교대인가 — 계약의 `activeShiftOf` 에 위임한다(미러도 같은 함수를 쓴다).
|
|
1793
|
+
* 선언이 없으면 `undefined`: 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
|
|
1794
|
+
*/
|
|
1795
|
+
shiftOf(r) {
|
|
1796
|
+
if (!r.workCalendar?.length)
|
|
1797
|
+
return undefined;
|
|
1798
|
+
/* 시각까지 넘긴다 — 휴일에는 어느 교대도 서지 않는다(되풀이만 보면 평소 교대로 잘못 답한다). */
|
|
1799
|
+
return activeShiftAt(r.workCalendar, this.nowMs(), this.boardDef?.utcOffsetMinutes);
|
|
1800
|
+
}
|
|
1801
|
+
/** 유효 기간 밖이라 이 시각의 모델에 참여하지 않는가 — 배정 게이트가 쓰는 형태. */
|
|
1802
|
+
outOfEffect(r) {
|
|
1803
|
+
return this.effectivityOf(r) !== undefined;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* 스냅샷·델타에 실을 조각 — **선언한 기간과 판정을 함께** 낸다.
|
|
1807
|
+
*
|
|
1808
|
+
* 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 갈라진다.
|
|
1809
|
+
* 선언이 없으면 아무것도 붙이지 않는다(대부분의 자원이 그렇다 — 필드를 늘리지 않는다).
|
|
1810
|
+
*/
|
|
1811
|
+
effectivePart(r) {
|
|
1812
|
+
if (!r.effectiveStart && !r.effectiveEnd)
|
|
1813
|
+
return {};
|
|
1814
|
+
const e = this.effectivityOf(r);
|
|
1815
|
+
return {
|
|
1816
|
+
...(r.effectiveStart ? { effectiveStart: r.effectiveStart } : {}),
|
|
1817
|
+
...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}),
|
|
1818
|
+
...(e ? { effectivity: e } : {})
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* 지금 근무 시간 밖인가 — **캘린더가 있으면 그것으로, 없으면 옛 `window` 로** 판정한다.
|
|
1823
|
+
*
|
|
1824
|
+
* `window`(시 단위 하나)는 캘린더의 특수한 경우다. 둘을 한 함수로 모아 두면 인원·설비가 같은 규칙을
|
|
1825
|
+
* 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 갈라진다).
|
|
1826
|
+
*/
|
|
1827
|
+
offCalendar(r) {
|
|
1828
|
+
return offCalendarAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
|
|
1832
|
+
*
|
|
1833
|
+
* **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
|
|
1834
|
+
* 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
|
|
1835
|
+
*/
|
|
1836
|
+
minuteOfDay() {
|
|
1837
|
+
return minuteOfDayAt(this.nowMs(), this.boardDef?.utcOffsetMinutes);
|
|
1202
1838
|
}
|
|
1203
1839
|
/** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
|
|
1204
1840
|
hourOfDay() {
|
|
1205
|
-
return new Date(
|
|
1841
|
+
return new Date(this.nowMs()).getUTCHours();
|
|
1206
1842
|
}
|
|
1207
1843
|
/**
|
|
1208
1844
|
* 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
|
|
@@ -1219,12 +1855,12 @@ export class FlowEngine {
|
|
|
1219
1855
|
/** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
|
|
1220
1856
|
sampleExp(meanMs) { return -Math.log(1 - this.rng()) * meanMs; }
|
|
1221
1857
|
/**
|
|
1222
|
-
* 확률적 설비 고장(MTBF/MTTR) — mtbf 지정
|
|
1858
|
+
* 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 설비만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
|
|
1223
1859
|
* up: nextFailure 도래 시 down(수리까지 repairUntil). down: downMs 누적, repair 도래 시 up(다음 고장 예약).
|
|
1224
|
-
* down 중
|
|
1860
|
+
* down 중 설비는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
|
|
1225
1861
|
*/
|
|
1226
1862
|
processFailures(dt) {
|
|
1227
|
-
for (const m of this.
|
|
1863
|
+
for (const m of this.equipment.values()) {
|
|
1228
1864
|
if (m.status === 'down') {
|
|
1229
1865
|
// down 회계 + 수리 — 확률적 고장·강제 고장(resource.down) 공통. repairUntilMs 도래 시 복구.
|
|
1230
1866
|
m.downMs += dt;
|
|
@@ -1233,7 +1869,7 @@ export class FlowEngine {
|
|
|
1233
1869
|
m.repairUntilMs = undefined;
|
|
1234
1870
|
if (m.mtbfMs !== undefined)
|
|
1235
1871
|
m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
|
|
1236
|
-
this.
|
|
1872
|
+
this.emitEquipment(m);
|
|
1237
1873
|
}
|
|
1238
1874
|
continue;
|
|
1239
1875
|
}
|
|
@@ -1243,12 +1879,12 @@ export class FlowEngine {
|
|
|
1243
1879
|
m.holdMs = (m.holdMs ?? 0) + dt;
|
|
1244
1880
|
continue;
|
|
1245
1881
|
}
|
|
1246
|
-
// 확률적 고장 — mtbf 지정
|
|
1882
|
+
// 확률적 고장 — mtbf 지정 설비만(미지정=고장 없음, rng 무소비 → byte-identical baseline).
|
|
1247
1883
|
if (m.mtbfMs !== undefined && this.clockMs >= (m.nextFailureMs ?? Infinity)) {
|
|
1248
1884
|
m.status = 'down';
|
|
1249
1885
|
m.repairUntilMs = this.clockMs + this.sampleExp(m.mttrMs ?? m.mtbfMs);
|
|
1250
1886
|
m.nextFailureMs = undefined;
|
|
1251
|
-
this.
|
|
1887
|
+
this.emitEquipment(m);
|
|
1252
1888
|
}
|
|
1253
1889
|
}
|
|
1254
1890
|
}
|
|
@@ -1268,6 +1904,8 @@ export class FlowEngine {
|
|
|
1268
1904
|
const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
|
|
1269
1905
|
return {
|
|
1270
1906
|
epc: i.epc,
|
|
1907
|
+
...(i.subLotId ? { subLotId: i.subLotId } : {}),
|
|
1908
|
+
...(i.definitionId ? { definitionId: i.definitionId } : {}),
|
|
1271
1909
|
...(i.gtin ? { gtin: i.gtin } : {}),
|
|
1272
1910
|
...(gtinKey ? { gtinKey } : {}),
|
|
1273
1911
|
...(lot ? { lot } : {}),
|
|
@@ -1277,6 +1915,7 @@ export class FlowEngine {
|
|
|
1277
1915
|
...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
|
|
1278
1916
|
...(i.qty !== undefined ? { qty: i.qty } : {}),
|
|
1279
1917
|
...(i.uom ? { uom: i.uom } : {}),
|
|
1918
|
+
...(i.quantities?.length ? { quantities: i.quantities } : {}),
|
|
1280
1919
|
...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
|
|
1281
1920
|
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
1282
1921
|
};
|
|
@@ -1309,9 +1948,13 @@ export class FlowEngine {
|
|
|
1309
1948
|
}
|
|
1310
1949
|
}
|
|
1311
1950
|
processOrders() {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1951
|
+
/* **우선순위 순으로 할당한다** — 표준 `OperationsRequest.Priority`. 같은 우선순위는 입력 순서를
|
|
1952
|
+
지켜 결정성을 잃지 않는다(정렬이 안정적이어야 같은 seed 가 같은 결과를 낸다). */
|
|
1953
|
+
const pending = [...this.orders.values()].filter(o => o.status === 'created' && !o.held);
|
|
1954
|
+
if (pending.some(o => o.priority !== undefined))
|
|
1955
|
+
pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
|
|
1956
|
+
for (const o of pending)
|
|
1957
|
+
this.allocate(o);
|
|
1315
1958
|
}
|
|
1316
1959
|
/**
|
|
1317
1960
|
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
@@ -1326,7 +1969,12 @@ export class FlowEngine {
|
|
|
1326
1969
|
}
|
|
1327
1970
|
assignTasks() {
|
|
1328
1971
|
// created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
|
|
1329
|
-
|
|
1972
|
+
/* 자원이 모자랄 때 **무엇을 먼저 잡는가** — 우선순위가 그것을 정한다(표준 `JobOrder.Priority`).
|
|
1973
|
+
선언이 하나도 없으면 정렬하지 않는다(기존 거동 그대로, 불필요한 순서 변경 없음). */
|
|
1974
|
+
const queue = [...this.tasks.values()];
|
|
1975
|
+
if (queue.some(t => t.priority !== undefined))
|
|
1976
|
+
queue.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
|
|
1977
|
+
for (const t of queue) {
|
|
1330
1978
|
if (t.status !== 'created')
|
|
1331
1979
|
continue;
|
|
1332
1980
|
/* 필요 인원 — 오퍼레이션 명세가 요구하면 등급별로 사람을 잡는다. 모자라면 **기다린다**
|
|
@@ -1338,12 +1986,17 @@ export class FlowEngine {
|
|
|
1338
1986
|
const gear = this.claimAssets(t);
|
|
1339
1987
|
if (gear === null)
|
|
1340
1988
|
continue;
|
|
1989
|
+
/* 필요 자재 — 부품이 없으면 라인이 선다. 인원·자산과 같은 규칙(부분 투입 없이 전량 아니면 대기). */
|
|
1990
|
+
const stock = this.claimMaterials(t);
|
|
1991
|
+
if (stock === null)
|
|
1992
|
+
continue;
|
|
1341
1993
|
if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
|
|
1342
1994
|
t.status = 'in-progress';
|
|
1343
1995
|
t.remainingMs = t.durationMs;
|
|
1344
1996
|
t.startedAtSimMs = this.clockMs;
|
|
1345
1997
|
this.assignCrew(t, crew);
|
|
1346
1998
|
this.assignAssets(t, gear);
|
|
1999
|
+
this.consumeMaterials(t, stock);
|
|
1347
2000
|
this.emitTask(t);
|
|
1348
2001
|
continue;
|
|
1349
2002
|
}
|
|
@@ -1356,33 +2009,34 @@ export class FlowEngine {
|
|
|
1356
2009
|
const rigs = this.claimEquipment(t);
|
|
1357
2010
|
if (!rigs)
|
|
1358
2011
|
continue;
|
|
1359
|
-
const
|
|
1360
|
-
// 체인지오버: task 의 changeoverKey 가
|
|
1361
|
-
if (t.setupMs && t.changeoverKey !== undefined &&
|
|
2012
|
+
const eq = this.equipment.get(rigs[0]);
|
|
2013
|
+
// 체인지오버: task 의 changeoverKey 가 설비 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
|
|
2014
|
+
if (t.setupMs && t.changeoverKey !== undefined && eq.lastChangeoverKey !== undefined && eq.lastChangeoverKey !== t.changeoverKey) {
|
|
1362
2015
|
t.appliedSetupMs = t.setupMs;
|
|
1363
|
-
t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(
|
|
2016
|
+
t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(설비 점유 = 셋업+사이클)
|
|
1364
2017
|
}
|
|
1365
2018
|
if (t.changeoverKey !== undefined)
|
|
1366
|
-
|
|
2019
|
+
eq.lastChangeoverKey = t.changeoverKey;
|
|
1367
2020
|
for (const id of rigs) {
|
|
1368
|
-
const m = this.
|
|
2021
|
+
const m = this.equipment.get(id);
|
|
1369
2022
|
m.status = 'busy';
|
|
1370
2023
|
m.taskId = t.id;
|
|
1371
2024
|
}
|
|
1372
2025
|
t.status = 'in-progress';
|
|
1373
|
-
t.resource =
|
|
2026
|
+
t.resource = eq.id;
|
|
1374
2027
|
t.remainingMs = t.durationMs;
|
|
1375
2028
|
t.startedAtSimMs = this.clockMs;
|
|
1376
2029
|
if (rigs.length > 1)
|
|
1377
2030
|
t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
|
|
1378
2031
|
this.assignCrew(t, crew);
|
|
1379
2032
|
this.assignAssets(t, gear);
|
|
2033
|
+
this.consumeMaterials(t, stock);
|
|
1380
2034
|
this.emitTask(t);
|
|
1381
2035
|
// process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
|
|
1382
2036
|
if (t.intent === 'process')
|
|
1383
|
-
this.
|
|
2037
|
+
this.emitEquipment(eq);
|
|
1384
2038
|
else
|
|
1385
|
-
this.
|
|
2039
|
+
this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
1386
2040
|
}
|
|
1387
2041
|
}
|
|
1388
2042
|
/** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
|
|
@@ -1390,11 +2044,61 @@ export class FlowEngine {
|
|
|
1390
2044
|
for (const t of this.tasks.values()) {
|
|
1391
2045
|
if (t.status !== 'in-progress')
|
|
1392
2046
|
continue;
|
|
1393
|
-
if (t.resource && this.
|
|
2047
|
+
if (t.resource && this.equipment.get(t.resource)?.status === 'down')
|
|
1394
2048
|
continue; // 설비 고장 → 작업 동결
|
|
1395
2049
|
t.remainingMs -= dt;
|
|
1396
2050
|
if (t.remainingMs > 0)
|
|
1397
2051
|
continue;
|
|
2052
|
+
/*
|
|
2053
|
+
* **산출 자재를 만든다** — ISA-95 `MaterialSpecification`(`use: 'produced'`).
|
|
2054
|
+
*
|
|
2055
|
+
* 도메인 훅(`onTaskComplete`)보다 **먼저** 한다: 훅이 산출물을 보고 다음 일을 만들 수 있어야
|
|
2056
|
+
* 한다(만들어지기 전에 물으면 없다고 답한다). 소비가 시작 시점인 것과 짝이다 — 자재는
|
|
2057
|
+
* 들어갈 때 없어지고 나올 때 생긴다.
|
|
2058
|
+
*/
|
|
2059
|
+
/*
|
|
2060
|
+
* **주체가 사라진 작업은 완료시키지 않고 접는다.**
|
|
2061
|
+
*
|
|
2062
|
+
* 진행 중에 그 물품이 없어질 수 있다(포장으로 합쳐지고, 출하로 나가고, 다른 공정이 소비한다).
|
|
2063
|
+
* 그때 도메인 훅은 "없는 물품을 옮기려" 하다 던졌고, **그 예외 하나가 예측 전체를 죽였다** —
|
|
2064
|
+
* 사용자에게는 정확도 추세가 통째로 사라진 것으로 보였다.
|
|
2065
|
+
*
|
|
2066
|
+
* 조용히 넘기지도 않는다: 몇 건이 왜 접혔는지 센다. 그것이 잦다면 **상류에 진짜 문제가 있는 것**
|
|
2067
|
+
* 이고, 그 신호를 지우면 아무도 모른다.
|
|
2068
|
+
*/
|
|
2069
|
+
const missing = this.missingContextOf(t);
|
|
2070
|
+
if (missing) {
|
|
2071
|
+
this.abandonedTasks++;
|
|
2072
|
+
/* **한 번은 말한다.** 세어만 두고 아무도 안 보면 없는 것과 같고, 매번 말하면 로그가 묻힌다.
|
|
2073
|
+
잦아지면 상류에 진짜 어긋남이 있다는 뜻이다 — 그 신호를 지우지 않는다. */
|
|
2074
|
+
if (this.abandonedTasks === 1) {
|
|
2075
|
+
const why = missing === 'item'
|
|
2076
|
+
? 'the item it moves is no longer in state (consumed, shipped, or the observation did not carry it)'
|
|
2077
|
+
: missing === 'order'
|
|
2078
|
+
? 'the order it belongs to is no longer in state (already fulfilled, so the seed did not restore it)'
|
|
2079
|
+
: 'the domain cannot complete it (its order or product definition is missing)';
|
|
2080
|
+
console.warn(`[twin-kernel] "${this.tenantId}": folding task ${t.id} — ${why}. Further such tasks are counted, not logged.`);
|
|
2081
|
+
}
|
|
2082
|
+
this.tasks.delete(t.id);
|
|
2083
|
+
this.releaseCrew(t);
|
|
2084
|
+
this.releaseAssets(t);
|
|
2085
|
+
if (t.resource) {
|
|
2086
|
+
const rig = this.equipment.get(t.resource);
|
|
2087
|
+
if (rig) {
|
|
2088
|
+
rig.status = 'idle';
|
|
2089
|
+
rig.taskId = null;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
for (const id of t.resources ?? []) {
|
|
2093
|
+
const m = this.equipment.get(id);
|
|
2094
|
+
if (m) {
|
|
2095
|
+
m.status = 'idle';
|
|
2096
|
+
m.taskId = null;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
this.produceMaterials(t);
|
|
1398
2102
|
this.onTaskComplete(t);
|
|
1399
2103
|
t.status = 'completed';
|
|
1400
2104
|
this.releaseCrew(t); // 사람은 설비와 별개로 해제한다(dwell 도 사람은 잡고 있었을 수 있다)
|
|
@@ -1403,20 +2107,20 @@ export class FlowEngine {
|
|
|
1403
2107
|
this.emitTask(t);
|
|
1404
2108
|
continue;
|
|
1405
2109
|
} // dwell(무자원) — 해제할 자원 없음
|
|
1406
|
-
const
|
|
2110
|
+
const eq = this.equipment.get(t.resource);
|
|
1407
2111
|
// OEE 계측: 셋업/가동 누적(가동 = 총 duration − 셋업). onTaskComplete 가 recordOutput 로 품질 보고.
|
|
1408
2112
|
const setup = t.appliedSetupMs ?? 0;
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2113
|
+
eq.setupMs += setup;
|
|
2114
|
+
eq.runMs += t.durationMs - setup;
|
|
2115
|
+
eq.status = 'idle';
|
|
2116
|
+
eq.taskId = null;
|
|
1413
2117
|
if (t.intent !== 'process')
|
|
1414
|
-
|
|
2118
|
+
eq.location = t.toNode; // 운반만 위치 이동; process 는 제자리
|
|
1415
2119
|
/* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
|
|
1416
2120
|
for (const id of t.resources ?? []) {
|
|
1417
|
-
if (id ===
|
|
2121
|
+
if (id === eq.id)
|
|
1418
2122
|
continue;
|
|
1419
|
-
const m = this.
|
|
2123
|
+
const m = this.equipment.get(id);
|
|
1420
2124
|
if (!m)
|
|
1421
2125
|
continue;
|
|
1422
2126
|
m.setupMs += setup;
|
|
@@ -1425,10 +2129,10 @@ export class FlowEngine {
|
|
|
1425
2129
|
m.taskId = null;
|
|
1426
2130
|
if (t.intent !== 'process')
|
|
1427
2131
|
m.location = t.toNode;
|
|
1428
|
-
this.
|
|
2132
|
+
this.emitEquipment(m);
|
|
1429
2133
|
}
|
|
1430
2134
|
this.emitTask(t);
|
|
1431
|
-
this.
|
|
2135
|
+
this.emitEquipment(eq);
|
|
1432
2136
|
}
|
|
1433
2137
|
}
|
|
1434
2138
|
}
|