Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
226 lines
6.7 KiB
Dart
226 lines
6.7 KiB
Dart
/// Hand-written client for `/api/v1/instruments/pending`.
|
|
///
|
|
/// **Temporary.** These routes do not exist in `openapi/openapi.json` yet, so
|
|
/// `app/packages/api_client` has no generated methods or models for them. The models and
|
|
/// calls here follow `docs/ai/import-contract.md` literally and are meant to be **deleted**
|
|
/// once the routes land in the spec and `just gen-client` regenerates the real client —
|
|
/// at that point the providers should switch to `getInstrumentsApi()` and these classes
|
|
/// should give way to the generated ones.
|
|
///
|
|
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, the bearer header and the
|
|
/// one-shot refresh on 401 are already wired there, so this layer only shapes URLs and JSON.
|
|
library;
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
/// A report line whose instrument the server refused to guess. Mirrors
|
|
/// `PendingInstrumentOut`; the extra fields (`firstSeenFileId`, `createdAt`) are absent
|
|
/// from the copy embedded in `ImportPreview.pending_instruments`, hence nullable.
|
|
class PendingInstrument {
|
|
const PendingInstrument({
|
|
required this.id,
|
|
required this.source,
|
|
required this.sourceKey,
|
|
required this.status,
|
|
this.isin,
|
|
this.ticker,
|
|
this.board,
|
|
this.name,
|
|
this.currency,
|
|
this.assetClassHint,
|
|
this.occurrences = 0,
|
|
this.sampleQuantity,
|
|
this.samplePrice,
|
|
this.instrumentId,
|
|
this.firstSeenFileId,
|
|
this.createdAt,
|
|
});
|
|
|
|
final int id;
|
|
final String source;
|
|
final String sourceKey;
|
|
final String status;
|
|
final String? isin;
|
|
final String? ticker;
|
|
final String? board;
|
|
final String? name;
|
|
final String? currency;
|
|
final String? assetClassHint;
|
|
final int occurrences;
|
|
final String? sampleQuantity;
|
|
final String? samplePrice;
|
|
final int? instrumentId;
|
|
final int? firstSeenFileId;
|
|
final DateTime? createdAt;
|
|
|
|
/// The best human label available, never a guess about which instrument this is.
|
|
String get title {
|
|
final parts = [?ticker, ?name];
|
|
if (parts.isNotEmpty) return parts.join(' · ');
|
|
return isin ?? sourceKey;
|
|
}
|
|
|
|
static PendingInstrument fromJson(Map<String, dynamic> json) =>
|
|
PendingInstrument(
|
|
id: asInt(json['id'])!,
|
|
source: asString(json['source']) ?? '',
|
|
sourceKey: asString(json['source_key']) ?? '',
|
|
status: asString(json['status']) ?? 'pending',
|
|
isin: asString(json['isin']),
|
|
ticker: asString(json['ticker']),
|
|
board: asString(json['board']),
|
|
name: asString(json['name']),
|
|
currency: asString(json['currency']),
|
|
assetClassHint: asString(json['asset_class_hint']),
|
|
occurrences: asInt(json['occurrences']) ?? 0,
|
|
sampleQuantity: asString(json['sample_quantity']),
|
|
samplePrice: asString(json['sample_price']),
|
|
instrumentId: asInt(json['instrument_id']),
|
|
firstSeenFileId: asInt(json['first_seen_file_id']),
|
|
createdAt: asDate(json['created_at']),
|
|
);
|
|
}
|
|
|
|
/// What `POST /instruments/pending/{id}/resolve` reports back.
|
|
class PendingResolveResult {
|
|
const PendingResolveResult({
|
|
required this.id,
|
|
required this.status,
|
|
this.instrumentId,
|
|
this.eventsBound = 0,
|
|
this.aliasCreated = false,
|
|
this.metricsRefreshed = false,
|
|
});
|
|
|
|
final int id;
|
|
final String status;
|
|
final int? instrumentId;
|
|
final int eventsBound;
|
|
final bool aliasCreated;
|
|
final bool metricsRefreshed;
|
|
|
|
static PendingResolveResult fromJson(Map<String, dynamic> json) =>
|
|
PendingResolveResult(
|
|
id: asInt(json['id']) ?? 0,
|
|
status: asString(json['status']) ?? 'resolved',
|
|
instrumentId: asInt(json['instrument_id']),
|
|
eventsBound: asInt(json['events_bound']) ?? 0,
|
|
aliasCreated: json['alias_created'] == true,
|
|
metricsRefreshed: json['metrics_refreshed'] == true,
|
|
);
|
|
}
|
|
|
|
/// The body of `action: "create"` — the user's own answer, typed in by hand.
|
|
class NewInstrument {
|
|
const NewInstrument({
|
|
required this.assetClass,
|
|
required this.name,
|
|
required this.currency,
|
|
this.isin,
|
|
this.ticker,
|
|
this.board,
|
|
this.lot,
|
|
});
|
|
|
|
/// A plain string on the wire: `AssetClass` is deliberately not exposed by the API
|
|
/// (`index` cannot be a Dart enum member — it collides with `Enum.index`).
|
|
final String assetClass;
|
|
final String name;
|
|
final String currency;
|
|
final String? isin;
|
|
final String? ticker;
|
|
final String? board;
|
|
final int? lot;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'asset_class': assetClass,
|
|
'name': name,
|
|
'currency': currency,
|
|
if (isin != null && isin!.isNotEmpty) 'isin': isin,
|
|
if (ticker != null && ticker!.isNotEmpty) 'ticker': ticker,
|
|
if (board != null && board!.isNotEmpty) 'board': board,
|
|
if (lot != null) 'lot': lot,
|
|
};
|
|
}
|
|
|
|
/// The asset classes the contract allows, as wire strings.
|
|
const assetClassKeys = [
|
|
'share',
|
|
'bond',
|
|
'etf',
|
|
'fund',
|
|
'currency',
|
|
'index',
|
|
'deposit',
|
|
'real_estate',
|
|
'crypto',
|
|
'custom',
|
|
];
|
|
|
|
class PendingApi {
|
|
const PendingApi(this._dio);
|
|
|
|
final Dio _dio;
|
|
|
|
static const _base = '/api/v1/instruments/pending';
|
|
|
|
Future<List<PendingInstrument>> list({
|
|
String status = 'pending',
|
|
int limit = 100,
|
|
int offset = 0,
|
|
}) async {
|
|
final r = await _dio.get<List<dynamic>>(
|
|
_base,
|
|
queryParameters: {'status': status, 'limit': limit, 'offset': offset},
|
|
);
|
|
return (r.data ?? const [])
|
|
.map(
|
|
(e) =>
|
|
PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map)),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
Future<PendingResolveResult> link(int id, int instrumentId) =>
|
|
_resolve(id, {'action': 'link', 'instrument_id': instrumentId});
|
|
|
|
Future<PendingResolveResult> create(int id, NewInstrument instrument) =>
|
|
_resolve(id, {'action': 'create', 'instrument': instrument.toJson()});
|
|
|
|
Future<PendingResolveResult> ignore(int id) =>
|
|
_resolve(id, {'action': 'ignore'});
|
|
|
|
Future<PendingResolveResult> _resolve(
|
|
int id,
|
|
Map<String, dynamic> body,
|
|
) async {
|
|
final r = await _dio.post<Map<String, dynamic>>(
|
|
'$_base/$id/resolve',
|
|
data: body,
|
|
);
|
|
return PendingResolveResult.fromJson(r.data ?? const {});
|
|
}
|
|
}
|
|
|
|
// --- JSON coercion helpers, shared with the imports layer -------------------------------
|
|
//
|
|
// The server sends money and quantities as strings and never as numbers; these helpers do
|
|
// not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept
|
|
// as its lossless string form and parsed into `Decimal` at the point of display.
|
|
|
|
String? asString(Object? v) =>
|
|
v == null ? null : (v is String ? v : v.toString());
|
|
|
|
int? asInt(Object? v) => switch (v) {
|
|
null => null,
|
|
final int i => i,
|
|
final String s => int.tryParse(s),
|
|
_ => null,
|
|
};
|
|
|
|
DateTime? asDate(Object? v) {
|
|
final s = asString(v);
|
|
if (s == null || s.isEmpty) return null;
|
|
return DateTime.tryParse(s);
|
|
}
|