Pre-Merge-Gates und Runtime-Backup/Restore sind grün. Ich merge jetzt den akzeptierten v2-Branch kontrolliert nach `main`; danach ergänze ich auf `main` die v3-UAT/UI/Report-Härtung und tagge erst den final grünen Stand.

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
unset GIT_ASKPASS
# stop dashboard before checkout/merge so stale processes cannot mask code state
for port in 8000 5173; do
  pids=$(ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p {print $0}' | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | sort -u || true)
  if [ -n "$pids" ]; then kill $pids || true; fi
done
git checkout main
git merge --no-ff feat/grocery-matching-review-learning-v2 -m "Merge release v0.3.2 grocery matching learning"
printf 'branch='; git branch --show-current; printf '\nhead='; git rev-parse HEAD; printf '\nstatus=\n'; git status --short --branch


terminal

Output:
Zu Zweig »main« gewechselt
Ihr Branch ist auf demselben Stand wie 'origin/main'.
Merge made by the 'ort' strategy.
 docs/grocery/product-matching-learning-v2.md       |  25 +
 docs/grocery/product-price-provider-strategy.md    |  53 +++
 frontend/src/api/budget.ts                         |  66 ++-
 frontend/src/api/portfolio.ts                      |   4 +-
 frontend/src/api/reports.ts                        |   4 +-
 frontend/src/api/types.ts                          |   1 +
 frontend/src/components/AppLayout.vue              |   4 +
 frontend/src/components/PageHelpButton.vue         |  20 +
 frontend/src/components/SidebarNav.vue             |  14 +-
 frontend/src/help/pageHelp.ts                      |  38 ++
 frontend/src/pages/BudgetCategoriesPage.test.ts    |  73 +++
 frontend/src/pages/BudgetCategoriesPage.vue        | 143 +++---
 .../src/pages/BudgetGroceryOptimizerPage.test.ts   |  51 ++
 frontend/src/pages/BudgetGroceryOptimizerPage.vue  | 179 +++++++
 .../src/pages/BudgetImportProductionV2.test.ts     |  46 +-
 frontend/src/pages/BudgetImportReviewPage.vue      |  41 +-
 frontend/src/pages/BudgetIncomePage.test.ts        |  18 +-
 frontend/src/pages/BudgetIncomePage.vue            |  21 +-
 frontend/src/pages/BudgetMonthlyImportPage.test.ts |  38 ++
 frontend/src/pages/BudgetMonthlyImportPage.vue     |  65 +++
 frontend/src/pages/BudgetPlanningPage.test.ts      |  93 ++++
 frontend/src/pages/BudgetPlanningPage.vue          |  92 ++++
 frontend/src/pages/BudgetRecurringPage.test.ts     |  48 ++
 frontend/src/pages/BudgetRecurringPage.vue         |  75 +++
 frontend/src/pages/BudgetRulesPage.vue             |  15 +-
 frontend/src/pages/BudgetStatusPage.vue            |   2 +-
 frontend/src/pages/BudgetTransactionsPage.vue      |  11 +-
 frontend/src/pages/BudgetTransfersPage.vue         |  45 ++
 frontend/src/pages/BudgetUxV2.test.ts              |   8 +-
 frontend/src/pages/BudgetVsActualPage.vue          |   2 +-
 frontend/src/pages/BudgetWorkflowHelpUx.test.ts    |  96 ++++
 frontend/src/pages/CommandCenter.test.ts           |  56 +--
 frontend/src/pages/CommandCenter.vue               | 105 ++---
 frontend/src/pages/DataExplorerPage.vue            |   6 +-
 .../src/pages/GroceryMatchingLearningV2.test.ts    |  72 +++
 frontend/src/pages/GroceryPriceProvidersV1.test.ts |  46 ++
 frontend/src/pages/ReportsPage.test.ts             |  29 ++
 frontend/src/pages/ReportsPage.vue                 |  17 +-
 frontend/src/pages/VueCockpitV11.test.ts           |   7 +-
 frontend/src/router/index.ts                       |  11 +-
 frontend/src/utils/month.test.ts                   |  15 +
 frontend/src/utils/month.ts                        |   4 +
 src/jarvis_finance/api/routers/budget.py           | 253 +++++++++-
 src/jarvis_finance/api/routers/overview.py         |  11 +-
 src/jarvis_finance/api/routers/reports.py          |  13 +-
 src/jarvis_finance/services/budget_analytics_v2.py |  17 +
 src/jarvis_finance/services/budget_categories.py   | 205 +++++++-
 .../services/budget_data_explorer.py               |   8 +
 src/jarvis_finance/services/budget_imports.py      | 109 ++++-
 .../services/budget_monthly_import.py              | 252 ++++++++++
 src/jarvis_finance/services/budget_overview.py     |  29 ++
 src/jarvis_finance/services/budget_planning.py     | 513 +++++++++++++++++++++
 src/jarvis_finance/services/budget_plans.py        |  48 +-
 src/jarvis_finance/services/budget_recurring.py    | 379 +++++++++++++++
 src/jarvis_finance/services/budget_transactions.py |  40 +-
 .../services/finance_command_center.py             | 258 +++++++++++
 src/jarvis_finance/services/grocery_optimizer.py   | 347 ++++++++++++++
 .../services/grocery_price_providers.py            | 348 ++++++++++++++
 src/jarvis_finance/storage/migrations.py           | 273 ++++++++++-
 .../test_budget_categories_cleanup_duplicates.py   | 135 ++++++
 tests/unit/test_budget_categories_ux_fix.py        |  48 ++
 .../test_budget_monthly_import_rule_learning_v1.py | 114 +++++
 tests/unit/test_budget_phase1.py                   |   2 +-
 tests/unit/test_budget_phase11.py                  |   2 +-
 tests/unit/test_budget_phase12_seed_review.py      |   2 +-
 tests/unit/test_budget_phase16_user_rules.py       |   2 +-
 tests/unit/test_budget_planning_forecast_v1.py     | 149 ++++++
 tests/unit/test_budget_planning_usability_v2.py    | 108 +++++
 .../unit/test_budget_review_bulk_assign_confirm.py | 131 ++++++
 tests/unit/test_budget_workflow_help_ux.py         |  79 ++++
 ...test_finance_command_center_monthly_close_v1.py | 109 +++++
 tests/unit/test_fixed_costs_subscriptions_v1.py    | 146 ++++++
 tests/unit/test_grocery_matching_learning_v2.py    | 150 ++++++
 tests/unit/test_grocery_price_providers_v1.py      | 156 +++++++
 tests/unit/test_migros_grocery_optimizer_v1.py     | 142 ++++++
 tests/unit/test_schema.py                          |   2 +-
 76 files changed, 6060 insertions(+), 279 deletions(-)
 create mode 100644 docs/grocery/product-matching-learning-v2.md
 create mode 100644 docs/grocery/product-price-provider-strategy.md
 create mode 100644 frontend/src/components/PageHelpButton.vue
 create mode 100644 frontend/src/help/pageHelp.ts
 create mode 100644 frontend/src/pages/BudgetCategoriesPage.test.ts
 create mode 100644 frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
 create mode 100644 frontend/src/pages/BudgetGroceryOptimizerPage.vue
 create mode 100644 frontend/src/pages/BudgetMonthlyImportPage.test.ts
 create mode 100644 frontend/src/pages/BudgetMonthlyImportPage.vue
 create mode 100644 frontend/src/pages/BudgetPlanningPage.test.ts
 create mode 100644 frontend/src/pages/BudgetPlanningPage.vue
 create mode 100644 frontend/src/pages/BudgetRecurringPage.test.ts
 create mode 100644 frontend/src/pages/BudgetRecurringPage.vue
 create mode 100644 frontend/src/pages/BudgetTransfersPage.vue
 create mode 100644 frontend/src/pages/BudgetWorkflowHelpUx.test.ts
 create mode 100644 frontend/src/pages/GroceryMatchingLearningV2.test.ts
 create mode 100644 frontend/src/pages/GroceryPriceProvidersV1.test.ts
 create mode 100644 frontend/src/pages/ReportsPage.test.ts
 create mode 100644 frontend/src/utils/month.test.ts
 create mode 100644 frontend/src/utils/month.ts
 create mode 100644 src/jarvis_finance/services/budget_monthly_import.py
 create mode 100644 src/jarvis_finance/services/budget_planning.py
 create mode 100644 src/jarvis_finance/services/budget_recurring.py
 create mode 100644 src/jarvis_finance/services/finance_command_center.py
 create mode 100644 src/jarvis_finance/services/grocery_optimizer.py
 create mode 100644 src/jarvis_finance/services/grocery_price_providers.py
 create mode 100644 tests/unit/test_budget_categories_cleanup_duplicates.py
 create mode 100644 tests/unit/test_budget_categories_ux_fix.py
 create mode 100644 tests/unit/test_budget_monthly_import_rule_learning_v1.py
 create mode 100644 tests/unit/test_budget_planning_forecast_v1.py
 create mode 100644 tests/unit/test_budget_planning_usability_v2.py
 create mode 100644 tests/unit/test_budget_review_bulk_assign_confirm.py
 create mode 100644 tests/unit/test_budget_workflow_help_ux.py
 create mode 100644 tests/unit/test_finance_command_center_monthly_close_v1.py
 create mode 100644 tests/unit/test_fixed_costs_subscriptions_v1.py
 create mode 100644 tests/unit/test_grocery_matching_learning_v2.py
 create mode 100644 tests/unit/test_grocery_price_providers_v1.py
 create mode 100644 tests/unit/test_migros_grocery_optimizer_v1.py
branch=main

head=95f864ca7fb6ecbca6c344fe35ce8c66af6f3e04

status=
## main...origin/main [voraus 13]


Merge nach `main` ist konfliktfrei erledigt. Bevor ich tagge, ergänze ich jetzt die v3-UAT-Politur: Produktfilter, klare Ergebnis-Trennung, Mapping-Liste/Preisrefresh-Controls und UAT-/Provider-Report-Doku — test-first.


1|<template>
     2|

     3|

     4|
Budget · Migros Optimizer

     5|
Migros Product Savings & Grocery Optimizer

     6|
Produktebene für Essen & Haushalt: Migros-Bon auswählen, Artikel normalisieren, reviewbare Alternativen bei Schweizer Grossisten vergleichen und Einkaufslisten bis maximal 3 Läden erstellen. Keine medizinische Bewertung, keine Bestellung.

     7|

     8|        <button class="btn" @click="load">Aktualisieren</button>
     9|        <button class="btn-secondary" @click="seedDemo">Demo-Bon anlegen</button>
    10|        <button class="btn-secondary" :disabled="!selectedReceipt" @click="searchOnlinePrices">Preise online suchen</button>
    11|        <button class="btn-secondary" :disabled="!selectedReceipt" @click="startAnalysis">Analyse starten</button>
    12|        <button class="btn-secondary" :disabled="!run" @click="createReport">Report erzeugen</button>
    13|

    14|
Gesundheitsanalyse später: health_analysis_status ist vorbereitet, aber Histamin/Behçet/Trigger werden in v1 nicht bewertet.

    15|

    16|
    17|

    18|

    19|
Bon auswählen

    20|

    21|          <button v-for="receipt in dashboard?.receipts || []" :key="receipt.receipt_id" class="receipt-card" :class="selectedReceipt?.receipt_id === receipt.receipt_id ? 'ring-2 ring-cyan-300' : ''" @click="selectReceipt(receipt)">
    22|            {{ receipt.purchase_date }} · {{ receipt.store_name }}
    23|            {{ receipt.article_count }} Artikel · Gesamtbetrag lokal sichtbar
    24|            Bon-ID: {{ receipt.receipt_id.slice(0, 10) }}…
    25|          </button>
    26|
Noch keine Migros-Bons in der Produktdetail-Ansicht.

    27|

    28|

    29|

    30|
Artikelliste

    31|

    32|

    33|
{{ item.raw_product_name }}<input v-model="includedItems[item.product_item_id]" type="checkbox" aria-label="für Analyse einschließen" />

    34|
Normalisiert: {{ item.normalized_product_name }}

    35|
Menge: {{ item.quantity_text || '—' }} · Preis: {{ item.total_price_text }} CHF

    36|
Aktion: {{ item.action_label }}

    37|
Gesundheitsanalyse später

    38|

    39|              Bekannte Mappings
    40|

    41|                {{ mapping.target_retailer }} · {{ mapping.target_product_name }} · {{ mapping.status }} · Cache: {{ mapping.cache_status || 'fehlt' }}
    42|                <button class="btn-secondary ml-2" type="button" :data-testid="`refresh-mapping-${mapping.mapping_id}`" @click="refreshMapping(mapping.mapping_id)">Preis aktualisieren</button>
    43|

    44|

    45|

    46|

    47|

    48|

    49|
    50|

    51|
Online-Preissuche

    52|

    53|        <input v-model="selectedRetailers" type="checkbox" value="Coop" /> Coop
    54|        <input v-model="selectedRetailers" type="checkbox" value="Migros" /> Migros
    55|        <input v-model="selectedRetailers" type="checkbox" value="Aldi Suisse" /> Aldi
    56|        <input v-model="selectedRetailers" type="checkbox" value="Lidl" /> Lidl
    57|        <input v-model="selectedRetailers" type="checkbox" value="Denner" /> Denner
    58|        <input v-model="selectedRetailers" type="checkbox" value="Otto's" /> Otto's
    59|        <input v-model="useCache" type="checkbox" /> Cache verwenden
    60|        <input v-model="onlySafeMatches" type="checkbox" /> nur sichere Matches
    61|

    62|
Provider werden nur nach Klick auf „Preise online suchen“ aufgerufen. Keine Provider-Calls beim Seiten-Rendern.

    63|

    64|        <article v-for="match in onlineMatches" :key="`${match.product_item_id}-${match.retailer}-${match.product_url}`" class="comparison-card">
    65|          {{ match.retailer }} · {{ match.product_name }}
    66|          Preis: {{ match.price_text || '—' }} CHF · Einheitspreis: {{ match.unit_price_text || '—' }}
    67|          Confidence {{ match.confidence || '—' }} · Qualitätsflag {{ (match.quality_flags || []).join(', ') || '—' }}
    68|          {{ match.product_url }}
    69|          Aktualisiert am {{ match.fetched_at || '—' }} · Quelle {{ match.source || '—' }}
    70|

    71|            <button class="btn-secondary" type="button" :data-testid="`accept-match-${match.match_id}`" :disabled="!match.match_id" @click="acceptMatch(match)">Akzeptieren</button>
    72|            <button class="btn-secondary" type="button" :data-testid="`reject-match-${match.match_id}`" :disabled="!match.match_id" @click="rejectMatch(match)">Ablehnen</button>
    73|            <button class="btn-secondary" type="button">Später prüfen</button>
    74|            <button class="btn-secondary" type="button">Details öffnen</button>
    75|

    76|

    77|

    78|

    79|
    80|

    81|
Manuell zuordnen

    82|
Falls Websuche nichts findet: Händler, Produkt und Quelle manuell speichern. Keine medizinische Bewertung.

    83|

    84|        <select v-model="manualRetailer" data-testid="manual-retailer" class="rounded-xl border border-slate-300 p-2"><option>Coop</option><option>Denner</option><option>Aldi Suisse</option><option>Lidl</option><option>Otto's</option></select>
    85|        <input v-model="manualName" data-testid="manual-name" class="rounded-xl border border-slate-300 p-2" placeholder="Produktname" />
    86|        <input v-model="manualUrl" data-testid="manual-url" class="rounded-xl border border-slate-300 p-2" placeholder="Produkt-URL" />
    87|        <input v-model="manualPrice" class="rounded-xl border border-slate-300 p-2" placeholder="Preis optional" />
    88|        <input v-model="manualUnitPrice" class="rounded-xl border border-slate-300 p-2" placeholder="Einheitspreis optional" />
    89|        <select v-model="manualMatchType" class="rounded-xl border border-slate-300 p-2"><option>manual_match</option><option>exact_match</option><option>close_match</option><option>cheaper_private_label</option></select>
    90|

    91|      <button class="btn-secondary mt-3" data-testid="save-manual-mapping" :disabled="!selectedReceipt || !manualName || !manualUrl" @click="saveManualMapping">Als bevorzugte Alternative speichern</button>
    92|

    93|
    94|

    95|
Ergebnis

    96|

    97|
Original Migros Total{{ run.original_total_text }} CHF

    98|
Optimiertes Total{{ run.optimized_total_text }} CHF

    99|
Geschätzte Ersparnis{{ run.summary.estimated_savings_text }} CHF

   100|
Qualität{{ run.quality_status }}

   101|

   102|
Preisvergleich

   103|

   104|

   105|          {{ row.migros_product }}
   106|          Beste Alternative: {{ row.best_alternative || 'nicht gefunden' }} · {{ row.retailer || '—' }}
   107|          Ersparnis: {{ row.savings_text }} CHF · Confidence {{ row.confidence }} · Status {{ row.status }}
   108|          Qualitätsflags: {{ (row.quality_flags || []).join(', ') || '—' }}
   109|

   110|

   111|
Optimierte Einkaufsliste max. 3 Läden

   112|

   113|
{{ store.retailer }}

{{ it.product }} → {{ it.candidate_product_name }} ({{ it.price_text }} CHF)

   114|

   115|
{{ reportMessage }}

   116|

   117|

   118|</template>
   119|
   179|
   180|


     1|import { mount, flushPromises } from '@vue/test-utils'
     2|import { beforeEach, describe, expect, it, vi } from 'vitest'
     3|import BudgetGroceryOptimizerPage from './BudgetGroceryOptimizerPage.vue'
     4|import { addGroceryProductMatch, createGroceryDemoReceipt, generateGroceryOptimizationReport, getGroceryOptimizerDashboard, runGroceryOptimization } from '@/api/budget'
     5|
     6|vi.mock('@/api/budget', async () => {
     7|  const actual = await vi.importActual<any>('@/api/budget')
     8|  return { ...actual, getGroceryOptimizerDashboard: vi.fn(), createGroceryDemoReceipt: vi.fn(), addGroceryProductMatch: vi.fn(), runGroceryOptimization: vi.fn(), generateGroceryOptimizationReport: vi.fn() }
     9|})
    10|
    11|const dashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1', supported_retailers: ['Coop', 'Aldi Suisse'], health_analysis_status: 'prepared_not_run', disclaimer: 'Keine medizinische Bewertung', receipts: [{ receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', article_count: 2, total_text: '5.70', items: [{ product_item_id: 'item_1', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Milch 1L', normalized_product_name: 'milch', quantity_text: '1L', total_price_text: '1.50', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }, { product_item_id: 'item_2', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Bio Eier 6 Stück', normalized_product_name: 'eier', quantity_text: '6 Stk', total_price_text: '4.20', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }, { receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', article_count: 1, total_text: '3.00', items: [{ product_item_id: 'item_3', receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', raw_product_name: 'Milch 2L', normalized_product_name: 'milch', quantity_text: '2L', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }] }
    12|
    13|beforeEach(() => {
    14|  vi.mocked(getGroceryOptimizerDashboard).mockResolvedValue(dashboard as any)
    15|  vi.mocked(createGroceryDemoReceipt).mockResolvedValue({} as any)
    16|  vi.mocked(addGroceryProductMatch).mockResolvedValue({} as any)
    17|  vi.mocked(runGroceryOptimization).mockResolvedValue({ run_id: 'run_1', receipt_id: 'receipt_1', original_total_text: '5.70', optimized_total_text: '5.40', quality_status: 'review_required', summary: { estimated_savings_text: '0.30' }, optimized_lists: [{ store_count: 1, stores: [{ retailer: 'Coop', items: [{ product: 'Milch 1L', candidate_product_name: 'Prix Garantie Milch 1L', price_text: '1.20' }] }] }], product_comparisons: [{ product_item_id: 'item_1', migros_product: 'Milch 1L', best_alternative: 'Prix Garantie Milch 1L', retailer: 'Coop', savings_text: '0.30', confidence: '0.86', status: 'suggested', quality_flags: ['close_match'] }], health_analysis_status: 'prepared_not_run' } as any)
    18|  vi.mocked(generateGroceryOptimizationReport).mockResolvedValue({ format: 'markdown', runtime_only: true } as any)
    19|})
    20|
    21|describe('BudgetGroceryOptimizerPage', () => {
    22|  it('renders receipt selection product rows optimization and runtime report without medical claims', async () => {
    23|    const wrapper = mount(BudgetGroceryOptimizerPage)
    24|    await flushPromises()
    25|    expect(wrapper.text()).toContain('Migros Product Savings')
    26|    expect(wrapper.text()).toContain('Bon auswählen')
    27|    expect(wrapper.text()).toContain('Milch 1L')
    28|    expect(wrapper.text()).toContain('Normalisiert: milch')
    29|    expect(wrapper.text()).toContain('Gesundheitsanalyse später')
    30|    await (wrapper.vm as any).startAnalysis()
    31|    await flushPromises()
    32|    expect(addGroceryProductMatch).toHaveBeenCalled()
    33|    expect(runGroceryOptimization).toHaveBeenCalledWith({ receipt_id: 'receipt_1', selected_retailers: ['Coop', 'Aldi Suisse', 'Lidl', 'Denner', "Otto's"], max_store_count: 3, included_product_item_ids: ['item_1', 'item_2'] })
    34|    expect(wrapper.text()).toContain('Geschätzte Ersparnis')
    35|    expect(wrapper.text()).toContain('Prix Garantie Milch 1L')
    36|    await (wrapper.vm as any).createReport()
    37|    await flushPromises()
    38|    expect(generateGroceryOptimizationReport).toHaveBeenCalledWith({ run_id: 'run_1' })
    39|    expect(wrapper.text()).toContain('Runtime-only Report')
    40|    expect(wrapper.text()).not.toContain('Histaminbewertung')
    41|  })
    42|
    43|  it('resets included item ids when switching receipts before analysis', async () => {
    44|    const wrapper = mount(BudgetGroceryOptimizerPage)
    45|    await flushPromises()
    46|    ;(wrapper.vm as any).selectReceipt((dashboard as any).receipts[1])
    47|    await (wrapper.vm as any).startAnalysis()
    48|    await flushPromises()
    49|    expect(runGroceryOptimization).toHaveBeenCalledWith({ receipt_id: 'receipt_2', selected_retailers: ['Coop', 'Aldi Suisse', 'Lidl', 'Denner', "Otto's"], max_store_count: 3, included_product_item_ids: ['item_3'] })
    50|  })
    51|})
    52|


     1|from __future__ import annotations
     2|
     3|import hashlib
     4|import ipaddress
     5|import json
     6|import re
     7|import socket
     8|from collections import defaultdict
     9|from datetime import datetime, timezone
    10|from decimal import Decimal, InvalidOperation
    11|from pathlib import Path
    12|from sqlite3 import Connection
    13|from typing import Any
    14|from urllib.parse import urlparse
    15|
    16|from jarvis_finance.config.settings import find_repo_root, load_settings
    17|from jarvis_finance.services.budget_common import new_id, row_to_dict
    18|
    19|SAFE_RETAILERS = {"Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's", "Migros"}
    20|SAFE_MATCH_TYPES = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
    21|SAFE_FLAGS = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
    22|UNCERTAIN_FLAGS = {"different_pack_size", "needs_review", "bio_vs_non_bio", "brand_vs_private_label"}
    23|
    24|
    25|def _now() -> str:
    26|    return datetime.now(timezone.utc).isoformat()
    27|
    28|
    29|def _money(value: Any) -> Decimal:
    30|    try:
    31|        text = str(value or "0").replace("CHF", "").replace("'", "").replace("’", "").strip().replace(",", ".")
    32|        return Decimal(text or "0").quantize(Decimal("0.01"))
    33|    except (InvalidOperation, ValueError):
    34|        return Decimal("0.00")
    35|
    36|
    37|def _fmt(value: Decimal | Any) -> str:
    38|    return f"{_money(value):.2f}"
    39|
    40|
    41|def normalize_product_name(raw_product_name: str) -> dict[str, str]:
    42|    raw = raw_product_name
    43|    text = re.sub(r"\b(aktion|rabatt|bonus|cumulus|hit|promo)\b", " ", raw, flags=re.I)
    44|    quantity = re.search(r"\b\d+(?:[.,]\d+)?\s*(?:kg|g|l|ml|stk|stück|x)\b", text, flags=re.I)
    45|    brand = "M-Budget" if re.search(r"m[- ]?budget", text, flags=re.I) else None
    46|    text = re.sub(r"m[- ]?budget|bio", " ", text, flags=re.I)
    47|    text = re.sub(r"\b\d+(?:[.,]\d+)?\s*(?:kg|g|l|ml|stk|stück|x)\b", " ", text, flags=re.I)
    48|    words = [w.lower() for w in re.findall(r"[A-Za-zÄÖÜäöüß]+", text) if len(w) > 1]
    49|    core_map = {"milch": "milch", "eier": "eier", "ei": "eier", "reis": "reis", "poulet": "poulet", "joghurt": "joghurt", "yogurt": "joghurt", "käse": "käse", "kaese": "käse", "waschmittel": "waschmittel", "katzenfutter": "katzenfutter"}
    50|    core = next((core_map[w] for w in words if w in core_map), " ".join(words[:3]).strip() or raw.strip().lower())
    51|    return {"raw_product_name": raw, "normalized_product_name": core, "brand_hint": brand or "", "quantity_hint": quantity.group(0).replace(" ", "") if quantity else "", "confidence": "0.82" if core else "0.50"}
    52|
    53|
    54|def add_demo_migros_receipt(conn: Connection, *, purchase_date: str, store_name: str, items: list[dict[str, Any]], receipt_id: str | None = None) -> dict[str, Any]:
    55|    rid = receipt_id or new_id("mreceipt")
    56|    out_items: list[dict[str, Any]] = []
    57|    for idx, item in enumerate(items, start=1):
    58|        norm = normalize_product_name(str(item.get("raw_product_name") or ""))
    59|        pid = new_id("gitem")
    60|        total = _fmt(item.get("total_price_text") or item.get("total") or "0")
    61|        conn.execute(
    62|            """INSERT INTO grocery_product_items(product_item_id,source_type,receipt_id,receipt_key,source_line_id,purchase_date,store_name,raw_product_name,normalized_product_name,normalization_confidence,brand_hint,quantity_hint,quantity_text,unit,unit_price_text,total_price_text,currency,action_label,include_in_analysis,notes,health_analysis_status,health_flags_json,health_notes,created_at)
    63|               VALUES (?, 'migros_receipt', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, 1, ?, 'prepared_not_run', '{}', ?, ?)""",
    64|            (pid, rid, f"{purchase_date}:{store_name}", str(idx), purchase_date, store_name, item.get("raw_product_name"), norm["normalized_product_name"], norm["confidence"], norm["brand_hint"], norm["quantity_hint"], item.get("quantity_text"), item.get("unit"), item.get("unit_price_text"), total, item.get("action_label"), item.get("notes"), "Gesundheitsanalyse später", _now()),
    65|        )
    66|        row = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (pid,)).fetchone())
    67|        out_items.append(row)
    68|    conn.commit()
    69|    return {"receipt_id": rid, "purchase_date": purchase_date, "store_name": store_name, "items": out_items}
    70|
    71|
    72|def _items_for_receipt(conn: Connection, receipt_id: str) -> list[dict[str, Any]]:
    73|    rows = conn.execute("SELECT * FROM grocery_product_items WHERE receipt_id=? ORDER BY source_line_id, product_item_id", (receipt_id,)).fetchall()
    74|    return [row_to_dict(r) for r in rows]
    75|
    76|
    77|def get_grocery_optimizer_dashboard(conn: Connection) -> dict[str, Any]:
    78|    rows = conn.execute("""SELECT receipt_id, purchase_date, store_name, COUNT(*) AS article_count, COALESCE(SUM(CAST(total_price_text AS REAL)),0) AS total_value FROM grocery_product_items GROUP BY receipt_id,purchase_date,store_name ORDER BY purchase_date DESC, receipt_id DESC""").fetchall()
    79|    receipts = []
    80|    for r in rows:
    81|        items = _items_for_receipt(conn, r["receipt_id"])
    82|        for item in items:
    83|            item["known_mappings"] = known_mappings_for_item(conn, item, include_rejected=True)
    84|        receipts.append({"receipt_id": r["receipt_id"], "purchase_date": r["purchase_date"], "store_name": r["store_name"], "article_count": int(r["article_count"] or 0), "total_text": _fmt(r["total_value"]), "items": items})
    85|    return {"purpose": "migros_product_savings_grocery_optimizer_v1", "receipts": receipts, "supported_retailers": sorted(SAFE_RETAILERS - {"Migros"}), "health_analysis_status": "prepared_not_run", "disclaimer": "Keine medizinische Bewertung; keine automatische Bestellung."}
    86|
    87|
    88|def _validate_product_url(url: str) -> None:
    89|    parsed = urlparse(url)
    90|    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
    91|        raise ValueError("invalid_product_url")
    92|    host = (parsed.hostname or "").strip().lower().rstrip(".")
    93|    if not host or host == "localhost" or host.endswith(".localhost"):
    94|        raise ValueError("unsafe_product_url")
    95|    try:
    96|        ip = ipaddress.ip_address(host)
    97|    except ValueError:
    98|        if re.fullmatch(r"[0-9.]+", host):
    99|            try:
   100|                ip = ipaddress.ip_address(socket.inet_ntoa(socket.inet_aton(host)))
   101|            except OSError:
   102|                return
   103|        else:
   104|            return
   105|    if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_unspecified or ip.is_reserved:
   106|        raise ValueError("unsafe_product_url")
   107|
   108|
   109|def upsert_product_match(conn: Connection, product_item_id: str, *, retailer: str, candidate_product_name: str, candidate_url: str, candidate_price_text: str | None, candidate_unit_price_text: str | None = None, quality_flags: list[str] | None = None, match_confidence: str = "0.60", status: str = "suggested", source: str = "manual", candidate_brand: str | None = None, candidate_package_size: str | None = None, candidate_unit: str | None = None) -> dict[str, Any]:
   110|    if retailer not in SAFE_RETAILERS:
   111|        raise ValueError("unsupported_retailer")
   112|    _validate_product_url(candidate_url)
   113|    mid = new_id("gmatch")
   114|    flags = quality_flags or ["needs_review"]
   115|    fetched = _now()
   116|    source_hash = hashlib.sha256((retailer + candidate_url + candidate_product_name + str(candidate_price_text)).encode()).hexdigest()
   117|    conn.execute(
   118|        """INSERT INTO grocery_product_matches(match_id,product_item_id,retailer,candidate_product_name,candidate_url,candidate_brand,candidate_package_size,candidate_unit,candidate_price_text,candidate_unit_price_text,price_currency,match_confidence,match_reason,quality_flags_json,fetched_at,source,status)
   119|           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?)""",
   120|        (mid, product_item_id, retailer, candidate_product_name, candidate_url, candidate_brand, candidate_package_size, candidate_unit, _fmt(candidate_price_text) if candidate_price_text is not None else None, candidate_unit_price_text, match_confidence, "reviewable sourced candidate", json.dumps(flags), fetched, source, status),
   121|    )
   122|    if source not in {"web_fetch", "cache"}:
   123|        conn.execute(
   124|            """INSERT OR REPLACE INTO grocery_product_details_cache(detail_id,retailer,product_url,product_name,price_text,unit_price_text,package_size,ingredients_text,nutrition_json,fetched_at,cache_status,source_hash)
   125|               VALUES (COALESCE((SELECT detail_id FROM grocery_product_details_cache WHERE retailer=? AND product_url=?), ?), ?, ?, ?, ?, ?, ?, NULL, '{}', ?, 'cached', ?)""",
   126|            (retailer, candidate_url, new_id("gdetail"), retailer, candidate_url, candidate_product_name, _fmt(candidate_price_text) if candidate_price_text is not None else None, candidate_unit_price_text, candidate_package_size, fetched, source_hash),
   127|        )
   128|    conn.commit()
   129|    return row_to_dict(conn.execute("SELECT * FROM grocery_product_matches WHERE match_id=?", (mid,)).fetchone())
   130|
   131|
   132|def _matches(conn: Connection, product_item_id: str) -> list[dict[str, Any]]:
   133|    return [row_to_dict(r) for r in conn.execute("SELECT * FROM grocery_product_matches WHERE product_item_id=? ORDER BY CAST(match_confidence AS REAL) DESC", (product_item_id,)).fetchall()]
   134|
   135|
   136|def _cache_status_for_url(conn: Connection, retailer: str, url: str) -> dict[str, Any]:
   137|    row = conn.execute("SELECT * FROM grocery_product_details_cache WHERE retailer=? AND product_url=? ORDER BY fetched_at DESC LIMIT 1", (retailer, url)).fetchone()
   138|    if not row:
   139|        return {"cache_status": "fehlt", "last_price_checked_at": None}
   140|    data = row_to_dict(row)
   141|    try:
   142|        fetched = datetime.fromisoformat(str(data.get("fetched_at")))
   143|        if fetched.tzinfo is None:
   144|            fetched = fetched.replace(tzinfo=timezone.utc)
   145|        age = (datetime.now(timezone.utc) - fetched).total_seconds()
   146|        status = "frisch" if age <= 86400 else "veraltet"
   147|    except Exception:
   148|        status = "fehler"
   149|    return {"cache_status": status, "last_price_checked_at": data.get("fetched_at"), "price_text": data.get("price_text"), "unit_price_text": data.get("unit_price_text")}
   150|
   151|
   152|def known_mappings_for_item(conn: Connection, item: dict[str, Any], include_rejected: bool = False) -> list[dict[str, Any]]:
   153|    statuses = ("accepted", "needs_review", "rejected") if include_rejected else ("accepted", "needs_review")
   154|    rows = conn.execute(
   155|        f"SELECT * FROM grocery_product_mappings WHERE source_retailer='Migros' AND source_product_normalized_name=? AND status IN ({','.join('?' for _ in statuses)}) ORDER BY status='accepted' DESC, CAST(confidence AS REAL) DESC, updated_at DESC",
   156|        (item.get("normalized_product_name"), *statuses),
   157|    ).fetchall()
   158|    mappings = []
   159|    for row in rows:
   160|        d = row_to_dict(row)
   161|        d.update(_cache_status_for_url(conn, d["target_retailer"], d["target_product_url"]))
   162|        mappings.append(d)
   163|    return mappings
   164|
   165|
   166|def _insert_mapping(conn: Connection, *, item: dict[str, Any], target_retailer: str, target_product_name: str, target_product_url: str, match_type: str, status: str, confidence: str = "0.80", user_note: str | None = None, source_match_id: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_text: str | None = None, target_unit_price_text: str | None = None, quality_flags: list[str] | None = None, last_price_checked_at: str | None = None) -> dict[str, Any]:
   167|    if target_retailer not in SAFE_RETAILERS:
   168|        raise ValueError("unsupported_retailer")
   169|    if match_type not in SAFE_MATCH_TYPES:
   170|        raise ValueError("unsupported_match_type")
   171|    _validate_product_url(target_product_url)
   172|    now = _now()
   173|    mapping_id = new_id("gmap")
   174|    conn.execute(
   175|        """INSERT INTO grocery_product_mappings(mapping_id,source_product_normalized_name,source_product_raw_name,source_retailer,target_retailer,target_product_name,target_product_url,target_brand,target_package_size,target_unit,target_price_text,target_unit_price_text,price_currency,match_type,status,confidence,user_note,source_match_id,quality_flags_json,health_analysis_status,health_flags_json,health_notes,created_at,updated_at,last_price_checked_at)
   176|           VALUES (?, ?, ?, 'Migros', ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?, 'prepared_not_run', '{}', ?, ?, ?, ?)""",
   177|        (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),
   178|    )
   179|    conn.commit()
   180|    return row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())
   181|
   182|
   183|def accept_grocery_product_match(conn: Connection, match_id: str, *, user_note: str | None = None, save_as_preferred: bool = True) -> dict[str, Any]:
   184|    row = conn.execute("SELECT * FROM grocery_product_matches WHERE match_id=?", (match_id,)).fetchone()
   185|    if not row:
   186|        raise ValueError("match_not_found")
   187|    match = row_to_dict(row)
   188|    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (match["product_item_id"],)).fetchone())
   189|    conn.execute("UPDATE grocery_product_matches SET status='accepted' WHERE match_id=?", (match_id,))
   190|    mapping = _insert_mapping(conn, item=item, target_retailer=match["retailer"], target_product_name=match["candidate_product_name"], target_product_url=match["candidate_url"], target_brand=match.get("candidate_brand"), target_package_size=match.get("candidate_package_size"), target_unit=match.get("candidate_unit"), target_price_text=match.get("candidate_price_text"), target_unit_price_text=match.get("candidate_unit_price_text"), match_type=(json.loads(match.get("quality_flags_json") or "[]") or ["manual_match"])[0], status="accepted" if save_as_preferred else "needs_review", confidence=match.get("match_confidence") or "0.80", user_note=user_note, source_match_id=match_id, quality_flags=json.loads(match.get("quality_flags_json") or "[]"), last_price_checked_at=match.get("fetched_at"))
   191|    return mapping
   192|
   193|
   194|def reject_grocery_product_match(conn: Connection, match_id: str, *, user_note: str | None = None) -> dict[str, Any]:
   195|    row = conn.execute("SELECT * FROM grocery_product_matches WHERE match_id=?", (match_id,)).fetchone()
   196|    if not row:
   197|        raise ValueError("match_not_found")
   198|    match = row_to_dict(row)
   199|    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (match["product_item_id"],)).fetchone())
   200|    conn.execute("UPDATE grocery_product_matches SET status='rejected' WHERE match_id=?", (match_id,))
   201|    mapping = _insert_mapping(conn, item=item, target_retailer=match["retailer"], target_product_name=match["candidate_product_name"], target_product_url=match["candidate_url"], target_brand=match.get("candidate_brand"), target_package_size=match.get("candidate_package_size"), target_unit=match.get("candidate_unit"), target_price_text=match.get("candidate_price_text"), target_unit_price_text=match.get("candidate_unit_price_text"), match_type="manual_match", status="rejected", confidence=match.get("match_confidence") or "0.50", user_note=user_note, source_match_id=match_id, quality_flags=json.loads(match.get("quality_flags_json") or "[]"), last_price_checked_at=match.get("fetched_at"))
   202|    return mapping
   203|
   204|
   205|def create_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, match_type: str = "manual_match", candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, user_note: str | None = None, confidence: str = "0.95") -> dict[str, Any]:
   206|    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
   207|    mapping = _insert_mapping(conn, item=item, target_retailer=target_retailer, target_product_name=target_product_name, target_product_url=target_product_url, target_brand=target_brand, target_package_size=target_package_size, target_unit=target_unit, target_price_text=candidate_price_text, target_unit_price_text=candidate_unit_price_text, match_type=match_type, status="accepted", confidence=confidence, user_note=user_note, quality_flags=[match_type, "manual_price" if candidate_price_text else "no_price"], last_price_checked_at=None)
   208|    return mapping
   209|
   210|
   211|def refresh_grocery_mapping_price(conn: Connection, mapping_id: str) -> dict[str, Any]:
   212|    row = conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone()
   213|    if not row:
   214|        raise ValueError("mapping_not_found")
   215|    mapping = row_to_dict(row)
   216|    cache = _cache_status_for_url(conn, mapping["target_retailer"], mapping["target_product_url"])
   217|    if cache.get("price_text"):
   218|        flags = [f for f in json.loads(mapping.get("quality_flags_json") or "[]") if f != "manual_price"]
   219|        if "manual_match" not in flags:
   220|            flags.append("manual_match")
   221|

