feat: async product/receipt recognition via Kafka

Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
  and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
  RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
  instead of blocking; 4 new endpoints: GET /ai/product-jobs,
  /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
  brokers started in App.Start()

Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
  models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
  ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
  when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
  horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 23:01:30 +02:00
parent bffeb05a43
commit c7317c4335
43 changed files with 2073 additions and 239 deletions

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "تخطي اختيار المنتجات",
"planProductsSkipNoProducts": "التخطيط بدون منتجات",
"planProductsSelectAll": "تحديد الكل",
"planProductsDeselectAll": "إلغاء تحديد الكل"
"planProductsDeselectAll": "إلغاء تحديد الكل",
"recentScans": "عمليات المسح الأخيرة",
"seeAllScans": "عرض الكل",
"productJobHistoryTitle": "سجل المسح",
"jobTypeReceipt": "إيصال",
"jobTypeProducts": "منتجات",
"scanSubmitting": "جارٍ الإرسال...",
"processingProducts": "جارٍ المعالجة..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Produktauswahl überspringen",
"planProductsSkipNoProducts": "Ohne Produkte planen",
"planProductsSelectAll": "Alle auswählen",
"planProductsDeselectAll": "Alle abwählen"
"planProductsDeselectAll": "Alle abwählen",
"recentScans": "Letzte Scans",
"seeAllScans": "Alle",
"productJobHistoryTitle": "Scan-Verlauf",
"jobTypeReceipt": "Kassenbon",
"jobTypeProducts": "Produkte",
"scanSubmitting": "Wird gesendet...",
"processingProducts": "Verarbeitung..."
}

View File

