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,441 @@
|
||||
/// Hand-written client for `/api/v1/imports`.
|
||||
///
|
||||
/// **Temporary.** The import routes are not in `openapi/openapi.json` yet, so the generated
|
||||
/// package `app/packages/api_client` knows nothing about them. Everything here — models and
|
||||
/// calls — follows `docs/ai/import-contract.md` literally and is meant to be **replaced by
|
||||
/// the generated client** as soon as the routes land in the spec and `just gen-client` runs.
|
||||
/// Until then this is the only place in the app that talks to those endpoints.
|
||||
///
|
||||
/// Raw Dio comes from `ref.read(apiProvider).dio`, which already carries the base URL, the
|
||||
/// bearer header and the single transparent refresh on 401.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../core/auth/auth_controller.dart' show problemMessage;
|
||||
import '../../pending/data/pending_api.dart';
|
||||
|
||||
/// A candidate account for an import whose `account_id` the server could not resolve.
|
||||
class AccountSuggestion {
|
||||
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? broker;
|
||||
final String? sourceId;
|
||||
|
||||
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion(
|
||||
id: asInt(json['id'])!,
|
||||
name: asString(json['name']) ?? '#${json['id']}',
|
||||
broker: asString(json['broker']),
|
||||
sourceId: asString(json['source_id']),
|
||||
);
|
||||
}
|
||||
|
||||
class ImportCounts {
|
||||
const ImportCounts({
|
||||
this.lines = 0,
|
||||
this.eventsTotal = 0,
|
||||
this.eventsNew = 0,
|
||||
this.eventsDuplicate = 0,
|
||||
this.eventsShadow = 0,
|
||||
this.eventsPending = 0,
|
||||
this.byKind = const {},
|
||||
});
|
||||
|
||||
final int lines;
|
||||
final int eventsTotal;
|
||||
final int eventsNew;
|
||||
final int eventsDuplicate;
|
||||
final int eventsShadow;
|
||||
final int eventsPending;
|
||||
final Map<String, int> byKind;
|
||||
|
||||
static ImportCounts fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return const ImportCounts();
|
||||
final byKind = json['by_kind'];
|
||||
return ImportCounts(
|
||||
lines: asInt(json['lines']) ?? 0,
|
||||
eventsTotal: asInt(json['events_total']) ?? 0,
|
||||
eventsNew: asInt(json['events_new']) ?? 0,
|
||||
eventsDuplicate: asInt(json['events_duplicate']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
eventsPending: asInt(json['events_pending']) ?? 0,
|
||||
byKind: byKind is Map
|
||||
? {
|
||||
for (final e in byKind.entries)
|
||||
e.key.toString(): asInt(e.value) ?? 0,
|
||||
}
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One position of the report checked against the ledger. Quantities stay strings here and
|
||||
/// are parsed into `Decimal` only where they are shown or compared.
|
||||
class ReconPosition {
|
||||
const ReconPosition({
|
||||
required this.matches,
|
||||
this.instrumentId,
|
||||
this.instrumentName,
|
||||
this.ticker,
|
||||
this.isin,
|
||||
this.qtyReport,
|
||||
this.qtyDerived,
|
||||
this.qtyDelta,
|
||||
});
|
||||
|
||||
final bool matches;
|
||||
final int? instrumentId;
|
||||
final String? instrumentName;
|
||||
final String? ticker;
|
||||
final String? isin;
|
||||
final String? qtyReport;
|
||||
final String? qtyDerived;
|
||||
final String? qtyDelta;
|
||||
|
||||
String get title => instrumentName ?? ticker ?? isin ?? '—';
|
||||
|
||||
static ReconPosition fromJson(Map<String, dynamic> json) => ReconPosition(
|
||||
matches: json['matches'] == true,
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
ticker: asString(json['ticker']),
|
||||
isin: asString(json['isin']),
|
||||
qtyReport: asString(json['qty_report']),
|
||||
qtyDerived: asString(json['qty_derived']),
|
||||
qtyDelta: asString(json['qty_delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class ReconCash {
|
||||
const ReconCash({
|
||||
required this.currency,
|
||||
required this.matches,
|
||||
this.balanceReport,
|
||||
this.balanceDerived,
|
||||
this.delta,
|
||||
});
|
||||
|
||||
final String currency;
|
||||
final bool matches;
|
||||
final String? balanceReport;
|
||||
final String? balanceDerived;
|
||||
final String? delta;
|
||||
|
||||
static ReconCash fromJson(Map<String, dynamic> json) => ReconCash(
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
matches: json['matches'] == true,
|
||||
balanceReport: asString(json['balance_report']),
|
||||
balanceDerived: asString(json['balance_derived']),
|
||||
delta: asString(json['delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class Reconciliation {
|
||||
const Reconciliation({
|
||||
required this.matches,
|
||||
this.asOf,
|
||||
this.positions = const [],
|
||||
this.cash = const [],
|
||||
});
|
||||
|
||||
final bool matches;
|
||||
final DateTime? asOf;
|
||||
final List<ReconPosition> positions;
|
||||
final List<ReconCash> cash;
|
||||
|
||||
bool get isEmpty => positions.isEmpty && cash.isEmpty;
|
||||
|
||||
static Reconciliation? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
return Reconciliation(
|
||||
matches: json['matches'] == true,
|
||||
asOf: asDate(json['as_of']),
|
||||
positions: asList(json['positions']).map(ReconPosition.fromJson).toList(),
|
||||
cash: asList(json['cash']).map(ReconCash.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed report line as the user should see it before committing anything.
|
||||
class SampleEvent {
|
||||
const SampleEvent({
|
||||
required this.lineNo,
|
||||
required this.kind,
|
||||
required this.isDuplicate,
|
||||
this.tradeDate,
|
||||
this.settleDate,
|
||||
this.instrumentKey,
|
||||
this.instrumentName,
|
||||
this.instrumentId,
|
||||
this.quantity,
|
||||
this.price,
|
||||
this.amount,
|
||||
this.currency,
|
||||
this.fee,
|
||||
this.tradeNo,
|
||||
this.dedupeKey,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final int lineNo;
|
||||
final String kind;
|
||||
final bool isDuplicate;
|
||||
final DateTime? tradeDate;
|
||||
final DateTime? settleDate;
|
||||
final String? instrumentKey;
|
||||
final String? instrumentName;
|
||||
final int? instrumentId;
|
||||
final String? quantity;
|
||||
final String? price;
|
||||
final String? amount;
|
||||
final String? currency;
|
||||
final String? fee;
|
||||
final String? tradeNo;
|
||||
final String? dedupeKey;
|
||||
final String? description;
|
||||
|
||||
static SampleEvent fromJson(Map<String, dynamic> json) => SampleEvent(
|
||||
lineNo: asInt(json['line_no']) ?? 0,
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
isDuplicate: json['is_duplicate'] == true,
|
||||
tradeDate: asDate(json['trade_date']),
|
||||
settleDate: asDate(json['settle_date']),
|
||||
instrumentKey: asString(json['instrument_key']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
quantity: asString(json['quantity']),
|
||||
price: asString(json['price']),
|
||||
amount: asString(json['amount']),
|
||||
currency: asString(json['currency']),
|
||||
fee: asString(json['fee']),
|
||||
tradeNo: asString(json['trade_no']),
|
||||
dedupeKey: asString(json['dedupe_key']),
|
||||
description: asString(json['description']),
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without
|
||||
/// `sample_events`, `pending_instruments` and `reconciliation`, so the list screen simply
|
||||
/// gets empty collections and a null reconciliation.
|
||||
class ImportPreview {
|
||||
const ImportPreview({
|
||||
required this.id,
|
||||
required this.filename,
|
||||
required this.parseStatus,
|
||||
this.broker,
|
||||
this.sha256,
|
||||
this.sizeBytes,
|
||||
this.parserName,
|
||||
this.parserVersion,
|
||||
this.error,
|
||||
this.duplicateOfId,
|
||||
this.accountExternalId,
|
||||
this.accountId,
|
||||
this.accountName,
|
||||
this.accountSuggestions = const [],
|
||||
this.periodFrom,
|
||||
this.periodTo,
|
||||
this.uploadedAt,
|
||||
this.committedAt,
|
||||
this.counts = const ImportCounts(),
|
||||
this.pendingInstruments = const [],
|
||||
this.reconciliation,
|
||||
this.warnings = const [],
|
||||
this.sampleEvents = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String filename;
|
||||
|
||||
/// `uploaded | parsed | committed | failed` — a plain string, like every other stable key.
|
||||
final String parseStatus;
|
||||
final String? broker;
|
||||
final String? sha256;
|
||||
final int? sizeBytes;
|
||||
final String? parserName;
|
||||
final String? parserVersion;
|
||||
final String? error;
|
||||
final int? duplicateOfId;
|
||||
final String? accountExternalId;
|
||||
final int? accountId;
|
||||
final String? accountName;
|
||||
final List<AccountSuggestion> accountSuggestions;
|
||||
final DateTime? periodFrom;
|
||||
final DateTime? periodTo;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? committedAt;
|
||||
final ImportCounts counts;
|
||||
final List<PendingInstrument> pendingInstruments;
|
||||
final Reconciliation? reconciliation;
|
||||
final List<String> warnings;
|
||||
final List<SampleEvent> sampleEvents;
|
||||
|
||||
bool get isCommitted => parseStatus == 'committed';
|
||||
bool get isFailed => parseStatus == 'failed';
|
||||
|
||||
/// Commit is allowed only once an account is known and the file actually parsed.
|
||||
bool get canCommit => accountId != null && !isCommitted && !isFailed;
|
||||
|
||||
bool get canDelete => !isCommitted;
|
||||
|
||||
static ImportPreview fromJson(Map<String, dynamic> json) => ImportPreview(
|
||||
id: asInt(json['id'])!,
|
||||
filename: asString(json['filename']) ?? 'без имени',
|
||||
parseStatus: asString(json['parse_status']) ?? 'uploaded',
|
||||
broker: asString(json['broker']),
|
||||
sha256: asString(json['sha256']),
|
||||
sizeBytes: asInt(json['size_bytes']),
|
||||
parserName: asString(json['parser_name']),
|
||||
parserVersion: asString(json['parser_version']),
|
||||
error: asString(json['error']),
|
||||
duplicateOfId: asInt(json['duplicate_of_id']),
|
||||
accountExternalId: asString(json['account_external_id']),
|
||||
accountId: asInt(json['account_id']),
|
||||
accountName: asString(json['account_name']),
|
||||
accountSuggestions:
|
||||
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(),
|
||||
periodFrom: asDate(json['period_from']),
|
||||
periodTo: asDate(json['period_to']),
|
||||
uploadedAt: asDate(json['uploaded_at']),
|
||||
committedAt: asDate(json['committed_at']),
|
||||
counts: ImportCounts.fromJson(asMap(json['counts'])),
|
||||
pendingInstruments:
|
||||
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(),
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
warnings: json['warnings'] is List
|
||||
? (json['warnings'] as List).map((e) => e.toString()).toList()
|
||||
: const [],
|
||||
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// What `POST /imports/{id}/commit` reports back.
|
||||
class ImportResult {
|
||||
const ImportResult({
|
||||
required this.importId,
|
||||
required this.committed,
|
||||
this.eventsCreated = 0,
|
||||
this.eventsUpdated = 0,
|
||||
this.eventsSkipped = 0,
|
||||
this.eventsShadow = 0,
|
||||
this.pendingInstruments = 0,
|
||||
this.reconciliation,
|
||||
this.metricsRefreshed = false,
|
||||
});
|
||||
|
||||
final int importId;
|
||||
final bool committed;
|
||||
final int eventsCreated;
|
||||
final int eventsUpdated;
|
||||
final int eventsSkipped;
|
||||
final int eventsShadow;
|
||||
final int pendingInstruments;
|
||||
final Reconciliation? reconciliation;
|
||||
final bool metricsRefreshed;
|
||||
|
||||
static ImportResult fromJson(Map<String, dynamic> json) => ImportResult(
|
||||
importId: asInt(json['import_id']) ?? 0,
|
||||
committed: json['committed'] == true,
|
||||
eventsCreated: asInt(json['events_created']) ?? 0,
|
||||
eventsUpdated: asInt(json['events_updated']) ?? 0,
|
||||
eventsSkipped: asInt(json['events_skipped']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
metricsRefreshed: json['metrics_refreshed'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop
|
||||
/// it hands over a [path] and reading the file is left to Dio. Both are supported so the
|
||||
/// web build keeps working.
|
||||
class PickedReport {
|
||||
const PickedReport({required this.name, this.bytes, this.path});
|
||||
|
||||
final String name;
|
||||
final List<int>? bytes;
|
||||
final String? path;
|
||||
}
|
||||
|
||||
class ImportsApi {
|
||||
const ImportsApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _base = '/api/v1/imports';
|
||||
|
||||
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'status': ?status,
|
||||
},
|
||||
);
|
||||
return (r.data ?? const [])
|
||||
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ImportPreview> get(int id) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/$id');
|
||||
return ImportPreview.fromJson(r.data!);
|
||||
}
|
||||
|
||||
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async {
|
||||
final bytes = report.bytes;
|
||||
final form = FormData.fromMap({
|
||||
'file': bytes != null
|
||||
? MultipartFile.fromBytes(bytes, filename: report.name)
|
||||
: await MultipartFile.fromFile(report.path!, filename: report.name),
|
||||
'account_id': ?accountId,
|
||||
'parser': ?parser,
|
||||
});
|
||||
final r = await _dio.post<Map<String, dynamic>>(_base, data: form);
|
||||
return ImportPreview.fromJson(r.data!);
|
||||
}
|
||||
|
||||
Future<ImportResult> commit(
|
||||
int id, {
|
||||
int? accountId,
|
||||
bool confirmDuplicates = false,
|
||||
bool dryRun = false,
|
||||
}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: {
|
||||
'account_id': ?accountId,
|
||||
'confirm_duplicates': confirmDuplicates,
|
||||
'dry_run': dryRun,
|
||||
});
|
||||
return ImportResult.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> delete(int id) => _dio.delete<void>('$_base/$id');
|
||||
}
|
||||
|
||||
/// RFC 7807 `detail` first; a readable Russian fallback for the statuses the contract names
|
||||
/// when the body carries no detail. Never surfaces a raw `DioException`.
|
||||
String importErrorMessage(Object error) {
|
||||
if (error is! DioException) return error.toString();
|
||||
final data = error.response?.data;
|
||||
if (data is Map) {
|
||||
final detail = data['detail'] ?? data['title'];
|
||||
if (detail is String && detail.isNotEmpty) return detail;
|
||||
}
|
||||
return switch (error.response?.statusCode) {
|
||||
413 => 'Файл больше 16 МБ',
|
||||
415 => 'Формат файла не распознан',
|
||||
422 => 'Файл не удалось разобрать',
|
||||
409 => 'Импорт уже закоммичен',
|
||||
404 => 'Импорт не найден',
|
||||
_ => problemMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asList(Object? v) => v is List
|
||||
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||
: const [];
|
||||
|
||||
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'imports_api.dart';
|
||||
|
||||
/// Picking a report is the one part of the import screen that touches the platform, so it
|
||||
/// sits behind this one-method interface: widget tests (and a future drag-and-drop variant)
|
||||
/// override [reportPickerProvider] instead of mocking a plugin.
|
||||
abstract class ReportPicker {
|
||||
Future<PickedReport?> pick();
|
||||
}
|
||||
|
||||
/// The extensions the report parsers accept.
|
||||
const reportExtensions = ['html', 'htm', 'xlsx', 'csv'];
|
||||
|
||||
class FilePickerReportPicker implements ReportPicker {
|
||||
const FilePickerReportPicker();
|
||||
|
||||
@override
|
||||
Future<PickedReport?> pick() async {
|
||||
// file_picker 11 exposes `pickFiles` as a static on [FilePicker]; the old
|
||||
// `FilePicker.platform` singleton is gone.
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: reportExtensions,
|
||||
// On web there is no path at all, only bytes; asking for bytes everywhere would read
|
||||
// a 16 MB report into memory for nothing on desktop.
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return null;
|
||||
final file = result.files.first;
|
||||
return PickedReport(name: file.name, bytes: file.bytes, path: file.path);
|
||||
}
|
||||
}
|
||||
|
||||
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker());
|
||||
Reference in New Issue
Block a user