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:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "вручную"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+52
-6
@@ -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,
|
||||
|
||||
+10
-3
@@ -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,
|
||||
|
||||
+119
-80
@@ -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,
|
||||
|
||||
+14
-13
@@ -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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user