Files
fin-tracker/app/lib/features/pending/data/pending_api.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

213 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);
}