@@ -28,7 +28,9 @@
"queuePosition": "Position {position}",
"@queuePosition": {
"placeholders": {
"position": { "type": "int" }
"position": {
"type": "int"
}
}
},
"processing": "Processing...",
@@ -116,7 +118,9 @@
"noResultsForQuery": "Nothing found for \"{query}\"",
"@noResultsForQuery": {
"placeholders": {
"query": { "type": "String" }
"query": {
"type": "String"
}
}
},
"servingsLabel": "Servings",
@@ -125,7 +129,9 @@
"planningForDate": "Planning for {date}",
"@planningForDate": {
"placeholders": {
"date": { "type": "String" }
"date": {
"type": "String"
}
}
},
"markAsEaten": "Mark as eaten",
@@ -134,7 +140,6 @@
"generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week",
"generatingMenu": "Generating menu...",
"dayPlannedLabel": "Day planned",
"planMenuButton": "Plan meals",
"planMenuTitle": "What to plan?",
"planOptionSingleMeal": "Single meal",
@@ -149,16 +154,23 @@
"planSelectMealType": "Meal type",
"planSelectRange": "Select period",
"planGenerateButton": "Plan",
"planGenerating": "Generating plan\u2026",
"planGenerating": "Generating plan",
"planSuccess": "Menu planned!",
"planProductsTitle": "Products for the menu",
"planProductsSubtitle": "AI will take the selected products into account when generating recipes",
"planProductsEmpty": "No products added",
"planProductsEmptyMessage": "Add products you have at home \u2014 AI will suggest recipes from what you already have",
"planProductsEmptyMessage": "Add products you have at home AI will suggest recipes from what you already have",
"planProductsAddProducts": "Add products",
"planProductsContinue": "Continue",
"planProductsSkip": "Skip product selection",
"planProductsSkipNoProducts": "Plan without products",
"planProductsSelectAll": "Select all",
"planProductsDeselectAll": "Deselect all"
"planProductsDeselectAll": "Deselect all",
"recentScans": "Recent scans",
"seeAllScans": "See all",
"productJobHistoryTitle": "Scan history",
"jobTypeReceipt": "Receipt",
"jobTypeProducts": "Products",
"scanSubmitting": "Submitting...",
"processingProducts": "Processing..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Omitir selección de productos",
"planProductsSkipNoProducts": "Planificar sin productos",
"planProductsSelectAll": "Seleccionar todo",
"planProductsDeselectAll": "Deseleccionar todo"
"planProductsDeselectAll": "Deseleccionar todo",
"recentScans": "Escaneos recientes",
"seeAllScans": "Ver todos",
"productJobHistoryTitle": "Historial de escaneos",
"jobTypeReceipt": "Ticket",
"jobTypeProducts": "Productos",
"scanSubmitting": "Enviando...",
"processingProducts": "Procesando..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Ignorer la sélection des produits",
"planProductsSkipNoProducts": "Planifier sans produits",
"planProductsSelectAll": "Tout sélectionner",
"planProductsDeselectAll": "Tout désélectionner"
"planProductsDeselectAll": "Tout désélectionner",
"recentScans": "Scans récents",
"seeAllScans": "Tout voir",
"productJobHistoryTitle": "Historique des scans",
"jobTypeReceipt": "Reçu",
"jobTypeProducts": "Produits",
"scanSubmitting": "Envoi...",
"processingProducts": "Traitement..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "उत्पाद चयन छोड़ें",
"planProductsSkipNoProducts": "उत्पादों के बिना योजना बनाएं",
"planProductsSelectAll": "सभी चुनें",
"planProductsDeselectAll": "सभी हटाएं"
"planProductsDeselectAll": "सभी हटाएं",
"recentScans": "हाल के स्कैन",
"seeAllScans": "सभी देखें",
"productJobHistoryTitle": "स्कैन इतिहास",
"jobTypeReceipt": "रसीद",
"jobTypeProducts": "उत्पाद",
"scanSubmitting": "सबमिट हो रहा है...",
"processingProducts": "प्रोसेस हो रहा है..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Salta la selezione dei prodotti",
"planProductsSkipNoProducts": "Pianifica senza prodotti",
"planProductsSelectAll": "Seleziona tutto",
"planProductsDeselectAll": "Deseleziona tutto"
"planProductsDeselectAll": "Deseleziona tutto",
"recentScans": "Scansioni recenti",
"seeAllScans": "Vedi tutto",
"productJobHistoryTitle": "Cronologia scansioni",
"jobTypeReceipt": "Scontrino",
"jobTypeProducts": "Prodotti",
"scanSubmitting": "Invio...",
"processingProducts": "Elaborazione..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "食材選択をスキップ",
"planProductsSkipNoProducts": "食材なしでプランニング",
"planProductsSelectAll": "すべて選択",
"planProductsDeselectAll": "すべて解除"
"planProductsDeselectAll": "すべて解除",
"recentScans": "最近のスキャン",
"seeAllScans": "すべて表示",
"productJobHistoryTitle": "スキャン履歴",
"jobTypeReceipt": "レシート",
"jobTypeProducts": "商品",
"scanSubmitting": "送信中...",
"processingProducts": "処理中..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "재료 선택 건너뛰기",
"planProductsSkipNoProducts": "재료 없이 계획하기",
"planProductsSelectAll": "모두 선택",
"planProductsDeselectAll": "모두 해제"
"planProductsDeselectAll": "모두 해제",
"recentScans": "최근 스캔",
"seeAllScans": "전체 보기",
"productJobHistoryTitle": "스캔 기록",
"jobTypeReceipt": "영수증",
"jobTypeProducts": "제품",
"scanSubmitting": "제출 중...",
"processingProducts": "처리 중..."
}

View File

@@ -987,6 +987,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Deselect all'**
String get planProductsDeselectAll;
/// No description provided for @recentScans.
///
/// In en, this message translates to:
/// **'Recent scans'**
String get recentScans;
/// No description provided for @seeAllScans.
///
/// In en, this message translates to:
/// **'See all'**
String get seeAllScans;
/// No description provided for @productJobHistoryTitle.
///
/// In en, this message translates to:
/// **'Scan history'**
String get productJobHistoryTitle;
/// No description provided for @jobTypeReceipt.
///
/// In en, this message translates to:
/// **'Receipt'**
String get jobTypeReceipt;
/// No description provided for @jobTypeProducts.
///
/// In en, this message translates to:
/// **'Products'**
String get jobTypeProducts;
/// No description provided for @scanSubmitting.
///
/// In en, this message translates to:
/// **'Submitting...'**
String get scanSubmitting;
/// No description provided for @processingProducts.
///
/// In en, this message translates to:
/// **'Processing...'**
String get processingProducts;
}
class _AppLocalizationsDelegate

