@golemio/energetics 2.0.0 → 2.1.0-rc.2970808972

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/09_enapo_consumption_history.sql +52 -11
  2. package/db/migrations/postgresql/20261013090000-enapo-ave-zero-core.js +53 -0
  3. package/db/migrations/postgresql/20261013090100-enapo-consumption-history-gas.js +53 -0
  4. package/db/migrations/postgresql/20261019090000-enapo-consumption-history-invoice-zero.js +53 -0
  5. package/db/migrations/postgresql/sqls/20261013090000-enapo-ave-zero-core-down.sql +1 -0
  6. package/db/migrations/postgresql/sqls/20261013090000-enapo-ave-zero-core-up.sql +7 -0
  7. package/db/migrations/postgresql/sqls/20261013090100-enapo-consumption-history-gas-down.sql +511 -0
  8. package/db/migrations/postgresql/sqls/20261013090100-enapo-consumption-history-gas-up.sql +492 -0
  9. package/db/migrations/postgresql/sqls/20261019090000-enapo-consumption-history-invoice-zero-down.sql +492 -0
  10. package/db/migrations/postgresql/sqls/20261019090000-enapo-consumption-history-invoice-zero-up.sql +495 -0
  11. package/dist/integration-engine/enapo/transformations/PPasMeasurementTransformation.d.ts +2 -2
  12. package/dist/integration-engine/enapo/transformations/PPasMeasurementTransformation.js +8 -5
  13. package/dist/integration-engine/enapo/transformations/PPasMeasurementTransformation.js.map +1 -1
  14. package/dist/output-gateway/routers/v2/V2EnoBuildingsRouter.d.ts +4 -1
  15. package/dist/output-gateway/routers/v2/V2EnoBuildingsRouter.js +17 -5
  16. package/dist/output-gateway/routers/v2/V2EnoBuildingsRouter.js.map +1 -1
  17. package/docs/implementation_documentation.md +7 -5
  18. package/docs/openapi-output.yaml +3 -1
  19. package/package.json +3 -3
