@things-factory/headless-twin 10.0.17 → 10.0.18
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-server/engine/canonical-ingest.d.ts +12 -85
- package/dist-server/engine/canonical-ingest.js +92 -26
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/energy-topology.js +3 -3
- package/dist-server/engine/energy-topology.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +91 -3
- package/dist-server/engine/kpi-fold.js +149 -25
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +99 -11
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/local-declarations.js +6 -5
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/model-vocabulary.js +2 -2
- package/dist-server/engine/model-vocabulary.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +2 -1
- package/dist-server/engine/oee-accumulator.js +3 -2
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -2
- package/dist-server/engine/twin-engine.js +150 -4
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/routes.js +1 -1
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +1 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +11 -2
- package/dist-server/service/reference/reference-master.js +1 -1
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +2 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
- package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
- package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
- package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
- package/dist-server/service/twin-backfill/index.d.ts +2 -0
- package/dist-server/service/twin-backfill/index.js +6 -0
- package/dist-server/service/twin-backfill/index.js.map +1 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/name-index.js +1 -1
- package/dist-server/service/twin-model/name-index.js.map +1 -1
- package/dist-server/service/twin-model/project-structure.js +4 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +2 -2
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +4 -4
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -6
- package/server/engine/canonical-ingest.ts +72 -84
- package/server/engine/energy-topology.ts +1 -1
- package/server/engine/kpi-fold.ts +219 -36
- package/server/engine/kpi-query.ts +97 -4
- package/server/engine/local-declarations.ts +2 -1
- package/server/engine/model-vocabulary.ts +1 -1
- package/server/engine/oee-accumulator.ts +4 -2
- package/server/engine/twin-engine.ts +163 -5
- package/server/routes.ts +6 -1
- package/server/service/index.ts +3 -0
- package/server/service/reference/reference-live.ts +8 -2
- package/server/service/reference/reference-master.ts +12 -3
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
- package/server/service/twin-backfill/backfill-shape.ts +86 -0
- package/server/service/twin-backfill/index.ts +3 -0
- package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
- package/server/service/twin-model/name-index.ts +1 -1
- package/server/service/twin-model/project-structure.ts +3 -2
- package/server/service/twin-model/twin-equipment.ts +14 -2
- package/server/service/twin-model/twin-model-item-query.ts +1 -1
- package/server/service/twin-model/twin-model-query.ts +2 -2
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +1 -1
- package/shared/entity-delta.ts +1 -1
- package/test/axis-read.test.ts +4 -3
- package/test/backfill-shape.test.ts +89 -0
- package/test/broadcast-cost-baseline.test.ts +2 -1
- package/test/canonical-ingest-vocabularies.test.ts +82 -3
- package/test/capability-mapping.test.ts +1 -1
- package/test/contract-layer-guard.test.ts +48 -0
- package/test/declaration-reaches-model.test.ts +2 -0
- package/test/equipment-identity-reaches.test.ts +57 -0
- package/test/fact-scope-wiring.test.ts +96 -0
- package/test/generated-not-counted.test.ts +99 -0
- package/test/ingest-bench.test.ts +2 -1
- package/test/ingest-wiring-guard.test.ts +197 -0
- package/test/kpi-fold.test.ts +81 -0
- package/test/live-mirror-parity.test.ts +6 -3
- package/test/local-declarations.test.ts +3 -2
- package/test/master-to-twin.test.ts +2 -2
- package/test/oee-accumulator.test.ts +18 -3
- package/test/projection-reaches-screen.test.ts +5 -0
- package/test/projection-reads-declared.test.ts +109 -0
- package/test/property-effects.test.ts +7 -3
- package/test/scale-twin-bench.test.ts +2 -1
- package/test/vocabulary-guard.test.ts +1 -1
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,92 +1,19 @@
|
|
|
1
|
-
import { type EnergyRecord, type EnergyEquipmentRecord, type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/
|
|
2
|
-
/**
|
|
3
|
-
export
|
|
4
|
-
/**
|
|
5
|
-
* 개체 하나의 식별자. **`quantityList` 를 쓰면 없어도 된다** — 낱개 번호가 없는 자재(밀가루 3.5kg)는
|
|
6
|
-
* 개체가 없다. 둘 다 없으면 무엇을 관측했는지 말하지 않은 것이므로 커널 매핑이 거부한다.
|
|
7
|
-
*/
|
|
8
|
-
epc?: string;
|
|
9
|
-
/**
|
|
10
|
-
* 클래스와 수량 — `{ epcClass, quantity, uom? }`. **잔량 절대값을 싣는다**(「얼마 뺐다」가 아니라
|
|
11
|
-
* 「얼마 남았다」). 그래야 사건 하나를 놓쳐도 다음 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다.
|
|
12
|
-
*
|
|
13
|
-
* 담김(`childQuantityList`)이 하루 먼저 이 자리를 받았다 — 그때와 같은 이유다. 원본이 비직렬 수량을
|
|
14
|
-
* 말할 길이 없어서, 그것이 주된 경로인 시스템(식품 제조 MES)은 라이브를 낼 수 없었다. 커널의
|
|
15
|
-
* 관측 리듀서는 처음부터 이 값을 읽었고 문만 없었다.
|
|
16
|
-
*/
|
|
17
|
-
quantityList?: {
|
|
18
|
-
epcClass: string;
|
|
19
|
-
quantity?: number;
|
|
20
|
-
uom?: string;
|
|
21
|
-
}[];
|
|
22
|
-
action: string;
|
|
23
|
-
bizStep: string;
|
|
24
|
-
disposition?: string;
|
|
25
|
-
readPoint?: string;
|
|
26
|
-
bizLocation?: string;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* 담김 관측 — **부모와 자식**으로 말한다. 자리 필드는 개체 관측과 같은 이름이다(문자열).
|
|
30
|
-
*
|
|
31
|
-
* 이름은 표준의 것을 그대로 쓴다(`parentID`·`childEPCs`) — 여기서 다시 지으면 매핑이 번역이 되고,
|
|
32
|
-
* 같은 사실에 두 어휘가 생긴다.
|
|
33
|
-
*/
|
|
1
|
+
import { type CanonicalRecord, type CanonicalAggregationRecord, type CanonicalTransformationRecord, type EnergyRecord, type EnergyEquipmentRecord, type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/ops-contract';
|
|
2
|
+
/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */
|
|
3
|
+
export declare function ingestCanonicalRecords(records: CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord | MasterDataRecord | (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord | MasterDataRecord)[] | undefined | null, tenantId: string, defaultEventTime: string,
|
|
34
4
|
/**
|
|
35
|
-
*
|
|
5
|
+
* **이 사실들이 어느 트윈의 것인가** — 마감된 구간 사실의 이름에 쓰인다(커널 §`SubjectBasis`).
|
|
36
6
|
*
|
|
37
|
-
*
|
|
38
|
-
* 한
|
|
7
|
+
* 커널은 도메인으로 서므로 자기가 어느 트윈인지 모른다. 그런데 설비 번호·계량 지점 번호는 트윈
|
|
8
|
+
* 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 된다.
|
|
9
|
+
* 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳤다.
|
|
39
10
|
*
|
|
40
|
-
*
|
|
41
|
-
* 커넥터 낱말(`consumed`·`produced`)로 담으면 판정되지 않는다 — 커넥터가 정규 이름으로 담아 보내고
|
|
42
|
-
* 매핑은 그 안에서 값을 고른다.
|
|
43
|
-
*
|
|
44
|
-
* **없는 쪽은 필드를 만들지 않는다.** 빈 배열을 실으면 「없다」고 말하는 것이 된다. 소실(N→0)과
|
|
45
|
-
* 생성(0→N)도 표준이 인정하는 변환이다.
|
|
46
|
-
*
|
|
47
|
-
* `transformationID` 가 한 오더의 여러 단계를 잇는다 — 소비를 먼저 알고 산출을 나중에 알게 되는 원본에서
|
|
48
|
-
* 그 둘을 묶는 유일한 자리다.
|
|
11
|
+
* 주지 않으면 이름표를 그대로 쓰고 `twin-local` 로 표시된다 — 겹칠 수 있다는 뜻이다.
|
|
49
12
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
inputQuantityList?: {
|
|
55
|
-
epcClass: string;
|
|
56
|
-
quantity?: number;
|
|
57
|
-
uom?: string;
|
|
58
|
-
}[];
|
|
59
|
-
outputEPCList?: string[];
|
|
60
|
-
outputQuantityList?: {
|
|
61
|
-
epcClass: string;
|
|
62
|
-
quantity?: number;
|
|
63
|
-
uom?: string;
|
|
64
|
-
}[];
|
|
65
|
-
/** 한 오더의 여러 단계를 잇는 식별자. */
|
|
66
|
-
transformationID?: string;
|
|
67
|
-
bizStep: string;
|
|
68
|
-
disposition?: string;
|
|
69
|
-
readPoint?: string;
|
|
70
|
-
bizLocation?: string;
|
|
71
|
-
}
|
|
72
|
-
export interface CanonicalAggregationRecord {
|
|
73
|
-
parentID: string;
|
|
74
|
-
/** 개체로 담긴 자식. 수량으로만 담겼으면 없다(둘 중 하나는 있어야 한다). */
|
|
75
|
-
childEPCs?: string[];
|
|
76
|
-
/** 수량으로 담긴 자식 — `{ epcClass, quantity, uom? }`. 값의 옳고 그름은 커널 검증이 본다. */
|
|
77
|
-
childQuantityList?: {
|
|
78
|
-
epcClass: string;
|
|
79
|
-
quantity?: number;
|
|
80
|
-
uom?: string;
|
|
81
|
-
}[];
|
|
82
|
-
action: string;
|
|
83
|
-
bizStep: string;
|
|
84
|
-
disposition?: string;
|
|
85
|
-
readPoint?: string;
|
|
86
|
-
bizLocation?: string;
|
|
87
|
-
}
|
|
88
|
-
/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */
|
|
89
|
-
export declare function ingestCanonicalRecords(records: CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord | MasterDataRecord | (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord | MasterDataRecord)[] | undefined | null, tenantId: string, defaultEventTime: string): CanonicalIngestResult;
|
|
13
|
+
scope?: {
|
|
14
|
+
scopeId?: string;
|
|
15
|
+
identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined;
|
|
16
|
+
}): CanonicalIngestResult;
|
|
90
17
|
/**
|
|
91
18
|
* 유입 결과 — 사건(봉투)과 **사건이 아닌 것**을 갈라 낸다.
|
|
92
19
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ingestCanonicalRecords = ingestCanonicalRecords;
|
|
4
|
-
const
|
|
4
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
5
5
|
/*
|
|
6
6
|
* 정규 레코드 → 커널 검증 ACL → CanonicalEnvelope (P1 seam 코어).
|
|
7
7
|
* 순수(커널만 의존; shell·integration-base 무의존 → node:test 검증 가능).
|
|
@@ -11,6 +11,7 @@ const twin_kernel_1 = require("@operato/twin-kernel");
|
|
|
11
11
|
* 에너지 계량 { meterId, kW?, kWh?, … } · 설비 에너지 { equipmentId, generatedKW?, soc?, … }
|
|
12
12
|
* 발전 적산 { equipmentId, kWh, kWhSince?, … }
|
|
13
13
|
* 사용 구간 { meterId, from, to, kWh, unitPrice?, basis?, … } · 청구서 { from, to, total?, … }
|
|
14
|
+
* 요금 기준 { from, to, billingDemandKW?, demandChargePerKW?, currency }
|
|
14
15
|
* 담김(적재) { parentID, childEPCs[] | childQuantityList[], action, bizStep, … }
|
|
15
16
|
* 운영 사실 { taskId | moverId | personId | assetId | orderId, status, … } // vocabulary-guard: allow 저널 와이어 필드
|
|
16
17
|
* 커널은 objectEvent 구성 + EPCIS 검증(위반=오염 차단) + 봉투 래핑만(방언 없음).
|
|
@@ -109,8 +110,28 @@ const TWIN_INGEST_RULES = [
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
];
|
|
113
|
+
/*
|
|
114
|
+
* 정규 레코드 셋(`CanonicalRecord` · `CanonicalTransformationRecord` · `CanonicalAggregationRecord`)은
|
|
115
|
+
* **계약으로 옮겼다**(2026-08-30, `@operato/ops-contract` 0.2.0).
|
|
116
|
+
*
|
|
117
|
+
* 여기 있던 이유는 소비 방식 때문이었다 — EPCIS 는 커널의 범용 어댑터를 지나서 타입을 요구하지 않았다.
|
|
118
|
+
* 그런데 모양 자체는 EPCIS 2.0 의 필드 이름이고 호스트의 것이 아니다. 이제 MES 가 이 모양 그대로
|
|
119
|
+
* 보낸다.
|
|
120
|
+
*
|
|
121
|
+
* **매핑 룰(`TWIN_INGEST_RULES`)은 여기 남는다** — 이 모양을 EPCIS 사건으로 옮기는 정책이지 모양이 아니다.
|
|
122
|
+
*/
|
|
112
123
|
/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */
|
|
113
|
-
function ingestCanonicalRecords(records, tenantId, defaultEventTime
|
|
124
|
+
function ingestCanonicalRecords(records, tenantId, defaultEventTime,
|
|
125
|
+
/**
|
|
126
|
+
* **이 사실들이 어느 트윈의 것인가** — 마감된 구간 사실의 이름에 쓰인다(커널 §`SubjectBasis`).
|
|
127
|
+
*
|
|
128
|
+
* 커널은 도메인으로 서므로 자기가 어느 트윈인지 모른다. 그런데 설비 번호·계량 지점 번호는 트윈
|
|
129
|
+
* 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 된다.
|
|
130
|
+
* 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳤다.
|
|
131
|
+
*
|
|
132
|
+
* 주지 않으면 이름표를 그대로 쓰고 `twin-local` 로 표시된다 — 겹칠 수 있다는 뜻이다.
|
|
133
|
+
*/
|
|
134
|
+
scope) {
|
|
114
135
|
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
115
136
|
/*
|
|
116
137
|
* ── **다섯째 어휘는 사건이 아니다** (2026-08-28) ────────────────────────────
|
|
@@ -127,8 +148,8 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
127
148
|
*
|
|
128
149
|
* 설계: `operato-twin/design/plans/master-data.md`
|
|
129
150
|
*/
|
|
130
|
-
const masterCandidates = arr.filter(
|
|
131
|
-
const master = masterCandidates.length ? (0,
|
|
151
|
+
const masterCandidates = arr.filter(ops_contract_1.isMasterDataRecord);
|
|
152
|
+
const master = masterCandidates.length ? (0, ops_contract_1.ingestMasterData)(masterCandidates) : { accepted: [], rejected: [] };
|
|
132
153
|
/*
|
|
133
154
|
* **어휘가 넷이다** — 물류(EPCIS)·에너지 계량·설비 에너지·운영 사실. 예전에는 이 함수가 무조건
|
|
134
155
|
* `ObjectEvent` 를 만들었고, 그래서 계측을 이 길로 넣으면 `epc` 가 없어 거부되거나(그나마 나은 쪽)
|
|
@@ -149,9 +170,26 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
149
170
|
* 커널이 구간 전력량을 차분으로 구하는데 우리가 넣은 값이 이미 구간의 양이다 — 차분의 차분이 되어
|
|
150
171
|
* 값이 조용히 틀린다. 거부되지도 않는다.
|
|
151
172
|
*/
|
|
152
|
-
const usagePeriods = arr.filter(
|
|
153
|
-
const bills = arr.filter(
|
|
154
|
-
|
|
173
|
+
const usagePeriods = arr.filter(ops_contract_1.isEnergyUsagePeriodRecord);
|
|
174
|
+
const bills = arr.filter(ops_contract_1.isEnergyBillRecord);
|
|
175
|
+
/*
|
|
176
|
+
* 여덟째 어휘 — **그 주기에 적용되는 요금 기준**. 커널 0.7.76 이 이 문을 열었다.
|
|
177
|
+
*
|
|
178
|
+
* ★ 청구서와 겹친다. 청구서가 `billingDemandKW` 를 실을 수 있고, 그러면 두 판정이 다 참이다.
|
|
179
|
+
* 그때 요금 기준 문으로 보내면 「금액은 이 문이 받지 않는다」로 거부된다 — 정산이 통째로
|
|
180
|
+
* 사라진다. 그래서 청구서인 것을 뺀다. 커널에서 배타로 만들어 달라고 올렸다.
|
|
181
|
+
*/
|
|
182
|
+
const tariffBases = arr.filter(r => (0, ops_contract_1.isEnergyTariffBasisRecord)(r) && !(0, ops_contract_1.isEnergyBillRecord)(r));
|
|
183
|
+
/*
|
|
184
|
+
* 아홉째 어휘 — **발전 단가**. 낸 것에 매겨지는 1kWh 당 값이고 날마다 바뀐다(커널 0.7.79).
|
|
185
|
+
*
|
|
186
|
+
* 다른 문과 겹치지 않는다: 계량 지점이나 설비 id 가 있으면 이 문이 아니고(커널이 배타로 막는다),
|
|
187
|
+
* 요금적용전력·기본요금 단가가 없으므로 요금 기준도 아니다.
|
|
188
|
+
*/
|
|
189
|
+
const generationPrices = arr.filter(ops_contract_1.isEnergyGenerationPriceRecord);
|
|
190
|
+
/* 마감된 발전 기간 — 지난 기록 채우기가 쓴다. 적산의 문과 배타다(구간이 있으면 이쪽). */
|
|
191
|
+
const generationPeriods = arr.filter(ops_contract_1.isEnergyGenerationPeriodRecord);
|
|
192
|
+
const energy = arr.filter(r => (0, ops_contract_1.isEnergyRecord)(r) && !(0, ops_contract_1.isEnergyUsagePeriodRecord)(r));
|
|
155
193
|
/* 세 번째 어휘 — 설비가 낸 자기 에너지 상태(발전·저장·감축 여지·개폐 위치). 계량과 갈라 두는
|
|
156
194
|
이유는 커널에 적혀 있다: 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */
|
|
157
195
|
/*
|
|
@@ -165,24 +203,37 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
165
203
|
* 값으로 세지 않아 「바꿀 상태가 하나도 없다」로 돌려보낸다. 커넥터는 보냈다고 믿고, 화면에는
|
|
166
204
|
* 발전량이 없다.
|
|
167
205
|
*/
|
|
168
|
-
const energyGeneration = arr.filter(
|
|
169
|
-
const energyEquipment = arr.filter(r => (0,
|
|
206
|
+
const energyGeneration = arr.filter(ops_contract_1.isEnergyGenerationRecord);
|
|
207
|
+
const energyEquipment = arr.filter(r => (0, ops_contract_1.isEnergyEquipmentRecord)(r) && !(0, ops_contract_1.isEnergyGenerationRecord)(r));
|
|
170
208
|
/*
|
|
171
209
|
* 네 번째 어휘 — **운영 사실**(작업·설비·사람·자산·오더·품질). 관측 리듀서는 이것을 오래전부터
|
|
172
210
|
* 계산했는데 **들어올 문이 없었다**: 원본이 「이 작업이 끝났다」를 말할 길이 없어 시뮬 커널만 그것을
|
|
173
211
|
* 아는 상태가 남았다(같은 화면이 두 구동에서 다른 것을 말한다).
|
|
174
212
|
*/
|
|
175
|
-
const operational = arr.filter(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
213
|
+
const operational = arr.filter(ops_contract_1.isOperationalRecord);
|
|
214
|
+
/*
|
|
215
|
+
* ── 이 목록은 **어휘를 더할 때마다 함께 늘어야 한다** (2026-08-30) ─────────
|
|
216
|
+
*
|
|
217
|
+
* EPCIS 갈래는 「나머지 전부」다. 새 어휘를 빼 주지 않으면 그 레코드가 제 문으로 가면서
|
|
218
|
+
* **EPCIS 문으로도 들어가** 거부된다. 받아들여지기도 하고 거부되기도 하는 상태가 되어,
|
|
219
|
+
* 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로 읽는다.
|
|
220
|
+
*
|
|
221
|
+
* 실제로 발전 단가를 더할 때 그렇게 됐다. 목록을 손으로 지키는 대신 시험이 지킨다
|
|
222
|
+
* (§`canonical-ingest-vocabularies` — 어휘마다 한 문으로만 가는지).
|
|
223
|
+
*/
|
|
224
|
+
const epcis = arr.filter(r => !(0, ops_contract_1.isEnergyRecord)(r) &&
|
|
225
|
+
!(0, ops_contract_1.isEnergyEquipmentRecord)(r) &&
|
|
226
|
+
!(0, ops_contract_1.isEnergyGenerationRecord)(r) &&
|
|
227
|
+
!(0, ops_contract_1.isEnergyUsagePeriodRecord)(r) &&
|
|
228
|
+
!(0, ops_contract_1.isEnergyBillRecord)(r) &&
|
|
229
|
+
!(0, ops_contract_1.isEnergyTariffBasisRecord)(r) &&
|
|
230
|
+
!(0, ops_contract_1.isEnergyGenerationPriceRecord)(r) &&
|
|
231
|
+
!(0, ops_contract_1.isEnergyGenerationPeriodRecord)(r) &&
|
|
232
|
+
!(0, ops_contract_1.isOperationalRecord)(r) &&
|
|
233
|
+
!(0, ops_contract_1.isMasterDataRecord)(r));
|
|
183
234
|
/* 품목 어휘 안에서도 갈림이 있다(개체 관측 / 담김) — 그 판정도 커널의 것을 부른다. */
|
|
184
235
|
const epcisResult = epcis.length
|
|
185
|
-
? (0,
|
|
236
|
+
? (0, ops_contract_1.ingest)(epcis.map(r => ({
|
|
186
237
|
...r,
|
|
187
238
|
/*
|
|
188
239
|
* ── **시각의 이름을 둘 흡수한다** (2026-08-24 실측) ────────────────────
|
|
@@ -204,9 +255,9 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
204
255
|
? { eventTime: r.eventTime ?? r.at }
|
|
205
256
|
: {}),
|
|
206
257
|
/* 판정은 커널이 한 곳에서 한다 — 여기서 다시 짐작하면 소비처마다 답이 달라진다. */
|
|
207
|
-
sourceType: (0,
|
|
258
|
+
sourceType: (0, ops_contract_1.isTransformationRecord)(r)
|
|
208
259
|
? 'twin-transformation'
|
|
209
|
-
: (0,
|
|
260
|
+
: (0, ops_contract_1.isAggregationRecord)(r)
|
|
210
261
|
? 'twin-aggregation'
|
|
211
262
|
: 'twin'
|
|
212
263
|
})), TWIN_INGEST_RULES,
|
|
@@ -226,19 +277,28 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
226
277
|
*/
|
|
227
278
|
{ tenantId, defaultEventTime, eventTimePath: 'eventTime' })
|
|
228
279
|
: { accepted: [], rejected: [] };
|
|
229
|
-
const energyResult = energy.length ? (0,
|
|
280
|
+
const energyResult = energy.length ? (0, ops_contract_1.ingestEnergyRecords)(energy, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] };
|
|
230
281
|
const equipmentResult = energyEquipment.length
|
|
231
|
-
? (0,
|
|
282
|
+
? (0, ops_contract_1.ingestEnergyEquipmentRecords)(energyEquipment, { tenantId, defaultEventTime, ...scope })
|
|
232
283
|
: { accepted: [], rejected: [] };
|
|
233
284
|
const usageResult = usagePeriods.length
|
|
234
|
-
? (0,
|
|
285
|
+
? (0, ops_contract_1.ingestEnergyUsagePeriodRecords)(usagePeriods, { tenantId, defaultEventTime, ...scope })
|
|
286
|
+
: { accepted: [], rejected: [] };
|
|
287
|
+
const billResult = bills.length ? (0, ops_contract_1.ingestEnergyBillRecords)(bills, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] };
|
|
288
|
+
const tariffBasisResult = tariffBases.length
|
|
289
|
+
? (0, ops_contract_1.ingestEnergyTariffBasisRecords)(tariffBases, { tenantId, defaultEventTime, ...scope })
|
|
290
|
+
: { accepted: [], rejected: [] };
|
|
291
|
+
const generationPeriodResult = generationPeriods.length
|
|
292
|
+
? (0, ops_contract_1.ingestEnergyGenerationPeriodRecords)(generationPeriods, { tenantId, defaultEventTime, ...scope })
|
|
293
|
+
: { accepted: [], rejected: [] };
|
|
294
|
+
const generationPriceResult = generationPrices.length
|
|
295
|
+
? (0, ops_contract_1.ingestEnergyGenerationPriceRecords)(generationPrices, { tenantId, defaultEventTime, ...scope })
|
|
235
296
|
: { accepted: [], rejected: [] };
|
|
236
|
-
const billResult = bills.length ? (0, twin_kernel_1.ingestEnergyBillRecords)(bills, { tenantId, defaultEventTime }) : { accepted: [], rejected: [] };
|
|
237
297
|
const generationResult = energyGeneration.length
|
|
238
|
-
? (0,
|
|
298
|
+
? (0, ops_contract_1.ingestEnergyGenerationRecords)(energyGeneration, { tenantId, defaultEventTime, ...scope })
|
|
239
299
|
: { accepted: [], rejected: [] };
|
|
240
300
|
const operationalResult = operational.length
|
|
241
|
-
? (0,
|
|
301
|
+
? (0, ops_contract_1.ingestOperationalRecords)(operational, { tenantId, defaultEventTime, ...scope })
|
|
242
302
|
: { accepted: [], rejected: [] };
|
|
243
303
|
/* 거부분을 합쳐 돌려준다 — 어느 어휘에서 거부됐는지는 이유 문구가 말한다. */
|
|
244
304
|
return {
|
|
@@ -248,7 +308,10 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
248
308
|
...equipmentResult.accepted,
|
|
249
309
|
...generationResult.accepted,
|
|
250
310
|
...usageResult.accepted,
|
|
311
|
+
...generationPriceResult.accepted,
|
|
312
|
+
...generationPeriodResult.accepted,
|
|
251
313
|
...billResult.accepted,
|
|
314
|
+
...tariffBasisResult.accepted,
|
|
252
315
|
...operationalResult.accepted
|
|
253
316
|
],
|
|
254
317
|
rejected: [
|
|
@@ -258,6 +321,9 @@ function ingestCanonicalRecords(records, tenantId, defaultEventTime) {
|
|
|
258
321
|
...generationResult.rejected,
|
|
259
322
|
...usageResult.rejected,
|
|
260
323
|
...billResult.rejected,
|
|
324
|
+
...tariffBasisResult.rejected,
|
|
325
|
+
...generationPriceResult.rejected,
|
|
326
|
+
...generationPeriodResult.rejected,
|
|
261
327
|
...operationalResult.rejected,
|
|
262
328
|
...master.rejected
|
|
263
329
|
],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"canonical-ingest.js","sourceRoot":"","sources":["../../server/engine/canonical-ingest.ts"],"names":[],"mappings":"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","sourcesContent":["import { ingest, ingestEnergyRecords, ingestEnergyEquipmentRecords, ingestEnergyGenerationRecords,\n ingestEnergyUsagePeriodRecords, ingestEnergyBillRecords, ingestOperationalRecords,\n isAggregationRecord, isTransformationRecord,\n isEnergyRecord, isEnergyEquipmentRecord, isEnergyGenerationRecord, isEnergyUsagePeriodRecord, isEnergyBillRecord,\n isOperationalRecord, type AdapterRule, type EnergyRecord,\n isMasterDataRecord, ingestMasterData,\n type EnergyEquipmentRecord, type EnergyGenerationRecord, type EnergyUsagePeriodRecord, type EnergyBillRecord,\n type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/twin-kernel'\n\n/*\n * 정규 레코드 → 커널 검증 ACL → CanonicalEnvelope (P1 seam 코어).\n * 순수(커널만 의존; shell·integration-base 무의존 → node:test 검증 가능).\n * 매핑(레거시 스키마 → 정규)은 이 앞 단계(jsonata step 또는 커넥터 openLiveFeed)가 끝낸다.\n * 여기 도착하는 레코드는 이미 정규 필드다 — 어휘마다 자기 필드 이름이 계약이다:\n * EPCIS 품목 { epc, action, bizStep, disposition?, readPoint?, bizLocation? }\n * 에너지 계량 { meterId, kW?, kWh?, … } · 설비 에너지 { equipmentId, generatedKW?, soc?, … }\n * 발전 적산 { equipmentId, kWh, kWhSince?, … }\n * 사용 구간 { meterId, from, to, kWh, unitPrice?, basis?, … } · 청구서 { from, to, total?, … }\n * 담김(적재) { parentID, childEPCs[] | childQuantityList[], action, bizStep, … }\n * 운영 사실 { taskId | moverId | personId | assetId | orderId, status, … } // vocabulary-guard: allow 저널 와이어 필드\n * 커널은 objectEvent 구성 + EPCIS 검증(위반=오염 차단) + 봉투 래핑만(방언 없음).\n *\n * 두 소비처가 이 단일 코어를 공유(중복 금지):\n * - sim/scenario 경로: operato-twin twin-ingest 태스크(http-get→jsonata→여기).\n * - live 커넥터 경로: reference-live(어댑터 openLiveFeed→여기).\n * SoT: design/integration/face2-adapters.md §5(seam)·§7(매핑=밖, 검증=커널).\n */\n\n/** 고정 정규 매핑(방언 아님 — 필드명 자체가 정규 계약). `sourceType` 으로 어느 품목 사실인지 고른다. */\nconst TWIN_INGEST_RULES: AdapterRule[] = [\n {\n sourceType: 'twin',\n mapping: {\n type: 'ObjectEvent',\n action: '$.action',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n epc: '$.epc',\n /* 비직렬 수량 — 개체 없이 「이 자리에 이 품목이 얼마 남았다」만 오는 관측이 있다. */\n quantityList: '$.quantityList',\n /*\n * **개체·로트의 마스터데이터**(로트 번호·소비기한 등) — 커널 0.7.58 에서 이 자리가 열렸다.\n *\n * 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난\n * 재고 950건·59,785kg 이 어디에도 나타나지 않았고, 빈 화면이 「이상 없음」으로 읽혔다.\n *\n * ★ **표준이 `action: 'ADD'` 일 때만 허용한다**(또는 변환). 마스터데이터는 로트가 **생길 때**\n * 말하는 사실이고, 주기 관측마다 실으면 커널이 거부한다 — 알리지 않고 통과시키지 않는 것이 옳다\n * (커넥터가 첫 목격에 `ADD`, 그 뒤로 `OBSERVE` 를 내야 한다).\n */\n ilmd: '$.ilmd',\n /*\n * **정정 선언** — 「이 사건은 없던 일이 되었다」(커널 0.7.65 에서 이 자리가 열렸다).\n *\n * 세 값이 함께여야 뜻이 있어 묶음으로 온다. 커널이 선언 시각을 요구하고, 없으면 사건 시각으로\n * 메우지 않고 이유와 함께 돌려준다 — 「언제 정정했나」와 「언제 일어났나」가 같아지면 둘을 다시\n * 갈라낼 수 없다.\n *\n * 상태는 이것으로 흔들리지 않는다. 커널은 정정을 목록에 적고 재고를 그대로 둔다 — 무엇을 어떻게\n * 되돌릴지는 도메인 판단이고, 참 재고는 그 뒤의 잔량 관측이 다시 말해 준다.\n */\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n },\n {\n /* **변환의 사실** — 무엇으로 무엇이 되었나. 이름은 표준의 것을 그대로 쓴다(갈래 판정이 그 이름을 본다). */\n sourceType: 'twin-transformation',\n mapping: {\n type: 'TransformationEvent',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n inputEPCList: '$.inputEPCList',\n inputQuantityList: '$.inputQuantityList',\n outputEPCList: '$.outputEPCList',\n outputQuantityList: '$.outputQuantityList',\n transformationID: '$.transformationID',\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n },\n {\n /*\n * **담김의 사실** — 무엇이 무엇에 실렸나(팔레트·상자). 오래 비어 있던 자리다: 관측 리듀서는 이 사실을\n * 받으면 자식 물품에 `parent` 를 붙이는데, 문이 `ObjectEvent` 하나만 알아서 원본은 적재를 말할 길이\n * 없었다(시뮬 원본을 붙여 돌려 보고서야 로그로 드러났다).\n */\n sourceType: 'twin-aggregation',\n mapping: {\n type: 'AggregationEvent',\n action: '$.action',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n parentID: '$.parentID',\n childEPCs: '$.childEPCs',\n /* 낱개 식별자 없이 **수량으로** 담긴 자식(「이 품번 40개」) — 입고·포장이 이 모양으로 온다. */\n childQuantityList: '$.childQuantityList',\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n }\n]\n\n/** 개체 관측 — 물건 하나가 어디서 무엇이 됐나. */\nexport interface CanonicalRecord {\n /**\n * 개체 하나의 식별자. **`quantityList` 를 쓰면 없어도 된다** — 낱개 번호가 없는 자재(밀가루 3.5kg)는\n * 개체가 없다. 둘 다 없으면 무엇을 관측했는지 말하지 않은 것이므로 커널 매핑이 거부한다.\n */\n epc?: string\n /**\n * 클래스와 수량 — `{ epcClass, quantity, uom? }`. **잔량 절대값을 싣는다**(「얼마 뺐다」가 아니라\n * 「얼마 남았다」). 그래야 사건 하나를 놓쳐도 다음 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다.\n *\n * 담김(`childQuantityList`)이 하루 먼저 이 자리를 받았다 — 그때와 같은 이유다. 원본이 비직렬 수량을\n * 말할 길이 없어서, 그것이 주된 경로인 시스템(식품 제조 MES)은 라이브를 낼 수 없었다. 커널의\n * 관측 리듀서는 처음부터 이 값을 읽었고 문만 없었다.\n */\n quantityList?: { epcClass: string; quantity?: number; uom?: string }[]\n action: string // ADD | OBSERVE | DELETE\n bizStep: string\n disposition?: string\n readPoint?: string\n bizLocation?: string\n}\n\n/**\n * 담김 관측 — **부모와 자식**으로 말한다. 자리 필드는 개체 관측과 같은 이름이다(문자열).\n *\n * 이름은 표준의 것을 그대로 쓴다(`parentID`·`childEPCs`) — 여기서 다시 지으면 매핑이 번역이 되고,\n * 같은 사실에 두 어휘가 생긴다.\n */\n/**\n * 변환 관측 — **무엇으로 무엇이 되었나.** 입력과 출력이 **한 사실에 함께** 있어야 계보가 남는다.\n *\n * 따로 보내면(「이 로트가 없어졌다」와 「이 제품이 생겼다」) 그 둘을 이을 근거가 없다. 그래서 표준이\n * 한 사건에 둘을 담고, 우리 레코드도 같은 모양이다.\n *\n * 이름은 **표준의 것을 그대로** 쓴다(`inputQuantityList` 등). 갈래 판정이 이 이름으로 이루어지므로\n * 커넥터 낱말(`consumed`·`produced`)로 담으면 판정되지 않는다 — 커넥터가 정규 이름으로 담아 보내고\n * 매핑은 그 안에서 값을 고른다.\n *\n * **없는 쪽은 필드를 만들지 않는다.** 빈 배열을 실으면 「없다」고 말하는 것이 된다. 소실(N→0)과\n * 생성(0→N)도 표준이 인정하는 변환이다.\n *\n * `transformationID` 가 한 오더의 여러 단계를 잇는다 — 소비를 먼저 알고 산출을 나중에 알게 되는 원본에서\n * 그 둘을 묶는 유일한 자리다.\n */\nexport interface CanonicalTransformationRecord {\n /** 개체로 소비된 것. */\n inputEPCList?: string[]\n /** 클래스와 수량으로 소비된 것 — 로트 단위 소비가 이 자리다. */\n inputQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]\n outputEPCList?: string[]\n outputQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]\n /** 한 오더의 여러 단계를 잇는 식별자. */\n transformationID?: string\n bizStep: string\n disposition?: string\n readPoint?: string\n bizLocation?: string\n}\n\nexport interface CanonicalAggregationRecord {\n parentID: string\n /** 개체로 담긴 자식. 수량으로만 담겼으면 없다(둘 중 하나는 있어야 한다). */\n childEPCs?: string[]\n /** 수량으로 담긴 자식 — `{ epcClass, quantity, uom? }`. 값의 옳고 그름은 커널 검증이 본다. */\n childQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]\n action: string // ADD(담다) | DELETE(풀다) | OBSERVE\n bizStep: string\n disposition?: string\n readPoint?: string\n bizLocation?: string\n}\n\n/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */\nexport function ingestCanonicalRecords(\n records:\n | CanonicalRecord\n | CanonicalAggregationRecord\n | CanonicalTransformationRecord\n | EnergyRecord\n | EnergyEquipmentRecord\n | OperationalRecord\n /* 다섯째 어휘 — 사건이 아닌 것(변하지 않는 속성). 저널로 가지 않는다(§`CanonicalIngestResult`). */\n | MasterDataRecord\n | (\n | CanonicalRecord\n | CanonicalAggregationRecord\n | CanonicalTransformationRecord\n | EnergyRecord\n | EnergyEquipmentRecord\n | OperationalRecord\n | MasterDataRecord\n )[]\n | undefined\n | null,\n tenantId: string,\n defaultEventTime: string\n): CanonicalIngestResult {\n const arr = Array.isArray(records) ? records : records ? [records] : []\n /*\n * ── **다섯째 어휘는 사건이 아니다** (2026-08-28) ────────────────────────────\n *\n * 마스터데이터 — 변하지 않는 속성(유통기한·로트번호·자리의 성질). 표준은 이것을 사건이 아니라\n * **어휘 요소의 속성**으로 담고, 커널에 그 자리가 없어서 지금까지 사건에 실려 왔다.\n *\n * 그 결과가 유입에서 이렇게 났다. 전량을 읽는 원본이 유통기한을 실으려고 **들어오지 않은 것을\n * `ADD`** 라고 말했고, `ilmd` 가 사건 단위라 자리별로 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에\n * 2,665건이다.\n *\n * 그래서 여기서 갈라 낸다. 이것은 봉투가 되지 않고 저널에도 적히지 않는다(시각축이 없는 값이다).\n * 갈래 판정은 커널의 것을 부른다 — 다른 넷과 같은 규율이다.\n *\n * 설계: `operato-twin/design/plans/master-data.md`\n */\n const masterCandidates = arr.filter(isMasterDataRecord)\n const master = masterCandidates.length ? ingestMasterData(masterCandidates) : { accepted: [], rejected: [] }\n /*\n * **어휘가 넷이다** — 물류(EPCIS)·에너지 계량·설비 에너지·운영 사실. 예전에는 이 함수가 무조건\n * `ObjectEvent` 를 만들었고, 그래서 계측을 이 길로 넣으면 `epc` 가 없어 거부되거나(그나마 나은 쪽)\n * 엉뚱한 물품 관측이 됐다.\n *\n * 물류 어휘 안에는 모양이 둘이다(개체 관측 `epc` / 담김 `parentID`+`childEPCs`).\n *\n * 판정은 커널이 한 곳에서 한다(`isEnergyRecord`·`isOperationalRecord`·`isAggregationRecord`) — 여기서 다시 짐작하면\n * 소비처마다 답이 달라진다. 봉투는 같은 것을 쓰므로 아래 소비처(미러·저널·브로드캐스팅)는 이 갈림을 알\n * 필요가 없다.\n */\n /*\n * 여섯째·일곱째 어휘 — **마감된 사용 구간**과 **청구서**. 커널 0.7.72 가 이 문들을 열었다.\n *\n * ── ★ 사용 구간을 계량보다 **먼저** 가른다 ─────────────────────────────────\n * `isEnergyRecord` 는 `meterId` 가 있으면 참이므로 사용 구간에도 참이다. 순서를 정하지 않으면\n * 시간별 사용량이 계량 표본으로 들어가고, 그 문은 `kWh` 를 **적산 레지스터**로 읽는다. 그러면\n * 커널이 구간 전력량을 차분으로 구하는데 우리가 넣은 값이 이미 구간의 양이다 — 차분의 차분이 되어\n * 값이 조용히 틀린다. 거부되지도 않는다.\n */\n const usagePeriods = arr.filter(isEnergyUsagePeriodRecord) as unknown as EnergyUsagePeriodRecord[]\n const bills = arr.filter(isEnergyBillRecord) as unknown as EnergyBillRecord[]\n const energy = arr.filter(r => isEnergyRecord(r) && !isEnergyUsagePeriodRecord(r)) as EnergyRecord[]\n /* 세 번째 어휘 — 설비가 낸 자기 에너지 상태(발전·저장·감축 여지·개폐 위치). 계량과 갈라 두는\n 이유는 커널에 적혀 있다: 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */\n /*\n * 다섯 번째 어휘 — **발전 적산**(설비가 지금까지 만든 양). 커널 0.7.69 가 이 문을 열었다.\n *\n * ── ★ 설비 상태보다 **먼저** 가른다 ──────────────────────────────────────────\n * 두 판정이 겹친다. `isEnergyEquipmentRecord` 는 `equipmentId` 가 있고 물류·계량 어휘가 아니면\n * 참이므로 발전 적산도 함께 참이다. 그래서 순서가 갈림을 정한다.\n *\n * 순서를 잘못 두면 조용히 사라지는 것이 아니라 **엉뚱한 이유로 거부된다**: 설비 상태 문은 적산을\n * 값으로 세지 않아 「바꿀 상태가 하나도 없다」로 돌려보낸다. 커넥터는 보냈다고 믿고, 화면에는\n * 발전량이 없다.\n */\n const energyGeneration = arr.filter(isEnergyGenerationRecord) as unknown as EnergyGenerationRecord[]\n const energyEquipment = arr.filter(r => isEnergyEquipmentRecord(r) && !isEnergyGenerationRecord(r)) as unknown as EnergyEquipmentRecord[]\n /*\n * 네 번째 어휘 — **운영 사실**(작업·설비·사람·자산·오더·품질). 관측 리듀서는 이것을 오래전부터\n * 계산했는데 **들어올 문이 없었다**: 원본이 「이 작업이 끝났다」를 말할 길이 없어 시뮬 커널만 그것을\n * 아는 상태가 남았다(같은 화면이 두 구동에서 다른 것을 말한다).\n */\n const operational = arr.filter(isOperationalRecord) as OperationalRecord[]\n const epcis = arr.filter(\n r =>\n !isEnergyRecord(r) &&\n !isEnergyEquipmentRecord(r) &&\n !isEnergyGenerationRecord(r) &&\n !isEnergyUsagePeriodRecord(r) &&\n !isEnergyBillRecord(r) &&\n !isOperationalRecord(r) &&\n !isMasterDataRecord(r)\n ) as (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord)[]\n\n /* 품목 어휘 안에서도 갈림이 있다(개체 관측 / 담김) — 그 판정도 커널의 것을 부른다. */\n const epcisResult = epcis.length\n ? ingest(\n epcis.map(r => ({\n ...r,\n /*\n * ── **시각의 이름을 둘 흡수한다** (2026-08-24 실측) ────────────────────\n * `eventTimePath` 는 **한 이름**만 받는다. 호스트가 `'eventTime'` 으로 못박아 두었는데, 뒤에\n * 생긴 커넥터는 `at` 을 싣는다(에너지 채널이 이미 `at` 을 쓰고 있어 그 어휘를 따랐다).\n * 그래서 그 커넥터의 사건은 **전부 폴링 순간**으로 찍혔다 — 저널 18,150건이 그랬다.\n *\n * 그 결과 둘이 함께 망가졌다: 재고 관측이 트윈의 시계를 밀지 못해 시계가 `task.status` 의\n * 최댓값(2026-04-15)에 **멈춰 있었고**, 이동 사건에서 「언제」가 사라져 순서만 남았다.\n *\n * 이름을 하나로 강요하지 않고 흡수한다 — `orderId`·`resourceRef`·`equipmentId` 를 함께 보는\n * 것과 같은 규율이다(§`twin-event-keys`). 세대가 섞이는 것은 이 이음새의 성질이다.\n *\n * **없으면 만들지 않는다**: 절대값 스냅샷 관측은 원본의 갱신 시각이 아니라 **폴링 순간**이\n * 맞는 관측 시각이다(「어제 값을 오늘 봤다」는 「어제 일어났다」가 아니다). 값을 말하는\n * 레코드만 자기 시각을 갖는다.\n */\n ...(((r as any).eventTime ?? (r as any).at) !== undefined\n ? { eventTime: (r as any).eventTime ?? (r as any).at }\n : {}),\n /* 판정은 커널이 한 곳에서 한다 — 여기서 다시 짐작하면 소비처마다 답이 달라진다. */\n sourceType: isTransformationRecord(r)\n ? 'twin-transformation'\n : isAggregationRecord(r)\n ? 'twin-aggregation'\n : 'twin'\n })),\n TWIN_INGEST_RULES,\n /*\n * **레코드가 시각을 말하면 그것을 쓴다** (2026-08-23).\n *\n * `eventTimePath` 를 주지 않던 동안 이 경로는 **언제나 폴링 시각**을 썼다(`defaultEventTime`).\n * 그래서 커넥터가 이미 싣고 있던 `eventTime`(변환 레코드의 `finishedAt` 등)이 **알리지 않고\n * 무시됐다** — 커넥터를 붙인 쪽은 그것이 먹는다고 믿고 있었고, 어디에도 오류가 나지 않았다.\n *\n * 무엇이 걸렸나: 계보(변환)의 시각이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다.\n * 그리고 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(§`kpi-fold`: `workMs = 완료 − 첫 착수`),\n * 폴링 간격을 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다.\n *\n * 레코드에 `eventTime` 이 없으면 커널이 알리지 않고 기본값으로 떨어진다(§`resolve` — 없는 값은\n * 오류가 아니다). 그래서 이 한 줄이 기존 경로를 막지 않는다: 시각을 말하는 레코드만 사실이 된다.\n */\n { tenantId, defaultEventTime, eventTimePath: 'eventTime' }\n )\n : { accepted: [], rejected: [] }\n const energyResult = energy.length ? ingestEnergyRecords(energy, { tenantId, defaultEventTime }) : { accepted: [], rejected: [] }\n const equipmentResult = energyEquipment.length\n ? ingestEnergyEquipmentRecords(energyEquipment, { tenantId, defaultEventTime })\n : { accepted: [], rejected: [] }\n const usageResult = usagePeriods.length\n ? ingestEnergyUsagePeriodRecords(usagePeriods, { tenantId, defaultEventTime })\n : { accepted: [], rejected: [] }\n const billResult = bills.length ? ingestEnergyBillRecords(bills, { tenantId, defaultEventTime }) : { accepted: [], rejected: [] }\n const generationResult = energyGeneration.length\n ? ingestEnergyGenerationRecords(energyGeneration, { tenantId, defaultEventTime })\n : { accepted: [], rejected: [] }\n const operationalResult = operational.length\n ? ingestOperationalRecords(operational, { tenantId, defaultEventTime })\n : { accepted: [], rejected: [] }\n\n /* 거부분을 합쳐 돌려준다 — 어느 어휘에서 거부됐는지는 이유 문구가 말한다. */\n return {\n accepted: [\n ...epcisResult.accepted,\n ...energyResult.accepted,\n ...equipmentResult.accepted,\n ...generationResult.accepted,\n ...usageResult.accepted,\n ...billResult.accepted,\n ...operationalResult.accepted\n ],\n rejected: [\n ...epcisResult.rejected,\n ...energyResult.rejected,\n ...equipmentResult.rejected,\n ...generationResult.rejected,\n ...usageResult.rejected,\n ...billResult.rejected,\n ...operationalResult.rejected,\n ...master.rejected\n ],\n /* 사건이 아닌 것은 따로 낸다 — 부르는 쪽이 상태 세우는 문으로 보낸다(저널로 가지 않게). */\n masterData: master.accepted\n }\n}\n\n/**\n * 유입 결과 — 사건(봉투)과 **사건이 아닌 것**을 갈라 낸다.\n *\n * 마스터데이터를 `accepted` 에 섞지 않는 이유: 부르는 쪽이 그것을 저널에 적게 되고, 시각축이 없는 값이\n * 「그때 일어난 일」로 남는다. 갈래를 타입으로 갈라 두면 그 실수를 할 수 없다.\n */\nexport interface CanonicalIngestResult extends IngestResult {\n masterData: MasterDataRecord[]\n}\n"]}
|
|
1
|
+
{"version":3,"file":"canonical-ingest.js","sourceRoot":"","sources":["../../server/engine/canonical-ingest.ts"],"names":[],"mappings":"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","sourcesContent":["import { type CanonicalRecord, type CanonicalAggregationRecord, type CanonicalTransformationRecord, type AdapterRule, type EnergyRecord, type EnergyEquipmentRecord, type EnergyGenerationRecord, type EnergyUsagePeriodRecord, type EnergyBillRecord, type EnergyTariffBasisRecord, type EnergyGenerationPriceRecord, type EnergyGenerationPeriodRecord, type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/ops-contract'\nimport { ingest, ingestEnergyRecords, ingestEnergyEquipmentRecords, ingestEnergyGenerationRecords, ingestEnergyUsagePeriodRecords, ingestEnergyBillRecords, ingestEnergyTariffBasisRecords, ingestEnergyGenerationPriceRecords, ingestEnergyGenerationPeriodRecords, ingestOperationalRecords, isAggregationRecord, isTransformationRecord, isEnergyRecord, isEnergyEquipmentRecord, isEnergyGenerationRecord, isEnergyUsagePeriodRecord, isEnergyBillRecord, isEnergyTariffBasisRecord, isEnergyGenerationPriceRecord, isEnergyGenerationPeriodRecord, isOperationalRecord, isMasterDataRecord, ingestMasterData } from '@operato/ops-contract'\n\n/*\n * 정규 레코드 → 커널 검증 ACL → CanonicalEnvelope (P1 seam 코어).\n * 순수(커널만 의존; shell·integration-base 무의존 → node:test 검증 가능).\n * 매핑(레거시 스키마 → 정규)은 이 앞 단계(jsonata step 또는 커넥터 openLiveFeed)가 끝낸다.\n * 여기 도착하는 레코드는 이미 정규 필드다 — 어휘마다 자기 필드 이름이 계약이다:\n * EPCIS 품목 { epc, action, bizStep, disposition?, readPoint?, bizLocation? }\n * 에너지 계량 { meterId, kW?, kWh?, … } · 설비 에너지 { equipmentId, generatedKW?, soc?, … }\n * 발전 적산 { equipmentId, kWh, kWhSince?, … }\n * 사용 구간 { meterId, from, to, kWh, unitPrice?, basis?, … } · 청구서 { from, to, total?, … }\n * 요금 기준 { from, to, billingDemandKW?, demandChargePerKW?, currency }\n * 담김(적재) { parentID, childEPCs[] | childQuantityList[], action, bizStep, … }\n * 운영 사실 { taskId | moverId | personId | assetId | orderId, status, … } // vocabulary-guard: allow 저널 와이어 필드\n * 커널은 objectEvent 구성 + EPCIS 검증(위반=오염 차단) + 봉투 래핑만(방언 없음).\n *\n * 두 소비처가 이 단일 코어를 공유(중복 금지):\n * - sim/scenario 경로: operato-twin twin-ingest 태스크(http-get→jsonata→여기).\n * - live 커넥터 경로: reference-live(어댑터 openLiveFeed→여기).\n * SoT: design/integration/face2-adapters.md §5(seam)·§7(매핑=밖, 검증=커널).\n */\n\n/** 고정 정규 매핑(방언 아님 — 필드명 자체가 정규 계약). `sourceType` 으로 어느 품목 사실인지 고른다. */\nconst TWIN_INGEST_RULES: AdapterRule[] = [\n {\n sourceType: 'twin',\n mapping: {\n type: 'ObjectEvent',\n action: '$.action',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n epc: '$.epc',\n /* 비직렬 수량 — 개체 없이 「이 자리에 이 품목이 얼마 남았다」만 오는 관측이 있다. */\n quantityList: '$.quantityList',\n /*\n * **개체·로트의 마스터데이터**(로트 번호·소비기한 등) — 커널 0.7.58 에서 이 자리가 열렸다.\n *\n * 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난\n * 재고 950건·59,785kg 이 어디에도 나타나지 않았고, 빈 화면이 「이상 없음」으로 읽혔다.\n *\n * ★ **표준이 `action: 'ADD'` 일 때만 허용한다**(또는 변환). 마스터데이터는 로트가 **생길 때**\n * 말하는 사실이고, 주기 관측마다 실으면 커널이 거부한다 — 알리지 않고 통과시키지 않는 것이 옳다\n * (커넥터가 첫 목격에 `ADD`, 그 뒤로 `OBSERVE` 를 내야 한다).\n */\n ilmd: '$.ilmd',\n /*\n * **정정 선언** — 「이 사건은 없던 일이 되었다」(커널 0.7.65 에서 이 자리가 열렸다).\n *\n * 세 값이 함께여야 뜻이 있어 묶음으로 온다. 커널이 선언 시각을 요구하고, 없으면 사건 시각으로\n * 메우지 않고 이유와 함께 돌려준다 — 「언제 정정했나」와 「언제 일어났나」가 같아지면 둘을 다시\n * 갈라낼 수 없다.\n *\n * 상태는 이것으로 흔들리지 않는다. 커널은 정정을 목록에 적고 재고를 그대로 둔다 — 무엇을 어떻게\n * 되돌릴지는 도메인 판단이고, 참 재고는 그 뒤의 잔량 관측이 다시 말해 준다.\n */\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n },\n {\n /* **변환의 사실** — 무엇으로 무엇이 되었나. 이름은 표준의 것을 그대로 쓴다(갈래 판정이 그 이름을 본다). */\n sourceType: 'twin-transformation',\n mapping: {\n type: 'TransformationEvent',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n inputEPCList: '$.inputEPCList',\n inputQuantityList: '$.inputQuantityList',\n outputEPCList: '$.outputEPCList',\n outputQuantityList: '$.outputQuantityList',\n transformationID: '$.transformationID',\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n },\n {\n /*\n * **담김의 사실** — 무엇이 무엇에 실렸나(팔레트·상자). 오래 비어 있던 자리다: 관측 리듀서는 이 사실을\n * 받으면 자식 물품에 `parent` 를 붙이는데, 문이 `ObjectEvent` 하나만 알아서 원본은 적재를 말할 길이\n * 없었다(시뮬 원본을 붙여 돌려 보고서야 로그로 드러났다).\n */\n sourceType: 'twin-aggregation',\n mapping: {\n type: 'AggregationEvent',\n action: '$.action',\n bizStep: '$.bizStep',\n disposition: '$.disposition',\n parentID: '$.parentID',\n childEPCs: '$.childEPCs',\n /* 낱개 식별자 없이 **수량으로** 담긴 자식(「이 품번 40개」) — 입고·포장이 이 모양으로 온다. */\n childQuantityList: '$.childQuantityList',\n errorDeclaration: {\n declarationTime: '$.errorDeclaration.declarationTime',\n reason: '$.errorDeclaration.reason',\n correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs'\n },\n readPoint: '$.readPoint',\n bizLocation: '$.bizLocation'\n }\n }\n]\n\n/*\n * 정규 레코드 셋(`CanonicalRecord` · `CanonicalTransformationRecord` · `CanonicalAggregationRecord`)은\n * **계약으로 옮겼다**(2026-08-30, `@operato/ops-contract` 0.2.0).\n *\n * 여기 있던 이유는 소비 방식 때문이었다 — EPCIS 는 커널의 범용 어댑터를 지나서 타입을 요구하지 않았다.\n * 그런데 모양 자체는 EPCIS 2.0 의 필드 이름이고 호스트의 것이 아니다. 이제 MES 가 이 모양 그대로\n * 보낸다.\n *\n * **매핑 룰(`TWIN_INGEST_RULES`)은 여기 남는다** — 이 모양을 EPCIS 사건으로 옮기는 정책이지 모양이 아니다.\n */\n\n/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */\nexport function ingestCanonicalRecords(\n records:\n | CanonicalRecord\n | CanonicalAggregationRecord\n | CanonicalTransformationRecord\n | EnergyRecord\n | EnergyEquipmentRecord\n | OperationalRecord\n /* 다섯째 어휘 — 사건이 아닌 것(변하지 않는 속성). 저널로 가지 않는다(§`CanonicalIngestResult`). */\n | MasterDataRecord\n | (\n | CanonicalRecord\n | CanonicalAggregationRecord\n | CanonicalTransformationRecord\n | EnergyRecord\n | EnergyEquipmentRecord\n | OperationalRecord\n | MasterDataRecord\n )[]\n | undefined\n | null,\n tenantId: string,\n defaultEventTime: string,\n /**\n * **이 사실들이 어느 트윈의 것인가** — 마감된 구간 사실의 이름에 쓰인다(커널 §`SubjectBasis`).\n *\n * 커널은 도메인으로 서므로 자기가 어느 트윈인지 모른다. 그런데 설비 번호·계량 지점 번호는 트윈\n * 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 된다.\n * 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳤다.\n *\n * 주지 않으면 이름표를 그대로 쓰고 `twin-local` 로 표시된다 — 겹칠 수 있다는 뜻이다.\n */\n scope?: { scopeId?: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined }\n): CanonicalIngestResult {\n const arr = Array.isArray(records) ? records : records ? [records] : []\n /*\n * ── **다섯째 어휘는 사건이 아니다** (2026-08-28) ────────────────────────────\n *\n * 마스터데이터 — 변하지 않는 속성(유통기한·로트번호·자리의 성질). 표준은 이것을 사건이 아니라\n * **어휘 요소의 속성**으로 담고, 커널에 그 자리가 없어서 지금까지 사건에 실려 왔다.\n *\n * 그 결과가 유입에서 이렇게 났다. 전량을 읽는 원본이 유통기한을 실으려고 **들어오지 않은 것을\n * `ADD`** 라고 말했고, `ilmd` 가 사건 단위라 자리별로 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에\n * 2,665건이다.\n *\n * 그래서 여기서 갈라 낸다. 이것은 봉투가 되지 않고 저널에도 적히지 않는다(시각축이 없는 값이다).\n * 갈래 판정은 커널의 것을 부른다 — 다른 넷과 같은 규율이다.\n *\n * 설계: `operato-twin/design/plans/master-data.md`\n */\n const masterCandidates = arr.filter(isMasterDataRecord)\n const master = masterCandidates.length ? ingestMasterData(masterCandidates) : { accepted: [], rejected: [] }\n /*\n * **어휘가 넷이다** — 물류(EPCIS)·에너지 계량·설비 에너지·운영 사실. 예전에는 이 함수가 무조건\n * `ObjectEvent` 를 만들었고, 그래서 계측을 이 길로 넣으면 `epc` 가 없어 거부되거나(그나마 나은 쪽)\n * 엉뚱한 물품 관측이 됐다.\n *\n * 물류 어휘 안에는 모양이 둘이다(개체 관측 `epc` / 담김 `parentID`+`childEPCs`).\n *\n * 판정은 커널이 한 곳에서 한다(`isEnergyRecord`·`isOperationalRecord`·`isAggregationRecord`) — 여기서 다시 짐작하면\n * 소비처마다 답이 달라진다. 봉투는 같은 것을 쓰므로 아래 소비처(미러·저널·브로드캐스팅)는 이 갈림을 알\n * 필요가 없다.\n */\n /*\n * 여섯째·일곱째 어휘 — **마감된 사용 구간**과 **청구서**. 커널 0.7.72 가 이 문들을 열었다.\n *\n * ── ★ 사용 구간을 계량보다 **먼저** 가른다 ─────────────────────────────────\n * `isEnergyRecord` 는 `meterId` 가 있으면 참이므로 사용 구간에도 참이다. 순서를 정하지 않으면\n * 시간별 사용량이 계량 표본으로 들어가고, 그 문은 `kWh` 를 **적산 레지스터**로 읽는다. 그러면\n * 커널이 구간 전력량을 차분으로 구하는데 우리가 넣은 값이 이미 구간의 양이다 — 차분의 차분이 되어\n * 값이 조용히 틀린다. 거부되지도 않는다.\n */\n const usagePeriods = arr.filter(isEnergyUsagePeriodRecord) as unknown as EnergyUsagePeriodRecord[]\n const bills = arr.filter(isEnergyBillRecord) as unknown as EnergyBillRecord[]\n /*\n * 여덟째 어휘 — **그 주기에 적용되는 요금 기준**. 커널 0.7.76 이 이 문을 열었다.\n *\n * ★ 청구서와 겹친다. 청구서가 `billingDemandKW` 를 실을 수 있고, 그러면 두 판정이 다 참이다.\n * 그때 요금 기준 문으로 보내면 「금액은 이 문이 받지 않는다」로 거부된다 — 정산이 통째로\n * 사라진다. 그래서 청구서인 것을 뺀다. 커널에서 배타로 만들어 달라고 올렸다.\n */\n const tariffBases = arr.filter(r => isEnergyTariffBasisRecord(r) && !isEnergyBillRecord(r)) as unknown as EnergyTariffBasisRecord[]\n /*\n * 아홉째 어휘 — **발전 단가**. 낸 것에 매겨지는 1kWh 당 값이고 날마다 바뀐다(커널 0.7.79).\n *\n * 다른 문과 겹치지 않는다: 계량 지점이나 설비 id 가 있으면 이 문이 아니고(커널이 배타로 막는다),\n * 요금적용전력·기본요금 단가가 없으므로 요금 기준도 아니다.\n */\n const generationPrices = arr.filter(isEnergyGenerationPriceRecord) as unknown as EnergyGenerationPriceRecord[]\n /* 마감된 발전 기간 — 지난 기록 채우기가 쓴다. 적산의 문과 배타다(구간이 있으면 이쪽). */\n const generationPeriods = arr.filter(isEnergyGenerationPeriodRecord) as unknown as EnergyGenerationPeriodRecord[]\n const energy = arr.filter(r => isEnergyRecord(r) && !isEnergyUsagePeriodRecord(r)) as EnergyRecord[]\n /* 세 번째 어휘 — 설비가 낸 자기 에너지 상태(발전·저장·감축 여지·개폐 위치). 계량과 갈라 두는\n 이유는 커널에 적혀 있다: 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */\n /*\n * 다섯 번째 어휘 — **발전 적산**(설비가 지금까지 만든 양). 커널 0.7.69 가 이 문을 열었다.\n *\n * ── ★ 설비 상태보다 **먼저** 가른다 ──────────────────────────────────────────\n * 두 판정이 겹친다. `isEnergyEquipmentRecord` 는 `equipmentId` 가 있고 물류·계량 어휘가 아니면\n * 참이므로 발전 적산도 함께 참이다. 그래서 순서가 갈림을 정한다.\n *\n * 순서를 잘못 두면 조용히 사라지는 것이 아니라 **엉뚱한 이유로 거부된다**: 설비 상태 문은 적산을\n * 값으로 세지 않아 「바꿀 상태가 하나도 없다」로 돌려보낸다. 커넥터는 보냈다고 믿고, 화면에는\n * 발전량이 없다.\n */\n const energyGeneration = arr.filter(isEnergyGenerationRecord) as unknown as EnergyGenerationRecord[]\n const energyEquipment = arr.filter(r => isEnergyEquipmentRecord(r) && !isEnergyGenerationRecord(r)) as unknown as EnergyEquipmentRecord[]\n /*\n * 네 번째 어휘 — **운영 사실**(작업·설비·사람·자산·오더·품질). 관측 리듀서는 이것을 오래전부터\n * 계산했는데 **들어올 문이 없었다**: 원본이 「이 작업이 끝났다」를 말할 길이 없어 시뮬 커널만 그것을\n * 아는 상태가 남았다(같은 화면이 두 구동에서 다른 것을 말한다).\n */\n const operational = arr.filter(isOperationalRecord) as OperationalRecord[]\n /*\n * ── 이 목록은 **어휘를 더할 때마다 함께 늘어야 한다** (2026-08-30) ─────────\n *\n * EPCIS 갈래는 「나머지 전부」다. 새 어휘를 빼 주지 않으면 그 레코드가 제 문으로 가면서\n * **EPCIS 문으로도 들어가** 거부된다. 받아들여지기도 하고 거부되기도 하는 상태가 되어,\n * 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로 읽는다.\n *\n * 실제로 발전 단가를 더할 때 그렇게 됐다. 목록을 손으로 지키는 대신 시험이 지킨다\n * (§`canonical-ingest-vocabularies` — 어휘마다 한 문으로만 가는지).\n */\n const epcis = arr.filter(\n r =>\n !isEnergyRecord(r) &&\n !isEnergyEquipmentRecord(r) &&\n !isEnergyGenerationRecord(r) &&\n !isEnergyUsagePeriodRecord(r) &&\n !isEnergyBillRecord(r) &&\n !isEnergyTariffBasisRecord(r) &&\n !isEnergyGenerationPriceRecord(r) &&\n !isEnergyGenerationPeriodRecord(r) &&\n !isOperationalRecord(r) &&\n !isMasterDataRecord(r)\n ) as (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord)[]\n\n /* 품목 어휘 안에서도 갈림이 있다(개체 관측 / 담김) — 그 판정도 커널의 것을 부른다. */\n const epcisResult = epcis.length\n ? ingest(\n epcis.map(r => ({\n ...r,\n /*\n * ── **시각의 이름을 둘 흡수한다** (2026-08-24 실측) ────────────────────\n * `eventTimePath` 는 **한 이름**만 받는다. 호스트가 `'eventTime'` 으로 못박아 두었는데, 뒤에\n * 생긴 커넥터는 `at` 을 싣는다(에너지 채널이 이미 `at` 을 쓰고 있어 그 어휘를 따랐다).\n * 그래서 그 커넥터의 사건은 **전부 폴링 순간**으로 찍혔다 — 저널 18,150건이 그랬다.\n *\n * 그 결과 둘이 함께 망가졌다: 재고 관측이 트윈의 시계를 밀지 못해 시계가 `task.status` 의\n * 최댓값(2026-04-15)에 **멈춰 있었고**, 이동 사건에서 「언제」가 사라져 순서만 남았다.\n *\n * 이름을 하나로 강요하지 않고 흡수한다 — `orderId`·`resourceRef`·`equipmentId` 를 함께 보는\n * 것과 같은 규율이다(§`twin-event-keys`). 세대가 섞이는 것은 이 이음새의 성질이다.\n *\n * **없으면 만들지 않는다**: 절대값 스냅샷 관측은 원본의 갱신 시각이 아니라 **폴링 순간**이\n * 맞는 관측 시각이다(「어제 값을 오늘 봤다」는 「어제 일어났다」가 아니다). 값을 말하는\n * 레코드만 자기 시각을 갖는다.\n */\n ...(((r as any).eventTime ?? (r as any).at) !== undefined\n ? { eventTime: (r as any).eventTime ?? (r as any).at }\n : {}),\n /* 판정은 커널이 한 곳에서 한다 — 여기서 다시 짐작하면 소비처마다 답이 달라진다. */\n sourceType: isTransformationRecord(r)\n ? 'twin-transformation'\n : isAggregationRecord(r)\n ? 'twin-aggregation'\n : 'twin'\n })),\n TWIN_INGEST_RULES,\n /*\n * **레코드가 시각을 말하면 그것을 쓴다** (2026-08-23).\n *\n * `eventTimePath` 를 주지 않던 동안 이 경로는 **언제나 폴링 시각**을 썼다(`defaultEventTime`).\n * 그래서 커넥터가 이미 싣고 있던 `eventTime`(변환 레코드의 `finishedAt` 등)이 **알리지 않고\n * 무시됐다** — 커넥터를 붙인 쪽은 그것이 먹는다고 믿고 있었고, 어디에도 오류가 나지 않았다.\n *\n * 무엇이 걸렸나: 계보(변환)의 시각이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다.\n * 그리고 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(§`kpi-fold`: `workMs = 완료 − 첫 착수`),\n * 폴링 간격을 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다.\n *\n * 레코드에 `eventTime` 이 없으면 커널이 알리지 않고 기본값으로 떨어진다(§`resolve` — 없는 값은\n * 오류가 아니다). 그래서 이 한 줄이 기존 경로를 막지 않는다: 시각을 말하는 레코드만 사실이 된다.\n */\n { tenantId, defaultEventTime, eventTimePath: 'eventTime' }\n )\n : { accepted: [], rejected: [] }\n const energyResult = energy.length ? ingestEnergyRecords(energy, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] }\n const equipmentResult = energyEquipment.length\n ? ingestEnergyEquipmentRecords(energyEquipment, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const usageResult = usagePeriods.length\n ? ingestEnergyUsagePeriodRecords(usagePeriods, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const billResult = bills.length ? ingestEnergyBillRecords(bills, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] }\n const tariffBasisResult = tariffBases.length\n ? ingestEnergyTariffBasisRecords(tariffBases, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const generationPeriodResult = generationPeriods.length\n ? ingestEnergyGenerationPeriodRecords(generationPeriods, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const generationPriceResult = generationPrices.length\n ? ingestEnergyGenerationPriceRecords(generationPrices, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const generationResult = energyGeneration.length\n ? ingestEnergyGenerationRecords(energyGeneration, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n const operationalResult = operational.length\n ? ingestOperationalRecords(operational, { tenantId, defaultEventTime, ...scope })\n : { accepted: [], rejected: [] }\n\n /* 거부분을 합쳐 돌려준다 — 어느 어휘에서 거부됐는지는 이유 문구가 말한다. */\n return {\n accepted: [\n ...epcisResult.accepted,\n ...energyResult.accepted,\n ...equipmentResult.accepted,\n ...generationResult.accepted,\n ...usageResult.accepted,\n ...generationPriceResult.accepted,\n ...generationPeriodResult.accepted,\n ...billResult.accepted,\n ...tariffBasisResult.accepted,\n ...operationalResult.accepted\n ],\n rejected: [\n ...epcisResult.rejected,\n ...energyResult.rejected,\n ...equipmentResult.rejected,\n ...generationResult.rejected,\n ...usageResult.rejected,\n ...billResult.rejected,\n ...tariffBasisResult.rejected,\n ...generationPriceResult.rejected,\n ...generationPeriodResult.rejected,\n ...operationalResult.rejected,\n ...master.rejected\n ],\n /* 사건이 아닌 것은 따로 낸다 — 부르는 쪽이 상태 세우는 문으로 보낸다(저널로 가지 않게). */\n masterData: master.accepted\n }\n}\n\n/**\n * 유입 결과 — 사건(봉투)과 **사건이 아닌 것**을 갈라 낸다.\n *\n * 마스터데이터를 `accepted` 에 섞지 않는 이유: 부르는 쪽이 그것을 저널에 적게 되고, 시각축이 없는 값이\n * 「그때 일어난 일」로 남는다. 갈래를 타입으로 갈라 두면 그 실수를 할 수 없다.\n */\nexport interface CanonicalIngestResult extends IngestResult {\n masterData: MasterDataRecord[]\n}\n"]}
|
|
@@ -20,7 +20,7 @@ exports.buildEnergyTopology = buildEnergyTopology;
|
|
|
20
20
|
* 자리를 모르는 설비, 계량기가 없는 자리, 계량이 덮지 않는 몫은 **각자 자기 칸으로** 나온다. 그림에서
|
|
21
21
|
* 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다.
|
|
22
22
|
*/
|
|
23
|
-
const
|
|
23
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
24
24
|
/**
|
|
25
25
|
* 이 설비의 **지금 값** — 스냅샷에서 그 설비를 찾아 에너지 축의 값만 옮긴다.
|
|
26
26
|
*
|
|
@@ -59,7 +59,7 @@ function liveOf(state, id, kind) {
|
|
|
59
59
|
live.measuredAt = String(e.measuredAt);
|
|
60
60
|
return { live };
|
|
61
61
|
}
|
|
62
|
-
const ELECTRICAL = new Set(
|
|
62
|
+
const ELECTRICAL = new Set(ops_contract_1.EMS_LOCATION_TYPES);
|
|
63
63
|
/** 부하가 아닌 계통 설비 — 이것들의 소비를 부하로 세면 총량이 이중으로 나뉜다. */
|
|
64
64
|
const GEAR = new Set(['meter', 'breaker', 'pv-array', 'battery']);
|
|
65
65
|
/**
|
|
@@ -131,7 +131,7 @@ equipmentState) {
|
|
|
131
131
|
const all = model?.locations ?? [];
|
|
132
132
|
for (const n of locations) {
|
|
133
133
|
const segment = byId.get(String(n.id));
|
|
134
|
-
const up = (0,
|
|
134
|
+
const up = (0, ops_contract_1.electricalUpstreamOf)(all, String(n.id));
|
|
135
135
|
const parent = up ? byId.get(up) : undefined;
|
|
136
136
|
if (parent && parent !== segment)
|
|
137
137
|
parent.children.push(segment);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"energy-topology.js","sourceRoot":"","sources":["../../server/engine/energy-topology.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 전력 계통도 — **선언된 것만 그린다.** 순수.\n *\n * ── 왜 필요한가 (2026-08-18) ────────────────────────────────────────────────\n * 분기별 막대는 「어디가 많이 쓰나」에 답하지만 **어디에 걸려 있나**에 답하지 못한다. 실제로 부딪힌\n * 세 가지가 전부 계통 질문이었다:\n * · 「하위 계량이 덮지 않는 327 kWh」 — 그 몫이 수전 직결 부하인지, 계량 없는 구간인지 표로는 모른다.\n * · 커넥터는 결선을 모르는 부하를 **수전에 매단다**(정직한 선택). 그런데 그 사실이 화면에 없었다.\n * · 「계량기를 어디에 더 달아야 하나」 — 어두운 구간이 보여야 답할 수 있다.\n *\n * ── 무엇을 그리지 않나 ─────────────────────────────────────────────────────\n * **보호 계통 단선도(SLD)가 아니다.** 차단기 정격·보호 협조·상(phase)·결선 방식은 모델에 없고, 그럴듯한\n * 단선도를 그리면 갖지 않은 공학적 충실도를 주장하게 된다. 여기서 그리는 것은 **트윈이 선언한 관계**\n * (자리의 계층 + 설비의 소속)와 **계량이 실제로 덮는 범위**뿐이다. 한계는 소비처가 화면에 적는다.\n *\n * ── 알리지 않고 빠뜨리지 않는다 ─────────────────────────────────────────────────\n * 자리를 모르는 설비, 계량기가 없는 자리, 계량이 덮지 않는 몫은 **각자 자기 칸으로** 나온다. 그림에서\n * 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다.\n */\nimport { EMS_LOCATION_TYPES, electricalUpstreamOf } from '@operato/twin-kernel'\n\n/** 계통의 한 구간(수전·분기·구역) — 자리 하나가 곧 구간이다. */\nexport interface TopologySegment {\n id: string\n /** 카탈로그 자리 타입(`incoming`·`feeder`·`submeter-zone`). */\n type: string\n label?: string\n /** 이 자리에 달린 계량 지점 id 들 — 비어 있으면 **어두운 구간**이다(값을 알 길이 없다). */\n meters: string[]\n /** 이 구간의 전력량과 근거 — 폴드가 낸 것만 싣는다(우리가 다시 계산하지 않는다). */\n kWh?: number\n basis?: 'measured' | 'apportioned'\n /**\n * **피크가 찍힌 순간** 이 구간이 얼마였나(kW) — 요금이 걸린 수다.\n *\n * 전력량이 큰 구간과 피크를 만든 구간은 다를 수 있다(하루 종일 조금씩 쓰는 부하 vs 잠깐 크게 쓰는 부하).\n * 그림에 둘을 함께 두면 「어느 분기를 깎아야 피크가 내려가나」가 바로 읽힌다.\n */\n peakKW?: number\n /** 이 자리에 매달린 부하(설비) — 종류를 그대로 싣는다. */\n loads: { id: string; kind: string }[]\n /**\n * 계통 설비(차단기·태양광·축전지) — 부하가 아니라 계통의 일부다.\n *\n * `live` 는 **지금 값**이다(구간 값이 아니다): 태양광이 지금 몇 kW 를 만들고, 축전지가 얼마를 내보내며\n * 충전율이 얼마인가. 원천이 보내 주는 사실이라 우리가 만들지 않는다 — 보내지 않은 필드는 없다.\n *\n * 시각(`measuredAt`)을 함께 나른다: 계측이 끊기면 **멈춘 값을 지금 값으로 그리게** 되고, 그것이 화면이\n * 낼 수 있는 가장 나쁜 거짓이다(값이 그럴듯할수록 위험하다).\n */\n gear: {\n id: string\n kind: string\n live?: { generatedKW?: number; exportKW?: number; chargeKW?: number; dischargeKW?: number; soc?: number; measuredAt?: string }\n }[]\n children: TopologySegment[]\n}\n\nexport interface EnergyTopology {\n roots: TopologySegment[]\n /**\n * 자리를 모르는 설비 — 집이 없거나, 없는 자리를 가리킨다. 숨기면 그림의 설비 수가 모델과 달라진다.\n */\n unplaced: { id: string; kind: string }[]\n /** 계량기가 없는 자리 id 들 — 「여기에 달면 무엇을 알 수 있나」의 답이 되는 목록이다. */\n unmetered: string[]\n /**\n * 결선을 모르는 부하가 매달린 자리 — 커넥터가 「어느 분기가 이 설비에 전기를 주는지 모른다」고 판단해\n * 수전에 매단 경우다. 그 자리의 몫이 분기로 내려가지 않는 이유가 이것이다.\n */\n loadsOnIncoming: number\n}\n\n/**\n * 이 설비의 **지금 값** — 스냅샷에서 그 설비를 찾아 에너지 축의 값만 옮긴다.\n *\n * 종류가 아는 필드만 옮긴다: 태양광에 충전율을 붙이거나 축전지에 발전량을 붙이면, 화면은 그것을 사실로\n * 그리고 사람은 그 설비가 그런 일을 한다고 읽는다(커널이 종류별로 필드를 가려 받는 것과 같은 규율).\n */\nfunction liveOf(\n state: readonly any[] | undefined | null,\n id: string,\n kind: string\n): { live: NonNullable<TopologySegment['gear'][number]['live']> } | undefined {\n const e = (state ?? []).find(x => String(x?.id ?? '') === id)\n if (!e) return undefined\n const num = (v: unknown): number | undefined => (Number.isFinite(Number(v)) ? Number(v) : undefined)\n const live: NonNullable<TopologySegment['gear'][number]['live']> = {}\n if (kind === 'pv-array') {\n const g = num(e.generatedKW)\n const x = num(e.exportKW)\n if (g !== undefined) live.generatedKW = g\n if (x !== undefined) live.exportKW = x\n } else if (kind === 'battery') {\n const soc = num(e.soc)\n const dis = num(e.dischargeKW)\n const cha = num(e.chargeKW)\n if (soc !== undefined) live.soc = soc\n if (dis !== undefined) live.dischargeKW = dis\n if (cha !== undefined) live.chargeKW = cha\n }\n if (!Object.keys(live).length) return undefined\n /* 값이 있으면 시각도 함께 — 없으면 붙이지 않는다(모르는 시각을 지금으로 만들지 않는다). */\n if (e.measuredAt) live.measuredAt = String(e.measuredAt)\n return { live }\n}\n\nconst ELECTRICAL = new Set(EMS_LOCATION_TYPES as readonly string[])\n/** 부하가 아닌 계통 설비 — 이것들의 소비를 부하로 세면 총량이 이중으로 나뉜다. */\nconst GEAR = new Set(['meter', 'breaker', 'pv-array', 'battery'])\n\n/**\n * 모델 + 폴드 결과 → 계통 트리.\n *\n * @param model 트윈 모델(자리 계층 `parentId`, 설비 소속 `homeLocation`).\n * @param energy 폴드가 낸 분기별 몫(있으면 노드에 싣는다 — 없으면 값 없이 구조만 그린다).\n */\nexport function buildEnergyTopology(\n model: { locations?: any[]; equipment?: any[] } | undefined | null,\n energy?: { byFeeder?: { key: string; kWh: number; basis?: 'measured' | 'apportioned'; peakKW?: number }[] } | null,\n /**\n * 지금의 설비 상태(스냅샷의 `equipment`) — 발전·저장의 **지금 값**을 기호 옆에 적기 위해서다.\n *\n * 없으면 값 없이 구조만 그린다. 여기서 값을 만들지 않는다: 원천이 보내지 않은 것은 모르는 것이다.\n */\n equipmentState?: readonly any[] | null\n): EnergyTopology {\n const locations: any[] = (model?.locations ?? []).filter(n => n?.id && ELECTRICAL.has(String(n.type)))\n const byId = new Map<string, TopologySegment>()\n for (const n of locations) {\n byId.set(String(n.id), {\n id: String(n.id),\n type: String(n.type),\n ...(n.name ? { label: String(n.name) } : {}),\n meters: [],\n loads: [],\n gear: [],\n children: []\n })\n }\n\n const share = new Map((energy?.byFeeder ?? []).map(f => [f.key, f]))\n for (const [id, segment] of byId) {\n const s = share.get(id)\n if (s) {\n segment.kWh = s.kWh\n if (s.basis) segment.basis = s.basis\n if (typeof s.peakKW === 'number') segment.peakKW = s.peakKW\n }\n }\n\n /* 설비를 자기 자리에 붙인다 — 계량기는 「이 구간을 잰다」는 뜻이라 따로 모은다. */\n const unplaced: { id: string; kind: string }[] = []\n let loadsOnIncoming = 0\n for (const e of (model?.equipment ?? []) as any[]) {\n if (!e?.id) continue\n const kind = String(e.kind ?? '')\n const home = String(e.homeLocation ?? e.homeLocationId ?? '')\n const at = byId.get(home)\n if (!at) {\n /* 전기 계통 밖의 자리에 사는 설비도 여기서는 「자리를 모른다」로 모은다 — 이 그림의 축은 계통이다. */\n unplaced.push({ id: String(e.id), kind })\n continue\n }\n if (kind === 'meter') at.meters.push(String(e.id))\n else if (GEAR.has(kind)) at.gear.push({ id: String(e.id), kind, ...(liveOf(equipmentState, String(e.id), kind) ?? {}) })\n else {\n at.loads.push({ id: String(e.id), kind })\n if (at.type === 'incoming') loadsOnIncoming++\n }\n }\n\n /*\n * 계층을 잇는 것은 **전기 상류**다 — 공간 부모(공장·구역)는 이 축이 아니다. 상류의 뜻은 커널이 읽는다\n * (옛 세대는 그 안에서 흡수된다) — 여기서 다시 판정하면 화면과 계산이 다른 계통을 말하게 된다.\n */\n const roots: TopologySegment[] = []\n const all = model?.locations ?? []\n for (const n of locations) {\n const segment = byId.get(String(n.id))!\n const up = electricalUpstreamOf(all as any, String(n.id))\n const parent = up ? byId.get(up) : undefined\n if (parent && parent !== segment) parent.children.push(segment)\n else roots.push(segment)\n }\n\n const unmetered = [...byId.values()].filter(n => !n.meters.length).map(n => n.id)\n return { roots, unplaced, unmetered, loadsOnIncoming }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"energy-topology.js","sourceRoot":"","sources":["../../server/engine/energy-topology.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 전력 계통도 — **선언된 것만 그린다.** 순수.\n *\n * ── 왜 필요한가 (2026-08-18) ────────────────────────────────────────────────\n * 분기별 막대는 「어디가 많이 쓰나」에 답하지만 **어디에 걸려 있나**에 답하지 못한다. 실제로 부딪힌\n * 세 가지가 전부 계통 질문이었다:\n * · 「하위 계량이 덮지 않는 327 kWh」 — 그 몫이 수전 직결 부하인지, 계량 없는 구간인지 표로는 모른다.\n * · 커넥터는 결선을 모르는 부하를 **수전에 매단다**(정직한 선택). 그런데 그 사실이 화면에 없었다.\n * · 「계량기를 어디에 더 달아야 하나」 — 어두운 구간이 보여야 답할 수 있다.\n *\n * ── 무엇을 그리지 않나 ─────────────────────────────────────────────────────\n * **보호 계통 단선도(SLD)가 아니다.** 차단기 정격·보호 협조·상(phase)·결선 방식은 모델에 없고, 그럴듯한\n * 단선도를 그리면 갖지 않은 공학적 충실도를 주장하게 된다. 여기서 그리는 것은 **트윈이 선언한 관계**\n * (자리의 계층 + 설비의 소속)와 **계량이 실제로 덮는 범위**뿐이다. 한계는 소비처가 화면에 적는다.\n *\n * ── 알리지 않고 빠뜨리지 않는다 ─────────────────────────────────────────────────\n * 자리를 모르는 설비, 계량기가 없는 자리, 계량이 덮지 않는 몫은 **각자 자기 칸으로** 나온다. 그림에서\n * 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다.\n */\nimport { EMS_LOCATION_TYPES, electricalUpstreamOf } from '@operato/ops-contract'\n\n/** 계통의 한 구간(수전·분기·구역) — 자리 하나가 곧 구간이다. */\nexport interface TopologySegment {\n id: string\n /** 카탈로그 자리 타입(`incoming`·`feeder`·`submeter-zone`). */\n type: string\n label?: string\n /** 이 자리에 달린 계량 지점 id 들 — 비어 있으면 **어두운 구간**이다(값을 알 길이 없다). */\n meters: string[]\n /** 이 구간의 전력량과 근거 — 폴드가 낸 것만 싣는다(우리가 다시 계산하지 않는다). */\n kWh?: number\n basis?: 'measured' | 'apportioned'\n /**\n * **피크가 찍힌 순간** 이 구간이 얼마였나(kW) — 요금이 걸린 수다.\n *\n * 전력량이 큰 구간과 피크를 만든 구간은 다를 수 있다(하루 종일 조금씩 쓰는 부하 vs 잠깐 크게 쓰는 부하).\n * 그림에 둘을 함께 두면 「어느 분기를 깎아야 피크가 내려가나」가 바로 읽힌다.\n */\n peakKW?: number\n /** 이 자리에 매달린 부하(설비) — 종류를 그대로 싣는다. */\n loads: { id: string; kind: string }[]\n /**\n * 계통 설비(차단기·태양광·축전지) — 부하가 아니라 계통의 일부다.\n *\n * `live` 는 **지금 값**이다(구간 값이 아니다): 태양광이 지금 몇 kW 를 만들고, 축전지가 얼마를 내보내며\n * 충전율이 얼마인가. 원천이 보내 주는 사실이라 우리가 만들지 않는다 — 보내지 않은 필드는 없다.\n *\n * 시각(`measuredAt`)을 함께 나른다: 계측이 끊기면 **멈춘 값을 지금 값으로 그리게** 되고, 그것이 화면이\n * 낼 수 있는 가장 나쁜 거짓이다(값이 그럴듯할수록 위험하다).\n */\n gear: {\n id: string\n kind: string\n live?: { generatedKW?: number; exportKW?: number; chargeKW?: number; dischargeKW?: number; soc?: number; measuredAt?: string }\n }[]\n children: TopologySegment[]\n}\n\nexport interface EnergyTopology {\n roots: TopologySegment[]\n /**\n * 자리를 모르는 설비 — 집이 없거나, 없는 자리를 가리킨다. 숨기면 그림의 설비 수가 모델과 달라진다.\n */\n unplaced: { id: string; kind: string }[]\n /** 계량기가 없는 자리 id 들 — 「여기에 달면 무엇을 알 수 있나」의 답이 되는 목록이다. */\n unmetered: string[]\n /**\n * 결선을 모르는 부하가 매달린 자리 — 커넥터가 「어느 분기가 이 설비에 전기를 주는지 모른다」고 판단해\n * 수전에 매단 경우다. 그 자리의 몫이 분기로 내려가지 않는 이유가 이것이다.\n */\n loadsOnIncoming: number\n}\n\n/**\n * 이 설비의 **지금 값** — 스냅샷에서 그 설비를 찾아 에너지 축의 값만 옮긴다.\n *\n * 종류가 아는 필드만 옮긴다: 태양광에 충전율을 붙이거나 축전지에 발전량을 붙이면, 화면은 그것을 사실로\n * 그리고 사람은 그 설비가 그런 일을 한다고 읽는다(커널이 종류별로 필드를 가려 받는 것과 같은 규율).\n */\nfunction liveOf(\n state: readonly any[] | undefined | null,\n id: string,\n kind: string\n): { live: NonNullable<TopologySegment['gear'][number]['live']> } | undefined {\n const e = (state ?? []).find(x => String(x?.id ?? '') === id)\n if (!e) return undefined\n const num = (v: unknown): number | undefined => (Number.isFinite(Number(v)) ? Number(v) : undefined)\n const live: NonNullable<TopologySegment['gear'][number]['live']> = {}\n if (kind === 'pv-array') {\n const g = num(e.generatedKW)\n const x = num(e.exportKW)\n if (g !== undefined) live.generatedKW = g\n if (x !== undefined) live.exportKW = x\n } else if (kind === 'battery') {\n const soc = num(e.soc)\n const dis = num(e.dischargeKW)\n const cha = num(e.chargeKW)\n if (soc !== undefined) live.soc = soc\n if (dis !== undefined) live.dischargeKW = dis\n if (cha !== undefined) live.chargeKW = cha\n }\n if (!Object.keys(live).length) return undefined\n /* 값이 있으면 시각도 함께 — 없으면 붙이지 않는다(모르는 시각을 지금으로 만들지 않는다). */\n if (e.measuredAt) live.measuredAt = String(e.measuredAt)\n return { live }\n}\n\nconst ELECTRICAL = new Set(EMS_LOCATION_TYPES as readonly string[])\n/** 부하가 아닌 계통 설비 — 이것들의 소비를 부하로 세면 총량이 이중으로 나뉜다. */\nconst GEAR = new Set(['meter', 'breaker', 'pv-array', 'battery'])\n\n/**\n * 모델 + 폴드 결과 → 계통 트리.\n *\n * @param model 트윈 모델(자리 계층 `parentId`, 설비 소속 `homeLocation`).\n * @param energy 폴드가 낸 분기별 몫(있으면 노드에 싣는다 — 없으면 값 없이 구조만 그린다).\n */\nexport function buildEnergyTopology(\n model: { locations?: any[]; equipment?: any[] } | undefined | null,\n energy?: { byFeeder?: { key: string; kWh: number; basis?: 'measured' | 'apportioned'; peakKW?: number }[] } | null,\n /**\n * 지금의 설비 상태(스냅샷의 `equipment`) — 발전·저장의 **지금 값**을 기호 옆에 적기 위해서다.\n *\n * 없으면 값 없이 구조만 그린다. 여기서 값을 만들지 않는다: 원천이 보내지 않은 것은 모르는 것이다.\n */\n equipmentState?: readonly any[] | null\n): EnergyTopology {\n const locations: any[] = (model?.locations ?? []).filter(n => n?.id && ELECTRICAL.has(String(n.type)))\n const byId = new Map<string, TopologySegment>()\n for (const n of locations) {\n byId.set(String(n.id), {\n id: String(n.id),\n type: String(n.type),\n ...(n.name ? { label: String(n.name) } : {}),\n meters: [],\n loads: [],\n gear: [],\n children: []\n })\n }\n\n const share = new Map((energy?.byFeeder ?? []).map(f => [f.key, f]))\n for (const [id, segment] of byId) {\n const s = share.get(id)\n if (s) {\n segment.kWh = s.kWh\n if (s.basis) segment.basis = s.basis\n if (typeof s.peakKW === 'number') segment.peakKW = s.peakKW\n }\n }\n\n /* 설비를 자기 자리에 붙인다 — 계량기는 「이 구간을 잰다」는 뜻이라 따로 모은다. */\n const unplaced: { id: string; kind: string }[] = []\n let loadsOnIncoming = 0\n for (const e of (model?.equipment ?? []) as any[]) {\n if (!e?.id) continue\n const kind = String(e.kind ?? '')\n const home = String(e.homeLocation ?? e.homeLocationId ?? '')\n const at = byId.get(home)\n if (!at) {\n /* 전기 계통 밖의 자리에 사는 설비도 여기서는 「자리를 모른다」로 모은다 — 이 그림의 축은 계통이다. */\n unplaced.push({ id: String(e.id), kind })\n continue\n }\n if (kind === 'meter') at.meters.push(String(e.id))\n else if (GEAR.has(kind)) at.gear.push({ id: String(e.id), kind, ...(liveOf(equipmentState, String(e.id), kind) ?? {}) })\n else {\n at.loads.push({ id: String(e.id), kind })\n if (at.type === 'incoming') loadsOnIncoming++\n }\n }\n\n /*\n * 계층을 잇는 것은 **전기 상류**다 — 공간 부모(공장·구역)는 이 축이 아니다. 상류의 뜻은 커널이 읽는다\n * (옛 세대는 그 안에서 흡수된다) — 여기서 다시 판정하면 화면과 계산이 다른 계통을 말하게 된다.\n */\n const roots: TopologySegment[] = []\n const all = model?.locations ?? []\n for (const n of locations) {\n const segment = byId.get(String(n.id))!\n const up = electricalUpstreamOf(all as any, String(n.id))\n const parent = up ? byId.get(up) : undefined\n if (parent && parent !== segment) parent.children.push(segment)\n else roots.push(segment)\n }\n\n const unmetered = [...byId.values()].filter(n => !n.meters.length).map(n => n.id)\n return { roots, unplaced, unmetered, loadsOnIncoming }\n}\n"]}
|