Use offset=221 to continue reading (showing 1-220 of 347 lines)


1|from __future__ import annotations
     2|
     3|import sqlite3
     4|from pathlib import Path
     5|
     6|import pytest
     7|
     8|from jarvis_finance.storage.migrations import apply_migrations, get_schema_version
     9|from jarvis_finance.services.grocery_optimizer import (
    10|    add_demo_migros_receipt,
    11|    accept_grocery_product_match,
    12|    reject_grocery_product_match,
    13|    create_manual_grocery_mapping,
    14|    refresh_grocery_mapping_price,
    15|    get_grocery_optimizer_dashboard,
    16|    run_grocery_optimization,
    17|    generate_grocery_optimization_report,
    18|)
    19|from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, CoopProvider, GroceryProductProvider, GroceryProviderResult
    20|
    21|
    22|def db() -> sqlite3.Connection:
    23|    conn = sqlite3.connect(':memory:')
    24|    conn.row_factory = sqlite3.Row
    25|    apply_migrations(conn)
    26|    assert get_schema_version(conn) == 34
    27|    return conn
    28|
    29|
    30|def seed_receipt(conn: sqlite3.Connection, raw='Milch 1L', receipt_id='r1') -> dict:
    31|    return add_demo_migros_receipt(conn, purchase_date='2026-05-19', store_name='Migros', receipt_id=receipt_id, items=[{'raw_product_name': raw, 'quantity_text': '1L', 'unit': 'l', 'unit_price_text': '1.50/l', 'total_price_text': '1.50'}])
    32|
    33|
    34|class StaticProvider(GroceryProductProvider):
    35|    def __init__(self, retailer: str, results: list[GroceryProviderResult]) -> None:
    36|        super().__init__(fetcher=lambda _url: "")
    37|        self.retailer = retailer
    38|        self.base_url = f'https://{retailer.lower().replace(" ", "-")}.example/'
    39|        self._results = results
    40|
    41|    def search_products(self, conn: sqlite3.Connection, query: str, retailer: str | None = None, locale: str = 'de-CH', use_cache: bool = True, max_results: int = 5, max_age_seconds: int = 86400) -> list[dict]:
    42|        self.request_count += 1
    43|        return [r.as_dict() for r in self._results[:max_results]]
    44|
    45|
    46|def test_accept_match_creates_preferred_mapping_and_next_receipt_reuses_without_provider_call() -> None:
    47|    conn = db()
    48|    first = seed_receipt(conn, receipt_id='r1')
    49|    provider = CoopProvider(fetcher=lambda _url: '
Prix Garantie Milch 1LCHF 1.201.20/l
')
    50|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[first['items'][0]['product_item_id']], use_cache=False)
    51|    accepted = accept_grocery_product_match(conn, found['matches'][0]['match_id'], user_note='passt')
    52|    assert accepted['status'] == 'accepted'
    53|    assert accepted['target_retailer'] == 'Coop'
    54|
    55|    second = seed_receipt(conn, receipt_id='r2')
    56|    fail_provider = CoopProvider(fetcher=lambda _url: (_ for _ in ()).throw(AssertionError('provider must not be called')))
    57|    reused = search_and_store_product_matches(conn, 'r2', providers=[fail_provider], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=True)
    58|    assert reused['provider_calls'] == 0
    59|    assert reused['known_mapping_hits'] == 1
    60|    assert reused['matches'][0]['source'] == 'known_mapping'
    61|    run = run_grocery_optimization(conn, 'r2', selected_retailers=['Coop'], included_product_item_ids=[second['items'][0]['product_item_id']])
    62|    assert run['summary']['replaceable_product_count'] == 1
    63|
    64|
    65|def test_rejected_match_is_persisted_and_not_recommended_again() -> None:
    66|    conn = db()
    67|    receipt = seed_receipt(conn)
    68|    provider = CoopProvider(fetcher=lambda _url: '
Coop Milch 1LCHF 1.201.20/l
')
    69|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
    70|    rejected = reject_grocery_product_match(conn, found['matches'][0]['match_id'], user_note='falsch')
    71|    assert rejected['status'] == 'rejected'
    72|
    73|    second = seed_receipt(conn, receipt_id='r2')
    74|    reused = search_and_store_product_matches(conn, 'r2', providers=[provider], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=True)
    75|    assert all(m.get('status') != 'suggested' for m in reused['matches'])
    76|    run = run_grocery_optimization(conn, 'r2', selected_retailers=['Coop'], included_product_item_ids=[second['items'][0]['product_item_id']])
    77|    assert run['summary']['replaceable_product_count'] == 0
    78|    assert 'Milch 1L' in run['optimized_lists'][0]['rejected_products']
    79|
    80|
    81|def test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh() -> None:
    82|    conn = db()
    83|    receipt = seed_receipt(conn)
    84|    mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', target_brand='Denner', target_package_size='1L', target_unit='l', match_type='manual_match', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', user_note='manuell notiert')
    85|    assert mapping['status'] == 'accepted'
    86|    assert mapping['match_type'] == 'manual_match'
    87|    assert mapping['last_price_checked_at'] is None
    88|    run_before = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
    89|    assert run_before['summary']['replaceable_product_count'] == 0
    90|    conn.execute("INSERT INTO grocery_product_details_cache(detail_id,retailer,product_url,product_name,price_text,unit_price_text,package_size,ingredients_text,nutrition_json,fetched_at,cache_status,source_hash) VALUES ('d1','Denner','https://www.denner.ch/milch','Denner Milch 1L','1.10','1.10/l','1L',NULL,'{}','2026-05-19T10:00:00+00:00','cached','hash')")
    91|    refreshed = refresh_grocery_mapping_price(conn, mapping['mapping_id'])
    92|    assert refreshed['last_price_checked_at'] == '2026-05-19T10:00:00+00:00'
    93|    run_after = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
    94|    assert run_after['optimized_total_text'] == '1.10'
    95|
    96|
    97|def test_manual_mapping_rejects_unsafe_url_and_match_type() -> None:
    98|    conn = db()
    99|    receipt = seed_receipt(conn)
   100|    for unsafe_url in ['javascript:alert(1)', 'http://127.1/x', 'http://[::1]/x', 'http://169.254.169.254/latest', 'http://172.31.0.1/x', 'http://localhost/x']:
   101|        with pytest.raises(ValueError):
   102|            create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Bad', target_product_url=unsafe_url)
   103|    with pytest.raises(ValueError):
   104|        create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Bad', target_product_url='https://coop.example/bad', match_type='evil_match')
   105|
   106|
   107|def test_dashboard_surfaces_known_mappings_and_cache_status() -> None:
   108|    conn = db()
   109|    receipt = seed_receipt(conn)
   110|    mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')
   111|    dash = get_grocery_optimizer_dashboard(conn)
   112|    item = dash['receipts'][0]['items'][0]
   113|    assert item['known_mappings'][0]['mapping_id'] == mapping['mapping_id']
   114|    assert item['known_mappings'][0]['cache_status'] in {'frisch', 'veraltet', 'fehlt', 'fehler'}
   115|
   116|
   117|def test_report_contains_known_mappings_sources_and_disclaimer_runtime_only(tmp_path: Path) -> None:
   118|    conn = db()
   119|    receipt = seed_receipt(conn)
   120|    create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')
   121|    run = run_grocery_optimization(conn, 'r1', selected_retailers=['Coop'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
   122|    report = generate_grocery_optimization_report(conn, run['run_id'], reports_dir=tmp_path / 'runtime-reports', repo_root=tmp_path / 'repo')
   123|    text = Path(report['report_path']).read_text()
   124|    assert report['runtime_only'] is True
   125|    assert 'Bekannte Mappings' in text
   126|    assert 'https://coop.example/milch' in text
   127|    assert 'keine medizinische Empfehlung' in text
   128|
   129|
   130|def test_no_price_provider_candidate_is_persisted_and_rejectable() -> None:
   131|    conn = db()
   132|    receipt = seed_receipt(conn)
   133|    provider = StaticProvider('Coop', [GroceryProviderResult(retailer='Coop', product_name='Coop Milch ohne Preis', product_url='https://coop.example/no-price', price_text=None, unit_price_text=None, confidence='0.50')])
   134|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
   135|    assert found['matches'][0]['status'] == 'needs_review'
   136|    assert found['matches'][0]['match_id']
   137|    rejected = reject_grocery_product_match(conn, found['matches'][0]['match_id'])
   138|    assert rejected['status'] == 'rejected'
   139|
   140|
   141|def test_rejected_exact_match_does_not_suppress_other_retailers() -> None:
   142|    conn = db()
   143|    receipt = seed_receipt(conn)
   144|    coop = StaticProvider('Coop', [GroceryProviderResult(retailer='Coop', product_name='Coop Milch 1L', product_url='https://coop.example/milch', price_text='1.20', unit_price_text='1.20/l', quality_flags=['close_match'], confidence='0.80')])
   145|    found = search_and_store_product_matches(conn, 'r1', providers=[coop], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
   146|    reject_grocery_product_match(conn, found['matches'][0]['match_id'])
   147|    denner = StaticProvider('Denner', [GroceryProviderResult(retailer='Denner', product_name='Denner Milch 1L', product_url='https://denner.example/milch', price_text='1.10', unit_price_text='1.10/l', quality_flags=['close_match'], confidence='0.80')])
   148|    second = seed_receipt(conn, receipt_id='r2')
   149|    found_again = search_and_store_product_matches(conn, 'r2', providers=[denner], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=False)
   150|    assert any(m['retailer'] == 'Denner' and m['status'] == 'suggested' for m in found_again['matches'])
   151|


Ich schreibe zuerst RED-Tests für die v3-UI-Anforderungen: Produktfilter, Mapping-Liste, sichere/unsichere Trennung, max-3-Einkaufsliste-Hinweise und Report/Preisrefresh-Buttons.

Explanation:
**Planning Tests for TDD**

I realize I need to address the failing tests in my Test-Driven Development (TDD). I'll patch the frontend tests, focusing on filters and sections, and possibly look into backend reports. I should also consider importing mocks for the search and other components. There are existing GroceryMatching tests, so it seems I'll add to those. Using known_mappings for types might be necessary. Overall, it sounds like I can use the tests to identify where failures are currently happening.

patch

const dashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1', supported_retailers: ['Coop', 'Aldi Suisse'], health_analysis_status: 'prepared_not_run', disclaimer: 'Keine medizinische Bewertung', receipts: [{ receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', article_count: 2, total_text: '5.70', items: [{ product_item_id: 'item_1', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Milch 1L', normalized_product_name: 'milch', quantity_text: '1L', total_price_text: '1.50', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }, { product_item_id: 'item_2', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Bio Eier 6 Stück', normalized_product_name: 'eier', quantity_text: '6 Stk', total_price_text: '4.20', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }, { receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', article_count: 1, total_text: '3.00', items: [{ product_item_id: 'item_3', receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', raw_product_name: 'Milch 2L', normalized_product_name: 'milch', quantity_text: '2L', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }] }

const dashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1', supported_retailers: ['Coop', 'Aldi Suisse'], health_analysis_status: 'prepared_not_run', disclaimer: 'Keine medizinische Bewertung', receipts: [{ receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', article_count: 4, total_text: '10.70', items: [{ product_item_id: 'item_1', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Milch 1L', normalized_product_name: 'milch', quantity_text: '1L', total_price_text: '1.50', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_accepted', target_retailer: 'Coop', target_product_name: 'Coop Milch', status: 'accepted', cache_status: 'frisch', last_price_checked_at: '2026-05-19T10:00:00Z' }] }, { product_item_id: 'item_2', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Bio Eier 6 Stück', normalized_product_name: 'eier', quantity_text: '6 Stk', total_price_text: '4.20', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_review', target_retailer: 'Denner', target_product_name: 'Denner Eier', status: 'needs_review', cache_status: 'fehlt' }] }, { product_item_id: 'item_4', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Reis 1kg', normalized_product_name: 'reis', quantity_text: '1kg', total_price_text: '2.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [] }, { product_item_id: 'item_5', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Joghurt', normalized_product_name: 'joghurt', quantity_text: '1 Stk', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_rejected', target_retailer: 'Coop', target_product_name: 'Falsches Joghurt', status: 'rejected', cache_status: 'veraltet' }] }] }, { receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', article_count: 1, total_text: '3.00', items: [{ product_item_id: 'item_3', receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', raw_product_name: 'Milch 2L', normalized_product_name: 'milch', quantity_text: '2L', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }] }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
@@ -8,7 +8,7 @@
   return { ...actual, getGroceryOptimizerDashboard: vi.fn(), createGroceryDemoReceipt: vi.fn(), addGroceryProductMatch: vi.fn(), runGroceryOptimization: vi.fn(), generateGroceryOptimizationReport: vi.fn() }
 })