@@ -0,0 +1,492 @@
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_core_month AS (
42
+ -- Cumulative 'core' reading (m3) at the first measured hour of each first-of-month day, kept per
43
+ -- (id, place_id): an id can carry several meters, one per PPAS place id, whose diffs are summed.
44
+ -- Not per device_serial_number - the integration stamps every refetched hour with the serial AVE
45
+ -- lists at fetch time, so the serial is unreliable for history and would split one meter in two.
46
+ SELECT DISTINCT ON (em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'))
47
+ em.id,
48
+ em.place_id,
49
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS month_start,
50
+ em.value
51
+ FROM enapo_measurements em
52
+ CROSS JOIN bounds b
53
+ WHERE em.source = 'ppas_ave_api'
54
+ AND em.var = 'core'
55
+ AND (em."timestamp" AT TIME ZONE 'Europe/Prague')::date
56
+ = date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
57
+ AND em."timestamp" >= b.month_from_tstz
58
+ AND em."timestamp" < (b.month_to_tstz + interval '1 month')
59
+ ORDER BY em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'), em."timestamp"
60
+ ),
61
+ gas_metered AS (
62
+ SELECT
63
+ cur.id AS place_id,
64
+ cur.month_start AS period_start,
65
+ -- A meter reading only grows, so a negative month total means a meter was exchanged or reset:
66
+ -- the month stays empty. Checked on the total, so a tiny drop on a small meter does not empty it.
67
+ (CASE WHEN sum(nxt.value - cur.value) < 0 THEN NULL ELSE sum(nxt.value - cur.value) END) AS m3_sum,
68
+ ((cur.month_start + interval '1 month')::date - cur.month_start) AS days_covered
69
+ FROM gas_core_month cur
70
+ INNER JOIN gas_core_month nxt
71
+ ON nxt.id = cur.id
72
+ AND nxt.place_id = cur.place_id
73
+ AND nxt.month_start = (cur.month_start + interval '1 month')::date
74
+ GROUP BY cur.id, cur.month_start
75
+ ),
76
+
77
+ distribution_device AS (
78
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
79
+ ins.eic,
80
+ d.place_id,
81
+ d.invoice_id,
82
+ d.date_from,
83
+ d.date_to,
84
+ d.kind,
85
+ d.unit,
86
+ d.consumption,
87
+ d.normometers_nm3,
88
+ d.gas_consumption_kwh,
89
+ d.combustion_heat
90
+ FROM enapo_ppas_distribution_invoice_device d
91
+ INNER JOIN enapo_ppas_distribution_invoice i
92
+ ON i.id = d.invoice_id
93
+ INNER JOIN enapo_ppas_distribution_installation ins
94
+ ON ins.invoice_id = d.invoice_id
95
+ AND ins.customer_id = i.customer_id
96
+ AND ins.place_id = d.place_id
97
+ WHERE COALESCE(i.is_canceled, false) = false
98
+ AND d.date_to >= d.date_from
99
+ AND d.consumption IS NOT NULL
100
+ AND d.kind IN ('ZIZWC', 'ZIABN3')
101
+ AND d.reading_type = 'STANDARD'
102
+ AND NOT EXISTS (
103
+ SELECT 1 FROM enapo_ppas_distribution_invoice s
104
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
105
+ )
106
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
107
+ i.facts_doc_date DESC, i.id DESC
108
+ ),
109
+ commercial_device AS (
110
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
111
+ ins.eic,
112
+ d.place_id,
113
+ d.invoice_id,
114
+ d.date_from,
115
+ d.date_to,
116
+ d.kind,
117
+ d.unit,
118
+ d.consumption,
119
+ d.normometers_nm3,
120
+ d.gas_consumption_kwh,
121
+ d.combustion_heat
122
+ FROM enapo_ppas_commercial_invoice_device d
123
+ INNER JOIN enapo_ppas_commercial_invoice i
124
+ ON i.id = d.invoice_id
125
+ INNER JOIN enapo_ppas_commercial_invoice_installations ins
126
+ ON ins.invoice_id = d.invoice_id
127
+ AND ins.customer_id = i.customer_id
128
+ AND ins.place_id = d.place_id
129
+ WHERE COALESCE(i.is_canceled, false) = false
130
+ AND d.date_to >= d.date_from
131
+ AND d.consumption IS NOT NULL
132
+ AND d.reading_type = 'STANDARD'
133
+ AND NOT EXISTS (
134
+ SELECT 1 FROM enapo_ppas_commercial_invoice s
135
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
136
+ )
137
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
138
+ i.facts_doc_date DESC, i.id DESC
139
+ ),
140
+
141
+ invoice_device_month AS (
142
+ SELECT
143
+ 'ppas_distribution_invoice' AS source,
144
+ d.eic AS place_id,
145
+ d.invoice_id,
146
+ gs.month::date AS period_start,
147
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share AS volume,
148
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END AS volume_unit,
149
+ d.normometers_nm3 * o.share AS nm3,
150
+ NULLIF(d.gas_consumption_kwh, 0) * o.share AS kwh,
151
+ (o.share < 1) AS is_estimated,
152
+ o.overlap_days AS days_covered
153
+ FROM distribution_device d
154
+ CROSS JOIN bounds b
155
+ CROSS JOIN LATERAL generate_series(
156
+ date_trunc('month', d.date_from::timestamp),
157
+ date_trunc('month', d.date_to::timestamp),
158
+ interval '1 month'
159
+ ) gs(month)
160
+ CROSS JOIN LATERAL (
161
+ SELECT
162
+ ov.overlap_days,
163
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
164
+ FROM (
165
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
166
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
167
+ ) ov
168
+ ) o
169
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
170
+
171
+ UNION ALL
172
+
173
+ SELECT
174
+ 'ppas_commercial_invoice',
175
+ d.eic,
176
+ d.invoice_id,
177
+ gs.month::date,
178
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share,
179
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END,
180
+ d.normometers_nm3 * o.share,
181
+ NULLIF(d.gas_consumption_kwh, 0) * o.share,
182
+ (o.share < 1),
183
+ o.overlap_days
184
+ FROM commercial_device d
185
+ CROSS JOIN bounds b
186
+ CROSS JOIN LATERAL generate_series(
187
+ date_trunc('month', d.date_from::timestamp),
188
+ date_trunc('month', d.date_to::timestamp),
189
+ interval '1 month'
190
+ ) gs(month)
191
+ CROSS JOIN LATERAL (
192
+ SELECT
193
+ ov.overlap_days,
194
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
195
+ FROM (
196
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
197
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
198
+ ) ov
199
+ ) o
200
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
201
+ ),
202
+ invoice_month AS (
203
+ SELECT
204
+ source,
205
+ place_id,
206
+ period_start,
207
+ sum(volume) AS volume,
208
+ max(volume_unit) AS volume_unit,
209
+ sum(nm3) AS nm3,
210
+ sum(kwh) AS kwh,
211
+ bool_or(is_estimated) AS is_estimated,
212
+ -- Clamp to the month's length: sum() would exceed it when meters are billed in parallel.
213
+ LEAST(
214
+ sum(days_covered),
215
+ (period_start + interval '1 month')::date - period_start
216
+ ) AS days_covered,
217
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
218
+ FROM invoice_device_month
219
+ GROUP BY source, place_id, period_start
220
+ ),
221
+ invoice_year AS (
222
+ SELECT
223
+ source,
224
+ place_id,
225
+ date_trunc('year', period_start)::date AS period_start,
226
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
227
+ FROM invoice_device_month
228
+ GROUP BY source, place_id, date_trunc('year', period_start)::date
229
+ ),
230
+
231
+ porsenna_period AS (
232
+ SELECT *
233
+ FROM (
234
+ SELECT
235
+ d.identifier AS place_id,
236
+ c.commodity,
237
+ c."var" AS period_type,
238
+ f_enapo_safe_period(c."var", c."period") AS period_start,
239
+ c.value,
240
+ c.unit,
241
+ c."count" AS days_covered
242
+ FROM enapo_porsenna_consumption c
243
+ INNER JOIN enapo_porsenna_devices d
244
+ ON d.source = c.source AND d.id = c.device_id
245
+ WHERE d.identifier IS NOT NULL
246
+ AND (d."level" IS NULL OR d."level" = 'main')
247
+ AND c.commodity IS NOT NULL
248
+ AND c."var" IN ('yearly', 'monthly')
249
+ ) parsed
250
+ WHERE parsed.period_start IS NOT NULL
251
+ ),
252
+
253
+ eb_month AS (
254
+ -- Energy Broker history, full calendar months only: a period that is longer or does not start on
255
+ -- the 1st needs the pro-rata methodology, which is a separate step. One EAN/EIC can appear under
256
+ -- two idodbmistoom with identical values (a re-registered OM), so one row per month is kept instead
257
+ -- of summing them into a double. The highest idodbmisto_rspotrebyfa stands in for the newest row: the
258
+ -- one-off import has no timestamp, and in production the duplicates carry identical values.
259
+ SELECT DISTINCT ON (eb.ean_eic, eb.komodita, eb.datum_od)
260
+ eb.ean_eic AS place_id,
261
+ CASE eb.komodita WHEN 1 THEN 'electricity' WHEN 2 THEN 'gas' END AS commodity,
262
+ eb.datum_od AS period_start,
263
+ eb.spotreba_mwh * 1000 AS value_kwh,
264
+ eb.datum_do - eb.datum_od + 1 AS days_covered
265
+ FROM enapo_eb_consumption eb
266
+ CROSS JOIN bounds b
267
+ WHERE eb.spotreba_mwh IS NOT NULL
268
+ AND eb.datum_od = date_trunc('month', eb.datum_od)::date
269
+ AND eb.datum_do = (date_trunc('month', eb.datum_od) + interval '1 month - 1 day')::date
270
+ AND eb.datum_od >= b.month_from
271
+ AND eb.datum_od < b.month_to
272
+ ORDER BY eb.ean_eic, eb.komodita, eb.datum_od, eb.idodbmisto_rspotrebyfa DESC
273
+ ),
274
+
275
+ live_monthly AS (
276
+ SELECT
277
+ p.gid,
278
+ p.place_id,
279
+ p.id_type,
280
+ p.commodity,
281
+ 'predi_input'::varchar(50) AS source,
282
+ 'monthly'::varchar(10) AS period_type,
283
+ e.period_start,
284
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value,
285
+ 'kWh'::varchar(10) AS unit,
286
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value_kwh,
287
+ 'EActi_VT_NT_DaySum'::varchar(50) AS internal_source_var,
288
+ e.days_covered,
289
+ false AS is_estimated,
290
+ '{}'::text[] AS invoice_ids
291
+ FROM point p
292
+ INNER JOIN electricity_metered e ON e.place_id = p.place_id
293
+ WHERE p.commodity = 'electricity'
294
+
295
+ UNION ALL
296
+
297
+ SELECT
298
+ p.gid,
299
+ p.place_id,
300
+ p.id_type,
301
+ p.commodity,
302
+ 'ppas_ave_api',
303
+ 'monthly',
304
+ g.period_start,
305
+ g.m3_sum,
306
+ 'm3',
307
+ -- No conversion inputs for metered gas, but no consumption is 0 kWh whatever the conversion.
308
+ CASE WHEN g.m3_sum = 0 THEN 0 END,
309
+ 'core',
310
+ g.days_covered,
311
+ false,
312
+ '{}'::text[]
313
+ FROM point p
314
+ INNER JOIN gas_metered g ON g.place_id = p.place_id
315
+ WHERE p.commodity = 'gas'
316
+
317
+ UNION ALL
318
+
319
+ SELECT
320
+ p.gid,
321
+ p.place_id,
322
+ p.id_type,
323
+ p.commodity,
324
+ i.source,
325
+ 'monthly',
326
+ i.period_start,
327
+ COALESCE(i.volume, i.nm3::numeric),
328
+ CASE WHEN i.volume IS NOT NULL THEN i.volume_unit ELSE 'Nm3' END,
329
+ i.kwh,
330
+ NULL,
331
+ i.days_covered,
332
+ i.is_estimated,
333
+ i.invoice_ids
334
+ FROM point p
335
+ INNER JOIN invoice_month i ON i.place_id = p.place_id
336
+ WHERE p.commodity = 'gas'
337
+ ),
338
+
339
+ monthly AS (
340
+ SELECT * FROM live_monthly
341
+
342
+ UNION ALL
343
+
344
+ SELECT
345
+ p.gid,
346
+ p.place_id,
347
+ p.id_type,
348
+ p.commodity,
349
+ 'energy_broker',
350
+ 'monthly',
351
+ e.period_start,
352
+ e.value_kwh,
353
+ 'kWh',
354
+ e.value_kwh,
355
+ NULL,
356
+ e.days_covered,
357
+ false,
358
+ '{}'::text[]
359
+ FROM point p
360
+ INNER JOIN eb_month e ON e.place_id = p.place_id AND e.commodity = p.commodity
361
+ -- A live source that read the whole month in kWh wins, so the overlap is not charted twice. A pro-rata
362
+ -- invoice or a partially covered month does not win: the EB figure is billed for the whole month. Nor
363
+ -- does a volume-only row (the PPAS gas meter): EB is the only kWh for that month, which the combined
364
+ -- kWh series needs - unless the meter read no consumption, which is 0 kWh and wins.
365
+ WHERE NOT EXISTS (
366
+ SELECT 1
367
+ FROM live_monthly l
368
+ WHERE l.place_id = e.place_id
369
+ AND l.commodity = e.commodity
370
+ AND l.period_start = e.period_start
371
+ AND l.value_kwh IS NOT NULL
372
+ AND NOT l.is_estimated
373
+ AND l.days_covered >= (e.period_start + interval '1 month')::date - e.period_start
374
+ )
375
+ ),
376
+
377
+ yearly_rollup AS (
378
+ SELECT
379
+ gid,
380
+ place_id,
381
+ id_type,
382
+ commodity,
383
+ source,
384
+ 'yearly'::varchar(10) AS period_type,
385
+ date_trunc('year', period_start)::date AS period_start,
386
+ sum(value) AS value,
387
+ unit,
388
+ -- count(*) = count(value_kwh) is an all-non-null check: only sum value_kwh when every month has one.
389
+ CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END AS value_kwh,
390
+ max(internal_source_var) AS internal_source_var,
391
+ sum(days_covered) AS days_covered,
392
+ bool_or(is_estimated) AS is_estimated
393
+ FROM monthly m
394
+ GROUP BY gid, place_id, id_type, commodity, source, date_trunc('year', period_start)::date, unit
395
+ ),
396
+ yearly AS (
397
+ SELECT
398
+ r.gid,
399
+ r.place_id,
400
+ r.id_type,
401
+ r.commodity,
402
+ r.source,
403
+ r.period_type,
404
+ r.period_start,
405
+ r.value,
406
+ r.unit,
407
+ r.value_kwh,
408
+ r.internal_source_var,
409
+ r.days_covered,
410
+ r.is_estimated,
411
+ COALESCE(iy.invoice_ids, '{}'::text[]) AS invoice_ids
412
+ FROM yearly_rollup r
413
+ LEFT JOIN invoice_year iy
414
+ ON iy.source = r.source AND iy.place_id = r.place_id AND iy.period_start = r.period_start
415
+
416
+ UNION ALL
417
+
418
+ SELECT
419
+ p.gid,
420
+ p.place_id,
421
+ p.id_type,
422
+ p.commodity,
423
+ 'porsenna',
424
+ 'yearly',
425
+ pp.period_start,
426
+ pp.value,
427
+ pp.unit,
428
+ CASE WHEN lower(pp.unit) = 'kwh' THEN pp.value ELSE NULL END,
429
+ NULL,
430
+ pp.days_covered,
431
+ false,
432
+ '{}'::text[]
433
+ FROM point p
434
+ INNER JOIN porsenna_period pp
435
+ ON pp.place_id = p.place_id AND pp.commodity = p.commodity
436
+ WHERE pp.period_type = 'yearly'
437
+ )
438
+
439
+ SELECT
440
+ gid::varchar AS gid,
441
+ place_id::varchar(50) AS place_id,
442
+ max(id_type)::varchar(10) AS id_type,
443
+ commodity::varchar(20) AS commodity,
444
+ source::varchar(50) AS source,
445
+ period_type::varchar(10) AS period_type,
446
+ period_start,
447
+ sum(value)::numeric(30, 6) AS value,
448
+ unit::varchar(10) AS unit,
449
+ (CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END)::numeric(30, 6) AS value_kwh,
450
+ max(internal_source_var)::varchar(50) AS internal_source_var,
451
+ COALESCE(max(invoice_ids), '{}'::text[]) AS invoice_ids,
452
+ max(days_covered)::int AS coverage_count,
453
+ (
454
+ CASE period_type
455
+ WHEN 'yearly' THEN (make_date(extract(year FROM period_start)::int + 1, 1, 1) - period_start)
456
+ ELSE (period_start + interval '1 month')::date - period_start
457
+ END
458
+ )::int AS expected_count,
459
+ bool_or(is_estimated) AS is_estimated
460
+ FROM (
461
+ SELECT * FROM monthly
462
+ UNION ALL
463
+ SELECT * FROM yearly
464
+ ) all_periods
465
+ WHERE value IS NOT NULL
466
+ GROUP BY gid, place_id, commodity, source, unit, period_type, period_start;
467
+
468
+
469
+ COMMENT ON MATERIALIZED VIEW mv_enapo_om_consumption IS
470
+ '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).';
471
+
472
+ COMMENT ON COLUMN mv_enapo_om_consumption.place_id IS
473
+ '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.';
474
+ COMMENT ON COLUMN mv_enapo_om_consumption.value IS
475
+ '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.';
476
+ COMMENT ON COLUMN mv_enapo_om_consumption.value_kwh IS
477
+ 'Derived comparable energy: for gas, normometers_nm3 * combustion_heat (verified exact). NULL when the conversion inputs are missing - `value` + `unit` stay authoritative. NULL for metered gas (no normometers_nm3 input), except 0 when no gas was consumed.';
478
+ COMMENT ON COLUMN mv_enapo_om_consumption.internal_source_var IS
479
+ 'Which enapo_measurements.var produced the row; NULL for invoice and Porsenna rows. Provenance for debugging, not part of the public response.';
480
+ COMMENT ON COLUMN mv_enapo_om_consumption.invoice_ids IS
481
+ '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.';
482
+ COMMENT ON COLUMN mv_enapo_om_consumption.coverage_count IS
483
+ 'Days of the period that carry data. Days for every commodity and both period types, so it is comparable with expected_count.';
484
+ COMMENT ON COLUMN mv_enapo_om_consumption.expected_count IS 'Days in the period (28-31, or 365/366).';
485
+ COMMENT ON COLUMN mv_enapo_om_consumption.is_estimated IS
486
+ 'True when the value was allocated pro rata from a billing period spanning more than one month, rather than read for the period itself.';
487
+
488
+ CREATE UNIQUE INDEX mv_enapo_om_consumption_uidx
489
+ ON mv_enapo_om_consumption (gid, place_id, commodity, source, unit, period_type, period_start);
490
+
491
+ CREATE INDEX mv_enapo_om_consumption_gid_period_idx
492
+ ON mv_enapo_om_consumption (gid, period_type, period_start);