Skip to content

Commit a89b3f3

Browse files
committed
feat: implement HA-side accumulation for monthly spent sensor
- Replace API-based calculation with Home Assistant state accumulation - Only track orders with final prices (delivered and closed) - Use RestoreEntity to persist monthly totals across restarts - Track processed order IDs to avoid duplicates - Automatically reset at start of each month - Add state_class for proper history tracking - Fixes issue where only 15 days of history were visible due to API limit (50 orders) This approach eliminates dependency on API order limit and provides full monthly history regardless of order count.
1 parent 108922c commit a89b3f3

1 file changed

Lines changed: 143 additions & 8 deletions

File tree

custom_components/rohlikcz/sensor.py

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
from datetime import timedelta, datetime, time
1010
from typing import Any
1111
from zoneinfo import ZoneInfo
12-
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
12+
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
1313
from homeassistant.config_entries import ConfigEntry
1414
from homeassistant.const import EntityCategory, STATE_UNAVAILABLE
1515
from homeassistant.core import HomeAssistant
1616
from homeassistant.helpers.entity_platform import AddEntitiesCallback
17+
from homeassistant.helpers.restore_state import RestoreEntity
1718
from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \
1819
ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \
1920
ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT
@@ -424,24 +425,158 @@ async def async_will_remove_from_hass(self) -> None:
424425
self._rohlik_account.remove_callback(self.async_write_ha_state)
425426

426427

427-
class MonthlySpent(BaseEntity, SensorEntity):
428-
"""Sensor for amount spent in current month."""
428+
class MonthlySpent(BaseEntity, SensorEntity, RestoreEntity):
429+
"""Sensor for amount spent in current month with HA-side accumulation.
430+
431+
Only tracks orders that are delivered and closed (have final price).
432+
Orders from the delivered_orders endpoint should all be finalized.
433+
Uses Home Assistant's restore state to persist monthly totals across restarts.
434+
"""
429435

