Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
460 lines
14 KiB
Dart
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;
|