diff --git a/portal/docs/FLEET-DESIGN.md b/portal/docs/FLEET-DESIGN.md index 9bfeb42..447ead7 100644 --- a/portal/docs/FLEET-DESIGN.md +++ b/portal/docs/FLEET-DESIGN.md @@ -316,7 +316,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ | Seam | Where v2 picks it up | |---|---| -| Tariff sync + cross-customer cost | New `fleet.Tariffs` table, push tariff change events from customer, derived cost in `fleet_daily_per_customer`. | +| ~~Tariff sync~~ + cross-customer cost | **Tariff sync shipped.** `fleet.Municipalities` + `fleet.Tariffs` + `fleet.TariffPeriods` mirror the customer-side rate hierarchy scoped by `CustomerId`. New push batch types: `municipalities`, `tariffs` (with periods nested). Per-municipality rates preserved. **Cost compute on Admin side is the natural next step** — a `FleetCostService` joining `fleet.hourly_per_device` × tariffs → per-customer kWh × rate. | | Per-customer Postgres RLS for multi-Admin-user setups | Add `current_setting('app.customer_filter')`-based RLS policies on `fleet.*` tables; Admin role to customer-scope mapping in `IdentityRole.CustomerId`. | | Bidirectional Admin → Customer commands | New WebSocket or long-poll channel on customer side; gated by mutual cert or a second token. | | Branding sync (for the "Admin sees customer's brand when drilling in" niceness) | Push branding row from customer; Admin renders the customer's brand on Customer-detail pages. | diff --git a/portal/frontend/src/api/fleet.ts b/portal/frontend/src/api/fleet.ts index 2120472..b0e22fa 100644 --- a/portal/frontend/src/api/fleet.ts +++ b/portal/frontend/src/api/fleet.ts @@ -57,6 +57,36 @@ export interface FleetIngestEvent { error: string | null; } +export interface FleetMunicipalityView { + id: number; + name: string; + timeZoneId: string | null; + isActive: boolean; +} + +export interface FleetTariffPeriodView { + id: number; + name: string; + daysOfWeek: number; + startTime: string; + endTime: string; + ratePerKwh: number; +} + +export interface FleetTariffView { + id: number; + municipalityId: number; + municipalityName: string | null; + name: string; + effectiveFrom: string; + effectiveTo: string | null; + defaultRatePerKwh: number; + fixedMonthlyCharge: number; + vatPercentage: number; + isActive: boolean; + periods: FleetTariffPeriodView[]; +} + export interface FleetCustomerDetail { id: string; code: string; @@ -67,6 +97,8 @@ export interface FleetCustomerDetail { createdAt: string; sites: FleetSite[]; devices: FleetDevice[]; + municipalities: FleetMunicipalityView[]; + tariffs: FleetTariffView[]; recentMeasurements: FleetRecentMeasurement[]; recentIngestEvents: FleetIngestEvent[]; } diff --git a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx index 90853a9..49799fc 100644 --- a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx +++ b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx @@ -6,6 +6,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { fetchFleetCustomerDetail, type FleetSite, type FleetDevice, type FleetRecentMeasurement, type FleetIngestEvent, + type FleetTariffView, type FleetTariffPeriodView, } from '../api/fleet'; import { fetchGrafanaConfig } from '../api/grafana'; @@ -129,9 +130,83 @@ export function AdminCustomerDetailPage() { label: `Devices (${data.devices.length})`, children: rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />, }, + { + key: 'tariffs', + label: `Tariffs (${data.tariffs.length})`, + children: , + }, ]} /> ); } + +// ── Tariffs tab: collapsible per-tariff cards with the period table inline ─ +function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView[]; municipalitiesCount: number }) { + if (tariffs.length === 0) { + return ( + + No tariffs synced yet ({municipalitiesCount} municipalities). They appear here after the + customer's next push tick. + + ); + } + + const periodCols: ColumnsType = [ + { title: 'Period', dataIndex: 'name', key: 'name', render: (v: string) => {v} }, + { + title: 'Days', dataIndex: 'daysOfWeek', key: 'd', + render: (mask: number) => formatDays(mask), + }, + { title: 'Start', dataIndex: 'startTime', key: 's' }, + { title: 'End', dataIndex: 'endTime', key: 'e' }, + { title: 'Rate (/kWh)', dataIndex: 'ratePerKwh', key: 'r', render: (n: number) => n.toFixed(4) }, + ]; + + return ( + + {tariffs.map((t) => ( + + {t.name} + · + {t.municipalityName ?? `(municipality #${t.municipalityId})`} + {t.isActive ? Active : Inactive} + + } + extra={ + + {t.effectiveFrom}{t.effectiveTo ? ` → ${t.effectiveTo}` : ' →'} + + } + > + + {t.defaultRatePerKwh.toFixed(4)}/kWh + {t.fixedMonthlyCharge.toFixed(2)} + {t.vatPercentage.toFixed(2)}% + + {t.periods.length > 0 ? ( + + rowKey="id" + size="small" + columns={periodCols} + dataSource={t.periods} + pagination={false} + /> + ) : ( + No TOU periods — default rate applies always. + )} + + ))} + + ); +} + +const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +function formatDays(mask: number): string { + return DAY_LABELS.filter((_, i) => (mask & (1 << i)) !== 0).join('') || '—'; +} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs index ac39130..eb333c8 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs @@ -36,6 +36,25 @@ public sealed record FleetRecentMeasurementDto( double ActivePowerKw, double? EnergyImportedKwh); +public sealed record FleetMunicipalityViewDto( + int Id, string Name, string? TimeZoneId, bool IsActive); + +public sealed record FleetTariffPeriodViewDto( + int Id, string Name, int DaysOfWeek, string StartTime, string EndTime, decimal RatePerKwh); + +public sealed record FleetTariffViewDto( + int Id, + int MunicipalityId, + string? MunicipalityName, + string Name, + DateOnly EffectiveFrom, + DateOnly? EffectiveTo, + decimal DefaultRatePerKwh, + decimal FixedMonthlyCharge, + decimal VatPercentage, + bool IsActive, + IReadOnlyList Periods); + public sealed record FleetCustomerDetailDto( Guid Id, string Code, @@ -46,5 +65,7 @@ public sealed record FleetCustomerDetailDto( DateTime CreatedAt, IReadOnlyList Sites, IReadOnlyList Devices, + IReadOnlyList Municipalities, + IReadOnlyList Tariffs, IReadOnlyList RecentMeasurements, IReadOnlyList RecentIngestEvents); diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/FleetIngestDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/FleetIngestDtos.cs index 556e17e..6797fc2 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/FleetIngestDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/FleetIngestDtos.cs @@ -33,3 +33,32 @@ public sealed record FleetMeasurementDto( DateTime ReceivedAt); public sealed record FleetIngestResult(int Accepted, int Rejected, string[] Errors); + +// ── Rates sync (Municipality → Tariff → TariffPeriod) ────────────────── +// Push includes municipalities first, then tariffs with their periods nested. + +public sealed record FleetMunicipalityDto( + int Id, + string Name, + string? TimeZoneId, + bool IsActive); + +public sealed record FleetTariffPeriodWireDto( + int Id, + string Name, + int DaysOfWeek, + string StartTime, + string EndTime, + decimal RatePerKwh); + +public sealed record FleetTariffWireDto( + int Id, + int MunicipalityId, + string Name, + DateOnly EffectiveFrom, + DateOnly? EffectiveTo, + decimal DefaultRatePerKwh, + decimal FixedMonthlyCharge, + decimal VatPercentage, + bool IsActive, + IReadOnlyList Periods); diff --git a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs index 40fb4ef..3d2ec96 100644 --- a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs +++ b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs @@ -18,6 +18,9 @@ public class AdminDbContext : IdentityDbContext FleetSites => Set(); public DbSet FleetDevices => Set(); public DbSet FleetPowerMeasurements => Set(); + public DbSet FleetMunicipalities => Set(); + public DbSet FleetTariffs => Set(); + public DbSet FleetTariffPeriods => Set(); public DbSet IngestEvents => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -84,5 +87,48 @@ public class AdminDbContext : IdentityDbContext x.CustomerId) .OnDelete(DeleteBehavior.Cascade); }); + + builder.Entity(entity => + { + entity.ToTable("Municipalities", schema: "fleet"); + entity.HasKey(x => new { x.CustomerId, x.Id }); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("Tariffs", schema: "fleet"); + entity.HasKey(x => new { x.CustomerId, x.Id }); + entity.Property(x => x.DefaultRatePerKwh).HasPrecision(10, 4); + entity.Property(x => x.FixedMonthlyCharge).HasPrecision(10, 2); + entity.Property(x => x.VatPercentage).HasPrecision(5, 2); + entity.HasIndex(x => new { x.CustomerId, x.MunicipalityId, x.EffectiveFrom }); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Municipality) + .WithMany() + .HasForeignKey(x => new { x.CustomerId, x.MunicipalityId }) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("TariffPeriods", schema: "fleet"); + entity.HasKey(x => new { x.CustomerId, x.Id }); + entity.Property(x => x.RatePerKwh).HasPrecision(10, 4); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Tariff) + .WithMany(t => t.Periods) + .HasForeignKey(x => new { x.CustomerId, x.TariffId }) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetMunicipality.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetMunicipality.cs new file mode 100644 index 0000000..313778c --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetMunicipality.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetMunicipality +{ + // Preserved from the customer-side Municipality.Id (int autoincrement). + // Composite PK with CustomerId so two customers' municipalities don't collide. + public int Id { get; set; } + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [MaxLength(100)] + public string? TimeZoneId { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPushState.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPushState.cs index 103fc89..20b9730 100644 --- a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPushState.cs +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPushState.cs @@ -7,6 +7,8 @@ public class FleetPushState { public const string ResourceSites = "sites"; public const string ResourceDevices = "devices"; + public const string ResourceMunicipalities = "municipalities"; + public const string ResourceTariffs = "tariffs"; public const string ResourceMeasurements = "measurements"; [MaxLength(20)] diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariff.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariff.cs new file mode 100644 index 0000000..2a2b6ca --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariff.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetTariff +{ + public int Id { get; set; } + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + // Refers to (CustomerId, MunicipalityId) on FleetMunicipality. + public int MunicipalityId { get; set; } + public FleetMunicipality? Municipality { get; set; } + + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + public DateOnly EffectiveFrom { get; set; } + public DateOnly? EffectiveTo { get; set; } + + public decimal DefaultRatePerKwh { get; set; } + public decimal FixedMonthlyCharge { get; set; } + public decimal VatPercentage { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + + public List Periods { get; set; } = new(); +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariffPeriod.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariffPeriod.cs new file mode 100644 index 0000000..c25a396 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetTariffPeriod.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using Tau.Acuvim.Portal.Domain.Rates; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetTariffPeriod +{ + public int Id { get; set; } + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + public int TariffId { get; set; } + public FleetTariff? Tariff { get; set; } + + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public DayOfWeekFlag DaysOfWeek { get; set; } + + public TimeOnly StartTime { get; set; } + public TimeOnly EndTime { get; set; } + + public decimal RatePerKwh { get; set; } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.Designer.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.Designer.cs new file mode 100644 index 0000000..e36655a --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.Designer.cs @@ -0,0 +1,770 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tau.Acuvim.Portal.Data; + +#nullable disable + +namespace Tau.Acuvim.Portal.Migrations.Admin +{ + [DbContext(typeof(AdminDbContext))] + [Migration("20260518090118_AddFleetRates")] + partial class AddFleetRates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("app") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ApplicationName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("WhiteLabelSettings", "app"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PreviousTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenIssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenRotatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("PreviousTokenHash"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Customers", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "SiteId"); + + b.ToTable("Devices", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeZoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Municipalities", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ActivePowerKw") + .HasColumnType("double precision"); + + b.Property("ApparentPowerKva") + .HasColumnType("double precision"); + + b.Property("EnergyExportedKwh") + .HasColumnType("double precision"); + + b.Property("EnergyImportedKwh") + .HasColumnType("double precision"); + + b.Property("FrequencyHz") + .HasColumnType("double precision"); + + b.Property("PowerFactor") + .HasColumnType("double precision"); + + b.Property("ReactivePowerKvar") + .HasColumnType("double precision"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VoltageV") + .HasColumnType("double precision"); + + b.HasKey("Time", "CustomerId", "DeviceId"); + + b.HasIndex("CustomerId", "Time") + .IsDescending(false, true); + + b.HasIndex("CustomerId", "DeviceId", "Time") + .IsDescending(false, false, true); + + b.ToTable("PowerMeasurements", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LocalMunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Sites", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DefaultRatePerKwh") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("EffectiveFrom") + .HasColumnType("date"); + + b.Property("EffectiveTo") + .HasColumnType("date"); + + b.Property("FixedMonthlyCharge") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "MunicipalityId", "EffectiveFrom"); + + b.ToTable("Tariffs", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DaysOfWeek") + .HasColumnType("integer"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RatePerKwh") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("TariffId") + .HasColumnType("integer"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "TariffId"); + + b.ToTable("TariffPeriods", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchBytes") + .HasColumnType("integer"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ClientHwm") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowsAccepted") + .HasColumnType("integer"); + + b.Property("RowsRejected") + .HasColumnType("integer"); + + b.Property("TimeSpread") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("IngestEvents", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site") + .WithMany() + .HasForeignKey("CustomerId", "SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) + .WithMany() + .HasForeignKey("CustomerId", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", "Municipality") + .WithMany() + .HasForeignKey("CustomerId", "MunicipalityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Municipality"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", "Tariff") + .WithMany("Periods") + .HasForeignKey("CustomerId", "TariffId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Tariff"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.Navigation("Periods"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.cs new file mode 100644 index 0000000..7b50cf8 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518090118_AddFleetRates.cs @@ -0,0 +1,136 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tau.Acuvim.Portal.Migrations.Admin +{ + /// + public partial class AddFleetRates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Municipalities", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + TimeZoneId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Municipalities", x => new { x.CustomerId, x.Id }); + table.ForeignKey( + name: "FK_Municipalities_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Tariffs", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + MunicipalityId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + EffectiveFrom = table.Column(type: "date", nullable: false), + EffectiveTo = table.Column(type: "date", nullable: true), + DefaultRatePerKwh = table.Column(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false), + FixedMonthlyCharge = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false), + VatPercentage = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tariffs", x => new { x.CustomerId, x.Id }); + table.ForeignKey( + name: "FK_Tariffs_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Tariffs_Municipalities_CustomerId_MunicipalityId", + columns: x => new { x.CustomerId, x.MunicipalityId }, + principalSchema: "fleet", + principalTable: "Municipalities", + principalColumns: new[] { "CustomerId", "Id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TariffPeriods", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + TariffId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + DaysOfWeek = table.Column(type: "integer", nullable: false), + StartTime = table.Column(type: "time without time zone", nullable: false), + EndTime = table.Column(type: "time without time zone", nullable: false), + RatePerKwh = table.Column(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TariffPeriods", x => new { x.CustomerId, x.Id }); + table.ForeignKey( + name: "FK_TariffPeriods_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TariffPeriods_Tariffs_CustomerId_TariffId", + columns: x => new { x.CustomerId, x.TariffId }, + principalSchema: "fleet", + principalTable: "Tariffs", + principalColumns: new[] { "CustomerId", "Id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TariffPeriods_CustomerId_TariffId", + schema: "fleet", + table: "TariffPeriods", + columns: new[] { "CustomerId", "TariffId" }); + + migrationBuilder.CreateIndex( + name: "IX_Tariffs_CustomerId_MunicipalityId_EffectiveFrom", + schema: "fleet", + table: "Tariffs", + columns: new[] { "CustomerId", "MunicipalityId", "EffectiveFrom" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TariffPeriods", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "Tariffs", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "Municipalities", + schema: "fleet"); + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs index 915ca35..7ea6dd7 100644 --- a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs @@ -295,6 +295,34 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.ToTable("Devices", "fleet"); }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeZoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Municipalities", "fleet"); + }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => { b.Property("Time") @@ -376,6 +404,89 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.ToTable("Sites", "fleet"); }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DefaultRatePerKwh") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("EffectiveFrom") + .HasColumnType("date"); + + b.Property("EffectiveTo") + .HasColumnType("date"); + + b.Property("FixedMonthlyCharge") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "MunicipalityId", "EffectiveFrom"); + + b.ToTable("Tariffs", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DaysOfWeek") + .HasColumnType("integer"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RatePerKwh") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("TariffId") + .HasColumnType("integer"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "TariffId"); + + b.ToTable("TariffPeriods", "fleet"); + }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => { b.Property("Id") @@ -566,6 +677,17 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.Navigation("Site"); }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => { b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) @@ -586,6 +708,44 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.Navigation("Customer"); }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", "Municipality") + .WithMany() + .HasForeignKey("CustomerId", "MunicipalityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Municipality"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", "Tariff") + .WithMany("Periods") + .HasForeignKey("CustomerId", "TariffId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Tariff"); + }); + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => { b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") @@ -596,6 +756,11 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.Navigation("Customer"); }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => + { + b.Navigation("Periods"); + }); #pragma warning restore 612, 618 } } diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetIngestService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetIngestService.cs index 50d7f96..404ee5a 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetIngestService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetIngestService.cs @@ -8,7 +8,7 @@ using Tau.Acuvim.Portal.DTOs; namespace Tau.Acuvim.Portal.Services; -public enum FleetBatchType { Sites, Devices, Measurements } +public enum FleetBatchType { Sites, Devices, Municipalities, Tariffs, Measurements } public sealed class FleetIngestService( AdminDbContext db, @@ -50,9 +50,11 @@ public sealed class FleetIngestService( { (result, spread) = batchType switch { - FleetBatchType.Sites => (await IngestSitesAsync(customer.Id, rawBody, ct), null), - FleetBatchType.Devices => (await IngestDevicesAsync(customer.Id, rawBody, ct), null), - FleetBatchType.Measurements => await IngestMeasurementsAsync(customer.Id, rawBody, ct), + FleetBatchType.Sites => (await IngestSitesAsync(customer.Id, rawBody, ct), null), + FleetBatchType.Devices => (await IngestDevicesAsync(customer.Id, rawBody, ct), null), + FleetBatchType.Municipalities => (await IngestMunicipalitiesAsync(customer.Id, rawBody, ct), null), + FleetBatchType.Tariffs => (await IngestTariffsAsync(customer.Id, rawBody, ct), null), + FleetBatchType.Measurements => await IngestMeasurementsAsync(customer.Id, rawBody, ct), _ => throw new InvalidOperationException() }; } @@ -84,9 +86,11 @@ public sealed class FleetIngestService( if (string.IsNullOrWhiteSpace(header)) return false; return header.ToLowerInvariant() switch { - "sites" => (type = FleetBatchType.Sites) is var _ && true, - "devices" => (type = FleetBatchType.Devices) is var _ && true, - "measurements" => (type = FleetBatchType.Measurements) is var _ && true, + "sites" => (type = FleetBatchType.Sites) is var _ && true, + "devices" => (type = FleetBatchType.Devices) is var _ && true, + "municipalities" => (type = FleetBatchType.Municipalities) is var _ && true, + "tariffs" => (type = FleetBatchType.Tariffs) is var _ && true, + "measurements" => (type = FleetBatchType.Measurements) is var _ && true, _ => false, }; } @@ -213,6 +217,125 @@ public sealed class FleetIngestService( return (new FleetIngestResult(affected, rejected, Array.Empty()), spread); } + // ── Municipalities: upsert by (CustomerId, Id) ───────────────────────── + private async Task IngestMunicipalitiesAsync(Guid customerId, string body, CancellationToken ct) + { + var rows = JsonSerializer.Deserialize(body, JsonOpts) ?? Array.Empty(); + if (rows.Length == 0) return new FleetIngestResult(0, 0, Array.Empty()); + + var sql = new StringBuilder( + """ + INSERT INTO fleet."Municipalities" ("CustomerId","Id","Name","TimeZoneId","IsActive","ReceivedAt") VALUES + """); + var ps = new List(rows.Length * 5); + for (int i = 0; i < rows.Length; i++) + { + if (i > 0) sql.Append(','); + var b = i * 5; + sql.Append($" (@cust,@p{b},@p{b + 1},@p{b + 2},@p{b + 3},@p{b + 4})"); + ps.Add(new($"p{b}", rows[i].Id)); + ps.Add(new($"p{b + 1}", rows[i].Name)); + ps.Add(new($"p{b + 2}", (object?)rows[i].TimeZoneId ?? DBNull.Value)); + ps.Add(new($"p{b + 3}", rows[i].IsActive)); + ps.Add(new($"p{b + 4}", DateTime.UtcNow)); + } + ps.Add(new("cust", customerId)); + sql.Append(""" + ON CONFLICT ("CustomerId","Id") DO UPDATE SET + "Name"=EXCLUDED."Name", + "TimeZoneId"=EXCLUDED."TimeZoneId", + "IsActive"=EXCLUDED."IsActive", + "ReceivedAt"=EXCLUDED."ReceivedAt"; + """); + await db.Database.ExecuteSqlRawAsync(sql.ToString(), ps, ct); + return new FleetIngestResult(rows.Length, 0, Array.Empty()); + } + + // ── Tariffs (with periods nested): upsert tariff + replace its periods ─ + // One transaction per batch so a tariff is never observed mid-update with stale periods. + private async Task IngestTariffsAsync(Guid customerId, string body, CancellationToken ct) + { + var rows = JsonSerializer.Deserialize(body, JsonOpts) ?? Array.Empty(); + if (rows.Length == 0) return new FleetIngestResult(0, 0, Array.Empty()); + + await using var tx = await db.Database.BeginTransactionAsync(ct); + + foreach (var t in rows) + { + // Upsert the tariff row. + await db.Database.ExecuteSqlRawAsync( + """ + INSERT INTO fleet."Tariffs" + ("CustomerId","Id","MunicipalityId","Name","EffectiveFrom","EffectiveTo", + "DefaultRatePerKwh","FixedMonthlyCharge","VatPercentage","IsActive","ReceivedAt") + VALUES (@cust,@id,@muni,@name,@from,@to,@def,@fix,@vat,@active,@recv) + ON CONFLICT ("CustomerId","Id") DO UPDATE SET + "MunicipalityId"=EXCLUDED."MunicipalityId", + "Name"=EXCLUDED."Name", + "EffectiveFrom"=EXCLUDED."EffectiveFrom", + "EffectiveTo"=EXCLUDED."EffectiveTo", + "DefaultRatePerKwh"=EXCLUDED."DefaultRatePerKwh", + "FixedMonthlyCharge"=EXCLUDED."FixedMonthlyCharge", + "VatPercentage"=EXCLUDED."VatPercentage", + "IsActive"=EXCLUDED."IsActive", + "ReceivedAt"=EXCLUDED."ReceivedAt"; + """, + new object[] + { + new NpgsqlParameter("cust", customerId), + new NpgsqlParameter("id", t.Id), + new NpgsqlParameter("muni", t.MunicipalityId), + new NpgsqlParameter("name", t.Name), + new NpgsqlParameter("from", t.EffectiveFrom), + new NpgsqlParameter("to", (object?)t.EffectiveTo ?? DBNull.Value), + new NpgsqlParameter("def", t.DefaultRatePerKwh), + new NpgsqlParameter("fix", t.FixedMonthlyCharge), + new NpgsqlParameter("vat", t.VatPercentage), + new NpgsqlParameter("active", t.IsActive), + new NpgsqlParameter("recv", DateTime.UtcNow), + }, + ct); + + // Replace the period set for this tariff atomically. + await db.Database.ExecuteSqlRawAsync( + """ + DELETE FROM fleet."TariffPeriods" + WHERE "CustomerId" = @cust AND "TariffId" = @tariffId; + """, + new object[] + { + new NpgsqlParameter("cust", customerId), + new NpgsqlParameter("tariffId", t.Id), + }, + ct); + + foreach (var p in t.Periods) + { + await db.Database.ExecuteSqlRawAsync( + """ + INSERT INTO fleet."TariffPeriods" + ("CustomerId","Id","TariffId","Name","DaysOfWeek","StartTime","EndTime","RatePerKwh") + VALUES (@cust,@id,@tariffId,@name,@days,@start,@end,@rate); + """, + new object[] + { + new NpgsqlParameter("cust", customerId), + new NpgsqlParameter("id", p.Id), + new NpgsqlParameter("tariffId", t.Id), + new NpgsqlParameter("name", p.Name), + new NpgsqlParameter("days", p.DaysOfWeek), + new NpgsqlParameter("start", TimeOnly.Parse(p.StartTime)), + new NpgsqlParameter("end", TimeOnly.Parse(p.EndTime)), + new NpgsqlParameter("rate", p.RatePerKwh), + }, + ct); + } + } + + await tx.CommitAsync(ct); + return new FleetIngestResult(rows.Length, 0, Array.Empty()); + } + private async Task WriteEventAsync( Guid customerId, string? batchType, int accepted, int rejected, int batchBytes, string? cursor, string? error, TimeSpan? spread, CancellationToken ct) diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetPushClient.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetPushClient.cs index cfbcfec..19674bc 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetPushClient.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetPushClient.cs @@ -34,6 +34,12 @@ public sealed class FleetPushClient( public Task PushMeasurementsAsync(IReadOnlyList rows, DateTime cursor, CancellationToken ct) => PushAsync("measurements", rows, cursor, ct); + public Task PushMunicipalitiesAsync(IReadOnlyList rows, DateTime cursor, CancellationToken ct) + => PushAsync("municipalities", rows, cursor, ct); + + public Task PushTariffsAsync(IReadOnlyList rows, DateTime cursor, CancellationToken ct) + => PushAsync("tariffs", rows, cursor, ct); + private async Task PushAsync( string batchType, IReadOnlyList rows, diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetPushService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetPushService.cs index 46aedc2..8b2e005 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetPushService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetPushService.cs @@ -68,9 +68,57 @@ public sealed class FleetPushService( var devicesOk = await PushDevicesAsync(db, client, ct); if (!devicesOk) return; + var munisOk = await PushMunicipalitiesAsync(db, client, ct); + if (!munisOk) return; + + var tariffsOk = await PushTariffsAsync(db, client, ct); + if (!tariffsOk) return; + await PushMeasurementsAsync(db, client, ct); } + // ── Municipalities: full set every tick ──────────────────────────────── + private async Task PushMunicipalitiesAsync(AppDbContext db, FleetPushClient client, CancellationToken ct) + { + var state = await GetStateAsync(db, FleetPushState.ResourceMunicipalities, ct); + var rows = await db.Municipalities.AsNoTracking() + .Select(m => new FleetMunicipalityDto(m.Id, m.Name, m.TimeZoneId, m.IsActive)) + .ToListAsync(ct); + if (rows.Count == 0) + { + await MarkSuccessAsync(db, state, cursor: DateTime.UtcNow, ct); + return true; + } + var result = await client.PushMunicipalitiesAsync(rows, DateTime.UtcNow, ct); + return await HandleResultAsync(db, state, result, cursor: DateTime.UtcNow, ct); + } + + // ── Tariffs (with periods nested): full set every tick ───────────────── + private async Task PushTariffsAsync(AppDbContext db, FleetPushClient client, CancellationToken ct) + { + var state = await GetStateAsync(db, FleetPushState.ResourceTariffs, ct); + var tariffs = await db.Tariffs.AsNoTracking() + .Include(t => t.Periods) + .ToListAsync(ct); + if (tariffs.Count == 0) + { + await MarkSuccessAsync(db, state, cursor: DateTime.UtcNow, ct); + return true; + } + var rows = tariffs.Select(t => new FleetTariffWireDto( + t.Id, t.MunicipalityId, t.Name, + t.EffectiveFrom, t.EffectiveTo, + t.DefaultRatePerKwh, t.FixedMonthlyCharge, t.VatPercentage, t.IsActive, + t.Periods.Select(p => new FleetTariffPeriodWireDto( + p.Id, p.Name, (int)p.DaysOfWeek, + p.StartTime.ToString("HH:mm"), + p.EndTime.ToString("HH:mm"), + p.RatePerKwh)).ToList() + )).ToList(); + var result = await client.PushTariffsAsync(rows, DateTime.UtcNow, ct); + return await HandleResultAsync(db, state, result, cursor: DateTime.UtcNow, ct); + } + // ── Sites: full set every tick ───────────────────────────────────────── private async Task PushSitesAsync(AppDbContext db, FleetPushClient client, CancellationToken ct) { diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs index 2dab16d..64d8b2b 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs @@ -93,10 +93,34 @@ public sealed class FleetQueryService(AdminDbContext db) e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error)) .ToListAsync(ct); + var munis = await db.FleetMunicipalities.AsNoTracking() + .Where(m => m.CustomerId == id) + .OrderBy(m => m.Name) + .Select(m => new FleetMunicipalityViewDto(m.Id, m.Name, m.TimeZoneId, m.IsActive)) + .ToListAsync(ct); + var muniNameMap = munis.ToDictionary(m => m.Id, m => m.Name); + + var tariffs = await db.FleetTariffs.AsNoTracking() + .Where(t => t.CustomerId == id) + .Include(t => t.Periods) + .OrderByDescending(t => t.EffectiveFrom) + .ToListAsync(ct); + + var tariffDtos = tariffs.Select(t => new FleetTariffViewDto( + t.Id, t.MunicipalityId, muniNameMap.GetValueOrDefault(t.MunicipalityId), + t.Name, t.EffectiveFrom, t.EffectiveTo, + t.DefaultRatePerKwh, t.FixedMonthlyCharge, t.VatPercentage, t.IsActive, + t.Periods.OrderBy(p => p.StartTime).Select(p => new FleetTariffPeriodViewDto( + p.Id, p.Name, (int)p.DaysOfWeek, + p.StartTime.ToString("HH:mm"), + p.EndTime.ToString("HH:mm"), + p.RatePerKwh)).ToList() + )).ToList(); + return new FleetCustomerDetailDto( c.Id, c.Code, c.Name, c.IsActive, c.FirstSeenAt, c.LastSeenAt, c.CreatedAt, - sites, devices, recentDtos, events); + sites, devices, munis, tariffDtos, recentDtos, events); } // Per-customer today: measurement count + kWh imported delta via raw SQL hitting