Fleet sync: Municipalities + Tariffs + TariffPeriods

Mirror the customer-side rate hierarchy (Municipality → Tariff →
TariffPeriod) to the central DB, scoped by CustomerId.
Per-municipality rate structure is preserved exactly as customers
configure it locally — each tariff still references its municipality
and carries its own per-period TOU rates.

Backend
- New fleet entities: FleetMunicipality, FleetTariff, FleetTariffPeriod.
  Composite PKs (CustomerId, Id) like Sites/Devices so two customers'
  rate trees never collide. FleetTariff has a real FK to
  FleetMunicipality on (CustomerId, MunicipalityId); FleetTariffPeriod
  cascades from its FleetTariff parent.
- Two new push batch types: "municipalities" (full set) and "tariffs"
  (full set with periods nested inside each tariff). Push order per
  tick: sites → devices → municipalities → tariffs → measurements.
- FleetIngestService dispatches the new types. Municipalities upsert
  by (CustomerId, Id). Tariffs run inside a single transaction per
  batch: upsert the tariff row, DELETE all periods for that tariff,
  INSERT the new set — atomic period replacement matches the
  customer-side UpsertTariffRequest semantics.
- FleetPushState gains ResourceMunicipalities + ResourceTariffs
  constants so the per-resource cursor/backoff state has slots for them.
- FleetQueryService.GetCustomerDetailAsync now includes municipalities
  and tariffs (with periods, with municipality name joined client-side
  from the lookup dict). New DTOs: FleetMunicipalityViewDto,
  FleetTariffViewDto, FleetTariffPeriodViewDto.
- AdminDbContext migration AddFleetRates creates the three tables and
  their indexes/FKs.

Frontend
- Customer detail page gains a "Tariffs (N)" tab. Each tariff renders
  as a collapsible card with its municipality, active flag, effective
  window, default rate / fixed charge / VAT, and an inline period
  table (Period name, day-of-week bitmask formatted as MTWTFSS, start,
  end, rate). Empty state when no tariffs synced yet.

Verified end-to-end on the dev host
- Created "Phase23 Test City" municipality + "Domestic TOU" tariff
  with two periods (Peak weekdays 17:00-20:00 @ 3.75, Off-Peak
  weekends 00:00-23:59 @ 1.20) on the Client.
- Within one push tick (~20s) all three rows landed on Admin:
  fleet.Municipalities (1), fleet.Tariffs (1), fleet.TariffPeriods (2).
- /api/fleet/customers/{id}/detail returns the full tree with
  municipality name resolved.

Tests: 61/61 still passing.

Design doc (FLEET-DESIGN.md §11) updated — tariff sync row marked
shipped, cross-customer cost compute flagged as the natural next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 11:10:51 +02:00
parent 3333202f3a
commit b654997fc9
17 changed files with 1565 additions and 9 deletions

View File