-const dashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1', supported_retailers: ['Coop', 'Aldi Suisse'], health_analysis_status: 'prepared_not_run', disclaimer: 'Keine medizinische Bewertung', receipts: [{ receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', article_count: 2, total_text: '5.70', items: [{ product_item_id: 'item_1', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Milch 1L', normalized_product_name: 'milch', quantity_text: '1L', total_price_text: '1.50', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }, { product_item_id: 'item_2', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Bio Eier 6 Stück', normalized_product_name: 'eier', quantity_text: '6 Stk', total_price_text: '4.20', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }, { receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', article_count: 1, total_text: '3.00', items: [{ product_item_id: 'item_3', receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', raw_product_name: 'Milch 2L', normalized_product_name: 'milch', quantity_text: '2L', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }] }
+const dashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1', supported_retailers: ['Coop', 'Aldi Suisse'], health_analysis_status: 'prepared_not_run', disclaimer: 'Keine medizinische Bewertung', receipts: [{ receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', article_count: 4, total_text: '10.70', items: [{ product_item_id: 'item_1', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Milch 1L', normalized_product_name: 'milch', quantity_text: '1L', total_price_text: '1.50', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_accepted', target_retailer: 'Coop', target_product_name: 'Coop Milch', status: 'accepted', cache_status: 'frisch', last_price_checked_at: '2026-05-19T10:00:00Z' }] }, { product_item_id: 'item_2', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Bio Eier 6 Stück', normalized_product_name: 'eier', quantity_text: '6 Stk', total_price_text: '4.20', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_review', target_retailer: 'Denner', target_product_name: 'Denner Eier', status: 'needs_review', cache_status: 'fehlt' }] }, { product_item_id: 'item_4', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Reis 1kg', normalized_product_name: 'reis', quantity_text: '1kg', total_price_text: '2.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [] }, { product_item_id: 'item_5', receipt_id: 'receipt_1', purchase_date: '2026-05-19', store_name: 'Migros Test', raw_product_name: 'Joghurt', normalized_product_name: 'joghurt', quantity_text: '1 Stk', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run', known_mappings: [{ mapping_id: 'map_rejected', target_retailer: 'Coop', target_product_name: 'Falsches Joghurt', status: 'rejected', cache_status: 'veraltet' }] }] }, { receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', article_count: 1, total_text: '3.00', items: [{ product_item_id: 'item_3', receipt_id: 'receipt_2', purchase_date: '2026-05-20', store_name: 'Migros Zwei', raw_product_name: 'Milch 2L', normalized_product_name: 'milch', quantity_text: '2L', total_price_text: '3.00', include_in_analysis: 1, health_analysis_status: 'prepared_not_run' }] }] }

 beforeEach(() => {
   vi.mocked(getGroceryOptimizerDashboard).mockResolvedValue(dashboard as any)

npx not usable: This is not the tsc command you are looking for


vi.mocked(runGroceryOptimization).mockResolvedValue({ run_id: 'run_1', receipt_id: 'receipt_1', original_total_text: '5.70', optimized_total_text: '5.40', quality_status: 'review_required', summary: { estimated_savings_text: '0.30' }, optimized_lists: [{ store_count: 1, stores: [{ retailer: 'Coop', items: [{ product: 'Milch 1L', candidate_product_name: 'Prix Garantie Milch 1L', price_text: '1.20' }] }], not_found_products: ['Reis 1kg'], needs_review_products: ['Bio Eier 6 Stück'], rejected_products: ['Joghurt'] }], product_comparisons: [{ product_item_id: 'item_1', migros_product: 'Milch 1L', best_alternative: 'Prix Garantie Milch 1L', retailer: 'Coop', savings_text: '0.30', confidence: '0.86', status: 'suggested', quality_flags: ['close_match'] }, { product_item_id: 'item_2', migros_product: 'Bio Eier 6 Stück', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'needs_review', quality_flags: ['different_pack_size'] }, { product_item_id: 'item_4', migros_product: 'Reis 1kg', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'not_found', quality_flags: [] }, { product_item_id: 'item_5', migros_product: 'Joghurt', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'rejected', quality_flags: ['rejected'] }], health_analysis_status: 'prepared_not_run' } as any)

vi.mocked(runGroceryOptimization).mockResolvedValue({ run_id: 'run_1', receipt_id: 'receipt_1', original_total_text: '5.70', optimized_total_text: '5.40', quality_status: 'review_required', summary: { estimated_savings_text: '0.30' }, optimized_lists: [{ store_count: 1, stores: [{ retailer: 'Coop', items: [{ product: 'Milch 1L', candidate_product_name: 'Prix Garantie Milch 1L', price_text: '1.20' }] }] }], product_comparisons: [{ product_item_id: 'item_1', migros_product: 'Milch 1L', best_alternative: 'Prix Garantie Milch 1L', retailer: 'Coop', savings_text: '0.30', confidence: '0.86', status: 'suggested', quality_flags: ['close_match'] }], health_analysis_status: 'prepared_not_run' } as any)


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
@@ -14,7 +14,7 @@
   vi.mocked(getGroceryOptimizerDashboard).mockResolvedValue(dashboard as any)
   vi.mocked(createGroceryDemoReceipt).mockResolvedValue({} as any)
   vi.mocked(addGroceryProductMatch).mockResolvedValue({} as any)
-  vi.mocked(runGroceryOptimization).mockResolvedValue({ run_id: 'run_1', receipt_id: 'receipt_1', original_total_text: '5.70', optimized_total_text: '5.40', quality_status: 'review_required', summary: { estimated_savings_text: '0.30' }, optimized_lists: [{ store_count: 1, stores: [{ retailer: 'Coop', items: [{ product: 'Milch 1L', candidate_product_name: 'Prix Garantie Milch 1L', price_text: '1.20' }] }] }], product_comparisons: [{ product_item_id: 'item_1', migros_product: 'Milch 1L', best_alternative: 'Prix Garantie Milch 1L', retailer: 'Coop', savings_text: '0.30', confidence: '0.86', status: 'suggested', quality_flags: ['close_match'] }], health_analysis_status: 'prepared_not_run' } as any)
+  vi.mocked(runGroceryOptimization).mockResolvedValue({ run_id: 'run_1', receipt_id: 'receipt_1', original_total_text: '5.70', optimized_total_text: '5.40', quality_status: 'review_required', summary: { estimated_savings_text: '0.30' }, optimized_lists: [{ store_count: 1, stores: [{ retailer: 'Coop', items: [{ product: 'Milch 1L', candidate_product_name: 'Prix Garantie Milch 1L', price_text: '1.20' }] }], not_found_products: ['Reis 1kg'], needs_review_products: ['Bio Eier 6 Stück'], rejected_products: ['Joghurt'] }], product_comparisons: [{ product_item_id: 'item_1', migros_product: 'Milch 1L', best_alternative: 'Prix Garantie Milch 1L', retailer: 'Coop', savings_text: '0.30', confidence: '0.86', status: 'suggested', quality_flags: ['close_match'] }, { product_item_id: 'item_2', migros_product: 'Bio Eier 6 Stück', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'needs_review', quality_flags: ['different_pack_size'] }, { product_item_id: 'item_4', migros_product: 'Reis 1kg', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'not_found', quality_flags: [] }, { product_item_id: 'item_5', migros_product: 'Joghurt', best_alternative: null, retailer: null, savings_text: '0.00', confidence: '0', status: 'rejected', quality_flags: ['rejected'] }], health_analysis_status: 'prepared_not_run' } as any)
   vi.mocked(generateGroceryOptimizationReport).mockResolvedValue({ format: 'markdown', runtime_only: true } as any)
 })


    expect(wrapper.text()).toContain('Runtime-only Report')
    expect(wrapper.text()).not.toContain('Histaminbewertung')
  })

  it('filters products and separates safe, uncertain, missing and rejected results for real-bon UAT', async () => {
    const wrapper = mount(BudgetGroceryOptimizerPage)
    await flushPromises()
    expect(wrapper.text()).toContain('Produktfilter')
    expect(wrapper.text()).toContain('mit akzeptiertem Mapping')
    expect(wrapper.text()).toContain('ohne Mapping')
    ;(wrapper.vm as any).productFilter = 'accepted'
    await flushPromises()
    expect(wrapper.text()).toContain('Coop Milch')
    expect(wrapper.text()).not.toContain('Reis 1kg')
    ;(wrapper.vm as any).productFilter = 'without_mapping'
    await flushPromises()
    expect(wrapper.text()).toContain('Reis 1kg')
    expect(wrapper.text()).not.toContain('Coop Milch')
    await (wrapper.vm as any).startAnalysis()
    await flushPromises()
    expect(wrapper.text()).toContain('Sichere Ersparnis')
    expect(wrapper.text()).toContain('Unsichere Vorschläge')
    expect(wrapper.text()).toContain('Nicht gefundene Produkte')
    expect(wrapper.text()).toContain('Abgelehnte Produkte')
    expect(wrapper.text()).toContain('Mapping-Liste anzeigen')
    expect(wrapper.text()).toContain('Alle Preise aktualisieren')
    expect(wrapper.text()).toContain('Grocery Health & Trigger Analysis v1')
  })

  it('resets included item ids when switching receipts before analysis', async () => {

    expect(wrapper.text()).toContain('Runtime-only Report')
    expect(wrapper.text()).not.toContain('Histaminbewertung')
  })

  it('resets included item ids when switching receipts before analysis', async () => {


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetGroceryOptimizerPage.test.ts
@@ -40,6 +40,31 @@
     expect(wrapper.text()).not.toContain('Histaminbewertung')
   })

