@golemio/energetics 1.16.2-dev.2870938995 → 1.17.0-dev.2892367644

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 (19) hide show
  1. package/db/example/00_clear_test_data.sql +1 -0
  2. package/db/example/09_enapo_consumption_history.sql +42 -0
  3. package/db/migrations/postgresql/20260923090000-enapo-consumption-history-eb.js +53 -0
  4. package/db/migrations/postgresql/sqls/20260923090000-enapo-consumption-history-eb-down.sql +452 -0
  5. package/db/migrations/postgresql/sqls/20260923090000-enapo-consumption-history-eb-up.sql +511 -0
  6. package/dist/integration-engine/enapo/repositories/AbstractMaterializedViewIndexRepository.d.ts +1 -0
  7. package/dist/integration-engine/enapo/repositories/AbstractMaterializedViewIndexRepository.js +10 -1
  8. package/dist/integration-engine/enapo/repositories/AbstractMaterializedViewIndexRepository.js.map +1 -1
  9. package/dist/output-gateway/models/interfaces/IEnapoOmConsumptionView.d.ts +1 -1
  10. package/dist/output-gateway/routers/interfaces/IEnoBuildingDetailResponse.d.ts +18 -0
  11. package/dist/output-gateway/transformations/EnoBuildingDetailTransformation.d.ts +2 -0
  12. package/dist/output-gateway/transformations/EnoBuildingDetailTransformation.js +18 -5
  13. package/dist/output-gateway/transformations/EnoBuildingDetailTransformation.js.map +1 -1
  14. package/dist/output-gateway/transformations/helpers/ConsumptionHistoryBuilder.d.ts +16 -1
  15. package/dist/output-gateway/transformations/helpers/ConsumptionHistoryBuilder.js +114 -4
  16. package/dist/output-gateway/transformations/helpers/ConsumptionHistoryBuilder.js.map +1 -1
  17. package/docs/implementation_documentation.md +3 -1
  18. package/docs/openapi-output.yaml +56 -1
  19. package/package.json +1 -1
