From f1e94968658c3e67556032232f84fd1c065c740f Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 1 Jun 2026 11:43:58 +0300 Subject: [PATCH] Fix pie --- .claude/settings.local.json | 6 +- docs/account_determination_plan.md | 153 ++++++++++++++++++ .../widgets/category_donut_card.dart | 20 +-- 3 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 docs/account_determination_plan.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d026d92..7aedef6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/docs/account_determination_plan.md b/docs/account_determination_plan.md new file mode 100644 index 0000000..d596543 --- /dev/null +++ b/docs/account_determination_plan.md @@ -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) — `@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`-правило. diff --git a/lib/src/features/home/presentation/widgets/category_donut_card.dart b/lib/src/features/home/presentation/widgets/category_donut_card.dart index 8f297df..733e94e 100644 --- a/lib/src/features/home/presentation/widgets/category_donut_card.dart +++ b/lib/src/features/home/presentation/widgets/category_donut_card.dart @@ -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(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), ), ], ),