Files
MeterVault/src/Infrastructure/Persistence/MeterVaultDbContext.cs
T
schmidt.florian 7e34aeccc8
ci / build-test (push) Successful in 1m24s
Meter chain topology + per-energy-type flow (Sankey) pages
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
2026-07-14 14:02:16 +02:00

200 lines
8.8 KiB
C#

using MeterVault.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Persistence;
/// <summary>
/// The EF Core context owning all relational tables. TimescaleDB-specific objects
/// (hypertables, compression, continuous aggregates) are created via raw SQL in the
/// migrations — they are not expressible through the model builder (SDD §5.3).
/// Snake_case table/column naming is applied at the options level
/// (<c>UseSnakeCaseNamingConvention</c>) so it matches the SDD schema.
/// </summary>
public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> options)
: DbContext(options)
{
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>();
public DbSet<Tank> Tanks => Set<Tank>();
public DbSet<Tariff> Tariffs => Set<Tariff>();
public DbSet<CostCategory> CostCategories => Set<CostCategory>();
public DbSet<CostCategoryMember> CostCategoryMembers => Set<CostCategoryMember>();
public DbSet<ManualCost> ManualCosts => Set<ManualCost>();
public DbSet<IngestionEndpoint> IngestionEndpoints => Set<IngestionEndpoint>();
public DbSet<ImportBatch> ImportBatches => Set<ImportBatch>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasPostgresExtension("timescaledb");
b.Entity<EnergyType>(e =>
{
e.ToTable("energy_type");
e.HasKey(x => x.Id);
e.Property(x => x.Id).UseIdentityByDefaultColumn();
e.Property(x => x.Key).HasMaxLength(64);
e.Property(x => x.DisplayName).HasMaxLength(128);
e.Property(x => x.BaseUnit).HasMaxLength(16);
e.Property(x => x.DefaultMode).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
e.HasIndex(x => x.Key).IsUnique();
});
b.Entity<Meter>(e =>
{
e.ToTable("meter");
e.HasKey(x => x.Id);
e.Property(x => x.Mode).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.Property(x => x.IsActive).HasDefaultValue(true);
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
e.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
e.HasOne(x => x.EnergyType).WithMany(x => x.Meters)
.HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.EnergyTypeId, x.IsActive });
});
b.Entity<MeterSource>(e =>
{
e.ToTable("meter_source");
e.HasKey(x => x.Id);
e.Property(x => x.SourceType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.ValueKind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.Property(x => x.Scale).HasDefaultValue(1d);
e.HasOne(x => x.Meter).WithMany(x => x.Sources)
.HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Endpoint).WithMany()
.HasForeignKey(x => x.EndpointId).OnDelete(DeleteBehavior.SetNull);
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 =>
{
e.ToTable("reading");
e.HasKey(x => new { x.MeterId, x.Time });
e.Property(x => x.Quality).HasConversion<short>();
e.Property(x => x.Flags).HasConversion<int>();
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ImportBatchId);
});
// Hypertable — (meter_id, time, kind) PK.
b.Entity<Consumption>(e =>
{
e.ToTable("consumption");
e.HasKey(x => new { x.MeterId, x.Time, x.Kind });
e.Property(x => x.Kind).HasConversion<short>();
e.Property(x => x.Quality).HasConversion<short>();
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<MeterEvent>(e =>
{
e.ToTable("meter_event");
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.MeterId, x.Time });
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<Tank>(e =>
{
e.ToTable("tank");
e.HasKey(x => x.Id);
e.Property(x => x.RateMode).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Unit).HasMaxLength(16);
e.Property(x => x.Calibration).HasColumnType("jsonb");
e.HasOne(x => x.Meter).WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.MeterId).IsUnique();
});
b.Entity<Tariff>(e =>
{
e.ToTable("tariff");
e.HasKey(x => x.Id);
e.Property(x => x.ScopeType).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Component).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Unit).HasMaxLength(16);
e.Property(x => x.Currency).HasMaxLength(8);
e.HasIndex(x => new { x.ScopeType, x.ScopeId, x.Component, x.ValidFrom });
});
b.Entity<CostCategory>(e =>
{
e.ToTable("cost_category");
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(128);
});
b.Entity<CostCategoryMember>(e =>
{
e.ToTable("cost_category_member");
e.HasKey(x => x.Id);
e.HasOne(x => x.Category).WithMany(x => x.Members)
.HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<EnergyType>().WithMany().HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Cascade);
e.ToTable(t => t.HasCheckConstraint(
"ck_cost_category_member_target",
"meter_id IS NOT NULL OR energy_type_id IS NOT NULL"));
});
b.Entity<ManualCost>(e =>
{
e.ToTable("manual_cost");
e.HasKey(x => x.Id);
e.Property(x => x.Currency).HasMaxLength(8);
e.HasOne<CostCategory>().WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.SetNull);
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<IngestionEndpoint>(e =>
{
e.ToTable("ingestion_endpoint");
e.HasKey(x => x.Id);
e.Property(x => x.Type).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
});
b.Entity<ImportBatch>(e =>
{
e.ToTable("import_batch");
e.HasKey(x => x.Id);
e.Property(x => x.Mapping).HasColumnType("jsonb");
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
b.Entity<AppSetting>(e =>
{
e.ToTable("app_setting");
e.HasKey(x => x.Key);
e.Property(x => x.Key).HasMaxLength(128);
e.Property(x => x.Value).HasColumnType("jsonb");
});
}
}