Fix pie
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user