Tau.Acuvim/portal/src/Tau.Acuvim.Portal/Services/RateService.cs
Diseri Pearson e17921a122 Add portal: customer-facing white-labeled monitoring stack
New top-level portal/ project, peer to console/ and firmware/. Delivers a
.NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per
customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no
changes to existing console or firmware.

Backend (src/Tau.Acuvim.Portal/):
- .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout).
- Single AppDbContext with identity / app / monitoring schemas.
- MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation)
  + IdentityBootstrapper (seeded admin + branding) on startup.
- Pure CostCalculator + DB-backed RateService for tariffs (effective-dated,
  TOU periods, VAT, fixed charges, per-municipality timezone).
- BrandingService with logo upload to mounted volume.
- Time-series ingest + bucketed query services (time_bucket aggregates,
  ON CONFLICT for idempotent re-delivery).
- ConfigOverviewService with redaction-by-construction (passwords never in
  payload).
- DataProtection keys persisted to /data/keys volume for cookie survival
  across container restarts.

Frontend (frontend/):
- React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query.
- BrandingProvider + ThemedRoot for live re-themed white-labelling.
- RequireAuth / RequireRole guards.
- Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin),
  Settings tabs (Branding / Rates / Users / Grafana / App config).

Infra:
- Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose
  files. Three services per customer; Traefik subdomain + same-origin
  /grafana path-prefix routing wired with labels.
- Grafana 11 with provisioned timescaledb datasource (uid pinned) and
  starter power-overview.json dashboard with device template variable.
- Compose project name documented as lowercase (Compose v2 requirement).

Tests (tests/Tau.Acuvim.Portal.Tests/):
- xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap,
  VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl.
  Production refusal), TariffValidator, DayOfWeekFlag.
- All passing locally against .NET 10.

Docs:
- README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer
  provisioning, secret rotation, backup, troubleshooting), TESTING.md
  (manual integration scenarios, frontend test scaffolding recipe).

Production safety guards:
- Refuses to start if Authentication:DefaultAdminPassword is unchanged
  default in Production.
- Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in
  Production.
- Prod Grafana ships with anonymous off and auth mode unset (three
  options documented in README Security) so iframe refuses to load
  until a deliberate prod auth choice is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:30:30 +02:00

