Fix parse pipeline

This commit is contained in:
2026-06-01 18:04:04 +03:00
parent 6358c3e71b
commit bc3acb68e6
4 changed files with 381 additions and 335 deletions
@@ -11,7 +11,6 @@ import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../notification_parsing/application/parsing_worker.dart';
import '../../../settings/application/settings_controller.dart';
import '../../../transactions/domain/entities/transaction.dart';
import '../../../user/application/active_user_controller.dart';
@@ -60,8 +59,6 @@ class _HomeContent extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final locale = Localizations.localeOf(context).toString();
// Активируем ParsingWorker (keepAlive): слушает raw_messages.pending.
ref.watch(parsingWorkerProvider(userId));
final categories =
ref.watch(categoriesStreamProvider(userId)).value ??
const <Category>[];
@@ -0,0 +1,359 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transactions_controller.dart';
import '../data/openrouter/openrouter_client.dart';
import '../data/parser/account_resolver.dart';
import '../data/parser/ai_parser.dart';
import '../data/parser/confidence_scorer.dart';
import '../data/parser/decision_gate.dart';
import '../data/parser/draft_codec.dart';
import '../data/parser/rule_lookup.dart';
import '../data/parser/rule_suggester.dart';
import '../domain/entities/parse_draft.dart';
import '../domain/entities/raw_message.dart';
import '../domain/entities/rule_candidate.dart';
import '../domain/entities/rule_suggestion.dart';
import '../domain/enums.dart';
import 'ai_providers.dart';
import 'notification_parsing_providers.dart';
import 'parsing_settings_controller.dart';
part 'parsing_pipeline.g.dart';
/// Точка внедрения pipeline (для воркера, тестов и будущего фонового драйвера).
@Riverpod(keepAlive: true)
ParsingPipeline parsingPipeline(Ref ref) => ParsingPipeline(ref);
/// Per-message обработка одного `raw_message` (§5, шаги 38): allowlist →
/// AI-извлечение → resolver → rule_lookup → suggester → scorer → gate.
///
/// Чистая логика «что делать», не знает, кто её запустил: `ParsingWorker`
/// (foreground), будущий фоновый изолят/WorkManager или тесты. Зависимости
/// читаются через [Ref] из текущего контейнера — провайдеры не UI-завязаны,
/// поэтому pipeline работает в любом изоляте со своим `ProviderContainer`.
///
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
/// в Inbox на ручной разбор.
class ParsingPipeline {
ParsingPipeline(this._ref);
final Ref _ref;
Future<void> process(String userId, RawMessage msg) async {
final settings = await _ref.read(parsingSettingsControllerProvider.future);
if (!settings.enabled) return; // фича выключена — оставляем pending.
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
// ДО AI, чтобы не тратить токены на посторонние пакеты.
final enabled =
await _ref.read(enabledSourcePackagesProvider(userId).future);
if (!enabled.contains(msg.packageName)) {
await _ref
.read(rawMessagesRepositoryProvider)
.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings);
}
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
/// иначе Inbox на ручной разбор.
Future<void> _extractViaAi(
String userId,
RawMessage msg,
ParsingSettings settings,
) async {
final repo = _ref.read(rawMessagesRepositoryProvider);
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
if (looksLikeNonTransaction(msg.body)) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Есть цифры → кандидат в транзакцию.
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
// состояние контроллера могло устареть при смене суток.
final aiAllowed =
settings.aiConsentGiven && !(await settingsCtrl.isDailyLimitReached());
final aiParser =
aiAllowed ? await _ref.read(aiParserProvider.future) : null;
if (aiParser == null) {
// AI недоступен → ручной разбор в Inbox.
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
return;
}
// Слишком много РЕАЛЬНЫХ попыток AI — сдаёмся (§7). Счётчик инкрементится
// только перед фактическим вызовом модели (ниже), офлайн-ожидание не в счёт.
if (msg.parseAttemptCount >= 5) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: 'AI retry limit reached',
);
return;
}
// Offline → отложим до восстановления сети (без расхода попытки).
final online = _ref.read(isOnlineProvider).value ?? true;
if (!online) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.pendingAi,
lastParseError: 'offline',
);
return;
}
// Реальная попытка обращения к модели — учитываем в счётчике попыток.
await repo.incrementParseAttempts(msg.id);
try {
final categories = await _ref
.read(categoryRepositoryProvider)
.watchByUser(userId)
.first;
final outcome = await aiParser.parse(
msg: msg,
model: settings.aiModel,
categoryNames: categories.map((c) => c.name).toList(),
);
if (outcome.tokensUsed > 0) {
await _ref
.read(parsingSettingsControllerProvider.notifier)
.addTokenUsage(outcome.tokensUsed);
}
switch (outcome.status) {
case AiParseStatus.ignored:
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
case AiParseStatus.partial:
// Недостаточно данных → Inbox с сырым текстом (без draft).
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.parsedPartial,
);
case AiParseStatus.draft:
await _runPipeline(userId, msg, outcome.draft!, settings,
categories: categories);
}
} on OpenRouterNetworkException {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.pendingAi,
lastParseError: 'network',
);
} on OpenRouterAuthException catch (e) {
// Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на
// каждом последующем сообщении. Пользователь введёт корректный ключ
// заново на экране согласия (это снова включит AI).
await settingsCtrl.setAiConsent(false);
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: 'AI auth failed (${e.statusCode})',
);
} on OpenRouterException catch (e) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: e.message,
);
}
}
/// Общий хвост pipeline (§5, шаги 38) для AI-draft.
Future<void> _runPipeline(
String userId,
RawMessage msg,
ParseDraft draft0,
ParsingSettings settings, {
List<Category>? categories,
}) async {
final repo = _ref.read(rawMessagesRepositoryProvider);
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
final rules =
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
null) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
final globalDefaultAccountId = (await _ref
.read(accountRepositoryProvider)
.watchDefault(userId)
.first)
?.id;
final resolver =
AccountResolver(_ref.read(accountBindingsRepositoryProvider));
final resolution = await resolver.resolve(
userId: userId,
packageName: msg.packageName,
body: msg.body,
cardLast4: draft0.cardLast4,
phone: draft0.counterpartyPhone,
merchantRaw: draft0.merchantRaw,
senderRules: rules,
globalDefaultAccountId: globalDefaultAccountId,
);
var draft = draft0.copyWith(accountId: resolution.accountId);
final rule =
findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw);
RuleCandidate? candidate;
if (draft.merchantRaw != null) {
candidate =
await _ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
userId,
ParseRuleKind.merchantToCategory,
draft.merchantRaw!,
);
}
// Применяем действие правила к draft.
if (rule != null) {
draft = draft.copyWith(
merchantCanonical: rule.merchantCanonical,
categoryId: rule.categoryId,
accountId: draft.accountId ?? rule.accountId,
);
}
// Предложение правила для Inbox (только для незнакомого мерчанта).
RuleSuggestion? suggestion;
if (rule == null && draft.merchantRaw != null) {
// AI-подсказка категории: матчим имя на существующую категорию (§7).
String? aiCategoryId;
String? aiCategoryName;
if (candidate == null && draft.categorySuggestion != null) {
final cats = categories ??
await _ref
.read(categoryRepositoryProvider)
.watchByUser(userId)
.first;
final match = _matchCategoryByName(cats, draft.categorySuggestion!);
if (match != null) {
aiCategoryId = match.id;
aiCategoryName = match.name;
}
}
suggestion = suggestRule(
merchantRaw: draft.merchantRaw!,
categoryCandidate: candidate,
aiCategoryId: aiCategoryId,
aiCategoryName: aiCategoryName,
);
// Имя категории для случая кандидата (когда AI-имя не подставлено).
if (suggestion.categoryId != null && suggestion.categoryName == null) {
final cat = await _ref
.read(categoryRepositoryProvider)
.findById(suggestion.categoryId!);
suggestion = suggestion.copyWith(categoryName: cat?.name);
}
}
// 6. Confidence.
final scores = scoreDraft(
draft: draft,
message: msg,
accountScore: resolution.score,
merchantRule: rule,
merchantCandidate: candidate,
categoryCandidate: candidate,
);
// 8. Gate.
final decision = decide(
merchantRule: rule,
sanityPassed: true,
scores: scores,
strictness: settings.strictness,
amountMinor: draft.amount,
accountResolved: draft.accountId != null,
accountTrusted: resolution.trusted,
);
if (decision == GateDecision.autoApply) {
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
} else {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.inbox,
draftJson: encodeDraftBundle(draft, suggestion),
confidenceAmount: scores.amount,
confidenceAccount: scores.account,
confidenceType: scores.type,
confidenceMerchant: scores.merchant,
confidenceCategory: scores.category,
);
}
}
Category? _matchCategoryByName(List<Category> cats, String name) {
final target = name.trim().toLowerCase();
for (final c in cats) {
if (c.name.trim().toLowerCase() == target) return c;
}
return null;
}
Future<void> _autoApply(
String userId,
RawMessage msg,
ParseDraft draft,
rule,
FieldScores scores,
String? bindingId,
) async {
final repo = _ref.read(rawMessagesRepositoryProvider);
final tx = await _ref
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: userId,
accountId: draft.accountId!,
categoryId: draft.categoryId,
type: draft.type,
amount: draft.amount,
date: draft.dateTime ?? msg.receivedAt,
merchant: draft.merchantCanonical ?? draft.merchantRaw,
rawMessageId: msg.id,
autoApplied: true,
appliedByRuleId: rule.id,
);
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.applied,
draftJson: encodeDraftBundle(draft, null),
confidenceAmount: scores.amount,
confidenceAccount: scores.account,
confidenceType: scores.type,
confidenceMerchant: scores.merchant,
confidenceCategory: scores.category,
);
await repo.linkTransaction(msg.id, tx.id);
await _ref
.read(parseRulesRepositoryProvider)
.incrementMatchCount(rule.id, DateTime.now());
if (bindingId != null) {
await _ref
.read(accountBindingsRepositoryProvider)
.incrementMatchCount(bindingId);
}
}
}
@@ -1,25 +1,10 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transactions_controller.dart';
import '../data/openrouter/openrouter_client.dart';
import '../data/parser/account_resolver.dart';
import '../data/parser/ai_parser.dart';
import '../data/parser/confidence_scorer.dart';
import '../data/parser/decision_gate.dart';
import '../data/parser/draft_codec.dart';
import '../data/parser/rule_lookup.dart';
import '../data/parser/rule_suggester.dart';
import '../domain/entities/parse_draft.dart';
import '../domain/entities/raw_message.dart';
import '../domain/entities/rule_candidate.dart';
import '../domain/entities/rule_suggestion.dart';
import '../domain/enums.dart';
import 'ai_providers.dart';
import 'notification_parsing_providers.dart';
import 'parsing_settings_controller.dart';
import 'parsing_pipeline.dart';
part 'parsing_worker.g.dart';
@@ -28,12 +13,11 @@ part 'parsing_worker.g.dart';
Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline
/// AI → resolver → rule_lookup → suggester → scorer → gate.
///
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
/// в Inbox на ручной разбор.
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
/// «что делать» вынесена в [ParsingPipeline], чтобы фоновый драйвер мог лечь
/// поверх того же кода без дублирования.
///
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
@@ -84,12 +68,13 @@ class ParsingWorker extends _$ParsingWorker {
}
Future<void> _drain(String userId, List<RawMessage> pending) async {
final pipeline = ref.read(parsingPipelineProvider);
for (final msg in pending) {
if (_inFlight.contains(msg.id)) continue;
_inFlight.add(msg.id);
final repo = ref.read(rawMessagesRepositoryProvider);
try {
await _process(userId, msg);
await pipeline.process(userId, msg);
} catch (e) {
await repo.updateAfterParse(
id: msg.id,
@@ -101,311 +86,4 @@ class ParsingWorker extends _$ParsingWorker {
}
}
}
Future<void> _process(String userId, RawMessage msg) async {
final settings = await ref.read(parsingSettingsControllerProvider.future);
if (!settings.enabled) return; // фича выключена — оставляем pending.
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
// ДО AI, чтобы не тратить токены на посторонние пакеты.
final enabled =
await ref.read(enabledSourcePackagesProvider(userId).future);
if (!enabled.contains(msg.packageName)) {
await ref
.read(rawMessagesRepositoryProvider)
.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings);
}
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
/// иначе Inbox на ручной разбор.
Future<void> _extractViaAi(
String userId,
RawMessage msg,
ParsingSettings settings,
) async {
final repo = ref.read(rawMessagesRepositoryProvider);
final settingsCtrl = ref.read(parsingSettingsControllerProvider.notifier);
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
if (looksLikeNonTransaction(msg.body)) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Есть цифры → кандидат в транзакцию.
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
// состояние контроллера могло устареть при смене суток.
final aiAllowed =
settings.aiConsentGiven && !(await settingsCtrl.isDailyLimitReached());
final aiParser = aiAllowed ? await ref.read(aiParserProvider.future) : null;
if (aiParser == null) {
// AI недоступен → ручной разбор в Inbox.
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
return;
}
// Слишком много РЕАЛЬНЫХ попыток AI — сдаёмся (§7). Счётчик инкрементится
// только перед фактическим вызовом модели (ниже), офлайн-ожидание не в счёт.
if (msg.parseAttemptCount >= 5) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: 'AI retry limit reached',
);
return;
}
// Offline → отложим до восстановления сети (без расхода попытки).
final online = ref.read(isOnlineProvider).value ?? true;
if (!online) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.pendingAi,
lastParseError: 'offline',
);
return;
}
// Реальная попытка обращения к модели — учитываем в счётчике попыток.
await repo.incrementParseAttempts(msg.id);
try {
final categories =
await ref.read(categoryRepositoryProvider).watchByUser(userId).first;
final outcome = await aiParser.parse(
msg: msg,
model: settings.aiModel,
categoryNames: categories.map((c) => c.name).toList(),
);
if (outcome.tokensUsed > 0) {
await ref
.read(parsingSettingsControllerProvider.notifier)
.addTokenUsage(outcome.tokensUsed);
}
switch (outcome.status) {
case AiParseStatus.ignored:
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
case AiParseStatus.partial:
// Недостаточно данных → Inbox с сырым текстом (без draft).
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.parsedPartial,
);
case AiParseStatus.draft:
await _runPipeline(userId, msg, outcome.draft!, settings,
categories: categories);
}
} on OpenRouterNetworkException {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.pendingAi,
lastParseError: 'network',
);
} on OpenRouterAuthException catch (e) {
// Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на
// каждом последующем сообщении. Пользователь введёт корректный ключ
// заново на экране согласия (это снова включит AI).
await settingsCtrl.setAiConsent(false);
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: 'AI auth failed (${e.statusCode})',
);
} on OpenRouterException catch (e) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
lastParseError: e.message,
);
}
}
/// Общий хвост pipeline (§5, шаги 38) для AI-draft.
Future<void> _runPipeline(
String userId,
RawMessage msg,
ParseDraft draft0,
ParsingSettings settings, {
List<Category>? categories,
}) async {
final repo = ref.read(rawMessagesRepositoryProvider);
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
final rules =
await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
null) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
final globalDefaultAccountId = (await ref
.read(accountRepositoryProvider)
.watchDefault(userId)
.first)
?.id;
final resolver =
AccountResolver(ref.read(accountBindingsRepositoryProvider));
final resolution = await resolver.resolve(
userId: userId,
packageName: msg.packageName,
body: msg.body,
cardLast4: draft0.cardLast4,
phone: draft0.counterpartyPhone,
merchantRaw: draft0.merchantRaw,
senderRules: rules,
globalDefaultAccountId: globalDefaultAccountId,
);
var draft = draft0.copyWith(accountId: resolution.accountId);
final rule =
findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw);
RuleCandidate? candidate;
if (draft.merchantRaw != null) {
candidate =
await ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
userId,
ParseRuleKind.merchantToCategory,
draft.merchantRaw!,
);
}
// Применяем действие правила к draft.
if (rule != null) {
draft = draft.copyWith(
merchantCanonical: rule.merchantCanonical,
categoryId: rule.categoryId,
accountId: draft.accountId ?? rule.accountId,
);
}
// Предложение правила для Inbox (только для незнакомого мерчанта).
RuleSuggestion? suggestion;
if (rule == null && draft.merchantRaw != null) {
// AI-подсказка категории: матчим имя на существующую категорию (§7).
String? aiCategoryId;
String? aiCategoryName;
if (candidate == null && draft.categorySuggestion != null) {
final cats = categories ??
await ref.read(categoryRepositoryProvider).watchByUser(userId).first;
final match = _matchCategoryByName(cats, draft.categorySuggestion!);
if (match != null) {
aiCategoryId = match.id;
aiCategoryName = match.name;
}
}
suggestion = suggestRule(
merchantRaw: draft.merchantRaw!,
categoryCandidate: candidate,
aiCategoryId: aiCategoryId,
aiCategoryName: aiCategoryName,
);
// Имя категории для случая кандидата (когда AI-имя не подставлено).
if (suggestion.categoryId != null && suggestion.categoryName == null) {
final cat = await ref
.read(categoryRepositoryProvider)
.findById(suggestion.categoryId!);
suggestion = suggestion.copyWith(categoryName: cat?.name);
}
}
// 6. Confidence.
final scores = scoreDraft(
draft: draft,
message: msg,
accountScore: resolution.score,
merchantRule: rule,
merchantCandidate: candidate,
categoryCandidate: candidate,
);
// 8. Gate.
final decision = decide(
merchantRule: rule,
sanityPassed: true,
scores: scores,
strictness: settings.strictness,
amountMinor: draft.amount,
accountResolved: draft.accountId != null,
accountTrusted: resolution.trusted,
);
if (decision == GateDecision.autoApply) {
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
} else {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.inbox,
draftJson: encodeDraftBundle(draft, suggestion),
confidenceAmount: scores.amount,
confidenceAccount: scores.account,
confidenceType: scores.type,
confidenceMerchant: scores.merchant,
confidenceCategory: scores.category,
);
}
}
Category? _matchCategoryByName(List<Category> cats, String name) {
final target = name.trim().toLowerCase();
for (final c in cats) {
if (c.name.trim().toLowerCase() == target) return c;
}
return null;
}
Future<void> _autoApply(
String userId,
RawMessage msg,
ParseDraft draft,
rule,
FieldScores scores,
String? bindingId,
) async {
final repo = ref.read(rawMessagesRepositoryProvider);
final tx =
await ref.read(transactionsControllerProvider.notifier).createTransaction(
userId: userId,
accountId: draft.accountId!,
categoryId: draft.categoryId,
type: draft.type,
amount: draft.amount,
date: draft.dateTime ?? msg.receivedAt,
merchant: draft.merchantCanonical ?? draft.merchantRaw,
rawMessageId: msg.id,
autoApplied: true,
appliedByRuleId: rule.id,
);
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.applied,
draftJson: encodeDraftBundle(draft, null),
confidenceAmount: scores.amount,
confidenceAccount: scores.account,
confidenceType: scores.type,
confidenceMerchant: scores.merchant,
confidenceCategory: scores.category,
);
await repo.linkTransaction(msg.id, tx.id);
await ref
.read(parseRulesRepositoryProvider)
.incrementMatchCount(rule.id, DateTime.now());
if (bindingId != null) {
await ref
.read(accountBindingsRepositoryProvider)
.incrementMatchCount(bindingId);
}
}
}
+14 -2
View File
@@ -1,15 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/notification_parsing/application/parsing_worker.dart';
import '../../features/user/application/active_user_controller.dart';
import 'app_bottom_nav.dart';
class AppScaffold extends StatelessWidget {
class AppScaffold extends ConsumerWidget {
const AppScaffold({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// Активируем ParsingWorker (keepAlive) на уровне оболочки, чтобы очередь
// парсинга драйнилась на любой вкладке, пока приложение открыто, — а не
// только при заходе на «Главную». До появления активного пользователя
// (/onboarding вне shell) активировать нечего.
final userId = ref.watch(activeUserControllerProvider).value?.id;
if (userId != null) {
ref.watch(parsingWorkerProvider(userId));
}
return Scaffold(
body: navigationShell,
bottomNavigationBar: AppBottomNav(