This commit is contained in:
2026-06-01 11:43:58 +03:00
parent fe4165a97b
commit f1e9496865
3 changed files with 169 additions and 10 deletions
+5 -1
View File
@@ -7,7 +7,11 @@
"Bash(dart run *)",
"Bash(flutter test *)",
"Bash(Select-String -Pattern \"Failing tests:|_test.dart:\" -Context 0,0)",
"Bash(Select-Object -Last 40)"
"Bash(Select-Object -Last 40)",
"Bash(Get-ChildItem -Path \"C:\\\\Sanders\\\\Flutter\\\\NewBudget\\\\lib\\\\src\\\\features\\\\notification_parsing\" -Recurse -Directory)",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(Get-ChildItem -Path \"C:\\\\Sanders\\\\Flutter\\\\NewBudget\\\\lib\\\\src\\\\features\\\\notification_parsing\" -Recurse)",
"Bash(Select-Object FullName)"
]
}
}
+153
View File
@@ -0,0 +1,153 @@
# Workflow определения счёта при парсинге уведомлений
## Context
Приложение парсит уведомления банков и создаёт черновики транзакций. Проблемы по текущему коду:
- **Нет управляемого списка приложений-источников.** `packageName` приходит как свободный текст
(в debug — `debug_inject_screen.dart`; нативного `NotificationListenerService` ещё нет, Phase 0).
Парсится всё подряд. Каталога банков нет (`bank_templates_catalog` — только в комментарии схемы).
- **Транзакция не создаётся без счёта.** `decision_gate.dart:27`: `if (!hasAccount) return inbox;`.
Даже когда правило категории сработало.
- **`AccountResolver`** (`account_resolver.dart`) умеет карта→счёт, телефон→счёт, packageName→счёт;
при нескольких счетах в банке без распознанной карты берёт «первый» (score 45). Нет понятия
**умолчательного счёта**.
- **`senderToAccount`** объявлен (`enums.dart:25`), есть в UI (`rules_list_screen.dart:33`,
`rule_card.dart:31`), но **не используется в пайплайне** — мёртвый код.
- Правило-мерчант кладёт `accountId` лишь как fallback (`parsing_worker.dart:265`).
- Привязки (`account_bindings`) создаются неявно и **не управляемы из UI**.
**Цель:** (1) список приложений-источников (allowlist) — парсить только выбранные; (2) на базе
этого списка задавать привязку приложение→счёт с умолчательными счетами (per-app + глобальный);
(3) задействовать `senderToAccount`-правила; (4) разрешить авто-применение с дефолтным счётом.
**Решения пользователя:**
1. Список приложений — **захардкодить каталог** сейчас, нативную выборку позже.
2. **Allowlist**: парсятся только включённые приложения-источники; остальные игнорируются.
3. Default-счёт: **per-app default + глобальный fallback** (`isDefault` в `account_bindings` + `Account.isDefault`).
4. Правила: **композитные раздельные** — задействовать `senderToAccount` + оставить `merchantToCategory`.
5. Gate: **авто-применять с дефолтным счётом** (но неоднозначность из нескольких реальных привязок без флага default — в Inbox).
6. **Сделать UI** управления приложениями-источниками и их привязками.
---
## A. Список приложений-источников (allowlist)
**Каталог (хардкод).** Новый `data/source_apps/source_apps_catalog.dart``const` список
`SourceAppTemplate(packageName, displayName, bankKey?)` для известных банков РФ
(Сбер `ru.sberbankmobile`, Т-Банк `com.idamob.tinkoff.android`, Альфа, ВТБ, Газпром и т.п.).
Это лишь источник вариантов для выбора; позже заменится нативным списком установленных приложений.
**Хранение выбора (per-user).** Новая Drift-таблица **`source_apps`**:
`id, userId(FK), packageName, displayName(nullable), enabled(bool, default true), createdAt`,
unique `(userId, packageName)`. Присутствие строки = приложение добавлено; `enabled` — мониторится
ли сейчас. Заводится при добавлении из каталога или вручную (кастомный packageName).
Слой: table + DAO + entity (freezed) + mapper + repository(+impl), по образцу `account_bindings`.
**Allowlist-фильтр в пайплайне.** В `parsing_worker.dart` перед разбором: загрузить множество
включённых `packageName` пользователя; если `msg.packageName` не в нём — пометить
`RawMessageStatus.ignored` и выйти (до AI/правил, чтобы не тратить токены). Провайдер
`enabledSourcePackagesProvider(userId)` (Set<String>) — `@riverpod`.
---
## B. Цепочка определения счёта (итоговая)
В `AccountResolver.resolve(...)`, который теперь принимает также `body`, `merchantRaw`,
`senderRules` и `globalDefaultAccountId`. Порядок по убыванию доверия:
| # | Источник | accountId | trusted | score |
|---|---|---|---|---|
| 1 | binding `packageName`+`cardLast4` / `phone` | да | да | 100 |
| 2 | **`senderToAccount`-правило** (матч по телу) | да | да | 90 |
| 3 | единственный binding по `packageName` | да | да | 75 |
| 4 | **per-app default binding** (`isDefault`, card неизвестна) | да | да | 70 |
| 5 | несколько bindings без default-флага | первый | **нет** → Inbox | 45 |
| 6 | **глобальный `Account.isDefault`** | да | да | 40 |
| 7 | ничего | null | — → Inbox | 15 |
`trusted` — новое булево в `AccountResolution`; именно оно (а не числовой score) решает судьбу
в gate: неоднозначность (#5) остаётся в Inbox, осознанные дефолты (#4, #6) разрешают авто-применение.
---
## Изменения по слоям
### 1. Schema / data (Drift) — миграция v6 → v7
- **`account_bindings_table.dart`** — `BoolColumn get isDefault => boolean().withDefault(const Constant(false))();`
- **Новая `source_apps_table.dart`** (см. раздел A) + регистрация в `@DriftDatabase` tables.
- **`app_database.dart`** — `schemaVersion` 6 → **7**; в `onUpgrade`:
`if (from < 7) { await m.addColumn(accountBindingsTable, accountBindingsTable.isDefault); await m.createTable(sourceApps); }`.
- **`account_binding.dart`** entity + **`account_binding_mapper.dart`** — пробросить `isDefault`.
- **`account_bindings_dao.dart`** — добавить `findDefaultByPackageName(...)` и
`setDefault(id, userId, packageName)` (сбросить флаг у остальных привязок пакета, выставить у выбранной).
- **`account_bindings_repository.dart`**(+impl) — новые методы + `isDefault` в `create(...)`.
- **Новые `source_apps` DAO / entity / mapper / repository(+impl)** + провайдеры в
`notification_parsing_providers.dart`.
> После правок таблиц/entity: `dart run build_runner build --delete-conflicting-outputs`.
### 2. Резолвер счёта + правила (data/parser)
- **`rule_lookup.dart`** — `findSenderRule(rules, body, merchantRaw)` по образцу `findMerchantRule`,
`kind == ParseRuleKind.senderToAccount`.
- **`account_resolver.dart`** — `AccountResolution` + `bool trusted` (и опц. `AccountSource source`);
`resolve(...)` переписать по таблице B; новые параметры `body`, `merchantRaw`, `senderRules`,
`globalDefaultAccountId`.
- **`confidence_scorer.dart`** — убрать `account` из `FieldScores.otherFieldsMin` (min только
`amount`, `type`); account-score остаётся для подсветки «?».
- **`decision_gate.dart`** — `decide(...)`: вместо `hasAccount` принимать `accountResolved` и
`accountTrusted`. Логика: `if (!accountResolved || !accountTrusted) return inbox;` далее как раньше.
### 3. Pipeline (application)
- **`parsing_worker.dart`** (`_runPipeline`):
- **в начале** — allowlist-фильтр (раздел A): не в множестве → `ignored`, выход.
- грузить `rules` раньше; передать `senderRules`/`body`/`merchantRaw` + `globalDefaultAccountId`
(`accountRepositoryProvider.watchDefault(userId).first`) в `resolver.resolve(...)`.
- в `decide(...)` передавать `accountResolved: draft.accountId != null`, `accountTrusted: resolution.trusted`.
### 4. UI приложений-источников и привязок (presentation)
- Routes в **`app_routes.dart`**: `parsingApps = '/settings/parsing/apps'`,
`parsingAppBindings = '/settings/parsing/apps/:pkg'`; регистрация в **`app_router.dart`**.
- **`source_apps_screen.dart`** (новый): секция «Включённые» (мониторятся, toggle + chevron →
привязки), секция «Доступные» (из каталога, ещё не добавлены — тап включает), «Добавить вручную»
(кастомный packageName + имя). Watch `enabledSourcePackagesProvider` / стрим `source_apps`.
- **`app_bindings_screen.dart`** (новый, per-app): привязки выбранного `packageName`
список (card/phone → счёт), флаг «по умолчанию для приложения» (`dao.setDefault`),
добавить привязку (счёт + опц. cardLast4), удалить (`deleteById`).
- Контроллеры **`source_apps_controller.dart`** и **`account_bindings_controller.dart`**
(тонкие `@riverpod`, по образцу `rules_controller.dart`).
- Вход: `_NavTile` «Приложения-источники» в **`parsing_settings_screen.dart`** (после «Правила»),
icon `Icons.apps_outlined`.
### 5. UI правил — создание `senderToAccount`
- **`rules_list_screen.dart:129`** + **`rule_editor_screen.dart`** — выбор `kind`
(merchant→category | sender→account); для `senderToAccount` показывать только picker счёта.
- **`rule_card.dart`** — отображать имя счёта для `senderToAccount`.
### 6. Локализация
- В `app_en.arb`/`app_ru.arb` — ключи для экранов приложений-источников/привязок, выбора kind
правила (`ruleKindMerchant`/`ruleKindAccount`), флага default, добавления/удаления. Затем `flutter gen-l10n`.
---
## Критические файлы
- `data/parser/account_resolver.dart`, `decision_gate.dart`, `confidence_scorer.dart`, `rule_lookup.dart`
- `application/parsing_worker.dart` (allowlist + проводка резолвера)
- `data/source_apps/source_apps_catalog.dart` (новый) + `data/drift/tables/source_apps_table.dart` (новый) + DAO/entity/mapper/repo
- `data/drift/tables/account_bindings_table.dart` + `core/database/app_database.dart` (миграция v7)
- `data/drift/daos/account_bindings_dao.dart`, mapper, repository(+impl), entity
- `presentation/screens/source_apps_screen.dart`, `app_bindings_screen.dart` (новые) + router/routes/настройки
- `presentation/screens/{rules_list,rule_editor}_screen.dart`, `widgets/rule_card.dart`
- `application/{source_apps_controller,account_bindings_controller}.dart` (новые) + `notification_parsing_providers.dart`
## Верификация
1. `dart run build_runner build --delete-conflicting-outputs` && `flutter gen-l10n` && `flutter analyze` — чисто.
2. **Unit `account_resolver_test.dart`**: 7 случаев из таблицы B — `accountId`/`trusted`/`score`.
3. **Unit `decision_gate_test.dart`**: правило категории + `accountTrusted=true``autoApply`;
`accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`.
4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается;
от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт.
(Помнить про `container.listen(pendingMessagesProvider(userId), …)` — см. CLAUDE.md.)
5. Существующий пакет `test/features/notification_parsing/` — зелёный.
6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту
и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило.
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_theme.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
@@ -27,6 +28,13 @@ class CategoryDonutCard extends ConsumerWidget {
final selectedCats = ref.watch(selectedCategoryFilterProvider);
final sorted = categoriesBySpend(summary.spendByCategory, categories);
// Центр отражает сумму выбранных категорий, если фильтр активен.
final centerTotalMinor = selectedCats.isEmpty
? summary.spendTotalMinor
: summary.spendByCategory.entries
.where((e) => selectedCats.contains(e.key))
.fold<int>(0, (s, e) => s + e.value);
final slices = [
for (final entry in sorted)
DonutSlice(
@@ -70,7 +78,7 @@ class CategoryDonutCard extends ConsumerWidget {
),
const SizedBox(height: 2),
MoneyText(
-summary.spendTotalMinor,
-centerTotalMinor,
color: p.ink,
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -126,15 +134,9 @@ class _Legend extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
MoneyText(
totalMinor == 0 ? 0 : (e.value * 100 ~/ totalMinor),
color: p.ink2,
fontSize: 12,
withCurrency: false,
),
Text(
'%',
style: TextStyle(fontSize: 12, color: p.ink2),
'${totalMinor == 0 ? 0 : (e.value * 100 ~/ totalMinor)}%',
style: monoStyle(color: p.ink2, fontSize: 12),
),
],
),