@ -316,7 +316,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ
| Seam | Where v2 picks it up | | 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`. | | 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. | | 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. | | 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. |

View File

@ -57,6 +57,36 @@ export interface FleetIngestEvent {
error: string | null; 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 { export interface FleetCustomerDetail {
id: string; id: string;
code: string; code: string;
@ -67,6 +97,8 @@ export interface FleetCustomerDetail {
createdAt: string; createdAt: string;
sites: FleetSite[]; sites: FleetSite[];
devices: FleetDevice[]; devices: FleetDevice[];
municipalities: FleetMunicipalityView[];
tariffs: FleetTariffView[];
recentMeasurements: FleetRecentMeasurement[]; recentMeasurements: FleetRecentMeasurement[];
recentIngestEvents: FleetIngestEvent[]; recentIngestEvents: FleetIngestEvent[];
} }

View File

@ -6,6 +6,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { import {
fetchFleetCustomerDetail, type FleetSite, type FleetDevice, fetchFleetCustomerDetail, type FleetSite, type FleetDevice,
type FleetRecentMeasurement, type FleetIngestEvent, type FleetRecentMeasurement, type FleetIngestEvent,
type FleetTariffView, type FleetTariffPeriodView,
} from '../api/fleet'; } from '../api/fleet';
import { fetchGrafanaConfig } from '../api/grafana'; import { fetchGrafanaConfig } from '../api/grafana';
@ -129,9 +130,83 @@ export function AdminCustomerDetailPage() {
label: `Devices (${data.devices.length})`, label: `Devices (${data.devices.length})`,
children: <Table<FleetDevice> rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />, children: <Table<FleetDevice> rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />,
}, },
{
key: 'tariffs',
label: `Tariffs (${data.tariffs.length})`,
children: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />,
},
]} ]}
/> />
</Card> </Card>
</Space> </Space>
); );
} }
// ── Tariffs tab: collapsible per-tariff cards with the period table inline ─
function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView[]; municipalitiesCount: number }) {
if (tariffs.length === 0) {
return (
<Text type="secondary">
No tariffs synced yet ({municipalitiesCount} municipalities). They appear here after the
customer's next push tick.
</Text>
);
}
const periodCols: ColumnsType<FleetTariffPeriodView> = [
{ title: 'Period', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{
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 (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{tariffs.map((t) => (
<Card
key={t.id}
size="small"
title={
<Space>
<Text strong>{t.name}</Text>
<Text type="secondary">·</Text>
<Text>{t.municipalityName ?? `(municipality #${t.municipalityId})`}</Text>
{t.isActive ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>}
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 12 }}>
{t.effectiveFrom}{t.effectiveTo ? `${t.effectiveTo}` : ' →'}
</Text>
}
>
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="Default rate">{t.defaultRatePerKwh.toFixed(4)}/kWh</Descriptions.Item>
<Descriptions.Item label="Fixed monthly">{t.fixedMonthlyCharge.toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="VAT">{t.vatPercentage.toFixed(2)}%</Descriptions.Item>
</Descriptions>
{t.periods.length > 0 ? (
<Table<FleetTariffPeriodView>
rowKey="id"
size="small"
columns={periodCols}
dataSource={t.periods}
pagination={false}
/>
) : (
<Text type="secondary">No TOU periods default rate applies always.</Text>
)}
</Card>
))}
</Space>
);
}
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('') || '—';
}

View File

@ -36,6 +36,25 @@ public sealed record FleetRecentMeasurementDto(
double ActivePowerKw, double ActivePowerKw,
double? EnergyImportedKwh); 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<FleetTariffPeriodViewDto> Periods);
public sealed record FleetCustomerDetailDto( public sealed record FleetCustomerDetailDto(
Guid Id, Guid Id,
string Code, string Code,
@ -46,5 +65,7 @@ public sealed record FleetCustomerDetailDto(
DateTime CreatedAt, DateTime CreatedAt,
IReadOnlyList<FleetSiteDto> Sites, IReadOnlyList<FleetSiteDto> Sites,
IReadOnlyList<FleetDeviceDto> Devices, IReadOnlyList<FleetDeviceDto> Devices,
IReadOnlyList<FleetMunicipalityViewDto> Municipalities,
IReadOnlyList<FleetTariffViewDto> Tariffs,
IReadOnlyList<FleetRecentMeasurementDto> RecentMeasurements, IReadOnlyList<FleetRecentMeasurementDto> RecentMeasurements,
IReadOnlyList<FleetIngestEventDto> RecentIngestEvents); IReadOnlyList<FleetIngestEventDto> RecentIngestEvents);

View File

@ -33,3 +33,32 @@ public sealed record FleetMeasurementDto(
DateTime ReceivedAt); DateTime ReceivedAt);
public sealed record FleetIngestResult(int Accepted, int Rejected, string[] Errors); 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<FleetTariffPeriodWireDto> Periods);

View File

@ -18,6 +18,9 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
public DbSet<FleetSite> FleetSites => Set<FleetSite>(); public DbSet<FleetSite> FleetSites => Set<FleetSite>();
public DbSet<FleetDevice> FleetDevices => Set<FleetDevice>(); public DbSet<FleetDevice> FleetDevices => Set<FleetDevice>();
public DbSet<FleetPowerMeasurement> FleetPowerMeasurements => Set<FleetPowerMeasurement>(); public DbSet<FleetPowerMeasurement> FleetPowerMeasurements => Set<FleetPowerMeasurement>();
public DbSet<FleetMunicipality> FleetMunicipalities => Set<FleetMunicipality>();
public DbSet<FleetTariff> FleetTariffs => Set<FleetTariff>();
public DbSet<FleetTariffPeriod> FleetTariffPeriods => Set<FleetTariffPeriod>();
public DbSet<IngestEvent> IngestEvents => Set<IngestEvent>(); public DbSet<IngestEvent> IngestEvents => Set<IngestEvent>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@ -84,5 +87,48 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.HasForeignKey(x => x.CustomerId) .HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
builder.Entity<FleetMunicipality>(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<FleetTariff>(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<FleetTariffPeriod>(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);
});
} }
} }

View File

@ -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;
}

View File

