@operato/twin-kernel 0.7.49 → 0.7.51
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/contract.js +30 -0
- package/dist/flow-engine.d.ts +70 -4
- package/dist/flow-engine.js +218 -28
- package/dist-cjs/index.cjs +121 -29
- package/package.json +1 -1
package/dist/contract.js
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Face 1 — 3채널 계약 (walking skeleton 범위).
|
|
3
3
|
* 설계 SoT: operato-twin/design/integration/face1-contract.md
|
|
4
|
+
*
|
|
5
|
+
* ── 이 계약을 넓히려는 사람이 먼저 읽을 것 (2026-08-23) ──────────────────────
|
|
6
|
+
*
|
|
7
|
+
* 사실은 두 종류이고, 종류가 그 자리를 정한다.
|
|
8
|
+
*
|
|
9
|
+
* **파생** — 시각만으로 넘어가는 판정. 근무 밖 · 교대 · 능력 · 자리 상태 · 납기 대비 · 진척.
|
|
10
|
+
* · **저장하지 않는다.** 저장하면 장부가 둘이 되고, 시각이 지나도 옛 값이 남는다.
|
|
11
|
+
* · **입력으로 받지 않는다.** 원본이 단정하면 답이 둘이 되고 커널이 어느 쪽을 믿을지 정해야 한다.
|
|
12
|
+
* · 계산할 수 없으면 **답하지 않는다.** 모르는 값을 숫자로 만들면 화면이 그것을 사실로 그린다
|
|
13
|
+
* (`dueStatusOf` 가 그 규율을 먼저 적었다: 납기가 없으면 「늦지 않았다」가 아니라 「판단할 수 없다」).
|
|
14
|
+
* · 파생이 **불가능할 때만** 원본의 단정을 쓴다. 그 순서가 선언돼 있어야 한다(`offShift`·`progress`).
|
|
15
|
+
*
|
|
16
|
+
* **누적** — 열린 구간에 쌓이는 값. 가동·준비·고장 시간 · 양품/불량 수 · 조건이 성립한 시각.
|
|
17
|
+
* · **다시 계산할 수 없다.** 원천은 「이번 창에서 지금까지 얼마」를 모른다.
|
|
18
|
+
* · 그래서 **재기동을 넘어 이어받는다.** 잃으면 오류 없이 값이 작아진다 — 가장 비싼 종류의 침묵이다.
|
|
19
|
+
*
|
|
20
|
+
* 그리고 **자리를 늘리는 조건은 하나다: 전이로 표현할 수 없는 사실.**
|
|
21
|
+
*
|
|
22
|
+
* 「자리가 없다」와 「담을 수 없다」는 다르다. 전자는 대개 이미 있는 축을 못 찾은 것이다 — 작업의
|
|
23
|
+
* 실제 착수·완료·대기는 **전이의 `eventTime`** 이 말하고(`task-fold`), 대기는 그것으로 계산된다
|
|
24
|
+
* (`kpi-fold`: `waitMs = startedMs − createdMs`). 원본이 전이를 놓쳐 보내면 **놓친 전이를 그 시각으로
|
|
25
|
+
* 함께 내는 것**이 답이고, 속성을 새로 여는 것이 아니다.
|
|
26
|
+
*
|
|
27
|
+
* 그러므로 원본을 붙이는 사람이 물어야 하는 것은 「이 컬럼을 실을 자리가 있나」가 아니라
|
|
28
|
+
* **「원본이 말하는 이 사실을 커널의 어휘로 어떻게 옮기나」**다. 컬럼 단위로 물으면 원본이 열이면
|
|
29
|
+
* 축도 열이 된다 — 2026-08-23 에 축 다섯을 열었다가 그 이유로 되돌렸다.
|
|
30
|
+
*
|
|
31
|
+
* 열지 않기로 한 것과 그 조건은 `design/04-decisions.md` ADR-0039 에 있다. 넓히기 전에 그 표를 본다.
|
|
32
|
+
* 지키는 하네스: `derived-not-input` · `actuals-come-from-transitions` · `accumulators-survive-restart` ·
|
|
33
|
+
* `purpose-conformance`.
|
|
4
34
|
*/
|
|
5
35
|
/**
|
|
6
36
|
* 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
|
|
1
|
+
import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
|
|
2
2
|
import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
|
|
3
3
|
import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
|
|
4
4
|
import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
|
|
@@ -78,6 +78,15 @@ export interface FlowPerson extends EffectivePeriod {
|
|
|
78
78
|
};
|
|
79
79
|
/** 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 있으면 `window` 보다 이것을 본다. */
|
|
80
80
|
workCalendar?: WorkCalendarEntry[];
|
|
81
|
+
/**
|
|
82
|
+
* **원본이 말한 근무 밖 표시** — 캘린더가 없을 때만 쓰는 값이다.
|
|
83
|
+
*
|
|
84
|
+
* 캘린더가 있으면 시각으로 판정한다(`offCalendar`) — 저장값을 함께 보면 교대 안으로 돌아왔을 때 옛
|
|
85
|
+
* `true` 가 남는다. 그런데 근무 캘린더를 주지 않는 원본이 실 시스템에 흔하고, 그때 이것이 **유일한
|
|
86
|
+
* 답**이다. 리듀서가 이미 그 규율로 이 값을 지키고 있었는데(§`observed-reducer` offShift) 커널 상태에
|
|
87
|
+
* 자리가 없어 옮기는 자리에서 잃고 있었다.
|
|
88
|
+
*/
|
|
89
|
+
offShift?: boolean;
|
|
81
90
|
/** 지금 어디에 있나 — 표준 `Person.OperationalLocation`. 커널은 사람을 움직이지 않는다(마스터·관측이 말한다). */
|
|
82
91
|
location?: string;
|
|
83
92
|
/** 자원 속성 — 표준 `PersonProperty`. */
|
|
@@ -130,7 +139,18 @@ export interface FlowEquipment extends EffectivePeriod {
|
|
|
130
139
|
export interface FlowTask {
|
|
131
140
|
id: string;
|
|
132
141
|
kind: string;
|
|
133
|
-
|
|
142
|
+
/**
|
|
143
|
+
* **작업 상태 — 어휘는 하나다**(`TaskStatus`: created · assigned · in-progress · completed).
|
|
144
|
+
*
|
|
145
|
+
* 예전에는 여기만 셋이었다(`assigned` 없음). 시뮬레이션은 배정과 착수를 한 번에 하므로 그것으로
|
|
146
|
+
* 충분했지만, **미러는 원본이 말한 것을 그대로 들고 있어야 한다** — 그리고 주의 계산은 이미
|
|
147
|
+
* `assigned` 를 기다리고 있었다(§`collectAttentions`: `status === 'created' || status === 'assigned'`).
|
|
148
|
+
* 즉 그 자리는 영원히 오지 않는 값을 기다렸다.
|
|
149
|
+
*
|
|
150
|
+
* 시뮬 경로의 거동은 바뀌지 않는다: 시뮬은 `assigned` 를 만들지 않고, 배정 루프는 `created` 만
|
|
151
|
+
* 집는다(§`assign`). 넓힌 것은 **관측이 말한 사실을 담을 자리**다.
|
|
152
|
+
*/
|
|
153
|
+
status: TaskStatus;
|
|
134
154
|
/**
|
|
135
155
|
* **실제로 들어가고 나온 자재** — ISA-95 `JobResponse.MaterialActual`.
|
|
136
156
|
*
|
|
@@ -154,6 +174,15 @@ export interface FlowTask {
|
|
|
154
174
|
/** 예정 창 — 표준 `JobOrder.StartTime`/`EndTime`. 실제 착수와 다른 축. */
|
|
155
175
|
startTime?: string;
|
|
156
176
|
endTime?: string;
|
|
177
|
+
/**
|
|
178
|
+
* **원본이 말한 진척** — 커널이 계산할 수 없을 때만 쓰는 값이다(§`progressOf`).
|
|
179
|
+
*
|
|
180
|
+
* 파생이 가능하면(소요를 알면) 파생이 이긴다. 이 자리는 파생을 대신하는 것이 아니라 **파생이
|
|
181
|
+
* 불가능할 때 침묵하지 않기 위한 것**이다 — 사람 교대(`offShift`)가 세워 둔 규율과 같다.
|
|
182
|
+
*
|
|
183
|
+
* 관측에서만 채운다. 시뮬은 자기 소요를 알므로 이 값을 쓸 일이 없다.
|
|
184
|
+
*/
|
|
185
|
+
progress?: number;
|
|
157
186
|
/** 이 작업에 투입된 사람들 — 설비와 별개 축(설비 1대 + 작업자 2명이 동시에 잡힌다). */
|
|
158
187
|
personnel?: string[];
|
|
159
188
|
/** 이 작업에 투입된 물리 자산(팔레트 등). */
|
|
@@ -700,7 +729,26 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
700
729
|
id: string;
|
|
701
730
|
since: string;
|
|
702
731
|
}[];
|
|
703
|
-
}, orders?: OrderStatusDelta[]
|
|
732
|
+
}, orders?: OrderStatusDelta[],
|
|
733
|
+
/**
|
|
734
|
+
* **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
|
|
735
|
+
*
|
|
736
|
+
* ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
|
|
737
|
+
* 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
|
|
738
|
+
* 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
|
|
739
|
+
* 가리킨다.
|
|
740
|
+
*
|
|
741
|
+
* 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
|
|
742
|
+
* 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
|
|
743
|
+
* 사실이 씨앗 규칙에 걸려 사라졌다.
|
|
744
|
+
*
|
|
745
|
+
* 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
|
|
746
|
+
* `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
|
|
747
|
+
* 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
|
|
748
|
+
*
|
|
749
|
+
* 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
|
|
750
|
+
*/
|
|
751
|
+
purpose?: 'seed' | 'observe'): void;
|
|
704
752
|
/**
|
|
705
753
|
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
706
754
|
*
|
|
@@ -1529,7 +1577,25 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
1529
1577
|
* 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
|
|
1530
1578
|
*/
|
|
1531
1579
|
protected itemState(i: FlowItem): ItemState;
|
|
1532
|
-
|
|
1580
|
+
/**
|
|
1581
|
+
* 진척 — **모르면 답하지 않는다.**
|
|
1582
|
+
*
|
|
1583
|
+
* ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
|
|
1584
|
+
* 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
|
|
1585
|
+
* 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
|
|
1586
|
+
* 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
|
|
1587
|
+
*
|
|
1588
|
+
* 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
|
|
1589
|
+
* 것이고, 화면은 그것을 사실로 그린다.
|
|
1590
|
+
*
|
|
1591
|
+
* 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
|
|
1592
|
+
* 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
|
|
1593
|
+
* 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
|
|
1594
|
+
*
|
|
1595
|
+
* 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
|
|
1596
|
+
* 이 함수는 **커널이 계산할 수 있나**만 답한다.
|
|
1597
|
+
*/
|
|
1598
|
+
protected progressOf(t: FlowTask): number | undefined;
|
|
1533
1599
|
private generate;
|
|
1534
1600
|
private processOrders;
|
|
1535
1601
|
/**
|
package/dist/flow-engine.js
CHANGED
|
@@ -832,7 +832,26 @@ export class FlowEngine {
|
|
|
832
832
|
this.seedDanglingRefs += dropped;
|
|
833
833
|
return { kept, dropped };
|
|
834
834
|
}
|
|
835
|
-
hydrateObserved(snap, orders = []
|
|
835
|
+
hydrateObserved(snap, orders = [],
|
|
836
|
+
/**
|
|
837
|
+
* **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
|
|
838
|
+
*
|
|
839
|
+
* ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
|
|
840
|
+
* 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
|
|
841
|
+
* 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
|
|
842
|
+
* 가리킨다.
|
|
843
|
+
*
|
|
844
|
+
* 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
|
|
845
|
+
* 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
|
|
846
|
+
* 사실이 씨앗 규칙에 걸려 사라졌다.
|
|
847
|
+
*
|
|
848
|
+
* 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
|
|
849
|
+
* `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
|
|
850
|
+
* 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
|
|
851
|
+
*
|
|
852
|
+
* 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
|
|
853
|
+
*/
|
|
854
|
+
purpose = 'seed') {
|
|
836
855
|
/* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
|
|
837
856
|
* 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
|
|
838
857
|
for (const id of snap.acked ?? [])
|
|
@@ -882,7 +901,15 @@ export class FlowEngine {
|
|
|
882
901
|
const prev = this.equipment.get(m.id);
|
|
883
902
|
this.equipment.set(m.id, {
|
|
884
903
|
...(prev ?? {}),
|
|
885
|
-
|
|
904
|
+
/*
|
|
905
|
+
* `taskId` — **관측이면 원본이 말한 값이다.**
|
|
906
|
+
*
|
|
907
|
+
* 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
|
|
908
|
+
* 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
|
|
909
|
+
* 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
|
|
910
|
+
* 오게 되고 한쪽만 도착한 순간 어긋난다.
|
|
911
|
+
*/
|
|
912
|
+
id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: purpose === 'observe' ? (m.taskId ?? null) : null,
|
|
886
913
|
runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
|
|
887
914
|
goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
|
|
888
915
|
/*
|
|
@@ -915,7 +942,19 @@ export class FlowEngine {
|
|
|
915
942
|
const prev = this.persons.get(p.id);
|
|
916
943
|
this.persons.set(p.id, {
|
|
917
944
|
...(prev ?? {}),
|
|
918
|
-
|
|
945
|
+
/* 설비와 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
|
|
946
|
+
id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
|
|
947
|
+
status: purpose === 'observe' ? (p.status ?? 'idle') : 'idle',
|
|
948
|
+
taskId: purpose === 'observe' ? (p.taskId ?? null) : null,
|
|
949
|
+
/*
|
|
950
|
+
* 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
|
|
951
|
+
*
|
|
952
|
+
* 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
|
|
953
|
+
* `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
|
|
954
|
+
* 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
|
|
955
|
+
* 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
|
|
956
|
+
*/
|
|
957
|
+
...(purpose === 'observe' && p.offShift ? { offShift: true } : {}),
|
|
919
958
|
...(p.location ? { location: p.location } : {}),
|
|
920
959
|
...(p.properties ? { properties: p.properties } : {}),
|
|
921
960
|
...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
|
|
@@ -929,7 +968,10 @@ export class FlowEngine {
|
|
|
929
968
|
const prev = this.assets.get(a.id);
|
|
930
969
|
this.assets.set(a.id, {
|
|
931
970
|
...(prev ?? {}),
|
|
932
|
-
|
|
971
|
+
/* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
|
|
972
|
+
id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location,
|
|
973
|
+
status: purpose === 'observe' ? (a.status ?? 'idle') : 'idle',
|
|
974
|
+
taskId: purpose === 'observe' ? (a.taskId ?? null) : null, carrying: a.carrying,
|
|
933
975
|
...(a.properties ? { properties: a.properties } : {}),
|
|
934
976
|
...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
|
|
935
977
|
/* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
|
|
@@ -993,11 +1035,35 @@ export class FlowEngine {
|
|
|
993
1035
|
const remaining = lines.length
|
|
994
1036
|
? lines.reduce((s, l) => s + l.requested, 0)
|
|
995
1037
|
: Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
|
|
996
|
-
|
|
997
|
-
|
|
1038
|
+
/*
|
|
1039
|
+
* ── **씨앗은 남은 데맨드로, 미러는 들은 그대로** (2026-08-23) ──────────────
|
|
1040
|
+
*
|
|
1041
|
+
* 아래 세 값(`status`·`requested`·`fulfilled`)은 씨앗의 규약이다: 씨앗은 「무엇이 남았나」로
|
|
1042
|
+
* 미래를 굴리므로 요청 10·이행 4 를 **요청 6·이행 0·상태 created** 로 다시 표현한다. 그리고 이미
|
|
1043
|
+
* 이행된 오더는 예측 대상이 아니므로 건너뛴다.
|
|
1044
|
+
*
|
|
1045
|
+
* **미러에는 전부 틀렸다.** 미러는 굴리지 않고 들은 것을 말한다. 실측(포천 미러):
|
|
1046
|
+
*
|
|
1047
|
+
* 원본이 말한 것 picking · 요청 10 · 이행 4 · 진척 0.4
|
|
1048
|
+
* 커널이 낸 것 created · 요청 6 · 이행 0 · 진척 0 ← 40% 된 오더가 0% 로 보였다
|
|
1049
|
+
*
|
|
1050
|
+
* 그리고 이행 완료된 오더는 **상태에서 아예 사라졌다** — 원본이 그 오더가 있다고 말하는데
|
|
1051
|
+
* 트윈은 없다고 말한다. 오더의 상태 낱말은 계약이 도메인에 넘긴 것인데(`picking`·`shipped`…)
|
|
1052
|
+
* 씨앗 규약이 그것을 커널 낱말로 덮고 있었다.
|
|
1053
|
+
*
|
|
1054
|
+
* 이것은 작업에서 고친 것과 **같은 결함의 세 번째 사례**다(§`purpose`): 한 함수가 두 목적을 겸하고
|
|
1055
|
+
* 씨앗 규약이 관측에 새어 나온다.
|
|
1056
|
+
*/
|
|
1057
|
+
const observing = purpose === 'observe';
|
|
1058
|
+
if (!observing && remaining <= 0)
|
|
1059
|
+
continue; // 씨앗: 이미 이행 완료 → 예측 대상 아님
|
|
998
1060
|
const resolved = this.resolvedAllocated(o.allocated);
|
|
999
1061
|
this.orders.set(o.orderId, {
|
|
1000
|
-
id: o.orderId,
|
|
1062
|
+
id: o.orderId,
|
|
1063
|
+
kind: o.kind,
|
|
1064
|
+
status: observing ? (o.status ?? 'created') : 'created',
|
|
1065
|
+
requested: observing ? (o.requested ?? 0) : remaining,
|
|
1066
|
+
fulfilled: observing ? (o.fulfilled ?? 0) : 0,
|
|
1001
1067
|
/* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
|
|
1002
1068
|
보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
|
|
1003
1069
|
않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
|
|
@@ -1006,7 +1072,9 @@ export class FlowEngine {
|
|
|
1006
1072
|
/* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
|
|
1007
1073
|
남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
|
|
1008
1074
|
...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
|
|
1009
|
-
bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
|
|
1075
|
+
bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
|
|
1076
|
+
/* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
|
|
1077
|
+
lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
|
|
1010
1078
|
...(resolved.dropped ? { seedIncomplete: true } : {}),
|
|
1011
1079
|
/* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
|
|
1012
1080
|
씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
|
|
@@ -1022,38 +1090,88 @@ export class FlowEngine {
|
|
|
1022
1090
|
}
|
|
1023
1091
|
/* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
|
|
1024
1092
|
const seededOrderIds = new Set(this.orders.keys());
|
|
1025
|
-
|
|
1093
|
+
/*
|
|
1094
|
+
* **떨어뜨린 이유를 따로 센다** — 예전에는 하나로 세고 「whose item is no longer in state」라고
|
|
1095
|
+
* 말했다. 그런데 오더에 걸린 경우도 같은 문장을 썼고, 그것을 본 사람이 물품 쪽을 반나절 뒤졌다.
|
|
1096
|
+
* 원인을 지어내는 문장은 없는 것보다 나쁘다.
|
|
1097
|
+
*/
|
|
1098
|
+
let droppedNoItem = 0;
|
|
1099
|
+
let droppedNoOrder = 0;
|
|
1100
|
+
let droppedCompleted = 0;
|
|
1101
|
+
/*
|
|
1102
|
+
* **씨앗이냐 관측 정착이냐** — 아래 셋을 거를지 말지가 여기서 갈린다(§`purpose`).
|
|
1103
|
+
*
|
|
1104
|
+
* 씨앗(seed) 굴릴 수 없는 작업은 뺀다. 심으면 첫 틱에서 없는 자리를 가리킨다.
|
|
1105
|
+
* 관측(observe) **거르지 않는다.** 미러는 그 작업을 굴리지 않고, 들은 것을 말한다.
|
|
1106
|
+
*/
|
|
1107
|
+
const filtering = purpose === 'seed';
|
|
1026
1108
|
for (const t of snap.tasks ?? []) {
|
|
1027
|
-
if (t.status === 'completed')
|
|
1109
|
+
if (filtering && t.status === 'completed') {
|
|
1110
|
+
droppedCompleted++;
|
|
1028
1111
|
continue;
|
|
1112
|
+
}
|
|
1029
1113
|
const ref = t.itemRefs?.[0];
|
|
1030
|
-
if (ref && !this.itemByRef(ref)) {
|
|
1031
|
-
|
|
1114
|
+
if (filtering && ref && !this.itemByRef(ref)) {
|
|
1115
|
+
droppedNoItem++;
|
|
1032
1116
|
continue;
|
|
1033
1117
|
}
|
|
1034
1118
|
/* 오더도 같다 — 이미 이행된 오더는 주입하지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
|
|
1035
1119
|
남으면 완료 시점에 없는 오더를 딛는다. 주입 단계에서 함께 뺀다. */
|
|
1036
|
-
if (t.orderId && !seededOrderIds.has(t.orderId)) {
|
|
1037
|
-
|
|
1120
|
+
if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
|
|
1121
|
+
droppedNoOrder++;
|
|
1038
1122
|
continue;
|
|
1039
1123
|
}
|
|
1040
1124
|
const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
|
|
1041
1125
|
this.tasks.set(t.id, {
|
|
1042
1126
|
id: t.id, kind: t.kind,
|
|
1043
|
-
|
|
1127
|
+
/*
|
|
1128
|
+
* **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
|
|
1129
|
+
* 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
|
|
1130
|
+
*
|
|
1131
|
+
* 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
|
|
1132
|
+
* `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
|
|
1133
|
+
* 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
|
|
1134
|
+
*/
|
|
1135
|
+
status: purpose === 'observe' ? t.status : known && t.status === 'in-progress' ? 'in-progress' : 'created',
|
|
1044
1136
|
/* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
|
|
1045
1137
|
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
1046
1138
|
itemEpc: t.itemRefs?.[0] ?? '',
|
|
1047
1139
|
fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
|
|
1048
|
-
|
|
1140
|
+
/*
|
|
1141
|
+
* 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
|
|
1142
|
+
* (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
|
|
1143
|
+
* 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
|
|
1144
|
+
*/
|
|
1145
|
+
resource: purpose === 'observe' ? (t.resourceRef ?? null) : known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
|
|
1049
1146
|
remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
|
|
1050
1147
|
startedAtSimMs: t.startedAtSimMs,
|
|
1051
1148
|
durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
|
|
1052
1149
|
orderId: t.orderId,
|
|
1053
|
-
intent: t.intent
|
|
1150
|
+
intent: t.intent,
|
|
1151
|
+
/*
|
|
1152
|
+
* 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
|
|
1153
|
+
* 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
|
|
1154
|
+
*/
|
|
1155
|
+
...(purpose === 'observe' && typeof t.progress === 'number' ? { progress: t.progress } : {})
|
|
1054
1156
|
});
|
|
1055
|
-
/*
|
|
1056
|
-
|
|
1157
|
+
/*
|
|
1158
|
+
* 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
|
|
1159
|
+
* 아래에서 채운다 — 씨앗 시절 진행 중인 작업에만 필요했던 자리다).
|
|
1160
|
+
*/
|
|
1161
|
+
if (purpose === 'observe' && t.personnel?.length) {
|
|
1162
|
+
const restored = this.tasks.get(t.id);
|
|
1163
|
+
if (restored)
|
|
1164
|
+
restored.personnel = [...t.personnel];
|
|
1165
|
+
}
|
|
1166
|
+
/*
|
|
1167
|
+
* **자원의 상태는 여기서 정하지 않는다 — 관측일 때는.**
|
|
1168
|
+
*
|
|
1169
|
+
* 씨앗에서는 필요하다: 진행 중인 작업을 심었으면 그 설비가 다른 일을 또 받으면 안 되므로 점유를
|
|
1170
|
+
* 표시한다. 그런데 미러에서는 설비·사람의 상태가 **자기 관측 축**(`equipment.status`,
|
|
1171
|
+
* `person.status`)에서 온다. 작업 하나로 그것을 덮으면 원본이 「유휴」라고 말한 설비를 트윈이
|
|
1172
|
+
* 「바쁘다」고 말한다 — 축이 둘이 되고, 어느 쪽이 사실인지 알 수 없게 된다.
|
|
1173
|
+
*/
|
|
1174
|
+
if (purpose === 'seed' && known && t.status === 'in-progress' && t.resourceRef) {
|
|
1057
1175
|
const mv = this.equipment.get(t.resourceRef);
|
|
1058
1176
|
if (mv) {
|
|
1059
1177
|
mv.status = 'busy';
|
|
@@ -1061,7 +1179,7 @@ export class FlowEngine {
|
|
|
1061
1179
|
}
|
|
1062
1180
|
}
|
|
1063
1181
|
/* 사람도 같다 — 진행 중이던 작업에 투입돼 있던 사람은 여전히 점유되어 있어야 한다. */
|
|
1064
|
-
if (known && t.status === 'in-progress') {
|
|
1182
|
+
if (purpose === 'seed' && known && t.status === 'in-progress') {
|
|
1065
1183
|
const restored = this.tasks.get(t.id);
|
|
1066
1184
|
if (restored)
|
|
1067
1185
|
restored.personnel = t.personnel ? [...t.personnel] : undefined;
|
|
@@ -1074,9 +1192,21 @@ export class FlowEngine {
|
|
|
1074
1192
|
}
|
|
1075
1193
|
}
|
|
1076
1194
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1195
|
+
/*
|
|
1196
|
+
* **무엇을 왜 뺐는지 그대로 말한다.** 씨앗에서만 나온다(관측은 빼지 않는다).
|
|
1197
|
+
*
|
|
1198
|
+
* 예전 문장은 셋을 하나로 뭉쳐 「whose item is no longer in state」라고 했다. 오더에 걸린 것을
|
|
1199
|
+
* 물품 탓으로 말했고, 그 문장을 읽은 사람이 물품 쪽을 뒤졌다. 원인을 지어내는 문장은 침묵보다 나쁘다.
|
|
1200
|
+
*/
|
|
1201
|
+
const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
|
|
1202
|
+
if (droppedTotal) {
|
|
1203
|
+
const why = [
|
|
1204
|
+
droppedNoItem ? `${droppedNoItem} whose item is not in state` : '',
|
|
1205
|
+
droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : '',
|
|
1206
|
+
droppedCompleted ? `${droppedCompleted} already completed` : ''
|
|
1207
|
+
].filter(Boolean).join(' · ');
|
|
1208
|
+
console.warn(`[twin-kernel] seed skipped ${droppedTotal} task(s) — ${why}. ` +
|
|
1209
|
+
'They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(…, …, "observe").)');
|
|
1080
1210
|
}
|
|
1081
1211
|
}
|
|
1082
1212
|
/**
|
|
@@ -1335,8 +1465,13 @@ export class FlowEngine {
|
|
|
1335
1465
|
equipment: [...this.equipment.values()].map(m => {
|
|
1336
1466
|
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 } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
|
|
1337
1467
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
1338
|
-
|
|
1339
|
-
|
|
1468
|
+
/*
|
|
1469
|
+
* 이동 구간은 **소요를 알 때만** 낸다. 모르면 아래 산수가 전부 0 이나 음수가 되고, 화면은 「제자리에서
|
|
1470
|
+
* 다 끝난 이동」을 그린다 — 원본이 소요를 주지 않는 미러에서 실제로 그랬다(§`progressOf`).
|
|
1471
|
+
*/
|
|
1472
|
+
const motionProgress = t && t.status === 'in-progress' && t.intent !== 'process' ? this.progressOf(t) : undefined;
|
|
1473
|
+
if (t && motionProgress !== undefined)
|
|
1474
|
+
s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
|
|
1340
1475
|
return s;
|
|
1341
1476
|
}),
|
|
1342
1477
|
assets: [...this.assets.values()].map(a => {
|
|
@@ -1365,10 +1500,17 @@ export class FlowEngine {
|
|
|
1365
1500
|
물을 수 없다(사실은 자기 집에서 나온다). 판정은 아래 `capability` 가 이미 답한다. */
|
|
1366
1501
|
if (p.testResults)
|
|
1367
1502
|
st.testResults = p.testResults;
|
|
1503
|
+
/*
|
|
1504
|
+
* 근무 밖 판정 — **선언이 있으면 선언이 이기고, 없을 때만 원본이 말한 값을 쓴다.**
|
|
1505
|
+
* 진척(`progressOf`)과 같은 순서다. 선언이 있는데 저장값을 함께 보면 교대 안으로 돌아왔을 때
|
|
1506
|
+
* 옛 `true` 가 남는다(리듀서가 그 이유를 적어 두었다).
|
|
1507
|
+
*/
|
|
1368
1508
|
if (this.personOffShift(p)) {
|
|
1369
1509
|
st.offShift = true;
|
|
1370
1510
|
Object.assign(st, this.offReason(p));
|
|
1371
1511
|
}
|
|
1512
|
+
else if (p.offShift && !p.workCalendar?.length)
|
|
1513
|
+
st.offShift = true;
|
|
1372
1514
|
const sh = this.shiftOf(p);
|
|
1373
1515
|
if (sh)
|
|
1374
1516
|
st.shift = sh;
|
|
@@ -1389,7 +1531,26 @@ export class FlowEngine {
|
|
|
1389
1531
|
fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
|
|
1390
1532
|
...(t.intent ? { intent: t.intent } : {}),
|
|
1391
1533
|
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
1392
|
-
|
|
1534
|
+
/*
|
|
1535
|
+
* 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
|
|
1536
|
+
*
|
|
1537
|
+
* 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
|
|
1538
|
+
* 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
|
|
1539
|
+
*
|
|
1540
|
+
* 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
|
|
1541
|
+
* 것이 이 시스템에서 가장 비싼 침묵이다.
|
|
1542
|
+
*/
|
|
1543
|
+
...(t.status === 'in-progress'
|
|
1544
|
+
? {
|
|
1545
|
+
remainingMs: t.remainingMs,
|
|
1546
|
+
startedAtSimMs: t.startedAtSimMs,
|
|
1547
|
+
...(() => {
|
|
1548
|
+
const derived = this.progressOf(t);
|
|
1549
|
+
const p = derived ?? t.progress;
|
|
1550
|
+
return p === undefined ? {} : { progress: p };
|
|
1551
|
+
})()
|
|
1552
|
+
}
|
|
1553
|
+
: {}),
|
|
1393
1554
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
1394
1555
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
1395
1556
|
...(t.resources?.length ? { resources: t.resources.slice() } : {}),
|
|
@@ -1723,7 +1884,14 @@ export class FlowEngine {
|
|
|
1723
1884
|
if (!this.observedDirty || !this.observer)
|
|
1724
1885
|
return;
|
|
1725
1886
|
this.observedDirty = false;
|
|
1726
|
-
|
|
1887
|
+
/*
|
|
1888
|
+
* **목적을 밝힌다 — 이것은 씨앗이 아니라 관측 정착이다**(§`hydrateObserved` 의 `purpose`).
|
|
1889
|
+
*
|
|
1890
|
+
* 이 한 낱말이 없던 동안 미러의 작업이 씨앗 규칙에 걸려 사라졌다: 포천 미러의 저널에
|
|
1891
|
+
* `task.status` 5,786건이 있는데 `getSnapshot().tasks` 는 0 이었고, 화면은 「아직 하나도
|
|
1892
|
+
* 없습니다」라고 말했다. 미러는 그 작업을 굴리지 않는다 — 들은 것을 말할 뿐이다.
|
|
1893
|
+
*/
|
|
1894
|
+
this.hydrateObserved(this.observer.snapshot(), [], 'observe');
|
|
1727
1895
|
}
|
|
1728
1896
|
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
1729
1897
|
observedHandlers() {
|
|
@@ -3321,7 +3489,29 @@ export class FlowEngine {
|
|
|
3321
3489
|
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
3322
3490
|
};
|
|
3323
3491
|
}
|
|
3324
|
-
|
|
3492
|
+
/**
|
|
3493
|
+
* 진척 — **모르면 답하지 않는다.**
|
|
3494
|
+
*
|
|
3495
|
+
* ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
|
|
3496
|
+
* 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
|
|
3497
|
+
* 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
|
|
3498
|
+
* 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
|
|
3499
|
+
*
|
|
3500
|
+
* 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
|
|
3501
|
+
* 것이고, 화면은 그것을 사실로 그린다.
|
|
3502
|
+
*
|
|
3503
|
+
* 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
|
|
3504
|
+
* 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
|
|
3505
|
+
* 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
|
|
3506
|
+
*
|
|
3507
|
+
* 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
|
|
3508
|
+
* 이 함수는 **커널이 계산할 수 있나**만 답한다.
|
|
3509
|
+
*/
|
|
3510
|
+
progressOf(t) {
|
|
3511
|
+
if (!(t.durationMs > 0))
|
|
3512
|
+
return undefined;
|
|
3513
|
+
return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
|
|
3514
|
+
}
|
|
3325
3515
|
generate() {
|
|
3326
3516
|
for (const g of this.gens) {
|
|
3327
3517
|
while (this.clockMs >= g.nextMs) {
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -3964,7 +3964,7 @@ var FlowEngine = class {
|
|
|
3964
3964
|
this.seedDanglingRefs += dropped;
|
|
3965
3965
|
return { kept, dropped };
|
|
3966
3966
|
}
|
|
3967
|
-
hydrateObserved(snap, orders = []) {
|
|
3967
|
+
hydrateObserved(snap, orders = [], purpose = "seed") {
|
|
3968
3968
|
for (const id of snap.acked ?? []) this._acked.add(id);
|
|
3969
3969
|
for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
|
|
3970
3970
|
for (const n of snap.locations) {
|
|
@@ -3994,12 +3994,20 @@ var FlowEngine = class {
|
|
|
3994
3994
|
const prev = this.equipment.get(m.id);
|
|
3995
3995
|
this.equipment.set(m.id, {
|
|
3996
3996
|
...prev ?? {},
|
|
3997
|
+
/*
|
|
3998
|
+
* `taskId` — **관측이면 원본이 말한 값이다.**
|
|
3999
|
+
*
|
|
4000
|
+
* 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
|
|
4001
|
+
* 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
|
|
4002
|
+
* 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
|
|
4003
|
+
* 오게 되고 한쪽만 도착한 순간 어긋난다.
|
|
4004
|
+
*/
|
|
3997
4005
|
id: m.id,
|
|
3998
4006
|
kind: m.kind,
|
|
3999
4007
|
location: m.location ?? "",
|
|
4000
4008
|
...m.homeLocation ? { homeLocation: m.homeLocation } : {},
|
|
4001
4009
|
status: m.status ?? "idle",
|
|
4002
|
-
taskId: null,
|
|
4010
|
+
taskId: purpose === "observe" ? m.taskId ?? null : null,
|
|
4003
4011
|
runMs: oee?.runMs ?? 0,
|
|
4004
4012
|
setupMs: oee?.setupMs ?? 0,
|
|
4005
4013
|
downMs: oee?.downMs ?? 0,
|
|
@@ -4030,10 +4038,20 @@ var FlowEngine = class {
|
|
|
4030
4038
|
const prev = this.persons.get(p.id);
|
|
4031
4039
|
this.persons.set(p.id, {
|
|
4032
4040
|
...prev ?? {},
|
|
4041
|
+
/* 설비와 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
|
|
4033
4042
|
id: p.id,
|
|
4034
4043
|
personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
|
|
4035
|
-
status: "idle",
|
|
4036
|
-
taskId: null,
|
|
4044
|
+
status: purpose === "observe" ? p.status ?? "idle" : "idle",
|
|
4045
|
+
taskId: purpose === "observe" ? p.taskId ?? null : null,
|
|
4046
|
+
/*
|
|
4047
|
+
* 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
|
|
4048
|
+
*
|
|
4049
|
+
* 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
|
|
4050
|
+
* `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
|
|
4051
|
+
* 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
|
|
4052
|
+
* 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
|
|
4053
|
+
*/
|
|
4054
|
+
...purpose === "observe" && p.offShift ? { offShift: true } : {},
|
|
4037
4055
|
...p.location ? { location: p.location } : {},
|
|
4038
4056
|
...p.properties ? { properties: p.properties } : {},
|
|
4039
4057
|
...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
|
|
@@ -4046,11 +4064,12 @@ var FlowEngine = class {
|
|
|
4046
4064
|
const prev = this.assets.get(a.id);
|
|
4047
4065
|
this.assets.set(a.id, {
|
|
4048
4066
|
...prev ?? {},
|
|
4067
|
+
/* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
|
|
4049
4068
|
id: a.id,
|
|
4050
4069
|
assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
|
|
4051
4070
|
location: a.location,
|
|
4052
|
-
status: "idle",
|
|
4053
|
-
taskId: null,
|
|
4071
|
+
status: purpose === "observe" ? a.status ?? "idle" : "idle",
|
|
4072
|
+
taskId: purpose === "observe" ? a.taskId ?? null : null,
|
|
4054
4073
|
carrying: a.carrying,
|
|
4055
4074
|
...a.properties ? { properties: a.properties } : {},
|
|
4056
4075
|
...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
|
|
@@ -4084,14 +4103,15 @@ var FlowEngine = class {
|
|
|
4084
4103
|
for (const o of observedOrders) {
|
|
4085
4104
|
const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
|
|
4086
4105
|
const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
|
|
4087
|
-
|
|
4106
|
+
const observing = purpose === "observe";
|
|
4107
|
+
if (!observing && remaining <= 0) continue;
|
|
4088
4108
|
const resolved = this.resolvedAllocated(o.allocated);
|
|
4089
4109
|
this.orders.set(o.orderId, {
|
|
4090
4110
|
id: o.orderId,
|
|
4091
4111
|
kind: o.kind,
|
|
4092
|
-
status: "created",
|
|
4093
|
-
requested: remaining,
|
|
4094
|
-
fulfilled: 0,
|
|
4112
|
+
status: observing ? o.status ?? "created" : "created",
|
|
4113
|
+
requested: observing ? o.requested ?? 0 : remaining,
|
|
4114
|
+
fulfilled: observing ? o.fulfilled ?? 0 : 0,
|
|
4095
4115
|
/* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
|
|
4096
4116
|
보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
|
|
4097
4117
|
않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
|
|
@@ -4104,7 +4124,8 @@ var FlowEngine = class {
|
|
|
4104
4124
|
allocated: resolved.kept,
|
|
4105
4125
|
picked: [],
|
|
4106
4126
|
shipmentEpc: null,
|
|
4107
|
-
|
|
4127
|
+
/* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
|
|
4128
|
+
lines: observing ? (o.lines ?? []).map((l) => ({ ...l })) : lines,
|
|
4108
4129
|
...resolved.dropped ? { seedIncomplete: true } : {},
|
|
4109
4130
|
/* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
|
|
4110
4131
|
씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
|
|
@@ -4119,43 +4140,71 @@ var FlowEngine = class {
|
|
|
4119
4140
|
});
|
|
4120
4141
|
}
|
|
4121
4142
|
const seededOrderIds = new Set(this.orders.keys());
|
|
4122
|
-
let
|
|
4143
|
+
let droppedNoItem = 0;
|
|
4144
|
+
let droppedNoOrder = 0;
|
|
4145
|
+
let droppedCompleted = 0;
|
|
4146
|
+
const filtering = purpose === "seed";
|
|
4123
4147
|
for (const t of snap.tasks ?? []) {
|
|
4124
|
-
if (t.status === "completed")
|
|
4148
|
+
if (filtering && t.status === "completed") {
|
|
4149
|
+
droppedCompleted++;
|
|
4150
|
+
continue;
|
|
4151
|
+
}
|
|
4125
4152
|
const ref = t.itemRefs?.[0];
|
|
4126
|
-
if (ref && !this.itemByRef(ref)) {
|
|
4127
|
-
|
|
4153
|
+
if (filtering && ref && !this.itemByRef(ref)) {
|
|
4154
|
+
droppedNoItem++;
|
|
4128
4155
|
continue;
|
|
4129
4156
|
}
|
|
4130
|
-
if (t.orderId && !seededOrderIds.has(t.orderId)) {
|
|
4131
|
-
|
|
4157
|
+
if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
|
|
4158
|
+
droppedNoOrder++;
|
|
4132
4159
|
continue;
|
|
4133
4160
|
}
|
|
4134
4161
|
const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
|
|
4135
4162
|
this.tasks.set(t.id, {
|
|
4136
4163
|
id: t.id,
|
|
4137
4164
|
kind: t.kind,
|
|
4138
|
-
|
|
4165
|
+
/*
|
|
4166
|
+
* **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
|
|
4167
|
+
* 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
|
|
4168
|
+
*
|
|
4169
|
+
* 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
|
|
4170
|
+
* `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
|
|
4171
|
+
* 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
|
|
4172
|
+
*/
|
|
4173
|
+
status: purpose === "observe" ? t.status : known && t.status === "in-progress" ? "in-progress" : "created",
|
|
4139
4174
|
/* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
|
|
4140
4175
|
...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
|
|
4141
4176
|
itemEpc: t.itemRefs?.[0] ?? "",
|
|
4142
4177
|
fromNode: t.fromNode ?? "",
|
|
4143
4178
|
toNode: t.toNode ?? "",
|
|
4144
|
-
|
|
4179
|
+
/*
|
|
4180
|
+
* 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
|
|
4181
|
+
* (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
|
|
4182
|
+
* 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
|
|
4183
|
+
*/
|
|
4184
|
+
resource: purpose === "observe" ? t.resourceRef ?? null : known && t.status === "in-progress" ? t.resourceRef ?? null : null,
|
|
4145
4185
|
remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
|
|
4146
4186
|
startedAtSimMs: t.startedAtSimMs,
|
|
4147
4187
|
durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
|
|
4148
4188
|
orderId: t.orderId,
|
|
4149
|
-
intent: t.intent
|
|
4189
|
+
intent: t.intent,
|
|
4190
|
+
/*
|
|
4191
|
+
* 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
|
|
4192
|
+
* 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
|
|
4193
|
+
*/
|
|
4194
|
+
...purpose === "observe" && typeof t.progress === "number" ? { progress: t.progress } : {}
|
|
4150
4195
|
});
|
|
4151
|
-
if (
|
|
4196
|
+
if (purpose === "observe" && t.personnel?.length) {
|
|
4197
|
+
const restored = this.tasks.get(t.id);
|
|
4198
|
+
if (restored) restored.personnel = [...t.personnel];
|
|
4199
|
+
}
|
|
4200
|
+
if (purpose === "seed" && known && t.status === "in-progress" && t.resourceRef) {
|
|
4152
4201
|
const mv = this.equipment.get(t.resourceRef);
|
|
4153
4202
|
if (mv) {
|
|
4154
4203
|
mv.status = "busy";
|
|
4155
4204
|
mv.taskId = t.id;
|
|
4156
4205
|
}
|
|
4157
4206
|
}
|
|
4158
|
-
if (known && t.status === "in-progress") {
|
|
4207
|
+
if (purpose === "seed" && known && t.status === "in-progress") {
|
|
4159
4208
|
const restored = this.tasks.get(t.id);
|
|
4160
4209
|
if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
|
|
4161
4210
|
for (const id of t.personnel ?? []) {
|
|
@@ -4167,9 +4216,15 @@ var FlowEngine = class {
|
|
|
4167
4216
|
}
|
|
4168
4217
|
}
|
|
4169
4218
|
}
|
|
4170
|
-
|
|
4219
|
+
const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
|
|
4220
|
+
if (droppedTotal) {
|
|
4221
|
+
const why = [
|
|
4222
|
+
droppedNoItem ? `${droppedNoItem} whose item is not in state` : "",
|
|
4223
|
+
droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : "",
|
|
4224
|
+
droppedCompleted ? `${droppedCompleted} already completed` : ""
|
|
4225
|
+
].filter(Boolean).join(" \xB7 ");
|
|
4171
4226
|
console.warn(
|
|
4172
|
-
`[twin-kernel] seed skipped ${
|
|
4227
|
+
`[twin-kernel] seed skipped ${droppedTotal} task(s) \u2014 ${why}. They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(\u2026, \u2026, "observe").)`
|
|
4173
4228
|
);
|
|
4174
4229
|
}
|
|
4175
4230
|
}
|
|
@@ -4397,7 +4452,8 @@ var FlowEngine = class {
|
|
|
4397
4452
|
equipment: [...this.equipment.values()].map((m) => {
|
|
4398
4453
|
const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
|
|
4399
4454
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
4400
|
-
|
|
4455
|
+
const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
|
|
4456
|
+
if (t && motionProgress !== void 0) s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
|
|
4401
4457
|
return s;
|
|
4402
4458
|
}),
|
|
4403
4459
|
assets: [...this.assets.values()].map((a) => {
|
|
@@ -4419,7 +4475,7 @@ var FlowEngine = class {
|
|
|
4419
4475
|
if (this.personOffShift(p)) {
|
|
4420
4476
|
st.offShift = true;
|
|
4421
4477
|
Object.assign(st, this.offReason(p));
|
|
4422
|
-
}
|
|
4478
|
+
} else if (p.offShift && !p.workCalendar?.length) st.offShift = true;
|
|
4423
4479
|
const sh = this.shiftOf(p);
|
|
4424
4480
|
if (sh) st.shift = sh;
|
|
4425
4481
|
Object.assign(st, this.effectivePart(p));
|
|
@@ -4445,7 +4501,24 @@ var FlowEngine = class {
|
|
|
4445
4501
|
orderId: t.orderId,
|
|
4446
4502
|
...t.intent ? { intent: t.intent } : {},
|
|
4447
4503
|
...t.durationMs ? { durationMs: t.durationMs } : {},
|
|
4448
|
-
|
|
4504
|
+
/*
|
|
4505
|
+
* 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
|
|
4506
|
+
*
|
|
4507
|
+
* 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
|
|
4508
|
+
* 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
|
|
4509
|
+
*
|
|
4510
|
+
* 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
|
|
4511
|
+
* 것이 이 시스템에서 가장 비싼 침묵이다.
|
|
4512
|
+
*/
|
|
4513
|
+
...t.status === "in-progress" ? {
|
|
4514
|
+
remainingMs: t.remainingMs,
|
|
4515
|
+
startedAtSimMs: t.startedAtSimMs,
|
|
4516
|
+
...(() => {
|
|
4517
|
+
const derived = this.progressOf(t);
|
|
4518
|
+
const p = derived ?? t.progress;
|
|
4519
|
+
return p === void 0 ? {} : { progress: p };
|
|
4520
|
+
})()
|
|
4521
|
+
} : {},
|
|
4449
4522
|
...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
|
|
4450
4523
|
...t.assets?.length ? { assets: t.assets.slice() } : {},
|
|
4451
4524
|
...t.resources?.length ? { resources: t.resources.slice() } : {},
|
|
@@ -4752,7 +4825,7 @@ var FlowEngine = class {
|
|
|
4752
4825
|
settleObserved() {
|
|
4753
4826
|
if (!this.observedDirty || !this.observer) return;
|
|
4754
4827
|
this.observedDirty = false;
|
|
4755
|
-
this.hydrateObserved(this.observer.snapshot());
|
|
4828
|
+
this.hydrateObserved(this.observer.snapshot(), [], "observe");
|
|
4756
4829
|
}
|
|
4757
4830
|
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
4758
4831
|
observedHandlers() {
|
|
@@ -6147,8 +6220,27 @@ var FlowEngine = class {
|
|
|
6147
6220
|
...i.ilmd ? { ilmd: i.ilmd } : {}
|
|
6148
6221
|
};
|
|
6149
6222
|
}
|
|
6223
|
+
/**
|
|
6224
|
+
* 진척 — **모르면 답하지 않는다.**
|
|
6225
|
+
*
|
|
6226
|
+
* ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
|
|
6227
|
+
* 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
|
|
6228
|
+
* 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
|
|
6229
|
+
* 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
|
|
6230
|
+
*
|
|
6231
|
+
* 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
|
|
6232
|
+
* 것이고, 화면은 그것을 사실로 그린다.
|
|
6233
|
+
*
|
|
6234
|
+
* 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
|
|
6235
|
+
* 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
|
|
6236
|
+
* 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
|
|
6237
|
+
*
|
|
6238
|
+
* 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
|
|
6239
|
+
* 이 함수는 **커널이 계산할 수 있나**만 답한다.
|
|
6240
|
+
*/
|
|
6150
6241
|
progressOf(t) {
|
|
6151
|
-
|
|
6242
|
+
if (!(t.durationMs > 0)) return void 0;
|
|
6243
|
+
return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
|
|
6152
6244
|
}
|
|
6153
6245
|
generate() {
|
|
6154
6246
|
for (const g of this.gens) {
|
package/package.json
CHANGED