/// 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 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 byKind; static ImportCounts fromJson(Map? 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 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 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 positions; final List cash; bool get isEmpty => positions.isEmpty && cash.isEmpty; static Reconciliation? fromJson(Map? 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 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 accountSuggestions; final DateTime? periodFrom; final DateTime? periodTo; final DateTime? uploadedAt; final DateTime? committedAt; final ImportCounts counts; final List pendingInstruments; final Reconciliation? reconciliation; final List warnings; final List 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 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 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? bytes; final String? path; } class ImportsApi { const ImportsApi(this._dio); final Dio _dio; static const _base = '/api/v1/imports'; Future> list({ int limit = 50, int offset = 0, String? status, }) async { final r = await _dio.get>( _base, queryParameters: {'limit': limit, 'offset': offset, 'status': ?status}, ); return (r.data ?? const []) .map((e) => ImportPreview.fromJson(Map.from(e as Map))) .toList(); } Future get(int id) async { final r = await _dio.get>('$_base/$id'); return ImportPreview.fromJson(r.data!); } Future 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>(_base, data: form); return ImportPreview.fromJson(r.data!); } Future commit( int id, { int? accountId, bool confirmDuplicates = false, bool dryRun = false, }) async { final r = await _dio.post>( '$_base/$id/commit', data: { 'account_id': ?accountId, 'confirm_duplicates': confirmDuplicates, 'dry_run': dryRun, }, ); return ImportResult.fromJson(r.data ?? const {}); } Future delete(int id) => _dio.delete('$_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> asList(Object? v) => v is List ? v.whereType().map((e) => Map.from(e)).toList() : const []; Map? asMap(Object? v) => v is Map ? Map.from(v) : null;