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,255 @@
|
||||
/// Hand-written client for `/api/v1/income`.
|
||||
///
|
||||
/// **Temporary.** The income routes are not in `openapi/openapi.json` yet, so the generated
|
||||
/// package `app/packages/api_client` knows nothing about them. Models and calls here follow
|
||||
/// `docs/ai/phase4-contract.md` §1 literally and are meant to be **replaced by the
|
||||
/// generated client** as soon as the routes land in the spec and `just gen-client` runs.
|
||||
///
|
||||
/// 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/utils/json.dart';
|
||||
|
||||
/// One expected (or already paid) payment.
|
||||
///
|
||||
/// [basis] is not decoration: `schedule` is arithmetic over a published schedule,
|
||||
/// `announced` is a fact the issuer declared, `history` is an extrapolation that can be
|
||||
/// wrong by any amount, and `paid` already happened. The screen must show it on every row.
|
||||
class IncomeEntry {
|
||||
const IncomeEntry({
|
||||
required this.kind,
|
||||
required this.basis,
|
||||
required this.currency,
|
||||
this.instrumentId,
|
||||
this.ticker,
|
||||
this.name,
|
||||
this.expectedDate,
|
||||
this.recordDate,
|
||||
this.qty,
|
||||
this.perUnit,
|
||||
this.amount,
|
||||
this.amountRub,
|
||||
this.taxWithheld,
|
||||
});
|
||||
|
||||
final int? instrumentId;
|
||||
final String? ticker;
|
||||
final String? name;
|
||||
|
||||
/// `dividend | coupon | amortization | repayment` — a plain string, like every stable key.
|
||||
final String kind;
|
||||
final DateTime? expectedDate;
|
||||
final DateTime? recordDate;
|
||||
final String? qty;
|
||||
final String? perUnit;
|
||||
final String? amount;
|
||||
final String currency;
|
||||
|
||||
/// Null when there is no FX rate for the date — not zero.
|
||||
final String? amountRub;
|
||||
|
||||
/// `schedule | announced | history | paid`.
|
||||
final String basis;
|
||||
final String? taxWithheld;
|
||||
|
||||
String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId');
|
||||
|
||||
static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry(
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
ticker: asString(json['ticker']),
|
||||
name: asString(json['name']),
|
||||
kind: asString(json['kind']) ?? 'dividend',
|
||||
expectedDate: asDate(json['expected_date']),
|
||||
recordDate: asDate(json['record_date']),
|
||||
qty: asString(json['qty']),
|
||||
perUnit: asString(json['per_unit']),
|
||||
amount: asString(json['amount']),
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
amountRub: asString(json['amount_rub']),
|
||||
basis: asString(json['basis']) ?? 'history',
|
||||
taxWithheld: asString(json['tax_withheld']),
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeCalendar {
|
||||
const IncomeCalendar({
|
||||
required this.totalExpectedRub,
|
||||
required this.currency,
|
||||
this.asOf,
|
||||
this.entries = const [],
|
||||
this.byBasis = const {},
|
||||
});
|
||||
|
||||
final DateTime? asOf;
|
||||
final String currency;
|
||||
final String totalExpectedRub;
|
||||
final List<IncomeEntry> entries;
|
||||
final Map<String, String> byBasis;
|
||||
|
||||
static IncomeCalendar fromJson(Map<String, dynamic> json) => IncomeCalendar(
|
||||
asOf: asDate(json['as_of']),
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
totalExpectedRub: asString(json['total_expected_rub']) ?? '0',
|
||||
entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(),
|
||||
byBasis: asStringMap(json['by_basis']),
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeHistoryRow {
|
||||
const IncomeHistoryRow({
|
||||
required this.kind,
|
||||
required this.currency,
|
||||
required this.amount,
|
||||
this.month,
|
||||
this.amountRub,
|
||||
this.taxWithheld,
|
||||
this.paymentCount = 0,
|
||||
});
|
||||
|
||||
final DateTime? month;
|
||||
final String kind;
|
||||
final String currency;
|
||||
final String amount;
|
||||
final String? amountRub;
|
||||
final String? taxWithheld;
|
||||
final int paymentCount;
|
||||
|
||||
static IncomeHistoryRow fromJson(Map<String, dynamic> json) => IncomeHistoryRow(
|
||||
month: asDate(json['month']),
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
amount: asString(json['amount']) ?? '0',
|
||||
amountRub: asString(json['amount_rub']),
|
||||
taxWithheld: asString(json['tax_withheld']),
|
||||
paymentCount: asInt(json['payment_count']) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeHistory {
|
||||
const IncomeHistory({
|
||||
this.rows = const [],
|
||||
this.totalRub = '0',
|
||||
this.taxWithheldRub = '0',
|
||||
});
|
||||
|
||||
final List<IncomeHistoryRow> rows;
|
||||
final String totalRub;
|
||||
final String taxWithheldRub;
|
||||
|
||||
static IncomeHistory fromJson(Map<String, dynamic> json) {
|
||||
final totals = asObject(json['totals']) ?? const {};
|
||||
return IncomeHistory(
|
||||
rows: asObjects(json['rows']).map(IncomeHistoryRow.fromJson).toList(),
|
||||
totalRub: asString(totals['amount_rub']) ?? '0',
|
||||
taxWithheldRub: asString(totals['tax_withheld_rub']) ?? '0',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ForecastMonth {
|
||||
const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}});
|
||||
|
||||
final DateTime? month;
|
||||
final String amountRub;
|
||||
|
||||
/// The split the total must never hide: a month made of `history` alone is a guess.
|
||||
final Map<String, String> byBasis;
|
||||
|
||||
static ForecastMonth fromJson(Map<String, dynamic> json) => ForecastMonth(
|
||||
month: asDate(json['month']),
|
||||
amountRub: asString(json['amount_rub']) ?? '0',
|
||||
byBasis: asStringMap(json['by_basis']),
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeForecast {
|
||||
const IncomeForecast({
|
||||
required this.totalRub,
|
||||
this.months = const [],
|
||||
this.annualYieldOnValue,
|
||||
this.warnings = const [],
|
||||
});
|
||||
|
||||
final List<ForecastMonth> months;
|
||||
final String totalRub;
|
||||
|
||||
/// Null when the current value is unknown — shown as an em dash, never as 0 %.
|
||||
final String? annualYieldOnValue;
|
||||
final List<String> warnings;
|
||||
|
||||
/// Every basis present anywhere in the forecast, in contract order.
|
||||
List<String> get bases {
|
||||
const order = ['schedule', 'announced', 'history', 'paid'];
|
||||
final seen = {for (final m in months) ...m.byBasis.keys};
|
||||
return [
|
||||
...order.where(seen.contains),
|
||||
...seen.where((b) => !order.contains(b)),
|
||||
];
|
||||
}
|
||||
|
||||
static IncomeForecast fromJson(Map<String, dynamic> json) => IncomeForecast(
|
||||
months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(),
|
||||
totalRub: asString(json['total_rub']) ?? '0',
|
||||
annualYieldOnValue: asString(json['annual_yield_on_value']),
|
||||
warnings: asStrings(json['warnings']),
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeApi {
|
||||
const IncomeApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _base = '/api/v1/income';
|
||||
|
||||
Future<IncomeCalendar> calendar({
|
||||
String scope = 'all',
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
bool includePaid = false,
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/calendar', queryParameters: {
|
||||
'scope': scope,
|
||||
'date_from': ?_isoDate(dateFrom),
|
||||
'date_to': ?_isoDate(dateTo),
|
||||
'include_paid': includePaid,
|
||||
});
|
||||
return IncomeCalendar.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<IncomeHistory> history({
|
||||
String scope = 'all',
|
||||
String group = 'month',
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
String? kind,
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/history', queryParameters: {
|
||||
'scope': scope,
|
||||
'group': group,
|
||||
'date_from': ?_isoDate(dateFrom),
|
||||
'date_to': ?_isoDate(dateTo),
|
||||
'kind': ?kind,
|
||||
});
|
||||
return IncomeHistory.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<IncomeForecast> forecast({String scope = 'all', int months = 12}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: {
|
||||
'scope': scope,
|
||||
'months': months,
|
||||
});
|
||||
return IncomeForecast.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// `format: date` on the wire. `DateQueryInterceptor` does this for the generated client;
|
||||
/// this layer builds its query maps itself, so it truncates here.
|
||||
static String? _isoDate(DateTime? d) => d == null
|
||||
? null
|
||||
: '${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
Reference in New Issue
Block a user