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