@ -7,6 +7,8 @@ public class FleetPushState
{ {
public const string ResourceSites = "sites"; public const string ResourceSites = "sites";
public const string ResourceDevices = "devices"; public const string ResourceDevices = "devices";
public const string ResourceMunicipalities = "municipalities";
public const string ResourceTariffs = "tariffs";
public const string ResourceMeasurements = "measurements"; public const string ResourceMeasurements = "measurements";
[MaxLength(20)] [MaxLength(20)]

View File

@ -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<FleetTariffPeriod> Periods { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -0,0 +1,770 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("FirstSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("PreviousTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreviousTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("TokenIssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("LocalMunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<decimal>("DefaultRatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<DateOnly>("EffectiveFrom")
.HasColumnType("date");
b.Property<DateOnly?>("EffectiveTo")
.HasColumnType("date");
b.Property<decimal>("FixedMonthlyCharge")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("DaysOfWeek")
.HasColumnType("integer");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("RatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BatchBytes")
.HasColumnType("integer");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ClientHwm")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Error")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RowsAccepted")
.HasColumnType("integer");
b.Property<int>("RowsRejected")
.HasColumnType("integer");
b.Property<TimeSpan?>("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<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View File

@ -0,0 +1,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
/// <inheritdoc />
public partial class AddFleetRates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Municipalities",
schema: "fleet",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TimeZoneId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
ReceivedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
MunicipalityId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
EffectiveFrom = table.Column<DateOnly>(type: "date", nullable: false),
EffectiveTo = table.Column<DateOnly>(type: "date", nullable: true),
DefaultRatePerKwh = table.Column<decimal>(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false),
FixedMonthlyCharge = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
VatPercentage = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
ReceivedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
TariffId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
DaysOfWeek = table.Column<int>(type: "integer", nullable: false),
StartTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
EndTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
RatePerKwh = table.Column<decimal>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TariffPeriods",
schema: "fleet");
migrationBuilder.DropTable(
name: "Tariffs",
schema: "fleet");
migrationBuilder.DropTable(
name: "Municipalities",
schema: "fleet");
}
}
}

View File

@ -295,6 +295,34 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.ToTable("Devices", "fleet"); b.ToTable("Devices", "fleet");
}); });
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TimeZoneId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("CustomerId", "Id");
b.ToTable("Municipalities", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
{ {
b.Property<DateTime>("Time") b.Property<DateTime>("Time")
@ -376,6 +404,89 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.ToTable("Sites", "fleet"); b.ToTable("Sites", "fleet");
}); });
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<decimal>("DefaultRatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<DateOnly>("EffectiveFrom")
.HasColumnType("date");
b.Property<DateOnly?>("EffectiveTo")
.HasColumnType("date");
b.Property<decimal>("FixedMonthlyCharge")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("DaysOfWeek")
.HasColumnType("integer");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("RatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("TariffId")
.HasColumnType("integer");
b.HasKey("CustomerId", "Id");
b.HasIndex("CustomerId", "TariffId");
b.ToTable("TariffPeriods", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -566,6 +677,17 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.Navigation("Site"); 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 => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
{ {
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null)
@ -586,6 +708,44 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.Navigation("Customer"); 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 => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
{ {
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
@ -596,6 +756,11 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.Navigation("Customer"); b.Navigation("Customer");
}); });
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{
b.Navigation("Periods");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@ -8,7 +8,7 @@ using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Services;
public enum FleetBatchType { Sites, Devices, Measurements } public enum FleetBatchType { Sites, Devices, Municipalities, Tariffs, Measurements }
public sealed class FleetIngestService( public sealed class FleetIngestService(
AdminDbContext db, AdminDbContext db,
@ -50,9 +50,11 @@ public sealed class FleetIngestService(
{ {
(result, spread) = batchType switch (result, spread) = batchType switch
{ {
FleetBatchType.Sites => (await IngestSitesAsync(customer.Id, rawBody, ct), null), FleetBatchType.Sites => (await IngestSitesAsync(customer.Id, rawBody, ct), null),
FleetBatchType.Devices => (await IngestDevicesAsync(customer.Id, rawBody, ct), null), FleetBatchType.Devices => (await IngestDevicesAsync(customer.Id, rawBody, ct), null),
FleetBatchType.Measurements => await IngestMeasurementsAsync(customer.Id, rawBody, ct), 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() _ => throw new InvalidOperationException()
}; };
} }
@ -84,9 +86,11 @@ public sealed class FleetIngestService(
if (string.IsNullOrWhiteSpace(header)) return false; if (string.IsNullOrWhiteSpace(header)) return false;
return header.ToLowerInvariant() switch return header.ToLowerInvariant() switch
{ {
"sites" => (type = FleetBatchType.Sites) is var _ && true, "sites" => (type = FleetBatchType.Sites) is var _ && true,
"devices" => (type = FleetBatchType.Devices) is var _ && true, "devices" => (type = FleetBatchType.Devices) is var _ && true,
"measurements" => (type = FleetBatchType.Measurements) 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, _ => false,
}; };
} }
@ -213,6 +217,125 @@ public sealed class FleetIngestService(
return (new FleetIngestResult(affected, rejected, Array.Empty<string>()), spread); return (new FleetIngestResult(affected, rejected, Array.Empty<string>()), spread);
} }
// ── Municipalities: upsert by (CustomerId, Id) ─────────────────────────
private async Task<FleetIngestResult> IngestMunicipalitiesAsync(Guid customerId, string body, CancellationToken ct)
{
var rows = JsonSerializer.Deserialize<FleetMunicipalityDto[]>(body, JsonOpts) ?? Array.Empty<FleetMunicipalityDto>();
if (rows.Length == 0) return new FleetIngestResult(0, 0, Array.Empty<string>());
var sql = new StringBuilder(
"""
INSERT INTO fleet."Municipalities" ("CustomerId","Id","Name","TimeZoneId","IsActive","ReceivedAt") VALUES
""");
var ps = new List<NpgsqlParameter>(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<string>());
}
// ── 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<FleetIngestResult> IngestTariffsAsync(Guid customerId, string body, CancellationToken ct)
{
var rows = JsonSerializer.Deserialize<FleetTariffWireDto[]>(body, JsonOpts) ?? Array.Empty<FleetTariffWireDto>();
if (rows.Length == 0) return new FleetIngestResult(0, 0, Array.Empty<string>());
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<string>());
}
private async Task WriteEventAsync( private async Task WriteEventAsync(
Guid customerId, string? batchType, int accepted, int rejected, int batchBytes, Guid customerId, string? batchType, int accepted, int rejected, int batchBytes,
string? cursor, string? error, TimeSpan? spread, CancellationToken ct) string? cursor, string? error, TimeSpan? spread, CancellationToken ct)

View File

@ -34,6 +34,12 @@ public sealed class FleetPushClient(
public Task<FleetPushResult> PushMeasurementsAsync(IReadOnlyList<FleetMeasurementDto> rows, DateTime cursor, CancellationToken ct) public Task<FleetPushResult> PushMeasurementsAsync(IReadOnlyList<FleetMeasurementDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("measurements", rows, cursor, ct); => PushAsync("measurements", rows, cursor, ct);
public Task<FleetPushResult> PushMunicipalitiesAsync(IReadOnlyList<FleetMunicipalityDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("municipalities", rows, cursor, ct);
public Task<FleetPushResult> PushTariffsAsync(IReadOnlyList<FleetTariffWireDto> rows, DateTime cursor, CancellationToken ct)
=> PushAsync("tariffs", rows, cursor, ct);
private async Task<FleetPushResult> PushAsync<T>( private async Task<FleetPushResult> PushAsync<T>(
string batchType, string batchType,
IReadOnlyList<T> rows, IReadOnlyList<T> rows,

View File

@ -68,9 +68,57 @@ public sealed class FleetPushService(
var devicesOk = await PushDevicesAsync(db, client, ct); var devicesOk = await PushDevicesAsync(db, client, ct);
if (!devicesOk) return; 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); await PushMeasurementsAsync(db, client, ct);
} }
// ── Municipalities: full set every tick ────────────────────────────────
private async Task<bool> 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<bool> 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 ───────────────────────────────────────── // ── Sites: full set every tick ─────────────────────────────────────────
private async Task<bool> PushSitesAsync(AppDbContext db, FleetPushClient client, CancellationToken ct) private async Task<bool> PushSitesAsync(AppDbContext db, FleetPushClient client, CancellationToken ct)
{ {

View File

@ -93,10 +93,34 @@ public sealed class FleetQueryService(AdminDbContext db)
e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error)) e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error))
.ToListAsync(ct); .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( return new FleetCustomerDetailDto(
c.Id, c.Code, c.Name, c.IsActive, c.Id, c.Code, c.Name, c.IsActive,
c.FirstSeenAt, c.LastSeenAt, c.CreatedAt, 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 // Per-customer today: measurement count + kWh imported delta via raw SQL hitting