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>
152 lines
6.4 KiB
Dart
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?,
|
|
);
|