Surface add-on prices and add an add-ons total sensor
Add-on rows already carried quantity, unit and extended price; only the name was exposed. The attribute now renders the full line and a new monetary sensor reports the add-ons subtotal separately from the box. The fixture totals were copied from a seven-add-on cart while the fixture itself had one, so they are now self-consistent and a test asserts that add-ons plus box price equals the portal's subtotal.
This commit is contained in:
@@ -5,6 +5,24 @@ All notable changes to this project are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `sensor.fresh_harvest_next_delivery_add_ons`, the combined cost of the
|
||||||
|
add-ons excluding the produce box.
|
||||||
|
- `add_ons_total` and `box_price` attributes on the item-count sensor.
|
||||||
|
- A test asserting `add_ons_total + box_price` equals the portal's own
|
||||||
|
subtotal, so a mis-parsed price cannot pass silently.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `add_ons` attribute entries now carry quantity, unit, and extended price
|
||||||
|
(`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`) rather than a bare name.
|
||||||
|
Templates reading these strings will need updating.
|
||||||
|
- The test fixture's totals are now internally consistent, so the subtotal
|
||||||
|
reconciliation is a real invariant rather than copied numbers.
|
||||||
|
|
||||||
## [0.1.0] - 2026-08-03
|
## [0.1.0] - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ the Georgia local-produce delivery subscription.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `sensor.fresh_harvest_next_delivery` | `2026-08-04` | Attributes: `delivery_day`, `box` |
|
| `sensor.fresh_harvest_next_delivery` | `2026-08-04` | Attributes: `delivery_day`, `box` |
|
||||||
| `sensor.fresh_harvest_next_delivery_total` | `109.06` | Attributes: `subtotal`, `tax`, `delivery_fee` |
|
| `sensor.fresh_harvest_next_delivery_total` | `109.06` | Attributes: `subtotal`, `tax`, `delivery_fee` |
|
||||||
| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `produce`, `add_ons` |
|
| `sensor.fresh_harvest_next_delivery_add_ons` | `72.88` | Add-ons only, excluding the box. Attributes: `box_price`, `add_ons` |
|
||||||
|
| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `box_price`, `produce`, `add_ons`, `add_ons_total` |
|
||||||
| `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` | The order you can still change |
|
| `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` | The order you can still change |
|
||||||
| `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` | `closed` when nothing is customizable |
|
| `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` | `closed` when nothing is customizable |
|
||||||
|
|
||||||
|
Each `add_ons` entry carries its quantity, unit, and extended price — a
|
||||||
|
multi-quantity line bills as one amount, so 4 smoothies read
|
||||||
|
`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`. `add_ons_total` plus
|
||||||
|
`box_price` always equals the portal's own subtotal, which a test asserts.
|
||||||
|
|
||||||
The next delivery and the *open* order are usually two different deliveries.
|
The next delivery and the *open* order are usually two different deliveries.
|
||||||
Once an order passes its cutoff it locks for packing, and the cart you can still
|
Once an order passes its cutoff it locks for packing, and the cart you can still
|
||||||
edit is the following week's.
|
edit is the following week's.
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ class DeliveryOrder:
|
|||||||
def all_items(self) -> list[OrderItem]:
|
def all_items(self) -> list[OrderItem]:
|
||||||
return [*self.items, *self.addons]
|
return [*self.items, *self.addons]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def addons_total(self) -> float:
|
||||||
|
"""Combined cost of the add-ons, excluding the produce box itself.
|
||||||
|
|
||||||
|
Add-on prices are already extended (4 smoothies bill as one $17.96
|
||||||
|
line), so this is a plain sum. It should equal `subtotal - box_price`.
|
||||||
|
"""
|
||||||
|
return round(sum(a.price for a in self.addons if a.price is not None), 2)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AccountSnapshot:
|
class AccountSnapshot:
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"domain": "freshharvest",
|
"domain": "freshharvest",
|
||||||
"name": "Fresh Harvest",
|
"name": "Fresh Harvest",
|
||||||
"codeowners": ["@flan"],
|
"codeowners": [
|
||||||
|
"@flan"
|
||||||
|
],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
|
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
|
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
|
||||||
"requirements": ["beautifulsoup4>=4.12"],
|
"requirements": [
|
||||||
"version": "0.1.0"
|
"beautifulsoup4>=4.12"
|
||||||
|
],
|
||||||
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,21 +18,30 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import FreshHarvestConfigEntry
|
from . import FreshHarvestConfigEntry
|
||||||
from .api import AccountSnapshot, DeliveryOrder
|
from .api import AccountSnapshot, DeliveryOrder, OrderItem
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import FreshHarvestCoordinator
|
from .coordinator import FreshHarvestCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
def _format_item(item: OrderItem, with_price: bool = False) -> str:
|
||||||
|
"""Render one line, e.g. '4 Complete Recovery Smoothie 15.2 fl oz — $17.96'."""
|
||||||
|
line = " ".join(
|
||||||
|
part for part in (str(item.quantity or ""), item.name, item.unit) if part
|
||||||
|
)
|
||||||
|
if with_price and item.price is not None:
|
||||||
|
line = f"{line} — ${item.price:,.2f}"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
|
def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
|
||||||
if order is None:
|
if order is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"box": order.box_name,
|
"box": order.box_name,
|
||||||
"produce": [
|
"box_price": order.box_price,
|
||||||
" ".join(part for part in (str(i.quantity or ""), i.name, i.unit) if part)
|
"produce": [_format_item(i) for i in order.items],
|
||||||
for i in order.items
|
"add_ons": [_format_item(a, with_price=True) for a in order.addons],
|
||||||
],
|
"add_ons_total": order.addons_total,
|
||||||
"add_ons": [a.name for a in order.addons],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +79,22 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
|
|||||||
"delivery_fee": s.next_order.delivery_fee,
|
"delivery_fee": s.next_order.delivery_fee,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
FreshHarvestSensorDescription(
|
||||||
|
key="next_delivery_addons_total",
|
||||||
|
translation_key="next_delivery_addons_total",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
native_unit_of_measurement="USD",
|
||||||
|
value_fn=lambda s: s.next_order.addons_total if s.next_order else None,
|
||||||
|
attrs_fn=lambda s: None
|
||||||
|
if s.next_order is None
|
||||||
|
else {
|
||||||
|
"box_price": s.next_order.box_price,
|
||||||
|
"add_ons": [
|
||||||
|
_format_item(a, with_price=True) for a in s.next_order.addons
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
FreshHarvestSensorDescription(
|
FreshHarvestSensorDescription(
|
||||||
key="next_delivery_items",
|
key="next_delivery_items",
|
||||||
translation_key="next_delivery_items",
|
translation_key="next_delivery_items",
|
||||||
|
|||||||
@@ -19,11 +19,24 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"next_delivery": { "name": "Next delivery" },
|
"next_delivery": {
|
||||||
"next_delivery_total": { "name": "Next delivery total" },
|
"name": "Next delivery"
|
||||||
"next_delivery_items": { "name": "Next delivery items" },
|
},
|
||||||
"open_order_delivery": { "name": "Open order delivery" },
|
"next_delivery_total": {
|
||||||
"shop_window": { "name": "Shopping window" }
|
"name": "Next delivery total"
|
||||||
|
},
|
||||||
|
"next_delivery_addons_total": {
|
||||||
|
"name": "Next delivery add-ons"
|
||||||
|
},
|
||||||
|
"next_delivery_items": {
|
||||||
|
"name": "Next delivery items"
|
||||||
|
},
|
||||||
|
"open_order_delivery": {
|
||||||
|
"name": "Open order delivery"
|
||||||
|
},
|
||||||
|
"shop_window": {
|
||||||
|
"name": "Shopping window"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,24 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"next_delivery": { "name": "Next delivery" },
|
"next_delivery": {
|
||||||
"next_delivery_total": { "name": "Next delivery total" },
|
"name": "Next delivery"
|
||||||
"next_delivery_items": { "name": "Next delivery items" },
|
},
|
||||||
"open_order_delivery": { "name": "Open order delivery" },
|
"next_delivery_total": {
|
||||||
"shop_window": { "name": "Shopping window" }
|
"name": "Next delivery total"
|
||||||
|
},
|
||||||
|
"next_delivery_addons_total": {
|
||||||
|
"name": "Next delivery add-ons"
|
||||||
|
},
|
||||||
|
"next_delivery_items": {
|
||||||
|
"name": "Next delivery items"
|
||||||
|
},
|
||||||
|
"open_order_delivery": {
|
||||||
|
"name": "Open order delivery"
|
||||||
|
},
|
||||||
|
"shop_window": {
|
||||||
|
"name": "Shopping window"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# next delivery, what is in the box, and the order that can still be changed.
|
# next delivery, what is in the box, and the order that can still be changed.
|
||||||
#
|
#
|
||||||
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
|
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
|
||||||
# editor", and paste this under `views:`. It only needs the five sensors the
|
# editor", and paste this under `views:`. It only needs the six sensors the
|
||||||
# integration creates.
|
# integration creates.
|
||||||
|
|
||||||
type: sections
|
type: sections
|
||||||
@@ -40,6 +40,11 @@ sections:
|
|||||||
name: Order total
|
name: Order total
|
||||||
icon: mdi:cash-multiple
|
icon: mdi:cash-multiple
|
||||||
color: teal
|
color: teal
|
||||||
|
- type: tile
|
||||||
|
entity: sensor.fresh_harvest_next_delivery_add_ons
|
||||||
|
name: Add-ons
|
||||||
|
icon: mdi:cart-plus
|
||||||
|
color: purple
|
||||||
- type: tile
|
- type: tile
|
||||||
entity: sensor.fresh_harvest_next_delivery_items
|
entity: sensor.fresh_harvest_next_delivery_items
|
||||||
name: Items
|
name: Items
|
||||||
@@ -55,8 +60,10 @@ sections:
|
|||||||
content: |-
|
content: |-
|
||||||
{%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%}
|
{%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%}
|
||||||
{%- set addons = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons') or [] -%}
|
{%- set addons = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons') or [] -%}
|
||||||
|
{%- set addons_total = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons_total') -%}
|
||||||
|
{%- set box_price = state_attr('sensor.fresh_harvest_next_delivery_items', 'box_price') -%}
|
||||||
{%- if produce -%}
|
{%- if produce -%}
|
||||||
**In the box**
|
**In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %}
|
||||||
{% for i in produce %}
|
{% for i in produce %}
|
||||||
- {{ i }}
|
- {{ i }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -64,7 +71,7 @@ sections:
|
|||||||
_Box contents not assigned yet._
|
_Box contents not assigned yet._
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{% if addons %}
|
{% if addons %}
|
||||||
**Add-ons**
|
**Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %}
|
||||||
{% for a in addons %}
|
{% for a in addons %}
|
||||||
- {{ a }}
|
- {{ a }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|||||||
Vendored
+36
-3
@@ -70,13 +70,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Multi-quantity add-on: item-total is the EXTENDED price, 4 x $4.49. -->
|
||||||
|
<div id='DODRow-4' class='cart-item qv-button shop-item-cursor' data-qvid='9012'>
|
||||||
|
<img class='cart-item-img' src='https://cdn.freshharvest.com/y.jpg' alt='Complete Recovery Smoothie' />
|
||||||
|
<div class='item-details'>
|
||||||
|
<span class='item-name'><div>Complete Recovery Smoothie</div></span>
|
||||||
|
<span class='item-total'>$17.96</span>
|
||||||
|
<span class='item-uom'><div>15.2 fl oz</div><div class='snap-tag-cart'></div></span>
|
||||||
|
<div class='item-order-wrapper'>
|
||||||
|
<div class='item-order item-order-disabled'>
|
||||||
|
<div class='btn-round item-order-remove qv-ignore'></div>
|
||||||
|
<span class='item-order-quantity qv-ignore' data-item-quantity='9012'>4</span>
|
||||||
|
<div class='btn-round item-order-add qv-ignore'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='DODRow-5' class='cart-item qv-button shop-item-cursor' data-qvid='9105'>
|
||||||
|
<img class='cart-item-img' src='https://cdn.freshharvest.com/z.jpg' alt='Organic Fruit Punch Juice Boxes' />
|
||||||
|
<div class='item-details'>
|
||||||
|
<span class='item-name'><div>Organic Fruit Punch Juice Boxes</div></span>
|
||||||
|
<span class='item-total'>$13.98</span>
|
||||||
|
<span class='item-uom'><div>8 count</div><div class='snap-tag-cart'></div></span>
|
||||||
|
<div class='item-order-wrapper'>
|
||||||
|
<div class='item-order item-order-disabled'>
|
||||||
|
<div class='btn-round item-order-remove qv-ignore'></div>
|
||||||
|
<span class='item-order-quantity qv-ignore' data-item-quantity='9105'>2</span>
|
||||||
|
<div class='btn-round item-order-add qv-ignore'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id='OrderTotals-2772590'>
|
<div id='OrderTotals-2772590'>
|
||||||
<div class='cart-summary'>
|
<div class='cart-summary'>
|
||||||
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$105.88</span>
|
<!-- Self-consistent: box 33.00 + add-ons 39.93 = 72.93 subtotal, which is
|
||||||
<span class='summary-item label'>Tax</span><span class='summary-item value'>$3.18</span>
|
over the $70 free-delivery threshold, hence the $0.00 fee. -->
|
||||||
|
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$72.93</span>
|
||||||
|
<span class='summary-item label'>Tax</span><span class='summary-item value'>$2.19</span>
|
||||||
<span class='summary-item label'>Delivery</span><span class='summary-item value'>$0.00</span>
|
<span class='summary-item label'>Delivery</span><span class='summary-item value'>$0.00</span>
|
||||||
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$109.06</span>
|
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$75.12</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+20
-6
@@ -70,16 +70,30 @@ def test_next_order_is_the_locked_one(snapshot):
|
|||||||
def test_next_order_totals_and_contents(snapshot):
|
def test_next_order_totals_and_contents(snapshot):
|
||||||
order = snapshot.next_order
|
order = snapshot.next_order
|
||||||
assert order.box_name == "Georgia Grown Small Box"
|
assert order.box_name == "Georgia Grown Small Box"
|
||||||
assert (order.subtotal, order.tax, order.delivery_fee) == (105.88, 3.18, 0.0)
|
assert (order.subtotal, order.tax, order.delivery_fee) == (72.93, 2.19, 0.0)
|
||||||
assert order.total == 109.06
|
assert order.total == 75.12
|
||||||
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
|
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
|
||||||
(1, "Bolero Carrots", ".5 lb"),
|
(1, "Bolero Carrots", ".5 lb"),
|
||||||
(2, "Georgia Peaches", "6 count"),
|
(2, "Georgia Peaches", "6 count"),
|
||||||
]
|
]
|
||||||
assert [
|
assert [(a.name, a.price, a.quantity, a.unit) for a in order.addons] == [
|
||||||
(a.name, a.price, a.quantity, a.unit) for a in order.addons
|
("Black Mission Figs", 7.99, 1, "1 pint"),
|
||||||
] == [("Black Mission Figs", 7.99, 1, "1 pint")]
|
("Complete Recovery Smoothie", 17.96, 4, "15.2 fl oz"),
|
||||||
assert len(order.all_items) == 3
|
("Organic Fruit Punch Juice Boxes", 13.98, 2, "8 count"),
|
||||||
|
]
|
||||||
|
assert len(order.all_items) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_addons_total_reconciles_with_the_subtotal(snapshot):
|
||||||
|
"""Add-ons plus the box price must equal the subtotal the portal reports."""
|
||||||
|
order = snapshot.next_order
|
||||||
|
assert order.addons_total == 39.93
|
||||||
|
assert round(order.addons_total + order.box_price, 2) == order.subtotal
|
||||||
|
|
||||||
|
|
||||||
|
def test_addons_total_is_zero_when_nothing_is_added(snapshot):
|
||||||
|
assert snapshot.open_order.addons == []
|
||||||
|
assert snapshot.open_order.addons_total == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_open_order(snapshot):
|
def test_open_order(snapshot):
|
||||||
|
|||||||
Reference in New Issue
Block a user