94ace2df0e
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7627306800 |
Cross-customer cost compute on the Admin side
Per-customer cost over a UTC time range, joining the fleet hierarchy:
hourly_per_device CA × Devices × Sites × Municipalities × active Tariff
× period (in the municipality's local time) → per-bucket kWh × rate.
Per-day rollup + per-device breakdown. VAT applied per bucket so cost
stays correct across mid-window tariff changes.
Backend
- FleetCostService.ComputeAsync(customerId, fromUtc, toUtc):
- Loads device→site→municipality mapping (small per customer).
- Loads tariffs (with periods) grouped by municipality, ordered by
EffectiveFrom desc for active-tariff lookup.
- Reads hourly buckets in range from fleet.hourly_per_device via
raw ADO (the CA isn't an EF entity).
- For each bucket: pick active tariff for the bucket's date, convert
bucket start to local time via Municipality.TimeZoneId, pick the
matching period (or default rate), compute base + VAT.
- Rolls up per UTC date + per device. Tracks BucketsWithoutTariff
when site has no muni or no tariff covers the bucket date.
- New DTOs: FleetCostDto / FleetCostDayDto / FleetCostDeviceDto.
- Endpoint: GET /api/fleet/customers/{id}/cost?from&to. AdminOnly.
Validation: to > from, range <= 366 days (BadRequest on violation).
- Period-selection helper duplicated from CostCalculator (5 lines;
generic abstraction across TariffPeriod / FleetTariffPeriod is
more code than the duplication).
- Fixed monthly charges deliberately NOT applied (whole-month billing
concept; FixedMonthlyChargesIncluded=false in the response).
Frontend
- AdminCustomerDetailPage gets a Cost tab:
- RangePicker with quick ranges (Today, Last 7d, Last 30d, This
month). Default last 7 days.
- 4 Statistic cards: total kWh, base cost, VAT, total.
- Warning alerts: when buckets-without-tariff > 0; always-on info
that fixed monthly charges aren't included.
- Per-day table + per-device table.
Verified end-to-end on the running stack
- Patched DEV0001's existing site to LocalMunicipalityId=1
(Phase 23 test municipality with Domestic TOU tariff).
- Ingested 3 measurements at 10:00 / 10:20 / 10:40 UTC with kWh
totals 2000 / 2020 / 2050 → hourly CA bucket has delta = 50 kWh.
- Total kWh in window = 56 (50 from new bucket + 6 from earlier
Phase 14 backfill bucket).
- Tariff resolution: 10:00-12:00 UTC = 12:00-14:00 SAST, which is
neither Peak (17:00-20:00 weekdays) nor Off-Peak (weekends only)
→ defaults to 2.50/kWh.
- 56 × 2.50 = 140.00 base ✓
- 140 × 0.15 = 21.00 VAT ✓
- Total 161.00 ✓ exactly what the API returned.
Docs: FLEET-DESIGN.md §11 row updated — tariff sync + cost compute
both marked as shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b654997fc9 |
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>
|
||
|
|
3333202f3a |
Dual-token rotation grace window (24h default)
Token rotation used to be immediate cutover — push gap from when ops rotates to when the customer's .env is updated and portal restarted. Now the old token keeps working for 24h after rotation, so customer ops has a full workday to swap it in without dropping a single push tick. Backend - Customer entity gains PreviousTokenHash + PreviousTokenExpiresAt (both nullable). Non-unique index on PreviousTokenHash so the OR-lookup in FindByTokenAsync stays cheap. - CustomerService.RotateTokenAsync(id, graceWindow=null, ct): copies the existing TokenHash into PreviousTokenHash with PreviousTokenExpiresAt = now + graceWindow (default 24h, lifted to CustomerService.DefaultTokenGracePeriod), then issues a new current token. Second rotation overwrites the previous slot — at most one previous token is ever honoured. - CustomerService.FindByTokenAsync matches either current OR (previous AND PreviousTokenExpiresAt > now). IsActive=false still rejects both. - DTO exposes PreviousTokenExpiresAt so the UI can render the grace window status. - New EF migration AddPreviousTokenGraceWindow on AdminDbContext. Frontend - Customers table "Token" column shows an "Old token valid until …" orange tag with a tooltip whenever the grace window is active, plus the issue/rotation date as before. - TokenShownOnceModal mentions the 24h grace window so ops knows they have time to update .env without urgency. - Rotate-token popconfirm copy updated to reflect the new behavior. Tests (+5, 61/61 passing) - CustomerTokenGraceTests covers: create doesn't set previous; rotate moves current into previous slot with future expiry; zero grace window rejects original immediately; second rotation overwrites previous (original dies, first-rotation becomes the new previous); inactive customer rejects both current AND previous. Verified end-to-end on the dev host - Migration applied cleanly on the existing admin_fleet DB (existing DEV0001 customer got NULL previous columns, no data loss). - Created GRACE01 → got token1. - Rotated → got token2. PreviousTokenExpiresAt = +24h. Both token1 and token2 push successfully (200). - Rotated again → got token3. token1 push now returns 401 (gone). token2 push still 200 (now the previous). token3 push 200 (current). Docs - FLEET-DESIGN.md §6 rewritten — no longer "immediate cutover". - §11 "open seams" row for this feature marked as shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
880525b306 |
Add Fleet ingest design doc (portal/docs/FLEET-DESIGN.md)
Locked design for Admin / cross-customer aggregation feature. Implementation lands in phases 13-15. Key decisions captured: - Same portal binary, RunMode=Client|Admin config flag. - Two DbContext classes (ClientDbContext + AdminDbContext) to keep schemas cleanly separated and migrations sane. - Fleet ingest is opt-in (FleetIngest__Enabled=false works exactly as today, no data leaves customer stack). - Push by ReceivedAt, not Time, so firmware offline-buffer replays are picked up automatically. - Per-tick batch cap so a back-fill wave from one customer doesn't starve other customers' pushes. - SHA-256 token hash (not bcrypt) for the high-throughput ingest endpoint; tokens shown once on Admin Customers page. - Realtime continuous aggregates with wide start_offset so late back-fills materialize on the next refresh tick. - No retention policy. TimescaleDB compression on chunks older than 7 days handles long-term storage cost. - Open seams (tariff sync, RLS, GDPR delete, dual-token rotation, sharding) documented with v2 extension paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |