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/),
|
||||
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
|
||||
|
||||
### 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_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_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.
|
||||
Once an order passes its cutoff it locks for packing, and the cart you can still
|
||||
edit is the following week's.
|
||||
|
||||
@@ -110,6 +110,15 @@ class DeliveryOrder:
|
||||
def all_items(self) -> list[OrderItem]:
|
||||
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
|
||||
class AccountSnapshot:
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"domain": "freshharvest",
|
||||
"name": "Fresh Harvest",
|
||||
"codeowners": ["@flan"],
|
||||
"codeowners": [
|
||||
"@flan"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
|
||||
"requirements": ["beautifulsoup4>=4.12"],
|
||||
"version": "0.1.0"
|
||||
"requirements": [
|
||||
"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 . import FreshHarvestConfigEntry
|
||||
from .api import AccountSnapshot, DeliveryOrder
|
||||
from .api import AccountSnapshot, DeliveryOrder, OrderItem
|
||||
from .const import DOMAIN
|
||||
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:
|
||||
if order is None:
|
||||
return None
|
||||
return {
|
||||
"box": order.box_name,
|
||||
"produce": [
|
||||
" ".join(part for part in (str(i.quantity or ""), i.name, i.unit) if part)
|
||||
for i in order.items
|
||||
],
|
||||
"add_ons": [a.name for a in order.addons],
|
||||
"box_price": order.box_price,
|
||||
"produce": [_format_item(i) for i in order.items],
|
||||
"add_ons": [_format_item(a, with_price=True) for a in order.addons],
|
||||
"add_ons_total": order.addons_total,
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +79,22 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
|
||||
"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(
|
||||
key="next_delivery_items",
|
||||
translation_key="next_delivery_items",
|
||||
|
||||
@@ -19,11 +19,24 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"next_delivery": { "name": "Next delivery" },
|
||||
"next_delivery_total": { "name": "Next delivery total" },
|
||||
"next_delivery_items": { "name": "Next delivery items" },
|
||||
"open_order_delivery": { "name": "Open order delivery" },
|
||||
"shop_window": { "name": "Shopping window" }
|
||||
"next_delivery": {
|
||||
"name": "Next delivery"
|
||||
},
|
||||
"next_delivery_total": {
|
||||
"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": {
|
||||
"sensor": {
|
||||
"next_delivery": { "name": "Next delivery" },
|
||||
"next_delivery_total": { "name": "Next delivery total" },
|
||||
"next_delivery_items": { "name": "Next delivery items" },
|
||||
"open_order_delivery": { "name": "Open order delivery" },
|
||||
"shop_window": { "name": "Shopping window" }
|
||||
"next_delivery": {
|
||||
"name": "Next delivery"
|
||||
},
|
||||
"next_delivery_total": {
|
||||
"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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
type: sections
|
||||
@@ -40,6 +40,11 @@ sections:
|
||||
name: Order total
|
||||
icon: mdi:cash-multiple
|
||||
color: teal
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_next_delivery_add_ons
|
||||
name: Add-ons
|
||||
icon: mdi:cart-plus
|
||||
color: purple
|
||||
- type: tile
|
||||
entity: sensor.fresh_harvest_next_delivery_items
|
||||
name: Items
|
||||
@@ -55,8 +60,10 @@ sections:
|
||||
content: |-
|
||||
{%- 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_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 -%}
|
||||
**In the box**
|
||||
**In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %}
|
||||
{% for i in produce %}
|
||||
- {{ i }}
|
||||
{%- endfor %}
|
||||
@@ -64,7 +71,7 @@ sections:
|
||||
_Box contents not assigned yet._
|
||||
{%- endif %}
|
||||
{% if addons %}
|
||||
**Add-ons**
|
||||
**Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %}
|
||||
{% for a in addons %}
|
||||
- {{ a }}
|
||||
{%- endfor %}
|
||||
|
||||
Vendored
+36
-3
@@ -70,13 +70,46 @@
|
||||
</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 id='OrderTotals-2772590'>
|
||||
<div class='cart-summary'>
|
||||
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$105.88</span>
|
||||
<span class='summary-item label'>Tax</span><span class='summary-item value'>$3.18</span>
|
||||
<!-- Self-consistent: box 33.00 + add-ons 39.93 = 72.93 subtotal, which is
|
||||
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 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>
|
||||
|
||||
|
||||
+20
-6
@@ -70,16 +70,30 @@ def test_next_order_is_the_locked_one(snapshot):
|
||||
def test_next_order_totals_and_contents(snapshot):
|
||||
order = snapshot.next_order
|
||||
assert order.box_name == "Georgia Grown Small Box"
|
||||
assert (order.subtotal, order.tax, order.delivery_fee) == (105.88, 3.18, 0.0)
|
||||
assert order.total == 109.06
|
||||
assert (order.subtotal, order.tax, order.delivery_fee) == (72.93, 2.19, 0.0)
|
||||
assert order.total == 75.12
|
||||
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
|
||||
(1, "Bolero Carrots", ".5 lb"),
|
||||
(2, "Georgia Peaches", "6 count"),
|
||||
]
|
||||
assert [
|
||||
(a.name, a.price, a.quantity, a.unit) for a in order.addons
|
||||
] == [("Black Mission Figs", 7.99, 1, "1 pint")]
|
||||
assert len(order.all_items) == 3
|
||||
assert [(a.name, a.price, a.quantity, a.unit) for a in order.addons] == [
|
||||
("Black Mission Figs", 7.99, 1, "1 pint"),
|
||||
("Complete Recovery Smoothie", 17.96, 4, "15.2 fl oz"),
|
||||
("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):
|
||||
|
||||
Reference in New Issue
Block a user