Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
ci / build-test (push) Successful in 1m24s
Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.
- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
sized by consumption; link value = downstream meter's consumption, split
proportionally across multiple upstreams; unaccounted remainder under a meter
becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
Sankey, and the meter list. NavMenu now lists a link per energy type
(Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
(descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
Haus dividing into Auto + Other.
Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
|
||||
public DbSet<Meter> Meters => Set<Meter>();
|
||||
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
|
||||
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
|
||||
public DbSet<Reading> Readings => Set<Reading>();
|
||||
public DbSet<Consumption> Consumption => Set<Consumption>();
|
||||
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
|
||||
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
e.HasIndex(x => x.MeterId);
|
||||
});
|
||||
|
||||
b.Entity<MeterLink>(e =>
|
||||
{
|
||||
e.ToTable("meter_link");
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
|
||||
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
|
||||
});
|
||||
|
||||
// Hypertable — the (meter_id, time) PK contains the partition column (time),
|
||||
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
|
||||
b.Entity<Reading>(e =>
|
||||
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MeterVaultDbContext))]
|
||||
[Migration("20260714114901_AddMeterLinks")]
|
||||
partial class AddMeterLinks
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key")
|
||||
.HasName("pk_app_setting");
|
||||
|
||||
b.ToTable("app_setting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<short>("Kind")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.HasKey("MeterId", "Time", "Kind")
|
||||
.HasName("pk_consumption");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||
|
||||
b.ToTable("consumption", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Sort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category");
|
||||
|
||||
b.ToTable("cost_category", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<short?>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category_member");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||
|
||||
b.HasIndex("EnergyTypeId")
|
||||
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||
|
||||
b.ToTable("cost_category_member", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Property<short>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||
|
||||
b.Property<string>("BaseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("base_unit");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DefaultMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("default_mode");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_energy_type");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_energy_type_key");
|
||||
|
||||
b.ToTable("energy_type", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Mapping")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("mapping");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevertedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reverted_at");
|
||||
|
||||
b.Property<int>("RowCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("row_count");
|
||||
|
||||
b.Property<string>("SourceName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("source_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_import_batch");
|
||||
|
||||
b.ToTable("import_batch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ingestion_endpoint");
|
||||
|
||||
b.ToTable("ingestion_endpoint", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<DateOnly>("PeriodEnd")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_end");
|
||||
|
||||
b.Property<DateOnly>("PeriodStart")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_start");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_cost");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_manual_cost_category_id");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||
|
||||
b.ToTable("manual_cost", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<short>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<double>("InitialBaseline")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("initial_baseline");
|
||||
|
||||
b.Property<DateOnly?>("InstalledAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("installed_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("manufacturer");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateOnly?>("RetiredAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("retired_at");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter");
|
||||
|
||||
b.HasIndex("EnergyTypeId", "IsActive")
|
||||
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||
|
||||
b.ToTable("meter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double?>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double?>("NewValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("new_value");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<double?>("PrevValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("prev_value");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_event");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId", "Time")
|
||||
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int?>("EndpointId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("endpoint_id");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<double?>("LastValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("last_value");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double>("Offset")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("offset");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<double>("Scale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("double precision")
|
||||
.HasDefaultValue(1.0)
|
||||
.HasColumnName("scale");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("source_type");
|
||||
|
||||
b.Property<string>("ValueKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("value_kind");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_source");
|
||||
|
||||
b.HasIndex("EndpointId")
|
||||
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_meter_source_meter_id");
|
||||
|
||||
b.ToTable("meter_source", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<int>("Flags")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("flags");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.Property<int?>("SourceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("source_id");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("MeterId", "Time")
|
||||
.HasName("pk_reading");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_reading_import_batch_id");
|
||||
|
||||
b.ToTable("reading", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CachedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cached_at");
|
||||
|
||||
b.Property<double?>("CachedBalance")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("cached_balance");
|
||||
|
||||
b.Property<string>("Calibration")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("calibration");
|
||||
|
||||
b.Property<double>("Capacity")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("capacity");
|
||||
|
||||
b.Property<double?>("FixedRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("fixed_rate");
|
||||
|
||||
b.Property<double?>("LowThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("low_threshold");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("RateMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("rate_mode");
|
||||
|
||||
b.Property<double?>("ReorderThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("reorder_threshold");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tank");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tank_meter_id");
|
||||
|
||||
b.ToTable("tank", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("component");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<int?>("ScopeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("scope_id");
|
||||
|
||||
b.Property<string>("ScopeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("scope_type");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateOnly>("ValidFrom")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_from");
|
||||
|
||||
b.Property<DateOnly?>("ValidTo")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_to");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tariff");
|
||||
|
||||
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||
|
||||
b.ToTable("tariff", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||
.WithMany("Meters")
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||
|
||||
b.Navigation("EnergyType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("EndpointId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany("Sources")
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||
|
||||
b.Navigation("Endpoint");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_reading_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tank_meter_meter_id");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Navigation("Meters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Navigation("Sources");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMeterLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "meter_link",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
from_meter_id = table.Column<int>(type: "integer", nullable: false),
|
||||
to_meter_id = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_meter_link", x => x.id);
|
||||
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_from_meter_id",
|
||||
column: x => x.from_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_to_meter_id",
|
||||
column: x => x.to_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_from_meter_id_to_meter_id",
|
||||
table: "meter_link",
|
||||
columns: new[] { "from_meter_id", "to_meter_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_to_meter_id",
|
||||
table: "meter_link",
|
||||
column: "to_meter_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "meter_link");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
|
||||
Reference in New Issue
Block a user