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();
}
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatQty, signColor;
import 'data/tax_api.dart';
import 'providers.dart';
/// Лоты и ЛДВ: the practical screen of the phase — what a sale costs **today** versus what
/// it costs after the three-year mark.
///
/// Lots close to the ЛДВ date are called out, because selling a lot 20 days early is the
/// one mistake this screen exists to prevent.
class TaxLotsTab extends ConsumerStatefulWidget {
const TaxLotsTab({super.key});
@override
ConsumerState<TaxLotsTab> createState() => _TaxLotsTabState();
}
class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
bool _onlyNearLdv = false;
@override
Widget build(BuildContext context) {
final lots = ref.watch(taxLotsProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(taxLotsProvider),
child: AsyncValueView(
value: lots,
onRetry: () => ref.invalidate(taxLotsProvider),
data: (all) {
final near = all.where((l) => l.nearLdv).toList();
// a copy: the provider's list must not be reordered under other watchers
final rows = [...(_onlyNearLdv ? near : all)];
// soonest ЛДВ first among the lots that do not have it yet, eligible ones last
rows.sort((a, b) {
if (a.ldvEligible != b.ldvEligible) return a.ldvEligible ? 1 : -1;
return (a.daysToLdv ?? 1 << 30).compareTo(b.daysToLdv ?? 1 << 30);
});
if (all.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
SizedBox(height: 48),
EmptyState(
icon: Icons.inventory_2_outlined,
message: 'Открытых лотов нет.',
),
],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (near.isNotEmpty)
Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile(
leading: const Icon(Icons.schedule),
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'),
subtitle: const Text(
'Продажа до этой даты облагается налогом на весь прирост.'),
trailing: FilterChip(
label: const Text('только они'),
selected: _onlyNearLdv,
onSelected: (v) => setState(() => _onlyNearLdv = v),
),
),
),
const SizedBox(height: 12),
SectionCard(
title: 'Открытые лоты',
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки',
child: _LotsTable(rows: rows),
),
],
);
},
),
);
}
}
class _LotsTable extends StatelessWidget {
const _LotsTable({required this.rows});
final List<TaxLot> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final headerStyle = theme.textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Бумага', style: headerStyle)),
DataColumn(label: Text('Куплен', style: headerStyle)),
DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true),
DataColumn(label: Text('Рынок', style: headerStyle), numeric: true),
DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true),
DataColumn(label: Text('Дата ЛДВ', style: headerStyle)),
DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true),
DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true),
],
rows: [
for (final l in rows)
DataRow(
color: l.nearLdv
? WidgetStatePropertyAll(
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5))
: null,
onSelectChanged: l.instrumentId == null
? null
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'),
cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.title),
if (l.nearLdv) ...[
const SizedBox(width: 6),
Tooltip(
message: 'До ЛДВ осталось ${l.daysToLdv} дн. — '
'продажа сейчас облагается налогом полностью',
child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error),
),
],
],
)),
DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))),
DataCell(Text(l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!))),
DataCell(l.costRub == null
? const Text('')
: MoneyText(l.costRub!, currency: 'RUB')),
DataCell(l.marketValueRub == null
? const Text('')
: MoneyText(l.marketValueRub!, currency: 'RUB')),
DataCell(l.unrealizedGainRub == null
? const Text('')
: MoneyText(
l.unrealizedGainRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, l.unrealizedGainRub)),
)),
DataCell(l.ldvEligible
? const Text('уже действует')
: Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!))),
DataCell(l.ldvEligible
? const Text('')
: Text(
l.daysToLdv == null ? '' : '${l.daysToLdv}',
style: l.nearLdv
? TextStyle(
color: theme.colorScheme.error, fontWeight: FontWeight.w600)
: null,
)),
DataCell(l.taxIfSoldNowRub == null
? const Text('')
: MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')),
],
),
],
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import 'data/tax_api.dart';
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio));
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
/// Optional account filter; null = all accounts.
final taxAccountProvider = StateProvider<int?>((ref) => null);
final taxSummaryProvider = FutureProvider.autoDispose<TaxSummary>((ref) async {
return ref.watch(taxApiProvider).summary(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
);
});
final taxLotsProvider = FutureProvider.autoDispose<List<TaxLot>>((ref) async {
return ref.watch(taxApiProvider).lots(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
);
});
void invalidateTaxProviders(WidgetRef ref) {
ref.invalidate(taxSummaryProvider);
ref.invalidate(taxLotsProvider);
}
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatPercent, signColor;
import 'data/tax_api.dart';
import 'providers.dart';
/// Сводка: the estimated tax picture for the year, per account and in total.
class TaxSummaryTab extends ConsumerWidget {
const TaxSummaryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(taxSummaryProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(taxSummaryProvider),
child: AsyncValueView(
value: summary,
onRetry: () => ref.invalidate(taxSummaryProvider),
data: (data) {
final totals = data.totals;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(width: 12),
if (data.estimated)
const Chip(
avatar: Icon(Icons.calculate_outlined, size: 16),
label: Text('оценка'),
visualDensity: VisualDensity.compact,
),
const Spacer(),
if (data.taxRate != null)
Text('ставка ${formatPercent(data.taxRate, signed: false)}'),
],
),
const SizedBox(height: 12),
if (totals != null) ...[
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Оценка налога',
value: _money(totals.estimatedTaxRub),
note: 'по всем счетам за год',
),
StatTile(
label: 'Удержано брокером',
value: _money(totals.taxWithheldRub),
note: 'по данным операций',
),
StatTile(
label: 'Налоговая база',
value: _money(totals.taxableBaseRub),
note: 'после вычета ЛДВ',
),
StatTile(
label: 'Освобождено по ЛДВ',
value: _money(totals.ldvExemptRub),
note: 'оценка по ст. 219.1',
),
StatTile(
label: 'Дивиденды и купоны',
value: _money(totals.dividendsGrossRub),
note: 'купоны ${_moneyText(totals.couponsGrossRub)}',
),
StatTile(
label: 'Реализовано',
value: _money(totals.realizedGainRub),
note: 'убыток ${_moneyText(totals.realizedLossRub)}',
),
],
),
const SizedBox(height: 16),
],
if (data.accounts.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 32),
child: EmptyState(
icon: Icons.receipt_long_outlined,
message: 'За этот год нет ни сделок, ни выплат.',
),
)
else
SectionCard(
title: 'По счетам',
subtitle: 'все суммы — оценка; авторитет — справка брокера',
child: _AccountsTable(rows: data.accounts, totals: totals),
),
],
);
},
),
);
}
static Widget _money(String? value) =>
value == null ? const Text('') : MoneyText(value, currency: 'RUB');
static String _moneyText(String? value) =>
value == null ? '' : MoneyText.format(value, 'RUB');
}
class _AccountsTable extends StatelessWidget {
const _AccountsTable({required this.rows, this.totals});
final List<TaxRow> rows;
final TaxRow? totals;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Счёт', style: headerStyle)),
DataColumn(label: Text('Дивиденды', style: headerStyle), numeric: true),
DataColumn(label: Text('Купоны', style: headerStyle), numeric: true),
DataColumn(label: Text('Удержано', style: headerStyle), numeric: true),
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true),
DataColumn(label: Text('Убыток', style: headerStyle), numeric: true),
DataColumn(label: Text('ЛДВ', style: headerStyle), numeric: true),
DataColumn(label: Text('База', style: headerStyle), numeric: true),
DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true),
],
rows: [
for (final r in rows) _row(context, r, bold: false),
if (totals != null) _row(context, totals!, bold: true),
],
),
);
}
DataRow _row(BuildContext context, TaxRow r, {required bool bold}) {
final style = bold ? Theme.of(context).textTheme.titleSmall : null;
Widget cell(String? v, {bool signed = false}) => v == null
? Text('', style: style)
: MoneyText(
v,
currency: 'RUB',
style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style,
);
return DataRow(
cells: [
DataCell(Text(bold ? 'Итого' : r.title, style: style)),
DataCell(cell(r.dividendsGrossRub)),
DataCell(cell(r.couponsGrossRub)),
DataCell(cell(r.taxWithheldRub)),
DataCell(cell(r.realizedGainRub, signed: true)),
DataCell(cell(r.realizedLossRub, signed: true)),
DataCell(cell(r.ldvExemptRub)),
DataCell(cell(r.taxableBaseRub)),
DataCell(cell(r.estimatedTaxRub)),
],
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'data/tax_api.dart';
import 'lots_tab.dart';
import 'providers.dart';
import 'summary_tab.dart';
/// Налоги: the year summary and the open-lot list with ЛДВ dates.
///
/// The word «оценка» is on the screen itself, not in a tooltip: the tax agent is the
/// broker, and these numbers exist so that the broker's statement can be checked against
/// something — not to replace it.
class TaxPage extends ConsumerWidget {
const TaxPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Налоги (оценка)'),
actions: [
const _YearSelector(),
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => invalidateTaxProviders(ref),
),
],
bottom: const TabBar(
tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')],
),
),
body: const Column(
children: [
EstimateBanner(),
Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])),
],
),
),
);
}
}
/// The disclaimer, always visible above both tabs.
class EstimateBanner extends ConsumerWidget {
const EstimateBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(taxSummaryProvider).valueOrNull;
final scheme = Theme.of(context).colorScheme;
final text = summary?.disclaimer ?? TaxSummary.defaultDisclaimer;
return Material(
color: scheme.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
);
}
}
class _YearSelector extends ConsumerWidget {
const _YearSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final year = ref.watch(taxYearProvider);
final now = DateTime.now().year;
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: year,
borderRadius: BorderRadius.circular(8),
items: [
for (var y = now; y >= now - 6; y--)
DropdownMenuItem(value: y, child: Text('$y')),
],
onChanged: (v) {
if (v != null) ref.read(taxYearProvider.notifier).state = v;
},
),
);
}
}