@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.
Files changed (122) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +12 -85
  2. package/dist-server/engine/canonical-ingest.js +92 -26
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/energy-topology.js +3 -3
  5. package/dist-server/engine/energy-topology.js.map +1 -1
  6. package/dist-server/engine/kpi-fold.d.ts +91 -3
  7. package/dist-server/engine/kpi-fold.js +149 -25
  8. package/dist-server/engine/kpi-fold.js.map +1 -1
  9. package/dist-server/engine/kpi-query.js +99 -11
  10. package/dist-server/engine/kpi-query.js.map +1 -1
  11. package/dist-server/engine/local-declarations.js +6 -5
  12. package/dist-server/engine/local-declarations.js.map +1 -1
  13. package/dist-server/engine/model-vocabulary.js +2 -2
  14. package/dist-server/engine/model-vocabulary.js.map +1 -1
  15. package/dist-server/engine/oee-accumulator.d.ts +2 -1
  16. package/dist-server/engine/oee-accumulator.js +3 -2
  17. package/dist-server/engine/oee-accumulator.js.map +1 -1
  18. package/dist-server/engine/twin-engine.d.ts +72 -2
  19. package/dist-server/engine/twin-engine.js +150 -4
  20. package/dist-server/engine/twin-engine.js.map +1 -1
  21. package/dist-server/routes.js +1 -1
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/index.d.ts +1 -1
  24. package/dist-server/service/index.js +3 -0
  25. package/dist-server/service/index.js.map +1 -1
  26. package/dist-server/service/reference/reference-live.js +1 -1
  27. package/dist-server/service/reference/reference-live.js.map +1 -1
  28. package/dist-server/service/reference/reference-master.d.ts +11 -2
  29. package/dist-server/service/reference/reference-master.js +1 -1
  30. package/dist-server/service/reference/reference-master.js.map +1 -1
  31. package/dist-server/service/reference/reference-resolver.js +2 -2
  32. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  33. package/dist-server/service/reference/template-registry.d.ts +1 -1
  34. package/dist-server/service/reference/template-registry.js.map +1 -1
  35. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  36. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  37. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  38. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  39. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  40. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  41. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  42. package/dist-server/service/twin-backfill/index.js +6 -0
  43. package/dist-server/service/twin-backfill/index.js.map +1 -0
  44. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  45. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  46. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  47. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  48. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  49. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  50. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  51. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  52. package/dist-server/service/twin-model/name-index.js +1 -1
  53. package/dist-server/service/twin-model/name-index.js.map +1 -1
  54. package/dist-server/service/twin-model/project-structure.js +4 -3
  55. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  56. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  57. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  58. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  59. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  60. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  61. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  62. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  63. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  64. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  65. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  66. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  67. package/dist-shared/entity-delta.js +1 -1
  68. package/dist-shared/entity-delta.js.map +1 -1
  69. package/package.json +7 -6
  70. package/server/engine/canonical-ingest.ts +72 -84
  71. package/server/engine/energy-topology.ts +1 -1
  72. package/server/engine/kpi-fold.ts +219 -36
  73. package/server/engine/kpi-query.ts +97 -4
  74. package/server/engine/local-declarations.ts +2 -1
  75. package/server/engine/model-vocabulary.ts +1 -1
  76. package/server/engine/oee-accumulator.ts +4 -2
  77. package/server/engine/twin-engine.ts +163 -5
  78. package/server/routes.ts +6 -1
  79. package/server/service/index.ts +3 -0
  80. package/server/service/reference/reference-live.ts +8 -2
  81. package/server/service/reference/reference-master.ts +12 -3
  82. package/server/service/reference/reference-resolver.ts +1 -1
  83. package/server/service/reference/template-registry.ts +1 -1
  84. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  85. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  86. package/server/service/twin-backfill/index.ts +3 -0
  87. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  88. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  89. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  90. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  91. package/server/service/twin-model/name-index.ts +1 -1
  92. package/server/service/twin-model/project-structure.ts +3 -2
  93. package/server/service/twin-model/twin-equipment.ts +14 -2
  94. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  95. package/server/service/twin-model/twin-model-query.ts +2 -2
  96. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  97. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  98. package/shared/entity-delta.ts +1 -1
  99. package/test/axis-read.test.ts +4 -3
  100. package/test/backfill-shape.test.ts +89 -0
  101. package/test/broadcast-cost-baseline.test.ts +2 -1
  102. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  103. package/test/capability-mapping.test.ts +1 -1
  104. package/test/contract-layer-guard.test.ts +48 -0
  105. package/test/declaration-reaches-model.test.ts +2 -0
  106. package/test/equipment-identity-reaches.test.ts +57 -0
  107. package/test/fact-scope-wiring.test.ts +96 -0
  108. package/test/generated-not-counted.test.ts +99 -0
  109. package/test/ingest-bench.test.ts +2 -1
  110. package/test/ingest-wiring-guard.test.ts +197 -0
  111. package/test/kpi-fold.test.ts +81 -0
  112. package/test/live-mirror-parity.test.ts +6 -3
  113. package/test/local-declarations.test.ts +3 -2
  114. package/test/master-to-twin.test.ts +2 -2
  115. package/test/oee-accumulator.test.ts +18 -3
  116. package/test/projection-reaches-screen.test.ts +5 -0
  117. package/test/projection-reads-declared.test.ts +109 -0
  118. package/test/property-effects.test.ts +7 -3
  119. package/test/scale-twin-bench.test.ts +2 -1
  120. package/test/vocabulary-guard.test.ts +1 -1
  121. package/tsconfig.shared.tsbuildinfo +1 -1
  122. package/tsconfig.tsbuildinfo +1 -1
@@ -1,11 +1,5 @@
1
- import { ingest, ingestEnergyRecords, ingestEnergyEquipmentRecords, ingestEnergyGenerationRecords,
2
- ingestEnergyUsagePeriodRecords, ingestEnergyBillRecords, ingestOperationalRecords,
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
- export interface CanonicalRecord {
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
- * **없는 쪽은 필드를 만들지 않는다.** 빈 배열을 실으면 「없다」고 말하는 것이 된다. 소실(N→0)과
159
- * 생성(0→N)도 표준이 인정하는 변환이다.
118
+ * 여기 있던 이유는 소비 방식 때문이었다 — EPCIS 는 커널의 범용 어댑터를 지나서 타입을 요구하지 않았다.
119
+ * 그런데 모양 자체는 EPCIS 2.0 의 필드 이름이고 호스트의 것이 아니다. 이제 MES 가 이 모양 그대로
120
+ * 보낸다.
160
121
  *
161
- * `transformationID` 가 한 오더의 여러 단계를 잇는다 — 소비를 먼저 알고 산출을 나중에 알게 되는 원본에서
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/twin-kernel'
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
- ENERGY_EVENT,
30
- attributeEnergy,
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
- if (!inside.length) return {}
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: inside.reduce((a, g) => a + g.kWh, 0),
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
- fromBill !== undefined
573
- ? { kW: fromBill, from: 'bill' as const }
574
- : carried !== undefined
575
- ? { kW: carried, from: 'carried-forward' as const }
576
- : declared !== undefined
577
- ? { kW: declared, from: 'declaration' as const }
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(generationPeriods, { startMs: window.fromMs, endMs: window.toMs }),
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)