From f2ed501ed4a0f9d9c9ab0eb9cd291e8e52edd3f0 Mon Sep 17 00:00:00 2001 From: Aleksandr Mineev Date: Sun, 19 Jul 2026 23:24:06 +0300 Subject: [PATCH] Unify parse rules: one rule = condition + actions, autoApply toggle, v3 migration - Drop ParseRuleKind from parse_rules (enum survives only in rule_candidates): a rule is now a condition (pattern + matchMode + optional txTypeGuard) plus any set of actions (merchantCanonical/categoryId/accountId) or isIgnore - Field-wise resolution: findClassificationRule fills merchant/category, findAccountRule feeds the account resolver - one message may use both - Per-rule autoApply toggle + ruleAutoApplyEnabled gate check; off -> Inbox with full prefill - Dedup guard in ParseRulesRepositoryImpl.create: same condition updates or reactivates the existing row instead of inserting a duplicate - ParsingPipeline.reapplyRulesToInbox: sweep app inbox cards on cached AI drafts after createRule/ignoreWithRule (zero tokens) - Schema v2 -> v3 migration (drop kind, add auto_apply) + migration_v3_test - Rework rule_editor_screen into a single unified form; update rules list, rule cards, source app detail, l10n strings, and tests Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 62 +++- lib/l10n/app_en.arb | 13 +- lib/l10n/app_localizations.dart | 66 +++- lib/l10n/app_localizations_en.dart | 35 +- lib/l10n/app_localizations_ru.dart | 36 +- lib/l10n/app_ru.arb | 13 +- lib/src/core/database/app_database.dart | 22 +- .../application/inbox_controller.dart | 29 +- .../application/parsing_pipeline.dart | 58 ++- .../application/rules_controller.dart | 9 +- .../data/drift/tables/parse_rules_table.dart | 21 +- .../data/mappers/parse_rule_mapper.dart | 5 +- .../data/parser/account_resolver.dart | 20 +- .../data/parser/decision_gate.dart | 17 +- .../data/parser/rule_lookup.dart | 26 +- .../parse_rules_repository_impl.dart | 58 ++- .../domain/entities/parse_rule.dart | 32 +- .../notification_parsing/domain/enums.dart | 4 +- .../repositories/parse_rules_repository.dart | 13 +- .../screens/rule_editor_screen.dart | 199 ++++++---- .../screens/rules_list_screen.dart | 13 +- .../screens/source_app_detail_screen.dart | 27 +- .../widgets/gate_check_labels.dart | 2 + .../presentation/widgets/inbox_card.dart | 1 + .../presentation/widgets/rule_card.dart | 42 ++- test/app/router/inbox_tab_test.dart | 24 +- test/core/database/migration_v2_test.dart | 11 +- test/core/database/migration_v3_test.dart | 119 ++++++ .../application/inbox_controller_test.dart | 111 +++++- .../application/inbox_resweep_test.dart | 346 ++++++++++++++++++ .../parsing_pipeline_per_app_test.dart | 5 +- .../parsing_worker_allowlist_test.dart | 8 +- .../application/rule_match_preview_test.dart | 180 +++++++++ .../data/parse_rules_dedup_test.dart | 210 +++++++++++ .../parser/account_resolver_test.dart | 20 +- .../parser/confidence_scorer_test.dart | 1 - .../parser/decision_gate_test.dart | 42 ++- .../parser/rule_lookup_test.dart | 100 +++-- .../presentation/inbox_card_test.dart | 1 + 39 files changed, 1677 insertions(+), 324 deletions(-) create mode 100644 test/core/database/migration_v3_test.dart create mode 100644 test/features/notification_parsing/application/inbox_resweep_test.dart create mode 100644 test/features/notification_parsing/application/rule_match_preview_test.dart create mode 100644 test/features/notification_parsing/data/parse_rules_dedup_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 2eec4f4..34abc43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ lib/ theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative) theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now core/ - database/app_database.dart # @DriftDatabase, schemaVersion=2 (+onUpgrade v1→v2) + database/app_database.dart # @DriftDatabase, schemaVersion=3 (+onUpgrade v1→v2→v3) database/tables/ # users / app_preferences / settings / accounts / categories / transactions database/daos/ # *_dao.dart with .watch*() methods database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here) @@ -110,11 +110,23 @@ Every domain table has a `userId` FK → `users`. **Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages` (+`diagnostics` — full notification-extras dump, captured only while the diagnostic-mode toggle -in parsing settings is on), `parse_rules` (+`txType` — transaction type pinned at rule creation, -checked by the gate; +`packageName` — rules are per-app: pipeline loads only rules of the -message's source app via `getEnabledForApp` (NULL = legacy global rows still match anywhere); -every create path must pass `packageName`), -`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`), +in parsing settings is on), `parse_rules` — **unified rule model, NO `kind` column** (dropped in +v2→v3): one rule = condition (pattern + matchMode + optional `txTypeGuard` type guard, SQL column +still `tx_type`) + any set of actions (`merchantCanonical`/`categoryId`/`accountId`, all +nullable) OR `isIgnore` (mutually exclusive with actions); domain helpers +`ParseRule.classifies` (merchant/category set) and `.routesAccount` (account set) replace kind +checks; +`autoApply` — per-rule toggle: false → matches land in Inbox fully prefilled (gate +check `ruleAutoApplyEnabled`); +`packageName` — rules are per-app: pipeline loads only rules of +the message's source app via `getEnabledForApp` (NULL = legacy global rows still match +anywhere); every create path must pass `packageName`. `ParseRulesRepositoryImpl.create` has a +**dedup guard**: same condition (trimmed case-insensitive pattern + matchMode + packageName, +NULL package on the existing row matches any app) → updates/reactivates the existing row instead +of inserting ("one condition = one rule"; ignore over a category rule turns it into an ignore +rule). Rule resolution is **field-wise**: `findClassificationRule` (enabled && classifies) fills +merchant/category, `findAccountRule` (enabled && routesAccount) is step #1 of the account +resolver (trusted) — one message may take category and account from different rules. +`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`; `ParseRuleKind` enum survives +ONLY here), `source_apps` (allowlist of monitored apps; +`selfMerchant` — "merchant is the app itself", e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox; +`defaultAccountId` — the app's default account, FK not enforced → tolerate dangling ids), @@ -123,22 +135,34 @@ in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/conve imported by `app_database.dart`). **Account resolution** (`data/parser/account_resolver.dart`, pure sync fn `resolveAccount`): -`senderToAccount` rule (pattern in body → account, trusted) → `source_apps.defaultAccountId` -(trusted) → global default account (**NOT trusted** → Inbox with prefill) → none. The app -default auto-learns: first Confirm/CreateRule in Inbox for an app without a default stores the -chosen account (`InboxController._maybeSetAppDefault`, returns `true` → card shows a SnackBar; -`learnAppDefault: false` skips). The old `account_bindings` table (card/phone → account) was -dropped in the v1→v2 migration: per-app default (or single) binding became `defaultAccountId`, -card/phone bindings became `senderToAccount` contains-rules. Per-app settings live on one -screen: `source_app_detail_screen.dart` (`/settings/parsing/apps/:pkg` — enabled, selfMerchant, -default account picker, senderToAccount rules of that app). +account rule (`findAccountRule`: pattern in body → account, trusted) → +`source_apps.defaultAccountId` (trusted) → global default account (**NOT trusted** → Inbox with +prefill) → none. The app default auto-learns: first Confirm/CreateRule in Inbox for an app +without a default stores the chosen account (`InboxController._maybeSetAppDefault`, returns +`true` → card shows a SnackBar; `learnAppDefault: false` skips). The old `account_bindings` +table (card/phone → account) was dropped in the v1→v2 migration: per-app default (or single) +binding became `defaultAccountId`, card/phone bindings became account contains-rules. Per-app +settings live on one screen: `source_app_detail_screen.dart` (`/settings/parsing/apps/:pkg` — +enabled, selfMerchant, default account picker, account-only rules (`routesAccount && +!classifies`) of that app). **Auto-apply gate** (`data/parser/decision_gate.dart`): no numeric confidence threshold — a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body, -currency known, draft type == rule `txType` (null = skip), account resolved+trusted, -amount ≤ 100 000 ₽), gated by the `autoApplyEnabled` settings toggle. Failed checks are -cached in `draftJson` (`DraftBundle.failedChecks`) and shown as "why not automatic" in the -inbox card / parsing log. Numeric per-field scores remain only for the "?" badge in Inbox. +currency known, draft type == rule `txTypeGuard` (null = skip), account resolved+trusted, +amount ≤ 100 000 ₽, rule's own `autoApply` toggle on — `ruleAutoApplyEnabled`), gated by the +`autoApplyEnabled` settings toggle. Failed checks are cached in `draftJson` +(`DraftBundle.failedChecks`) and shown as "why not automatic" in the inbox card / parsing log. +Numeric per-field scores remain only for the "?" badge in Inbox. + +**Inbox sweep** (`ParsingPipeline.reapplyRulesToInbox(userId, packageName)`): after +createRule/ignoreWithRule the `InboxController` re-runs the pipeline tail over the app's inbox +cards on their **cached** AI drafts (zero tokens; called AFTER `_maybeSetAppDefault` so learned +defaults make card accounts trusted; failures are swallowed — the action already succeeded). +Matching cards auto-apply through the same gate or get updated in place (category prefilled, +suggestion gone, failedChecks recorded); transfer halves / merged pairs are skipped. Rule editor +(`rule_editor_screen.dart`) is one unified form: condition → ignore switch → actions (merchant, +category, account with "Auto — app account" placeholder) → autoApply switch → advanced +(txTypeGuard dropdown, priority, enabled). Enums live alongside their Drift tables; `enum_converters.dart` is the single import point for UI. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab3c2c5..5594597 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -225,6 +225,7 @@ "gateCheckAccountResolved": "account not resolved", "gateCheckAccountTrusted": "account not confirmed for this app", "gateCheckAmountUnderCap": "amount is too large", + "gateCheckRuleAutoApplyEnabled": "auto-apply is off for the rule", "parsingRulesTile": "Parsing rules", "parsingRulesCreatedCount": "Rules created: {count}", "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, @@ -449,7 +450,13 @@ "appDetailAddRule": "Add", "inboxAppDefaultSet": "Account saved as the app's default", - "ruleKindMerchant": "Merchant → category", - "ruleKindAccount": "Sender → account", - "ruleEditorAccountPick": "Select account" + "ruleEditorAccountPick": "Select account", + "ruleEditorAccountAuto": "Auto — app account", + "ruleEditorIgnoreLabel": "Ignore message", + "ruleEditorIgnoreHint": "Matching notifications are skipped without a transaction", + "ruleEditorAutoApplyLabel": "Apply automatically", + "ruleEditorAutoApplyHint": "Off: matches go to the Inbox pre-filled for manual review", + "ruleEditorTxType": "Type", + "ruleEditorTxTypeAny": "Any", + "ruleManualBadge": "manual" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4e2d2fd..93ffbed 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1016,6 +1016,12 @@ abstract class AppLocalizations { /// **'слишком крупная сумма'** String get gateCheckAmountUnderCap; + /// No description provided for @gateCheckRuleAutoApplyEnabled. + /// + /// In ru, this message translates to: + /// **'авто-применение выключено у правила'** + String get gateCheckRuleAutoApplyEnabled; + /// No description provided for @parsingRulesTile. /// /// In ru, this message translates to: @@ -2132,23 +2138,59 @@ abstract class AppLocalizations { /// **'Счёт сохранён как основной для приложения'** String get inboxAppDefaultSet; - /// No description provided for @ruleKindMerchant. - /// - /// In ru, this message translates to: - /// **'Мерчант → категория'** - String get ruleKindMerchant; - - /// No description provided for @ruleKindAccount. - /// - /// In ru, this message translates to: - /// **'Отправитель → счёт'** - String get ruleKindAccount; - /// No description provided for @ruleEditorAccountPick. /// /// In ru, this message translates to: /// **'Выбрать счёт'** String get ruleEditorAccountPick; + + /// No description provided for @ruleEditorAccountAuto. + /// + /// In ru, this message translates to: + /// **'Авто — счёт приложения'** + String get ruleEditorAccountAuto; + + /// No description provided for @ruleEditorIgnoreLabel. + /// + /// In ru, this message translates to: + /// **'Игнорировать сообщение'** + String get ruleEditorIgnoreLabel; + + /// No description provided for @ruleEditorIgnoreHint. + /// + /// In ru, this message translates to: + /// **'Совпавшие уведомления пропускаются без транзакции'** + String get ruleEditorIgnoreHint; + + /// No description provided for @ruleEditorAutoApplyLabel. + /// + /// In ru, this message translates to: + /// **'Применять автоматически'** + String get ruleEditorAutoApplyLabel; + + /// No description provided for @ruleEditorAutoApplyHint. + /// + /// In ru, this message translates to: + /// **'Выкл: совпадения приходят в Инбокс с префиллом на ручной разбор'** + String get ruleEditorAutoApplyHint; + + /// No description provided for @ruleEditorTxType. + /// + /// In ru, this message translates to: + /// **'Тип операции'** + String get ruleEditorTxType; + + /// No description provided for @ruleEditorTxTypeAny. + /// + /// In ru, this message translates to: + /// **'Любой'** + String get ruleEditorTxTypeAny; + + /// No description provided for @ruleManualBadge. + /// + /// In ru, this message translates to: + /// **'вручную'** + String get ruleManualBadge; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e49f3dd..0f131be 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -530,6 +530,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get gateCheckAmountUnderCap => 'amount is too large'; + @override + String get gateCheckRuleAutoApplyEnabled => 'auto-apply is off for the rule'; + @override String get parsingRulesTile => 'Parsing rules'; @@ -1121,12 +1124,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get inboxAppDefaultSet => 'Account saved as the app\'s default'; - @override - String get ruleKindMerchant => 'Merchant → category'; - - @override - String get ruleKindAccount => 'Sender → account'; - @override String get ruleEditorAccountPick => 'Select account'; + + @override + String get ruleEditorAccountAuto => 'Auto — app account'; + + @override + String get ruleEditorIgnoreLabel => 'Ignore message'; + + @override + String get ruleEditorIgnoreHint => + 'Matching notifications are skipped without a transaction'; + + @override + String get ruleEditorAutoApplyLabel => 'Apply automatically'; + + @override + String get ruleEditorAutoApplyHint => + 'Off: matches go to the Inbox pre-filled for manual review'; + + @override + String get ruleEditorTxType => 'Type'; + + @override + String get ruleEditorTxTypeAny => 'Any'; + + @override + String get ruleManualBadge => 'manual'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e3c45ea..e7f3075 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -541,6 +541,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get gateCheckAmountUnderCap => 'слишком крупная сумма'; + @override + String get gateCheckRuleAutoApplyEnabled => + 'авто-применение выключено у правила'; + @override String get parsingRulesTile => 'Правила парсинга'; @@ -1134,12 +1138,32 @@ class AppLocalizationsRu extends AppLocalizations { @override String get inboxAppDefaultSet => 'Счёт сохранён как основной для приложения'; - @override - String get ruleKindMerchant => 'Мерчант → категория'; - - @override - String get ruleKindAccount => 'Отправитель → счёт'; - @override String get ruleEditorAccountPick => 'Выбрать счёт'; + + @override + String get ruleEditorAccountAuto => 'Авто — счёт приложения'; + + @override + String get ruleEditorIgnoreLabel => 'Игнорировать сообщение'; + + @override + String get ruleEditorIgnoreHint => + 'Совпавшие уведомления пропускаются без транзакции'; + + @override + String get ruleEditorAutoApplyLabel => 'Применять автоматически'; + + @override + String get ruleEditorAutoApplyHint => + 'Выкл: совпадения приходят в Инбокс с префиллом на ручной разбор'; + + @override + String get ruleEditorTxType => 'Тип операции'; + + @override + String get ruleEditorTxTypeAny => 'Любой'; + + @override + String get ruleManualBadge => 'вручную'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3acf8d6..27fb12d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -225,6 +225,7 @@ "gateCheckAccountResolved": "счёт не определён", "gateCheckAccountTrusted": "счёт не подтверждён для приложения", "gateCheckAmountUnderCap": "слишком крупная сумма", + "gateCheckRuleAutoApplyEnabled": "авто-применение выключено у правила", "parsingRulesTile": "Правила парсинга", "parsingRulesCreatedCount": "Правил создано: {count}", "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, @@ -449,7 +450,13 @@ "appDetailAddRule": "Добавить", "inboxAppDefaultSet": "Счёт сохранён как основной для приложения", - "ruleKindMerchant": "Мерчант → категория", - "ruleKindAccount": "Отправитель → счёт", - "ruleEditorAccountPick": "Выбрать счёт" + "ruleEditorAccountPick": "Выбрать счёт", + "ruleEditorAccountAuto": "Авто — счёт приложения", + "ruleEditorIgnoreLabel": "Игнорировать сообщение", + "ruleEditorIgnoreHint": "Совпавшие уведомления пропускаются без транзакции", + "ruleEditorAutoApplyLabel": "Применять автоматически", + "ruleEditorAutoApplyHint": "Выкл: совпадения приходят в Инбокс с префиллом на ручной разбор", + "ruleEditorTxType": "Тип операции", + "ruleEditorTxTypeAny": "Любой", + "ruleManualBadge": "вручную" } diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index f6c3402..f870cb5 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -67,7 +67,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -76,9 +76,29 @@ class AppDatabase extends _$AppDatabase { }, onUpgrade: (m, from, to) async { if (from < 2) await _migrateV1ToV2(m); + if (from < 3) await _migrateV2ToV3(m); }, ); + /// v2 → v3: единая модель правил — колонка `kind` исчезает, появляются + /// `is_ignore` и `auto_apply` (план «Правила парсинга: единая модель»): + /// - kind='ignore' → is_ignore=1; + /// - merchantToCategory / senderToAccount различий больше не имеют: действия + /// читаются из заполненных полей (merchant/category → классификация, + /// account → выбор счёта). Побочный эффект (намеренный): merchant-правила + /// с вручную заданным accountId становятся trusted-источником счёта + /// (ступень #1 резолвера), раньше были слабым fallback. + Future _migrateV2ToV3(Migrator m) async { + await m.addColumn(parseRulesTable, parseRulesTable.isIgnore); + await m.addColumn(parseRulesTable, parseRulesTable.autoApply); + // Raw SQL: физическая колонка kind ещё существует (Dart-схема её уже + // не знает), table rewrite ниже её дропнет. + await customStatement( + "UPDATE parse_rules SET is_ignore = 1 WHERE kind = 'ignore'", + ); + await m.alterTable(TableMigration(parseRulesTable)); + } + /// v1 → v2: `account_bindings` заменяются на `source_apps.defaultAccountId` /// + правила `senderToAccount` (см. план «Упрощение связок»): /// - default-связка приложения (или единственная) → дефолтный счёт приложения; diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart index 660aa47..ea02a06 100644 --- a/lib/src/features/notification_parsing/application/inbox_controller.dart +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -10,6 +10,7 @@ import '../domain/entities/parse_draft.dart'; import '../domain/entities/raw_message.dart'; import '../domain/enums.dart'; import 'notification_parsing_providers.dart'; +import 'parsing_pipeline.dart'; part 'inbox_controller.g.dart'; @@ -64,7 +65,10 @@ class InboxController extends _$InboxController { AsyncValue build() => const AsyncData(null); /// «Создать правило»: транзакция + parse_rule + удаление кандидата. - /// Все последующие похожие сообщения auto-apply молча. + /// Все последующие похожие сообщения auto-apply молча (при [autoApply]; + /// false — совпадения идут в Inbox с полным префиллом). Счёт в правило + /// НЕ пишется — он идёт в [_maybeSetAppDefault] (авто-обучение дефолта + /// приложения). После создания — sweep по остальным карточкам приложения. /// /// Возвращает true, если выбранный счёт был записан дефолтом приложения /// (авто-обучение) — карточка показывает SnackBar. @@ -77,6 +81,7 @@ class InboxController extends _$InboxController { required String merchantCanonical, required String pattern, MatchMode matchMode = MatchMode.contains, + bool autoApply = true, bool learnAppDefault = true, }) async { state = const AsyncLoading(); @@ -96,10 +101,10 @@ class InboxController extends _$InboxController { await ref.read(parseRulesRepositoryProvider).create( userId: userId, packageName: message.packageName, - kind: ParseRuleKind.merchantToCategory, matchMode: matchMode, pattern: pattern, - txType: draft.type, + autoApply: autoApply, + txTypeGuard: draft.type, merchantCanonical: merchantCanonical, categoryId: categoryId, ); @@ -111,8 +116,10 @@ class InboxController extends _$InboxController { } await ref.read(rawMessagesRepositoryProvider).linkTransaction(message.id, tx.id); + // Дефолт учим ДО sweep: выученный дефолт делает счета карточек trusted. final learned = learnAppDefault && await _maybeSetAppDefault(userId, message, accountId); + await _sweepInbox(userId, message.packageName); state = const AsyncData(null); return learned; } catch (e, st) { @@ -121,6 +128,16 @@ class InboxController extends _$InboxController { } } + /// Sweep по инбоксу приложения после создания правила. Сбой проглатываем: + /// транзакция и правило уже созданы, ронять действие из-за sweep нельзя. + Future _sweepInbox(String userId, String packageName) async { + try { + await ref + .read(parsingPipelineProvider) + .reapplyRulesToInbox(userId, packageName); + } catch (_) {} + } + /// «Подтвердить разово»: только транзакция (правило не создаём), /// усиливаем кандидата для будущего предложения. /// @@ -332,7 +349,8 @@ class InboxController extends _$InboxController { } } - /// «Игнорировать» + правило-исключение (kind=ignore). + /// «Игнорировать» + правило-исключение (isIgnore). После создания — sweep: + /// остальные совпавшие карточки приложения тоже уходят в ignored. Future ignoreWithRule({ required String userId, required RawMessage message, @@ -344,13 +362,14 @@ class InboxController extends _$InboxController { await ref.read(parseRulesRepositoryProvider).create( userId: userId, packageName: message.packageName, - kind: ParseRuleKind.ignore, matchMode: matchMode, pattern: pattern, + isIgnore: true, ); await ref .read(rawMessagesRepositoryProvider) .updateStatus(message.id, RawMessageStatus.ignored); + await _sweepInbox(userId, message.packageName); state = const AsyncData(null); } catch (e, st) { state = AsyncError(e, st); diff --git a/lib/src/features/notification_parsing/application/parsing_pipeline.dart b/lib/src/features/notification_parsing/application/parsing_pipeline.dart index 80af1a1..035e72d 100644 --- a/lib/src/features/notification_parsing/application/parsing_pipeline.dart +++ b/lib/src/features/notification_parsing/application/parsing_pipeline.dart @@ -82,6 +82,40 @@ class ParsingPipeline { await _extractViaAi(userId, msg, settings, sourceApp: sourceApp); } + /// Sweep по Inbox после создания/изменения правила: повторный прогон + /// «хвоста» pipeline по карточкам приложения [packageName] на кешированном + /// AI-draft (ноль токенов). Совпавшие с новым правилом карточки либо + /// auto-apply (тот же gate), либо обновляются (категория подставлена, + /// suggestion исчез, failedChecks записаны). Правило с autoApply=false + /// карточки обновляет, но транзакций не создаёт — это обеспечивает + /// gate-чек [AutoApplyCheck.ruleAutoApplyEnabled], спец-кода нет. + Future reapplyRulesToInbox(String userId, String packageName) async { + final settings = await _ref.read(parsingSettingsControllerProvider.future); + if (!settings.enabled) return; + + final sourceApp = await _ref + .read(sourceAppsRepositoryProvider) + .findByPackageName(userId, packageName); + if (sourceApp == null || !sourceApp.enabled) return; + + final inbox = await _ref + .read(rawMessagesRepositoryProvider) + .watchInbox(userId) + .first; + for (final msg in inbox) { + if (msg.packageName != packageName) continue; + if (msg.status != RawMessageStatus.inbox) continue; + final bundle = decodeDraftBundle(msg.draftJson); + if (bundle == null) continue; + // Склеенные переводы не трогаем; transfer-половинки не возвращаем в + // waitingPair (они уже релизнуты по таймауту одиночками). + if (bundle.pairedRawMessageId != null) continue; + if (isTransferHalf(bundle.draft)) continue; + await _runPipeline(userId, msg, bundle.draft, settings, + sourceApp: sourceApp); + } + } + /// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён), /// иначе Inbox на ручной разбор. Future _extractViaAi( @@ -224,7 +258,7 @@ class ParsingPipeline { return; } - // 3. Разрешение счёта (§B): senderToAccount → дефолт приложения → + // 3. Разрешение счёта (§B): правило со счётом → дефолт приложения → // глобальный дефолт (не trusted). final globalDefaultAccountId = (await _ref .read(accountRepositoryProvider) @@ -234,14 +268,14 @@ class ParsingPipeline { final resolution = resolveAccount( body: msg.body, merchantRaw: draft0.merchantRaw, - senderRules: rules, + rules: rules, appDefaultAccountId: sourceApp.defaultAccountId, globalDefaultAccountId: globalDefaultAccountId, ); var draft = draft0.copyWith(accountId: resolution.accountId); - final rule = - findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw); + final rule = findClassificationRule(rules, + body: msg.body, merchantRaw: draft.merchantRaw); RuleCandidate? candidate; if (draft.merchantRaw != null) { @@ -253,19 +287,19 @@ class ParsingPipeline { ); } - // Применяем действие правила к draft. + // Применяем действия правила к draft (по-полевое разрешение: счёт правила + // приходит через резолвер ступенью #1, здесь не трогаем). if (rule != null) { draft = draft.copyWith( - merchantCanonical: rule.merchantCanonical, - categoryId: rule.categoryId, - accountId: draft.accountId ?? rule.accountId, + merchantCanonical: rule.merchantCanonical ?? draft.merchantCanonical, + categoryId: rule.categoryId ?? draft.categoryId, ); } - // Предложение правила для Inbox — только для незнакомого мерчанта и не - // для selfMerchant-источников (мерчант там — само приложение, правило - // бессмысленно). Без suggestion карточка покажет «Подтвердить» вместо - // кнопки «Создать правило». + // Предложение правила для Inbox — только для незнакомого мерчанта (нет + // классифицирующего правила) и не для selfMerchant-источников (мерчант + // там — само приложение, правило бессмысленно). Без suggestion карточка + // покажет «Подтвердить» вместо кнопки «Создать правило». RuleSuggestion? suggestion; if (!selfMerchant && rule == null && draft.merchantRaw != null) { // AI-подсказка категории: матчим имя на существующую категорию (§7). diff --git a/lib/src/features/notification_parsing/application/rules_controller.dart b/lib/src/features/notification_parsing/application/rules_controller.dart index ceea11b..85ba7e0 100644 --- a/lib/src/features/notification_parsing/application/rules_controller.dart +++ b/lib/src/features/notification_parsing/application/rules_controller.dart @@ -1,5 +1,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/database/converters/enum_converters.dart'; import '../data/parser/rule_lookup.dart'; import '../domain/entities/parse_rule.dart'; import '../domain/entities/raw_message.dart'; @@ -47,10 +48,12 @@ class RulesController extends _$RulesController { Future create({ required String userId, required String packageName, - required ParseRuleKind kind, required MatchMode matchMode, required String pattern, int priority = 0, + bool isIgnore = false, + bool autoApply = true, + TransactionType? txTypeGuard, String? merchantCanonical, String? categoryId, String? accountId, @@ -60,10 +63,12 @@ class RulesController extends _$RulesController { final rule = await ref.read(parseRulesRepositoryProvider).create( userId: userId, packageName: packageName, - kind: kind, matchMode: matchMode, pattern: pattern, priority: priority, + isIgnore: isIgnore, + autoApply: autoApply, + txTypeGuard: txTypeGuard, merchantCanonical: merchantCanonical, categoryId: categoryId, accountId: accountId, diff --git a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart index 54ec87f..2bbe150 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart @@ -26,9 +26,6 @@ class ParseRulesTable extends Table { /// Без FK на source_apps — удаление приложения правила не трогает. TextColumn get packageName => text().nullable()(); - /// merchantToCategory | senderToAccount | ignore - TextColumn get kind => text().map(const ParseRuleKindConverter())(); - /// contains | exact | regex TextColumn get matchMode => text().map(const MatchModeConverter()).withDefault(const Constant('contains'))(); @@ -39,13 +36,19 @@ class ParseRulesTable extends Table { IntColumn get weight => integer().withDefault(const Constant(1))(); DateTimeColumn get lastMatchAt => dateTime().nullable()(); - /// Тип операции, зафиксированный при создании правила (merchantToCategory). - /// Gate сверяет с ним тип из AI-draft; null (легаси-правила) = проверка - /// пропускается. - TextColumn get txType => - text().map(const TransactionTypeConverter()).nullable()(); + /// «Пропустить сообщение»: совпавшее уведомление игнорируется целиком. + BoolColumn get isIgnore => boolean().withDefault(const Constant(false))(); - // Action fields (заполняются в зависимости от kind): + /// Тумблер «проводить без подтверждения»: false → совпавшие сообщения + /// идут в Inbox с полным префиллом. + BoolColumn get autoApply => boolean().withDefault(const Constant(true))(); + + /// Страж условия: gate сверяет с ним тип из AI-draft; null = любой тип. + /// SQL-имя `tx_type` историческое (до переименования в txTypeGuard). + TextColumn get txTypeGuard => + text().map(const TransactionTypeConverter()).named('tx_type').nullable()(); + + // Действия (все опциональны): TextColumn get merchantCanonical => text().nullable()(); TextColumn get categoryId => text() .references(CategoriesTable, #id, onDelete: KeyAction.setNull) diff --git a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart index b70ffd2..eb6cdb9 100644 --- a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart @@ -7,14 +7,15 @@ extension ParseRuleMapper on ParseRulesTableData { id: id, userId: userId, packageName: packageName, - kind: kind, matchMode: matchMode, pattern: pattern, priority: priority, matchCount: matchCount, weight: weight, lastMatchAt: lastMatchAt, - txType: txType, + isIgnore: isIgnore, + autoApply: autoApply, + txTypeGuard: txTypeGuard, merchantCanonical: merchantCanonical, categoryId: categoryId, accountId: accountId, diff --git a/lib/src/features/notification_parsing/data/parser/account_resolver.dart b/lib/src/features/notification_parsing/data/parser/account_resolver.dart index c4f60f1..88ded9c 100644 --- a/lib/src/features/notification_parsing/data/parser/account_resolver.dart +++ b/lib/src/features/notification_parsing/data/parser/account_resolver.dart @@ -2,7 +2,7 @@ import '../../domain/entities/parse_rule.dart'; import 'rule_lookup.dart'; /// Откуда взят разрешённый счёт (для диагностики / подсветки). -enum AccountSource { senderRule, appDefault, globalDefault, none } +enum AccountSource { accountRule, appDefault, globalDefault, none } /// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B). class AccountResolution { @@ -28,25 +28,25 @@ class AccountResolution { final AccountSource source; } -/// Разрешает `accountId` по лестнице источников (§B): правило senderToAccount -/// (паттерн в теле → счёт) → дефолтный счёт приложения → глобальный дефолтный +/// Разрешает `accountId` по лестнице источников (§B): правило со счётом +/// (паттерн → счёт) → дефолтный счёт приложения → глобальный дефолтный /// счёт (без доверия) → ничего. AccountResolution resolveAccount({ required String body, String? merchantRaw, - List senderRules = const [], + List rules = const [], String? appDefaultAccountId, String? globalDefaultAccountId, }) { - // #1 — senderToAccount-правило (матч по телу). - final senderRule = - findSenderRule(senderRules, body: body, merchantRaw: merchantRaw); - if (senderRule?.accountId != null) { + // #1 — правило со счётом (routesAccount). + final accountRule = + findAccountRule(rules, body: body, merchantRaw: merchantRaw); + if (accountRule?.accountId != null) { return AccountResolution( - accountId: senderRule!.accountId, + accountId: accountRule!.accountId, score: 90, trusted: true, - source: AccountSource.senderRule, + source: AccountSource.accountRule, ); } diff --git a/lib/src/features/notification_parsing/data/parser/decision_gate.dart b/lib/src/features/notification_parsing/data/parser/decision_gate.dart index 279e29b..3afb7c2 100644 --- a/lib/src/features/notification_parsing/data/parser/decision_gate.dart +++ b/lib/src/features/notification_parsing/data/parser/decision_gate.dart @@ -20,17 +20,22 @@ enum AutoApplyCheck { /// Валюта распознана. currencyKnown, - /// Тип операции совпадает с зафиксированным при создании правила - /// (null у легаси-правил — проверка пропускается). + /// Тип операции совпадает со стражем правила ([ParseRule.txTypeGuard]; + /// null — проверка пропускается). typeMatchesRule, /// Счёт разрешён (правило / дефолт приложения / глобальный дефолт). accountResolved, - /// Счёту можно доверять для авто-применения: правило senderToAccount или + /// Счёту можно доверять для авто-применения: правило со счётом или /// дефолт приложения; глобальный дефолт — лишь префилл, не доверяем. accountTrusted, + /// Авто-применение включено у самого правила ([ParseRule.autoApply]). + /// Выключенный тумблер = «ручной разбор»: карточка в Inbox с полным + /// префиллом. + ruleAutoApplyEnabled, + /// Сумма ниже потолка — защита от галлюцинаций (§15). amountUnderCap, } @@ -70,12 +75,14 @@ GateResult decide({ AutoApplyCheck.amountVerifiedInBody, if (draft.currency.isEmpty) AutoApplyCheck.currencyKnown, if (merchantRule != null && - merchantRule.txType != null && - merchantRule.txType != draft.type) + merchantRule.txTypeGuard != null && + merchantRule.txTypeGuard != draft.type) AutoApplyCheck.typeMatchesRule, if (draft.accountId == null) AutoApplyCheck.accountResolved, if (!resolution.trusted) AutoApplyCheck.accountTrusted, if (draft.amount > _hugeAmountMinor) AutoApplyCheck.amountUnderCap, + if (merchantRule != null && !merchantRule.autoApply) + AutoApplyCheck.ruleAutoApplyEnabled, }; return GateResult( diff --git a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart index 2cee2ed..6a8487b 100644 --- a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart +++ b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart @@ -36,12 +36,15 @@ bool patternMatches( } } -/// ГЛАВНЫЙ gate (§5, шаг 4): ищет активное правило `merchantToCategory`, -/// которое уже подтверждено пользователем для этого мерчанта. +/// Разрешение по-полевое: одно сообщение может взять счёт из одного правила, +/// а категорию — из другого; одно правило может задавать всё сразу. +/// +/// ГЛАВНЫЙ gate (§5, шаг 4): ищет активное классифицирующее правило +/// (мерчант/категория), уже подтверждённое пользователем. /// /// При конфликте (§9.4): более длинный pattern → больший matchCount → /// больший priority. -ParseRule? findMerchantRule( +ParseRule? findClassificationRule( List rules, { required String body, String? merchantRaw, @@ -49,17 +52,17 @@ ParseRule? findMerchantRule( final matches = rules .where((r) => r.enabled && - r.kind == ParseRuleKind.merchantToCategory && + r.classifies && ruleMatches(r, body: body, merchantRaw: merchantRaw)) .toList() ..sort(_bySpecificity); return matches.isEmpty ? null : matches.first; } -/// Ищет активное правило `senderToAccount`, матчащее тело сообщения (§B #2). +/// Ищет активное правило, выбирающее счёт (§B #2). /// -/// При конфликте — та же специфичность, что у [findMerchantRule]. -ParseRule? findSenderRule( +/// При конфликте — та же специфичность, что у [findClassificationRule]. +ParseRule? findAccountRule( List rules, { required String body, String? merchantRaw, @@ -67,15 +70,14 @@ ParseRule? findSenderRule( final matches = rules .where((r) => r.enabled && - r.kind == ParseRuleKind.senderToAccount && - r.accountId != null && + r.routesAccount && ruleMatches(r, body: body, merchantRaw: merchantRaw)) .toList() ..sort(_bySpecificity); return matches.isEmpty ? null : matches.first; } -/// Ищет активное правило-исключение (kind=ignore) для сообщения. +/// Ищет активное правило-исключение (isIgnore) для сообщения. ParseRule? findIgnoreRule( List rules, { required String body, @@ -84,7 +86,7 @@ ParseRule? findIgnoreRule( final matches = rules .where((r) => r.enabled && - r.kind == ParseRuleKind.ignore && + r.isIgnore && ruleMatches(r, body: body, merchantRaw: merchantRaw)) .toList() ..sort(_bySpecificity); @@ -99,7 +101,7 @@ ParseRule? findBodyIgnoreRule(List rules, String body) { final matches = rules .where((r) => r.enabled && - r.kind == ParseRuleKind.ignore && + r.isIgnore && (r.matchMode == MatchMode.contains || r.matchMode == MatchMode.regex) && ruleMatches(r, body: body)) diff --git a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart index 64f4d91..05c1547 100644 --- a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart @@ -39,26 +39,51 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { Future create({ required String userId, required String packageName, - required ParseRuleKind kind, required MatchMode matchMode, required String pattern, int priority = 0, - TransactionType? txType, + bool isIgnore = false, + bool autoApply = true, + TransactionType? txTypeGuard, String? merchantCanonical, String? categoryId, String? accountId, }) async { + // Dedup guard: то же условие (включая disabled-строки) → обновляем + // существующее правило вместо вставки конкурента. Ignore с тем же + // паттерном превращает правило категории в ignore-правило. + final existing = await _findDuplicate( + userId: userId, + packageName: packageName, + matchMode: matchMode, + pattern: pattern, + ); + if (existing != null) { + final updated = existing.copyWith( + isIgnore: isIgnore, + autoApply: autoApply, + txTypeGuard: txTypeGuard, + merchantCanonical: merchantCanonical, + categoryId: categoryId, + accountId: accountId, + enabled: true, + ); + await update(updated); + return updated; + } + final id = const Uuid().v4(); await _dao.insert( ParseRulesTableCompanion.insert( id: id, userId: userId, packageName: Value(packageName), - kind: kind, pattern: pattern, matchMode: Value(matchMode), priority: Value(priority), - txType: Value(txType), + isIgnore: Value(isIgnore), + autoApply: Value(autoApply), + txTypeGuard: Value(txTypeGuard), merchantCanonical: Value(merchantCanonical), categoryId: Value(categoryId), accountId: Value(accountId), @@ -68,16 +93,37 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { return row!.toDomain(); } + /// Дубль по условию: pattern (trim + без регистра) и matchMode совпадают, + /// packageName равен ИЛИ у существующего NULL (легаси-глобальное правило + /// действует везде). Правил мало — фильтруем в Dart по всем строкам юзера. + Future _findDuplicate({ + required String userId, + required String packageName, + required MatchMode matchMode, + required String pattern, + }) async { + final normalized = pattern.trim().toLowerCase(); + final all = await getByUser(userId); + for (final r in all) { + if (r.matchMode != matchMode) continue; + if (r.pattern.trim().toLowerCase() != normalized) continue; + if (r.packageName != null && r.packageName != packageName) continue; + return r; + } + return null; + } + @override Future update(ParseRule rule) => _dao.updateRow( ParseRulesTableCompanion( id: Value(rule.id), packageName: Value(rule.packageName), - kind: Value(rule.kind), matchMode: Value(rule.matchMode), pattern: Value(rule.pattern), priority: Value(rule.priority), - txType: Value(rule.txType), + isIgnore: Value(rule.isIgnore), + autoApply: Value(rule.autoApply), + txTypeGuard: Value(rule.txTypeGuard), merchantCanonical: Value(rule.merchantCanonical), categoryId: Value(rule.categoryId), accountId: Value(rule.accountId), diff --git a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart index 3c12ea6..b298c4d 100644 --- a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart +++ b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart @@ -4,7 +4,8 @@ import '../enums.dart'; part 'parse_rule.freezed.dart'; -/// Пользовательское правило разбора уведомлений. +/// Пользовательское правило разбора уведомлений: условие (паттерн + режим) +/// + любой набор действий (мерчант / категория / счёт) либо игнор. /// /// Создаётся только явным действием в Inbox (или редакторе) — никакого /// авто-промоушена. Активно с момента создания ([enabled] = true). @@ -12,11 +13,10 @@ part 'parse_rule.freezed.dart'; /// [weight] — счётчик доверия: растёт при успешных применениях, падает /// при откатах («правило сработало неправильно»). При [weight] <= 0 /// правило деактивируется ([enabled] = false). -/// -/// Поля действия ([merchantCanonical], [categoryId], [accountId]) заполняются -/// в зависимости от [kind]. @freezed abstract class ParseRule with _$ParseRule { + const ParseRule._(); + const factory ParseRule({ required String id, required String userId, @@ -24,7 +24,6 @@ abstract class ParseRule with _$ParseRule { /// Приложение-источник, к которому привязано правило. null — легаси /// (глобальное правило, матчится в любом приложении). String? packageName, - required ParseRuleKind kind, required MatchMode matchMode, required String pattern, @Default(0) int priority, @@ -32,11 +31,19 @@ abstract class ParseRule with _$ParseRule { @Default(1) int weight, DateTime? lastMatchAt, - /// Тип операции, зафиксированный при создании правила (merchantToCategory): - /// gate сверяет с ним тип AI-draft. null (легаси) — проверка пропускается. - TransactionType? txType, + /// «Пропустить сообщение»: совпавшее уведомление игнорируется целиком. + /// Взаимоисключимо с действиями ([merchantCanonical]/[categoryId]/ + /// [accountId]). + @Default(false) bool isIgnore, - // Action fields (nullable, depend on kind): + /// Тумблер «проводить без подтверждения»: false — совпавшие сообщения + /// идут в Inbox с полным префиллом (ручной разбор). + @Default(true) bool autoApply, + + /// Страж условия: gate сверяет с ним тип AI-draft. null = любой тип. + TransactionType? txTypeGuard, + + // Действия (все опциональны; счёт == null → «Авто — счёт приложения»): String? merchantCanonical, String? categoryId, String? accountId, @@ -44,4 +51,11 @@ abstract class ParseRule with _$ParseRule { @Default(true) bool enabled, required DateTime createdAt, }) = _ParseRule; + + /// Правило классифицирует транзакцию (задаёт мерчанта и/или категорию). + bool get classifies => + !isIgnore && (merchantCanonical != null || categoryId != null); + + /// Правило выбирает счёт. + bool get routesAccount => !isIgnore && accountId != null; } diff --git a/lib/src/features/notification_parsing/domain/enums.dart b/lib/src/features/notification_parsing/domain/enums.dart index 618cc15..731f8ff 100644 --- a/lib/src/features/notification_parsing/domain/enums.dart +++ b/lib/src/features/notification_parsing/domain/enums.dart @@ -26,7 +26,9 @@ enum RawMessageStatus { } // --------------------------------------------------------------------------- -// ParseRuleKind +// ParseRuleKind — используется ТОЛЬКО для rule_candidates (часть ключа +// userId+kind+rawValue). У parse_rules вида больше нет: единое правило = +// условие + любой набор действий (см. ParseRule.classifies/routesAccount). // --------------------------------------------------------------------------- enum ParseRuleKind { merchantToCategory, diff --git a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart index dabf18d..5e70ed4 100644 --- a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart @@ -15,15 +15,22 @@ abstract interface class ParseRulesRepository { Future findById(String id); - /// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность. + /// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую + /// сущность. + /// + /// Dedup guard: если у пользователя уже есть правило с тем же условием + /// (pattern без регистра/пробелов + matchMode + packageName, где NULL + /// у существующего = глобальное легаси, действует везде) — вместо вставки + /// обновляет его действия и реактивирует. «Одно условие = одно правило». Future create({ required String userId, required String packageName, - required ParseRuleKind kind, required MatchMode matchMode, required String pattern, int priority, - TransactionType? txType, + bool isIgnore, + bool autoApply, + TransactionType? txTypeGuard, String? merchantCanonical, String? categoryId, String? accountId, diff --git a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart index d17e56b..225723b 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart @@ -18,13 +18,12 @@ import '../../domain/entities/parse_rule.dart'; import '../../domain/entities/source_app.dart'; import '../../domain/enums.dart'; -/// Предзаполнение редактора при создании правила из Inbox (merchant→category -/// с паттерном/мерчантом) или с детального экрана приложения (senderToAccount -/// с одним лишь packageName). Приложение и вид правила фиксируются. +/// Предзаполнение редактора при создании правила из Inbox (паттерн/мерчант/ +/// категория) или с детального экрана приложения (один лишь packageName). +/// Приложение фиксируется. class RuleEditorPrefill { const RuleEditorPrefill({ required this.packageName, - this.kind = ParseRuleKind.merchantToCategory, this.pattern, this.merchantCanonical, this.categoryId, @@ -33,7 +32,6 @@ class RuleEditorPrefill { }); final String packageName; - final ParseRuleKind kind; final String? pattern; final String? merchantCanonical; final String? categoryId; @@ -45,26 +43,31 @@ class RuleEditorPrefill { class RuleEditorResult { const RuleEditorResult({ required this.packageName, - required this.kind, required this.matchMode, required this.pattern, required this.merchantCanonical, + this.isIgnore = false, + this.autoApply = true, + this.txTypeGuard, this.categoryId, this.accountId, this.priority = 0, }); final String packageName; - final ParseRuleKind kind; final MatchMode matchMode; final String pattern; final String merchantCanonical; + final bool isIgnore; + final bool autoApply; + final TransactionType? txTypeGuard; final String? categoryId; final String? accountId; final int priority; } -/// Редактор правила (§9.3). Два режима: +/// Редактор правила (§9.3): условие + любой набор действий либо игнор. +/// Два режима: /// - [ruleId] != null — правка существующего правила (пишет в БД, есть «Удалить»); /// - иначе — compose из Inbox: возвращает [RuleEditorResult] через `pop`. class RuleEditorScreen extends ConsumerStatefulWidget { @@ -80,9 +83,11 @@ class RuleEditorScreen extends ConsumerStatefulWidget { class _RuleEditorScreenState extends ConsumerState { final _patternCtrl = TextEditingController(); final _merchantCtrl = TextEditingController(); - ParseRuleKind _kind = ParseRuleKind.merchantToCategory; MatchMode _matchMode = MatchMode.contains; String? _packageName; + bool _isIgnore = false; + bool _autoApply = true; + TransactionType? _txTypeGuard; String? _categoryId; String? _accountId; int _priority = 0; @@ -102,7 +107,6 @@ class _RuleEditorScreenState extends ConsumerState { final pf = widget.prefill; if (pf != null) { _packageName = pf.packageName; - _kind = pf.kind; _patternCtrl.text = pf.pattern ?? ''; _merchantCtrl.text = pf.merchantCanonical ?? ''; _matchMode = pf.matchMode; @@ -122,10 +126,12 @@ class _RuleEditorScreenState extends ConsumerState { void _hydrateFromRule(ParseRule rule) { if (_initialized) return; _packageName = rule.packageName; - _kind = rule.kind; _patternCtrl.text = rule.pattern; _merchantCtrl.text = rule.merchantCanonical ?? ''; _matchMode = rule.matchMode; + _isIgnore = rule.isIgnore; + _autoApply = rule.autoApply; + _txTypeGuard = rule.txTypeGuard; _categoryId = rule.categoryId; _accountId = rule.accountId; _priority = rule.priority; @@ -133,31 +139,36 @@ class _RuleEditorScreenState extends ConsumerState { _initialized = true; } - bool get _isSender => _kind == ParseRuleKind.senderToAccount; - - /// Готовность к сохранению: нужны приложение и (для sender→account) счёт. - /// При правке легаси-правила без packageName сохранение не блокируем. + /// Готовность к сохранению: паттерн + приложение, и (игнор ИЛИ хотя бы + /// одно действие: мерчант/категория/счёт). При правке легаси-правила без + /// packageName сохранение не блокируем. bool get _canSave => _patternCtrl.text.trim().isNotEmpty && (_isEdit || _packageName != null) && - (!_isSender || _accountId != null); + (_isIgnore || + _merchantCtrl.text.trim().isNotEmpty || + _categoryId != null || + _accountId != null); Future _save(String userId, ParseRule? existing) async { final pattern = _patternCtrl.text.trim(); if (pattern.isEmpty) return; - final merchant = _isSender ? '' : _merchantCtrl.text.trim(); - // sender→account не задаёт категорию/мерчанта. - final categoryId = _isSender ? null : _categoryId; + // Игнор взаимоисключим с действиями — при isIgnore действия обнуляем. + final merchant = _isIgnore ? '' : _merchantCtrl.text.trim(); + final categoryId = _isIgnore ? null : _categoryId; + final accountId = _isIgnore ? null : _accountId; if (_isEdit && existing != null) { await ref.read(rulesControllerProvider.notifier).update( existing.copyWith( - kind: _kind, matchMode: _matchMode, pattern: pattern, + isIgnore: _isIgnore, + autoApply: _autoApply, + txTypeGuard: _txTypeGuard, merchantCanonical: merchant.isEmpty ? null : merchant, categoryId: categoryId, - accountId: _accountId, + accountId: accountId, priority: _priority, enabled: _enabled, ), @@ -167,12 +178,14 @@ class _RuleEditorScreenState extends ConsumerState { context.pop( RuleEditorResult( packageName: _packageName!, - kind: _kind, matchMode: _matchMode, pattern: pattern, merchantCanonical: merchant, + isIgnore: _isIgnore, + autoApply: _autoApply, + txTypeGuard: _txTypeGuard, categoryId: categoryId, - accountId: _accountId, + accountId: accountId, priority: _priority, ), ); @@ -236,16 +249,6 @@ class _RuleEditorScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ - // Выбор вида — только при создании из списка правил. С prefill - // вид зафиксирован источником: Inbox — merchant→category, детальный - // экран приложения — senderToAccount. - if (_appIsPickable) ...[ - _KindSelector( - kind: _kind, - onChanged: (k) => setState(() => _kind = k), - ), - const SizedBox(height: 16), - ], // Приложение-источник: правило действует только внутри него. // Выбирается при создании «с нуля»; из Inbox и при правке — read-only. if (_appIsPickable) @@ -313,16 +316,29 @@ class _RuleEditorScreenState extends ConsumerState { mode: _matchMode, onChanged: (m) => setState(() => _matchMode = m), ), - const SizedBox(height: 18), - Text(l10n.ruleEditorThen, - style: TextStyle(fontSize: 13, color: p.ink2)), - const SizedBox(height: 8), - if (!_isSender) ...[ + const SizedBox(height: 12), + // «Игнорировать сообщение»: вкл → блок действий скрыт. + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.ruleEditorIgnoreLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(l10n.ruleEditorIgnoreHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + value: _isIgnore, + activeThumbColor: p.accent, + onChanged: (v) => setState(() => _isIgnore = v), + ), + if (!_isIgnore) ...[ + const SizedBox(height: 6), + Text(l10n.ruleEditorThen, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), _FieldRow( label: l10n.ruleEditorMerchant, child: TextField( controller: _merchantCtrl, textAlign: TextAlign.end, + onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: l10n.ruleEditorMerchantHint, isDense: true, @@ -345,22 +361,36 @@ class _RuleEditorScreenState extends ConsumerState { }, ), _Divider(color: p.line), + _PickerRow( + label: l10n.ruleEditorAccount, + // null = «Авто»: счёт придёт от дефолта приложения (резолвер). + value: accountName ?? l10n.ruleEditorAccountAuto, + onClear: _accountId == null + ? null + : () => setState(() => _accountId = null), + onTap: () async { + final id = await showAccountPicker( + context, + userId: userId, + currentAccountId: _accountId, + ); + if (id != null) setState(() => _accountId = id); + }, + ), + const SizedBox(height: 4), + // «Применять автоматически»: выкл → совпадения идут в Inbox + // с полным префиллом (ручной разбор). + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.ruleEditorAutoApplyLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(l10n.ruleEditorAutoApplyHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + value: _autoApply, + activeThumbColor: p.accent, + onChanged: (v) => setState(() => _autoApply = v), + ), ], - _PickerRow( - label: l10n.ruleEditorAccount, - value: accountName ?? - (_isSender - ? l10n.ruleEditorAccountPick - : l10n.ruleEditorAccountUnchanged), - onTap: () async { - final id = await showAccountPicker( - context, - userId: userId, - currentAccountId: _accountId, - ); - if (id != null) setState(() => _accountId = id); - }, - ), const SizedBox(height: 12), _AdvancedToggle( expanded: _advanced, @@ -369,6 +399,34 @@ class _RuleEditorScreenState extends ConsumerState { ), if (_advanced) ...[ const SizedBox(height: 8), + if (!_isIgnore) + _FieldRow( + label: l10n.ruleEditorTxType, + child: DropdownButton( + value: _txTypeGuard, + isExpanded: true, + underline: const SizedBox.shrink(), + alignment: AlignmentDirectional.centerEnd, + items: [ + DropdownMenuItem( + value: null, + child: Text(l10n.ruleEditorTxTypeAny, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + DropdownMenuItem( + value: TransactionType.expense, + child: Text(l10n.txTypeExpense, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + DropdownMenuItem( + value: TransactionType.income, + child: Text(l10n.txTypeIncome, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + ], + onChanged: (v) => setState(() => _txTypeGuard = v), + ), + ), _FieldRow( label: l10n.ruleEditorPriority, child: DropdownButton( @@ -471,33 +529,6 @@ class _MatchesPreview extends ConsumerWidget { } } -class _KindSelector extends StatelessWidget { - const _KindSelector({required this.kind, required this.onChanged}); - - final ParseRuleKind kind; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return SegmentedButton( - segments: [ - ButtonSegment( - value: ParseRuleKind.merchantToCategory, - label: Text(l10n.ruleKindMerchant), - ), - ButtonSegment( - value: ParseRuleKind.senderToAccount, - label: Text(l10n.ruleKindAccount), - ), - ], - selected: {kind}, - showSelectedIcon: false, - onSelectionChanged: (s) => onChanged(s.first), - ); - } -} - class _MatchModeSelector extends StatelessWidget { const _MatchModeSelector({required this.mode, required this.onChanged}); @@ -559,11 +590,13 @@ class _PickerRow extends StatelessWidget { required this.label, required this.value, required this.onTap, + this.onClear, }); final String label; final String value; final VoidCallback onTap; + final VoidCallback? onClear; @override Widget build(BuildContext context) { @@ -585,6 +618,12 @@ class _PickerRow extends StatelessWidget { style: TextStyle(fontSize: 14, color: p.ink), ), ), + if (onClear != null) + IconButton( + icon: Icon(Icons.clear, size: 18, color: p.ink2), + visualDensity: VisualDensity.compact, + onPressed: onClear, + ), Icon(Icons.chevron_right, size: 18, color: p.ink2), ], ), diff --git a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart index 6a2649e..1109aac 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart @@ -14,7 +14,6 @@ import '../../application/rules_controller.dart'; import '../../application/source_apps_controller.dart'; import '../../domain/entities/parse_rule.dart'; import '../../domain/entities/source_app.dart'; -import '../../domain/enums.dart'; import '../widgets/rule_card.dart'; import 'rule_editor_screen.dart'; @@ -31,11 +30,13 @@ class RulesListScreen extends ConsumerStatefulWidget { class _RulesListScreenState extends ConsumerState { _RuleFilter _filter = _RuleFilter.all; + // Фильтры по действиям правила: «Счета» — правила, которые только выбирают + // счёт (классифицирующие со счётом остаются в «Мерчантах»). bool _matches(ParseRule r) => switch (_filter) { _RuleFilter.all => true, - _RuleFilter.merchants => r.kind == ParseRuleKind.merchantToCategory, - _RuleFilter.accounts => r.kind == ParseRuleKind.senderToAccount, - _RuleFilter.ignore => r.kind == ParseRuleKind.ignore, + _RuleFilter.merchants => r.classifies, + _RuleFilter.accounts => r.routesAccount && !r.classifies, + _RuleFilter.ignore => r.isIgnore, }; @override @@ -167,10 +168,12 @@ class _RulesListScreenState extends ConsumerState { await ref.read(rulesControllerProvider.notifier).create( userId: userId, packageName: result.packageName, - kind: result.kind, matchMode: result.matchMode, pattern: result.pattern, priority: result.priority, + isIgnore: result.isIgnore, + autoApply: result.autoApply, + txTypeGuard: result.txTypeGuard, merchantCanonical: result.merchantCanonical.isEmpty ? null : result.merchantCanonical, categoryId: result.categoryId, diff --git a/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart index 8ebb285..582ea25 100644 --- a/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/source_app_detail_screen.dart @@ -13,12 +13,11 @@ import '../../application/rules_controller.dart'; import '../../application/source_apps_controller.dart'; import '../../domain/entities/parse_rule.dart'; import '../../domain/entities/source_app.dart'; -import '../../domain/enums.dart'; import 'rule_editor_screen.dart'; /// Детальный экран приложения-источника: всё про одно приложение в одном /// месте — мониторинг, selfMerchant, дефолтный счёт и правила выбора счёта -/// (senderToAccount). Заменил экран привязок card/phone → счёт. +/// (routesAccount без классификации). Заменил экран привязок card/phone → счёт. class SourceAppDetailScreen extends ConsumerWidget { const SourceAppDetailScreen({super.key, required this.packageName}); @@ -52,10 +51,13 @@ class SourceAppDetailScreen extends ConsumerWidget { .firstOrNull; final accountById = {for (final a in accounts) a.id: a}; - final senderRules = + // Правила выбора счёта этого приложения: только счёт, без классификации + // (классифицирующие правила со счётом живут в общем списке правил). + final accountRules = (ref.watch(parseRulesListProvider(userId)).value ?? const []) .where((r) => - r.kind == ParseRuleKind.senderToAccount && + r.routesAccount && + !r.classifies && r.packageName == packageName) .toList(); @@ -170,7 +172,7 @@ class SourceAppDetailScreen extends ConsumerWidget { ), ], ), - if (senderRules.isEmpty) + if (accountRules.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.appDetailRoutingRulesEmpty, @@ -179,7 +181,7 @@ class SourceAppDetailScreen extends ConsumerWidget { else _Card( children: [ - for (final (i, rule) in senderRules.indexed) ...[ + for (final (i, rule) in accountRules.indexed) ...[ if (i > 0) Container(height: 1, color: p.line), _RuleTile( rule: rule, @@ -195,8 +197,8 @@ class SourceAppDetailScreen extends ConsumerWidget { ); } - /// «+»: редактор в compose-режиме с зафиксированными приложением и видом - /// senderToAccount; результат сохраняем здесь (редактор только композит). + /// «+»: редактор в compose-режиме с зафиксированным приложением; + /// результат сохраняем здесь (редактор только композит). Future _addRule( BuildContext context, WidgetRef ref, @@ -204,19 +206,18 @@ class SourceAppDetailScreen extends ConsumerWidget { ) async { final result = await context.push( AppRoutes.parsingRuleNew, - extra: RuleEditorPrefill( - packageName: packageName, - kind: ParseRuleKind.senderToAccount, - ), + extra: RuleEditorPrefill(packageName: packageName), ); if (result == null) return; await ref.read(rulesControllerProvider.notifier).create( userId: userId, packageName: result.packageName, - kind: result.kind, matchMode: result.matchMode, pattern: result.pattern, priority: result.priority, + isIgnore: result.isIgnore, + autoApply: result.autoApply, + txTypeGuard: result.txTypeGuard, merchantCanonical: result.merchantCanonical.isEmpty ? null : result.merchantCanonical, diff --git a/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart b/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart index 1081c6c..2267723 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/gate_check_labels.dart @@ -22,6 +22,8 @@ String gateCheckLabel(BuildContext context, AutoApplyCheck check) { return l10n.gateCheckAccountTrusted; case AutoApplyCheck.amountUnderCap: return l10n.gateCheckAmountUnderCap; + case AutoApplyCheck.ruleAutoApplyEnabled: + return l10n.gateCheckRuleAutoApplyEnabled; } } diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart index 9294096..7324f9a 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -310,6 +310,7 @@ class _RecognizedBody extends ConsumerWidget { : result.merchantCanonical, pattern: result.pattern, matchMode: result.matchMode, + autoApply: result.autoApply, ), ); } diff --git a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart index fd2c9f2..5043925 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart @@ -5,7 +5,6 @@ import '../../../../app/theme/app_colors.dart'; import '../../../accounts/domain/entities/account.dart'; import '../../../categories/domain/entities/category.dart'; import '../../domain/entities/parse_rule.dart'; -import '../../domain/enums.dart'; /// Карточка правила в списке (§9.2 / §12.3). class RuleCard extends StatelessWidget { @@ -29,21 +28,24 @@ class RuleCard extends StatelessWidget { final p = context.palette; final l10n = context.l10n; - final icon = switch (rule.kind) { - ParseRuleKind.merchantToCategory => Icons.storefront_outlined, - ParseRuleKind.senderToAccount => Icons.credit_card_outlined, - ParseRuleKind.ignore => Icons.block_outlined, - }; + // Иконка по действиям: игнор → block, только счёт → credit_card, + // иначе (классифицирует) → storefront. + final icon = rule.isIgnore + ? Icons.block_outlined + : (rule.routesAccount && !rule.classifies) + ? Icons.credit_card_outlined + : Icons.storefront_outlined; final categoryName = rule.categoryId != null ? categoryById[rule.categoryId]?.name : null; final accountName = rule.accountId != null ? accountById[rule.accountId]?.name : null; - final target = rule.kind == ParseRuleKind.senderToAccount - ? (accountName ?? '') + final target = rule.isIgnore + ? '' : [ rule.merchantCanonical, categoryName, + accountName, ].whereType().join(' · '); return Dismissible( @@ -98,8 +100,28 @@ class RuleCard extends StatelessWidget { ], ), ), - Text(rule.matchMode.name, - style: TextStyle(fontSize: 11, color: p.line2)), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(rule.matchMode.name, + style: TextStyle(fontSize: 11, color: p.line2)), + // «вручную»: авто-применение выключено — совпадения идут + // в Inbox с префиллом. + if (rule.classifies && !rule.autoApply) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: p.cardSoft, + borderRadius: BorderRadius.circular(6), + ), + child: Text(l10n.ruleManualBadge, + style: TextStyle(fontSize: 10, color: p.ink2)), + ), + ], + ], + ), ], ), ), diff --git a/test/app/router/inbox_tab_test.dart b/test/app/router/inbox_tab_test.dart index 89f5032..1e0ab0d 100644 --- a/test/app/router/inbox_tab_test.dart +++ b/test/app/router/inbox_tab_test.dart @@ -59,20 +59,16 @@ void main() { child: const NewBudgetApp(), ), ); - // Пара кадров: активный пользователь и настройки парсинга грузятся из БД. - await tester.pump(); - await tester.pump(); - await tester.pump(); - - // ignore: avoid_print - for (final t in tester.allWidgets.whereType()) { - // ignore: avoid_print - print('TEXT: ${t.data}'); + // Несколько кадров с паузами: активный пользователь и настройки парсинга + // грузятся из реальной in-memory БД асинхронно. + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); } // Парсинг выключен → вкладки Inbox нет, остальные на месте. - expect(find.text('Inbox'), findsNothing); - expect(find.text('Home'), findsOneWidget); + // Локаль по умолчанию — ru: подписи навигации русские. + expect(find.text('Входящие'), findsNothing); + expect(find.text('Главная'), findsOneWidget); // Deep-link на /inbox при выключенном парсинге уводит на /home. final router = container.read(appRouterProvider); @@ -87,10 +83,10 @@ void main() { .read(parsingSettingsControllerProvider.notifier) .setEnabled(true); await tester.pump(); - expect(find.text('Inbox'), findsOneWidget); + expect(find.text('Входящие'), findsOneWidget); // Теперь /inbox доступен. - await tester.tap(find.text('Inbox')); + await tester.tap(find.text('Входящие')); await tester.pump(); await tester.pump(); expect(router.state.matchedLocation, AppRoutes.inbox); @@ -104,6 +100,6 @@ void main() { await tester.pump(); await tester.pump(); expect(router.state.matchedLocation, AppRoutes.home); - expect(find.text('Inbox'), findsNothing); + expect(find.text('Входящие'), findsNothing); }); } diff --git a/test/core/database/migration_v2_test.dart b/test/core/database/migration_v2_test.dart index b222a75..19a5629 100644 --- a/test/core/database/migration_v2_test.dart +++ b/test/core/database/migration_v2_test.dart @@ -115,14 +115,19 @@ void main() { reason: 'несколько связок без default — дефолт не выводится'); }); - test('карточные/phone-связки → правила senderToAccount, избыточные — скип', + test('карточные/phone-связки → правила выбора счёта, избыточные — скип', () async { + // Открытие с user_version=1 гонит цепочку v1→v2→v3: вставленные в v2 + // senderToAccount-строки доезжают до единой модели как «правила со счётом». final rules = await db.select(db.parseRulesTable).get(); - expect(rules.every((r) => r.kind == ParseRuleKind.senderToAccount), isTrue); + expect(rules.every((r) => r.accountId != null && !r.isIgnore), isTrue, + reason: 'после v3 вид определяется действиями: задан только счёт'); + expect(rules.every((r) => r.autoApply), isTrue, + reason: 'SQL-дефолт auto_apply=1 должен примениться'); expect(rules.every((r) => r.matchMode == MatchMode.contains), isTrue); expect(rules.every((r) => r.enabled), isTrue, reason: 'SQL-дефолт enabled=1 должен примениться'); - expect(rules.every((r) => r.txType == null), isTrue, + expect(rules.every((r) => r.txTypeGuard == null), isTrue, reason: 'tx_type NULL — gate пропускает проверку типа'); final byPattern = {for (final r in rules) r.pattern: r}; diff --git a/test/core/database/migration_v3_test.dart b/test/core/database/migration_v3_test.dart new file mode 100644 index 0000000..8044a6f --- /dev/null +++ b/test/core/database/migration_v3_test.dart @@ -0,0 +1,119 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; + +/// Миграция v2 → v3: единая модель правил (см. `AppDatabase._migrateV2ToV3`) — +/// колонка `kind` дропается table rewrite'ом, появляются `is_ignore` и +/// `auto_apply`; kind='ignore' → is_ignore=1, остальные данные целы. +/// +/// Сценарий: raw-схема v2 + по правилу каждого старого kind заливаются в +/// `setup:` (до drift), `PRAGMA user_version = 2` заставляет drift выполнить +/// onUpgrade при открытии. + +const _userId = 'u1'; +const _sber = 'ru.sberbankmobile'; + +void _createV2Schema(dynamic raw) { + raw.execute(''' +CREATE TABLE source_apps ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + package_name TEXT NOT NULL, + display_name TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + self_merchant INTEGER NOT NULL DEFAULT 0, + default_account_id TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE (user_id, package_name) +);'''); + raw.execute(''' +CREATE TABLE parse_rules ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + package_name TEXT, + kind TEXT NOT NULL, + match_mode TEXT NOT NULL DEFAULT 'contains', + pattern TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + match_count INTEGER NOT NULL DEFAULT 0, + weight INTEGER NOT NULL DEFAULT 1, + last_match_at INTEGER, + tx_type TEXT, + merchant_canonical TEXT, + category_id TEXT, + account_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +);'''); + + // По правилу каждого старого kind. + raw.execute( + 'INSERT INTO parse_rules ' + '(id, user_id, package_name, kind, match_mode, pattern, priority, ' + 'match_count, tx_type, merchant_canonical, category_id, account_id, ' + 'enabled) VALUES ' + "('r-merchant', '$_userId', '$_sber', 'merchantToCategory', 'contains', " + "'PYATEROCHKA', 1, 5, 'expense', 'Пятёрочка', 'cat-1', NULL, 1), " + "('r-sender', '$_userId', '$_sber', 'senderToAccount', 'contains', " + "'*3456', 0, 2, NULL, NULL, NULL, 'acc-1', 1), " + "('r-ignore', '$_userId', '$_sber', 'ignore', 'regex', " + "'заказ \\d+', 0, 0, NULL, NULL, NULL, NULL, 0)"); + + raw.execute('PRAGMA user_version = 2'); +} + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory(setup: _createV2Schema)); + }); + + tearDown(() => db.close()); + + test('колонка kind исчезла из parse_rules', () async { + final cols = await db + .customSelect("PRAGMA table_info('parse_rules')") + .get(); + final names = cols.map((r) => r.read('name')).toSet(); + expect(names, isNot(contains('kind'))); + expect(names, containsAll(['is_ignore', 'auto_apply', 'tx_type'])); + }); + + test('merchantToCategory → классифицирующее правило, данные целы', () async { + final r = await db.parseRulesDao.findById('r-merchant'); + expect(r, isNotNull); + expect(r!.isIgnore, isFalse); + expect(r.autoApply, isTrue, reason: 'дефолт auto_apply=1'); + expect(r.pattern, 'PYATEROCHKA'); + expect(r.matchMode, MatchMode.contains); + expect(r.priority, 1); + expect(r.matchCount, 5); + expect(r.txTypeGuard, TransactionType.expense); + expect(r.merchantCanonical, 'Пятёрочка'); + expect(r.categoryId, 'cat-1'); + expect(r.accountId, isNull); + expect(r.enabled, isTrue); + }); + + test('senderToAccount → правило со счётом', () async { + final r = await db.parseRulesDao.findById('r-sender'); + expect(r, isNotNull); + expect(r!.isIgnore, isFalse); + expect(r.accountId, 'acc-1'); + expect(r.categoryId, isNull); + expect(r.merchantCanonical, isNull); + expect(r.matchCount, 2); + }); + + test('ignore → is_ignore=1, disabled сохраняется', () async { + final r = await db.parseRulesDao.findById('r-ignore'); + expect(r, isNotNull); + expect(r!.isIgnore, isTrue); + expect(r.pattern, 'заказ \\d+'); + expect(r.matchMode, MatchMode.regex); + expect(r.enabled, isFalse, reason: 'enabled=0 переносится как есть'); + }); +} diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart index 62792b0..34bf772 100644 --- a/test/features/notification_parsing/application/inbox_controller_test.dart +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:new_budget/src/core/database/converters/enum_converters.dart'; import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart'; import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_pipeline.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; @@ -155,21 +156,23 @@ class _FakeParseRulesRepo implements ParseRulesRepository { Future create({ required String userId, required String packageName, - required ParseRuleKind kind, required MatchMode matchMode, required String pattern, int priority = 0, - TransactionType? txType, + bool isIgnore = false, + bool autoApply = true, + TransactionType? txTypeGuard, String? merchantCanonical, String? categoryId, String? accountId, }) async { created.add({ 'packageName': packageName, - 'kind': kind, 'matchMode': matchMode, 'pattern': pattern, - 'txType': txType, + 'isIgnore': isIgnore, + 'autoApply': autoApply, + 'txTypeGuard': txTypeGuard, 'merchantCanonical': merchantCanonical, 'categoryId': categoryId, }); @@ -177,10 +180,11 @@ class _FakeParseRulesRepo implements ParseRulesRepository { id: 'rule1', userId: userId, packageName: packageName, - kind: kind, matchMode: matchMode, pattern: pattern, - txType: txType, + isIgnore: isIgnore, + autoApply: autoApply, + txTypeGuard: txTypeGuard, merchantCanonical: merchantCanonical, categoryId: categoryId, accountId: accountId, @@ -252,12 +256,30 @@ SourceApp _sourceApp({String? defaultAccountId}) => SourceApp( createdAt: _now, ); +/// Фейк pipeline: перехватывает sweep-вызовы [reapplyRulesToInbox] +/// (createRule/ignoreWithRule), опционально кидает для проверки, что сбой +/// sweep не роняет действие. +class _FakePipeline extends ParsingPipeline { + _FakePipeline(super.ref, this.sweepCalls, {this.throwOnSweep = false}); + + final List<(String, String)> sweepCalls; + final bool throwOnSweep; + + @override + Future reapplyRulesToInbox(String userId, String packageName) async { + sweepCalls.add((userId, packageName)); + if (throwOnSweep) throw StateError('sweep failed'); + } +} + void main() { late _FakeTransactionRepo txRepo; late _FakeRawMessagesRepo rawRepo; late _FakeParseRulesRepo rulesRepo; late _FakeRuleCandidatesRepo candidatesRepo; late _FakeSourceAppsRepo sourceAppsRepo; + late List<(String, String)> sweepCalls; + late bool sweepThrows; late ProviderContainer container; setUp(() { @@ -266,6 +288,8 @@ void main() { rulesRepo = _FakeParseRulesRepo(); candidatesRepo = _FakeRuleCandidatesRepo(); sourceAppsRepo = _FakeSourceAppsRepo()..app = _sourceApp(); + sweepCalls = []; + sweepThrows = false; container = ProviderContainer( overrides: [ transactionRepositoryProvider.overrideWithValue(txRepo), @@ -273,6 +297,8 @@ void main() { parseRulesRepositoryProvider.overrideWithValue(rulesRepo), ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo), sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo), + parsingPipelineProvider.overrideWith( + (ref) => _FakePipeline(ref, sweepCalls, throwOnSweep: sweepThrows)), ], ); }); @@ -300,12 +326,13 @@ void main() { expect(txRepo.created.single['rawMessageId'], 'msg1'); expect(rulesRepo.created, hasLength(1)); - expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory); + expect(rulesRepo.created.single['isIgnore'], isFalse); + expect(rulesRepo.created.single['autoApply'], isTrue); expect(rulesRepo.created.single['categoryId'], 'cat1'); // Правило привязывается к приложению-источнику сообщения (per-app scope). expect(rulesRepo.created.single['packageName'], 'ru.sberbankmobile'); // Тип операции фиксируется в правиле — gate-проверка typeMatchesRule. - expect(rulesRepo.created.single['txType'], TransactionType.expense); + expect(rulesRepo.created.single['txTypeGuard'], TransactionType.expense); expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA'))); expect(rawRepo.linked, contains(('msg1', 'tx1'))); @@ -314,8 +341,76 @@ void main() { expect(learned, isTrue); expect(sourceAppsRepo.defaultsSet, [('app1', 'acc1')]); + // Sweep по инбоксу приложения — после создания правила. + expect(sweepCalls, [(_userId, 'ru.sberbankmobile')]); + expect(container.read(inboxControllerProvider).hasValue, isTrue); }); + + test('autoApply=false прокидывается в правило', () async { + await controller().createRule( + userId: _userId, + message: _message(), + draft: _draft(), + accountId: 'acc1', + categoryId: 'cat1', + merchantCanonical: 'PYATEROCHKA', + pattern: 'PYATEROCHKA', + autoApply: false, + ); + + expect(rulesRepo.created.single['autoApply'], isFalse); + }); + + test('сбой sweep не роняет createRule', () async { + sweepThrows = true; + // Пересоздаём контейнер с кидающим фейком pipeline. + container.dispose(); + container = ProviderContainer( + overrides: [ + transactionRepositoryProvider.overrideWithValue(txRepo), + rawMessagesRepositoryProvider.overrideWithValue(rawRepo), + parseRulesRepositoryProvider.overrideWithValue(rulesRepo), + ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo), + sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo), + parsingPipelineProvider.overrideWith((ref) => + _FakePipeline(ref, sweepCalls, throwOnSweep: sweepThrows)), + ], + ); + + final learned = await controller().createRule( + userId: _userId, + message: _message(), + draft: _draft(), + accountId: 'acc1', + categoryId: 'cat1', + merchantCanonical: 'PYATEROCHKA', + pattern: 'PYATEROCHKA', + ); + + // Действие завершилось успешно, sweep был вызван и упал молча. + expect(learned, isTrue); + expect(sweepCalls, hasLength(1)); + expect(container.read(inboxControllerProvider).hasValue, isTrue); + }); + }); + + group('ignoreWithRule', () { + test('создаёт ignore-правило, гасит сообщение и запускает sweep', () async { + await controller().ignoreWithRule( + userId: _userId, + message: _message(), + pattern: 'PYATEROCHKA', + ); + + expect(rulesRepo.created, hasLength(1)); + expect(rulesRepo.created.single['isIgnore'], isTrue); + expect(rulesRepo.created.single['pattern'], 'PYATEROCHKA'); + expect(rawRepo.statusUpdates, + contains(('msg1', RawMessageStatus.ignored))); + expect(txRepo.created, isEmpty); + expect(sweepCalls, [(_userId, 'ru.sberbankmobile')]); + }); }); group('confirmOnce', () { diff --git a/test/features/notification_parsing/application/inbox_resweep_test.dart b/test/features/notification_parsing/application/inbox_resweep_test.dart new file mode 100644 index 0000000..04df4c0 --- /dev/null +++ b/test/features/notification_parsing/application/inbox_resweep_test.dart @@ -0,0 +1,346 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_pipeline.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart'; + +/// Sweep по инбоксу ([ParsingPipeline.reapplyRulesToInbox]) после +/// createRule/ignoreWithRule: реальная БД, реальные InboxController + Pipeline. +/// AI замокан — sweep работает на кешированных draft'ах (ноль токенов), +/// мок нужен только для сквозного сценария через `pipeline.process`. + +const _userId = 'u1'; +const _accountId = 'acc-1'; +const _categoryId = 'cat-1'; +const _bank = 'ru.sberbankmobile'; + +AiParser _fakeAiParser({required String merchantRaw, required num amount}) { + final mock = MockClient((req) async { + final content = jsonEncode({ + 'type': 'expense', + 'kind': 'purchase', + 'amount': amount, + 'currency': 'RUB', + 'merchantRaw': merchantRaw, + }); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': content}, + } + ], + 'usage': {'total_tokens': 42}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + return AiParser(DeepSeekClient(client: mock, apiKey: 'k')); +} + +void main() { + late AppDatabase db; + late ProviderContainer container; + late RawMessagesRepository repo; + + Future bootstrap() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert( + id: _accountId, userId: _userId, name: 'Main'), + ); + await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert( + id: _categoryId, + userId: _userId, + name: 'Продукты', + )); + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-1', + userId: _userId, + packageName: _bank, + )); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith( + (ref) async => _fakeAiParser(merchantRaw: 'LENTA', amount: 1700)), + ]); + repo = container.read(rawMessagesRepositoryProvider); + // Прогреваем настройки (enabled/autoApplyEnabled = true по умолчанию). + await container.read(parsingSettingsControllerProvider.future); + } + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + ParseDraft draftFor({ + required String rawMessageId, + required int amount, + String? merchantRaw = 'LENTA', + TxKind kind = TxKind.purchase, + }) => + ParseDraft( + rawMessageId: rawMessageId, + type: TransactionType.expense, + amount: amount, + currency: 'RUB', + merchantRaw: merchantRaw, + kind: kind, + source: ParseSource.ai, + ); + + /// Inbox-карточка с кешированным AI-draft, как её оставил pipeline. + Future insertInboxCard({ + required String body, + required int amount, + String? merchantRaw = 'LENTA', + TxKind kind = TxKind.purchase, + String? pairedRawMessageId, + }) async { + final msg = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: body, + receivedAt: DateTime(2026, 7, 10, 12), + ); + final draft = + draftFor(rawMessageId: msg.id, amount: amount, merchantRaw: merchantRaw, kind: kind); + final suggestion = merchantRaw == null + ? null + : RuleSuggestion(merchantRaw: merchantRaw, merchantCanonical: merchantRaw); + await repo.updateAfterParse( + id: msg.id, + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle(draft, suggestion, + pairedRawMessageId: pairedRawMessageId), + ); + return (await repo.findById(msg.id))!; + } + + InboxController controller() => + container.read(inboxControllerProvider.notifier); + + Future createLentaRule(RawMessage message, {bool autoApply = true}) => + controller().createRule( + userId: _userId, + message: message, + draft: decodeDraftBundle(message.draftJson)!.draft, + accountId: _accountId, + categoryId: _categoryId, + merchantCanonical: 'LENTA', + pattern: 'LENTA', + autoApply: autoApply, + ); + + test( + 'createRule по первой карточке: вторая (тот же мерчант, сумма в теле) ' + 'auto-applied, третья (другой мерчант) не применена', () async { + await bootstrap(); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + final msg2 = await insertInboxCard( + body: 'Payment of 2300 RUB at LENTA', amount: 230000); + final msg3 = await insertInboxCard( + body: 'Payment of 900 RUB at OZON', amount: 90000, merchantRaw: 'OZON'); + + await createLentaRule(msg1); + + final after2 = (await repo.findById(msg2.id))!; + expect(after2.status, RawMessageStatus.applied); + + final txns = await db.select(db.transactionsTable).get(); + final tx2 = + txns.where((t) => t.rawMessageId == msg2.id).single; + expect(tx2.autoApplied, isTrue); + expect(tx2.appliedByRuleId, isNotNull); + expect(tx2.accountId, _accountId, + reason: 'счёт из выученного дефолта приложения (trusted)'); + expect(tx2.categoryId, _categoryId, reason: 'категория из правила'); + + // matchCount: sweep — первое срабатывание правила (создание не в счёт). + final rule = (await container + .read(parseRulesRepositoryProvider) + .getByUser(_userId)) + .single; + expect(tx2.appliedByRuleId, rule.id); + expect(rule.matchCount, 1); + + // Третья карточка — другой мерчант: осталась в Inbox без транзакции. + final after3 = (await repo.findById(msg3.id))!; + expect(after3.status, RawMessageStatus.inbox); + expect(txns.where((t) => t.rawMessageId == msg3.id), isEmpty); + }); + + test('gate не прошёл (суммы нет в теле) → карточка обновлена, без транзакции', + () async { + await bootstrap(); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + // Сумма draft (2300) не встречается в теле → amountVerifiedInBody падает. + final msg2 = await insertInboxCard( + body: 'Payment at LENTA, amount hidden', amount: 230000); + + await createLentaRule(msg1); + + final after2 = (await repo.findById(msg2.id))!; + expect(after2.status, RawMessageStatus.inbox); + final bundle = decodeDraftBundle(after2.draftJson)!; + expect(bundle.draft.categoryId, _categoryId, + reason: 'категория из правила подставлена в draft'); + expect(bundle.suggestion, isNull, + reason: 'мерчант теперь знакомый — suggestion исчезает'); + expect(bundle.failedChecks, contains(AutoApplyCheck.amountVerifiedInBody)); + + final txns = await db.select(db.transactionsTable).get(); + expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty); + }); + + test('глобальный autoApplyEnabled=false → только обновление карточки', + () async { + await bootstrap(); + await container + .read(parsingSettingsControllerProvider.notifier) + .setAutoApplyEnabled(false); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + final msg2 = await insertInboxCard( + body: 'Payment of 2300 RUB at LENTA', amount: 230000); + + await createLentaRule(msg1); + + final after2 = (await repo.findById(msg2.id))!; + expect(after2.status, RawMessageStatus.inbox); + final bundle = decodeDraftBundle(after2.draftJson)!; + expect(bundle.draft.categoryId, _categoryId); + expect(bundle.suggestion, isNull); + + final txns = await db.select(db.transactionsTable).get(); + expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty); + }); + + test( + 'правило с autoApply=false → карточка обновлена, транзакции нет, ' + 'failedChecks содержит ruleAutoApplyEnabled', () async { + await bootstrap(); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + final msg2 = await insertInboxCard( + body: 'Payment of 2300 RUB at LENTA', amount: 230000); + + await createLentaRule(msg1, autoApply: false); + + final after2 = (await repo.findById(msg2.id))!; + expect(after2.status, RawMessageStatus.inbox); + final bundle = decodeDraftBundle(after2.draftJson)!; + expect(bundle.draft.categoryId, _categoryId, + reason: 'полный префилл: категория из правила'); + expect(bundle.failedChecks, + contains(AutoApplyCheck.ruleAutoApplyEnabled)); + + final txns = await db.select(db.transactionsTable).get(); + expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty); + }); + + test('ignoreWithRule → совпавшие карточки тоже ignored', () async { + await bootstrap(); + final msg1 = await insertInboxCard( + body: 'Доставлен заказ №1 от LENTA', amount: 150000); + final msg2 = await insertInboxCard( + body: 'Доставлен заказ №2 от LENTA', amount: 230000); + final msg3 = await insertInboxCard( + body: 'Payment of 900 RUB at OZON', amount: 90000, merchantRaw: 'OZON'); + + await controller().ignoreWithRule( + userId: _userId, + message: msg1, + pattern: 'Доставлен заказ', + ); + + expect((await repo.findById(msg1.id))!.status, RawMessageStatus.ignored); + expect((await repo.findById(msg2.id))!.status, RawMessageStatus.ignored, + reason: 'sweep прогнал ignore-правило по остальным карточкам'); + expect((await repo.findById(msg3.id))!.status, RawMessageStatus.inbox); + expect(await db.select(db.transactionsTable).get(), isEmpty); + }); + + test('transfer-половинка и merged-карточка sweep\'ом не трогаются', () async { + await bootstrap(); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + // Релизнутая по таймауту transfer-половинка (kind=transferOut). + final half = await insertInboxCard( + body: 'Перевод 2300 RUB LENTA', amount: 230000, kind: TxKind.transferOut); + // Склеенный перевод (pairedRawMessageId != null). + final merged = await insertInboxCard( + body: 'Перевод 500 RUB LENTA', + amount: 50000, + pairedRawMessageId: 'someone-else'); + + final halfJson = (await repo.findById(half.id))!.draftJson; + final mergedJson = (await repo.findById(merged.id))!.draftJson; + + await createLentaRule(msg1); + + final afterHalf = (await repo.findById(half.id))!; + final afterMerged = (await repo.findById(merged.id))!; + expect(afterHalf.status, RawMessageStatus.inbox); + expect(afterHalf.draftJson, halfJson, reason: 'draft не перезаписан'); + expect(afterMerged.status, RawMessageStatus.inbox); + expect(afterMerged.draftJson, mergedJson); + }); + + test( + 'сквозной: после createRule новое сообщение того же мерчанта ' + 'auto-apply через pipeline.process', () async { + await bootstrap(); + // AI-консент — для пути process → _extractViaAi (мок-модель, без сети). + await container + .read(parsingSettingsControllerProvider.notifier) + .setAiConsent(true); + final msg1 = await insertInboxCard( + body: 'Payment of 1500 RUB at LENTA', amount: 150000); + await createLentaRule(msg1); + + final fresh = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: 'Payment of 1700 RUB at LENTA', + receivedAt: DateTime(2026, 7, 11, 12), + ); + await container.read(parsingPipelineProvider).process(_userId, fresh); + + final after = (await repo.findById(fresh.id))!; + expect(after.status, RawMessageStatus.applied); + final tx = (await db.select(db.transactionsTable).get()) + .where((t) => t.rawMessageId == fresh.id) + .single; + expect(tx.autoApplied, isTrue); + expect(tx.accountId, _accountId); + expect(tx.categoryId, _categoryId); + }); +} diff --git a/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart index a8ac8b8..bdcc217 100644 --- a/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart +++ b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart @@ -140,9 +140,9 @@ void main() { await container.read(parseRulesRepositoryProvider).create( userId: _userId, packageName: _bankA, - kind: ParseRuleKind.ignore, matchMode: MatchMode.contains, pattern: 'Доставлен заказ', + isIgnore: true, ); _activateWorker(container); @@ -179,10 +179,9 @@ void main() { await container.read(parseRulesRepositoryProvider).create( userId: _userId, packageName: _bankA, - kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'LENTA', - txType: TransactionType.expense, + txTypeGuard: TransactionType.expense, categoryId: 'cat-1', ); _activateWorker(container); diff --git a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart index 3f5be75..3db68cb 100644 --- a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart +++ b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart @@ -177,9 +177,9 @@ void main() { await container.read(parseRulesRepositoryProvider).create( userId: _userId, packageName: _bank, - kind: ParseRuleKind.ignore, matchMode: MatchMode.contains, pattern: 'Доставлен заказ', + isIgnore: true, ); _activateWorker(container); @@ -229,10 +229,9 @@ void main() { await container.read(parseRulesRepositoryProvider).create( userId: _userId, packageName: _bank, - kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'LENTA', - txType: TransactionType.expense, + txTypeGuard: TransactionType.expense, categoryId: 'cat-1', ); // Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB, @@ -289,10 +288,9 @@ void main() { await container.read(parseRulesRepositoryProvider).create( userId: _userId, packageName: _bank, - kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'LENTA', - txType: TransactionType.expense, + txTypeGuard: TransactionType.expense, categoryId: 'cat-1', ); diff --git a/test/features/notification_parsing/application/rule_match_preview_test.dart b/test/features/notification_parsing/application/rule_match_preview_test.dart new file mode 100644 index 0000000..3b70a67 --- /dev/null +++ b/test/features/notification_parsing/application/rule_match_preview_test.dart @@ -0,0 +1,180 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:new_budget/l10n/app_localizations.dart'; +import 'package:new_budget/src/app/theme/app_theme.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/rules_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart'; + +/// Live-превью правила ([ruleMatchPreviewProvider], §9.3): сообщения за +/// 30 дней, совпадающие с паттерном; скоуп по packageName; + widget-тест +/// секции превью в редакторе. + +const _userId = 'u1'; +const _bank = 'ru.sberbankmobile'; +const _otherBank = 'com.idamob.tinkoff.android'; + +void main() { + late AppDatabase db; + late ProviderContainer container; + + Future bootstrap() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.settingsDao.setPreference('active_user_id', _userId); + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-1', + userId: _userId, + packageName: _bank, + )); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + ]); + } + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + Future insertMessage( + String body, { + String packageName = _bank, + DateTime? receivedAt, + }) async { + await container.read(rawMessagesRepositoryProvider).insertIncoming( + userId: _userId, + packageName: packageName, + body: body, + receivedAt: receivedAt ?? DateTime.now(), + ); + } + + Future> preview( + String pattern, { + MatchMode mode = MatchMode.contains, + String? packageName = _bank, + }) async { + final list = await container.read( + ruleMatchPreviewProvider(_userId, pattern, mode, packageName).future); + return list.map((m) => m.body).toList(); + } + + group('ruleMatchPreviewProvider', () { + test('contains: без регистра, только совпавшие', () async { + await bootstrap(); + await insertMessage('Покупка LENTA 1500 ₽'); + await insertMessage('Покупка OZON 900 ₽'); + + expect(await preview('lenta'), ['Покупка LENTA 1500 ₽']); + }); + + test('regex-матч; битый regex → пусто', () async { + await bootstrap(); + await insertMessage('Доставлен заказ 123'); + + expect(await preview(r'заказ \d+', mode: MatchMode.regex), + ['Доставлен заказ 123']); + expect(await preview('[invalid(', mode: MatchMode.regex), isEmpty); + }); + + test('exact: у превью нет мерчанта → сверка с полным телом', () async { + await bootstrap(); + await insertMessage('LENTA'); + await insertMessage('Покупка LENTA 1500 ₽'); + + expect(await preview('lenta', mode: MatchMode.exact), ['LENTA']); + }); + + test('пустой паттерн → пусто', () async { + await bootstrap(); + await insertMessage('Покупка LENTA 1500 ₽'); + + expect(await preview(' '), isEmpty); + }); + + test('скоуп packageName; null — без фильтра', () async { + await bootstrap(); + await insertMessage('LENTA из сбера'); + await insertMessage('LENTA из тинькофф', packageName: _otherBank); + + expect(await preview('LENTA'), ['LENTA из сбера']); + expect((await preview('LENTA', packageName: null)).toSet(), + {'LENTA из сбера', 'LENTA из тинькофф'}); + }); + + test('окно 30 дней: старые сообщения не попадают', () async { + await bootstrap(); + await insertMessage('LENTA свежее'); + await insertMessage('LENTA старое', + receivedAt: DateTime.now().subtract(const Duration(days: 40))); + + expect(await preview('LENTA'), ['LENTA свежее']); + }); + }); + + group('секция превью в редакторе правила', () { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + Widget host() => UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const RuleEditorScreen( + prefill: RuleEditorPrefill(packageName: _bank, pattern: 'LENTA'), + ), + ), + ); + + // Реальная in-memory БД: провайдеры добираются до данных не за один кадр, + // pumpAndSettle может отработать до эмиссии стрима — качаем явно. + Future pumpUntilLoaded(WidgetTester tester) async { + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + } + + testWidgets('паттерн с совпадениями → заголовок + тела сообщений', + (tester) async { + await bootstrap(); + await insertMessage('Покупка LENTA 1500 ₽'); + await insertMessage('Покупка OZON 900 ₽'); + + await tester.pumpWidget(host()); + await pumpUntilLoaded(tester); + + // Секция в конце ListView — за пределами вьюпорта теста (600px), + // поэтому ищем и offstage-виджеты (cacheExtent их уже построил). + expect(find.text('Совпадает с (последние 30 дней)', skipOffstage: false), + findsOneWidget); + expect(find.text('Покупка LENTA 1500 ₽', skipOffstage: false), + findsOneWidget); + expect(find.text('Покупка OZON 900 ₽', skipOffstage: false), + findsNothing); + }); + + testWidgets('паттерн без совпадений → «Нет совпадений»', (tester) async { + await bootstrap(); + await insertMessage('Покупка OZON 900 ₽'); + + await tester.pumpWidget(host()); + await pumpUntilLoaded(tester); + + expect(find.text('Нет совпадений за 30 дней', skipOffstage: false), + findsOneWidget); + }); + }); +} diff --git a/test/features/notification_parsing/data/parse_rules_dedup_test.dart b/test/features/notification_parsing/data/parse_rules_dedup_test.dart new file mode 100644 index 0000000..dd1093b --- /dev/null +++ b/test/features/notification_parsing/data/parse_rules_dedup_test.dart @@ -0,0 +1,210 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; + +/// Dedup guard в [ParseRulesRepositoryImpl.create]: «одно условие = одно +/// правило». Дубль по условию (pattern без регистра/пробелов + matchMode + +/// packageName, NULL у существующего = глобальное легаси) обновляет +/// существующую строку вместо вставки конкурента. + +const _userId = 'u1'; +const _bank = 'ru.sberbankmobile'; + +void main() { + late AppDatabase db; + late ParseRulesRepositoryImpl repo; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + repo = ParseRulesRepositoryImpl(db.parseRulesDao); + }); + + tearDown(() => db.close()); + + Future rowCount() async => + (await db.select(db.parseRulesTable).get()).length; + + test('повторный create с тем же условием → одна строка, действия обновлены', + () async { + final first = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + final second = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-2', + merchantCanonical: 'Лента', + ); + + expect(await rowCount(), 1); + expect(second.id, first.id, reason: 'обновляется существующая строка'); + expect(second.categoryId, 'cat-2'); + expect(second.merchantCanonical, 'Лента'); + }); + + test('паттерн с другим регистром/пробелами → это дубль', () async { + final first = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + final second = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: ' lenta ', + categoryId: 'cat-2', + ); + + expect(await rowCount(), 1); + expect(second.id, first.id); + expect(second.categoryId, 'cat-2'); + }); + + test('disabled-дубль реактивируется', () async { + final first = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + await repo.setEnabled(first.id, enabled: false); + + final second = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + + expect(await rowCount(), 1); + expect(second.enabled, isTrue); + expect((await repo.findById(first.id))!.enabled, isTrue); + }); + + test('другой matchMode → отдельная строка', () async { + await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.exact, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + + expect(await rowCount(), 2); + }); + + test('другое приложение → отдельная строка', () async { + await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + await repo.create( + userId: _userId, + packageName: 'com.other.bank', + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + ); + + expect(await rowCount(), 2); + }); + + test('легаси-правило без packageName — дубль для любого приложения', + () async { + // Легаси-строку создаём напрямую через DAO (create требует packageName). + await db.parseRulesDao.insert(ParseRulesTableCompanion.insert( + id: 'legacy-1', + userId: _userId, + pattern: 'LENTA', + matchMode: const Value(MatchMode.contains), + categoryId: const Value('cat-old'), + )); + + final updated = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-new', + ); + + expect(await rowCount(), 1); + expect(updated.id, 'legacy-1'); + expect(updated.categoryId, 'cat-new'); + }); + + test('ignore поверх правила категории → правило стало ignore', () async { + final first = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'Доставлен заказ', + categoryId: 'cat-1', + ); + + final second = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'Доставлен заказ', + isIgnore: true, + ); + + expect(await rowCount(), 1); + expect(second.id, first.id); + expect(second.isIgnore, isTrue); + expect(second.categoryId, isNull, + reason: 'ignore взаимоисключим с действиями — категория снята'); + }); + + test('txTypeGuard и autoApply обновляются при дедупе', () async { + await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + txTypeGuard: TransactionType.expense, + ); + final second = await repo.create( + userId: _userId, + packageName: _bank, + matchMode: MatchMode.contains, + pattern: 'LENTA', + categoryId: 'cat-1', + autoApply: false, + ); + + expect(second.autoApply, isFalse); + expect(second.txTypeGuard, isNull, + reason: 'условие обновляется целиком значениями нового create'); + }); +} diff --git a/test/features/notification_parsing/parser/account_resolver_test.dart b/test/features/notification_parsing/parser/account_resolver_test.dart index f3eb144..a99d93e 100644 --- a/test/features/notification_parsing/parser/account_resolver_test.dart +++ b/test/features/notification_parsing/parser/account_resolver_test.dart @@ -6,11 +6,11 @@ import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; const _userId = 'u1'; final _now = DateTime(2026, 1, 1); -ParseRule _senderRule(String pattern, String accountId, {bool enabled = true}) => +ParseRule _accountRule(String pattern, String accountId, + {bool enabled = true}) => ParseRule( id: 'sr-$pattern', userId: _userId, - kind: ParseRuleKind.senderToAccount, matchMode: MatchMode.contains, pattern: pattern, accountId: accountId, @@ -19,37 +19,37 @@ ParseRule _senderRule(String pattern, String accountId, {bool enabled = true}) = ); AccountResolution _resolve({ - List senderRules = const [], + List rules = const [], String? appDefaultAccountId, String? globalDefaultAccountId, String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA', }) => resolveAccount( body: body, - senderRules: senderRules, + rules: rules, appDefaultAccountId: appDefaultAccountId, globalDefaultAccountId: globalDefaultAccountId, ); void main() { group('resolveAccount (лестница §B)', () { - test('#1 senderToAccount rule → 90, trusted; бьёт оба дефолта', () { + test('#1 правило со счётом → 90, trusted; бьёт оба дефолта', () { final r = _resolve( - senderRules: [_senderRule('*3456', 'acc-rule')], + rules: [_accountRule('*3456', 'acc-rule')], appDefaultAccountId: 'acc-app', globalDefaultAccountId: 'acc-global', ); expect(r.accountId, 'acc-rule'); expect(r.score, 90); expect(r.trusted, isTrue); - expect(r.source, AccountSource.senderRule); + expect(r.source, AccountSource.accountRule); }); test('#1 несовпавшее/выключенное правило пропускается', () { final r = _resolve( - senderRules: [ - _senderRule('НЕ СОВПАДЁТ', 'acc-miss'), - _senderRule('*3456', 'acc-off', enabled: false), + rules: [ + _accountRule('НЕ СОВПАДЁТ', 'acc-miss'), + _accountRule('*3456', 'acc-off', enabled: false), ], appDefaultAccountId: 'acc-app', ); diff --git a/test/features/notification_parsing/parser/confidence_scorer_test.dart b/test/features/notification_parsing/parser/confidence_scorer_test.dart index b0691b5..61c171c 100644 --- a/test/features/notification_parsing/parser/confidence_scorer_test.dart +++ b/test/features/notification_parsing/parser/confidence_scorer_test.dart @@ -34,7 +34,6 @@ ParseDraft _draft({ ParseRule _rule({String? categoryId = 'c1'}) => ParseRule( id: 'r1', userId: 'u1', - kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'PYATEROCHKA', categoryId: categoryId, diff --git a/test/features/notification_parsing/parser/decision_gate_test.dart b/test/features/notification_parsing/parser/decision_gate_test.dart index 87ecd24..be17022 100644 --- a/test/features/notification_parsing/parser/decision_gate_test.dart +++ b/test/features/notification_parsing/parser/decision_gate_test.dart @@ -7,14 +7,17 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/par import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; -ParseRule _rule({TransactionType? txType = TransactionType.expense}) => +ParseRule _rule({ + TransactionType? txTypeGuard = TransactionType.expense, + bool autoApply = true, +}) => ParseRule( id: 'r1', userId: 'u1', - kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'PYATEROCHKA', - txType: txType, + txTypeGuard: txTypeGuard, + autoApply: autoApply, categoryId: 'c1', createdAt: DateTime(2026, 1, 1), ); @@ -51,7 +54,7 @@ const _trusted = AccountResolution( accountId: 'a1', score: 90, trusted: true, - source: AccountSource.senderRule, + source: AccountSource.accountRule, ); const _untrusted = AccountResolution( @@ -123,10 +126,10 @@ void main() { expect(r.failed, {AutoApplyCheck.currencyKnown}); }); - test('draft type differs from rule txType → inbox', () { + test('draft type differs from rule txTypeGuard → inbox', () { final r = decide( autoApplyEnabled: true, - merchantRule: _rule(txType: TransactionType.expense), + merchantRule: _rule(txTypeGuard: TransactionType.expense), draft: _draft(type: TransactionType.income), message: _message(), resolution: _trusted, @@ -135,10 +138,10 @@ void main() { expect(r.failed, {AutoApplyCheck.typeMatchesRule}); }); - test('legacy rule without txType → type check skipped, autoApply', () { + test('rule without txTypeGuard → type check skipped, autoApply', () { final r = decide( autoApplyEnabled: true, - merchantRule: _rule(txType: null), + merchantRule: _rule(txTypeGuard: null), draft: _draft(type: TransactionType.income), message: _message(), resolution: _trusted, @@ -146,6 +149,29 @@ void main() { expect(r.decision, GateDecision.autoApply); }); + test('rule with autoApply=false → inbox with ruleAutoApplyEnabled', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: _rule(autoApply: false), + draft: _draft(), + message: _message(), + resolution: _trusted, + ); + expect(r.decision, GateDecision.inbox); + expect(r.failed, {AutoApplyCheck.ruleAutoApplyEnabled}); + }); + + test('rule with autoApply=true → no ruleAutoApplyEnabled in failed', () { + final r = decide( + autoApplyEnabled: true, + merchantRule: _rule(), + draft: _draft(), + message: _message(), + resolution: _trusted, + ); + expect(r.failed, isNot(contains(AutoApplyCheck.ruleAutoApplyEnabled))); + }); + test('no account → inbox even with rule', () { final r = decide( autoApplyEnabled: true, diff --git a/test/features/notification_parsing/parser/rule_lookup_test.dart b/test/features/notification_parsing/parser/rule_lookup_test.dart index 8e5e00f..ab0c386 100644 --- a/test/features/notification_parsing/parser/rule_lookup_test.dart +++ b/test/features/notification_parsing/parser/rule_lookup_test.dart @@ -7,22 +7,24 @@ ParseRule _rule({ required String id, required String pattern, MatchMode mode = MatchMode.contains, - ParseRuleKind kind = ParseRuleKind.merchantToCategory, + bool isIgnore = false, bool enabled = true, int matchCount = 0, int priority = 0, String? categoryId = 'c1', + String? accountId, }) { return ParseRule( id: id, userId: 'u1', - kind: kind, matchMode: mode, pattern: pattern, + isIgnore: isIgnore, enabled: enabled, matchCount: matchCount, priority: priority, - categoryId: categoryId, + categoryId: isIgnore ? null : categoryId, + accountId: isIgnore ? null : accountId, createdAt: DateTime(2026, 1, 1), ); } @@ -62,11 +64,11 @@ void main() { }); }); - group('findMerchantRule', () { + group('findClassificationRule', () { const body = 'Покупка PYATEROCHKA 1240'; - test('returns matching enabled merchant rule', () { - final r = findMerchantRule( + test('returns matching enabled classification rule', () { + final r = findClassificationRule( [_rule(id: 'a', pattern: 'PYATEROCHKA')], body: body, ); @@ -74,23 +76,45 @@ void main() { }); test('ignores disabled rules', () { - final r = findMerchantRule( + final r = findClassificationRule( [_rule(id: 'a', pattern: 'PYATEROCHKA', enabled: false)], body: body, ); expect(r, isNull); }); - test('ignores ignore-kind rules', () { - final r = findMerchantRule( - [_rule(id: 'a', pattern: 'PYATEROCHKA', kind: ParseRuleKind.ignore)], + test('ignores ignore-rules', () { + final r = findClassificationRule( + [_rule(id: 'a', pattern: 'PYATEROCHKA', isIgnore: true)], body: body, ); expect(r, isNull); }); + test('ignores account-only rules (no merchant/category)', () { + final r = findClassificationRule( + [ + _rule( + id: 'a', + pattern: 'PYATEROCHKA', + categoryId: null, + accountId: 'acc1'), + ], + body: body, + ); + expect(r, isNull); + }); + + test('rule with category AND account still classifies', () { + final r = findClassificationRule( + [_rule(id: 'a', pattern: 'PYATEROCHKA', accountId: 'acc1')], + body: body, + ); + expect(r?.id, 'a'); + }); + test('conflict: longer pattern wins', () { - final r = findMerchantRule( + final r = findClassificationRule( [ _rule(id: 'short', pattern: 'PYAT'), _rule(id: 'long', pattern: 'PYATEROCHKA'), @@ -101,7 +125,7 @@ void main() { }); test('conflict: equal length → higher matchCount wins', () { - final r = findMerchantRule( + final r = findClassificationRule( [ _rule(id: 'a', pattern: 'PYATEROCHKA', matchCount: 1), _rule(id: 'b', pattern: 'PYATEROCHKX', matchCount: 9), @@ -112,10 +136,10 @@ void main() { }); }); - group('findSenderRule', () { + group('findAccountRule', () { const body = 'Покупка PYATEROCHKA 1240'; - ParseRule sender({ + ParseRule account({ required String id, required String pattern, String? accountId = 'acc1', @@ -124,29 +148,38 @@ void main() { _rule( id: id, pattern: pattern, - kind: ParseRuleKind.senderToAccount, enabled: enabled, categoryId: null, - ).copyWith(accountId: accountId); + accountId: accountId, + ); - test('returns matching enabled sender rule', () { - final r = findSenderRule( - [sender(id: 's', pattern: 'PYATEROCHKA')], + test('returns matching enabled account rule', () { + final r = findAccountRule( + [account(id: 's', pattern: 'PYATEROCHKA')], body: body, ); expect(r?.id, 's'); }); test('ignores rules without accountId', () { - final r = findSenderRule( - [sender(id: 's', pattern: 'PYATEROCHKA', accountId: null)], + final r = findAccountRule( + [account(id: 's', pattern: 'PYATEROCHKA', accountId: null)], body: body, ); expect(r, isNull); }); - test('ignores merchant-kind rules', () { - final r = findSenderRule( + test('classification rule with account also routes account', () { + // По-полевое разрешение: одно правило может задавать и категорию, и счёт. + final r = findAccountRule( + [_rule(id: 'm', pattern: 'PYATEROCHKA', accountId: 'acc1')], + body: body, + ); + expect(r?.id, 'm'); + }); + + test('ignores category-only rules', () { + final r = findAccountRule( [_rule(id: 'm', pattern: 'PYATEROCHKA')], body: body, ); @@ -157,7 +190,7 @@ void main() { group('findIgnoreRule', () { test('finds enabled ignore rule', () { final r = findIgnoreRule( - [_rule(id: 'i', pattern: 'спам', kind: ParseRuleKind.ignore)], + [_rule(id: 'i', pattern: 'спам', isIgnore: true)], body: 'это спам реклама', ); expect(r?.id, 'i'); @@ -167,12 +200,7 @@ void main() { group('findBodyIgnoreRule', () { test('matches contains ignore rule by body (pre-AI)', () { final r = findBodyIgnoreRule( - [ - _rule( - id: 'i', - pattern: 'Доставлен заказ', - kind: ParseRuleKind.ignore), - ], + [_rule(id: 'i', pattern: 'Доставлен заказ', isIgnore: true)], 'Доставлен заказ №123', ); expect(r?.id, 'i'); @@ -185,7 +213,7 @@ void main() { id: 'i', pattern: r'заказ \d+', mode: MatchMode.regex, - kind: ParseRuleKind.ignore), + isIgnore: true), ], 'Доставлен заказ 123', ); @@ -199,7 +227,7 @@ void main() { id: 'i', pattern: 'Доставлен заказ №123', mode: MatchMode.exact, - kind: ParseRuleKind.ignore), + isIgnore: true), ], 'Доставлен заказ №123', ); @@ -209,12 +237,8 @@ void main() { test('skips disabled and non-ignore rules', () { final r = findBodyIgnoreRule( [ - _rule( - id: 'a', - pattern: 'спам', - kind: ParseRuleKind.ignore, - enabled: false), - _rule(id: 'b', pattern: 'спам', kind: ParseRuleKind.merchantToCategory), + _rule(id: 'a', pattern: 'спам', isIgnore: true, enabled: false), + _rule(id: 'b', pattern: 'спам'), ], 'это спам', ); diff --git a/test/features/notification_parsing/presentation/inbox_card_test.dart b/test/features/notification_parsing/presentation/inbox_card_test.dart index 5a2acbb..7899abc 100644 --- a/test/features/notification_parsing/presentation/inbox_card_test.dart +++ b/test/features/notification_parsing/presentation/inbox_card_test.dart @@ -50,6 +50,7 @@ class FakeInboxController extends InboxController { required String merchantCanonical, required String pattern, MatchMode matchMode = MatchMode.contains, + bool autoApply = true, bool learnAppDefault = true, }) async { createRuleCalls++;