@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,11 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
isAggregationRecord, isTransformationRecord,
|
|
4
|
-
isEnergyRecord, isEnergyEquipmentRecord, isEnergyGenerationRecord, isEnergyUsagePeriodRecord, isEnergyBillRecord,
|
|
5
|
-
isOperationalRecord, type AdapterRule, type EnergyRecord,
|
|
6
|
-
isMasterDataRecord, ingestMasterData,
|
|
7
|
-
type EnergyEquipmentRecord, type EnergyGenerationRecord, type EnergyUsagePeriodRecord, type EnergyBillRecord,
|
|
8
|
-
type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/twin-kernel'
|
|
1
|
+
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'
|
|
2
|
+
import { 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'
|
|
9
3
|
|
|
10
4
|
/*
|
|
11
5
|
* 정규 레코드 → 커널 검증 ACL → CanonicalEnvelope (P1 seam 코어).
|
|
@@ -16,6 +10,7 @@ import { ingest, ingestEnergyRecords, ingestEnergyEquipmentRecords, ingestEnergy
|
|
|
16
10
|
* 에너지 계량 { meterId, kW?, kWh?, … } · 설비 에너지 { equipmentId, generatedKW?, soc?, … }
|
|
17
11
|
* 발전 적산 { equipmentId, kWh, kWhSince?, … }
|
|
18
12
|
* 사용 구간 { meterId, from, to, kWh, unitPrice?, basis?, … } · 청구서 { from, to, total?, … }
|
|
13
|
+
* 요금 기준 { from, to, billingDemandKW?, demandChargePerKW?, currency }
|
|
19
14
|
* 담김(적재) { parentID, childEPCs[] | childQuantityList[], action, bizStep, … }
|
|
20
15
|
* 운영 사실 { taskId | moverId | personId | assetId | orderId, status, … } // vocabulary-guard: allow 저널 와이어 필드
|
|
21
16
|
* 커널은 objectEvent 구성 + EPCIS 검증(위반=오염 차단) + 봉투 래핑만(방언 없음).
|
|
@@ -116,78 +111,16 @@ const TWIN_INGEST_RULES: AdapterRule[] = [
|
|
|
116
111
|
}
|
|
117
112
|
]
|
|
118
113
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
* 개체 하나의 식별자. **`quantityList` 를 쓰면 없어도 된다** — 낱개 번호가 없는 자재(밀가루 3.5kg)는
|
|
123
|
-
* 개체가 없다. 둘 다 없으면 무엇을 관측했는지 말하지 않은 것이므로 커널 매핑이 거부한다.
|
|
124
|
-
*/
|
|
125
|
-
epc?: string
|
|
126
|
-
/**
|
|
127
|
-
* 클래스와 수량 — `{ epcClass, quantity, uom? }`. **잔량 절대값을 싣는다**(「얼마 뺐다」가 아니라
|
|
128
|
-
* 「얼마 남았다」). 그래야 사건 하나를 놓쳐도 다음 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다.
|
|
129
|
-
*
|
|
130
|
-
* 담김(`childQuantityList`)이 하루 먼저 이 자리를 받았다 — 그때와 같은 이유다. 원본이 비직렬 수량을
|
|
131
|
-
* 말할 길이 없어서, 그것이 주된 경로인 시스템(식품 제조 MES)은 라이브를 낼 수 없었다. 커널의
|
|
132
|
-
* 관측 리듀서는 처음부터 이 값을 읽었고 문만 없었다.
|
|
133
|
-
*/
|
|
134
|
-
quantityList?: { epcClass: string; quantity?: number; uom?: string }[]
|
|
135
|
-
action: string // ADD | OBSERVE | DELETE
|
|
136
|
-
bizStep: string
|
|
137
|
-
disposition?: string
|
|
138
|
-
readPoint?: string
|
|
139
|
-
bizLocation?: string
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 담김 관측 — **부모와 자식**으로 말한다. 자리 필드는 개체 관측과 같은 이름이다(문자열).
|
|
144
|
-
*
|
|
145
|
-
* 이름은 표준의 것을 그대로 쓴다(`parentID`·`childEPCs`) — 여기서 다시 지으면 매핑이 번역이 되고,
|
|
146
|
-
* 같은 사실에 두 어휘가 생긴다.
|
|
147
|
-
*/
|
|
148
|
-
/**
|
|
149
|
-
* 변환 관측 — **무엇으로 무엇이 되었나.** 입력과 출력이 **한 사실에 함께** 있어야 계보가 남는다.
|
|
150
|
-
*
|
|
151
|
-
* 따로 보내면(「이 로트가 없어졌다」와 「이 제품이 생겼다」) 그 둘을 이을 근거가 없다. 그래서 표준이
|
|
152
|
-
* 한 사건에 둘을 담고, 우리 레코드도 같은 모양이다.
|
|
153
|
-
*
|
|
154
|
-
* 이름은 **표준의 것을 그대로** 쓴다(`inputQuantityList` 등). 갈래 판정이 이 이름으로 이루어지므로
|
|
155
|
-
* 커넥터 낱말(`consumed`·`produced`)로 담으면 판정되지 않는다 — 커넥터가 정규 이름으로 담아 보내고
|
|
156
|
-
* 매핑은 그 안에서 값을 고른다.
|
|
114
|
+
/*
|
|
115
|
+
* 정규 레코드 셋(`CanonicalRecord` · `CanonicalTransformationRecord` · `CanonicalAggregationRecord`)은
|
|
116
|
+
* **계약으로 옮겼다**(2026-08-30, `@operato/ops-contract` 0.2.0).
|
|
157
117
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
118
|
+
* 여기 있던 이유는 소비 방식 때문이었다 — EPCIS 는 커널의 범용 어댑터를 지나서 타입을 요구하지 않았다.
|
|
119
|
+
* 그런데 모양 자체는 EPCIS 2.0 의 필드 이름이고 호스트의 것이 아니다. 이제 MES 가 이 모양 그대로
|
|
120
|
+
* 보낸다.
|
|
160
121
|
*
|
|
161
|
-
* `
|
|
162
|
-
* 그 둘을 묶는 유일한 자리다.
|
|
122
|
+
* **매핑 룰(`TWIN_INGEST_RULES`)은 여기 남는다** — 이 모양을 EPCIS 사건으로 옮기는 정책이지 모양이 아니다.
|
|
163
123
|
*/
|
|
164
|
-
export interface CanonicalTransformationRecord {
|
|
165
|
-
/** 개체로 소비된 것. */
|
|
166
|
-
inputEPCList?: string[]
|
|
167
|
-
/** 클래스와 수량으로 소비된 것 — 로트 단위 소비가 이 자리다. */
|
|
168
|
-
inputQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]
|
|
169
|
-
outputEPCList?: string[]
|
|
170
|
-
outputQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]
|
|
171
|
-
/** 한 오더의 여러 단계를 잇는 식별자. */
|
|
172
|
-
transformationID?: string
|
|
173
|
-
bizStep: string
|
|
174
|
-
disposition?: string
|
|
175
|
-
readPoint?: string
|
|
176
|
-
bizLocation?: string
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export interface CanonicalAggregationRecord {
|
|
180
|
-
parentID: string
|
|
181
|
-
/** 개체로 담긴 자식. 수량으로만 담겼으면 없다(둘 중 하나는 있어야 한다). */
|
|
182
|
-
childEPCs?: string[]
|
|
183
|
-
/** 수량으로 담긴 자식 — `{ epcClass, quantity, uom? }`. 값의 옳고 그름은 커널 검증이 본다. */
|
|
184
|
-
childQuantityList?: { epcClass: string; quantity?: number; uom?: string }[]
|
|
185
|
-
action: string // ADD(담다) | DELETE(풀다) | OBSERVE
|
|
186
|
-
bizStep: string
|
|
187
|
-
disposition?: string
|
|
188
|
-
readPoint?: string
|
|
189
|
-
bizLocation?: string
|
|
190
|
-
}
|
|
191
124
|
|
|
192
125
|
/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */
|
|
193
126
|
export function ingestCanonicalRecords(
|
|
@@ -212,7 +145,17 @@ export function ingestCanonicalRecords(
|
|
|
212
145
|
| undefined
|
|
213
146
|
| null,
|
|
214
147
|
tenantId: string,
|
|
215
|
-
defaultEventTime: string
|
|
148
|
+
defaultEventTime: string,
|
|
149
|
+
/**
|
|
150
|
+
* **이 사실들이 어느 트윈의 것인가** — 마감된 구간 사실의 이름에 쓰인다(커널 §`SubjectBasis`).
|
|
151
|
+
*
|
|
152
|
+
* 커널은 도메인으로 서므로 자기가 어느 트윈인지 모른다. 그런데 설비 번호·계량 지점 번호는 트윈
|
|
153
|
+
* 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 된다.
|
|
154
|
+
* 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳤다.
|
|
155
|
+
*
|
|
156
|
+
* 주지 않으면 이름표를 그대로 쓰고 `twin-local` 로 표시된다 — 겹칠 수 있다는 뜻이다.
|
|
157
|
+
*/
|
|
158
|
+
scope?: { scopeId?: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined }
|
|
216
159
|
): CanonicalIngestResult {
|
|
217
160
|
const arr = Array.isArray(records) ? records : records ? [records] : []
|
|
218
161
|
/*
|
|
@@ -254,6 +197,23 @@ export function ingestCanonicalRecords(
|
|
|
254
197
|
*/
|
|
255
198
|
const usagePeriods = arr.filter(isEnergyUsagePeriodRecord) as unknown as EnergyUsagePeriodRecord[]
|
|
256
199
|
const bills = arr.filter(isEnergyBillRecord) as unknown as EnergyBillRecord[]
|
|
200
|
+
/*
|
|
201
|
+
* 여덟째 어휘 — **그 주기에 적용되는 요금 기준**. 커널 0.7.76 이 이 문을 열었다.
|
|
202
|
+
*
|
|
203
|
+
* ★ 청구서와 겹친다. 청구서가 `billingDemandKW` 를 실을 수 있고, 그러면 두 판정이 다 참이다.
|
|
204
|
+
* 그때 요금 기준 문으로 보내면 「금액은 이 문이 받지 않는다」로 거부된다 — 정산이 통째로
|
|
205
|
+
* 사라진다. 그래서 청구서인 것을 뺀다. 커널에서 배타로 만들어 달라고 올렸다.
|
|
206
|
+
*/
|
|
207
|
+
const tariffBases = arr.filter(r => isEnergyTariffBasisRecord(r) && !isEnergyBillRecord(r)) as unknown as EnergyTariffBasisRecord[]
|
|
208
|
+
/*
|
|
209
|
+
* 아홉째 어휘 — **발전 단가**. 낸 것에 매겨지는 1kWh 당 값이고 날마다 바뀐다(커널 0.7.79).
|
|
210
|
+
*
|
|
211
|
+
* 다른 문과 겹치지 않는다: 계량 지점이나 설비 id 가 있으면 이 문이 아니고(커널이 배타로 막는다),
|
|
212
|
+
* 요금적용전력·기본요금 단가가 없으므로 요금 기준도 아니다.
|
|
213
|
+
*/
|
|
214
|
+
const generationPrices = arr.filter(isEnergyGenerationPriceRecord) as unknown as EnergyGenerationPriceRecord[]
|
|
215
|
+
/* 마감된 발전 기간 — 지난 기록 채우기가 쓴다. 적산의 문과 배타다(구간이 있으면 이쪽). */
|
|
216
|
+
const generationPeriods = arr.filter(isEnergyGenerationPeriodRecord) as unknown as EnergyGenerationPeriodRecord[]
|
|
257
217
|
const energy = arr.filter(r => isEnergyRecord(r) && !isEnergyUsagePeriodRecord(r)) as EnergyRecord[]
|
|
258
218
|
/* 세 번째 어휘 — 설비가 낸 자기 에너지 상태(발전·저장·감축 여지·개폐 위치). 계량과 갈라 두는
|
|
259
219
|
이유는 커널에 적혀 있다: 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */
|
|
@@ -276,6 +236,16 @@ export function ingestCanonicalRecords(
|
|
|
276
236
|
* 아는 상태가 남았다(같은 화면이 두 구동에서 다른 것을 말한다).
|
|
277
237
|
*/
|
|
278
238
|
const operational = arr.filter(isOperationalRecord) as OperationalRecord[]
|
|
239
|
+
/*
|
|
240
|
+
* ── 이 목록은 **어휘를 더할 때마다 함께 늘어야 한다** (2026-08-30) ─────────
|
|
241
|
+
*
|
|
242
|
+
* EPCIS 갈래는 「나머지 전부」다. 새 어휘를 빼 주지 않으면 그 레코드가 제 문으로 가면서
|
|
243
|
+
* **EPCIS 문으로도 들어가** 거부된다. 받아들여지기도 하고 거부되기도 하는 상태가 되어,
|
|
244
|
+
* 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로 읽는다.
|
|
245
|
+
*
|
|
246
|
+
* 실제로 발전 단가를 더할 때 그렇게 됐다. 목록을 손으로 지키는 대신 시험이 지킨다
|
|
247
|
+
* (§`canonical-ingest-vocabularies` — 어휘마다 한 문으로만 가는지).
|
|
248
|
+
*/
|
|
279
249
|
const epcis = arr.filter(
|
|
280
250
|
r =>
|
|
281
251
|
!isEnergyRecord(r) &&
|
|
@@ -283,6 +253,9 @@ export function ingestCanonicalRecords(
|
|
|
283
253
|
!isEnergyGenerationRecord(r) &&
|
|
284
254
|
!isEnergyUsagePeriodRecord(r) &&
|
|
285
255
|
!isEnergyBillRecord(r) &&
|
|
256
|
+
!isEnergyTariffBasisRecord(r) &&
|
|
257
|
+
!isEnergyGenerationPriceRecord(r) &&
|
|
258
|
+
!isEnergyGenerationPeriodRecord(r) &&
|
|
286
259
|
!isOperationalRecord(r) &&
|
|
287
260
|
!isMasterDataRecord(r)
|
|
288
261
|
) as (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord)[]
|
|
@@ -336,19 +309,28 @@ export function ingestCanonicalRecords(
|
|
|
336
309
|
{ tenantId, defaultEventTime, eventTimePath: 'eventTime' }
|
|
337
310
|
)
|
|
338
311
|
: { accepted: [], rejected: [] }
|
|
339
|
-
const energyResult = energy.length ? ingestEnergyRecords(energy, { tenantId, defaultEventTime }) : { accepted: [], rejected: [] }
|
|
312
|
+
const energyResult = energy.length ? ingestEnergyRecords(energy, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] }
|
|
340
313
|
const equipmentResult = energyEquipment.length
|
|
341
|
-
? ingestEnergyEquipmentRecords(energyEquipment, { tenantId, defaultEventTime })
|
|
314
|
+
? ingestEnergyEquipmentRecords(energyEquipment, { tenantId, defaultEventTime, ...scope })
|
|
342
315
|
: { accepted: [], rejected: [] }
|
|
343
316
|
const usageResult = usagePeriods.length
|
|
344
|
-
? ingestEnergyUsagePeriodRecords(usagePeriods, { tenantId, defaultEventTime })
|
|
317
|
+
? ingestEnergyUsagePeriodRecords(usagePeriods, { tenantId, defaultEventTime, ...scope })
|
|
318
|
+
: { accepted: [], rejected: [] }
|
|
319
|
+
const billResult = bills.length ? ingestEnergyBillRecords(bills, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] }
|
|
320
|
+
const tariffBasisResult = tariffBases.length
|
|
321
|
+
? ingestEnergyTariffBasisRecords(tariffBases, { tenantId, defaultEventTime, ...scope })
|
|
322
|
+
: { accepted: [], rejected: [] }
|
|
323
|
+
const generationPeriodResult = generationPeriods.length
|
|
324
|
+
? ingestEnergyGenerationPeriodRecords(generationPeriods, { tenantId, defaultEventTime, ...scope })
|
|
325
|
+
: { accepted: [], rejected: [] }
|
|
326
|
+
const generationPriceResult = generationPrices.length
|
|
327
|
+
? ingestEnergyGenerationPriceRecords(generationPrices, { tenantId, defaultEventTime, ...scope })
|
|
345
328
|
: { accepted: [], rejected: [] }
|
|
346
|
-
const billResult = bills.length ? ingestEnergyBillRecords(bills, { tenantId, defaultEventTime }) : { accepted: [], rejected: [] }
|
|
347
329
|
const generationResult = energyGeneration.length
|
|
348
|
-
? ingestEnergyGenerationRecords(energyGeneration, { tenantId, defaultEventTime })
|
|
330
|
+
? ingestEnergyGenerationRecords(energyGeneration, { tenantId, defaultEventTime, ...scope })
|
|
349
331
|
: { accepted: [], rejected: [] }
|
|
350
332
|
const operationalResult = operational.length
|
|
351
|
-
? ingestOperationalRecords(operational, { tenantId, defaultEventTime })
|
|
333
|
+
? ingestOperationalRecords(operational, { tenantId, defaultEventTime, ...scope })
|
|
352
334
|
: { accepted: [], rejected: [] }
|
|
353
335
|
|
|
354
336
|
/* 거부분을 합쳐 돌려준다 — 어느 어휘에서 거부됐는지는 이유 문구가 말한다. */
|
|
@@ -359,7 +341,10 @@ export function ingestCanonicalRecords(
|
|
|
359
341
|
...equipmentResult.accepted,
|
|
360
342
|
...generationResult.accepted,
|
|
361
343
|
...usageResult.accepted,
|
|
344
|
+
...generationPriceResult.accepted,
|
|
345
|
+
...generationPeriodResult.accepted,
|
|
362
346
|
...billResult.accepted,
|
|
347
|
+
...tariffBasisResult.accepted,
|
|
363
348
|
...operationalResult.accepted
|
|
364
349
|
],
|
|
365
350
|
rejected: [
|
|
@@ -369,6 +354,9 @@ export function ingestCanonicalRecords(
|
|
|
369
354
|
...generationResult.rejected,
|
|
370
355
|
...usageResult.rejected,
|
|
371
356
|
...billResult.rejected,
|
|
357
|
+
...tariffBasisResult.rejected,
|
|
358
|
+
...generationPriceResult.rejected,
|
|
359
|
+
...generationPeriodResult.rejected,
|
|
372
360
|
...operationalResult.rejected,
|
|
373
361
|
...master.rejected
|
|
374
362
|
],
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* 자리를 모르는 설비, 계량기가 없는 자리, 계량이 덮지 않는 몫은 **각자 자기 칸으로** 나온다. 그림에서
|
|
18
18
|
* 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다.
|
|
19
19
|
*/
|
|
20
|
-
import { EMS_LOCATION_TYPES, electricalUpstreamOf } from '@operato/
|
|
20
|
+
import { EMS_LOCATION_TYPES, electricalUpstreamOf } from '@operato/ops-contract'
|
|
21
21
|
|
|
22
22
|
/** 계통의 한 구간(수전·분기·구역) — 자리 하나가 곧 구간이다. */
|
|
23
23
|
export interface TopologySegment {
|
|
@@ -25,20 +25,9 @@
|
|
|
25
25
|
* 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
|
|
26
26
|
* 이 파일은 이제 "계산한 기록 → 창 지표" 만 한다.
|
|
27
27
|
*/
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
electricityCost,
|
|
32
|
-
energyIntensity,
|
|
33
|
-
energyOfWindows,
|
|
34
|
-
foldTaskRecords,
|
|
35
|
-
isOrderTerminal,
|
|
36
|
-
activeShiftAt,
|
|
37
|
-
type ElectricityCost,
|
|
38
|
-
type TariffDeclaration,
|
|
39
|
-
type TaskFacets,
|
|
40
|
-
type WorkCalendarEntry
|
|
41
|
-
} from '@operato/twin-kernel'
|
|
28
|
+
import { attributeEnergy, electricityCost, energyIntensity, energyOfWindows, foldTaskRecords, type ElectricityCost, type TariffDeclaration, type TaskFacets } from '@operato/twin-kernel'
|
|
29
|
+
import { type WorkCalendarEntry } from '@operato/ops-contract'
|
|
30
|
+
import { ENERGY_EVENT, isOrderTerminal, activeShiftAt } from '@operato/ops-contract'
|
|
42
31
|
|
|
43
32
|
/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
|
|
44
33
|
export interface KpiEvent {
|
|
@@ -158,6 +147,35 @@ export interface KpiResult {
|
|
|
158
147
|
partial: number
|
|
159
148
|
/** 설비별 몫 — 이것은 **측정**이다(배분이 아니다). 각 설비의 적산에서 왔다. */
|
|
160
149
|
byEquipment: { id: string; kWh: number }[]
|
|
150
|
+
/**
|
|
151
|
+
* **이 창에 다 들어오지 않아 더하지 못한 기간** — 조용히 버리지 않는다.
|
|
152
|
+
*
|
|
153
|
+
* 더하는 것은 창에 온전히 든 기간뿐이다(걸친 기간을 더하면 다른 날 값이 이 창의 값인 척한다).
|
|
154
|
+
* 그런데 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 드는 일이 거의 없다 — 창이 한낮에서
|
|
155
|
+
* 시작하기 때문이다. 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고, 사용자는 그것을
|
|
156
|
+
* 「발전량이 없다」로 읽었다(2026-08-30 실측).
|
|
157
|
+
*
|
|
158
|
+
* 이 값이 있으면 화면은 **「이 구간에 다 들어오지 않은 기간이 있다」**를 말해야 한다.
|
|
159
|
+
*/
|
|
160
|
+
notCounted?: { periods: number; kWh: number; earliestStart: string; latestEnd: string }
|
|
161
|
+
/**
|
|
162
|
+
* 이용률 — 만든 양 ÷ (정격 × 그 기간의 시간).
|
|
163
|
+
*
|
|
164
|
+
* **어느 정격으로 나눴는지를 함께 낸다.** 변환기를 낀 발전은 직류 정격이 교류보다 크고,
|
|
165
|
+
* 직류로 나눠 보여 주는 시스템이 실제로 있다. 밝히지 않으면 두 화면의 수가 두 배 달라도
|
|
166
|
+
* 왜 다른지 말할 수 없다.
|
|
167
|
+
*
|
|
168
|
+
* 창 전체가 아니라 **센 기간들의 길이**로 나눈다 — 하루치 하나만 센 24시간 창에서 창 길이로
|
|
169
|
+
* 나누면 그 하루가 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
170
|
+
*/
|
|
171
|
+
capacityFactor?: { value: number; ratedKW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location'; hours: number }
|
|
172
|
+
/**
|
|
173
|
+
* 낸 것의 값 — 기간마다 그때의 단가로 매긴다.
|
|
174
|
+
*
|
|
175
|
+
* **모든 기간에 단가가 있을 때만** 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인
|
|
176
|
+
* 척한다 — 못 매긴 기간 수를 함께 낸다.
|
|
177
|
+
*/
|
|
178
|
+
revenue?: { amount: number; currency: string; priced: number; unpriced: number }
|
|
161
179
|
}
|
|
162
180
|
/**
|
|
163
181
|
* `kWh` 가 어느 회계에서 왔나.
|
|
@@ -182,9 +200,18 @@ export interface KpiResult {
|
|
|
182
200
|
*/
|
|
183
201
|
billingDemand?: {
|
|
184
202
|
kW: number
|
|
185
|
-
from: 'bill' | 'carried-forward' | 'declaration'
|
|
203
|
+
from: 'tariff-basis' | 'bill' | 'carried-forward' | 'declaration'
|
|
186
204
|
declaredKW?: number
|
|
187
205
|
stale?: boolean
|
|
206
|
+
/**
|
|
207
|
+
* 이 창에서 관측한 최고 수요가 그 기준을 넘었다 — **다음 주기의 기본요금이 오른다.**
|
|
208
|
+
*
|
|
209
|
+
* 계약 초과와 다른 값이다. 계약 초과는 지금 줄이면 되고, 이것은 이미 정해진 결과다.
|
|
210
|
+
*
|
|
211
|
+
* `observedTo` 는 이 최고값이 언제까지의 것인지다. 최고 수요는 구간이 마감되어야 오므로
|
|
212
|
+
* 넘은 것을 뒤늦게 안다 — 그 한계를 화면이 말할 수 있어야 한다.
|
|
213
|
+
*/
|
|
214
|
+
exceeded?: { peakKW: number; overKW: number; observedTo: string }
|
|
188
215
|
}
|
|
189
216
|
/**
|
|
190
217
|
* 밖에서 마감되어 온 사용 구간 — 계량 표본이 없는 현장의 유일한 근거다.
|
|
@@ -487,10 +514,42 @@ function generatedOfPeriods(
|
|
|
487
514
|
observedFromMs: number
|
|
488
515
|
observedToMs: number
|
|
489
516
|
}[],
|
|
490
|
-
range: { startMs: number; endMs: number }
|
|
517
|
+
range: { startMs: number; endMs: number },
|
|
518
|
+
/** 이용률의 분모 — 없으면 이용률을 내지 않는다(0 이나 짐작을 만들지 않는다). */
|
|
519
|
+
rated?: { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' },
|
|
520
|
+
/** 기간마다 적용할 단가 — 기간을 덮는 것을 쓴다. 없으면 금액을 만들지 않는다. */
|
|
521
|
+
prices?: readonly { startMs: number; endMs: number; unitPrice: number; currency: string }[]
|
|
491
522
|
): Partial<NonNullable<KpiResult['energy']>> {
|
|
492
523
|
const inside = periods.filter(g => g.startMs >= range.startMs && g.endMs <= range.endMs)
|
|
493
|
-
|
|
524
|
+
|
|
525
|
+
/*
|
|
526
|
+
* ── **걸친 기간을 조용히 버리지 않는다** (2026-08-30) ──────────────────────
|
|
527
|
+
*
|
|
528
|
+
* 더하는 것은 창에 **온전히 들어온 기간**뿐이다. 그것이 맞는 셈이다 — 걸친 기간을 더하면 다른 날의
|
|
529
|
+
* 값이 이 창의 값인 척한다.
|
|
530
|
+
*
|
|
531
|
+
* 그런데 버린 것을 말하지 않았다. 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 들어오는 일이
|
|
532
|
+
* 거의 없다(창이 한낮에서 시작하므로). 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고,
|
|
533
|
+
* 사용자는 그것을 **「발전량이 없다」**로 읽었다. 실측으로 그 일이 났다.
|
|
534
|
+
*
|
|
535
|
+
* 이제 걸친 것을 세어 함께 낸다. 화면이 「이 창에 다 들어오지 않은 기간이 있다」를 말할 수 있다.
|
|
536
|
+
*/
|
|
537
|
+
const straddling = periods.filter(
|
|
538
|
+
g => !(g.startMs >= range.startMs && g.endMs <= range.endMs) && g.endMs > range.startMs && g.startMs < range.endMs
|
|
539
|
+
)
|
|
540
|
+
const notCounted = straddling.length
|
|
541
|
+
? {
|
|
542
|
+
periods: straddling.length,
|
|
543
|
+
kWh: straddling.reduce((a, g) => a + g.kWh, 0),
|
|
544
|
+
earliestStart: new Date(Math.min(...straddling.map(g => g.startMs))).toISOString(),
|
|
545
|
+
latestEnd: new Date(Math.max(...straddling.map(g => g.endMs))).toISOString()
|
|
546
|
+
}
|
|
547
|
+
: undefined
|
|
548
|
+
|
|
549
|
+
if (!inside.length) {
|
|
550
|
+
/* 온전히 들어온 것이 없다 — 그래도 걸친 것이 있으면 그 사실을 낸다(빈 값과 「없음」은 다르다). */
|
|
551
|
+
return notCounted ? { generated: { kWh: 0, counted: 0, partial: 0, byEquipment: [], notCounted } } : {}
|
|
552
|
+
}
|
|
494
553
|
|
|
495
554
|
const byEquipment = new Map<string, number>()
|
|
496
555
|
let partial = 0
|
|
@@ -501,14 +560,48 @@ function generatedOfPeriods(
|
|
|
501
560
|
if (!coveredFrom || !coveredTo) partial++
|
|
502
561
|
}
|
|
503
562
|
|
|
563
|
+
const kWh = inside.reduce((a, g) => a + g.kWh, 0)
|
|
564
|
+
/*
|
|
565
|
+
* 이용률의 분모는 **센 기간들의 길이**다. 창 길이로 나누면, 하루치 하나만 센 24시간 창에서 그 하루가
|
|
566
|
+
* 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
567
|
+
*/
|
|
568
|
+
const hours = inside.reduce((a, g) => a + (g.endMs - g.startMs) / 3_600_000, 0)
|
|
569
|
+
const capacityFactor =
|
|
570
|
+
rated && rated.kW > 0 && hours > 0
|
|
571
|
+
? { value: kWh / (rated.kW * hours), ratedKW: rated.kW, basis: rated.basis, from: rated.from, hours }
|
|
572
|
+
: undefined
|
|
573
|
+
|
|
574
|
+
/*
|
|
575
|
+
* 낸 것의 값 — 기간마다 **그 기간을 덮는 단가**로 매긴다. 걸친 단가는 쓰지 않는다: 하루 단가로
|
|
576
|
+
* 이틀치를 매기면 그 절반은 다른 날의 값이 된다.
|
|
577
|
+
*
|
|
578
|
+
* 모든 기간에 단가가 있을 때만 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인 척한다.
|
|
579
|
+
*/
|
|
580
|
+
let amount = 0
|
|
581
|
+
let priced = 0
|
|
582
|
+
let currency: string | undefined
|
|
583
|
+
for (const g of inside) {
|
|
584
|
+
const p = (prices ?? []).find(x => x.startMs <= g.startMs && x.endMs >= g.endMs)
|
|
585
|
+
if (!p) continue
|
|
586
|
+
amount += g.kWh * p.unitPrice
|
|
587
|
+
priced++
|
|
588
|
+
currency = currency ?? p.currency
|
|
589
|
+
}
|
|
590
|
+
const unpriced = inside.length - priced
|
|
591
|
+
const revenue = priced > 0 && unpriced === 0 && currency ? { amount, currency, priced, unpriced } : undefined
|
|
592
|
+
|
|
504
593
|
return {
|
|
505
594
|
generated: {
|
|
506
|
-
kWh
|
|
595
|
+
kWh,
|
|
507
596
|
counted: inside.length,
|
|
508
597
|
partial,
|
|
509
598
|
byEquipment: [...byEquipment.entries()]
|
|
510
599
|
.map(([id, kWh]) => ({ id, kWh }))
|
|
511
|
-
.sort((a, b) => b.kWh - a.kWh)
|
|
600
|
+
.sort((a, b) => b.kWh - a.kWh),
|
|
601
|
+
...(capacityFactor ? { capacityFactor } : {}),
|
|
602
|
+
...(revenue ? { revenue } : {}),
|
|
603
|
+
/* 이 창에 다 들어오지 않아 더하지 못한 기간 — 있으면 화면이 그것을 말한다. */
|
|
604
|
+
...(notCounted ? { notCounted } : {})
|
|
512
605
|
}
|
|
513
606
|
}
|
|
514
607
|
}
|
|
@@ -560,30 +653,47 @@ function usageOfPeriods(
|
|
|
560
653
|
*/
|
|
561
654
|
function billingDemandOf(
|
|
562
655
|
billsInWindow: readonly { endMs: number; billingDemandKW: number }[],
|
|
656
|
+
tariffBasis: { billingDemandKW?: number } | undefined,
|
|
563
657
|
lastBill: { billingDemandKW?: number } | undefined,
|
|
564
|
-
declaredKW: number | undefined
|
|
658
|
+
declaredKW: number | undefined,
|
|
659
|
+
/** 이 창에서 관측한 최고 수요와 그 값이 언제까지의 것인지 — 사용 구간의 `maxKW` 에서 온다. */
|
|
660
|
+
observedPeak?: { kW: number; toMs: number }
|
|
565
661
|
): NonNullable<KpiResult['energy']>['billingDemand'] | undefined {
|
|
566
662
|
const declared = Number.isFinite(Number(declaredKW)) ? Number(declaredKW) : undefined
|
|
567
663
|
const withValue = billsInWindow.filter(b => Number.isFinite(b.billingDemandKW))
|
|
568
664
|
const fromBill = withValue.length ? withValue.reduce((a, x) => (x.endMs > a.endMs ? x : a)).billingDemandKW : undefined
|
|
569
665
|
const carried = Number.isFinite(Number(lastBill?.billingDemandKW)) ? Number(lastBill?.billingDemandKW) : undefined
|
|
666
|
+
/* 이 주기에 적용되는 기준이 가장 먼저다 — 청구서는 끝난 기간의 것이다. */
|
|
667
|
+
const fromBasis = Number.isFinite(Number(tariffBasis?.billingDemandKW)) ? Number(tariffBasis?.billingDemandKW) : undefined
|
|
570
668
|
|
|
571
669
|
const picked =
|
|
572
|
-
|
|
573
|
-
? { kW:
|
|
574
|
-
:
|
|
575
|
-
? { kW:
|
|
576
|
-
:
|
|
577
|
-
? { kW:
|
|
578
|
-
: undefined
|
|
670
|
+
fromBasis !== undefined
|
|
671
|
+
? { kW: fromBasis, from: 'tariff-basis' as const }
|
|
672
|
+
: fromBill !== undefined
|
|
673
|
+
? { kW: fromBill, from: 'bill' as const }
|
|
674
|
+
: carried !== undefined
|
|
675
|
+
? { kW: carried, from: 'carried-forward' as const }
|
|
676
|
+
: declared !== undefined
|
|
677
|
+
? { kW: declared, from: 'declaration' as const }
|
|
678
|
+
: undefined
|
|
579
679
|
if (!picked) return undefined
|
|
580
680
|
|
|
581
681
|
/* 선언이 있는데 청구서와 다르면 그 사실을 낸다 — 낡은 선언은 스스로 말해야 한다. */
|
|
582
682
|
const stale = picked.from !== 'declaration' && declared !== undefined && declared !== picked.kW
|
|
683
|
+
const exceeded =
|
|
684
|
+
observedPeak && observedPeak.kW > picked.kW
|
|
685
|
+
? {
|
|
686
|
+
peakKW: observedPeak.kW,
|
|
687
|
+
overKW: observedPeak.kW - picked.kW,
|
|
688
|
+
observedTo: new Date(observedPeak.toMs).toISOString()
|
|
689
|
+
}
|
|
690
|
+
: undefined
|
|
691
|
+
|
|
583
692
|
return {
|
|
584
693
|
...picked,
|
|
585
694
|
...(declared !== undefined ? { declaredKW: declared } : {}),
|
|
586
|
-
...(stale ? { stale: true } : {})
|
|
695
|
+
...(stale ? { stale: true } : {}),
|
|
696
|
+
...(exceeded ? { exceeded } : {})
|
|
587
697
|
}
|
|
588
698
|
}
|
|
589
699
|
|
|
@@ -720,6 +830,26 @@ function measuredFeeders(
|
|
|
720
830
|
}
|
|
721
831
|
|
|
722
832
|
/** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */
|
|
833
|
+
/*
|
|
834
|
+
* ── 같은 사실이 두 벌 있어도 한 번만 센다 (2026-08-30) ──────────────────────
|
|
835
|
+
*
|
|
836
|
+
* 마감된 구간 사실은 정체성이 내용에 있다(커널 0.7.81). 그런데 저널에 이미 여러 벌 적힌 것이 있다 —
|
|
837
|
+
* 쓰는 자리에서 막기 전에 들어간 것들이다(실측: 어느 날의 발전 단가가 세 벌).
|
|
838
|
+
*
|
|
839
|
+
* 지난 기록을 지우지 않는다 — 저널은 일어난 일의 기록이다. 대신 **읽을 때 한 번만 센다.**
|
|
840
|
+
* 그러지 않으면 그 구간의 수익·사용량이 그 횟수만큼 세어진다.
|
|
841
|
+
*/
|
|
842
|
+
function onceById<T extends { payload?: any }>(rows: readonly T[]): T[] {
|
|
843
|
+
const seen = new Set<string>()
|
|
844
|
+
return rows.filter(r => {
|
|
845
|
+
const id = (r as any)?.payload?.eventId
|
|
846
|
+
if (typeof id !== 'string' || !id) return true
|
|
847
|
+
if (seen.has(id)) return false
|
|
848
|
+
seen.add(id)
|
|
849
|
+
return true
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
|
|
723
853
|
export interface KpiFoldOptions {
|
|
724
854
|
groupBy?: KpiGroupBy
|
|
725
855
|
/**
|
|
@@ -729,6 +859,24 @@ export interface KpiFoldOptions {
|
|
|
729
859
|
* 커널 상태에서 읽어 넘긴다.
|
|
730
860
|
*/
|
|
731
861
|
lastBill?: { to: string; billingDemandKW?: number }
|
|
862
|
+
/**
|
|
863
|
+
* 이 트윈이 이어받고 있는 **요금 기준** — 이 주기에 적용되는 값(§`ENERGY_EVENT.tariffBasis`).
|
|
864
|
+
*
|
|
865
|
+
* 청구서보다 먼저다. 청구서는 끝난 기간의 정산이라 이번 주기의 기준을 말하지 않는다.
|
|
866
|
+
*/
|
|
867
|
+
tariffBasis?: { from: string; billingDemandKW?: number }
|
|
868
|
+
/**
|
|
869
|
+
* 이 트윈의 **발전 정격**과 그 근거 — 이용률의 분모(§ 커널 `generationRated`).
|
|
870
|
+
*
|
|
871
|
+
* 모델의 선언에서 오므로 호출부가 읽어 넘긴다. 폴드는 저널만 본다.
|
|
872
|
+
*/
|
|
873
|
+
generationRated?: { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' }
|
|
874
|
+
/**
|
|
875
|
+
* 이 트윈이 이어받고 있는 **발전 단가** — 창 밖의 단가도 이 창에 적용될 수 있다.
|
|
876
|
+
*
|
|
877
|
+
* 창 안의 단가 사실이 먼저다. 이것은 그 창에 단가가 없을 때 쓴다.
|
|
878
|
+
*/
|
|
879
|
+
generationPrice?: { from: string; to: string; unitPrice: number; currency: string }
|
|
732
880
|
/**
|
|
733
881
|
* 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
|
|
734
882
|
* 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(알리지 않고 다른 축으로 바꾸지 않는다).
|
|
@@ -1024,8 +1172,7 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1024
1172
|
*
|
|
1025
1173
|
* 관측 구간(`observedFrom`·`observedTo`)을 함께 읽는다 — 값의 근거가 얼마나 덮였는지가 그 둘에 있다.
|
|
1026
1174
|
*/
|
|
1027
|
-
const generationPeriods = events
|
|
1028
|
-
.filter(e => e.eventType === ENERGY_EVENT.generationPeriod)
|
|
1175
|
+
const generationPeriods = onceById(events.filter(e => e.eventType === ENERGY_EVENT.generationPeriod))
|
|
1029
1176
|
.map(e => {
|
|
1030
1177
|
const d = e.payload?.data ?? e.payload ?? {}
|
|
1031
1178
|
return {
|
|
@@ -1042,8 +1189,7 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1042
1189
|
/*
|
|
1043
1190
|
* 밖에서 마감되어 온 사용 구간 — 구간 마감과 같은 규칙으로 **창에 온전히 든 것만** 센다.
|
|
1044
1191
|
*/
|
|
1045
|
-
const usagePeriods = events
|
|
1046
|
-
.filter(e => e.eventType === ENERGY_EVENT.usagePeriod)
|
|
1192
|
+
const usagePeriods = onceById(events.filter(e => e.eventType === ENERGY_EVENT.usagePeriod))
|
|
1047
1193
|
.map(e => {
|
|
1048
1194
|
const d = e.payload?.data ?? e.payload ?? {}
|
|
1049
1195
|
return {
|
|
@@ -1052,6 +1198,7 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1052
1198
|
startMs: Date.parse(String(d.from ?? '')),
|
|
1053
1199
|
endMs: Date.parse(String(d.to ?? '')),
|
|
1054
1200
|
unitPrice: Number(d.unitPrice),
|
|
1201
|
+
maxKW: Number(d.maxKW),
|
|
1055
1202
|
currency: d.currency ? String(d.currency) : undefined,
|
|
1056
1203
|
basis: d.basis ? String(d.basis) : 'unstated'
|
|
1057
1204
|
}
|
|
@@ -1059,8 +1206,7 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1059
1206
|
.filter(u => u.meterId && Number.isFinite(u.kWh) && Number.isFinite(u.startMs) && Number.isFinite(u.endMs))
|
|
1060
1207
|
|
|
1061
1208
|
/* 청구서 — 창에 온전히 든 것 중 가장 늦게 끝난 것 하나. */
|
|
1062
|
-
const bills = events
|
|
1063
|
-
.filter(e => e.eventType === ENERGY_EVENT.bill)
|
|
1209
|
+
const bills = onceById(events.filter(e => e.eventType === ENERGY_EVENT.bill))
|
|
1064
1210
|
.map(e => {
|
|
1065
1211
|
const d = e.payload?.data ?? e.payload ?? {}
|
|
1066
1212
|
return {
|
|
@@ -1077,6 +1223,19 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1077
1223
|
})
|
|
1078
1224
|
.filter(b => Number.isFinite(b.startMs) && Number.isFinite(b.endMs))
|
|
1079
1225
|
|
|
1226
|
+
/* 발전 단가 — 기간마다 그때의 값이다(날마다 바뀐다). */
|
|
1227
|
+
const generationPrices = onceById(events.filter(e => e.eventType === ENERGY_EVENT.generationPrice))
|
|
1228
|
+
.map(e => {
|
|
1229
|
+
const d = e.payload?.data ?? e.payload ?? {}
|
|
1230
|
+
return {
|
|
1231
|
+
startMs: Date.parse(String(d.from ?? '')),
|
|
1232
|
+
endMs: Date.parse(String(d.to ?? '')),
|
|
1233
|
+
unitPrice: Number(d.unitPrice),
|
|
1234
|
+
currency: String(d.currency ?? '')
|
|
1235
|
+
}
|
|
1236
|
+
})
|
|
1237
|
+
.filter(p => Number.isFinite(p.startMs) && Number.isFinite(p.endMs) && Number.isFinite(p.unitPrice) && p.currency)
|
|
1238
|
+
|
|
1080
1239
|
const leads: number[] = []
|
|
1081
1240
|
const works: number[] = []
|
|
1082
1241
|
const waits: number[] = []
|
|
@@ -1140,10 +1299,17 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1140
1299
|
* 다른 단가가 사라지고, 「피크를 깎아 얼마를 아끼나」가 그 차이 위에 선다.
|
|
1141
1300
|
*/
|
|
1142
1301
|
/* 요금적용전력은 청구서가 먼저다 — 선언은 청구가 오지 않는 현장의 몫이다. */
|
|
1302
|
+
/* 이 창의 최고 수요 — 사용 구간이 실어 온 값 중 가장 큰 것(원본 계량기가 잰 값이다). */
|
|
1303
|
+
const peaks = usagePeriods
|
|
1304
|
+
.filter(u => u.startMs >= window.fromMs && u.endMs <= window.toMs && Number.isFinite(u.maxKW))
|
|
1305
|
+
.map(u => ({ kW: u.maxKW as number, toMs: u.endMs }))
|
|
1306
|
+
const observedPeak = peaks.length ? peaks.reduce((a, x) => (x.kW > a.kW ? x : a)) : undefined
|
|
1143
1307
|
const billingDemand = billingDemandOf(
|
|
1144
1308
|
bills.filter(b => Number.isFinite(b.billingDemandKW)).map(b => ({ endMs: b.endMs, billingDemandKW: b.billingDemandKW })),
|
|
1309
|
+
options.tariffBasis,
|
|
1145
1310
|
options.lastBill,
|
|
1146
|
-
options.tariff?.billingDemandKW
|
|
1311
|
+
options.tariff?.billingDemandKW,
|
|
1312
|
+
observedPeak
|
|
1147
1313
|
)
|
|
1148
1314
|
const cost = options.tariff
|
|
1149
1315
|
? electricityCost({
|
|
@@ -1176,7 +1342,24 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
1176
1342
|
feederShares(e.kWh, utilization, options.resourceFeeder)),
|
|
1177
1343
|
/* 금액은 같은 창의 전력량·최대수요에서 나온다 — 창이 하나뿐이라 어긋날 수 없다. */
|
|
1178
1344
|
...(cost ? { cost } : {}),
|
|
1179
|
-
...generatedOfPeriods(
|
|
1345
|
+
...generatedOfPeriods(
|
|
1346
|
+
generationPeriods,
|
|
1347
|
+
{ startMs: window.fromMs, endMs: window.toMs },
|
|
1348
|
+
options.generationRated,
|
|
1349
|
+
/* 창 안의 단가 사실이 먼저이고, 없으면 이어받은 마지막 단가를 쓴다. */
|
|
1350
|
+
generationPrices.length
|
|
1351
|
+
? generationPrices
|
|
1352
|
+
: options.generationPrice
|
|
1353
|
+
? [
|
|
1354
|
+
{
|
|
1355
|
+
startMs: Date.parse(options.generationPrice.from),
|
|
1356
|
+
endMs: Date.parse(options.generationPrice.to),
|
|
1357
|
+
unitPrice: options.generationPrice.unitPrice,
|
|
1358
|
+
currency: options.generationPrice.currency
|
|
1359
|
+
}
|
|
1360
|
+
]
|
|
1361
|
+
: []
|
|
1362
|
+
),
|
|
1180
1363
|
/* 청구서는 사실이다 — 우리 계산을 덮지 않고 나란히 둔다(§`billOfWindow`). */
|
|
1181
1364
|
...(() => {
|
|
1182
1365
|
const b = billOfWindow(bills, { startMs: window.fromMs, endMs: window.toMs }, cost as any)
|