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:
flan
2026-08-03 19:15:19 +00:00
parent a97f0aec6a
commit 527f662189
10 changed files with 174 additions and 32 deletions
+18
View File
@@ -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
+7 -1
View File
@@ -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.
+9
View File
@@ -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:
+7 -3
View File
@@ -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"
} }
+31 -6
View File
@@ -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",
+18 -5
View File
@@ -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"
}
} }
} }
} }
+10 -3
View File
@@ -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 %}
+36 -3
View File
@@ -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
View File
@@ -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):