Files
OnBudget/lib/src/features/notification_parsing/data/parser/draft_codec.dart
T
SandersandClaude Opus 4.8 da65bf6f8e Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding
Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:49:04 +03:00

152 lines
6.4 KiB
Dart

import 'dart:convert';
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/parse_draft.dart';
import '../../domain/entities/rule_suggestion.dart';
import '../../domain/enums.dart';
import 'decision_gate.dart';
/// Сериализация связки `ParseDraft` + `RuleSuggestion` в строку для
/// `RawMessage.draftJson`. Проект не использует json_serializable —
/// кодируем вручную (см. CLAUDE.md).
///
/// Связка кешируется при отправке сообщения в Inbox, чтобы экран мог
/// восстановить предложение без повторного парсинга.
class DraftBundle {
const DraftBundle({
required this.draft,
this.suggestion,
this.failedChecks = const {},
this.accountTrusted = false,
this.pairedRawMessageId,
this.preMerge,
this.mergeUndo,
});
final ParseDraft draft;
final RuleSuggestion? suggestion;
/// Gate-проверки, не пройденные при отправке в Inbox — для строки
/// «почему не автоматически» в карточке/журнале.
final Set<AutoApplyCheck> failedChecks;
/// Снапшот `AccountResolution.trusted` на момент парсинга — sweep решает
/// по нему допустимость доклейки (resolution к тому моменту уже недоступен).
final bool accountTrusted;
/// id второй половинки склеенного перевода. != null → merged-карточка
/// «Перевод между счетами» (или доклеенная половинка при [mergeUndo]).
final String? pairedRawMessageId;
/// Снапшот полей primary-draft, перезаписанных склейкой
/// (`{'type', 'categoryId'}`) — для восстановления при расклейке из Inbox.
final Map<String, dynamic>? preMerge;
/// Снапшот полей транзакции до доклейки (`{'txId', 'prevType',
/// 'prevAccountId', 'prevCategoryId', 'prevTransferToAccountId'}`) —
/// для отката доклеенного перевода из журнала.
final Map<String, dynamic>? mergeUndo;
}
String encodeDraftBundle(
ParseDraft draft,
RuleSuggestion? suggestion, {
Set<AutoApplyCheck> failedChecks = const {},
bool accountTrusted = false,
String? pairedRawMessageId,
Map<String, dynamic>? preMerge,
Map<String, dynamic>? mergeUndo,
}) {
return jsonEncode(<String, dynamic>{
'draft': _draftToJson(draft),
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
if (failedChecks.isNotEmpty)
'failedChecks': failedChecks.map((c) => c.name).toList(),
if (accountTrusted) 'accountTrusted': true,
'pairedRawMessageId': ?pairedRawMessageId,
'preMerge': ?preMerge,
'mergeUndo': ?mergeUndo,
});
}
DraftBundle? decodeDraftBundle(String? json) {
if (json == null || json.isEmpty) return null;
final map = jsonDecode(json) as Map<String, dynamic>;
final draftMap = map['draft'] as Map<String, dynamic>?;
if (draftMap == null) return null;
final suggMap = map['suggestion'] as Map<String, dynamic>?;
return DraftBundle(
draft: _draftFromJson(draftMap),
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
failedChecks: _failedChecksFromJson(map['failedChecks']),
accountTrusted: map['accountTrusted'] as bool? ?? false,
pairedRawMessageId: map['pairedRawMessageId'] as String?,
preMerge: (map['preMerge'] as Map<String, dynamic>?),
mergeUndo: (map['mergeUndo'] as Map<String, dynamic>?),
);
}
/// Неизвестные имена проверок (например, после удаления enum-значения в новой
/// версии) молча пропускаем — это кеш, а не источник истины.
Set<AutoApplyCheck> _failedChecksFromJson(Object? raw) {
if (raw is! List) return const {};
final byName = {for (final c in AutoApplyCheck.values) c.name: c};
return raw.whereType<String>().map((n) => byName[n]).nonNulls.toSet();
}
Map<String, dynamic> _draftToJson(ParseDraft d) => {
'rawMessageId': d.rawMessageId,
'type': d.type.name,
'amount': d.amount,
'currency': d.currency,
'cardLast4': d.cardLast4,
'merchantRaw': d.merchantRaw,
'counterpartyName': d.counterpartyName,
'counterpartyPhone': d.counterpartyPhone,
'dateTime': d.dateTime?.toIso8601String(),
'kind': d.kind?.name,
'categorySuggestion': d.categorySuggestion,
'accountId': d.accountId,
'merchantCanonical': d.merchantCanonical,
'categoryId': d.categoryId,
'transferToAccountId': d.transferToAccountId,
'source': d.source.name,
};
ParseDraft _draftFromJson(Map<String, dynamic> m) => ParseDraft(
rawMessageId: m['rawMessageId'] as String,
type: TransactionType.values.byName(m['type'] as String),
amount: m['amount'] as int,
currency: (m['currency'] as String?) ?? 'RUB',
cardLast4: m['cardLast4'] as String?,
merchantRaw: m['merchantRaw'] as String?,
counterpartyName: m['counterpartyName'] as String?,
counterpartyPhone: m['counterpartyPhone'] as String?,
dateTime: m['dateTime'] == null
? null
: DateTime.parse(m['dateTime'] as String),
kind: m['kind'] == null ? null : TxKind.values.byName(m['kind'] as String),
categorySuggestion: m['categorySuggestion'] as String?,
accountId: m['accountId'] as String?,
merchantCanonical: m['merchantCanonical'] as String?,
categoryId: m['categoryId'] as String?,
transferToAccountId: m['transferToAccountId'] as String?,
source: ParseSource.values.byName(m['source'] as String),
);
Map<String, dynamic> _suggestionToJson(RuleSuggestion s) => {
'merchantRaw': s.merchantRaw,
'merchantCanonical': s.merchantCanonical,
'categoryId': s.categoryId,
'categoryName': s.categoryName,
'accountId': s.accountId,
};
RuleSuggestion _suggestionFromJson(Map<String, dynamic> m) => RuleSuggestion(
merchantRaw: m['merchantRaw'] as String,
merchantCanonical: m['merchantCanonical'] as String,
categoryId: m['categoryId'] as String?,
categoryName: m['categoryName'] as String?,
accountId: m['accountId'] as String?,
);