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:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
@@ -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')}';
}