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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:24:06 +03:00
co-authored by Claude Fable 5
parent fdc5c14852
commit f2ed501ed4
39 changed files with 1677 additions and 324 deletions
+43 -19
View File
@@ -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.
+10 -3
View File
@@ -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"
}
+54 -12
View File
@@ -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
+29 -6
View File
@@ -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';
}
+30 -6
View File
@@ -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 => 'вручную';
}
+10 -3
View File
@@ -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": "вручную"
}
+21 -1
View File
@@ -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<void> _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-связка приложения (или единственная) → дефолтный счёт приложения;
@@ -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<void> 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<void> _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<void> 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);
@@ -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<void> 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<void> _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).
@@ -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<ParseRule> 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,
@@ -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)
@@ -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,
@@ -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<ParseRule> senderRules = const [],
List<ParseRule> 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,
);
}
@@ -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(
@@ -36,12 +36,15 @@ bool patternMatches(
}
}
/// ГЛАВНЫЙ gate (§5, шаг 4): ищет активное правило `merchantToCategory`,
/// которое уже подтверждено пользователем для этого мерчанта.
/// Разрешение по-полевое: одно сообщение может взять счёт из одного правила,
/// а категорию — из другого; одно правило может задавать всё сразу.
///
/// ГЛАВНЫЙ gate (§5, шаг 4): ищет активное классифицирующее правило
/// (мерчант/категория), уже подтверждённое пользователем.
///
/// При конфликте (§9.4): более длинный pattern → больший matchCount →
/// больший priority.
ParseRule? findMerchantRule(
ParseRule? findClassificationRule(
List<ParseRule> 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<ParseRule> 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<ParseRule> 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<ParseRule> 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))
@@ -39,26 +39,51 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
Future<ParseRule> 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<ParseRule?> _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<void> 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),
@@ -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;
}
@@ -26,7 +26,9 @@ enum RawMessageStatus {
}
// ---------------------------------------------------------------------------
// ParseRuleKind
// ParseRuleKind — используется ТОЛЬКО для rule_candidates (часть ключа
// userId+kind+rawValue). У parse_rules вида больше нет: единое правило =
// условие + любой набор действий (см. ParseRule.classifies/routesAccount).
// ---------------------------------------------------------------------------
enum ParseRuleKind {
merchantToCategory,
@@ -15,15 +15,22 @@ abstract interface class ParseRulesRepository {
Future<ParseRule?> findById(String id);
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую
/// сущность.
///
/// Dedup guard: если у пользователя уже есть правило с тем же условием
/// (pattern без регистра/пробелов + matchMode + packageName, где NULL
/// у существующего = глобальное легаси, действует везде) — вместо вставки
/// обновляет его действия и реактивирует. «Одно условие = одно правило».
Future<ParseRule> 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,
@@ -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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
_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<void> _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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
},
),
_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<RuleEditorScreen> {
),
if (_advanced) ...[
const SizedBox(height: 8),
if (!_isIgnore)
_FieldRow(
label: l10n.ruleEditorTxType,
child: DropdownButton<TransactionType?>(
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<int>(
@@ -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<ParseRuleKind> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SegmentedButton<ParseRuleKind>(
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),
],
),
@@ -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<RulesListScreen> {
_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<RulesListScreen> {
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,
@@ -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 <ParseRule>[])
.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<void> _addRule(
BuildContext context,
WidgetRef ref,
@@ -204,19 +206,18 @@ class SourceAppDetailScreen extends ConsumerWidget {
) async {
final result = await context.push<RuleEditorResult?>(
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,
@@ -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;
}
}
@@ -310,6 +310,7 @@ class _RecognizedBody extends ConsumerWidget {
: result.merchantCanonical,
pattern: result.pattern,
matchMode: result.matchMode,
autoApply: result.autoApply,
),
);
}
@@ -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<String>().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)),
),
],
],
),
],
),
),
+10 -14
View File
@@ -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<Text>()) {
// 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);
});
}
+8 -3
View File
@@ -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};
+119
View File
@@ -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<String>('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 переносится как есть');
});
}
@@ -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<ParseRule> 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<void> 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', () {
@@ -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<void> 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<bool>.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<RawMessage> 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<void> 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);
});
}
@@ -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);
@@ -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',
);
@@ -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<void> 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<void> 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<List<String>> 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<void> 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);
});
});
}
@@ -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<int> 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');
});
}
@@ -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<ParseRule> senderRules = const [],
List<ParseRule> 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',
);
@@ -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,
@@ -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,
@@ -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: 'спам'),
],
'это спам',
);
@@ -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++;