+  it('filters products and separates safe, uncertain, missing and rejected results for real-bon UAT', async () => {
+    const wrapper = mount(BudgetGroceryOptimizerPage)
+    await flushPromises()
+    expect(wrapper.text()).toContain('Produktfilter')
+    expect(wrapper.text()).toContain('mit akzeptiertem Mapping')
+    expect(wrapper.text()).toContain('ohne Mapping')
+    ;(wrapper.vm as any).productFilter = 'accepted'
+    await flushPromises()
+    expect(wrapper.text()).toContain('Coop Milch')
+    expect(wrapper.text()).not.toContain('Reis 1kg')
+    ;(wrapper.vm as any).productFilter = 'without_mapping'
+    await flushPromises()
+    expect(wrapper.text()).toContain('Reis 1kg')
+    expect(wrapper.text()).not.toContain('Coop Milch')
+    await (wrapper.vm as any).startAnalysis()
+    await flushPromises()
+    expect(wrapper.text()).toContain('Sichere Ersparnis')
+    expect(wrapper.text()).toContain('Unsichere Vorschläge')
+    expect(wrapper.text()).toContain('Nicht gefundene Produkte')
+    expect(wrapper.text()).toContain('Abgelehnte Produkte')
+    expect(wrapper.text()).toContain('Mapping-Liste anzeigen')
+    expect(wrapper.text()).toContain('Alle Preise aktualisieren')
+    expect(wrapper.text()).toContain('Grocery Health & Trigger Analysis v1')
+  })
+
   it('resets included item ids when switching receipts before analysis', async () => {
     const wrapper = mount(BudgetGroceryOptimizerPage)
     await flushPromises()


[Your active task list was preserved across context compression]
- [>] grv3-0. Release-Preflight: Branch/Remote/Main/Working Tree/Merge-Konflikt-Risiko/Tests/Git-Safety prüfen (in_progress)
- [ ] grv3-1. V2 nach main mergen, v0.3.2 taggen, main/tag pushen, Dashboard aus main neu starten (pending)
- [ ] grv3-2. Kleinen Real-Bon-UAT mit 5–10 Produkten durchführen; keine Preise/Produktdetails im Chat/Git (pending)
- [ ] grv3-3. Mapping-Qualität aggregiert bewerten und Mapping-Library Reuse/Reject/Manual/Refresh prüfen (pending)
- [ ] grv3-4. Provider-Härtung kontrolliert bewerten/dokumentieren ohne Massenscrape (pending)
- [ ] grv3-5. UI/Report v3 Ergänzungen: Produktfilter, sichere/unsichere Trennung, Mapping-Liste/Preisrefresh/Report-Hinweise (pending)
- [ ] grv3-6. Finale Tests/Build/Scans/Git-Safety/Browser-Sanity/Commit/Push/Abschlussbericht (pending)


Ich fahre fort. Stand: Preflight war grün, v2 ist bereits konfliktfrei nach `main` gemergt; ich aktualisiere gleich die Todo-Stati und implementiere die v3-UI/Report-Härtung gegen die gerade geschriebenen RED-Tests.

Explanation:
**Updating tasks and using tools**

I need to continue by updating the statuses of to-dos after merging. I think I should definitely utilize the skill that I've already loaded. Then, I'll inspect the files and implement tests while maintaining focus to avoid getting overwhelmed. It feels important to keep things manageable, so I definitely want to make sure I'm using the appropriate tools effectively as I go through this process. Let’s keep things streamlined!

todo

Release-Preflight: Branch/Remote/Main/Working Tree/Merge-Konflikt-Risiko/Tests/Git-Safety prüfen

V2 nach main mergen, v0.3.2 taggen, main/tag pushen, Dashboard aus main neu starten

UI/Report v3 Ergänzungen: Produktfilter, sichere/unsichere Trennung, Mapping-Liste/Preisrefresh/Report-Hinweise


Kleinen Real-Bon-UAT mit 5–10 Produkten durchführen; keine Preise/Produktdetails im Chat/Git

Mapping-Qualität aggregiert bewerten und Mapping-Library Reuse/Reject/Manual/Refresh prüfen

Provider-Härtung kontrolliert bewerten/dokumentieren ohne Massenscrape

Finale Tests/Build/Scans/Git-Safety/Browser-Sanity/Commit/Push/Abschlussbericht


terminal

cd /home/agent/.hermes/repos/FinanceManager && npm test -- --run src/pages/BudgetGroceryOptimizerPage.test.ts


Output:
npm error code ENOENT
npm error syscall open
npm error path /home/agent/.hermes/repos/FinanceManager/package.json
npm error errno -2
npm error enoent Could not read package.json: Error: ENOENT: no such file or directory, open '/home/agent/.hermes/repos/FinanceManager/package.json'
npm error enoent This is related to npm not being able to find a file.
npm error enoent
npm error A complete log of this run can be found in: /home/agent/.npm/_logs/2026-05-19T15_10_33_959Z-debug-0.log


Kleiner Pfad-Patzer: `npm` lief im Repo-Root statt im Frontend. Ich wiederhole fokussiert korrekt im Frontend.

Explanation:
**Rethinking command usage**

I used "cd root" instead of the "frontend" workdir, which probably created confusion. I think I need to rerun my commands with the correct directory in mind. It's one of those small mistakes that can trip me up, but it's a learning moment! I really want to get this right, so I’ll make sure to double-check my commands next time before running them. Let's dive back in and get this sorted!

npm test -- --run src/pages/BudgetGroceryOptimizerPage.test.ts


Output:
> jarvis-finance-dashboard-vue@0.0.0 test
> vitest run --environment jsdom --run src/pages/BudgetGroceryOptimizerPage.test.ts

 RUN  v4.1.6 /home/agent/.hermes/repos/FinanceManager/frontend

 ❯ src/pages/BudgetGroceryOptimizerPage.test.ts (3 tests | 2 failed) 215ms
     × renders receipt selection product rows optimization and runtime report without medical claims 118ms
     × filters products and separates safe, uncertain, missing and rejected results for real-bon UAT 52ms
     ✓ resets included item ids when switching receipts before analysis 43ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/pages/BudgetGroceryOptimizerPage.test.ts > BudgetGroceryOptimizerPage > renders receipt selection product rows optimization and runtime report without medical claims
AssertionError: expected "vi.fn()" to be called with arguments: [ { receipt_id: 'receipt_1', …(3) } ]

Received:

  1st vi.fn() call:

@@ -1,10 +1,12 @@
  [
    {
      "included_product_item_ids": [
        "item_1",
        "item_2",
+       "item_4",
+       "item_5",
      ],
      "max_store_count": 3,
      "receipt_id": "receipt_1",
      "selected_retailers": [
        "Coop",

Number of calls: 1

 ❯ src/pages/BudgetGroceryOptimizerPage.test.ts:33:36

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  src/pages/BudgetGroceryOptimizerPage.test.ts > BudgetGroceryOptimizerPage > filters products and separates safe, uncertain, missing and rejected results for real-bon UAT
AssertionError: expected 'Budget · Migros OptimizerMigros Produ…' to contain 'Produktfilter'

Expected: "Produktfilter"
Received: "Budget · Migros OptimizerMigros Product Savings & Grocery OptimizerProduktebene für Essen & Haushalt: Migros-Bon auswählen, Artikel normalisieren, reviewbare Alternativen bei Schweizer Grossisten vergleichen und Einkaufslisten bis maximal 3 Läden erstellen. Keine medizinische Bewertung, keine Bestellung.AktualisierenDemo-Bon anlegenPreise online suchenAnalyse startenReport erzeugenGesundheitsanalyse später: health_analysis_status ist vorbereitet, aber Histamin/Behçet/Trigger werden in v1 nicht bewertet.Bon auswählen2026-05-19 · Migros Test4 Artikel · Gesamtbetrag lokal sichtbarBon-ID: receipt_1…2026-05-20 · Migros Zwei1 Artikel · Gesamtbetrag lokal sichtbarBon-ID: receipt_2…ArtikellisteMilch 1LNormalisiert: milchMenge: 1L · Preis: 1.50 CHFGesundheitsanalyse späterBekannte MappingsCoop · Coop Milch · accepted · Cache: frisch Preis aktualisierenBio Eier 6 StückNormalisiert: eierMenge: 6 Stk · Preis: 4.20 CHFGesundheitsanalyse späterBekannte MappingsDenner · Denner Eier · needs_review · Cache: fehlt Preis aktualisierenReis 1kgNormalisiert: reisMenge: 1kg · Preis: 2.00 CHFGesundheitsanalyse späterJoghurtNormalisiert: joghurtMenge: 1 Stk · Preis: 3.00 CHFGesundheitsanalyse späterBekannte MappingsCoop · Falsches Joghurt · rejected · Cache: veraltet Preis aktualisierenOnline-Preissuche Coop Migros Aldi Lidl Denner Otto's Cache verwenden nur sichere MatchesProvider werden nur nach Klick auf „Preise online suchen“ aufgerufen. Keine Provider-Calls beim Seiten-Rendern.Manuell zuordnenFalls Websuche nichts findet: Händler, Produkt und Quelle manuell speichern. Keine medizinische Bewertung.CoopDennerAldi SuisseLidlOtto'smanual_matchexact_matchclose_matchcheaper_private_labelAls bevorzugte Alternative speichern"

 ❯ src/pages/BudgetGroceryOptimizerPage.test.ts:46:28
     44|     const wrapper = mount(BudgetGroceryOptimizerPage)
     45|     await flushPromises()
     46|     expect(wrapper.text()).toContain('Produktfilter')
       |                            ^
     47|     expect(wrapper.text()).toContain('mit akzeptiertem Mapping')
     48|     expect(wrapper.text()).toContain('ohne Mapping')

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯

 Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)
   Start at  17:10:39
   Duration  1.33s (transform 320ms, setup 132ms, import 315ms, tests 215ms, environment 558ms)


Explanation:
**Adjusting components and tests**

I need to patch a component and also adjust the old test — it seems like it should include all four elements, but the first fail is crucial. I have to implement this, so I think I'll likely rewrite that section. It looks like I might need to consider types, possibly checking if known_mappings already exist. I’ll read the API types to ensure everything aligns correctly.