147 lines
5.7 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Rates;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class RateService(AppDbContext db)
{
public async Task<List<MunicipalityDto>> ListMunicipalitiesAsync(CancellationToken ct = default) =>
await db.Municipalities
.OrderBy(m => m.Name)
.Select(m => new MunicipalityDto(m.Id, m.Name, m.TimeZoneId, m.IsActive, m.Tariffs.Count))
.ToListAsync(ct);
public async Task<Municipality> CreateMunicipalityAsync(CreateMunicipalityRequest req, CancellationToken ct = default)
{
var m = new Municipality
{
Name = req.Name.Trim(),
TimeZoneId = NormalizeTz(req.TimeZoneId),
IsActive = req.IsActive
};
db.Municipalities.Add(m);
await db.SaveChangesAsync(ct);
return m;
}
public async Task<bool> UpdateMunicipalityAsync(int id, UpdateMunicipalityRequest req, CancellationToken ct = default)
{
var m = await db.Municipalities.FindAsync(new object?[] { id }, ct);
if (m is null) return false;
m.Name = req.Name.Trim();
m.TimeZoneId = NormalizeTz(req.TimeZoneId);
m.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
return true;
}
public async Task<bool> DeleteMunicipalityAsync(int id, CancellationToken ct = default)
{
var m = await db.Municipalities.FindAsync(new object?[] { id }, ct);
if (m is null) return false;
db.Municipalities.Remove(m);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<List<TariffSummaryDto>> ListTariffsAsync(int municipalityId, CancellationToken ct = default) =>
await db.Tariffs
.Where(t => t.MunicipalityId == municipalityId)
.OrderByDescending(t => t.EffectiveFrom)
.Select(t => new TariffSummaryDto(
t.Id, t.Name, t.EffectiveFrom, t.EffectiveTo,
t.DefaultRatePerKwh, t.FixedMonthlyCharge, t.VatPercentage,
t.IsActive, t.Periods.Count))
.ToListAsync(ct);
public async Task<TariffDetailDto?> GetTariffAsync(int tariffId, CancellationToken ct = default)
{
var t = await db.Tariffs.Include(x => x.Periods)
.FirstOrDefaultAsync(x => x.Id == tariffId, ct);
return t is null ? null : ToDetail(t);
}
public async Task<TariffDetailDto?> GetActiveTariffAsync(int municipalityId, DateTime atUtc, CancellationToken ct = default)
{
var day = DateOnly.FromDateTime(atUtc);
var t = await db.Tariffs.Include(x => x.Periods)
.Where(x => x.MunicipalityId == municipalityId
&& x.IsActive
&& x.EffectiveFrom <= day
&& (x.EffectiveTo == null || x.EffectiveTo >= day))
.OrderByDescending(x => x.EffectiveFrom)
.FirstOrDefaultAsync(ct);
return t is null ? null : ToDetail(t);
}
public async Task<TariffDetailDto> CreateTariffAsync(int municipalityId, UpsertTariffRequest req, CancellationToken ct = default)
{
var t = new Tariff
{
MunicipalityId = municipalityId,
Name = req.Name.Trim(),
EffectiveFrom = req.EffectiveFrom,
EffectiveTo = req.EffectiveTo,
DefaultRatePerKwh = req.DefaultRatePerKwh,
FixedMonthlyCharge = req.FixedMonthlyCharge,
VatPercentage = req.VatPercentage,
IsActive = req.IsActive,
Periods = MapPeriods(req.Periods).ToList()
};
db.Tariffs.Add(t);
await db.SaveChangesAsync(ct);
return ToDetail(t);
}
public async Task<TariffDetailDto?> UpdateTariffAsync(int tariffId, UpsertTariffRequest req, CancellationToken ct = default)
{
var t = await db.Tariffs.Include(x => x.Periods).FirstOrDefaultAsync(x => x.Id == tariffId, ct);
if (t is null) return null;
t.Name = req.Name.Trim();
t.EffectiveFrom = req.EffectiveFrom;
t.EffectiveTo = req.EffectiveTo;
t.DefaultRatePerKwh = req.DefaultRatePerKwh;
t.FixedMonthlyCharge = req.FixedMonthlyCharge;
t.VatPercentage = req.VatPercentage;
t.IsActive = req.IsActive;
db.TariffPeriods.RemoveRange(t.Periods);
t.Periods = MapPeriods(req.Periods).ToList();
await db.SaveChangesAsync(ct);
return ToDetail(t);
}
public async Task<bool> DeleteTariffAsync(int tariffId, CancellationToken ct = default)
{
var t = await db.Tariffs.FindAsync(new object?[] { tariffId }, ct);
if (t is null) return false;
db.Tariffs.Remove(t);
await db.SaveChangesAsync(ct);
return true;
}
private static IEnumerable<TariffPeriod> MapPeriods(IReadOnlyList<TariffPeriodDto> periods) =>
periods.Select(p => new TariffPeriod
{
Name = p.Name.Trim(),
DaysOfWeek = (DayOfWeekFlag)p.DaysOfWeek,
StartTime = TimeOnly.Parse(p.StartTime),
EndTime = TimeOnly.Parse(p.EndTime),
RatePerKwh = p.RatePerKwh
});
private static TariffDetailDto ToDetail(Tariff t) => new(
t.Id, 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 TariffPeriodDto(
p.Name, (int)p.DaysOfWeek, p.StartTime.ToString("HH:mm"), p.EndTime.ToString("HH:mm"),
p.RatePerKwh)).ToArray());
private static string? NormalizeTz(string? tz) =>
string.IsNullOrWhiteSpace(tz) ? null : tz.Trim();
}