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,212 @@
|
||||
/// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user