@operato/twin-kernel 0.7.31 → 0.7.33
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.d.ts +11 -0
- package/dist/flow-engine.d.ts +50 -0
- package/dist/flow-engine.js +47 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mes-kernel.js +5 -2
- package/dist/operational-ingest.d.ts +44 -0
- package/dist/operational-ingest.js +283 -0
- package/dist/task-fold.d.ts +10 -0
- package/dist/task-fold.js +3 -0
- package/dist/vocabulary.d.ts +11 -2
- package/dist/vocabulary.js +11 -2
- package/dist-cjs/index.cjs +328 -9
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -1770,6 +1770,17 @@ export interface TaskStatusDelta {
|
|
|
1770
1770
|
remainingMs?: number;
|
|
1771
1771
|
/** 이 작업의 총 소요 예상(ms) — 진척의 분모. */
|
|
1772
1772
|
durationMs?: number;
|
|
1773
|
+
/**
|
|
1774
|
+
* 품질 판정 결과 — **판정이 있었던 작업만** (2026-08-19).
|
|
1775
|
+
*
|
|
1776
|
+
* 왜 계약에 있나: 양품/불량은 설비 누적 카운터(종류를 모른다)와 EPCIS disposition(종류를 모른다)
|
|
1777
|
+
* 에만 남아서, **「이 공정의 양품률이 얼마였나」를 저널에서 되짚을 수 없었다** — 재기동하면 종류별
|
|
1778
|
+
* 품질 이력이 통째로 사라졌다. 판정이 일어나는 자리(작업 완료)에서 사실을 적는다.
|
|
1779
|
+
*
|
|
1780
|
+
* **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다). 그래서 소비처는
|
|
1781
|
+
* 없는 것을 세지 않는다 — 모름을 양품으로 세면 양품률이 조용히 100% 로 올라간다.
|
|
1782
|
+
*/
|
|
1783
|
+
outcome?: 'good' | 'scrap';
|
|
1773
1784
|
/** 우선순위 — 나가지 않으면 미러가 배정 순서의 근거를 모른다(상태⊆이벤트). */
|
|
1774
1785
|
priority?: number;
|
|
1775
1786
|
startTime?: ISOTime;
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -174,6 +174,20 @@ export interface FlowTask {
|
|
|
174
174
|
* dwell: 자리에서 시간만 소비(무자원 체류·큐어링 — 즉시 진행, 자원 불요).
|
|
175
175
|
*/
|
|
176
176
|
intent?: 'transport' | 'process' | 'dwell';
|
|
177
|
+
/**
|
|
178
|
+
* 이 작업의 **품질 판정 결과** — 판정이 있었을 때만 채운다 (2026-08-19).
|
|
179
|
+
*
|
|
180
|
+
* ── 왜 작업에 적나 ────────────────────────────────────────────────────────
|
|
181
|
+
* 양품/불량은 지금까지 두 곳에만 남았다: 설비 누적 카운터(`goodCount`·`scrapCount` — 종류를 모른다)와
|
|
182
|
+
* EPCIS disposition(`sellable`/`non_sellable` — 종류를 모른다). 그래서 **「이 공정의 양품률이 얼마였나」**
|
|
183
|
+
* 를 되짚을 방법이 저널에 없었다 — 재기동하면 종류별 품질 이력이 통째로 사라진다.
|
|
184
|
+
*
|
|
185
|
+
* 판정이 일어나는 자리는 작업 완료다(그때 종류·자원·결과를 함께 안다). 그러니 그 자리에서 적는다 —
|
|
186
|
+
* 나중에 자리·업무단계로 종류를 되짚는 추론을 만들지 않는다(설비 둘이 한 자리를 쓰면 조용히 틀린다).
|
|
187
|
+
*
|
|
188
|
+
* **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다).
|
|
189
|
+
*/
|
|
190
|
+
outcome?: 'good' | 'scrap';
|
|
177
191
|
}
|
|
178
192
|
/** 오더 라인(멀티SKU) — 라인별 품목·잔량. 단일 SKU 오더 = 1 라인. */
|
|
179
193
|
export interface OrderLine {
|
|
@@ -245,6 +259,17 @@ export declare const ATTENTION_DEFAULTS: {
|
|
|
245
259
|
*
|
|
246
260
|
* 이 값이 얹히는 대상은 **자리·설비가 아니라 공정 종류**다(`FlowTask.kind` = `OperationDef.key`).
|
|
247
261
|
*/
|
|
262
|
+
/**
|
|
263
|
+
* 이력에서 배운 양품률 — 종류별 비율(0..1). **모르면 `undefined`**(0 을 답하지 않는다: 0 은 「전부 불량」이다).
|
|
264
|
+
*
|
|
265
|
+
* 소요시간 추정기와 같은 모양이다(무상태 시임, fork 가 참조를 공유). 표본이 모자란 종류는 추정기가
|
|
266
|
+
* 스스로 빼야 한다 — 두 건으로 만든 비율은 근거가 아니다.
|
|
267
|
+
*/
|
|
268
|
+
export interface YieldEstimator {
|
|
269
|
+
estimate(ctx: {
|
|
270
|
+
kind: string;
|
|
271
|
+
}): number | undefined;
|
|
272
|
+
}
|
|
248
273
|
export declare const OPERATION_PROPERTY: {
|
|
249
274
|
/** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
|
|
250
275
|
readonly duration: "operation.duration";
|
|
@@ -363,6 +388,13 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
363
388
|
protected policy: AllocationPolicy;
|
|
364
389
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
365
390
|
durationEstimator?: DurationEstimator;
|
|
391
|
+
/**
|
|
392
|
+
* 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
|
|
393
|
+
*
|
|
394
|
+
* 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
|
|
395
|
+
* 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
|
|
396
|
+
*/
|
|
397
|
+
yieldEstimator?: YieldEstimator;
|
|
366
398
|
/**
|
|
367
399
|
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
368
400
|
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
@@ -824,6 +856,16 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
824
856
|
protected paramNumber(opKey: string, id: string): number | undefined;
|
|
825
857
|
/** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
|
|
826
858
|
protected paramDuration(opKey: string, id: string): number | undefined;
|
|
859
|
+
/**
|
|
860
|
+
* 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
|
|
861
|
+
*
|
|
862
|
+
* 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
|
|
863
|
+
* (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
|
|
864
|
+
*
|
|
865
|
+
* 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
|
|
866
|
+
* 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
|
|
867
|
+
*/
|
|
868
|
+
protected yieldOf(kind: string): number | undefined;
|
|
827
869
|
/**
|
|
828
870
|
* 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
|
|
829
871
|
*
|
|
@@ -832,6 +874,12 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
832
874
|
*/
|
|
833
875
|
private paramValue;
|
|
834
876
|
private noteSpecUse;
|
|
877
|
+
/**
|
|
878
|
+
* 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
|
|
879
|
+
*
|
|
880
|
+
* 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
|
|
881
|
+
* 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
|
|
882
|
+
*/
|
|
835
883
|
private noteParamUse;
|
|
836
884
|
/**
|
|
837
885
|
* 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
|
|
@@ -845,6 +893,8 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
845
893
|
duration: 'measured' | 'declared' | 'default';
|
|
846
894
|
variability?: string;
|
|
847
895
|
parameters: string[];
|
|
896
|
+
/** 그 모수를 무엇으로 읽었나(`id → measured|declared`) — 없는 id 는 상수를 썼다는 뜻이다. */
|
|
897
|
+
parameterBasis?: Record<string, 'measured' | 'declared'>;
|
|
848
898
|
}[];
|
|
849
899
|
/** 이력·계산에서 온 소요(추정기) — 선언값보다 강한 근거. */
|
|
850
900
|
measuredDurations: number;
|
package/dist/flow-engine.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
|
|
13
13
|
import { ObservedReducer } from "./observed-reducer.js";
|
|
14
14
|
import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
|
|
15
|
+
import { OP_PARAM } from "./domain-definition.js";
|
|
15
16
|
import { parseIsoDuration } from "./iso-duration.js";
|
|
16
17
|
import { analyzeCapacity } from "./capacity.js";
|
|
17
18
|
import { operationsCapabilityOf } from "./operations-capability.js";
|
|
@@ -67,14 +68,6 @@ export const ATTENTION_PROPERTY = {
|
|
|
67
68
|
};
|
|
68
69
|
/** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
|
|
69
70
|
export const ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
|
|
70
|
-
/**
|
|
71
|
-
* 공정에 선언할 수 있는 것의 **어휘** — 지금은 소요시간 하나다(`declareDurations` 가 읽는다).
|
|
72
|
-
*
|
|
73
|
-
* 자리·설비의 속성처럼 「속성 id」로 부른다: 선언을 받는 문·화면·AI 가 같은 이름을 써야 하고, 그 이름이
|
|
74
|
-
* 두 곳에 적히면 한쪽만 바뀌는 순간 값이 조용히 버려진다.
|
|
75
|
-
*
|
|
76
|
-
* 이 값이 얹히는 대상은 **자리·설비가 아니라 공정 종류**다(`FlowTask.kind` = `OperationDef.key`).
|
|
77
|
-
*/
|
|
78
71
|
export const OPERATION_PROPERTY = {
|
|
79
72
|
/** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
|
|
80
73
|
duration: 'operation.duration',
|
|
@@ -327,6 +320,13 @@ export class FlowEngine {
|
|
|
327
320
|
policy;
|
|
328
321
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
329
322
|
durationEstimator;
|
|
323
|
+
/**
|
|
324
|
+
* 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
|
|
325
|
+
*
|
|
326
|
+
* 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
|
|
327
|
+
* 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
|
|
328
|
+
*/
|
|
329
|
+
yieldEstimator;
|
|
330
330
|
/**
|
|
331
331
|
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
332
332
|
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
@@ -1245,7 +1245,10 @@ export class FlowEngine {
|
|
|
1245
1245
|
clone.durationEstimator = this.durationEstimator; // 무상태 시임 — 참조 공유(policy 와 동형)
|
|
1246
1246
|
/* 명세는 불변(정의) → 참조 공유. 사용 기록은 fork 자기 것(무엇을 기본값으로 굴렸는지 fork 별로 다르다). */
|
|
1247
1247
|
clone.operationSpecs = this.operationSpecs;
|
|
1248
|
-
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [
|
|
1248
|
+
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [
|
|
1249
|
+
k,
|
|
1250
|
+
{ duration: u.duration, variability: u.variability, params: new Set(u.params), paramBasis: new Map(u.paramBasis) }
|
|
1251
|
+
]));
|
|
1249
1252
|
return clone;
|
|
1250
1253
|
}
|
|
1251
1254
|
/**
|
|
@@ -1725,6 +1728,23 @@ export class FlowEngine {
|
|
|
1725
1728
|
this.noteParamUse(opKey, id);
|
|
1726
1729
|
return ms;
|
|
1727
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
|
|
1733
|
+
*
|
|
1734
|
+
* 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
|
|
1735
|
+
* (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
|
|
1736
|
+
*
|
|
1737
|
+
* 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
|
|
1738
|
+
* 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
|
|
1739
|
+
*/
|
|
1740
|
+
yieldOf(kind) {
|
|
1741
|
+
const measured = this.yieldEstimator?.estimate({ kind });
|
|
1742
|
+
if (typeof measured === 'number' && measured >= 0 && measured <= 1) {
|
|
1743
|
+
this.noteParamUse(kind, OP_PARAM.yield, 'measured');
|
|
1744
|
+
return measured;
|
|
1745
|
+
}
|
|
1746
|
+
return this.paramNumber(kind, OP_PARAM.yield);
|
|
1747
|
+
}
|
|
1728
1748
|
/**
|
|
1729
1749
|
* 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
|
|
1730
1750
|
*
|
|
@@ -1745,14 +1765,22 @@ export class FlowEngine {
|
|
|
1745
1765
|
cur.variability = variability;
|
|
1746
1766
|
return;
|
|
1747
1767
|
}
|
|
1748
|
-
this.specUse.set(kind, { duration, ...(variability ? { variability } : {}), params: new Set() });
|
|
1768
|
+
this.specUse.set(kind, { duration, ...(variability ? { variability } : {}), params: new Set(), paramBasis: new Map() });
|
|
1749
1769
|
}
|
|
1750
|
-
|
|
1770
|
+
/**
|
|
1771
|
+
* 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
|
|
1772
|
+
*
|
|
1773
|
+
* 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
|
|
1774
|
+
* 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
|
|
1775
|
+
*/
|
|
1776
|
+
noteParamUse(kind, id, basis = 'declared') {
|
|
1751
1777
|
const cur = this.specUse.get(kind);
|
|
1752
|
-
if (cur)
|
|
1778
|
+
if (cur) {
|
|
1753
1779
|
cur.params.add(id);
|
|
1754
|
-
|
|
1755
|
-
|
|
1780
|
+
cur.paramBasis.set(id, basis);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
this.specUse.set(kind, { duration: 'default', params: new Set([id]), paramBasis: new Map([[id, basis]]) });
|
|
1756
1784
|
}
|
|
1757
1785
|
/**
|
|
1758
1786
|
* 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
|
|
@@ -1769,7 +1797,9 @@ export class FlowEngine {
|
|
|
1769
1797
|
kind,
|
|
1770
1798
|
duration: u.duration,
|
|
1771
1799
|
...(variability ? { variability } : {}),
|
|
1772
|
-
parameters: [...u.params].sort()
|
|
1800
|
+
parameters: [...u.params].sort(),
|
|
1801
|
+
/* 모수를 **무엇으로** 읽었나 — 읽은 것이 있을 때만 낸다(빈 표를 만들지 않는다). */
|
|
1802
|
+
...(u.paramBasis.size ? { parameterBasis: Object.fromEntries([...u.paramBasis.entries()].sort()) } : {})
|
|
1773
1803
|
};
|
|
1774
1804
|
});
|
|
1775
1805
|
return {
|
|
@@ -2008,6 +2038,8 @@ export class FlowEngine {
|
|
|
2008
2038
|
/* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
|
|
2009
2039
|
...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
|
|
2010
2040
|
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
2041
|
+
/* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
|
|
2042
|
+
...(t.outcome ? { outcome: t.outcome } : {}),
|
|
2011
2043
|
...(t.priority !== undefined ? { priority: t.priority } : {}),
|
|
2012
2044
|
...(t.startTime ? { startTime: t.startTime } : {}),
|
|
2013
2045
|
...(t.endTime ? { endTime: t.endTime } : {}),
|
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export { YmsKernel } from './yms-kernel.ts';
|
|
|
30
30
|
export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
|
|
31
31
|
export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from './ems-kernel.ts';
|
|
32
32
|
export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from './energy-ingest.ts';
|
|
33
|
+
export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from './operational-ingest.ts';
|
|
34
|
+
export type { OperationalKind, OperationalRecord, OperationalIngestOptions } from './operational-ingest.ts';
|
|
33
35
|
export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from './energy-attribution.ts';
|
|
34
36
|
export type { AttributionBasis, AttributionResult, ElectricityCost, EnergyConsumer, EnergyPool, EnergyShare, IntensityInput, IntensityResult, IntensityDenominator, TariffDeclaration, WeightKind, WindowedEnergy } from './energy-attribution.ts';
|
|
35
37
|
export type { EnergyRecord, EnergyEquipmentRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,8 @@ export { YmsKernel } from "./yms-kernel.js";
|
|
|
30
30
|
export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
|
|
31
31
|
export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from "./ems-kernel.js";
|
|
32
32
|
export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from "./energy-ingest.js";
|
|
33
|
+
/* 운영 사실의 문 — 리듀서가 접는 여섯이 들어오는 자리(미러가 시뮬보다 가난하지 않게). */
|
|
34
|
+
export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from "./operational-ingest.js";
|
|
33
35
|
export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from "./energy-attribution.js";
|
|
34
36
|
/* 에너지 상태 타입은 **계약**에 있다(상태의 모양은 계약이다) — contract 의 `export *` 가 이미 낸다. */
|
|
35
37
|
export * from "./vocabulary.js";
|
package/dist/mes-kernel.js
CHANGED
|
@@ -229,7 +229,9 @@ export class MesKernel extends FlowEngine {
|
|
|
229
229
|
// 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
|
|
230
230
|
const fgStore = this.locationByType('fg-store');
|
|
231
231
|
const wip = order.allocated[0];
|
|
232
|
-
const good = this.rng() < (this.
|
|
232
|
+
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
233
|
+
/* 판정을 **작업에 적는다** — 그 자리에서 적어야 저널에 종류와 함께 남고, 이력에서 수율을 배울 수 있다. */
|
|
234
|
+
t.outcome = good ? 'good' : 'scrap';
|
|
233
235
|
this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
|
|
234
236
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
235
237
|
const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
|
|
@@ -352,7 +354,8 @@ export class MesKernel extends FlowEngine {
|
|
|
352
354
|
}
|
|
353
355
|
const fgStore = this.locationByType('fg-store');
|
|
354
356
|
const wip = order.allocated[0];
|
|
355
|
-
const good = this.rng() < (this.
|
|
357
|
+
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
358
|
+
t.outcome = good ? 'good' : 'scrap'; // 같은 규율 — 판정은 일어난 자리에서 적는다
|
|
356
359
|
this.recordOutput(t.resource, good);
|
|
357
360
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
358
361
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IngestResult } from './face2-adapter.ts';
|
|
2
|
+
/** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
|
|
3
|
+
export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality';
|
|
4
|
+
/**
|
|
5
|
+
* 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
|
|
6
|
+
*
|
|
7
|
+
* `at` 은 봉투의 `eventTime` 이 된다(리듀서가 늦게 온 옛 사실을 걸러내는 기준). 페이로드에는 싣지
|
|
8
|
+
* 않는다 — 델타에 없는 필드이고, 같은 사실이 두 시각을 갖지 않게.
|
|
9
|
+
*/
|
|
10
|
+
export interface OperationalRecordEnvelopeFields {
|
|
11
|
+
/** 발생 시각(ISO) — 없으면 `defaultEventTime`, 그것도 없으면 거부한다. */
|
|
12
|
+
at?: string;
|
|
13
|
+
/** 기록 시각(ISO) — 같은 발생 시각이 겹칠 때의 보조 순서. 리듀서가 페이로드에서 읽는다. */
|
|
14
|
+
recordTime?: string;
|
|
15
|
+
}
|
|
16
|
+
export type OperationalRecord = Record<string, unknown> & OperationalRecordEnvelopeFields;
|
|
17
|
+
export interface OperationalIngestOptions {
|
|
18
|
+
tenantId: string;
|
|
19
|
+
/** 레코드에 시각이 없을 때 쓸 값 — 주지 않으면 그 레코드를 거부한다. */
|
|
20
|
+
defaultEventTime?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
|
|
24
|
+
*
|
|
25
|
+
* EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
|
|
26
|
+
* **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
|
|
27
|
+
* 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
|
|
28
|
+
*
|
|
29
|
+
* ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
|
|
30
|
+
* 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
|
|
31
|
+
* 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
|
|
32
|
+
* 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
|
|
33
|
+
* 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
|
|
34
|
+
*/
|
|
35
|
+
export declare function operationalKindOf(record: unknown): OperationalKind | undefined;
|
|
36
|
+
/** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
|
|
37
|
+
export declare function isOperationalRecord(record: unknown): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
|
|
40
|
+
*
|
|
41
|
+
* 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
|
|
42
|
+
* 그대로 얻는다. 어휘만 자기 것이다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ingestOperationalRecords(records: OperationalRecord | OperationalRecord[] | undefined | null, opts: OperationalIngestOptions): IngestResult;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 운영 사실 인제스트 — **작업·설비·사람·자산·오더·품질이 들어오는 문.** (ADR-0029 어휘 넓히기)
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 없었나 (2026-08-19) ──────────────────────────────────────────────
|
|
5
|
+
* 관측 리듀서는 이 여섯을 **이미 접는다**(`observed-reducer.ts` 의 `OP_EVENT.*` 분기). 그런데 라이브
|
|
6
|
+
* 인제스트 문은 어휘를 셋만 알았다: EPCIS 품목 사실, 에너지 계량, 설비 에너지 상태. 그래서 원본이
|
|
7
|
+
* 「이 작업이 끝났다」·「이 설비가 고장이다」를 말할 **길이 없었다** — 넣으면 `epc` 가 없어 EPCIS
|
|
8
|
+
* 검증에서 거부됐다.
|
|
9
|
+
*
|
|
10
|
+
* 그 결과가 이 프로젝트가 가장 싫어하는 모양이었다: **시뮬만 아는 상태.** 시뮬 커널은 작업과 설비를
|
|
11
|
+
* 알고 미러는 영원히 몰랐다. 그러면 같은 화면이 두 구동에서 다른 것을 말하고, 미러 위에 세운 예측은
|
|
12
|
+
* 「진행 중인 일이 하나도 없는 현장」에서 출발한다.
|
|
13
|
+
*
|
|
14
|
+
* ── 어휘는 델타의 이름이다 ──────────────────────────────────────────────────
|
|
15
|
+
* 필드 이름을 새로 짓지 않는다. `TaskStatusDelta`·`EquipmentStatusDelta`… 가 이미 계약이고, 리듀서가
|
|
16
|
+
* 그 이름으로 읽는다. 여기서 다른 이름을 받아 옮기면 **같은 사실에 두 어휘**가 생긴다(에너지가 그
|
|
17
|
+
* 규율을 먼저 세웠다: "필드 이름이 계약이다").
|
|
18
|
+
*
|
|
19
|
+
* ── 무엇을 거부하나 ─────────────────────────────────────────────────────────
|
|
20
|
+
* 지어낼 수 없는 것이 빠지면 거부한다 — 정체(누구의 상태인가)와 상태다. 그리고 **접을 수 없는 낱말**도
|
|
21
|
+
* 거부한다: 설비 상태를 `'RUNNING'` 으로 받으면 아무 오류 없이 가동률이 0% 가 되고(누적기는 `busy`·
|
|
22
|
+
* `down` 만 센다), 사람이 `'available'` 이면 배정에서 조용히 사라진다. 그 실패는 화면에서 「일이 없는
|
|
23
|
+
* 공장」으로 보이고 원인을 되짚을 수 없다. 그래서 커널이 접을 수 있는 낱말만 받고, **받는 낱말을 이유에
|
|
24
|
+
* 적어** 커넥터가 매핑을 고칠 수 있게 한다(매핑=밖, 검증=커널).
|
|
25
|
+
*
|
|
26
|
+
* 오더의 상태·종류는 **열려 있다** — 도메인이 소유한다(`picking`·`packed`·`shipped`…). 커널이 그 낱말로
|
|
27
|
+
* 무엇을 접지 않으므로 닫을 근거가 없다.
|
|
28
|
+
*
|
|
29
|
+
* ── 파생은 받아도 커널이 다시 계산한다 ──────────────────────────────────────
|
|
30
|
+
* 작업의 진척(`progress`)은 계약에 있어 받지만, 상태에 앉는 값은 커널이 **소요·남은 시간에서 다시
|
|
31
|
+
* 계산한 것**이다(`progressOf`). 그러니 원본이 진척을 보이게 하려면 `durationMs`·`remainingMs` 를 보내야
|
|
32
|
+
* 한다 — 파생을 사실로 삼지 않는 규율이고, 이 문을 붙이는 사람이 알아야 하는 사실이라 여기 적는다.
|
|
33
|
+
*
|
|
34
|
+
* 모르는 필드는 **조용히 버리지 않고 거부한다.** `taskID` 처럼 한 글자 틀린 이름은 통과시키면 영원히
|
|
35
|
+
* 보이지 않는 손실이 된다(이 문에는 아직 옛 발신자가 없어 호환 부담도 없다).
|
|
36
|
+
*/
|
|
37
|
+
import { OP_EVENT } from "./contract.js";
|
|
38
|
+
/**
|
|
39
|
+
* 닫아 둔 낱말과 그 이유.
|
|
40
|
+
* · 작업 상태 — 성과 폴드가 `completed`·`in-progress` 로 갈린다(`kpi-fold`).
|
|
41
|
+
* · 설비 상태 — OEE 누적기가 `busy`·`down` 만 센다. 그 밖의 낱말은 가동률 0% 로 조용히 앉는다.
|
|
42
|
+
* · 사람·자산 상태 — 배정이 `idle` 을 찾는다. 다른 낱말이면 있는 자원이 없는 것이 된다.
|
|
43
|
+
*/
|
|
44
|
+
const TASK_STATUS = ['created', 'assigned', 'in-progress', 'completed'];
|
|
45
|
+
const EQUIPMENT_STATUS = ['idle', 'busy', 'down'];
|
|
46
|
+
const PERSON_STATUS = ['idle', 'busy'];
|
|
47
|
+
const ASSET_STATUS = ['idle', 'in-use'];
|
|
48
|
+
const SPECS = {
|
|
49
|
+
task: {
|
|
50
|
+
eventType: OP_EVENT.task,
|
|
51
|
+
identity: 'taskId',
|
|
52
|
+
/* 종류가 없으면 성과를 종류별로 접을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
53
|
+
required: ['taskId', 'kind', 'status'],
|
|
54
|
+
fields: {
|
|
55
|
+
taskId: 'string', kind: 'string', status: 'string', fromNode: 'string', toNode: 'string',
|
|
56
|
+
itemRefs: 'string[]', resourceRef: 'string', resources: 'string[]', personnel: 'string[]', assets: 'string[]',
|
|
57
|
+
orderId: 'string', intent: 'string', progress: 'number', remainingMs: 'number', durationMs: 'number',
|
|
58
|
+
startedAtSimMs: 'number', outcome: 'string', priority: 'number', startTime: 'string', endTime: 'string',
|
|
59
|
+
materialActual: 'object[]', recordTime: 'string'
|
|
60
|
+
},
|
|
61
|
+
enums: {
|
|
62
|
+
status: TASK_STATUS,
|
|
63
|
+
intent: ['transport', 'process', 'dwell'],
|
|
64
|
+
/* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
|
|
65
|
+
outcome: ['good', 'scrap']
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
equipment: {
|
|
69
|
+
eventType: OP_EVENT.equipment,
|
|
70
|
+
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
71
|
+
required: ['moverId', 'kind', 'status'], // vocabulary-guard: allow 위와 같은 이유
|
|
72
|
+
fields: {
|
|
73
|
+
moverId: 'string', kind: 'string', status: 'string', location: 'string', homeLocation: 'string', // vocabulary-guard: allow
|
|
74
|
+
taskId: 'string', held: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string',
|
|
75
|
+
/* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
|
|
76
|
+
재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
|
|
77
|
+
motion: 'object'
|
|
78
|
+
},
|
|
79
|
+
enums: { status: EQUIPMENT_STATUS }
|
|
80
|
+
},
|
|
81
|
+
person: {
|
|
82
|
+
eventType: OP_EVENT.person,
|
|
83
|
+
identity: 'personId',
|
|
84
|
+
required: ['personId', 'status'],
|
|
85
|
+
fields: {
|
|
86
|
+
personId: 'string', status: 'string', personnelClassIds: 'string[]', taskId: 'string', location: 'string',
|
|
87
|
+
offShift: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
|
|
88
|
+
},
|
|
89
|
+
enums: { status: PERSON_STATUS }
|
|
90
|
+
},
|
|
91
|
+
asset: {
|
|
92
|
+
eventType: OP_EVENT.asset,
|
|
93
|
+
identity: 'assetId',
|
|
94
|
+
required: ['assetId', 'status'],
|
|
95
|
+
fields: {
|
|
96
|
+
assetId: 'string', status: 'string', assetClassIds: 'string[]', location: 'string', taskId: 'string',
|
|
97
|
+
carrying: 'string', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
|
|
98
|
+
},
|
|
99
|
+
enums: { status: ASSET_STATUS }
|
|
100
|
+
},
|
|
101
|
+
order: {
|
|
102
|
+
eventType: OP_EVENT.order,
|
|
103
|
+
identity: 'orderId',
|
|
104
|
+
/*
|
|
105
|
+
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
106
|
+
* 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
|
|
107
|
+
*/
|
|
108
|
+
required: ['orderId', 'kind', 'status', 'requested', 'fulfilled'],
|
|
109
|
+
fields: {
|
|
110
|
+
orderId: 'string', kind: 'string', status: 'string', requested: 'number', fulfilled: 'number',
|
|
111
|
+
gtin: 'string', held: 'boolean', lines: 'object[]', priority: 'number', startTime: 'string', endTime: 'string',
|
|
112
|
+
allocated: 'string[]', bizTransaction: 'string', dockDoor: 'string', windowStartMs: 'number', recordTime: 'string'
|
|
113
|
+
}
|
|
114
|
+
/* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
|
|
115
|
+
},
|
|
116
|
+
quality: {
|
|
117
|
+
eventType: OP_EVENT.quality,
|
|
118
|
+
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드
|
|
119
|
+
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
120
|
+
required: ['moverId', 'good', 'goodCount', 'scrapCount'], // vocabulary-guard: allow
|
|
121
|
+
fields: { moverId: 'string', good: 'boolean', goodCount: 'number', scrapCount: 'number', recordTime: 'string' } // vocabulary-guard: allow
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
|
|
126
|
+
*
|
|
127
|
+
* EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
|
|
128
|
+
* **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
|
|
129
|
+
* 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
|
|
130
|
+
*
|
|
131
|
+
* ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
|
|
132
|
+
* 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
|
|
133
|
+
* 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
|
|
134
|
+
* 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
|
|
135
|
+
* 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
|
|
136
|
+
*/
|
|
137
|
+
export function operationalKindOf(record) {
|
|
138
|
+
if (!record || typeof record !== 'object')
|
|
139
|
+
return undefined;
|
|
140
|
+
const r = record;
|
|
141
|
+
if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
|
|
144
|
+
/* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
|
|
145
|
+
if (has('moverId'))
|
|
146
|
+
return r.good !== undefined ? 'quality' : 'equipment';
|
|
147
|
+
if (has('personId'))
|
|
148
|
+
return 'person';
|
|
149
|
+
if (has('assetId'))
|
|
150
|
+
return 'asset';
|
|
151
|
+
if (has('taskId'))
|
|
152
|
+
return 'task'; // 작업이 든 `orderId` 는 소속(참조)이다
|
|
153
|
+
if (has('orderId'))
|
|
154
|
+
return 'order';
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
/** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
|
|
158
|
+
export function isOperationalRecord(record) {
|
|
159
|
+
return operationalKindOf(record) !== undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
|
|
163
|
+
*
|
|
164
|
+
* 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
|
|
165
|
+
* 그대로 얻는다. 어휘만 자기 것이다.
|
|
166
|
+
*/
|
|
167
|
+
export function ingestOperationalRecords(records, opts) {
|
|
168
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
169
|
+
const accepted = [];
|
|
170
|
+
const rejected = [];
|
|
171
|
+
let seq = 0;
|
|
172
|
+
for (const record of arr) {
|
|
173
|
+
const kind = operationalKindOf(record);
|
|
174
|
+
if (!kind) {
|
|
175
|
+
rejected.push({
|
|
176
|
+
record,
|
|
177
|
+
errors: ['어느 운영 사실인지 모른다 — 정체 필드가 필요하다(taskId · moverId(+good=품질) · personId · assetId · orderId)'] // vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const spec = SPECS[kind];
|
|
182
|
+
const r = record;
|
|
183
|
+
const errors = [];
|
|
184
|
+
/* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 조용히 사라지는 것을 막는다. */
|
|
185
|
+
const unknown = Object.keys(r).filter(k => k !== 'at' && spec.fields[k] === undefined);
|
|
186
|
+
if (unknown.length)
|
|
187
|
+
errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
|
|
188
|
+
for (const name of spec.required) {
|
|
189
|
+
const v = r[name];
|
|
190
|
+
if (v === undefined || v === null || v === '')
|
|
191
|
+
errors.push(`${kind}: ${name} 없음 — 지어낼 수 없는 값이다`);
|
|
192
|
+
}
|
|
193
|
+
const data = {};
|
|
194
|
+
for (const [name, type] of Object.entries(spec.fields)) {
|
|
195
|
+
const v = r[name];
|
|
196
|
+
if (v === undefined || v === null || v === '')
|
|
197
|
+
continue;
|
|
198
|
+
switch (type) {
|
|
199
|
+
case 'string': {
|
|
200
|
+
if (typeof v !== 'string') {
|
|
201
|
+
errors.push(`${kind}.${name} 이 문자열이 아니다: ${JSON.stringify(v)}`);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
const allowed = spec.enums?.[name];
|
|
205
|
+
if (allowed && !allowed.includes(v)) {
|
|
206
|
+
errors.push(`${kind}.${name} 이 커널이 접는 낱말이 아니다: ${JSON.stringify(v)} — 받는 값은 ${allowed.join(' · ')}`);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
data[name] = v;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'number': {
|
|
213
|
+
const n = Number(v);
|
|
214
|
+
if (typeof v === 'boolean' || !Number.isFinite(n)) {
|
|
215
|
+
errors.push(`${kind}.${name} 가 수가 아니다: ${JSON.stringify(v)}`);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
/* 진척은 비율이다 — 백분율(95)을 그대로 받으면 화면이 9,500% 를 말한다. */
|
|
219
|
+
if (name === 'progress' && (n < 0 || n > 1)) {
|
|
220
|
+
errors.push(`${kind}.progress 는 0~1 비율이다: ${n}`);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if ((name === 'requested' || name === 'fulfilled' || name === 'goodCount' || name === 'scrapCount') && n < 0) {
|
|
224
|
+
errors.push(`${kind}.${name} 가 음수다: ${n}`);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
data[name] = n;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'boolean': {
|
|
231
|
+
if (typeof v !== 'boolean') {
|
|
232
|
+
errors.push(`${kind}.${name} 가 참/거짓이 아니다: ${JSON.stringify(v)}`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
data[name] = v;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'string[]': {
|
|
239
|
+
if (!Array.isArray(v) || v.some(x => typeof x !== 'string')) {
|
|
240
|
+
errors.push(`${kind}.${name} 가 문자열 배열이 아니다: ${JSON.stringify(v)}`);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
data[name] = v.slice();
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'object': {
|
|
247
|
+
if (Array.isArray(v) || typeof v !== 'object') {
|
|
248
|
+
errors.push(`${kind}.${name} 가 객체가 아니다: ${JSON.stringify(v)}`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
data[name] = { ...v };
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case 'object[]': {
|
|
255
|
+
if (!Array.isArray(v) || v.some(x => !x || typeof x !== 'object')) {
|
|
256
|
+
errors.push(`${kind}.${name} 가 객체 배열이 아니다: ${JSON.stringify(v)}`);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
data[name] = v.map(x => ({ ...x }));
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const at = String(r.at ?? '').trim() || opts.defaultEventTime;
|
|
265
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
266
|
+
if (!Number.isFinite(atMs)) {
|
|
267
|
+
/* 시각이 없으면 순서를 판정할 수 없다 — 늦게 온 옛 사실이 최신 상태를 덮어써 위치가 과거로 튄다. */
|
|
268
|
+
errors.push(`${kind}: at 없음/형식 오류 — 시각 없이는 늦게 온 옛 사실을 걸러낼 수 없다`);
|
|
269
|
+
}
|
|
270
|
+
if (errors.length) {
|
|
271
|
+
rejected.push({ record, errors });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
accepted.push({
|
|
275
|
+
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
276
|
+
eventType: spec.eventType,
|
|
277
|
+
eventTime: new Date(atMs).toISOString(),
|
|
278
|
+
tenantId: opts.tenantId,
|
|
279
|
+
data
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return { accepted, rejected };
|
|
283
|
+
}
|
package/dist/task-fold.d.ts
CHANGED
|
@@ -23,6 +23,16 @@ export interface TaskFacets {
|
|
|
23
23
|
order?: string;
|
|
24
24
|
/** 가장 최근 도착 지점. 진행에 따라 바뀌므로 마지막 것이 사실이다. */
|
|
25
25
|
location?: string;
|
|
26
|
+
/**
|
|
27
|
+
* 품질 판정 결과 — **판정이 있었을 때만** (2026-08-19).
|
|
28
|
+
*
|
|
29
|
+
* 이 축이 없으면 「이 공정의 양품률이 얼마였나」를 저널에서 되짚을 수 없다: 양품/불량은 설비 누적
|
|
30
|
+
* 카운터(종류 모름)와 EPCIS disposition(종류 모름)에만 남아 있었다. 판정은 작업 완료에서 일어나므로
|
|
31
|
+
* 그 자리에서 적고, 여기서 그대로 접는다.
|
|
32
|
+
*
|
|
33
|
+
* **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다).
|
|
34
|
+
*/
|
|
35
|
+
outcome?: 'good' | 'scrap';
|
|
26
36
|
}
|
|
27
37
|
/** 작업 하나의 이정표 — 소비처가 이것으로 구간·지표를 만든다. */
|
|
28
38
|
export interface TaskRecord {
|
package/dist/task-fold.js
CHANGED
|
@@ -46,6 +46,9 @@ export function foldTaskRecords(rows) {
|
|
|
46
46
|
f.kind = d.kind;
|
|
47
47
|
if (f.order === undefined && d.orderId)
|
|
48
48
|
f.order = d.orderId;
|
|
49
|
+
/* 품질 판정은 완료 전이에 실려 온다 — 한 번 실린 값은 바뀌지 않는다(정정이 오면 마지막 것이 사실). */
|
|
50
|
+
if (d.outcome === 'good' || d.outcome === 'scrap')
|
|
51
|
+
f.outcome = d.outcome;
|
|
49
52
|
const loc = d.toNode ?? d.fromNode;
|
|
50
53
|
if (loc)
|
|
51
54
|
f.location = loc;
|
package/dist/vocabulary.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 금지 어휘 — 표준 어휘로 대체된 옛 낱말. 파생 식별자까지 잡도록 부분 일치로 본다.
|
|
3
|
+
*
|
|
4
|
+
* `BoardDef`·`loadBoard` 는 ADR-0033 으로 `TwinModelDef`·`loadTwinModel` 이 됐다. 커널 소스에는
|
|
5
|
+
* **0곳**이라 개명이 끝났는데, 문서에는 31곳이 남아 있었다(2026-08-19 실측) — 코드에 없는 이름으로
|
|
6
|
+
* 설계를 설명하고 있었다. 코드에 0곳이므로 여기 올려 되돌아오는 것을 막는다.
|
|
7
|
+
*
|
|
8
|
+
* (문서 쪽은 `test/doc-vocabulary-guard.test.ts` 가 **자기 목록**으로 본다 — 이 목록의 `node`·`mover` 는
|
|
9
|
+
* ADR 기록이 없어 문서의 개념 명사에까지 들이대지 않는다.)
|
|
10
|
+
*/
|
|
11
|
+
export declare const RETIRED_VOCABULARY: readonly ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
|
|
3
12
|
/**
|
|
4
13
|
* 예외 — 남아 있는 것이 **정당한** 토큰. 각 항목에 이유가 붙는다.
|
|
5
14
|
*
|
package/dist/vocabulary.js
CHANGED
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 예외는 **이유와 함께** 여기 적는다. 주석에만 적으면 다음 사람이 "원래 그런가 보다" 하고 넘긴다.
|
|
12
12
|
*/
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 금지 어휘 — 표준 어휘로 대체된 옛 낱말. 파생 식별자까지 잡도록 부분 일치로 본다.
|
|
15
|
+
*
|
|
16
|
+
* `BoardDef`·`loadBoard` 는 ADR-0033 으로 `TwinModelDef`·`loadTwinModel` 이 됐다. 커널 소스에는
|
|
17
|
+
* **0곳**이라 개명이 끝났는데, 문서에는 31곳이 남아 있었다(2026-08-19 실측) — 코드에 없는 이름으로
|
|
18
|
+
* 설계를 설명하고 있었다. 코드에 0곳이므로 여기 올려 되돌아오는 것을 막는다.
|
|
19
|
+
*
|
|
20
|
+
* (문서 쪽은 `test/doc-vocabulary-guard.test.ts` 가 **자기 목록**으로 본다 — 이 목록의 `node`·`mover` 는
|
|
21
|
+
* ADR 기록이 없어 문서의 개념 명사에까지 들이대지 않는다.)
|
|
22
|
+
*/
|
|
23
|
+
export const RETIRED_VOCABULARY = ['mover', 'Mover', 'MOVER', 'node', 'Node', 'NODE', 'BoardDef', 'loadBoard'];
|
|
15
24
|
/**
|
|
16
25
|
* 예외 — 남아 있는 것이 **정당한** 토큰. 각 항목에 이유가 붙는다.
|
|
17
26
|
*
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -117,10 +117,12 @@ __export(index_exports, {
|
|
|
117
117
|
ingest: () => ingest,
|
|
118
118
|
ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
|
|
119
119
|
ingestEnergyRecords: () => ingestEnergyRecords,
|
|
120
|
+
ingestOperationalRecords: () => ingestOperationalRecords,
|
|
120
121
|
isElectricalLocationType: () => isElectricalLocationType,
|
|
121
122
|
isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
|
|
122
123
|
isEnergyRecord: () => isEnergyRecord,
|
|
123
124
|
isEquipmentLevel: () => isEquipmentLevel,
|
|
125
|
+
isOperationalRecord: () => isOperationalRecord,
|
|
124
126
|
isoDurationHours: () => isoDurationHours,
|
|
125
127
|
itemKeyOf: () => itemKeyOf,
|
|
126
128
|
levelOfLocationType: () => levelOfLocationType,
|
|
@@ -134,6 +136,7 @@ __export(index_exports, {
|
|
|
134
136
|
objectEvent: () => objectEvent,
|
|
135
137
|
offCalendarAt: () => offCalendarAt,
|
|
136
138
|
offCalendarReasonAt: () => offCalendarReasonAt,
|
|
139
|
+
operationalKindOf: () => operationalKindOf,
|
|
137
140
|
operationsCapabilityOf: () => operationsCapabilityOf,
|
|
138
141
|
parseEpc: () => parseEpc,
|
|
139
142
|
parseIsoDuration: () => parseIsoDuration,
|
|
@@ -2630,6 +2633,7 @@ function foldTaskRecords(rows) {
|
|
|
2630
2633
|
if (f.resource === void 0 && d.resourceRef) f.resource = d.resourceRef;
|
|
2631
2634
|
if (f.kind === void 0 && d.kind) f.kind = d.kind;
|
|
2632
2635
|
if (f.order === void 0 && d.orderId) f.order = d.orderId;
|
|
2636
|
+
if (d.outcome === "good" || d.outcome === "scrap") f.outcome = d.outcome;
|
|
2633
2637
|
const loc = d.toNode ?? d.fromNode;
|
|
2634
2638
|
if (loc) f.location = loc;
|
|
2635
2639
|
if (d.status === "created") rec.createdMs = at;
|
|
@@ -3172,6 +3176,13 @@ var FlowEngine = class {
|
|
|
3172
3176
|
policy;
|
|
3173
3177
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
3174
3178
|
durationEstimator;
|
|
3179
|
+
/**
|
|
3180
|
+
* 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
|
|
3181
|
+
*
|
|
3182
|
+
* 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
|
|
3183
|
+
* 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
|
|
3184
|
+
*/
|
|
3185
|
+
yieldEstimator;
|
|
3175
3186
|
/**
|
|
3176
3187
|
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
3177
3188
|
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
@@ -4003,7 +4014,12 @@ var FlowEngine = class {
|
|
|
4003
4014
|
clone.rng.state = this.rng.state;
|
|
4004
4015
|
clone.durationEstimator = this.durationEstimator;
|
|
4005
4016
|
clone.operationSpecs = this.operationSpecs;
|
|
4006
|
-
clone.specUse = new Map(
|
|
4017
|
+
clone.specUse = new Map(
|
|
4018
|
+
[...this.specUse.entries()].map(([k, u]) => [
|
|
4019
|
+
k,
|
|
4020
|
+
{ duration: u.duration, variability: u.variability, params: new Set(u.params), paramBasis: new Map(u.paramBasis) }
|
|
4021
|
+
])
|
|
4022
|
+
);
|
|
4007
4023
|
return clone;
|
|
4008
4024
|
}
|
|
4009
4025
|
/**
|
|
@@ -4437,6 +4453,23 @@ var FlowEngine = class {
|
|
|
4437
4453
|
if (ms2 !== void 0) this.noteParamUse(opKey, id);
|
|
4438
4454
|
return ms2;
|
|
4439
4455
|
}
|
|
4456
|
+
/**
|
|
4457
|
+
* 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
|
|
4458
|
+
*
|
|
4459
|
+
* 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
|
|
4460
|
+
* (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
|
|
4461
|
+
*
|
|
4462
|
+
* 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
|
|
4463
|
+
* 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
|
|
4464
|
+
*/
|
|
4465
|
+
yieldOf(kind) {
|
|
4466
|
+
const measured = this.yieldEstimator?.estimate({ kind });
|
|
4467
|
+
if (typeof measured === "number" && measured >= 0 && measured <= 1) {
|
|
4468
|
+
this.noteParamUse(kind, OP_PARAM.yield, "measured");
|
|
4469
|
+
return measured;
|
|
4470
|
+
}
|
|
4471
|
+
return this.paramNumber(kind, OP_PARAM.yield);
|
|
4472
|
+
}
|
|
4440
4473
|
/**
|
|
4441
4474
|
* 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
|
|
4442
4475
|
*
|
|
@@ -4455,12 +4488,22 @@ var FlowEngine = class {
|
|
|
4455
4488
|
if (variability) cur.variability = variability;
|
|
4456
4489
|
return;
|
|
4457
4490
|
}
|
|
4458
|
-
this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set() });
|
|
4491
|
+
this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set(), paramBasis: /* @__PURE__ */ new Map() });
|
|
4459
4492
|
}
|
|
4460
|
-
|
|
4493
|
+
/**
|
|
4494
|
+
* 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
|
|
4495
|
+
*
|
|
4496
|
+
* 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
|
|
4497
|
+
* 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
|
|
4498
|
+
*/
|
|
4499
|
+
noteParamUse(kind, id, basis = "declared") {
|
|
4461
4500
|
const cur = this.specUse.get(kind);
|
|
4462
|
-
if (cur)
|
|
4463
|
-
|
|
4501
|
+
if (cur) {
|
|
4502
|
+
cur.params.add(id);
|
|
4503
|
+
cur.paramBasis.set(id, basis);
|
|
4504
|
+
return;
|
|
4505
|
+
}
|
|
4506
|
+
this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]), paramBasis: /* @__PURE__ */ new Map([[id, basis]]) });
|
|
4464
4507
|
}
|
|
4465
4508
|
/**
|
|
4466
4509
|
* 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
|
|
@@ -4476,7 +4519,9 @@ var FlowEngine = class {
|
|
|
4476
4519
|
kind,
|
|
4477
4520
|
duration: u.duration,
|
|
4478
4521
|
...variability ? { variability } : {},
|
|
4479
|
-
parameters: [...u.params].sort()
|
|
4522
|
+
parameters: [...u.params].sort(),
|
|
4523
|
+
/* 모수를 **무엇으로** 읽었나 — 읽은 것이 있을 때만 낸다(빈 표를 만들지 않는다). */
|
|
4524
|
+
...u.paramBasis.size ? { parameterBasis: Object.fromEntries([...u.paramBasis.entries()].sort()) } : {}
|
|
4480
4525
|
};
|
|
4481
4526
|
});
|
|
4482
4527
|
return {
|
|
@@ -4701,6 +4746,8 @@ var FlowEngine = class {
|
|
|
4701
4746
|
/* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
|
|
4702
4747
|
...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
|
|
4703
4748
|
...t.durationMs ? { durationMs: t.durationMs } : {},
|
|
4749
|
+
/* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
|
|
4750
|
+
...t.outcome ? { outcome: t.outcome } : {},
|
|
4704
4751
|
...t.priority !== void 0 ? { priority: t.priority } : {},
|
|
4705
4752
|
...t.startTime ? { startTime: t.startTime } : {},
|
|
4706
4753
|
...t.endTime ? { endTime: t.endTime } : {},
|
|
@@ -6163,7 +6210,8 @@ var MesKernel = class extends FlowEngine {
|
|
|
6163
6210
|
}
|
|
6164
6211
|
const fgStore = this.locationByType("fg-store");
|
|
6165
6212
|
const wip = order.allocated[0];
|
|
6166
|
-
const good = this.rng() < (this.
|
|
6213
|
+
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
6214
|
+
t.outcome = good ? "good" : "scrap";
|
|
6167
6215
|
this.recordOutput(t.resource, good);
|
|
6168
6216
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
6169
6217
|
const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
|
|
@@ -6275,7 +6323,8 @@ var MesKernel = class extends FlowEngine {
|
|
|
6275
6323
|
}
|
|
6276
6324
|
const fgStore = this.locationByType("fg-store");
|
|
6277
6325
|
const wip = order.allocated[0];
|
|
6278
|
-
const good = this.rng() < (this.
|
|
6326
|
+
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
6327
|
+
t.outcome = good ? "good" : "scrap";
|
|
6279
6328
|
this.recordOutput(t.resource, good);
|
|
6280
6329
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
6281
6330
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
@@ -7120,6 +7169,273 @@ function ingestEnergyEquipmentRecords(records, opts) {
|
|
|
7120
7169
|
return { accepted, rejected };
|
|
7121
7170
|
}
|
|
7122
7171
|
|
|
7172
|
+
// src/operational-ingest.ts
|
|
7173
|
+
var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
|
|
7174
|
+
var EQUIPMENT_STATUS = ["idle", "busy", "down"];
|
|
7175
|
+
var PERSON_STATUS = ["idle", "busy"];
|
|
7176
|
+
var ASSET_STATUS = ["idle", "in-use"];
|
|
7177
|
+
var SPECS = {
|
|
7178
|
+
task: {
|
|
7179
|
+
eventType: OP_EVENT.task,
|
|
7180
|
+
identity: "taskId",
|
|
7181
|
+
/* 종류가 없으면 성과를 종류별로 접을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
7182
|
+
required: ["taskId", "kind", "status"],
|
|
7183
|
+
fields: {
|
|
7184
|
+
taskId: "string",
|
|
7185
|
+
kind: "string",
|
|
7186
|
+
status: "string",
|
|
7187
|
+
fromNode: "string",
|
|
7188
|
+
toNode: "string",
|
|
7189
|
+
itemRefs: "string[]",
|
|
7190
|
+
resourceRef: "string",
|
|
7191
|
+
resources: "string[]",
|
|
7192
|
+
personnel: "string[]",
|
|
7193
|
+
assets: "string[]",
|
|
7194
|
+
orderId: "string",
|
|
7195
|
+
intent: "string",
|
|
7196
|
+
progress: "number",
|
|
7197
|
+
remainingMs: "number",
|
|
7198
|
+
durationMs: "number",
|
|
7199
|
+
startedAtSimMs: "number",
|
|
7200
|
+
outcome: "string",
|
|
7201
|
+
priority: "number",
|
|
7202
|
+
startTime: "string",
|
|
7203
|
+
endTime: "string",
|
|
7204
|
+
materialActual: "object[]",
|
|
7205
|
+
recordTime: "string"
|
|
7206
|
+
},
|
|
7207
|
+
enums: {
|
|
7208
|
+
status: TASK_STATUS,
|
|
7209
|
+
intent: ["transport", "process", "dwell"],
|
|
7210
|
+
/* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
|
|
7211
|
+
outcome: ["good", "scrap"]
|
|
7212
|
+
}
|
|
7213
|
+
},
|
|
7214
|
+
equipment: {
|
|
7215
|
+
eventType: OP_EVENT.equipment,
|
|
7216
|
+
identity: "moverId",
|
|
7217
|
+
// vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
7218
|
+
required: ["moverId", "kind", "status"],
|
|
7219
|
+
// vocabulary-guard: allow 위와 같은 이유
|
|
7220
|
+
fields: {
|
|
7221
|
+
moverId: "string",
|
|
7222
|
+
kind: "string",
|
|
7223
|
+
status: "string",
|
|
7224
|
+
location: "string",
|
|
7225
|
+
homeLocation: "string",
|
|
7226
|
+
// vocabulary-guard: allow
|
|
7227
|
+
taskId: "string",
|
|
7228
|
+
held: "boolean",
|
|
7229
|
+
effectiveStart: "string",
|
|
7230
|
+
effectiveEnd: "string",
|
|
7231
|
+
recordTime: "string",
|
|
7232
|
+
/* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
|
|
7233
|
+
재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
|
|
7234
|
+
motion: "object"
|
|
7235
|
+
},
|
|
7236
|
+
enums: { status: EQUIPMENT_STATUS }
|
|
7237
|
+
},
|
|
7238
|
+
person: {
|
|
7239
|
+
eventType: OP_EVENT.person,
|
|
7240
|
+
identity: "personId",
|
|
7241
|
+
required: ["personId", "status"],
|
|
7242
|
+
fields: {
|
|
7243
|
+
personId: "string",
|
|
7244
|
+
status: "string",
|
|
7245
|
+
personnelClassIds: "string[]",
|
|
7246
|
+
taskId: "string",
|
|
7247
|
+
location: "string",
|
|
7248
|
+
offShift: "boolean",
|
|
7249
|
+
effectiveStart: "string",
|
|
7250
|
+
effectiveEnd: "string",
|
|
7251
|
+
recordTime: "string"
|
|
7252
|
+
},
|
|
7253
|
+
enums: { status: PERSON_STATUS }
|
|
7254
|
+
},
|
|
7255
|
+
asset: {
|
|
7256
|
+
eventType: OP_EVENT.asset,
|
|
7257
|
+
identity: "assetId",
|
|
7258
|
+
required: ["assetId", "status"],
|
|
7259
|
+
fields: {
|
|
7260
|
+
assetId: "string",
|
|
7261
|
+
status: "string",
|
|
7262
|
+
assetClassIds: "string[]",
|
|
7263
|
+
location: "string",
|
|
7264
|
+
taskId: "string",
|
|
7265
|
+
carrying: "string",
|
|
7266
|
+
effectiveStart: "string",
|
|
7267
|
+
effectiveEnd: "string",
|
|
7268
|
+
recordTime: "string"
|
|
7269
|
+
},
|
|
7270
|
+
enums: { status: ASSET_STATUS }
|
|
7271
|
+
},
|
|
7272
|
+
order: {
|
|
7273
|
+
eventType: OP_EVENT.order,
|
|
7274
|
+
identity: "orderId",
|
|
7275
|
+
/*
|
|
7276
|
+
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
7277
|
+
* 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
|
|
7278
|
+
*/
|
|
7279
|
+
required: ["orderId", "kind", "status", "requested", "fulfilled"],
|
|
7280
|
+
fields: {
|
|
7281
|
+
orderId: "string",
|
|
7282
|
+
kind: "string",
|
|
7283
|
+
status: "string",
|
|
7284
|
+
requested: "number",
|
|
7285
|
+
fulfilled: "number",
|
|
7286
|
+
gtin: "string",
|
|
7287
|
+
held: "boolean",
|
|
7288
|
+
lines: "object[]",
|
|
7289
|
+
priority: "number",
|
|
7290
|
+
startTime: "string",
|
|
7291
|
+
endTime: "string",
|
|
7292
|
+
allocated: "string[]",
|
|
7293
|
+
bizTransaction: "string",
|
|
7294
|
+
dockDoor: "string",
|
|
7295
|
+
windowStartMs: "number",
|
|
7296
|
+
recordTime: "string"
|
|
7297
|
+
}
|
|
7298
|
+
/* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
|
|
7299
|
+
},
|
|
7300
|
+
quality: {
|
|
7301
|
+
eventType: OP_EVENT.quality,
|
|
7302
|
+
identity: "moverId",
|
|
7303
|
+
// vocabulary-guard: allow 저널 와이어 필드
|
|
7304
|
+
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
7305
|
+
required: ["moverId", "good", "goodCount", "scrapCount"],
|
|
7306
|
+
// vocabulary-guard: allow
|
|
7307
|
+
fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", recordTime: "string" }
|
|
7308
|
+
// vocabulary-guard: allow
|
|
7309
|
+
}
|
|
7310
|
+
};
|
|
7311
|
+
function operationalKindOf(record) {
|
|
7312
|
+
if (!record || typeof record !== "object") return void 0;
|
|
7313
|
+
const r = record;
|
|
7314
|
+
if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
|
|
7315
|
+
const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
|
|
7316
|
+
if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
|
|
7317
|
+
if (has("personId")) return "person";
|
|
7318
|
+
if (has("assetId")) return "asset";
|
|
7319
|
+
if (has("taskId")) return "task";
|
|
7320
|
+
if (has("orderId")) return "order";
|
|
7321
|
+
return void 0;
|
|
7322
|
+
}
|
|
7323
|
+
function isOperationalRecord(record) {
|
|
7324
|
+
return operationalKindOf(record) !== void 0;
|
|
7325
|
+
}
|
|
7326
|
+
function ingestOperationalRecords(records, opts) {
|
|
7327
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
7328
|
+
const accepted = [];
|
|
7329
|
+
const rejected = [];
|
|
7330
|
+
let seq = 0;
|
|
7331
|
+
for (const record of arr) {
|
|
7332
|
+
const kind = operationalKindOf(record);
|
|
7333
|
+
if (!kind) {
|
|
7334
|
+
rejected.push({
|
|
7335
|
+
record,
|
|
7336
|
+
errors: ["\uC5B4\uB290 \uC6B4\uC601 \uC0AC\uC2E4\uC778\uC9C0 \uBAA8\uB978\uB2E4 \u2014 \uC815\uCCB4 \uD544\uB4DC\uAC00 \uD544\uC694\uD558\uB2E4(taskId \xB7 moverId(+good=\uD488\uC9C8) \xB7 personId \xB7 assetId \xB7 orderId)"]
|
|
7337
|
+
// vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
|
|
7338
|
+
});
|
|
7339
|
+
continue;
|
|
7340
|
+
}
|
|
7341
|
+
const spec = SPECS[kind];
|
|
7342
|
+
const r = record;
|
|
7343
|
+
const errors = [];
|
|
7344
|
+
const unknown = Object.keys(r).filter((k) => k !== "at" && spec.fields[k] === void 0);
|
|
7345
|
+
if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
|
|
7346
|
+
for (const name of spec.required) {
|
|
7347
|
+
const v = r[name];
|
|
7348
|
+
if (v === void 0 || v === null || v === "") errors.push(`${kind}: ${name} \uC5C6\uC74C \u2014 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB294 \uAC12\uC774\uB2E4`);
|
|
7349
|
+
}
|
|
7350
|
+
const data = {};
|
|
7351
|
+
for (const [name, type] of Object.entries(spec.fields)) {
|
|
7352
|
+
const v = r[name];
|
|
7353
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
7354
|
+
switch (type) {
|
|
7355
|
+
case "string": {
|
|
7356
|
+
if (typeof v !== "string") {
|
|
7357
|
+
errors.push(`${kind}.${name} \uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7358
|
+
break;
|
|
7359
|
+
}
|
|
7360
|
+
const allowed = spec.enums?.[name];
|
|
7361
|
+
if (allowed && !allowed.includes(v)) {
|
|
7362
|
+
errors.push(`${kind}.${name} \uC774 \uCEE4\uB110\uC774 \uC811\uB294 \uB0B1\uB9D0\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)} \u2014 \uBC1B\uB294 \uAC12\uC740 ${allowed.join(" \xB7 ")}`);
|
|
7363
|
+
break;
|
|
7364
|
+
}
|
|
7365
|
+
data[name] = v;
|
|
7366
|
+
break;
|
|
7367
|
+
}
|
|
7368
|
+
case "number": {
|
|
7369
|
+
const n = Number(v);
|
|
7370
|
+
if (typeof v === "boolean" || !Number.isFinite(n)) {
|
|
7371
|
+
errors.push(`${kind}.${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7372
|
+
break;
|
|
7373
|
+
}
|
|
7374
|
+
if (name === "progress" && (n < 0 || n > 1)) {
|
|
7375
|
+
errors.push(`${kind}.progress \uB294 0~1 \uBE44\uC728\uC774\uB2E4: ${n}`);
|
|
7376
|
+
break;
|
|
7377
|
+
}
|
|
7378
|
+
if ((name === "requested" || name === "fulfilled" || name === "goodCount" || name === "scrapCount") && n < 0) {
|
|
7379
|
+
errors.push(`${kind}.${name} \uAC00 \uC74C\uC218\uB2E4: ${n}`);
|
|
7380
|
+
break;
|
|
7381
|
+
}
|
|
7382
|
+
data[name] = n;
|
|
7383
|
+
break;
|
|
7384
|
+
}
|
|
7385
|
+
case "boolean": {
|
|
7386
|
+
if (typeof v !== "boolean") {
|
|
7387
|
+
errors.push(`${kind}.${name} \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7388
|
+
break;
|
|
7389
|
+
}
|
|
7390
|
+
data[name] = v;
|
|
7391
|
+
break;
|
|
7392
|
+
}
|
|
7393
|
+
case "string[]": {
|
|
7394
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
|
|
7395
|
+
errors.push(`${kind}.${name} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7396
|
+
break;
|
|
7397
|
+
}
|
|
7398
|
+
data[name] = v.slice();
|
|
7399
|
+
break;
|
|
7400
|
+
}
|
|
7401
|
+
case "object": {
|
|
7402
|
+
if (Array.isArray(v) || typeof v !== "object") {
|
|
7403
|
+
errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7404
|
+
break;
|
|
7405
|
+
}
|
|
7406
|
+
data[name] = { ...v };
|
|
7407
|
+
break;
|
|
7408
|
+
}
|
|
7409
|
+
case "object[]": {
|
|
7410
|
+
if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object")) {
|
|
7411
|
+
errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
7412
|
+
break;
|
|
7413
|
+
}
|
|
7414
|
+
data[name] = v.map((x) => ({ ...x }));
|
|
7415
|
+
break;
|
|
7416
|
+
}
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
7419
|
+
const at = String(r.at ?? "").trim() || opts.defaultEventTime;
|
|
7420
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
7421
|
+
if (!Number.isFinite(atMs)) {
|
|
7422
|
+
errors.push(`${kind}: at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC2DC\uAC01 \uC5C6\uC774\uB294 \uB2A6\uAC8C \uC628 \uC61B \uC0AC\uC2E4\uC744 \uAC78\uB7EC\uB0BC \uC218 \uC5C6\uB2E4`);
|
|
7423
|
+
}
|
|
7424
|
+
if (errors.length) {
|
|
7425
|
+
rejected.push({ record, errors });
|
|
7426
|
+
continue;
|
|
7427
|
+
}
|
|
7428
|
+
accepted.push({
|
|
7429
|
+
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
7430
|
+
eventType: spec.eventType,
|
|
7431
|
+
eventTime: new Date(atMs).toISOString(),
|
|
7432
|
+
tenantId: opts.tenantId,
|
|
7433
|
+
data
|
|
7434
|
+
});
|
|
7435
|
+
}
|
|
7436
|
+
return { accepted, rejected };
|
|
7437
|
+
}
|
|
7438
|
+
|
|
7123
7439
|
// src/energy-attribution.ts
|
|
7124
7440
|
var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
|
7125
7441
|
function attributeEnergy(opts) {
|
|
@@ -7304,7 +7620,7 @@ function electricityCost(input) {
|
|
|
7304
7620
|
}
|
|
7305
7621
|
|
|
7306
7622
|
// src/vocabulary.ts
|
|
7307
|
-
var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
|
|
7623
|
+
var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
|
|
7308
7624
|
var VOCABULARY_EXCEPTIONS = [
|
|
7309
7625
|
/* ── 저널 와이어 필드 — append-only 역사이므로 이름을 바꾸지 않는다 ───────
|
|
7310
7626
|
* 같은 사실이 시점에 따라 다른 키로 들어가면 낡은 이름보다 나쁘다. 개명은 이벤트 스키마
|
|
@@ -7445,10 +7761,12 @@ function retiredVocabularyIn(line) {
|
|
|
7445
7761
|
ingest,
|
|
7446
7762
|
ingestEnergyEquipmentRecords,
|
|
7447
7763
|
ingestEnergyRecords,
|
|
7764
|
+
ingestOperationalRecords,
|
|
7448
7765
|
isElectricalLocationType,
|
|
7449
7766
|
isEnergyEquipmentRecord,
|
|
7450
7767
|
isEnergyRecord,
|
|
7451
7768
|
isEquipmentLevel,
|
|
7769
|
+
isOperationalRecord,
|
|
7452
7770
|
isoDurationHours,
|
|
7453
7771
|
itemKeyOf,
|
|
7454
7772
|
levelOfLocationType,
|
|
@@ -7462,6 +7780,7 @@ function retiredVocabularyIn(line) {
|
|
|
7462
7780
|
objectEvent,
|
|
7463
7781
|
offCalendarAt,
|
|
7464
7782
|
offCalendarReasonAt,
|
|
7783
|
+
operationalKindOf,
|
|
7465
7784
|
operationsCapabilityOf,
|
|
7466
7785
|
parseEpc,
|
|
7467
7786
|
parseIsoDuration,
|
package/package.json
CHANGED