From 11173c70885ad9eac91ff2a2fde38c0b49f2b3d1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 12:32:17 +0200 Subject: [PATCH] kuchnia: walk every active order, not just the first Co-Authored-By: Claude Opus 5.5 (1M context) --- app/lib/services/kuchnia_client.dart | 7 +- app/lib/services/kuchnia_orders.dart | 26 ++-- app/lib/services/kuchnia_queue.dart | 4 +- app/pubspec.yaml | 2 +- app/test/services/kuchnia_client_test.dart | 45 +++++++ app/test/services/kuchnia_orders_test.dart | 111 ++++++++++++++- .../services/kuchnia_queue_refresh_test.dart | 127 ++++++++++++++++++ diet_guard/_kuchnia_client.py | 9 ++ diet_guard/_kuchnia_import.py | 5 +- diet_guard/_kuchnia_orders.py | 27 ++-- diet_guard/tests/test_kuchnia_client.py | 13 +- diet_guard/tests/test_kuchnia_import.py | 10 ++ diet_guard/tests/test_kuchnia_orders.py | 39 ++++++ docs/DOCS-kuchnia-wikinga.md | 5 +- 14 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 app/test/services/kuchnia_client_test.dart create mode 100644 app/test/services/kuchnia_queue_refresh_test.dart diff --git a/app/lib/services/kuchnia_client.dart b/app/lib/services/kuchnia_client.dart index 2f90b8a..0c7fea8 100644 --- a/app/lib/services/kuchnia_client.dart +++ b/app/lib/services/kuchnia_client.dart @@ -134,8 +134,13 @@ class KuchniaSession { throw KuchniaError('catering panel unreachable: $error'); } if (response.statusCode < 200 || response.statusCode >= 300) { + // A 401 is a wrong password, and on the phone the only place to fix it + // is Settings; any other status is the panel's problem, not the user's. + final hint = response.statusCode == 401 + ? ' — check the password in Settings' + : ''; throw KuchniaError( - 'catering login rejected (HTTP ${response.statusCode})', + 'catering login rejected (HTTP ${response.statusCode})$hint', ); } final cookie = sessionCookieFrom(response.headers['set-cookie']); diff --git a/app/lib/services/kuchnia_orders.dart b/app/lib/services/kuchnia_orders.dart index 551e893..d74f23b 100644 --- a/app/lib/services/kuchnia_orders.dart +++ b/app/lib/services/kuchnia_orders.dart @@ -3,7 +3,9 @@ /// The Dart mirror of `diet_guard/_kuchnia_orders.py`. Three calls, and the /// shape of the walk is not what the endpoint names suggest: /// -/// 1. `company/customer/order/active-ids` -> `[orderId]`. +/// 1. `company/customer/order/active-ids` -> `[orderId, ...]`. More than one +/// while a renewal overlaps the running order, **newest first** -- so the +/// first id is the future order and today's delivery sits in a later one. /// 2. `company/customer/order/{orderId}` -> the order, which **embeds every /// delivery** with its date. No enumeration call is needed, but the /// embedded `deliveryMeals` carry ids only -- no names, no macros. @@ -21,10 +23,10 @@ import 'package:diet_guard_app/services/kuchnia_client.dart'; import 'package:diet_guard_app/services/kuchnia_errors.dart'; import 'package:diet_guard_app/services/kuchnia_parse.dart'; -/// Returns the first active order id, or null when there is no active order. -Object? activeOrderId(Object? payload) { - if (payload is! List || payload.isEmpty) return null; - return payload.first; +/// Returns every active order id, empty when there is no active order. +List activeOrderIds(Object? payload) { + if (payload is! List) return const []; + return payload; } /// Returns the delivery id whose date is [wanted] (an ISO `YYYY-MM-DD`). @@ -59,14 +61,18 @@ Future> fetchDishes( KuchniaSession session, DateTime day, ) async { - final orderId = activeOrderId(await session.getJson( + final orderIds = activeOrderIds(await session.getJson( 'company/customer/order/active-ids', )); - if (orderId == null) return const []; - - final order = await session.getJson('company/customer/order/$orderId'); final wanted = isoDay(day); - final deliveryId = deliveryIdFor(order, wanted); + // Never trust the list's order: on 2026-10-10 a renewal starting 10-24 + // came first, and reading only it made every day look delivery-free. + Object? deliveryId; + for (final orderId in orderIds) { + final order = await session.getJson('company/customer/order/$orderId'); + deliveryId = deliveryIdFor(order, wanted); + if (deliveryId != null) break; + } if (deliveryId == null) return const []; final menu = await session.getJson( diff --git a/app/lib/services/kuchnia_queue.dart b/app/lib/services/kuchnia_queue.dart index 197212b..56e0db3 100644 --- a/app/lib/services/kuchnia_queue.dart +++ b/app/lib/services/kuchnia_queue.dart @@ -139,7 +139,9 @@ class KuchniaQueueService { if (service == null) return const KuchniaRefresh(); if (service.alreadyFetched(day)) return const KuchniaRefresh(); final result = await refreshDelivery(day); - if (result.ok) await service.recordFetched(day); + // Only a fetch that found dishes counts: an outage, or a walk that missed + // the delivery, must not suppress the day's retries. + if (result.ok && result.dishes.isNotEmpty) await service.recordFetched(day); return result; } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5350e20..c5ce774 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.101+1 +version: 1.0.102+1 environment: sdk: ^3.12.2 diff --git a/app/test/services/kuchnia_client_test.dart b/app/test/services/kuchnia_client_test.dart new file mode 100644 index 0000000..3a4bac9 --- /dev/null +++ b/app/test/services/kuchnia_client_test.dart @@ -0,0 +1,45 @@ +/// The catering login's error messages. +/// +/// A 401 is a wrong password, and on the phone the only place to fix that is +/// Settings -- so only a 401 points there. Any other status is the panel's +/// problem; sending the user to re-type a correct password would mislead. +library; + +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/services/kuchnia_errors.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const _settingsHint = 'check the password in Settings'; + +/// Logs in against a panel that answers the login with [status]. +Future _loginError(int status) async { + final session = KuchniaSession( + username: 'me@example.com', + password: 'pw', + client: MockClient((_) async => http.Response('', status)), + ); + try { + await session.login(); + } on KuchniaError catch (error) { + return error; + } + fail('a $status login must throw KuchniaError'); +} + +void main() { + group('a rejected login', () { + test('401 points the user at the password in Settings', () async { + final error = await _loginError(401); + expect(error.message, contains('HTTP 401')); + expect(error.message, contains(_settingsHint)); + }); + + test('500 is the panel\'s fault, not the password\'s', () async { + final error = await _loginError(500); + expect(error.message, contains('HTTP 500')); + expect(error.message, isNot(contains(_settingsHint))); + }); + }); +} diff --git a/app/test/services/kuchnia_orders_test.dart b/app/test/services/kuchnia_orders_test.dart index 6eb0d8e..c898b35 100644 --- a/app/test/services/kuchnia_orders_test.dart +++ b/app/test/services/kuchnia_orders_test.dart @@ -6,9 +6,58 @@ /// the live panel rather than guessed from its JavaScript. library; +import 'dart:convert'; + import 'package:diet_guard_app/services/kuchnia_client.dart'; import 'package:diet_guard_app/services/kuchnia_orders.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// A panel serving [orders] (id -> deliveries) as active ids, newest first, +/// and a one-dish menu for any delivery. Every GET path lands in [requested]. +KuchniaSession _session( + Map>> orders, + List requested, +) { + final client = MockClient((request) async { + final path = request.url.path; + requested.add(path); + if (path.endsWith('/order/active-ids')) { + return http.Response(jsonEncode(orders.keys.toList()), 200); + } + for (final id in orders.keys) { + if (path.endsWith('/order/$id')) { + return http.Response(jsonEncode({'deliveries': orders[id]}), 200); + } + } + if (path.contains('/menus/delivery/')) { + return http.Response( + jsonEncode({ + 'deliveryMenuMeal': [ + { + 'mealName': 'Obiad', + 'menuMealName': 'Kaszotto', + 'mealPriority': 1, + 'nutrition': { + 'weight': 300.0, + 'calories': 400.0, + 'protein': 25.0, + 'carbohydrate': 45.0, + 'fat': 12.0, + }, + }, + ], + }), + 200, + ); + } + return http.Response('', 404); + }); + // A seeded cookie skips the login: these tests are about the order walk. + return KuchniaSession(username: 'me', password: 'pw', client: client) + ..sessionCookie = 'abc123'; +} void main() { group('the order walk', () { @@ -29,10 +78,14 @@ void main() { expect(deliveryIdFor({}, '2026-08-23'), isNull); }); - test('a malformed active-ids payload yields no order', () { - expect(activeOrderId('nope'), isNull); - expect(activeOrderId([]), isNull); - expect(activeOrderId(['ORD1']), 'ORD1'); + test('a malformed active-ids payload yields no orders', () { + expect(activeOrderIds('nope'), isEmpty); + expect(activeOrderIds(null), isEmpty); + expect(activeOrderIds([]), isEmpty); + }); + + test('every active order id is returned, in the panel\'s order', () { + expect(activeOrderIds(['FUTURE', 'CURRENT']), ['FUTURE', 'CURRENT']); }); test('formats the day the way the panel keys deliveries', () { @@ -41,6 +94,56 @@ void main() { }); }); + group('fetchDishes walks every active order', () { + final day = DateTime(2026, 10, 10); + + test( + 'finds today in the second order when the first is a renewal', + () async { + // The panel lists ids newest first: on 2026-10-10 a renewal starting + // 10-24 came first and today's delivery sat in the running order. + final requested = []; + final session = _session({ + 'FUTURE': [ + {'date': '2026-10-24', 'deliveryId': 'DEL_FUTURE'}, + ], + 'CURRENT': [ + {'date': '2026-10-10', 'deliveryId': 'DEL_TODAY'}, + ], + }, requested); + + final dishes = await fetchDishes(session, day); + + expect(dishes.single.name, 'Kaszotto'); + expect( + requested, + contains(endsWith('/menus/delivery/DEL_TODAY/new')), + reason: 'the menu must be keyed by the current order\'s delivery', + ); + expect(requested, isNot(contains(contains('DEL_FUTURE')))); + }, + ); + + test('is empty when no active order has the day', () async { + final requested = []; + final session = _session({ + 'FUTURE': [ + {'date': '2026-10-24', 'deliveryId': 'DEL_FUTURE'}, + ], + 'CURRENT': [ + {'date': '2026-10-09', 'deliveryId': 'DEL_YESTERDAY'}, + {'date': '2026-10-10', 'deliveryId': 'GONE', 'deleted': true}, + ], + }, requested); + + expect(await fetchDishes(session, day), isEmpty); + // Both orders were read, and no menu was asked for. + expect(requested, contains(endsWith('/order/FUTURE'))); + expect(requested, contains(endsWith('/order/CURRENT'))); + expect(requested, isNot(contains(contains('/menus/')))); + }); + }); + group('the session cookie', () { test('is read out of a comma-joined set-cookie header', () { // `Response.headers` folds duplicate set-cookie values into one string. diff --git a/app/test/services/kuchnia_queue_refresh_test.dart b/app/test/services/kuchnia_queue_refresh_test.dart new file mode 100644 index 0000000..8f1fadb --- /dev/null +++ b/app/test/services/kuchnia_queue_refresh_test.dart @@ -0,0 +1,127 @@ +/// What `refreshOnce` records as "already fetched today". +/// +/// Split out of `kuchnia_queue_test.dart` for the repo's 250-line cap. Only a +/// fetch that found dishes may record the day. An empty "no delivery" answer +/// is exactly what a walk that missed the delivery looks like (the 2026-10-10 +/// renewal-first `active-ids` bug), and recording it would suppress every +/// retry until midnight. The assertions count **fetches**, not results. +/// +/// `refreshOnce` has no session seam, so the panel is injected through +/// `http.runWithClient`: `KuchniaSession` falls back to `http.Client()`, which +/// resolves to the zone's client. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +/// A panel whose one order has [deliveries]; [fetches] counts the walks. +MockClient _panel(List> deliveries, List fetches) => + MockClient((request) async { + final path = request.url.path; + if (path.endsWith('/auth/login')) { + return http.Response( + '', + 200, + headers: {'set-cookie': 'SESSION=abc123; Path=/'}, + ); + } + if (path.endsWith('/order/active-ids')) { + fetches[0]++; + return http.Response(jsonEncode(['ORD1']), 200); + } + if (path.endsWith('/order/ORD1')) { + return http.Response(jsonEncode({'deliveries': deliveries}), 200); + } + if (path.contains('/menus/delivery/DEL1')) { + return http.Response( + jsonEncode({ + 'deliveryMenuMeal': [ + { + 'mealName': 'Obiad', + 'menuMealName': 'Kaszotto', + 'mealPriority': 1, + 'nutrition': { + 'weight': 300.0, + 'calories': 400.0, + 'protein': 25.0, + 'carbohydrate': 45.0, + 'fat': 12.0, + }, + }, + ], + }), + 200, + ); + } + return http.Response('', 404); + }); + +void main() { + late _MemoryStore store; + final today = DateTime(2026, 8, 23); + + Future refreshAgainst(http.Client panel) => http + .runWithClient(() => KuchniaQueueService.refreshOnce(today), () => panel); + + setUp(() async { + store = _MemoryStore(); + FoodBankService.resetForTesting(store: store); + KuchniaCredentialService.resetForTesting(store: store); + await KuchniaCredentialService.instance.save('me@example.com', 'pw'); + await KuchniaQueueService.initForTesting(store); + }); + + tearDown(() { + KuchniaQueueService.resetForTesting(); + FoodBankService.resetForTesting(); + KuchniaCredentialService.resetForTesting(); + }); + + test('an empty successful fetch does not record the day', () async { + final fetches = [0]; + final panel = _panel(const [], fetches); + + final first = await refreshAgainst(panel); + expect(first.ok, isTrue); + expect(first.dishes, isEmpty); + expect(KuchniaQueueService.instance.alreadyFetched(today), isFalse); + + await refreshAgainst(panel); + expect(fetches[0], 2, reason: 'an empty answer must not suppress a retry'); + }); + + test('a fetch that found dishes records the day', () async { + final fetches = [0]; + final panel = _panel(const [ + {'date': '2026-08-23', 'deliveryId': 'DEL1'}, + ], fetches); + + final first = await refreshAgainst(panel); + expect(first.dishes.single.name, 'Kaszotto'); + expect(KuchniaQueueService.instance.alreadyFetched(today), isTrue); + + await refreshAgainst(panel); + expect(fetches[0], 1, reason: 'the guard must still stop a second walk'); + }); +} diff --git a/diet_guard/_kuchnia_client.py b/diet_guard/_kuchnia_client.py index 0d04e7c..4438baa 100644 --- a/diet_guard/_kuchnia_client.py +++ b/diet_guard/_kuchnia_client.py @@ -15,11 +15,13 @@ still wins over the lazy hook. from __future__ import annotations +from http import HTTPStatus from importlib import import_module import sys import time from typing import TYPE_CHECKING +from diet_guard import _kuchnia_config from diet_guard._constants import ( KUCHNIA_API_BASE, KUCHNIA_COMPANY, @@ -119,6 +121,13 @@ class PanelSession: ) if not response.ok: msg = f"catering login rejected (HTTP {response.status_code})" + if response.status_code == HTTPStatus.UNAUTHORIZED: + # The PC reads the hand-written file first, so that is the + # password to fix -- not the synced copy. + msg += ( + " -- check the password in " + f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}" + ) raise KuchniaError(msg) cookie = self._session.cookies.get(SESSION_COOKIE) if cookie is None: diff --git a/diet_guard/_kuchnia_import.py b/diet_guard/_kuchnia_import.py index 745e735..9ee5490 100644 --- a/diet_guard/_kuchnia_import.py +++ b/diet_guard/_kuchnia_import.py @@ -130,7 +130,8 @@ def refresh_delivery_once(day: date) -> tuple[list[Dish], str | None]: if last_import_day() == stamp: return [], None dishes, reason = refresh_delivery(day) - if reason is None: - # Only a clean fetch counts: an outage must not suppress the retry. + if reason is None and dishes: + # Only a fetch that found dishes counts: an outage, or a walk that + # missed the delivery, must not suppress the day's retries. record_import_day(stamp) return dishes, reason diff --git a/diet_guard/_kuchnia_orders.py b/diet_guard/_kuchnia_orders.py index fc19436..23feaf9 100644 --- a/diet_guard/_kuchnia_orders.py +++ b/diet_guard/_kuchnia_orders.py @@ -2,7 +2,9 @@ Three calls, and the shape of the walk is not what the endpoint names suggest: -1. ``company/customer/order/active-ids`` -> ``[orderId]``. +1. ``company/customer/order/active-ids`` -> ``[orderId, ...]``. More than one + while a renewal overlaps the running order, **newest first** -- so the first + id is the future order and today's delivery sits in a later one. 2. ``company/customer/order/{orderId}`` -> the order, which **embeds every delivery** with its date. No enumeration call is needed, but the embedded ``deliveryMeals`` carry ids only -- no dish names, no macros. @@ -29,12 +31,12 @@ if TYPE_CHECKING: # pragma: no cover - typing only from diet_guard._kuchnia_parse import Dish -def _active_order_id(session: PanelSession) -> object | None: - """Return the first active order id, or None when there is no active order.""" +def _active_order_ids(session: PanelSession) -> list[object]: + """Return every active order id, empty when there is no active order.""" payload = session.get_json("company/customer/order/active-ids") - if not isinstance(payload, list) or not payload: - return None - return payload[0] + if not isinstance(payload, list): + return [] + return payload def _delivery_id_for(payload: object, wanted: str) -> object | None: @@ -76,11 +78,14 @@ def fetch_dishes(session: PanelSession, day: date) -> list[Dish]: Raises: KuchniaError: When the panel cannot be read. """ - order_id = _active_order_id(session) - if order_id is None: - return [] - order = session.get_json(f"company/customer/order/{order_id}") - delivery_id = _delivery_id_for(order, day.isoformat()) + # Never trust the list's order: on 2026-10-10 a renewal starting 10-24 + # came first, and reading only it made every day look delivery-free. + delivery_id = None + for order_id in _active_order_ids(session): + order = session.get_json(f"company/customer/order/{order_id}") + delivery_id = _delivery_id_for(order, day.isoformat()) + if delivery_id is not None: + break if delivery_id is None: return [] menu = session.get_json(f"company/general/menus/delivery/{delivery_id}/new") diff --git a/diet_guard/tests/test_kuchnia_client.py b/diet_guard/tests/test_kuchnia_client.py index 91156c1..679d83f 100644 --- a/diet_guard/tests/test_kuchnia_client.py +++ b/diet_guard/tests/test_kuchnia_client.py @@ -106,14 +106,23 @@ class TestAuthenticate: assert session.calls == [] assert session.cookies["SESSION"] == "cached" - def test_a_rejected_login_raises(self, creds: None) -> None: + def test_a_rejected_login_names_the_password_to_fix(self, creds: None) -> None: session = _session_with([FakeResponse(401, {})]) with ( patch.object(_kuchnia_client, "requests", fake_requests(session)), - pytest.raises(KuchniaError, match="login rejected"), + pytest.raises(KuchniaError, match=r"login rejected.*check the password"), ): PanelSession().authenticate() + def test_a_server_error_at_login_blames_no_password(self, creds: None) -> None: + session = _session_with([FakeResponse(500, {})]) + with ( + patch.object(_kuchnia_client, "requests", fake_requests(session)), + pytest.raises(KuchniaError, match="login rejected") as raised, + ): + PanelSession().authenticate() + assert "password" not in str(raised.value) + def test_a_login_that_sets_no_cookie_raises(self, creds: None) -> None: session = _session_with([FakeResponse(200, {})]) session.login_cookie = None diff --git a/diet_guard/tests/test_kuchnia_import.py b/diet_guard/tests/test_kuchnia_import.py index a28b257..a926d03 100644 --- a/diet_guard/tests/test_kuchnia_import.py +++ b/diet_guard/tests/test_kuchnia_import.py @@ -163,6 +163,16 @@ class TestRefreshOnce: _kuchnia_import.refresh_delivery_once(DAY) assert refresh.call_count == 2, "a failed day must be retried" + def test_an_empty_fetch_does_not_suppress_the_retry(self) -> None: + # "No delivery" is what a walk that missed the order looks like too; + # stamping it hid a whole day's delivery on 2026-10-10. + with patch.object( + _kuchnia_import, "refresh_delivery", return_value=([], None) + ) as refresh: + _kuchnia_import.refresh_delivery_once(DAY) + _kuchnia_import.refresh_delivery_once(DAY) + assert refresh.call_count == 2 + def test_an_unreadable_marker_just_means_fetch_again(self) -> None: marker = _kuchnia_config.KUCHNIA_LAST_IMPORT_FILE marker.parent.mkdir(parents=True, exist_ok=True) diff --git a/diet_guard/tests/test_kuchnia_orders.py b/diet_guard/tests/test_kuchnia_orders.py index 782515f..547d5a2 100644 --- a/diet_guard/tests/test_kuchnia_orders.py +++ b/diet_guard/tests/test_kuchnia_orders.py @@ -117,3 +117,42 @@ class TestFetchDishes: panel, session = self._panel([FakeResponse(200, {"not": "a list"})]) with patch.object(_kuchnia_client, "requests", fake_requests(session)): assert fetch_dishes(panel, DAY) == [] + + def test_every_active_order_is_walked_for_the_day(self, creds: None) -> None: + # The panel lists ids newest first: while a renewal overlaps the running + # order, the first id is the *future* order and today sits in the next. + panel, session = self._panel( + [ + FakeResponse(200, [2, 1]), + FakeResponse(200, order_payload(date="2026-10-24", delivery_id=999)), + FakeResponse(200, order_payload(delivery_id=111)), + FakeResponse(200, menu_payload(2)), + ], + ) + with patch.object(_kuchnia_client, "requests", fake_requests(session)): + dishes = fetch_dishes(panel, DAY) + assert len(dishes) == 2 + assert any("menus/delivery/111/new" in url for _, url in session.calls) + + def test_the_walk_stops_at_the_first_order_with_the_day(self, creds: None) -> None: + panel, session = self._panel( + [ + FakeResponse(200, [1, 2]), + FakeResponse(200, order_payload(delivery_id=111)), + FakeResponse(200, menu_payload(1)), + ], + ) + with patch.object(_kuchnia_client, "requests", fake_requests(session)): + fetch_dishes(panel, DAY) + assert not any(url.endswith("order/2") for _, url in session.calls) + + def test_no_order_with_the_day_is_not_an_error(self, creds: None) -> None: + panel, session = self._panel( + [ + FakeResponse(200, [2, 1]), + FakeResponse(200, order_payload(date="2026-10-24")), + FakeResponse(200, order_payload(date="2026-01-01")), + ], + ) + with patch.object(_kuchnia_client, "requests", fake_requests(session)): + assert fetch_dishes(panel, DAY) == [] diff --git a/docs/DOCS-kuchnia-wikinga.md b/docs/DOCS-kuchnia-wikinga.md index 4e764ba..b3d7665 100644 --- a/docs/DOCS-kuchnia-wikinga.md +++ b/docs/DOCS-kuchnia-wikinga.md @@ -52,7 +52,10 @@ responses on 2026-08-22. Re-check if the bundle hashes change. The walk is three calls: -1. `company/customer/order/active-ids` → `[orderId]` +1. `company/customer/order/active-ids` → `[orderId, …]`, **newest first**. A + renewal overlapping the running order makes it two ids, and the first is + the *future* order — so every id is walked until one has the day's + delivery (2026-10-10: reading only `[0]` made every day look empty). 2. `company/customer/order/{orderId}` → the order, which **embeds every delivery** with its date. No enumeration call needed — but `deliveryMeals` carries ids only, no names and no macros.