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>
147 lines
5.7 KiB
C#
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();
|
|
}
|