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
+171
View File
@@ -0,0 +1,171 @@
/// Hand-written client for `/api/v1/tax`.
///
/// **Temporary.** The tax routes are not in `openapi/openapi.json` yet, so
/// `app/packages/api_client` has no generated models or methods for them. Everything here
/// follows `docs/ai/phase4-contract.md` §5 literally and is meant to be **replaced by the
/// generated client** once the routes land in the spec and `just gen-client` runs.
///
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot
/// refresh on 401 are already wired there.
library;
import 'package:dio/dio.dart';
import '../../../core/utils/json.dart';
/// Per-account (or total) tax figures for a year. Every number is an **estimate**: the tax
/// agent is the broker, and these exist so that the broker's statement can be checked.
class TaxRow {
const TaxRow({
this.accountId,
this.accountName,
this.dividendsGrossRub,
this.couponsGrossRub,
this.taxWithheldRub,
this.realizedGainRub,
this.realizedLossRub,
this.ldvExemptRub,
this.taxableBaseRub,
this.estimatedTaxRub,
});
final int? accountId;
final String? accountName;
final String? dividendsGrossRub;
final String? couponsGrossRub;
final String? taxWithheldRub;
final String? realizedGainRub;
final String? realizedLossRub;
final String? ldvExemptRub;
final String? taxableBaseRub;
final String? estimatedTaxRub;
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
static TaxRow fromJson(Map<String, dynamic> json) => TaxRow(
accountId: asInt(json['account_id']),
accountName: asString(json['account_name']),
dividendsGrossRub: asString(json['dividends_gross_rub']),
couponsGrossRub: asString(json['coupons_gross_rub']),
taxWithheldRub: asString(json['tax_withheld_rub']),
realizedGainRub: asString(json['realized_gain_rub']),
realizedLossRub: asString(json['realized_loss_rub']),
ldvExemptRub: asString(json['ldv_exempt_rub']),
taxableBaseRub: asString(json['taxable_base_rub']),
estimatedTaxRub: asString(json['estimated_tax_rub']),
);
}
class TaxSummary {
const TaxSummary({
required this.year,
required this.estimated,
this.taxRate,
this.accounts = const [],
this.totals,
this.disclaimer,
});
final int year;
/// Always true per the contract — and shown on screen, not hidden in a tooltip.
final bool estimated;
final String? taxRate;
final List<TaxRow> accounts;
final TaxRow? totals;
final String? disclaimer;
static const defaultDisclaimer =
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.';
static TaxSummary fromJson(Map<String, dynamic> json) {
final totals = asObject(json['totals']);
return TaxSummary(
year: asInt(json['year']) ?? DateTime.now().year,
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true,
taxRate: asString(json['tax_rate']),
accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(),
totals: totals == null ? null : TaxRow.fromJson(totals),
disclaimer: asString(json['disclaimer']),
);
}
}
/// An open lot with the date after which a sale falls under ЛДВ (the three-year exemption).
class TaxLot {
const TaxLot({
required this.lotId,
required this.ldvEligible,
this.instrumentId,
this.ticker,
this.accountId,
this.openDate,
this.qtyRemaining,
this.costRub,
this.marketValueRub,
this.unrealizedGainRub,
this.ldvDate,
this.daysToLdv,
this.taxIfSoldNowRub,
});
final int lotId;
final int? instrumentId;
final String? ticker;
final int? accountId;
final DateTime? openDate;
final String? qtyRemaining;
final String? costRub;
final String? marketValueRub;
final String? unrealizedGainRub;
final bool ldvEligible;
final DateTime? ldvDate;
final int? daysToLdv;
final String? taxIfSoldNowRub;
/// Close enough to ЛДВ that selling now is an expensive mistake. Six months is the
/// horizon at which a person can still decide to wait.
bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183;
String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId');
static TaxLot fromJson(Map<String, dynamic> json) => TaxLot(
lotId: asInt(json['lot_id']) ?? 0,
instrumentId: asInt(json['instrument_id']),
ticker: asString(json['ticker']),
accountId: asInt(json['account_id']),
openDate: asDate(json['open_date']),
qtyRemaining: asString(json['qty_remaining']),
costRub: asString(json['cost_rub']),
marketValueRub: asString(json['market_value_rub']),
unrealizedGainRub: asString(json['unrealized_gain_rub']),
ldvEligible: asBool(json['ldv_eligible']),
ldvDate: asDate(json['ldv_date']),
daysToLdv: asInt(json['days_to_ldv']),
taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']),
);
}
class TaxApi {
const TaxApi(this._dio);
final Dio _dio;
static const _base = '/api/v1/tax';
Future<TaxSummary> summary({required int year, int? accountId}) async {
final r = await _dio.get<Map<String, dynamic>>(
_base,
queryParameters: {'year': year, 'account_id': ?accountId},
);
return TaxSummary.fromJson(r.data ?? const {});
}
Future<List<TaxLot>> lots({required int year, int? accountId}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_base/lots',
queryParameters: {'year': year, 'account_id': ?accountId},
);
return asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList();
}
}