Initial commit.

This commit is contained in:
Edward Betts 2026-09-19 11:32:53 +01:00
commit 628e5c3823
26 changed files with 3478 additions and 0 deletions

161
ocado_grocy/receipt.py Normal file
View file

@ -0,0 +1,161 @@
"""Parse receipt data only: never treat the live basket as an order."""
from dataclasses import asdict, dataclass, field
from datetime import date, datetime
from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation
import json
class ImportError(ValueError):
"""An input or integration error suitable for displaying to the user."""
def number(value, label, *, money=False):
if isinstance(value, dict):
currency = value.get("currency", "GBP")
if currency not in ("GBP", "GBX"):
raise ImportError(f"Unsupported currency: {currency}")
result = number(value.get("amount"), label, money=money)
return result / 100 if currency == "GBX" else result
text = str(value).strip().replace("£", "").replace(",", "")
try:
result = Decimal(text)
except InvalidOperation as exc:
raise ImportError(f"Invalid {label}: {value!r}") from exc
if not result.is_finite() or result < 0:
raise ImportError(f"Invalid {label}: {value!r}")
return result
def receipt_date(value):
if not value:
return None
text = str(value).strip()
try:
return date.fromisoformat(text[:10]).isoformat()
except ValueError:
pass
for fmt in ("%d/%m/%Y", "%d %B %Y", "%d %b %Y", "%A %d %B %Y"):
try:
return datetime.strptime(text, fmt).date().isoformat()
except ValueError:
pass
raise ImportError(f"Unrecognised date: {value!r}; use YYYY-MM-DD")
@dataclass
class Item:
name: str
quantity: Decimal
total: Decimal
product_id: str = ""
barcode: str = ""
url: str = ""
best_before: str | None = None
metadata: dict = field(default_factory=dict)
@property
def unit_price(self):
return self.total / self.quantity
@dataclass
class Receipt:
order_id: str
purchased_date: str | None
items: list[Item]
metadata: dict = field(default_factory=dict)
def export(self):
return asdict(self)
def parse_document(data):
"""Read a normalized cached receipt."""
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise ImportError("Receipt JSON requires an items array")
if data.get("currency", "GBP") != "GBP":
raise ImportError("Only GBP receipts are supported")
order_id = str(data.get("order_id") or "").strip()
if not order_id:
raise ImportError("Receipt has no order ID")
items = []
for row in data["items"]:
if not isinstance(row, dict):
raise ImportError("Receipt items must be objects")
status = str(row.get("status", "")).lower()
if status in {"unavailable", "cancelled", "rejected", "not delivered"}:
continue
quantity = number(row.get("delivered_quantity", row.get("quantity")), "quantity")
if quantity == 0:
continue
name = str(row.get("name") or "").strip()
if not name:
raise ImportError("Receipt item has no name")
total = number(row.get("total"), f"line total for {name}", money=True)
metadata = dict(row.get("metadata") or {})
metadata.update({k: v for k, v in row.items() if k not in {
"name", "quantity", "delivered_quantity", "total", "product_id",
"barcode", "url", "best_before", "metadata"}})
items.append(Item(name, quantity, total, str(row.get("product_id") or ""),
str(row.get("barcode") or ""), str(row.get("url") or ""),
receipt_date(row.get("best_before")), metadata))
if not items:
raise ImportError("Receipt contains no delivered items")
return Receipt(order_id, receipt_date(data.get("purchased_date")), items, data.get("metadata", {}))
def product_metadata(product):
return {key: product[key] for key in ("brand", "categoryPath", "size", "packSize",
"description", "ingredients", "nutritionalInformation", "allergens", "storage",
"guaranteedProductLife", "retailerProductId") if key in product}
def parse_ocado_order(data, *, expected_order_id=None, product_urls=None):
"""Parse the order JSON loaded by Playwright, never the page's live basket."""
try:
order = data["entities"]["order"][data["result"]]
order_id = str(order["orderId"])
if expected_order_id and order_id != str(expected_order_id):
raise ImportError("Ocado API order does not match the requested order")
if order["status"] != "DELIVERED":
raise ImportError("Ocado order is not delivered")
dates = order["dates"]
delivered_at = datetime.fromisoformat(dates["deliveryStartDate"].replace("Z", "+00:00"))
if delivered_at.tzinfo is not None:
delivered_at = delivered_at.astimezone(ZoneInfo(dates.get("timeZoneId", "Europe/London")))
purchased_date = delivered_at.date().isoformat()
groups = order["groupedProducts"]
products = list(groups["products"])
for original in groups.get("substitutes", []):
for replacement in original.get("substitutes", []):
if replacement.get("status") == "ACCEPTED":
products.append({"storageType": original.get("storageType"),
"substituted_for": original["name"], **replacement})
rows = []
catalogue = data["entities"].get("product", {})
for product in products:
info = catalogue.get(product["productId"], {})
retailer_id = str(product["retailerProductId"])
metadata = {"ocado": product_metadata(info), "storage_type": product.get("storageType"),
"pack_info": product.get("packInfo", {}), "promotions": product.get("promotions", []),
"original_line_price": product["prices"].get("retail"),
"price_per_item": product["prices"].get("pricePerItem"),
"image_url": (product.get("image") or {}).get("src", "")}
if product.get("substituted_for"):
metadata["substituted_for"] = product["substituted_for"]
rows.append({"name": " ".join(product["name"].split()), "quantity": product["quantity"],
"total": product["prices"]["offered"], "product_id": retailer_id,
"best_before": product.get("expirationDate"),
"url": (product_urls or {}).get(retailer_id, ""), "metadata": metadata})
# Stable ordering is independent of the browser's equal-expiry sorting.
rows.sort(key=lambda row:(row["best_before"] or "9999-12-31", row["product_id"], row["name"]))
receipt = parse_document({"order_id":order_id,"purchased_date":purchased_date,"items":rows})
if sum(item.quantity for item in receipt.items) != number(order["totalItems"], "receipt item count"):
raise ImportError("Delivered quantities do not match the order summary")
if sum(item.total for item in receipt.items) != number(order["orderTotals"]["itemPriceAfterPromos"], "receipt product total"):
raise ImportError("Delivered line prices do not match the order summary")
receipt.metadata = {"source":"ocado-order-api", "order_totals":order["orderTotals"]}
return receipt
except (KeyError, TypeError, AttributeError) as exc:
raise ImportError("Unrecognised Ocado order API structure") from exc