430436
_attr_translation_key = "monthly_spent"
431437
_attr_should_poll = False
438+
_attr_state_class = SensorStateClass.TOTAL
439+
_attr_native_unit_of_measurement = "CZK"
440+
441+
def __init__(self, rohlik_account: RohlikAccount) -> None:
442+
super().__init__(rohlik_account)
443+
self._monthly_total: float = 0.0
444+
self._processed_orders: set[str] = set() # Store order IDs
445+
self._current_month: str = datetime.now().strftime("%Y-%m")
446+
self._last_reset: datetime | None = None
447+
448+
def _is_order_final(self, order: dict) -> bool:
449+
"""
450+
Verify order has a final price.
451+
452+
Since orders come from the 'delivered_orders' endpoint, they should be finalized.
453+
We verify by checking that priceComposition exists and has a valid amount.
454+
"""
455+
# Check if priceComposition exists
456+
price_comp = order.get('priceComposition')
457+
if not price_comp:
458+
return False
459+
460+
# Check if total exists
461+
total = price_comp.get('total')
462+
if not total:
463+
return False
464+
465+
# Check if amount exists and is a valid number
466+
amount = total.get('amount')
467+
if amount is None:
468+
return False
469+
470+
# Verify it's a valid number
471+
try:
472+
float(amount)
473+
return True
474+
except (ValueError, TypeError):
475+
return False
476+
477+
async def async_added_to_hass(self) -> None:
478+
"""Restore state when added to HA."""
479+
await super().async_added_to_hass()
480+
481+
if (last_state := await self.async_get_last_state()) is not None:
482+
self._monthly_total = last_state.attributes.get("monthly_total", 0.0)
483+
self._processed_orders = set(last_state.attributes.get("processed_orders", []))
484+
self._current_month = last_state.attributes.get("current_month", datetime.now().strftime("%Y-%m"))
485+
if last_reset_str := last_state.attributes.get("last_reset"):
486+
self._last_reset = datetime.fromisoformat(last_reset_str)
487+
488+
self._check_and_reset_month()
489+
self._process_new_orders()
490+
491+
self._rohlik_account.register_callback(self.async_write_ha_state)
492+
493+
def _check_and_reset_month(self) -> None:
494+
"""Reset total if month changed."""
495+
current_month = datetime.now().strftime("%Y-%m")
496+
if current_month != self._current_month:
497+
_LOGGER.info(f"Month changed from {self._current_month} to {current_month}, resetting monthly total")
498+
self._monthly_total = 0.0
499+
self._processed_orders = set()
500+
self._current_month = current_month
501+
self._last_reset = datetime.now(ZoneInfo("Europe/Prague"))
502+
503+
def _process_new_orders(self) -> None:
504+
"""Process new orders and add to total.
505+
506+
Only processes orders that are delivered and closed (have final price).
507+
Uses order ID for unique identification.
508+
"""
509+
orders = self._rohlik_account.data.get('delivered_orders', [])
510+
if not orders:
511+
return
512+
513+
current_month_pattern = datetime.now().strftime("%Y-%m-")
514+
new_orders_count = 0
515+
516+
for order in orders:
517+
try:
518+
order_time = order.get('orderTime', '')
519+
520+
# Only process orders from current month
521+
if current_month_pattern not in order_time:
522+
continue
523+
524+
# Verify order has final price (delivered and closed)
525+
if not self._is_order_final(order):
526+
_LOGGER.debug(f"Order {order.get('id')} does not have final price, skipping")
527+
continue
528+
529+
# Get order ID (unique identifier)
530+
order_id = order.get('id')
531+
if not order_id:
532+
_LOGGER.warning(f"Order missing ID, skipping: {order.get('orderTime')}")
533+
continue
534+
535+
order_key = str(order_id)
536+
537+
# Skip if already processed
538+
if order_key in self._processed_orders:
539+
continue
540+
541+
# Get the final price
542+
amount = float(order['priceComposition']['total']['amount'])
543+
544+
# Add to total and mark as processed
545+
self._monthly_total += amount
546+
self._processed_orders.add(order_key)
547+
new_orders_count += 1
548+
549+
_LOGGER.debug(f"Added order {order_id} with amount {amount} CZK. New total: {self._monthly_total} CZK")
550+
551+
except (KeyError, ValueError, TypeError) as e:
552+
_LOGGER.warning(f"Skipping order due to error: {e}, order ID: {order.get('id')}")
553+
continue
554+
555+
if new_orders_count > 0:
556+
_LOGGER.info(f"Processed {new_orders_count} new order(s). Monthly total: {self._monthly_total} CZK")
432557

433558
@property
434559
def native_value(self) -> float | None:
435-
"""Returns amount spend within last month."""
436-
return calculate_current_month_orders_total(self._rohlik_account.data.get('delivered_orders', []))
560+
"""Returns amount spent in current month."""
561+
self._check_and_reset_month()
562+
self._process_new_orders()
563+
return self._monthly_total if self._monthly_total > 0 else 0.0
564+
565+
@property
566+
def extra_state_attributes(self) -> Mapping[str, Any] | None:
567+
"""Store state for restoration."""
568+
return {
569+
"monthly_total": self._monthly_total,
570+
"processed_orders": list(self._processed_orders),
571+
"current_month": self._current_month,
572+
"last_reset": self._last_reset.isoformat() if self._last_reset else None,
573+
"processed_count": len(self._processed_orders)
574+
}
437575

438576
@property
439577
def icon(self) -> str:
440578
return ICON_MONTHLY_SPENT
441579

442-
async def async_added_to_hass(self) -> None:
443-
self._rohlik_account.register_callback(self.async_write_ha_state)
444-
445580
async def async_will_remove_from_hass(self) -> None:
446581
self._rohlik_account.remove_callback(self.async_write_ha_state)
447582

0 commit comments

Comments
 (0)