View File

@@ -452,4 +452,25 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get planProductsDeselectAll => 'إلغاء تحديد الكل';
@override
String get recentScans => 'عمليات المسح الأخيرة';
@override
String get seeAllScans => 'عرض الكل';
@override
String get productJobHistoryTitle => 'سجل المسح';
@override
String get jobTypeReceipt => 'إيصال';
@override
String get jobTypeProducts => 'منتجات';
@override
String get scanSubmitting => 'جارٍ الإرسال...';
@override
String get processingProducts => 'جارٍ المعالجة...';
}

View File

@@ -454,4 +454,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Alle abwählen';
@override
String get recentScans => 'Letzte Scans';
@override
String get seeAllScans => 'Alle';
@override
String get productJobHistoryTitle => 'Scan-Verlauf';
@override
String get jobTypeReceipt => 'Kassenbon';
@override
String get jobTypeProducts => 'Produkte';
@override
String get scanSubmitting => 'Wird gesendet...';
@override
String get processingProducts => 'Verarbeitung...';
}

View File

@@ -452,4 +452,25 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Deselect all';
@override
String get recentScans => 'Recent scans';
@override
String get seeAllScans => 'See all';
@override
String get productJobHistoryTitle => 'Scan history';
@override
String get jobTypeReceipt => 'Receipt';
@override
String get jobTypeProducts => 'Products';
@override
String get scanSubmitting => 'Submitting...';
@override
String get processingProducts => 'Processing...';
}

View File

@@ -454,4 +454,25 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Deseleccionar todo';
@override
String get recentScans => 'Escaneos recientes';
@override
String get seeAllScans => 'Ver todos';
@override
String get productJobHistoryTitle => 'Historial de escaneos';
@override
String get jobTypeReceipt => 'Ticket';
@override
String get jobTypeProducts => 'Productos';
@override
String get scanSubmitting => 'Enviando...';
@override
String get processingProducts => 'Procesando...';
}

View File

@@ -455,4 +455,25 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Tout désélectionner';
@override
String get recentScans => 'Scans récents';
@override
String get seeAllScans => 'Tout voir';
@override
String get productJobHistoryTitle => 'Historique des scans';
@override
String get jobTypeReceipt => 'Reçu';
@override
String get jobTypeProducts => 'Produits';
@override
String get scanSubmitting => 'Envoi...';
@override
String get processingProducts => 'Traitement...';
}

View File

@@ -453,4 +453,25 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get planProductsDeselectAll => 'सभी हटाएं';
@override
String get recentScans => 'हाल के स्कैन';
@override
String get seeAllScans => 'सभी देखें';
@override
String get productJobHistoryTitle => 'स्कैन इतिहास';
@override
String get jobTypeReceipt => 'रसीद';
@override
String get jobTypeProducts => 'उत्पाद';
@override
String get scanSubmitting => 'सबमिट हो रहा है...';
@override
String get processingProducts => 'प्रोसेस हो रहा है...';
}

View File

@@ -454,4 +454,25 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Deseleziona tutto';
@override
String get recentScans => 'Scansioni recenti';
@override
String get seeAllScans => 'Vedi tutto';
@override
String get productJobHistoryTitle => 'Cronologia scansioni';
@override
String get jobTypeReceipt => 'Scontrino';
@override
String get jobTypeProducts => 'Prodotti';
@override
String get scanSubmitting => 'Invio...';
@override
String get processingProducts => 'Elaborazione...';
}

View File

