diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index dac29ed..aa178d9 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -416,7 +416,7 @@ Phase D(API + 前端) - **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。 ### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试 -- **Status**: `todo` · **Depends**: M6-T04, M6-T09 +- **Status**: `done` · **Depends**: M6-T04, M6-T09 - **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts`;`modify` Config 页 Tibber 测试入口;`create` 对应 `*.test.tsx` - **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。 - **Out of scope**: 不做服务端降采样;不改后端。 diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index fd14a4d..214ca8c 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -242,6 +242,323 @@ export interface paths { patch: operations["patch_poo_api_poo__timestamp__patch"]; trace?: never; }; + "/api/energy/prices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Prices + * @description Return the price curve for the active contract. + * + * **Tibber contracts** (kind="tibber"): + * Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending + * by ``starts_at``. At most ``limit`` rows are returned (most recent first + * within the window, then reversed to ascending order — identical to the + * modbus readings pattern). + * + * Response ``points`` carries per-slot: + * - ``buy = total`` (Tibber all-inclusive price) + * - ``sell = total − energy_tax − sell_adjust`` (from active version values) + * - ``level`` (Tibber price level, may be null) + * + * ``tariff`` is null. + * + * **Manual contracts** (kind="manual"): + * ``points`` is empty. ``tariff`` carries the four effective prices + * derived using the billing engine formula: + * - ``buy_dal = energy.buy.dal + energy_tax + ode`` + * - ``buy_normal = energy.buy.normal + energy_tax + ode`` + * - ``sell_dal = energy.sell.dal`` + * - ``sell_normal = energy.sell.normal`` + * + * **No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200). + */ + get: operations["get_prices_api_energy_prices_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/costs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Costs + * @description Return energy_cost_period rows within a time window. + * + * Rows are ordered by ``period_start`` ascending. When the window contains + * more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT), + * then reversed to ascending order — identical to the modbus readings pattern. + * + * Query parameters: + * - ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime). + * - ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime). + * - ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}). + */ + get: operations["get_costs_api_energy_costs_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/costs/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Costs Summary + * @description Aggregate billing for a time interval. + * + * Calls ``energy_cost.summarize(session, start, end)`` which computes: + * + * total_payable = Σ(net_cost) + fixed_costs − credits + * + * where ``fixed_costs`` is (network_fee + management_fee) apportioned to the + * interval length in days (÷30 per month), and ``credits`` is heffingskorting + * apportioned similarly (÷365 per year). + * + * Both ``fixed_costs`` and ``credits`` are derived from the **currently active + * contract version at ``end``**. When no active contract exists they are 0. + */ + get: operations["get_costs_summary_api_energy_costs_summary_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/dsmr/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Dsmr Latest + * @description Return the most recent dsmr_reading row. + * + * Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not + * 404) when no rows exist yet, so the front-end can distinguish "no data" from + * a server error. + */ + get: operations["get_dsmr_latest_api_energy_dsmr_latest_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/costs/recompute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Recompute + * @description Idempotently recompute billing records in a time window. + * + * Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites + * existing rows (including successful ones) for every UTC quarter-hour boundary + * in ``[start, end)``. + * + * **Idempotency**: repeated calls with the same window produce the same + * outcome. No rows are deleted; only upserted. + * + * **Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days. + * Requests exceeding this return 422. + * + * Returns the number of periods for which a billing record was written. + * Periods skipped due to missing contract or missing Tibber price are not counted. + */ + post: operations["post_recompute_api_energy_costs_recompute_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/tibber/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Tibber Test + * @description Test Tibber API connectivity by fetching the current price point. + * + * Three possible outcomes: + * + * - **200** ``{ result: "success", message: ..., price: {...} }`` + * The Tibber API responded with a valid current price. ``price`` contains + * starts_at, total, energy, tax, currency, and level. + * + * - **400** ``{ result: "config-error", message: ... }`` + * The Tibber API token is empty or not configured. + * + * - **502** ``{ result: "failed", message: ... }`` + * The API call failed (authentication rejected, network error, timeout, + * unexpected response, etc.). + * + * The API token is **never** included in the response body or logged. + */ + post: operations["post_tibber_test_api_energy_tibber_test_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/profiles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Profiles + * @description List all available pricing profile structures. + * + * Returns the full profile structure for each supported contract kind + * (``manual`` and ``tibber``). The front-end uses this to dynamically + * render the correct fields and labels for the contract creation/editing form. + */ + get: operations["get_profiles_api_energy_profiles_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/contracts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Energy Contracts + * @description List all energy contracts with their active status. + * + * Returns a flat list (no embedded version history); use + * GET /api/energy/contracts/{id} to fetch the full version history for a + * specific contract. + */ + get: operations["list_energy_contracts_api_energy_contracts_get"]; + put?: never; + /** + * Create Energy Contract + * @description Create a new energy contract with an initial pricing version. + * + * The ``values`` dict is validated against the YAML profile for the given + * ``kind``; non-conforming values result in 422 Unprocessable Entity. + * The new contract is created with ``active=False``; use + * PATCH /api/energy/contracts/{id} with ``active=true`` to activate it. + */ + post: operations["create_energy_contract_api_energy_contracts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/contracts/{contract_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Energy Contract + * @description Return a single energy contract with its full version history. + * + * Versions are ordered by ``effective_from`` ascending so the caller can + * easily inspect the pricing timeline. + */ + get: operations["get_energy_contract_api_energy_contracts__contract_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Patch Energy Contract + * @description Partially update a contract: rename or change activation status. + * + * - ``name``: updates the human-readable label. + * - ``active=true``: activates this contract (all others are deactivated). + * - ``active=false``: deactivates this contract (no effect on others). + * + * At most one contract may be active at any time; the service layer enforces + * mutual exclusion. + */ + patch: operations["patch_energy_contract_api_energy_contracts__contract_id__patch"]; + trace?: never; + }; + "/api/energy/contracts/{contract_id}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add Contract Version + * @description Add a new pricing version to an existing contract. + * + * This is how price changes are recorded: the current open version is + * automatically closed (its ``effective_to`` is set to ``body.effective_from``) + * and a new version is created starting at ``body.effective_from``. + * + * The ``values`` dict must conform to the contract's pricing profile. + * Non-conforming values return 422. If ``effective_from`` is not strictly + * after the previous version's ``effective_from``, 422 is returned without + * writing any rows. + * + * Historical versions are never modified; this endpoint is append-only. + */ + post: operations["add_contract_version_api_energy_contracts__contract_id__versions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/expose": { parameters: { query?: never; @@ -874,6 +1191,216 @@ export interface components { /** Sections */ sections: components["schemas"]["ConfigSection"][]; }; + /** + * ContractCreate + * @description Request body for POST /api/energy/contracts. + * + * ``effective_from`` defaults to the current UTC time if not provided, + * giving the first version an open-ended start from "now". + * ``kind`` is validated at the application layer against the profile registry; + * clients should send ``"manual"`` or ``"tibber"``. + */ + ContractCreate: { + /** Name */ + name: string; + /** Kind */ + kind: string; + /** + * Currency + * @default EUR + */ + currency: string; + /** Values */ + values: { + [key: string]: unknown; + }; + /** + * Effective From + * @description UTC datetime from which the first pricing version is effective. Defaults to the current UTC time when omitted. + */ + effective_from?: string | null; + }; + /** + * ContractDetailResponse + * @description Response schema for a single EnergyContract with full version history. + * + * Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH + * operations where the caller needs to see all version data. + */ + ContractDetailResponse: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Kind */ + kind: string; + /** Active */ + active: boolean; + /** Currency */ + currency: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** Versions */ + versions: components["schemas"]["ContractVersionResponse"][]; + }; + /** + * ContractListResponse + * @description Response schema for GET /api/energy/contracts. + */ + ContractListResponse: { + /** Items */ + items: components["schemas"]["ContractResponse"][]; + /** Total */ + total: number; + }; + /** + * ContractPatch + * @description Request body for PATCH /api/energy/contracts/{id}. + * + * All fields are optional. Sending ``active=true`` activates this contract + * (deactivating all others); ``active=false`` deactivates it without affecting + * other contracts. + */ + ContractPatch: { + /** Name */ + name?: string | null; + /** Active */ + active?: boolean | null; + }; + /** + * ContractResponse + * @description Response schema for a single EnergyContract (without embedded versions). + * + * Used for list responses where embedding all versions would be expensive. + */ + ContractResponse: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Kind */ + kind: string; + /** Active */ + active: boolean; + /** Currency */ + currency: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * ContractVersionResponse + * @description Response schema for a single EnergyContractVersion row. + */ + ContractVersionResponse: { + /** Id */ + id: number; + /** + * Effective From + * Format: date-time + */ + effective_from: string; + /** Effective To */ + effective_to: string | null; + /** Values */ + values: { + [key: string]: unknown; + }; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** + * CostPeriodSchema + * @description One 15-minute billing record from the energy_cost_period table. + */ + CostPeriodSchema: { + /** + * Period Start + * Format: date-time + */ + period_start: string; + /** + * D1 Kwh + * @description Delivered low-tariff kWh for this period. + */ + d1_kwh: number; + /** + * D2 Kwh + * @description Delivered normal-tariff kWh for this period. + */ + d2_kwh: number; + /** + * R1 Kwh + * @description Returned low-tariff kWh for this period. + */ + r1_kwh: number; + /** + * R2 Kwh + * @description Returned normal-tariff kWh for this period. + */ + r2_kwh: number; + /** + * Import Cost + * @description Cost of electricity drawn from grid (EUR). + */ + import_cost: number; + /** + * Export Revenue + * @description Revenue from electricity fed to grid (EUR). + */ + export_revenue: number; + /** + * Net Cost + * @description import_cost − export_revenue (EUR). + */ + net_cost: number; + /** + * Currency + * @description ISO 4217 currency code. + */ + currency: string; + /** + * Degraded + * @description True when the period was computed with incomplete data. + */ + degraded: boolean; + /** + * Contract Version Id + * @description FK to the contract version used for this billing period (null when degraded). + */ + contract_version_id?: number | null; + }; + /** + * CostsResponse + * @description Response for GET /api/energy/costs. + */ + CostsResponse: { + /** Items */ + items: components["schemas"]["CostPeriodSchema"][]; + /** + * Total + * @description Number of items returned. + */ + total: number; + }; /** * DeviceInfoSchema * @description HA device grouping info for an exposable entity. @@ -884,6 +1411,23 @@ export interface components { /** Name */ name: string; }; + /** + * DsmrLatestResponse + * @description Response for GET /api/energy/dsmr/latest. + * + * ``found`` is False when no dsmr_reading rows exist yet. The front-end + * should check ``found`` before reading ``recorded_at`` or ``payload``. + */ + DsmrLatestResponse: { + /** Found */ + found: boolean; + /** Recorded At */ + recorded_at?: string | null; + /** Payload */ + payload?: { + [key: string]: unknown; + } | null; + }; /** * ExposableEntitySchema * @description One exposable entity in the catalog. @@ -985,6 +1529,35 @@ export interface components { /** Totp Code */ totp_code?: string | null; }; + /** + * ManualTariffSchema + * @description Fixed-tariff breakdown for manual contracts. + * + * Prices are the *effective* buy prices as used by the billing engine + * (energy_buy_x + energy_tax + ode) and the raw sell prices. + */ + ManualTariffSchema: { + /** + * Buy Dal + * @description Effective buy price, low-tariff / dal (EUR/kWh). + */ + buy_dal: number; + /** + * Buy Normal + * @description Effective buy price, normal / high-tariff (EUR/kWh). + */ + buy_normal: number; + /** + * Sell Dal + * @description Sell price, low-tariff / dal (EUR/kWh). + */ + sell_dal: number; + /** + * Sell Normal + * @description Sell price, normal / high-tariff (EUR/kWh). + */ + sell_normal: number; + }; /** * MetricInfo * @description Metadata for a single measurable quantity in a device's profile. @@ -1247,6 +1820,63 @@ export interface components { /** Longitude */ longitude?: number | null; }; + /** + * PricePointSchema + * @description A single 15-minute price point (tibber) or placeholder entry. + */ + PricePointSchema: { + /** + * Starts At + * Format: date-time + */ + starts_at: string; + /** + * Buy + * @description All-in buy price in EUR/kWh (including taxes). + */ + buy: number; + /** + * Sell + * @description Net sell price in EUR/kWh. + */ + sell: number; + /** + * Level + * @description Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual. + */ + level?: string | null; + }; + /** + * PricesResponse + * @description Response for GET /api/energy/prices. + * + * ``kind`` mirrors the active contract kind: + * - ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null. + * - ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table. + * - ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null. + * + * ``currency`` comes from the active contract (or "EUR" fallback). + * ``points`` is always ascending by ``starts_at``. + */ + PricesResponse: { + /** + * Kind + * @description Active contract kind ('tibber' or 'manual'), or null if no active contract. + */ + kind: string | null; + /** + * Currency + * @description ISO 4217 currency code. + */ + currency: string; + /** + * Points + * @description 15-minute price points for tibber contracts (ascending by starts_at). Empty for manual contracts or when no active contract exists. + */ + points: components["schemas"]["PricePointSchema"][]; + /** @description Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists. */ + tariff?: components["schemas"]["ManualTariffSchema"] | null; + }; /** * ProfileSummary * @description One entry in the GET /api/modbus/profiles response. @@ -1257,6 +1887,21 @@ export interface components { /** Description */ description: string; }; + /** + * ProfilesResponse + * @description Response schema for GET /api/energy/profiles. + * + * ``profiles`` is a list of raw profile dicts as produced by + * ``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum + * ``kind`` and ``label``; the full nested structure allows the front-end to + * render a type-appropriate form for each pricing profile. + */ + ProfilesResponse: { + /** Profiles */ + profiles: { + [key: string]: unknown; + }[]; + }; /** PublicIPCheckResponse */ PublicIPCheckResponse: { /** @@ -1321,6 +1966,17 @@ export interface components { /** Last Provider */ last_provider: string | null; }; + /** + * RecomputeResponse + * @description Response for POST /api/energy/costs/recompute. + */ + RecomputeResponse: { + /** + * Recomputed + * @description Number of 15-minute periods for which a billing record was written (inserted or updated). Periods skipped due to missing contract or missing Tibber price are not counted. + */ + recomputed: number; + }; /** * RepublishResponse * @description Response for POST /api/expose/republish. @@ -1362,6 +2018,110 @@ export interface components { /** Status */ status: string; }; + /** + * SummaryResponse + * @description Response for GET /api/energy/costs/summary. + * + * All monetary values are in ``currency``. + * + * ``total_payable = metered_net + fixed_costs − credits`` + */ + SummaryResponse: { + /** Currency */ + currency: string; + /** + * Metered Import + * @description Σ import_cost for non-degraded periods. + */ + metered_import: number; + /** + * Metered Export + * @description Σ export_revenue for non-degraded periods. + */ + metered_export: number; + /** + * Metered Net + * @description Σ net_cost for non-degraded periods. + */ + metered_net: number; + /** + * Fixed Costs + * @description Standing charges (network_fee + management_fee) apportioned over the interval. + */ + fixed_costs: number; + /** + * Credits + * @description Energy-tax credit (heffingskorting) apportioned over the interval. + */ + credits: number; + /** + * Total Payable + * @description metered_net + fixed_costs − credits (actual amount owed). + */ + total_payable: number; + /** + * Period Count + * @description Number of non-degraded billing periods in range. + */ + period_count: number; + /** + * Degraded Count + * @description Number of degraded billing periods in range. + */ + degraded_count: number; + /** + * Days + * @description Interval length in days. + */ + days: number; + }; + /** + * TibberTestPriceSchema + * @description Current Tibber price point returned on a successful test. + * + * Carries enough fields for the front-end to confirm the API is working and + * display the live price. The API token is **never** included. + */ + TibberTestPriceSchema: { + /** + * Starts At + * Format: date-time + */ + starts_at: string; + /** Total */ + total: number; + /** Energy */ + energy: number; + /** Tax */ + tax: number; + /** Currency */ + currency: string; + /** Level */ + level?: string | null; + }; + /** + * TibberTestResponse + * @description Three-state response for POST /api/energy/tibber/test. + * + * Possible ``result`` values: + * + * - ``"success"`` — Tibber API responded with a valid price. + * - ``"config-error"`` — Token is missing or not configured. + * - ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.). + * + * ``price`` is populated only on ``"success"``; it is null otherwise. + * ``message`` always contains a human-readable explanation. + */ + TibberTestResponse: { + /** + * Result + * @enum {string} + */ + result: "success" | "config-error" | "failed"; + /** Message */ + message: string; + price?: components["schemas"]["TibberTestPriceSchema"] | null; + }; /** * TotpDisableRequest * @description Disable TOTP by proving identity. @@ -1415,6 +2175,21 @@ export interface components { /** Error Type */ type: string; }; + /** + * VersionCreate + * @description Request body for POST /api/energy/contracts/{id}/versions. + */ + VersionCreate: { + /** + * Effective From + * Format: date-time + */ + effective_from: string; + /** Values */ + values: { + [key: string]: unknown; + }; + }; }; responses: never; parameters: never; @@ -1832,6 +2607,395 @@ export interface operations { }; }; }; + get_prices_api_energy_prices_get: { + parameters: { + query?: { + /** @description Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted. */ + start?: string | null; + /** @description Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted. */ + end?: string | null; + /** @description Maximum number of Tibber price points to return. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PricesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_costs_api_energy_costs_get: { + parameters: { + query?: { + /** @description Inclusive lower bound for period_start (ISO 8601). */ + start?: string | null; + /** @description Inclusive upper bound for period_start (ISO 8601). */ + end?: string | null; + /** @description Maximum number of cost periods to return (default 500, max 5000). */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CostsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_costs_summary_api_energy_costs_summary_get: { + parameters: { + query?: { + /** @description Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day. */ + start?: string | null; + /** @description Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data). */ + end?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SummaryResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_dsmr_latest_api_energy_dsmr_latest_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DsmrLatestResponse"]; + }; + }; + }; + }; + post_recompute_api_energy_costs_recompute_post: { + parameters: { + query: { + /** @description Inclusive start of the recompute window (ISO 8601). Required. */ + start: string; + /** @description Exclusive end of the recompute window (ISO 8601). Required. */ + end: string; + }; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecomputeResponse"]; + }; + }; + /** @description Validation error (missing window or range too large) */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post_tibber_test_api_energy_tibber_test_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TibberTestResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TibberTestResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Bad Gateway */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TibberTestResponse"]; + }; + }; + }; + }; + get_profiles_api_energy_profiles_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProfilesResponse"]; + }; + }; + }; + }; + list_energy_contracts_api_energy_contracts_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractListResponse"]; + }; + }; + }; + }; + create_energy_contract_api_energy_contracts_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContractCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_energy_contract_api_energy_contracts__contract_id__get: { + parameters: { + query?: never; + header?: never; + path: { + contract_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_energy_contract_api_energy_contracts__contract_id__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + contract_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContractPatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add_contract_version_api_energy_contracts__contract_id__versions_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + contract_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VersionCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_expose_api_expose_get: { parameters: { query?: never; diff --git a/frontend/src/energy/ContractForm.test.tsx b/frontend/src/energy/ContractForm.test.tsx new file mode 100644 index 0000000..31f7a95 --- /dev/null +++ b/frontend/src/energy/ContractForm.test.tsx @@ -0,0 +1,249 @@ +/** + * Tests for ContractForm component. + * + * Coverage: + * 1. Renders profile fields dynamically from profile structure (not hardcoded) + * — verified by passing defaultKind so fields render automatically. + * 2. Submit in create mode calls POST /api/energy/contracts. + * 3. Submit in add-version mode calls POST /api/energy/contracts/{id}/versions. + * 4. Cancel closes modal. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() +const mockPost = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + GET: (...args: unknown[]) => mockGet(...args), + POST: (...args: unknown[]) => mockPost(...args), + PATCH: vi.fn(), + DELETE: vi.fn(), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const PROFILES_RESPONSE = { + profiles: [ + { + kind: 'manual', + label: 'Fixed / Variable Rate (NL, dual-tariff)', + energy: { + dual_tariff: true, + buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } }, + sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } }, + energy_tax: { unit: 'EUR/kWh' }, + ode: { unit: 'EUR/kWh', default: 0 }, + }, + standing: { + network_fee: { unit: 'EUR/month' }, + management_fee: { unit: 'EUR/month' }, + }, + credits: { + heffingskorting: { unit: 'EUR/year' }, + }, + }, + ], +} + +const CREATED_CONTRACT = { + id: 1, + name: 'Test Contract', + kind: 'manual', + active: false, + currency: 'EUR', + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-01T00:00:00Z', + versions: [], +} + +// --------------------------------------------------------------------------- +// Import component +// --------------------------------------------------------------------------- + +import { ContractForm } from './ContractForm' + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ContractForm', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders profile fields dynamically from profile structure (not hardcoded)', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/profiles') { + return Promise.resolve({ data: PROFILES_RESPONSE }) + } + return Promise.resolve({ data: null }) + }) + + const onClose = vi.fn() + const onSaved = vi.fn() + + // Pass defaultKind so the profile fields render automatically in create mode + renderWithProviders( + , + ) + + // Wait for profiles to load and fields to appear + await waitFor(() => { + expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Verify sections are rendered from profile structure (not hardcoded) + expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument() + expect(screen.getByTestId('contract-section-standing')).toBeInTheDocument() + expect(screen.getByTestId('contract-section-credits')).toBeInTheDocument() + + // Verify specific fields exist from the profile structure + // "energy.buy.normal" → label "Buy Normal" + expect(screen.getByTestId('contract-field-energy.buy.normal')).toBeInTheDocument() + expect(screen.getByTestId('contract-field-energy.buy.dal')).toBeInTheDocument() + expect(screen.getByTestId('contract-field-standing.network_fee')).toBeInTheDocument() + }) + + it('calls POST /api/energy/contracts in create mode', async () => { + const user = userEvent.setup() + + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/profiles') { + return Promise.resolve({ data: PROFILES_RESPONSE }) + } + return Promise.resolve({ data: null }) + }) + mockPost.mockResolvedValue({ data: CREATED_CONTRACT }) + + const onClose = vi.fn() + const onSaved = vi.fn() + + // Use defaultKind to ensure fields load without needing to interact with Select + renderWithProviders( + , + ) + + // Wait for form to render + await waitFor(() => { + expect(screen.getByTestId('contract-form')).toBeInTheDocument() + }) + + // Wait for profile fields to appear + await waitFor(() => { + expect(screen.getByTestId('contract-name')).toBeInTheDocument() + }) + + // Fill in contract name + await user.type(screen.getByTestId('contract-name'), 'Test Contract') + + // Submit form + await user.click(screen.getByTestId('contract-form-submit')) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/api/energy/contracts', + expect.objectContaining({ + body: expect.objectContaining({ + name: 'Test Contract', + kind: 'manual', + currency: 'EUR', + }), + }), + ) + }, { timeout: 3000 }) + }) + + it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => { + const user = userEvent.setup() + + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/profiles') { + return Promise.resolve({ data: PROFILES_RESPONSE }) + } + return Promise.resolve({ data: null }) + }) + mockPost.mockResolvedValue({ data: CREATED_CONTRACT }) + + const onClose = vi.fn() + const onSaved = vi.fn() + + renderWithProviders( + , + ) + + // Wait for form to be ready (add-version mode) + await waitFor(() => { + expect(screen.getByTestId('contract-form')).toBeInTheDocument() + }) + + // In add-version mode, no name or kind fields shown + expect(screen.queryByTestId('contract-name')).not.toBeInTheDocument() + expect(screen.queryByTestId('contract-kind')).not.toBeInTheDocument() + + // Fill in effective_from (required in add-version mode) + const effectiveDateInput = screen.getByTestId('contract-effective-from') + await user.type(effectiveDateInput, '2026-07-01') + + // Submit form + await user.click(screen.getByTestId('contract-form-submit')) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/api/energy/contracts/{contract_id}/versions', + expect.objectContaining({ + params: { path: { contract_id: 1 } }, + body: expect.objectContaining({ + values: expect.any(Object), + }), + }), + ) + }, { timeout: 3000 }) + }) + + it('calls onClose when Cancel button is clicked', async () => { + const user = userEvent.setup() + + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/profiles') { + return Promise.resolve({ data: PROFILES_RESPONSE }) + } + return Promise.resolve({ data: null }) + }) + + const onClose = vi.fn() + const onSaved = vi.fn() + + renderWithProviders( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('contract-form-cancel')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('contract-form-cancel')) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/energy/ContractForm.tsx b/frontend/src/energy/ContractForm.tsx new file mode 100644 index 0000000..7026515 --- /dev/null +++ b/frontend/src/energy/ContractForm.tsx @@ -0,0 +1,386 @@ +/** + * ContractForm — create a new energy contract OR add a version to an existing one. + * + * Form fields are dynamically rendered from the profile structure returned by + * GET /api/energy/profiles — never hardcoded. The profile structure provides + * section names (energy, standing, credits), leaf keys, and their units. + * + * Create mode: show Name, Kind (Select from profiles), Currency, optional + * effective_from, then dynamically rendered profile value fields. + * Add-version mode (contractId provided): show only effective_from (required) + * + profile value fields for the contract's kind. + */ + +import { useState } from 'react' +import { + Modal, + Stack, + TextInput, + NumberInput, + Select, + Button, + Group, + Alert, + Loader, + Center, + Text, + Divider, + Title, +} from '@mantine/core' +import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks' + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface ContractFormProps { + /** When provided: add-version mode; contract must exist. */ + contractId?: number + /** Existing contract kind (for add-version mode or edit). */ + defaultKind?: string + onClose: () => void + onSaved: () => void +} + +// --------------------------------------------------------------------------- +// Helpers for dynamic profile field rendering +// --------------------------------------------------------------------------- + +/** + * A leaf in the profile structure is any object with a `unit` key. + * Returns true if the value is a leaf (renderable field). + */ +function isLeaf(val: unknown): val is { unit: string; default?: unknown } { + return typeof val === 'object' && val !== null && 'unit' in val +} + +/** + * Extract all leaf fields from a profile section, returning + * [dotPath, unit, defaultValue] tuples. E.g.: + * "energy.buy.normal" → "EUR/kWh" + * "energy.energy_tax" → "EUR/kWh" + */ +interface LeafField { + /** Dot-separated path within the section, e.g. "buy.normal" */ + fieldPath: string + unit: string + defaultValue?: number +} + +function extractLeafFields(obj: Record, prefix = ''): LeafField[] { + const fields: LeafField[] = [] + for (const [key, val] of Object.entries(obj)) { + // Skip non-object values (e.g. dual_tariff: true) + if (typeof val !== 'object' || val === null) continue + const path = prefix ? `${prefix}.${key}` : key + if (isLeaf(val)) { + fields.push({ + fieldPath: path, + unit: val.unit, + defaultValue: typeof val.default === 'number' ? val.default : undefined, + }) + } else { + fields.push(...extractLeafFields(val as Record, path)) + } + } + return fields +} + +/** + * Build a nested values object from flat field values. + * E.g. { "buy.normal": 0.133, "buy.dal": 0.127 } → { buy: { normal: 0.133, dal: 0.127 } } + */ +function buildNestedValues( + sectionFields: Record, + fieldValues: Record, +): Record { + const result: Record = {} + + for (const [section, fields] of Object.entries(sectionFields)) { + const sectionObj: Record = {} + for (const field of fields) { + const raw = fieldValues[`${section}.${field.fieldPath}`] + const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw)) + // Set nested path + const parts = field.fieldPath.split('.') + let current = sectionObj + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current)) current[parts[i]] = {} + current = current[parts[i]] as Record + } + current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal + } + result[section] = sectionObj + } + + return result +} + +/** + * Label formatter: converts "buy.normal" → "Buy Normal", "energy_tax" → "Energy Tax" + */ +function formatLabel(path: string): string { + return path + .replace(/\./g, ' ') + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) { + const isAddVersion = contractId != null + + // Profiles query + const profilesQuery = useEnergyProfiles() + + // Create mode fields + const [name, setName] = useState('') + const [selectedKind, setSelectedKind] = useState(defaultKind ?? null) + const [currency, setCurrency] = useState('EUR') + // ISO date string "YYYY-MM-DD" for effective_from; empty = default to now + const [effectiveFromStr, setEffectiveFromStr] = useState('') + + // Dynamic profile field values: key = "
.", value = number + const [fieldValues, setFieldValues] = useState>({}) + + const [error, setError] = useState(null) + + const createMutation = useCreateContract() + const addVersionMutation = useAddContractVersion() + + const isBusy = createMutation.isPending || addVersionMutation.isPending + + // Resolve the effective kind (add-version mode uses defaultKind) + const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind + + // Build profile options from API response + const profiles = profilesQuery.data?.profiles ?? [] + const profileOptions = profiles.map((p: Record) => ({ + value: p.kind as string, + label: (p.label as string | undefined) ?? (p.kind as string), + })) + + // Find the selected profile structure + const selectedProfile = profiles.find((p: Record) => p.kind === effectiveKind) + + // Extract section → leaf fields mapping from the selected profile + const sectionFields: Record = {} + if (selectedProfile) { + const profileObj = selectedProfile as Record + for (const [key, val] of Object.entries(profileObj)) { + // Skip metadata fields + if (key === 'kind' || key === 'label') continue + if (typeof val === 'object' && val !== null && !isLeaf(val)) { + sectionFields[key] = extractLeafFields(val as Record) + } + } + } + + function handleFieldChange(key: string, val: number | string) { + setFieldValues((prev) => ({ ...prev, [key]: val })) + } + + // Initialize default values when profile changes + function initDefaults(kind: string | null) { + if (!kind) return + const profile = profiles.find((p: Record) => p.kind === kind) + if (!profile) return + const profileObj = profile as Record + const defaults: Record = {} + for (const [sectionKey, sectionVal] of Object.entries(profileObj)) { + if (sectionKey === 'kind' || sectionKey === 'label') continue + if (typeof sectionVal === 'object' && sectionVal !== null && !isLeaf(sectionVal)) { + const leaves = extractLeafFields(sectionVal as Record) + for (const leaf of leaves) { + defaults[`${sectionKey}.${leaf.fieldPath}`] = leaf.defaultValue ?? 0 + } + } + } + setFieldValues(defaults) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (!isAddVersion && !name.trim()) { + setError('Contract name is required.') + return + } + if (!effectiveKind) { + setError('Contract kind is required.') + return + } + + const values = buildNestedValues(sectionFields, fieldValues) + + try { + // Convert local date string to ISO datetime if provided + const effectiveFromISO = effectiveFromStr + ? new Date(effectiveFromStr).toISOString() + : undefined + + if (isAddVersion) { + const body = { + effective_from: effectiveFromISO ?? new Date().toISOString(), + values, + } + await addVersionMutation.mutateAsync({ id: contractId, body }) + } else { + const body = { + name: name.trim(), + kind: effectiveKind, + currency, + values, + ...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}), + } + await createMutation.mutateAsync(body) + } + onSaved() + onClose() + } catch { + setError('Failed to save. Please try again.') + } + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const title = isAddVersion ? 'Add Contract Version' : 'New Energy Contract' + + return ( + + {profilesQuery.isLoading && ( +
+ +
+ )} + + {profilesQuery.isError && ( + + Failed to load pricing profiles. Cannot continue. + + )} + + {!profilesQuery.isLoading && ( +
+ + {/* Create mode fields */} + {!isAddVersion && ( + <> + setName(e.currentTarget.value)} + data-testid="contract-name" + /> + +