|
9 | 9 | from datetime import timedelta, datetime, time |
10 | 10 | from typing import Any |
11 | 11 | from zoneinfo import ZoneInfo |
12 | | -from homeassistant.components.sensor import SensorEntity, SensorDeviceClass |
| 12 | +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass |
13 | 13 | from homeassistant.config_entries import ConfigEntry |
14 | 14 | from homeassistant.const import EntityCategory, STATE_UNAVAILABLE |
15 | 15 | from homeassistant.core import HomeAssistant |
16 | 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback |
| 17 | +from homeassistant.helpers.restore_state import RestoreEntity |
17 | 18 | from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \ |
18 | 19 | ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \ |
19 | 20 | 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: |
424 | 425 | self._rohlik_account.remove_callback(self.async_write_ha_state) |
425 | 426 |
|
426 | 427 |
|
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 | + """ |
429 | 435 |
|
430 | 436 | _attr_translation_key = "monthly_spent" |
431 | 437 | _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") |
432 | 557 |
|
433 | 558 | @property |
434 | 559 | 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 | + } |
437 | 575 |
|
438 | 576 | @property |
439 | 577 | def icon(self) -> str: |
440 | 578 | return ICON_MONTHLY_SPENT |
441 | 579 |
|
442 | | - async def async_added_to_hass(self) -> None: |
443 | | - self._rohlik_account.register_callback(self.async_write_ha_state) |
444 | | - |
445 | 580 | async def async_will_remove_from_hass(self) -> None: |
446 | 581 | self._rohlik_account.remove_callback(self.async_write_ha_state) |
447 | 582 |
|
|
0 commit comments