feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import 'package:fintracker_app/features/imports/data/imports_api.dart';
|
||||
import 'package:fintracker_app/features/pending/data/pending_api.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The exact payload from `docs/ai/import-contract.md`. If the backend renames a field this
|
||||
/// test is the first thing that notices.
|
||||
const _previewJson = <String, dynamic>{
|
||||
'id': 12,
|
||||
'broker': 'sber',
|
||||
'filename': 'S930W42_11022026_17092026.html',
|
||||
'sha256': '9f2c',
|
||||
'size_bytes': 87333,
|
||||
'parser_name': 'report_sber',
|
||||
'parser_version': '1',
|
||||
'parse_status': 'parsed',
|
||||
'error': null,
|
||||
'duplicate_of_id': null,
|
||||
'account_external_id': 'S930W42',
|
||||
'account_id': 57,
|
||||
'account_name': 'Сбер ИИС',
|
||||
'account_suggestions': [
|
||||
{'id': 57, 'name': 'Сбер ИИС', 'broker': 'sber', 'source_id': 'S930W42'},
|
||||
],
|
||||
'period_from': '2026-02-11',
|
||||
'period_to': '2026-09-17',
|
||||
'uploaded_at': '2026-09-18T12:30:00+03:00',
|
||||
'committed_at': null,
|
||||
'counts': {
|
||||
'lines': 61,
|
||||
'events_total': 47,
|
||||
'events_new': 47,
|
||||
'events_duplicate': 0,
|
||||
'events_shadow': 0,
|
||||
'events_pending': 3,
|
||||
'by_kind': {'buy': 22, 'sell': 2, 'deposit': 8, 'commission': 15},
|
||||
},
|
||||
'pending_instruments': [
|
||||
{
|
||||
'id': 4,
|
||||
'source': 'report_sber',
|
||||
'source_key': 'ISIN:RU000A1035S8',
|
||||
'isin': 'RU000A1035S8',
|
||||
'ticker': 'STME',
|
||||
'board': null,
|
||||
'name': 'Первая-ВечныйПортф БПИФ',
|
||||
'currency': 'RUB',
|
||||
'asset_class_hint': 'fund',
|
||||
'occurrences': 3,
|
||||
'sample_quantity': '439',
|
||||
'sample_price': '4.48',
|
||||
'status': 'pending',
|
||||
'instrument_id': null,
|
||||
},
|
||||
],
|
||||
'reconciliation': {
|
||||
'as_of': '2026-09-17',
|
||||
'positions': [
|
||||
{
|
||||
'instrument_id': 88,
|
||||
'instrument_name': 'Аэрофлот',
|
||||
'ticker': 'AFLT',
|
||||
'isin': 'RU0009062285',
|
||||
'qty_report': '130',
|
||||
'qty_derived': '130',
|
||||
'qty_delta': '0',
|
||||
'matches': true,
|
||||
},
|
||||
],
|
||||
'cash': [
|
||||
{
|
||||
'currency': 'RUB',
|
||||
'balance_report': '3171.34',
|
||||
'balance_derived': '3171.34',
|
||||
'delta': '0',
|
||||
'matches': true,
|
||||
},
|
||||
],
|
||||
'matches': true,
|
||||
},
|
||||
'warnings': ['Раздел «Купонный доход» отсутствует в файле'],
|
||||
'sample_events': [
|
||||
{
|
||||
'line_no': 3,
|
||||
'kind': 'buy',
|
||||
'trade_date': '2026-02-24',
|
||||
'settle_date': '2026-02-25',
|
||||
'instrument_key': 'ISIN:RU000A1035S8',
|
||||
'instrument_name': 'Первая-ВечныйПортф БПИФ',
|
||||
'instrument_id': null,
|
||||
'quantity': '439',
|
||||
'price': '4.48',
|
||||
'amount': '-1966.72',
|
||||
'currency': 'RUB',
|
||||
'fee': '0.39',
|
||||
'trade_no': '15678045077',
|
||||
'dedupe_key': 'a1b2',
|
||||
'is_duplicate': false,
|
||||
'description': 'Покупка',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
test('parses the contract ImportPreview payload', () {
|
||||
final p = ImportPreview.fromJson(_previewJson);
|
||||
|
||||
expect(p.id, 12);
|
||||
expect(p.broker, 'sber');
|
||||
expect(p.parseStatus, 'parsed');
|
||||
expect(p.accountId, 57);
|
||||
expect(p.periodFrom, DateTime.parse('2026-02-11'));
|
||||
expect(p.counts.eventsPending, 3);
|
||||
expect(p.counts.byKind['buy'], 22);
|
||||
expect(p.accountSuggestions.single.sourceId, 'S930W42');
|
||||
expect(p.warnings, hasLength(1));
|
||||
expect(p.canCommit, isTrue);
|
||||
|
||||
// Money and quantities stay strings end to end — never parsed into a double here.
|
||||
final sample = p.sampleEvents.single;
|
||||
expect(sample.amount, '-1966.72');
|
||||
expect(sample.price, '4.48');
|
||||
expect(sample.isDuplicate, isFalse);
|
||||
|
||||
final recon = p.reconciliation!;
|
||||
expect(recon.matches, isTrue);
|
||||
expect(recon.positions.single.qtyDelta, '0');
|
||||
expect(recon.cash.single.balanceReport, '3171.34');
|
||||
|
||||
expect(p.pendingInstruments.single.ticker, 'STME');
|
||||
});
|
||||
|
||||
test('an ImportSummary without the preview-only sections still parses', () {
|
||||
final json = Map<String, dynamic>.from(_previewJson)
|
||||
..remove('sample_events')
|
||||
..remove('pending_instruments')
|
||||
..remove('reconciliation');
|
||||
final p = ImportPreview.fromJson(json);
|
||||
|
||||
expect(p.sampleEvents, isEmpty);
|
||||
expect(p.pendingInstruments, isEmpty);
|
||||
expect(p.reconciliation, isNull);
|
||||
});
|
||||
|
||||
test('commit is blocked until an account is known', () {
|
||||
final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null;
|
||||
expect(ImportPreview.fromJson(json).canCommit, isFalse);
|
||||
|
||||
final failed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'failed';
|
||||
expect(ImportPreview.fromJson(failed).canCommit, isFalse);
|
||||
expect(ImportPreview.fromJson(failed).canDelete, isTrue);
|
||||
|
||||
final committed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'committed';
|
||||
expect(ImportPreview.fromJson(committed).canDelete, isFalse);
|
||||
});
|
||||
|
||||
test('parses PendingResolveResult and builds the create body as plain strings', () {
|
||||
final r = PendingResolveResult.fromJson(const {
|
||||
'id': 4,
|
||||
'status': 'resolved',
|
||||
'instrument_id': 88,
|
||||
'events_bound': 3,
|
||||
'alias_created': true,
|
||||
'metrics_refreshed': true,
|
||||
});
|
||||
expect(r.eventsBound, 3);
|
||||
expect(r.instrumentId, 88);
|
||||
|
||||
final body = const NewInstrument(
|
||||
assetClass: 'index',
|
||||
name: 'Индекс МосБиржи',
|
||||
currency: 'RUB',
|
||||
isin: '',
|
||||
ticker: 'IMOEX',
|
||||
lot: 1,
|
||||
).toJson();
|
||||
expect(body['asset_class'], 'index');
|
||||
expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted');
|
||||
expect(body['ticker'], 'IMOEX');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user