@@ -449,4 +449,25 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get planProductsDeselectAll => 'すべて解除';
@override
String get recentScans => '最近のスキャン';
@override
String get seeAllScans => 'すべて表示';
@override
String get productJobHistoryTitle => 'スキャン履歴';
@override
String get jobTypeReceipt => 'レシート';
@override
String get jobTypeProducts => '商品';
@override
String get scanSubmitting => '送信中...';
@override
String get processingProducts => '処理中...';
}

View File

@@ -449,4 +449,25 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get planProductsDeselectAll => '모두 해제';
@override
String get recentScans => '최근 스캔';
@override
String get seeAllScans => '전체 보기';
@override
String get productJobHistoryTitle => '스캔 기록';
@override
String get jobTypeReceipt => '영수증';
@override
String get jobTypeProducts => '제품';
@override
String get scanSubmitting => '제출 중...';
@override
String get processingProducts => '처리 중...';
}

View File

@@ -454,4 +454,25 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Desmarcar tudo';
@override
String get recentScans => 'Scans recentes';
@override
String get seeAllScans => 'Ver tudo';
@override
String get productJobHistoryTitle => 'Histórico de scans';
@override
String get jobTypeReceipt => 'Recibo';
@override
String get jobTypeProducts => 'Produtos';
@override
String get scanSubmitting => 'Enviando...';
@override
String get processingProducts => 'Processando...';
}

View File

@@ -452,4 +452,25 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get planProductsDeselectAll => 'Снять всё';
@override
String get recentScans => 'Последние сканирования';
@override
String get seeAllScans => 'Все';
@override
String get productJobHistoryTitle => 'История сканирования';
@override
String get jobTypeReceipt => 'Чек';
@override
String get jobTypeProducts => 'Продукты';
@override
String get scanSubmitting => 'Отправка...';
@override
String get processingProducts => 'Обработка...';
}

View File

@@ -448,4 +448,25 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get planProductsDeselectAll => '取消全选';
@override
String get recentScans => '最近扫描';
@override
String get seeAllScans => '全部';
@override
String get productJobHistoryTitle => '扫描历史';
@override
String get jobTypeReceipt => '收据';
@override
String get jobTypeProducts => '产品';
@override
String get scanSubmitting => '提交中...';
@override
String get processingProducts => '处理中...';
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Pular seleção de produtos",
"planProductsSkipNoProducts": "Planejar sem produtos",
"planProductsSelectAll": "Selecionar tudo",
"planProductsDeselectAll": "Desmarcar tudo"
"planProductsDeselectAll": "Desmarcar tudo",
"recentScans": "Scans recentes",
"seeAllScans": "Ver tudo",
"productJobHistoryTitle": "Histórico de scans",
"jobTypeReceipt": "Recibo",
"jobTypeProducts": "Produtos",
"scanSubmitting": "Enviando...",
"processingProducts": "Processando..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "Пропустить выбор продуктов",
"planProductsSkipNoProducts": "Планировать без продуктов",
"planProductsSelectAll": "Выбрать все",
"planProductsDeselectAll": "Снять всё"
"planProductsDeselectAll": "Снять всё",
"recentScans": "Последние сканирования",
"seeAllScans": "Все",
"productJobHistoryTitle": "История сканирования",
"jobTypeReceipt": "Чек",
"jobTypeProducts": "Продукты",
"scanSubmitting": "Отправка...",
"processingProducts": "Обработка..."
}

View File

@@ -165,5 +165,12 @@
"planProductsSkip": "跳过食材选择",
"planProductsSkipNoProducts": "不选食材直接规划",
"planProductsSelectAll": "全选",
"planProductsDeselectAll": "取消全选"
"planProductsDeselectAll": "取消全选",
"recentScans": "最近扫描",
"seeAllScans": "全部",
"productJobHistoryTitle": "扫描历史",
"jobTypeReceipt": "收据",
"jobTypeProducts": "产品",
"scanSubmitting": "提交中...",
"processingProducts": "处理中..."
}