@operato/twin-kernel 0.3.0 → 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/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +512 -23
- package/dist/contract.js +332 -12
- package/dist/divergence.js +5 -2
- package/dist/domain-definition.d.ts +45 -0
- package/dist/epcis.d.ts +12 -0
- package/dist/epcis.js +12 -0
- package/dist/event-journal.d.ts +30 -0
- package/dist/event-journal.js +26 -0
- package/dist/flow-engine.d.ts +270 -7
- package/dist/flow-engine.js +727 -52
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -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 +25 -5
- package/dist/mes-kernel.d.ts +28 -0
- package/dist/mes-kernel.js +58 -4
- package/dist/observed-reducer.d.ts +61 -0
- package/dist/observed-reducer.js +214 -17
- package/dist/yms-kernel.js +26 -5
- package/dist-cjs/index.cjs +1233 -84
- package/package.json +1 -1
package/dist/flow-engine.js
CHANGED
|
@@ -9,11 +9,24 @@
|
|
|
9
9
|
* 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
|
|
10
10
|
* (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
|
|
11
11
|
*/
|
|
12
|
-
import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure } from "./contract.js";
|
|
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 = (() => {
|
|
@@ -30,7 +43,9 @@ function mulberry32(seed) {
|
|
|
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 = [];
|
|
@@ -63,6 +78,55 @@ export function deriveAttentions(view, acked) {
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
}
|
|
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
|
+
}
|
|
66
130
|
for (const m of view.equipment) {
|
|
67
131
|
const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
|
|
68
132
|
if (total >= 10) {
|
|
@@ -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))
|
|
@@ -120,6 +204,11 @@ export class FlowEngine {
|
|
|
120
204
|
equipment = new Map();
|
|
121
205
|
/** 등급 정의(표준 `<X>Class`) — 상속·유효기간 판정의 재료. 선언 안 하면 비어 있고, 소속 그대로 판정한다. */
|
|
122
206
|
classDefs = {};
|
|
207
|
+
/**
|
|
208
|
+
* 품목 정의 색인(표준 `MaterialDefinition`) — 단위 환산의 **유일한 근거**.
|
|
209
|
+
* 비어 있으면 환산하지 않는다(계수를 모르는데 값을 만들지 않는다).
|
|
210
|
+
*/
|
|
211
|
+
materialDefs = new Map();
|
|
123
212
|
/** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
|
|
124
213
|
persons = new Map();
|
|
125
214
|
/** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
|
|
@@ -168,13 +257,14 @@ export class FlowEngine {
|
|
|
168
257
|
this.boardDef = def;
|
|
169
258
|
for (const n of readBoardLocations(def))
|
|
170
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 });
|
|
171
|
-
this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses };
|
|
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]));
|
|
172
262
|
for (const p of def.persons ?? [])
|
|
173
|
-
this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}) });
|
|
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) });
|
|
174
264
|
for (const a of readBoardAssets(def))
|
|
175
|
-
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 } : {}) });
|
|
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) });
|
|
176
266
|
for (const m of readBoardEquipment(def)) {
|
|
177
|
-
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 };
|
|
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) };
|
|
178
268
|
if (m.mtbfMs !== undefined) {
|
|
179
269
|
eq.mtbfMs = m.mtbfMs;
|
|
180
270
|
eq.mttrMs = m.mttrMs;
|
|
@@ -205,6 +295,23 @@ export class FlowEngine {
|
|
|
205
295
|
* 오더는 남은 데맨드(라인별 requested-fulfilled)를 'created' 로 복원(현재 재고에서 재계획).
|
|
206
296
|
* 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
|
|
207
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
|
+
}
|
|
208
315
|
hydrateObserved(snap, orders = []) {
|
|
209
316
|
/* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
|
|
210
317
|
* 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
|
|
@@ -214,10 +321,14 @@ export class FlowEngine {
|
|
|
214
321
|
}
|
|
215
322
|
this.items.clear();
|
|
216
323
|
for (const it of snap.items) {
|
|
217
|
-
|
|
324
|
+
/* **부분마다 한 줄로 심는다** — `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접혀
|
|
325
|
+
씨앗에서 재고가 줄어든다(§MaterialSubLot 에서 겪은 것과 같은 오류의 세 번째 자리). */
|
|
326
|
+
this.items.set(itemKeyOf(it), {
|
|
218
327
|
/* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
|
|
219
328
|
epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
|
|
220
|
-
|
|
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 } : {}),
|
|
221
332
|
parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
|
|
222
333
|
});
|
|
223
334
|
}
|
|
@@ -234,32 +345,121 @@ export class FlowEngine {
|
|
|
234
345
|
* 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
|
|
235
346
|
*/
|
|
236
347
|
const oee = m.oee;
|
|
348
|
+
/* **마스터가 말한 것을 관측이 지우지 않는다.** 예전에는 이 자리에서 통째로 새 객체를 만들어
|
|
349
|
+
* 교대 캘린더·속성·유효 기간·계획정지를 **전부 버렸다.** 그 결과 라이브(관측) 커널은
|
|
350
|
+
* 근무 시간을 모르고(항상 가용), 폐기를 모르고, 속도를 몰라 이동시간을 상수로 떨어뜨렸다.
|
|
351
|
+
* 관측이 말하는 것(위치·상태·계측)만 덮고 **선언은 지킨다**. */
|
|
352
|
+
const prev = this.equipment.get(m.id);
|
|
237
353
|
this.equipment.set(m.id, {
|
|
354
|
+
...(prev ?? {}),
|
|
238
355
|
id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
|
|
239
356
|
runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
|
|
240
|
-
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)
|
|
241
365
|
});
|
|
242
366
|
}
|
|
243
367
|
/* 사람을 이어 붙인다 — **없으면 인원 요구가 영원히 채워지지 않는다.** 관측 상태에 사람이 있는데
|
|
244
368
|
* 씨앗이 버리면, 인원을 요구하는 공정의 작업이 하나도 시작되지 못하고 예측이 멈춘다(0 명 < 2 명).
|
|
245
369
|
* 배정 상태(busy/taskId)는 아래 작업 복원이 다시 세우므로 여기서는 등급·교대만 살린다. */
|
|
246
370
|
for (const p of snap.persons ?? []) {
|
|
247
|
-
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
|
+
});
|
|
248
380
|
}
|
|
249
381
|
/* 자산도 같다 — 잃으면 자산을 요구하는 작업이 영원히 못 나간다(빈 팔레트 0개 < 1개). */
|
|
250
382
|
for (const a of snap.assets ?? []) {
|
|
251
|
-
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
|
+
});
|
|
252
391
|
}
|
|
253
392
|
/* 진행 중이던 작업을 이어 붙인다 — 없으면 예측이 "일이 하나도 없는 현장" 에서 출발한다.
|
|
254
393
|
* 남은 시간을 모르면 **진척을 꾸미지 않고** 미착수(created)로 되돌린다: 그 일이 남아 있다는 사실은
|
|
255
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;
|
|
256
443
|
for (const t of snap.tasks ?? []) {
|
|
257
444
|
if (t.status === 'completed')
|
|
258
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
|
+
}
|
|
259
457
|
const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
|
|
260
458
|
this.tasks.set(t.id, {
|
|
261
459
|
id: t.id, kind: t.kind,
|
|
262
460
|
status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
|
|
461
|
+
/* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
|
|
462
|
+
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
263
463
|
itemEpc: t.itemRefs?.[0] ?? '',
|
|
264
464
|
fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
|
|
265
465
|
resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
|
|
@@ -291,19 +491,9 @@ export class FlowEngine {
|
|
|
291
491
|
}
|
|
292
492
|
}
|
|
293
493
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
? orders
|
|
298
|
-
: (snap.orders ?? [])
|
|
299
|
-
.filter(o => o.requested !== undefined)
|
|
300
|
-
.map(o => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
|
|
301
|
-
for (const o of observedOrders) {
|
|
302
|
-
const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
|
|
303
|
-
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
304
|
-
if (remaining <= 0)
|
|
305
|
-
continue; // 이미 이행 완료 → 예측 대상 아님
|
|
306
|
-
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).');
|
|
307
497
|
}
|
|
308
498
|
}
|
|
309
499
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
@@ -332,7 +522,16 @@ export class FlowEngine {
|
|
|
332
522
|
* 도메인 커맨드(order.release 등)는 handleCommand 로 위임. 커맨드는 sim 상태를 변이하고
|
|
333
523
|
* State 델타를 유발한다(command → 행위 → 관측 폐루프).
|
|
334
524
|
*/
|
|
335
|
-
_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 로 표시(재발 시 재활성)
|
|
336
535
|
dispatch(cmd) {
|
|
337
536
|
const ok = () => ({ commandId: cmd.commandId, accepted: true });
|
|
338
537
|
// 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
|
|
@@ -463,6 +662,7 @@ export class FlowEngine {
|
|
|
463
662
|
return {
|
|
464
663
|
revision: this.revision,
|
|
465
664
|
simClockMs: this.clockMs,
|
|
665
|
+
nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
|
|
466
666
|
/* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
467
667
|
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
468
668
|
locations: [...this.locations.values()].map(n => {
|
|
@@ -473,7 +673,7 @@ export class FlowEngine {
|
|
|
473
673
|
}),
|
|
474
674
|
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
475
675
|
equipment: [...this.equipment.values()].map(m => {
|
|
476
|
-
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 } : {}) };
|
|
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) } : {}) };
|
|
477
677
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
478
678
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
479
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 };
|
|
@@ -487,6 +687,7 @@ export class FlowEngine {
|
|
|
487
687
|
st.properties = a.properties;
|
|
488
688
|
if (a.testSpecificationIds)
|
|
489
689
|
st.testSpecificationIds = a.testSpecificationIds;
|
|
690
|
+
Object.assign(st, this.effectivePart(a));
|
|
490
691
|
return st;
|
|
491
692
|
}),
|
|
492
693
|
persons: [...this.persons.values()].map(p => {
|
|
@@ -497,8 +698,14 @@ export class FlowEngine {
|
|
|
497
698
|
st.properties = p.properties;
|
|
498
699
|
if (p.testSpecificationIds)
|
|
499
700
|
st.testSpecificationIds = p.testSpecificationIds;
|
|
500
|
-
if (this.personOffShift(p))
|
|
701
|
+
if (this.personOffShift(p)) {
|
|
501
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));
|
|
502
709
|
return st;
|
|
503
710
|
}),
|
|
504
711
|
/* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
|
|
@@ -512,13 +719,20 @@ export class FlowEngine {
|
|
|
512
719
|
...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
|
|
513
720
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
514
721
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
515
|
-
...(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 } : {})
|
|
516
727
|
})),
|
|
517
728
|
orders: [...this.orders.values()].map(o => ({
|
|
518
729
|
id: o.id, kind: o.kind, status: o.status,
|
|
519
730
|
progress: o.requested ? o.fulfilled / o.requested : 0,
|
|
520
731
|
requested: o.requested, fulfilled: o.fulfilled,
|
|
521
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 } : {}),
|
|
522
736
|
held: o.held
|
|
523
737
|
})),
|
|
524
738
|
attentions: this.computeAttentions()
|
|
@@ -531,12 +745,34 @@ export class FlowEngine {
|
|
|
531
745
|
*/
|
|
532
746
|
computeAttentions() {
|
|
533
747
|
// 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
|
|
534
|
-
const
|
|
535
|
-
|
|
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
|
+
);
|
|
536
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)·시작 시각 프루닝 — 사라진 조건은 함께 지운다(재발하면 그때가 새 시작이다).
|
|
537
770
|
for (const id of [...this._acked])
|
|
538
771
|
if (!present.has(id))
|
|
539
772
|
this._acked.delete(id);
|
|
773
|
+
for (const id of [...this._attentionSince.keys()])
|
|
774
|
+
if (!present.has(id))
|
|
775
|
+
this._attentionSince.delete(id);
|
|
540
776
|
return out;
|
|
541
777
|
}
|
|
542
778
|
/**
|
|
@@ -570,7 +806,43 @@ export class FlowEngine {
|
|
|
570
806
|
return clone;
|
|
571
807
|
}
|
|
572
808
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
573
|
-
|
|
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
|
+
}
|
|
574
846
|
randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
|
|
575
847
|
/**
|
|
576
848
|
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
@@ -629,6 +901,43 @@ export class FlowEngine {
|
|
|
629
901
|
if (o?.key)
|
|
630
902
|
this.operationSpecs.set(o.key, o);
|
|
631
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
|
+
}
|
|
632
941
|
/**
|
|
633
942
|
* task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
|
|
634
943
|
*
|
|
@@ -756,8 +1065,19 @@ export class FlowEngine {
|
|
|
756
1065
|
defaultDurations: operations.filter(o => o.duration === 'default').length
|
|
757
1066
|
};
|
|
758
1067
|
}
|
|
759
|
-
/**
|
|
1068
|
+
/**
|
|
1069
|
+
* `skuMix` 에서 weight 로 gtin 선택(rng) — 도착·오더 자극의 품목 결정.
|
|
1070
|
+
*
|
|
1071
|
+
* **빈 목록이면 고르지 않는다**(`undefined`). 예전에는 `mix[mix.length - 1].gtin` 으로 떨어져
|
|
1072
|
+
* `mix[-1]` 이 undefined 가 되고 거기서 던졌다 — 그 예외가 서버까지 올라가 **정확도 추세 전체를
|
|
1073
|
+
* 죽였다**(품목 구성을 선언하지 않은 자극 하나가 예측 전체를 껐다).
|
|
1074
|
+
*
|
|
1075
|
+
* 없는 품목을 지어내지 않는다: 무엇을 만들지 모르면 **만들지 않는 것**이 맞고, 부르는 쪽이
|
|
1076
|
+
* 그 사실을 알고 건너뛴다.
|
|
1077
|
+
*/
|
|
760
1078
|
pickGtin(mix) {
|
|
1079
|
+
if (!mix?.length)
|
|
1080
|
+
return undefined;
|
|
761
1081
|
const total = mix.reduce((s, m) => s + m.weight, 0);
|
|
762
1082
|
let r = this.rng() * total;
|
|
763
1083
|
for (const m of mix) {
|
|
@@ -960,26 +1280,37 @@ export class FlowEngine {
|
|
|
960
1280
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
961
1281
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
962
1282
|
...(t.resources?.length ? { resources: t.resources.slice() } : {}),
|
|
1283
|
+
/* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
|
|
1284
|
+
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
963
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 } : {}),
|
|
964
1289
|
...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
965
1290
|
});
|
|
966
1291
|
}
|
|
967
1292
|
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
968
1293
|
emitAsset(a) {
|
|
969
|
-
this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying });
|
|
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) });
|
|
970
1295
|
}
|
|
971
1296
|
emitPerson(p) {
|
|
972
|
-
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...(
|
|
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`)를 자기 시각으로 부른다(유효 기간과 같은 규율). */
|
|
973
1301
|
}
|
|
974
1302
|
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
975
1303
|
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
976
1304
|
emitEquipment(m, motion) {
|
|
977
|
-
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 });
|
|
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) });
|
|
978
1306
|
}
|
|
979
1307
|
/** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
|
|
980
1308
|
emitOrder(o) {
|
|
981
1309
|
this.emitOp(OP_EVENT.order, {
|
|
982
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 } : {}),
|
|
983
1314
|
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
|
|
984
1315
|
});
|
|
985
1316
|
}
|
|
@@ -1058,6 +1389,7 @@ export class FlowEngine {
|
|
|
1058
1389
|
/* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
|
|
1059
1390
|
a => a.status === 'idle' &&
|
|
1060
1391
|
!picked.includes(a.id) &&
|
|
1392
|
+
!this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
|
|
1061
1393
|
(req.assetClass === undefined ||
|
|
1062
1394
|
classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass)));
|
|
1063
1395
|
if (avail.length < want)
|
|
@@ -1082,7 +1414,7 @@ export class FlowEngine {
|
|
|
1082
1414
|
a.carrying = t.itemEpc;
|
|
1083
1415
|
/* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
|
|
1084
1416
|
* 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
|
|
1085
|
-
const it = this.
|
|
1417
|
+
const it = this.itemByRef(t.itemEpc);
|
|
1086
1418
|
if (it)
|
|
1087
1419
|
it.carriedBy = a.id;
|
|
1088
1420
|
}
|
|
@@ -1116,6 +1448,223 @@ export class FlowEngine {
|
|
|
1116
1448
|
* 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
|
|
1117
1449
|
* (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
|
|
1118
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
|
+
}
|
|
1119
1668
|
claimPersonnel(t) {
|
|
1120
1669
|
const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
|
|
1121
1670
|
if (!need?.length)
|
|
@@ -1128,6 +1677,7 @@ export class FlowEngine {
|
|
|
1128
1677
|
const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
|
|
1129
1678
|
!picked.includes(p.id) &&
|
|
1130
1679
|
!this.personOffShift(p) &&
|
|
1680
|
+
!this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
|
|
1131
1681
|
/* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
|
|
1132
1682
|
다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
|
|
1133
1683
|
유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
|
|
@@ -1149,6 +1699,7 @@ export class FlowEngine {
|
|
|
1149
1699
|
const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
|
|
1150
1700
|
!m.held &&
|
|
1151
1701
|
!this.offShift(m) &&
|
|
1702
|
+
!this.outOfEffect(m) && // 도입 전·폐기 후는 이 시각의 모델에 없다
|
|
1152
1703
|
!picked.includes(m.id) &&
|
|
1153
1704
|
/* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
|
|
1154
1705
|
다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
|
|
@@ -1199,11 +1750,7 @@ export class FlowEngine {
|
|
|
1199
1750
|
}
|
|
1200
1751
|
/** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
|
|
1201
1752
|
personOffShift(p) {
|
|
1202
|
-
|
|
1203
|
-
if (!w)
|
|
1204
|
-
return false;
|
|
1205
|
-
const h = this.hourOfDay();
|
|
1206
|
-
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
1753
|
+
return this.offCalendar(p);
|
|
1207
1754
|
}
|
|
1208
1755
|
/**
|
|
1209
1756
|
* 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
|
|
@@ -1223,15 +1770,75 @@ export class FlowEngine {
|
|
|
1223
1770
|
}
|
|
1224
1771
|
/** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
|
|
1225
1772
|
offShift(m) {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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);
|
|
1231
1838
|
}
|
|
1232
1839
|
/** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
|
|
1233
1840
|
hourOfDay() {
|
|
1234
|
-
return new Date(
|
|
1841
|
+
return new Date(this.nowMs()).getUTCHours();
|
|
1235
1842
|
}
|
|
1236
1843
|
/**
|
|
1237
1844
|
* 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
|
|
@@ -1297,6 +1904,8 @@ export class FlowEngine {
|
|
|
1297
1904
|
const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
|
|
1298
1905
|
return {
|
|
1299
1906
|
epc: i.epc,
|
|
1907
|
+
...(i.subLotId ? { subLotId: i.subLotId } : {}),
|
|
1908
|
+
...(i.definitionId ? { definitionId: i.definitionId } : {}),
|
|
1300
1909
|
...(i.gtin ? { gtin: i.gtin } : {}),
|
|
1301
1910
|
...(gtinKey ? { gtinKey } : {}),
|
|
1302
1911
|
...(lot ? { lot } : {}),
|
|
@@ -1306,6 +1915,7 @@ export class FlowEngine {
|
|
|
1306
1915
|
...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
|
|
1307
1916
|
...(i.qty !== undefined ? { qty: i.qty } : {}),
|
|
1308
1917
|
...(i.uom ? { uom: i.uom } : {}),
|
|
1918
|
+
...(i.quantities?.length ? { quantities: i.quantities } : {}),
|
|
1309
1919
|
...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
|
|
1310
1920
|
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
1311
1921
|
};
|
|
@@ -1338,9 +1948,13 @@ export class FlowEngine {
|
|
|
1338
1948
|
}
|
|
1339
1949
|
}
|
|
1340
1950
|
processOrders() {
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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);
|
|
1344
1958
|
}
|
|
1345
1959
|
/**
|
|
1346
1960
|
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
@@ -1355,7 +1969,12 @@ export class FlowEngine {
|
|
|
1355
1969
|
}
|
|
1356
1970
|
assignTasks() {
|
|
1357
1971
|
// created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
|
|
1358
|
-
|
|
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) {
|
|
1359
1978
|
if (t.status !== 'created')
|
|
1360
1979
|
continue;
|
|
1361
1980
|
/* 필요 인원 — 오퍼레이션 명세가 요구하면 등급별로 사람을 잡는다. 모자라면 **기다린다**
|
|
@@ -1367,12 +1986,17 @@ export class FlowEngine {
|
|
|
1367
1986
|
const gear = this.claimAssets(t);
|
|
1368
1987
|
if (gear === null)
|
|
1369
1988
|
continue;
|
|
1989
|
+
/* 필요 자재 — 부품이 없으면 라인이 선다. 인원·자산과 같은 규칙(부분 투입 없이 전량 아니면 대기). */
|
|
1990
|
+
const stock = this.claimMaterials(t);
|
|
1991
|
+
if (stock === null)
|
|
1992
|
+
continue;
|
|
1370
1993
|
if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
|
|
1371
1994
|
t.status = 'in-progress';
|
|
1372
1995
|
t.remainingMs = t.durationMs;
|
|
1373
1996
|
t.startedAtSimMs = this.clockMs;
|
|
1374
1997
|
this.assignCrew(t, crew);
|
|
1375
1998
|
this.assignAssets(t, gear);
|
|
1999
|
+
this.consumeMaterials(t, stock);
|
|
1376
2000
|
this.emitTask(t);
|
|
1377
2001
|
continue;
|
|
1378
2002
|
}
|
|
@@ -1406,6 +2030,7 @@ export class FlowEngine {
|
|
|
1406
2030
|
t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
|
|
1407
2031
|
this.assignCrew(t, crew);
|
|
1408
2032
|
this.assignAssets(t, gear);
|
|
2033
|
+
this.consumeMaterials(t, stock);
|
|
1409
2034
|
this.emitTask(t);
|
|
1410
2035
|
// process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
|
|
1411
2036
|
if (t.intent === 'process')
|
|
@@ -1424,6 +2049,56 @@ export class FlowEngine {
|
|
|
1424
2049
|
t.remainingMs -= dt;
|
|
1425
2050
|
if (t.remainingMs > 0)
|
|
1426
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);
|
|
1427
2102
|
this.onTaskComplete(t);
|
|
1428
2103
|
t.status = 'completed';
|
|
1429
2104
|
this.releaseCrew(t); // 사람은 설비와 별개로 해제한다(dwell 도 사람은 잡고 있었을 수 있다)
|