@@ -0,0 +1,511 @@
1
+ DROP MATERIALIZED VIEW IF EXISTS mv_enapo_om_consumption;
2
+
3
+ CREATE MATERIALIZED VIEW mv_enapo_om_consumption AS
4
+ WITH bounds AS (
5
+ SELECT
6
+ ((date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months')
7
+ AT TIME ZONE 'Europe/Prague') AS month_from_tstz,
8
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months') AS month_from,
9
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague')
10
+ AT TIME ZONE 'Europe/Prague') AS month_to_tstz,
11
+ date_trunc('month', now() AT TIME ZONE 'Europe/Prague') AS month_to
12
+ ),
13
+
14
+ point AS (
15
+ SELECT DISTINCT gid, place_id, id_type, commodity
16
+ FROM v_enapo_building_consumption_points
17
+ ),
18
+
19
+ electricity_metered AS (
20
+ SELECT
21
+ em.id AS place_id,
22
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS period_start,
23
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum') AS total_kwh,
24
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_DaySum') AS vt_kwh,
25
+ sum(em.value) FILTER (WHERE em.var = 'EActi_NT_DaySum') AS nt_kwh,
26
+ -- Coverage is the max day-count across the three vars, not just the total's.
27
+ GREATEST(
28
+ count(*) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum'),
29
+ count(*) FILTER (WHERE em.var = 'EActi_VT_DaySum'),
30
+ count(*) FILTER (WHERE em.var = 'EActi_NT_DaySum')
31
+ ) AS days_covered
32
+ FROM enapo_measurements em
33
+ CROSS JOIN bounds b
34
+ WHERE em.source = 'predi_input'
35
+ AND em.var IN ('EActi_VT_NT_DaySum', 'EActi_VT_DaySum', 'EActi_NT_DaySum')
36
+ AND em."timestamp" >= b.month_from_tstz
37
+ AND em."timestamp" < b.month_to_tstz
38
+ GROUP BY em.id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
39
+ ),
40
+
41
+ gas_registration AS (
42
+ SELECT DISTINCT ON (em.id, em.device_serial_number)
43
+ em.id,
44
+ em.device_serial_number,
45
+ em.place_id
46
+ FROM enapo_measurements em
47
+ CROSS JOIN bounds b
48
+ WHERE em.source = 'ppas_ave_api'
49
+ AND em.var IN ('core2', 'corediff')
50
+ AND em."timestamp" >= b.month_from_tstz
51
+ GROUP BY em.id, em.device_serial_number, em.place_id
52
+ ORDER BY em.id, em.device_serial_number, max(em."timestamp") DESC, em.place_id DESC
53
+ ),
54
+ gas_core_month AS (
55
+ -- Cumulative 'core' reading (m3) at the first measured hour of each first-of-month day, kept
56
+ -- per (id, device_serial_number, place_id) - an id can carry several registrations (see
57
+ -- gas_registration) whose diffs must be summed separately, not collapsed into one reading.
58
+ SELECT DISTINCT ON (em.id, em.device_serial_number, em.place_id,
59
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'))
60
+ em.id,
61
+ em.device_serial_number,
62
+ em.place_id,
63
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS month_start,
64
+ em.value
65
+ FROM enapo_measurements em
66
+ CROSS JOIN bounds b
67
+ INNER JOIN gas_registration gr
68
+ ON gr.id = em.id
69
+ AND gr.device_serial_number = em.device_serial_number
70
+ AND gr.place_id = em.place_id
71
+ WHERE em.source = 'ppas_ave_api'
72
+ AND em.var = 'core'
73
+ AND (em."timestamp" AT TIME ZONE 'Europe/Prague')::date
74
+ = date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
75
+ AND em."timestamp" >= b.month_from_tstz
76
+ AND em."timestamp" < (b.month_to_tstz + interval '1 month')
77
+ ORDER BY em.id, em.device_serial_number, em.place_id,
78
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'), em."timestamp"
79
+ ),
80
+ gas_metered AS (
81
+ SELECT
82
+ cur.id AS place_id,
83
+ cur.month_start AS period_start,
84
+ -- A negative diff means the meter was exchanged/reset between the two boundary readings -
85
+ -- null rather than a nonsense negative volume.
86
+ (CASE WHEN sum(nxt.value - cur.value) < 0 THEN NULL ELSE sum(nxt.value - cur.value) END) AS m3_sum,
87
+ ((cur.month_start + interval '1 month')::date - cur.month_start) AS days_covered
88
+ FROM gas_core_month cur
89
+ INNER JOIN gas_core_month nxt
90
+ ON nxt.id = cur.id
91
+ AND nxt.device_serial_number = cur.device_serial_number
92
+ AND nxt.place_id = cur.place_id
93
+ AND nxt.month_start = (cur.month_start + interval '1 month')::date
94
+ GROUP BY cur.id, cur.month_start
95
+ ),
96
+
97
+ distribution_device AS (
98
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
99
+ ins.eic,
100
+ d.place_id,
101
+ d.invoice_id,
102
+ d.date_from,
103
+ d.date_to,
104
+ d.kind,
105
+ d.unit,
106
+ d.consumption,
107
+ d.normometers_nm3,
108
+ d.gas_consumption_kwh,
109
+ d.combustion_heat
110
+ FROM enapo_ppas_distribution_invoice_device d
111
+ INNER JOIN enapo_ppas_distribution_invoice i
112
+ ON i.id = d.invoice_id
113
+ INNER JOIN enapo_ppas_distribution_installation ins
114
+ ON ins.invoice_id = d.invoice_id
115
+ AND ins.customer_id = i.customer_id
116
+ AND ins.place_id = d.place_id
117
+ WHERE COALESCE(i.is_canceled, false) = false
118
+ AND d.date_to >= d.date_from
119
+ AND d.consumption IS NOT NULL
120
+ AND d.kind IN ('ZIZWC', 'ZIABN3')
121
+ AND d.reading_type = 'STANDARD'
122
+ AND NOT EXISTS (
123
+ SELECT 1 FROM enapo_ppas_distribution_invoice s
124
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
125
+ )
126
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
127
+ i.facts_doc_date DESC, i.id DESC
128
+ ),
129
+ commercial_device AS (
130
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
131
+ ins.eic,
132
+ d.place_id,
133
+ d.invoice_id,
134
+ d.date_from,
135
+ d.date_to,
136
+ d.kind,
137
+ d.unit,
138
+ d.consumption,
139
+ d.normometers_nm3,
140
+ d.gas_consumption_kwh,
141
+ d.combustion_heat
142
+ FROM enapo_ppas_commercial_invoice_device d
143
+ INNER JOIN enapo_ppas_commercial_invoice i
144
+ ON i.id = d.invoice_id
145
+ INNER JOIN enapo_ppas_commercial_invoice_installations ins
146
+ ON ins.invoice_id = d.invoice_id
147
+ AND ins.customer_id = i.customer_id
148
+ AND ins.place_id = d.place_id
149
+ WHERE COALESCE(i.is_canceled, false) = false
150
+ AND d.date_to >= d.date_from
151
+ AND d.consumption IS NOT NULL
152
+ AND d.reading_type = 'STANDARD'
153
+ AND NOT EXISTS (
154
+ SELECT 1 FROM enapo_ppas_commercial_invoice s
155
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
156
+ )
157
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
158
+ i.facts_doc_date DESC, i.id DESC
159
+ ),
160
+
161
+ invoice_device_month AS (
162
+ SELECT
163
+ 'ppas_distribution_invoice' AS source,
164
+ d.eic AS place_id,
165
+ d.invoice_id,
166
+ gs.month::date AS period_start,
167
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share AS volume,
168
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END AS volume_unit,
169
+ d.normometers_nm3 * o.share AS nm3,
170
+ NULLIF(d.gas_consumption_kwh, 0) * o.share AS kwh,
171
+ (o.share < 1) AS is_estimated,
172
+ o.overlap_days AS days_covered
173
+ FROM distribution_device d
174
+ CROSS JOIN bounds b
175
+ CROSS JOIN LATERAL generate_series(
176
+ date_trunc('month', d.date_from::timestamp),
177
+ date_trunc('month', d.date_to::timestamp),
178
+ interval '1 month'
179
+ ) gs(month)
180
+ CROSS JOIN LATERAL (
181
+ SELECT
182
+ ov.overlap_days,
183
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
184
+ FROM (
185
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
186
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
187
+ ) ov
188
+ ) o
189
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
190
+
191
+ UNION ALL
192
+
193
+ SELECT
194
+ 'ppas_commercial_invoice',
195
+ d.eic,
196
+ d.invoice_id,
197
+ gs.month::date,
198
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share,
199
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END,
200
+ d.normometers_nm3 * o.share,
201
+ NULLIF(d.gas_consumption_kwh, 0) * o.share,
202
+ (o.share < 1),
203
+ o.overlap_days
204
+ FROM commercial_device d
205
+ CROSS JOIN bounds b
206
+ CROSS JOIN LATERAL generate_series(
207
+ date_trunc('month', d.date_from::timestamp),
208
+ date_trunc('month', d.date_to::timestamp),
209
+ interval '1 month'
210
+ ) gs(month)
211
+ CROSS JOIN LATERAL (
212
+ SELECT
213
+ ov.overlap_days,
214
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
215
+ FROM (
216
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
217
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
218
+ ) ov
219
+ ) o
220
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
221
+ ),
222
+ invoice_month AS (
223
+ SELECT
224
+ source,
225
+ place_id,
226
+ period_start,
227
+ sum(volume) AS volume,
228
+ max(volume_unit) AS volume_unit,
229
+ sum(nm3) AS nm3,
230
+ sum(kwh) AS kwh,
231
+ bool_or(is_estimated) AS is_estimated,
232
+ -- Clamp to the month's length: sum() would exceed it when meters are billed in parallel.
233
+ LEAST(
234
+ sum(days_covered),
235
+ (period_start + interval '1 month')::date - period_start
236
+ ) AS days_covered,
237
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
238
+ FROM invoice_device_month
239
+ GROUP BY source, place_id, period_start
240
+ ),
241
+ invoice_year AS (
242
+ SELECT
243
+ source,
244
+ place_id,
245
+ date_trunc('year', period_start)::date AS period_start,
246
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
247
+ FROM invoice_device_month
248
+ GROUP BY source, place_id, date_trunc('year', period_start)::date
249
+ ),
250
+
251
+ porsenna_period AS (
252
+ SELECT *
253
+ FROM (
254
+ SELECT
255
+ d.identifier AS place_id,
256
+ c.commodity,
257
+ c."var" AS period_type,
258
+ f_enapo_safe_period(c."var", c."period") AS period_start,
259
+ c.value,
260
+ c.unit,
261
+ c."count" AS days_covered
262
+ FROM enapo_porsenna_consumption c
263
+ INNER JOIN enapo_porsenna_devices d
264
+ ON d.source = c.source AND d.id = c.device_id
265
+ WHERE d.identifier IS NOT NULL
266
+ AND (d."level" IS NULL OR d."level" = 'main')
267
+ AND c.commodity IS NOT NULL
268
+ AND c."var" IN ('yearly', 'monthly')
269
+ ) parsed
270
+ WHERE parsed.period_start IS NOT NULL
271
+ ),
272
+
273
+ eb_month AS (
274
+ -- Energy Broker history, full calendar months only: a period that is longer or does not start on
275
+ -- the 1st needs the pro-rata methodology, which is a separate step. One EAN/EIC can appear under
276
+ -- two idodbmistoom with identical values (a re-registered OM), so one row per month is kept instead
277
+ -- of summing them into a double. The highest idodbmisto_rspotrebyfa stands in for the newest row: the
278
+ -- one-off import has no timestamp, and in production the duplicates carry identical values.
279
+ SELECT DISTINCT ON (eb.ean_eic, eb.komodita, eb.datum_od)
280
+ eb.ean_eic AS place_id,
281
+ CASE eb.komodita WHEN 1 THEN 'electricity' WHEN 2 THEN 'gas' END AS commodity,
282
+ eb.datum_od AS period_start,
283
+ eb.spotreba_mwh * 1000 AS value_kwh,
284
+ eb.datum_do - eb.datum_od + 1 AS days_covered
285
+ FROM enapo_eb_consumption eb
286
+ CROSS JOIN bounds b
287
+ WHERE eb.spotreba_mwh IS NOT NULL
288
+ AND eb.datum_od = date_trunc('month', eb.datum_od)::date
289
+ AND eb.datum_do = (date_trunc('month', eb.datum_od) + interval '1 month - 1 day')::date
290
+ AND eb.datum_od >= b.month_from
291
+ AND eb.datum_od < b.month_to
292
+ ORDER BY eb.ean_eic, eb.komodita, eb.datum_od, eb.idodbmisto_rspotrebyfa DESC
293
+ ),
294
+
295
+ live_monthly AS (
296
+ SELECT
297
+ p.gid,
298
+ p.place_id,
299
+ p.id_type,
300
+ p.commodity,
301
+ 'predi_input'::varchar(50) AS source,
302
+ 'monthly'::varchar(10) AS period_type,
303
+ e.period_start,
304
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value,
305
+ 'kWh'::varchar(10) AS unit,
306
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value_kwh,
307
+ 'EActi_VT_NT_DaySum'::varchar(50) AS internal_source_var,
308
+ e.days_covered,
309
+ false AS is_estimated,
310
+ '{}'::text[] AS invoice_ids
311
+ FROM point p
312
+ INNER JOIN electricity_metered e ON e.place_id = p.place_id
313
+ WHERE p.commodity = 'electricity'
314
+
315
+ UNION ALL
316
+
317
+ SELECT
318
+ p.gid,
319
+ p.place_id,
320
+ p.id_type,
321
+ p.commodity,
322
+ 'ppas_ave_api',
323
+ 'monthly',
324
+ g.period_start,
325
+ g.m3_sum,
326
+ 'm3',
327
+ NULL::numeric,
328
+ 'core',
329
+ g.days_covered,
330
+ false,
331
+ '{}'::text[]
332
+ FROM point p
333
+ INNER JOIN gas_metered g ON g.place_id = p.place_id
334
+ WHERE p.commodity = 'gas'
335
+
336
+ UNION ALL
337
+
338
+ SELECT
339
+ p.gid,
340
+ p.place_id,
341
+ p.id_type,
342
+ p.commodity,
343
+ i.source,
344
+ 'monthly',
345
+ i.period_start,
346
+ COALESCE(i.volume, i.nm3::numeric),
347
+ CASE WHEN i.volume IS NOT NULL THEN i.volume_unit ELSE 'Nm3' END,
348
+ i.kwh,
349
+ NULL,
350
+ i.days_covered,
351
+ i.is_estimated,
352
+ i.invoice_ids
353
+ FROM point p
354
+ INNER JOIN invoice_month i ON i.place_id = p.place_id
355
+ WHERE p.commodity = 'gas'
356
+ ),
357
+
358
+ monthly AS (
359
+ SELECT * FROM live_monthly
360
+
361
+ UNION ALL
362
+
363
+ SELECT
364
+ p.gid,
365
+ p.place_id,
366
+ p.id_type,
367
+ p.commodity,
368
+ 'energy_broker',
369
+ 'monthly',
370
+ e.period_start,
371
+ e.value_kwh,
372
+ 'kWh',
373
+ e.value_kwh,
374
+ NULL,
375
+ e.days_covered,
376
+ false,
377
+ '{}'::text[]
378
+ FROM point p
379
+ INNER JOIN eb_month e ON e.place_id = p.place_id AND e.commodity = p.commodity
380
+ -- A live source that read the whole month in kWh wins, so the overlap is not charted twice. A pro-rata
381
+ -- invoice or a partially covered month does not win: the EB figure is billed for the whole month. Nor
382
+ -- does a volume-only row (the PPAS gas meter): EB is the only kWh for that month, which the combined
383
+ -- kWh series needs.
384
+ WHERE NOT EXISTS (
385
+ SELECT 1
386
+ FROM live_monthly l
387
+ WHERE l.place_id = e.place_id
388
+ AND l.commodity = e.commodity
389
+ AND l.period_start = e.period_start
390
+ AND l.value_kwh IS NOT NULL
391
+ AND NOT l.is_estimated
392
+ AND l.days_covered >= (e.period_start + interval '1 month')::date - e.period_start
393
+ )
394
+ ),
395
+
396
+ yearly_rollup AS (
397
+ SELECT
398
+ gid,
399
+ place_id,
400
+ id_type,
401
+ commodity,
402
+ source,
403
+ 'yearly'::varchar(10) AS period_type,
404
+ date_trunc('year', period_start)::date AS period_start,
405
+ sum(value) AS value,
406
+ unit,
407
+ -- count(*) = count(value_kwh) is an all-non-null check: only sum value_kwh when every month has one.
408
+ CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END AS value_kwh,
409
+ max(internal_source_var) AS internal_source_var,
410
+ sum(days_covered) AS days_covered,
411
+ bool_or(is_estimated) AS is_estimated
412
+ FROM monthly m
413
+ GROUP BY gid, place_id, id_type, commodity, source, date_trunc('year', period_start)::date, unit
414
+ ),
415
+ yearly AS (
416
+ SELECT
417
+ r.gid,
418
+ r.place_id,
419
+ r.id_type,
420
+ r.commodity,
421
+ r.source,
422
+ r.period_type,
423
+ r.period_start,
424
+ r.value,
425
+ r.unit,
426
+ r.value_kwh,
427
+ r.internal_source_var,
428
+ r.days_covered,
429
+ r.is_estimated,
430
+ COALESCE(iy.invoice_ids, '{}'::text[]) AS invoice_ids
431
+ FROM yearly_rollup r
432
+ LEFT JOIN invoice_year iy
433
+ ON iy.source = r.source AND iy.place_id = r.place_id AND iy.period_start = r.period_start
434
+
435
+ UNION ALL
436
+
437
+ SELECT
438
+ p.gid,
439
+ p.place_id,
440
+ p.id_type,
441
+ p.commodity,
442
+ 'porsenna',
443
+ 'yearly',
444
+ pp.period_start,
445
+ pp.value,
446
+ pp.unit,
447
+ CASE WHEN lower(pp.unit) = 'kwh' THEN pp.value ELSE NULL END,
448
+ NULL,
449
+ pp.days_covered,
450
+ false,
451
+ '{}'::text[]
452
+ FROM point p
453
+ INNER JOIN porsenna_period pp
454
+ ON pp.place_id = p.place_id AND pp.commodity = p.commodity
455
+ WHERE pp.period_type = 'yearly'
456
+ )
457
+
458
+ SELECT
459
+ gid::varchar AS gid,
460
+ place_id::varchar(50) AS place_id,
461
+ max(id_type)::varchar(10) AS id_type,
462
+ commodity::varchar(20) AS commodity,
463
+ source::varchar(50) AS source,
464
+ period_type::varchar(10) AS period_type,
465
+ period_start,
466
+ sum(value)::numeric(30, 6) AS value,
467
+ unit::varchar(10) AS unit,
468
+ (CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END)::numeric(30, 6) AS value_kwh,
469
+ max(internal_source_var)::varchar(50) AS internal_source_var,
470
+ COALESCE(max(invoice_ids), '{}'::text[]) AS invoice_ids,
471
+ max(days_covered)::int AS coverage_count,
472
+ (
473
+ CASE period_type
474
+ WHEN 'yearly' THEN (make_date(extract(year FROM period_start)::int + 1, 1, 1) - period_start)
475
+ ELSE (period_start + interval '1 month')::date - period_start
476
+ END
477
+ )::int AS expected_count,
478
+ bool_or(is_estimated) AS is_estimated
479
+ FROM (
480
+ SELECT * FROM monthly
481
+ UNION ALL
482
+ SELECT * FROM yearly
483
+ ) all_periods
484
+ WHERE value IS NOT NULL
485
+ GROUP BY gid, place_id, commodity, source, unit, period_type, period_start;
486
+
487
+
488
+ COMMENT ON MATERIALIZED VIEW mv_enapo_om_consumption IS
489
+ 'Monthly and yearly consumption per consumption point per source, for the last 48 months, behind the DeD building detail endpoint. One row per (gid, place_id, commodity, source, unit, period_type, period_start). Refreshed by EnapoConsumptionHistoryRefreshTask (integration engine).';
490
+
491
+ COMMENT ON COLUMN mv_enapo_om_consumption.place_id IS
492
+ 'EAN / EIC / OM code, as in enapo_gid_mapping.place_id - NOT enapo_measurements.place_id, which holds a PPAS-internal place id. Measurements are joined through enapo_measurements.id.';
493
+ COMMENT ON COLUMN mv_enapo_om_consumption.value IS
494
+ 'Consumption for the period in `unit`. Electricity is kWh (the upstream T1_Wh_total naming is wrong); metered gas is m3, the difference between the cumulative `core` reading on the first day of the next month and the first day of the current month; invoiced gas follows the invoice unit; Porsenna carries its own unit; Energy Broker (enapo_eb_consumption, full calendar months only, suppressed where a live source read the whole month) is kWh converted from MWh for both electricity and gas.';
495
+ COMMENT ON COLUMN mv_enapo_om_consumption.value_kwh IS
496
+ 'Derived comparable energy: for gas, normometers_nm3 * combustion_heat (verified exact). NULL when the conversion inputs are missing - `value` + `unit` stay authoritative. Always NULL for metered gas (no normometers_nm3 input).';
497
+ COMMENT ON COLUMN mv_enapo_om_consumption.internal_source_var IS
498
+ 'Which enapo_measurements.var produced the row; NULL for invoice and Porsenna rows. Provenance for debugging, not part of the public response.';
499
+ COMMENT ON COLUMN mv_enapo_om_consumption.invoice_ids IS
500
+ 'Billing documents the row was allocated from, sorted and deduplicated; empty for metered and Porsenna rows. A period can be allocated from several invoices (a yearly roll-up usually is), which is why it is an array.';
501
+ COMMENT ON COLUMN mv_enapo_om_consumption.coverage_count IS
502
+ 'Days of the period that carry data. Days for every commodity and both period types, so it is comparable with expected_count.';
503
+ COMMENT ON COLUMN mv_enapo_om_consumption.expected_count IS 'Days in the period (28-31, or 365/366).';
504
+ COMMENT ON COLUMN mv_enapo_om_consumption.is_estimated IS
505
+ 'True when the value was allocated pro rata from a billing period spanning more than one month, rather than read for the period itself.';
506
+
507
+ CREATE UNIQUE INDEX mv_enapo_om_consumption_uidx
508
+ ON mv_enapo_om_consumption (gid, place_id, commodity, source, unit, period_type, period_start);
509
+
510
+ CREATE INDEX mv_enapo_om_consumption_gid_period_idx
511
+ ON mv_enapo_om_consumption (gid, period_type, period_start);
@@ -5,4 +5,5 @@ export declare abstract class AbstractMaterializedViewIndexRepository {
5
5
  private errorMessage;
6
6
  constructor(connector: IDatabaseConnector, viewName: string, errorMessage: string);
7
7
  refresh(): Promise<void>;
8
+ private isPopulated;
8
9
  }
@@ -12,14 +12,23 @@ class AbstractMaterializedViewIndexRepository {
12
12
  }
13
13
  async refresh() {
14
14
  try {
15
+ // CONCURRENTLY keeps readers unblocked but refuses a view that was never populated, and a view a
16
+ // migration left empty would otherwise stay empty for good.
17
+ const mode = (await this.isPopulated()) ? "CONCURRENTLY " : "";
15
18
  await this.connector
16
19
  .getConnection()
17
- .query(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${constants_1.PG_SCHEMA}.${this.viewName}`, { type: sequelize_1.QueryTypes.RAW });
20
+ .query(`REFRESH MATERIALIZED VIEW ${mode}${constants_1.PG_SCHEMA}.${this.viewName}`, { type: sequelize_1.QueryTypes.RAW });
18
21
  }
19
22
  catch (err) {
20
23
  throw new golemio_errors_1.GeneralError(this.errorMessage, this.constructor.name, err);
21
24
  }
22
25
  }
26
+ async isPopulated() {
27
+ const rows = await this.connector
28
+ .getConnection()
29
+ .query("SELECT ispopulated FROM pg_matviews WHERE schemaname = :schema AND matviewname = :view", { type: sequelize_1.QueryTypes.SELECT, replacements: { schema: constants_1.PG_SCHEMA, view: this.viewName } });
30
+ return rows[0]?.ispopulated ?? false;
31
+ }
23
32
  }
24
33
  exports.AbstractMaterializedViewIndexRepository = AbstractMaterializedViewIndexRepository;
25
34
  //# sourceMappingURL=AbstractMaterializedViewIndexRepository.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AbstractMaterializedViewIndexRepository.js","sourceRoot":"","sources":["../../../../src/integration-engine/enapo/repositories/AbstractMaterializedViewIndexRepository.ts"],"names":[],"mappings":";;;AAAA,qEAA2C;AAE3C,6EAAwE;AACxE,mEAAiE;AAEjE,MAAsB,uCAAuC;IACzD,YAAoB,SAA6B,EAAU,QAAgB,EAAU,YAAoB;QAArF,cAAS,GAAT,SAAS,CAAoB;QAAU,aAAQ,GAAR,QAAQ,CAAQ;QAAU,iBAAY,GAAZ,YAAY,CAAQ;IAAG,CAAC;IAEtG,KAAK,CAAC,OAAO;QAChB,IAAI,CAAC;YACD,MAAM,IAAI,CAAC,SAAS;iBACf,aAAa,EAAE;iBACf,KAAK,CAAC,0CAA0C,qBAAS,IAAI,IAAI,CAAC,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,sBAAU,CAAC,GAAG,EAAE,CAAC,CAAC;QACjH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,MAAM,IAAI,6BAAY,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAC1E,CAAC;IACL,CAAC;CACJ;AAZD,0FAYC"}
1
+ {"version":3,"file":"AbstractMaterializedViewIndexRepository.js","sourceRoot":"","sources":["../../../../src/integration-engine/enapo/repositories/AbstractMaterializedViewIndexRepository.ts"],"names":[],"mappings":";;;AAAA,qEAA2C;AAE3C,6EAAwE;AACxE,mEAAiE;AAEjE,MAAsB,uCAAuC;IACzD,YAAoB,SAA6B,EAAU,QAAgB,EAAU,YAAoB;QAArF,cAAS,GAAT,SAAS,CAAoB;QAAU,aAAQ,GAAR,QAAQ,CAAQ;QAAU,iBAAY,GAAZ,YAAY,CAAQ;IAAG,CAAC;IAEtG,KAAK,CAAC,OAAO;QAChB,IAAI,CAAC;YACD,iGAAiG;YACjG,4DAA4D;YAC5D,MAAM,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/D,MAAM,IAAI,CAAC,SAAS;iBACf,aAAa,EAAE;iBACf,KAAK,CAAC,6BAA6B,IAAI,GAAG,qBAAS,IAAI,IAAI,CAAC,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,sBAAU,CAAC,GAAG,EAAE,CAAC,CAAC;QAC3G,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,MAAM,IAAI,6BAAY,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAC1E,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,WAAW;QACrB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS;aAC5B,aAAa,EAAE;aACf,KAAK,CACF,wFAAwF,EACxF,EAAE,IAAI,EAAE,sBAAU,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,qBAAS,EAAE,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,EAAE,CACxF,CAAC;QAEN,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,WAAW,IAAI,KAAK,CAAC;IACzC,CAAC;CACJ;AA1BD,0FA0BC"}
@@ -1,6 +1,6 @@
1
1
  import { EnapoCommodity } from "../../../schema-definitions/models/enapo/interfaces/IEnapoGidMapping";
2
2
  export type EnapoConsumptionPeriodType = "monthly" | "yearly";
3
- export type EnapoConsumptionSource = "predi_input" | "ppas_ave_api" | "ppas_distribution_invoice" | "ppas_commercial_invoice" | "porsenna";
3
+ export type EnapoConsumptionSource = "predi_input" | "ppas_ave_api" | "ppas_distribution_invoice" | "ppas_commercial_invoice" | "porsenna" | "energy_broker";
4
4
  export interface IEnapoOmConsumptionView {
5
5
  gid: string;
6
6
  place_id: string;
@@ -1,4 +1,5 @@
1
1
  import { StripPrefix } from "../../helpers/objectMappers";
2
+ import { EnapoConsumptionSource } from "../../models/interfaces/IEnapoOmConsumptionView";
2
3
  import { IEnoBuildingDetailView } from "../../models/interfaces/IEnoBuildingDetailView";
3
4
  import { IEnoConsumptionPointView } from "../../models/interfaces/IEnoConsumptionPointView";
4
5
  import { IPpasCommercialInvoiceDevice } from "../../../schema-definitions/models/enapo/ppas/interfaces/IPpasCommercialInvoiceDevice";
@@ -60,6 +61,20 @@ export interface IEnoConsumptionSeries {
60
61
  monthly: IEnoConsumptionEntry[];
61
62
  yearly: IEnoConsumptionEntry[];
62
63
  }
64
+ export interface IEnoCombinedConsumptionEntry extends IEnoConsumptionEntry {
65
+ sources: EnapoConsumptionSource[];
66
+ }
67
+ export interface IEnoCombinedConsumptionSeries {
68
+ unit: string;
69
+ points: string[];
70
+ data_from: string | null;
71
+ monthly: IEnoCombinedConsumptionEntry[];
72
+ yearly: IEnoCombinedConsumptionEntry[];
73
+ }
74
+ export interface IEnoCombinedConsumption {
75
+ electricity: IEnoCombinedConsumptionSeries | null;
76
+ gas: IEnoCombinedConsumptionSeries | null;
77
+ }
63
78
  export interface IEnoConsumptionHistory {
64
79
  period_from: string;
65
80
  period_to: string;
@@ -70,6 +85,7 @@ export interface IEnoConsumptionHistory {
70
85
  gas: IEnoConsumptionSeries[];
71
86
  heat: IEnoConsumptionSeries[];
72
87
  water: IEnoConsumptionSeries[];
88
+ combined: IEnoCombinedConsumption;
73
89
  }
74
90
  export interface IEnoEnergyManagement {
75
91
  source: string;
@@ -83,6 +99,7 @@ export interface IEnoElectricityBlock {
83
99
  metadata: TEnoElectricityMetadata | null;
84
100
  porsenna?: IEnoPorsennaBlock;
85
101
  consumption_history: IEnoConsumptionSeries[];
102
+ combined_history: IEnoCombinedConsumptionSeries | null;
86
103
  }
87
104
  export interface IEnoGasDistributionBlock {
88
105
  invoice: TEnoDistributionInvoice;
@@ -101,6 +118,7 @@ export interface IEnoGasBlock {
101
118
  commercial: IEnoGasCommercialBlock | null;
102
119
  porsenna?: IEnoPorsennaBlock;
103
120
  consumption_history: IEnoConsumptionSeries[];
121
+ combined_history: IEnoCombinedConsumptionSeries | null;
104
122
  }
105
123
  export interface IEnoSharedBuilding {
106
124
  gid: string;
@@ -9,7 +9,9 @@ export declare class EnoBuildingDetailTransformation extends AbstractTransformat
9
9
  private resolveBuildingAddress;
10
10
  private transformConsumptionHistory;
11
11
  private hasSharedPoints;
12
+ private pointRows;
12
13
  private buildPointHistory;
14
+ private buildPointCombined;
13
15
  private extractBuilding;
14
16
  private transformProperty;
15
17
  private buildRegistrationUnit;
@@ -70,6 +70,7 @@ let EnoBuildingDetailTransformation = class EnoBuildingDetailTransformation exte
70
70
  const gas = byCommodity("gas");
71
71
  const heat = byCommodity("heat");
72
72
  const water = byCommodity("water");
73
+ const combinedOf = (commodity) => ConsumptionHistoryBuilder_1.ConsumptionHistoryBuilder.buildCombined(aggregate.consumptionHistory.filter((row) => row.commodity === commodity), aggregate.historyWindow);
73
74
  return {
74
75
  period_from: aggregate.historyWindow.periodFrom,
75
76
  period_to: aggregate.historyWindow.periodTo,
@@ -80,10 +81,16 @@ let EnoBuildingDetailTransformation = class EnoBuildingDetailTransformation exte
80
81
  gas,
81
82
  heat,
82
83
  water,
84
+ combined: {
85
+ electricity: combinedOf("electricity"),
86
+ gas: combinedOf("gas"),
87
+ },
83
88
  };
84
89
  };
85
90
  this.hasSharedPoints = (aggregate) => aggregate.sharedPoints.length > 0;
86
- this.buildPointHistory = (point, aggregate) => ConsumptionHistoryBuilder_1.ConsumptionHistoryBuilder.build(aggregate.consumptionHistory.filter((row) => row.place_id === point.place_id && row.commodity === point.commodity), aggregate.historyWindow);
91
+ this.pointRows = (point, aggregate) => aggregate.consumptionHistory.filter((row) => row.place_id === point.place_id && row.commodity === point.commodity);
92
+ this.buildPointHistory = (point, aggregate) => ConsumptionHistoryBuilder_1.ConsumptionHistoryBuilder.build(this.pointRows(point, aggregate), aggregate.historyWindow);
93
+ this.buildPointCombined = (point, aggregate) => ConsumptionHistoryBuilder_1.ConsumptionHistoryBuilder.buildCombined(this.pointRows(point, aggregate), aggregate.historyWindow);
87
94
  this.extractBuilding = (building) => {
88
95
  return (0, objectMappers_1.omitKeys)(building, [
89
96
  "gid",
@@ -150,8 +157,12 @@ let EnoBuildingDetailTransformation = class EnoBuildingDetailTransformation exte
150
157
  valid_from: point.valid_from,
151
158
  valid_to: point.valid_to,
152
159
  address: this.resolveAddress(point),
153
- ...(point.commodity === "electricity" ? { electricity: this.transformElectricity(point, porsenna, history) } : {}),
154
- ...(point.commodity === "gas" ? { gas: this.transformGas(point, aggregate, porsenna, history) } : {}),
160
+ ...(point.commodity === "electricity"
161
+ ? { electricity: this.transformElectricity(point, porsenna, history, this.buildPointCombined(point, aggregate)) }
162
+ : {}),
163
+ ...(point.commodity === "gas"
164
+ ? { gas: this.transformGas(point, aggregate, porsenna, history, this.buildPointCombined(point, aggregate)) }
165
+ : {}),
155
166
  ...(point.commodity === "heat" ? { heat: { porsenna, consumption_history: history } } : {}),
156
167
  ...(point.commodity === "water" ? { water: { porsenna, consumption_history: history } } : {}),
157
168
  };
@@ -159,16 +170,18 @@ let EnoBuildingDetailTransformation = class EnoBuildingDetailTransformation exte
159
170
  this.buildSharedWith = (point, aggregate) => aggregate.sharedPoints
160
171
  .filter((shared) => shared.place_id === point.place_id)
161
172
  .map((shared) => ({ gid: shared.gid, name: shared.name }));
162
- this.transformElectricity = (point, porsenna, history) => {
173
+ this.transformElectricity = (point, porsenna, history, combinedHistory) => {
163
174
  return {
164
175
  metadata: point.md_year === null ? null : (0, objectMappers_1.stripPrefix)(point, "md_"),
165
176
  ...(porsenna === null ? {} : { porsenna }),
166
177
  consumption_history: history,
178
+ combined_history: combinedHistory,
167
179
  };
168
180
  };
169
- this.transformGas = (point, aggregate, porsenna, history) => {
181
+ this.transformGas = (point, aggregate, porsenna, history, combinedHistory) => {
170
182
  return {
171
183
  consumption_history: history,
184
+ combined_history: combinedHistory,
172
185
  distribution: point.di_id === null
173
186
  ? null
174
187
  : {