Files
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

460 lines
14 KiB
Dart

/// 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;