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,441 @@
|
||||
/// Hand-written client for `/api/v1/imports`.
|
||||
///
|
||||
/// **Temporary.** The import routes are not in `openapi/openapi.json` yet, so the generated
|
||||
/// package `app/packages/api_client` knows nothing about them. Everything here — models and
|
||||
/// calls — follows `docs/ai/import-contract.md` literally and is meant to be **replaced by
|
||||
/// the generated client** as soon as the routes land in the spec and `just gen-client` runs.
|
||||
/// Until then this is the only place in the app that talks to those endpoints.
|
||||
///
|
||||
/// 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/auth/auth_controller.dart' show problemMessage;
|
||||
import '../../pending/data/pending_api.dart';
|
||||
|
||||
/// A candidate account for an import whose `account_id` the server could not resolve.
|
||||
class AccountSuggestion {
|
||||
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? broker;
|
||||
final String? sourceId;
|
||||
|
||||
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion(
|
||||
id: asInt(json['id'])!,
|
||||
name: asString(json['name']) ?? '#${json['id']}',
|
||||
broker: asString(json['broker']),
|
||||
sourceId: asString(json['source_id']),
|
||||
);
|
||||
}
|
||||
|
||||
class ImportCounts {
|
||||
const ImportCounts({
|
||||
this.lines = 0,
|
||||
this.eventsTotal = 0,
|
||||
this.eventsNew = 0,
|
||||
this.eventsDuplicate = 0,
|
||||
this.eventsShadow = 0,
|
||||
this.eventsPending = 0,
|
||||
this.byKind = const {},
|
||||
});
|
||||
|
||||
final int lines;
|
||||
final int eventsTotal;
|
||||
final int eventsNew;
|
||||
final int eventsDuplicate;
|
||||
final int eventsShadow;
|
||||
final int eventsPending;
|
||||
final Map<String, int> byKind;
|
||||
|
||||
static ImportCounts fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return const ImportCounts();
|
||||
final byKind = json['by_kind'];
|
||||
return ImportCounts(
|
||||
lines: asInt(json['lines']) ?? 0,
|
||||
eventsTotal: asInt(json['events_total']) ?? 0,
|
||||
eventsNew: asInt(json['events_new']) ?? 0,
|
||||
eventsDuplicate: asInt(json['events_duplicate']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
eventsPending: asInt(json['events_pending']) ?? 0,
|
||||
byKind: byKind is Map
|
||||
? {
|
||||
for (final e in byKind.entries)
|
||||
e.key.toString(): asInt(e.value) ?? 0,
|
||||
}
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One position of the report checked against the ledger. Quantities stay strings here and
|
||||
/// are parsed into `Decimal` only where they are shown or compared.
|
||||
class ReconPosition {
|
||||
const ReconPosition({
|
||||
required this.matches,
|
||||
this.instrumentId,
|
||||
this.instrumentName,
|
||||
this.ticker,
|
||||
this.isin,
|
||||
this.qtyReport,
|
||||
this.qtyDerived,
|
||||
this.qtyDelta,
|
||||
});
|
||||
|
||||
final bool matches;
|
||||
final int? instrumentId;
|
||||
final String? instrumentName;
|
||||
final String? ticker;
|
||||
final String? isin;
|
||||
final String? qtyReport;
|
||||
final String? qtyDerived;
|
||||
final String? qtyDelta;
|
||||
|
||||
String get title => instrumentName ?? ticker ?? isin ?? '—';
|
||||
|
||||
static ReconPosition fromJson(Map<String, dynamic> json) => ReconPosition(
|
||||
matches: json['matches'] == true,
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
ticker: asString(json['ticker']),
|
||||
isin: asString(json['isin']),
|
||||
qtyReport: asString(json['qty_report']),
|
||||
qtyDerived: asString(json['qty_derived']),
|
||||
qtyDelta: asString(json['qty_delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class ReconCash {
|
||||
const ReconCash({
|
||||
required this.currency,
|
||||
required this.matches,
|
||||
this.balanceReport,
|
||||
this.balanceDerived,
|
||||
this.delta,
|
||||
});
|
||||
|
||||
final String currency;
|
||||
final bool matches;
|
||||
final String? balanceReport;
|
||||
final String? balanceDerived;
|
||||
final String? delta;
|
||||
|
||||
static ReconCash fromJson(Map<String, dynamic> json) => ReconCash(
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
matches: json['matches'] == true,
|
||||
balanceReport: asString(json['balance_report']),
|
||||
balanceDerived: asString(json['balance_derived']),
|
||||
delta: asString(json['delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class Reconciliation {
|
||||
const Reconciliation({
|
||||
required this.matches,
|
||||
this.asOf,
|
||||
this.positions = const [],
|
||||
this.cash = const [],
|
||||
});
|
||||
|
||||
final bool matches;
|
||||
final DateTime? asOf;
|
||||
final List<ReconPosition> positions;
|
||||
final List<ReconCash> cash;
|
||||
|
||||
bool get isEmpty => positions.isEmpty && cash.isEmpty;
|
||||
|
||||
static Reconciliation? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
return Reconciliation(
|
||||
matches: json['matches'] == true,
|
||||
asOf: asDate(json['as_of']),
|
||||
positions: asList(json['positions']).map(ReconPosition.fromJson).toList(),
|
||||
cash: asList(json['cash']).map(ReconCash.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed report line as the user should see it before committing anything.
|
||||
class SampleEvent {
|
||||
const SampleEvent({
|
||||
required this.lineNo,
|
||||
required this.kind,
|
||||
required this.isDuplicate,
|
||||
this.tradeDate,
|
||||
this.settleDate,
|
||||
this.instrumentKey,
|
||||
this.instrumentName,
|
||||
this.instrumentId,
|
||||
this.quantity,
|
||||
this.price,
|
||||
this.amount,
|
||||
this.currency,
|
||||
this.fee,
|
||||
this.tradeNo,
|
||||
this.dedupeKey,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final int lineNo;
|
||||
final String kind;
|
||||
final bool isDuplicate;
|
||||
final DateTime? tradeDate;
|
||||
final DateTime? settleDate;
|
||||
final String? instrumentKey;
|
||||
final String? instrumentName;
|
||||
final int? instrumentId;
|
||||
final String? quantity;
|
||||
final String? price;
|
||||
final String? amount;
|
||||
final String? currency;
|
||||
final String? fee;
|
||||
final String? tradeNo;
|
||||
final String? dedupeKey;
|
||||
final String? description;
|
||||
|
||||
static SampleEvent fromJson(Map<String, dynamic> json) => SampleEvent(
|
||||
lineNo: asInt(json['line_no']) ?? 0,
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
isDuplicate: json['is_duplicate'] == true,
|
||||
tradeDate: asDate(json['trade_date']),
|
||||
settleDate: asDate(json['settle_date']),
|
||||
instrumentKey: asString(json['instrument_key']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
quantity: asString(json['quantity']),
|
||||
price: asString(json['price']),
|
||||
amount: asString(json['amount']),
|
||||
currency: asString(json['currency']),
|
||||
fee: asString(json['fee']),
|
||||
tradeNo: asString(json['trade_no']),
|
||||
dedupeKey: asString(json['dedupe_key']),
|
||||
description: asString(json['description']),
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without
|
||||
/// `sample_events`, `pending_instruments` and `reconciliation`, so the list screen simply
|
||||
/// gets empty collections and a null reconciliation.
|
||||
class ImportPreview {
|
||||
const ImportPreview({
|
||||
required this.id,
|
||||
required this.filename,
|
||||
required this.parseStatus,
|
||||
this.broker,
|
||||
this.sha256,
|
||||
this.sizeBytes,
|
||||
this.parserName,
|
||||
this.parserVersion,
|
||||
this.error,
|
||||
this.duplicateOfId,
|
||||
this.accountExternalId,
|
||||
this.accountId,
|
||||
this.accountName,
|
||||
this.accountSuggestions = const [],
|
||||
this.periodFrom,
|
||||
this.periodTo,
|
||||
this.uploadedAt,
|
||||
this.committedAt,
|
||||
this.counts = const ImportCounts(),
|
||||
this.pendingInstruments = const [],
|
||||
this.reconciliation,
|
||||
this.warnings = const [],
|
||||
this.sampleEvents = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String filename;
|
||||
|
||||
/// `uploaded | parsed | committed | failed` — a plain string, like every other stable key.
|
||||
final String parseStatus;
|
||||
final String? broker;
|
||||
final String? sha256;
|
||||
final int? sizeBytes;
|
||||
final String? parserName;
|
||||
final String? parserVersion;
|
||||
final String? error;
|
||||
final int? duplicateOfId;
|
||||
final String? accountExternalId;
|
||||
final int? accountId;
|
||||
final String? accountName;
|
||||
final List<AccountSuggestion> accountSuggestions;
|
||||
final DateTime? periodFrom;
|
||||
final DateTime? periodTo;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? committedAt;
|
||||
final ImportCounts counts;
|
||||
final List<PendingInstrument> pendingInstruments;
|
||||
final Reconciliation? reconciliation;
|
||||
final List<String> warnings;
|
||||
final List<SampleEvent> sampleEvents;
|
||||
|
||||
bool get isCommitted => parseStatus == 'committed';
|
||||
bool get isFailed => parseStatus == 'failed';
|
||||
|
||||
/// Commit is allowed only once an account is known and the file actually parsed.
|
||||
bool get canCommit => accountId != null && !isCommitted && !isFailed;
|
||||
|
||||
bool get canDelete => !isCommitted;
|
||||
|
||||
static ImportPreview fromJson(Map<String, dynamic> json) => ImportPreview(
|
||||
id: asInt(json['id'])!,
|
||||
filename: asString(json['filename']) ?? 'без имени',
|
||||
parseStatus: asString(json['parse_status']) ?? 'uploaded',
|
||||
broker: asString(json['broker']),
|
||||
sha256: asString(json['sha256']),
|
||||
sizeBytes: asInt(json['size_bytes']),
|
||||
parserName: asString(json['parser_name']),
|
||||
parserVersion: asString(json['parser_version']),
|
||||
error: asString(json['error']),
|
||||
duplicateOfId: asInt(json['duplicate_of_id']),
|
||||
accountExternalId: asString(json['account_external_id']),
|
||||
accountId: asInt(json['account_id']),
|
||||
accountName: asString(json['account_name']),
|
||||
accountSuggestions:
|
||||
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(),
|
||||
periodFrom: asDate(json['period_from']),
|
||||
periodTo: asDate(json['period_to']),
|
||||
uploadedAt: asDate(json['uploaded_at']),
|
||||
committedAt: asDate(json['committed_at']),
|
||||
counts: ImportCounts.fromJson(asMap(json['counts'])),
|
||||
pendingInstruments:
|
||||
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(),
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
warnings: json['warnings'] is List
|
||||
? (json['warnings'] as List).map((e) => e.toString()).toList()
|
||||
: const [],
|
||||
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// What `POST /imports/{id}/commit` reports back.
|
||||
class ImportResult {
|
||||
const ImportResult({
|
||||
required this.importId,
|
||||
required this.committed,
|
||||
this.eventsCreated = 0,
|
||||
this.eventsUpdated = 0,
|
||||
this.eventsSkipped = 0,
|
||||
this.eventsShadow = 0,
|
||||
this.pendingInstruments = 0,
|
||||
this.reconciliation,
|
||||
this.metricsRefreshed = false,
|
||||
});
|
||||
|
||||
final int importId;
|
||||
final bool committed;
|
||||
final int eventsCreated;
|
||||
final int eventsUpdated;
|
||||
final int eventsSkipped;
|
||||
final int eventsShadow;
|
||||
final int pendingInstruments;
|
||||
final Reconciliation? reconciliation;
|
||||
final bool metricsRefreshed;
|
||||
|
||||
static ImportResult fromJson(Map<String, dynamic> json) => ImportResult(
|
||||
importId: asInt(json['import_id']) ?? 0,
|
||||
committed: json['committed'] == true,
|
||||
eventsCreated: asInt(json['events_created']) ?? 0,
|
||||
eventsUpdated: asInt(json['events_updated']) ?? 0,
|
||||
eventsSkipped: asInt(json['events_skipped']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
metricsRefreshed: json['metrics_refreshed'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop
|
||||
/// it hands over a [path] and reading the file is left to Dio. Both are supported so the
|
||||
/// web build keeps working.
|
||||
class PickedReport {
|
||||
const PickedReport({required this.name, this.bytes, this.path});
|
||||
|
||||
final String name;
|
||||
final List<int>? bytes;
|
||||
final String? path;
|
||||
}
|
||||
|
||||
class ImportsApi {
|
||||
const ImportsApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _base = '/api/v1/imports';
|
||||
|
||||
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'status': ?status,
|
||||
},
|
||||
);
|
||||
return (r.data ?? const [])
|
||||
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ImportPreview> get(int id) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/$id');
|
||||
return ImportPreview.fromJson(r.data!);
|
||||
}
|
||||
|
||||
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async {
|
||||
final bytes = report.bytes;
|
||||
final form = FormData.fromMap({
|
||||
'file': bytes != null
|
||||
? MultipartFile.fromBytes(bytes, filename: report.name)
|
||||
: await MultipartFile.fromFile(report.path!, filename: report.name),
|
||||
'account_id': ?accountId,
|
||||
'parser': ?parser,
|
||||
});
|
||||
final r = await _dio.post<Map<String, dynamic>>(_base, data: form);
|
||||
return ImportPreview.fromJson(r.data!);
|
||||
}
|
||||
|
||||
Future<ImportResult> commit(
|
||||
int id, {
|
||||
int? accountId,
|
||||
bool confirmDuplicates = false,
|
||||
bool dryRun = false,
|
||||
}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: {
|
||||
'account_id': ?accountId,
|
||||
'confirm_duplicates': confirmDuplicates,
|
||||
'dry_run': dryRun,
|
||||
});
|
||||
return ImportResult.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> delete(int id) => _dio.delete<void>('$_base/$id');
|
||||
}
|
||||
|
||||
/// RFC 7807 `detail` first; a readable Russian fallback for the statuses the contract names
|
||||
/// when the body carries no detail. Never surfaces a raw `DioException`.
|
||||
String importErrorMessage(Object error) {
|
||||
if (error is! DioException) return error.toString();
|
||||
final data = error.response?.data;
|
||||
if (data is Map) {
|
||||
final detail = data['detail'] ?? data['title'];
|
||||
if (detail is String && detail.isNotEmpty) return detail;
|
||||
}
|
||||
return switch (error.response?.statusCode) {
|
||||
413 => 'Файл больше 16 МБ',
|
||||
415 => 'Формат файла не распознан',
|
||||
422 => 'Файл не удалось разобрать',
|
||||
409 => 'Импорт уже закоммичен',
|
||||
404 => 'Импорт не найден',
|
||||
_ => problemMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asList(Object? v) => v is List
|
||||
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||
: const [];
|
||||
|
||||
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'imports_api.dart';
|
||||
|
||||
/// Picking a report is the one part of the import screen that touches the platform, so it
|
||||
/// sits behind this one-method interface: widget tests (and a future drag-and-drop variant)
|
||||
/// override [reportPickerProvider] instead of mocking a plugin.
|
||||
abstract class ReportPicker {
|
||||
Future<PickedReport?> pick();
|
||||
}
|
||||
|
||||
/// The extensions the report parsers accept.
|
||||
const reportExtensions = ['html', 'htm', 'xlsx', 'csv'];
|
||||
|
||||
class FilePickerReportPicker implements ReportPicker {
|
||||
const FilePickerReportPicker();
|
||||
|
||||
@override
|
||||
Future<PickedReport?> pick() async {
|
||||
// file_picker 11 exposes `pickFiles` as a static on [FilePicker]; the old
|
||||
// `FilePicker.platform` singleton is gone.
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: reportExtensions,
|
||||
// On web there is no path at all, only bytes; asking for bytes everywhere would read
|
||||
// a 16 MB report into memory for nothing on desktop.
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return null;
|
||||
final file = result.files.first;
|
||||
return PickedReport(name: file.name, bytes: file.bytes, path: file.path);
|
||||
}
|
||||
}
|
||||
|
||||
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker());
|
||||
@@ -0,0 +1,551 @@
|
||||
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 '../portfolio/labels.dart' show eventKindLabels, formatQty;
|
||||
import 'data/imports_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
import 'widgets/reconciliation_card.dart';
|
||||
import 'widgets/sample_events_table.dart';
|
||||
|
||||
/// The preview of one uploaded report: what the parser found, how it lines up with the
|
||||
/// ledger, and the one button that actually writes events. Nothing on this screen has
|
||||
/// touched `event` yet — upload only parses.
|
||||
class ImportPreviewPage extends ConsumerStatefulWidget {
|
||||
const ImportPreviewPage({required this.importId, super.key});
|
||||
|
||||
final int importId;
|
||||
|
||||
@override
|
||||
ConsumerState<ImportPreviewPage> createState() => _ImportPreviewPageState();
|
||||
}
|
||||
|
||||
class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
/// Chosen by the user when the server could not resolve `account_id` itself.
|
||||
int? _accountChoice;
|
||||
bool _confirmDuplicates = false;
|
||||
bool _busy = false;
|
||||
ImportResult? _result;
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
Future<void> _commit(ImportPreview preview) async {
|
||||
final accountId = preview.accountId ?? _accountChoice;
|
||||
if (accountId == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final result = await ref.read(importsApiProvider).commit(
|
||||
preview.id,
|
||||
accountId: preview.accountId == null ? accountId : null,
|
||||
confirmDuplicates: _confirmDuplicates,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _result = result);
|
||||
// The ledger changed: every screen that counts events or values positions is stale.
|
||||
invalidateLedgerDependents(ref);
|
||||
ref.invalidate(importsListProvider);
|
||||
ref.invalidate(importPreviewProvider(preview.id));
|
||||
_snack('Импортировано: создано ${result.eventsCreated}, '
|
||||
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack(importErrorMessage(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(ImportPreview preview) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить импорт?'),
|
||||
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. '
|
||||
'События в леджере не создавались, так что удалять нечего.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(importsApiProvider).delete(preview.id);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(importsListProvider);
|
||||
context.go('/imports');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack(importErrorMessage(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = ref.watch(importPreviewProvider(widget.importId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/imports'),
|
||||
),
|
||||
title: const Text('Импорт отчёта'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AsyncValueView(
|
||||
value: preview,
|
||||
onRetry: () => ref.invalidate(importPreviewProvider(widget.importId)),
|
||||
data: _buildBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
final accountId = p.accountId ?? _accountChoice;
|
||||
final recon = p.reconciliation;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_header(p),
|
||||
if (p.duplicateOfId != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(
|
||||
icon: Icons.copy_all_outlined,
|
||||
color: theme.colorScheme.secondary,
|
||||
title: 'Этот файл уже загружали',
|
||||
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. '
|
||||
'Повторная загрузка не создаёт новых событий.',
|
||||
),
|
||||
],
|
||||
if (p.isFailed) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(
|
||||
icon: Icons.error_outline,
|
||||
color: theme.colorScheme.error,
|
||||
title: 'Файл не разобрался',
|
||||
body: p.error ?? 'Парсер не смог прочитать отчёт.',
|
||||
),
|
||||
],
|
||||
if (p.accountId == null && !p.isFailed) ...[
|
||||
const SizedBox(height: 12),
|
||||
_accountPicker(p),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_countsCard(p),
|
||||
if (recon != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ReconciliationCard(reconciliation: recon),
|
||||
],
|
||||
if (p.pendingInstruments.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_pendingCard(p),
|
||||
],
|
||||
if (p.warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_warningsCard(p),
|
||||
],
|
||||
if (p.sampleEvents.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Строки отчёта', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}',
|
||||
style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 8),
|
||||
SampleEventsTable(events: p.sampleEvents),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_result != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_resultCard(_result!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_actions(p, accountId),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
final period = p.periodFrom != null && p.periodTo != null
|
||||
? '${ruDate(p.periodFrom!)} – ${ruDate(p.periodTo!)}'
|
||||
: 'период не определён';
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(p.filename, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
parseStatusChip(context, p.parseStatus),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_kv('Брокер', brokerLabel(p.broker)),
|
||||
_kv('Период', period),
|
||||
_kv(
|
||||
'Счёт',
|
||||
p.accountName ??
|
||||
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'),
|
||||
),
|
||||
if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!),
|
||||
if (p.parserName != null)
|
||||
_kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'),
|
||||
if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)),
|
||||
if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
|
||||
if (p.committedAt != null)
|
||||
_kv('Импортирован', ruDate(p.committedAt!.toLocal())),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _accountPicker(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('Счёт не определён',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: theme.colorScheme.onErrorContainer)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p.accountSuggestions.isEmpty
|
||||
? 'В отчёте номер счёта ${p.accountExternalId ?? '—'}, но подходящего '
|
||||
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.'
|
||||
: 'Выберите счёт, в который писать события. Без него импорт недоступен.',
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
if (p.accountSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _accountChoice,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Счёт',
|
||||
filled: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final s in p.accountSuggestions)
|
||||
DropdownMenuItem(
|
||||
value: s.id,
|
||||
child: Text([
|
||||
s.name,
|
||||
if (s.broker != null) brokerLabel(s.broker),
|
||||
if (s.sourceId != null) s.sourceId!,
|
||||
].join(' · ')),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _accountChoice = v),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _countsCard(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
final c = p.counts;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Что нашлось', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_stat('Строк', c.lines),
|
||||
_stat('Событий', c.eventsTotal),
|
||||
_stat('Новых', c.eventsNew, color: Colors.green),
|
||||
_stat('Дубликатов', c.eventsDuplicate,
|
||||
color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null,
|
||||
hint: 'Уже есть в леджере: будут обновлены, а не продублированы'),
|
||||
_stat('Shadow', c.eventsShadow,
|
||||
hint: 'Не первичный источник — в аналитику не идут'),
|
||||
_stat('Ждут инструмента', c.eventsPending,
|
||||
color: c.eventsPending > 0 ? theme.colorScheme.error : null,
|
||||
hint: 'Инструмент не распознан, события останутся в статусе pending'),
|
||||
],
|
||||
),
|
||||
if (c.byKind.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text('По типам', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final e in c.byKind.entries)
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pendingCard(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Нераспознанные инструменты (${p.pendingInstruments.length})',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
|
||||
'их события останутся в статусе pending и не попадут в аналитику.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final pi in p.pendingInstruments)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(pi.title),
|
||||
subtitle: Text([
|
||||
if (pi.isin != null) 'ISIN ${pi.isin}',
|
||||
'встречается ${pi.occurrences}',
|
||||
if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}',
|
||||
].join(' · ')),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.go('/instruments/pending'),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('К резолву инструментов'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _warningsCard(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Предупреждения', style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final w in p.warnings)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text('• $w'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultCard(ImportResult r) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Результат импорта', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_kv('Создано событий', '${r.eventsCreated}'),
|
||||
_kv('Обновлено', '${r.eventsUpdated}'),
|
||||
_kv('Пропущено', '${r.eventsSkipped}'),
|
||||
if (r.eventsShadow > 0) _kv('Shadow', '${r.eventsShadow}'),
|
||||
if (r.pendingInstruments > 0)
|
||||
_kv('Ждут инструмента', '${r.pendingInstruments}'),
|
||||
_kv('Метрики пересчитаны', r.metricsRefreshed ? 'да' : 'нет'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actions(ImportPreview p, int? accountId) {
|
||||
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (p.counts.eventsDuplicate > 0 && !p.isCommitted)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _confirmDuplicates,
|
||||
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
|
||||
title: const Text('Обновлять дубликаты'),
|
||||
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, '
|
||||
'которые уже есть в леджере'),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: canCommit ? () => _commit(p) : null,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.playlist_add_check),
|
||||
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
|
||||
),
|
||||
if (p.canDelete)
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _delete(p),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (accountId == null && !p.isFailed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Импорт недоступен, пока не выбран счёт.',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _banner({
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required String title,
|
||||
required String body,
|
||||
}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(color: color)),
|
||||
const SizedBox(height: 4),
|
||||
Text(body),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(String label, int value, {Color? color, String? hint}) {
|
||||
final theme = Theme.of(context);
|
||||
final tile = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
Text('$value',
|
||||
style: theme.textTheme.titleLarge?.copyWith(color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return hint == null ? tile : Tooltip(message: hint, child: tile);
|
||||
}
|
||||
|
||||
Widget _kv(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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 '../pending/providers.dart';
|
||||
import 'data/imports_api.dart';
|
||||
import 'data/report_picker.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Импорт: the list of uploaded broker reports plus the upload button.
|
||||
///
|
||||
/// Uploading never writes to the ledger — it parses the file and opens the preview, where
|
||||
/// the numbers are checked against the ledger before anything is committed.
|
||||
class ImportsPage extends ConsumerStatefulWidget {
|
||||
const ImportsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImportsPage> createState() => _ImportsPageState();
|
||||
}
|
||||
|
||||
class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
bool _uploading = false;
|
||||
|
||||
Future<void> _upload() async {
|
||||
final picked = await ref.read(reportPickerProvider).pick();
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final preview = await ref.read(importsApiProvider).upload(picked);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(importsListProvider);
|
||||
if (preview.duplicateOfId != null) {
|
||||
_snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}');
|
||||
}
|
||||
context.go('/imports/${preview.id}');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack(importErrorMessage(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imports = ref.watch(importsListProvider);
|
||||
final pendingCount = ref.watch(pendingCountProvider).valueOrNull ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Импорт отчётов'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(importsListProvider);
|
||||
ref.invalidate(pendingCountProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _uploading ? null : _upload,
|
||||
icon: _uploading
|
||||
? const SizedBox(
|
||||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(importsListProvider);
|
||||
ref.invalidate(pendingCountProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
|
||||
children: [
|
||||
if (pendingCount > 0)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: Text('Нераспознанных инструментов: $pendingCount'),
|
||||
subtitle: const Text(
|
||||
'События по ним ждут в статусе pending и не попадают в аналитику.'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.go('/instruments/pending'),
|
||||
),
|
||||
),
|
||||
const _StatusFilter(),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: imports,
|
||||
onRetry: () => ref.invalidate(importsListProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.upload_file_outlined,
|
||||
message: 'Отчёты ещё не загружались.\n'
|
||||
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
|
||||
),
|
||||
)
|
||||
: Column(children: [for (final row in rows) _ImportCard(item: row)]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusFilter extends ConsumerWidget {
|
||||
const _StatusFilter();
|
||||
|
||||
static const _options = <String?, String>{
|
||||
null: 'Все',
|
||||
'uploaded': 'Загружены',
|
||||
'parsed': 'Разобраны',
|
||||
'committed': 'Импортированы',
|
||||
'failed': 'С ошибкой',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(importsStatusFilterProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final e in _options.entries)
|
||||
ChoiceChip(
|
||||
label: Text(e.value),
|
||||
selected: current == e.key,
|
||||
onSelected: (_) =>
|
||||
ref.read(importsStatusFilterProvider.notifier).state = e.key,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportCard extends StatelessWidget {
|
||||
const _ImportCard({required this.item});
|
||||
|
||||
final ImportPreview item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final period = item.periodFrom != null && item.periodTo != null
|
||||
? '${ruDate(item.periodFrom!)} – ${ruDate(item.periodTo!)}'
|
||||
: 'период не определён';
|
||||
final subtitle = [
|
||||
period,
|
||||
item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'),
|
||||
if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}',
|
||||
if (item.sizeBytes != null) formatBytes(item.sizeBytes),
|
||||
].join(' · ');
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: () => context.go('/imports/${item.id}'),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 8),
|
||||
parseStatusChip(context, item.parseStatus),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${brokerLabel(item.broker)} · $subtitle'),
|
||||
if (item.counts.eventsTotal > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'событий ${item.counts.eventsTotal}'
|
||||
' · новых ${item.counts.eventsNew}'
|
||||
' · дубликатов ${item.counts.eventsDuplicate}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (item.isFailed && item.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(item.error!,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Russian labels for the stable string keys the import contract sends.
|
||||
const brokerLabels = {
|
||||
'sber': 'Сбер',
|
||||
'vtb': 'ВТБ',
|
||||
'tinvest': 'Т-Инвестиции',
|
||||
'csv': 'CSV',
|
||||
};
|
||||
|
||||
String brokerLabel(String? broker) => brokerLabels[broker] ?? broker ?? '—';
|
||||
|
||||
const parseStatusLabels = {
|
||||
'uploaded': 'загружен',
|
||||
'parsed': 'разобран',
|
||||
'committed': 'импортирован',
|
||||
'failed': 'ошибка',
|
||||
};
|
||||
|
||||
String parseStatusLabel(String status) => parseStatusLabels[status] ?? status;
|
||||
|
||||
/// The colour of a `parse_status` chip: only `failed` is an error, and only `committed`
|
||||
/// means the events are actually in the ledger.
|
||||
Color parseStatusColor(BuildContext context, String status) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return switch (status) {
|
||||
'committed' => Colors.green,
|
||||
'failed' => scheme.error,
|
||||
'parsed' => scheme.primary,
|
||||
_ => scheme.outline,
|
||||
};
|
||||
}
|
||||
|
||||
Widget parseStatusChip(BuildContext context, String status) {
|
||||
final color = parseStatusColor(context, status);
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(parseStatusLabel(status)),
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
labelStyle: TextStyle(color: color),
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
);
|
||||
}
|
||||
|
||||
String formatBytes(int? bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} КБ';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1).replaceAll('.', ',')} МБ';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../events/providers.dart';
|
||||
import '../home/providers.dart';
|
||||
import '../portfolio/providers.dart';
|
||||
import 'data/imports_api.dart';
|
||||
|
||||
final importsApiProvider = Provider<ImportsApi>((ref) => ImportsApi(ref.watch(apiProvider).dio));
|
||||
|
||||
/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so
|
||||
/// changing it refetches without rebuilding the page state.
|
||||
final importsStatusFilterProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref) async {
|
||||
final status = ref.watch(importsStatusFilterProvider);
|
||||
return ref.watch(importsApiProvider).list(status: status);
|
||||
});
|
||||
|
||||
/// A single import's preview. For an uncommitted import the server recomputes counts and
|
||||
/// reconciliation on every read, so this is deliberately re-fetched rather than cached from
|
||||
/// the list response.
|
||||
final importPreviewProvider =
|
||||
FutureProvider.autoDispose.family<ImportPreview, int>((ref, id) async {
|
||||
return ref.watch(importsApiProvider).get(id);
|
||||
});
|
||||
|
||||
/// Everything whose numbers change once events land in (or move inside) the ledger:
|
||||
/// portfolio, holdings, allocation, the value series, the dashboard and the event list.
|
||||
/// Called after a successful commit and after every pending-instrument resolve.
|
||||
void invalidateLedgerDependents(WidgetRef ref) {
|
||||
invalidatePortfolioProviders(ref);
|
||||
invalidateHomeProviders(ref);
|
||||
ref.invalidate(eventsControllerProvider);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/utils/ru_date.dart';
|
||||
import '../../../core/widgets/money_text.dart';
|
||||
import '../../portfolio/labels.dart' show formatQty;
|
||||
import '../data/imports_api.dart';
|
||||
|
||||
/// Сверка: the report's own positions and cash balances against what the ledger derives.
|
||||
///
|
||||
/// When everything matches this collapses to a single green line — a full table of zeroes
|
||||
/// is noise. A mismatch is the whole point of the screen, so it stays expanded and red.
|
||||
class ReconciliationCard extends StatelessWidget {
|
||||
const ReconciliationCard({required this.reconciliation, super.key});
|
||||
|
||||
final Reconciliation reconciliation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final asOf = reconciliation.asOf;
|
||||
final title = asOf == null ? 'Сверка' : 'Сверка на ${ruDate(asOf)}';
|
||||
|
||||
if (reconciliation.isEmpty) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline),
|
||||
title: Text(title),
|
||||
subtitle: const Text('В отчёте нет остатков для сверки'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (reconciliation.matches) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline, color: Colors.green),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
'Всё сошлось: позиций ${reconciliation.positions.length}, '
|
||||
'остатков ${reconciliation.cash.length}',
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Отчёт и леджер разошлись. Импортировать можно, но расхождение стоит объяснить.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (reconciliation.positions.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Позиции', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
rows: [
|
||||
for (final p in reconciliation.positions)
|
||||
_Row(
|
||||
cells: [
|
||||
p.title,
|
||||
_qty(p.qtyReport),
|
||||
_qty(p.qtyDerived),
|
||||
_qty(p.qtyDelta),
|
||||
],
|
||||
highlight: !p.matches,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (reconciliation.cash.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Денежные остатки', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
rows: [
|
||||
for (final c in reconciliation.cash)
|
||||
_Row(
|
||||
cells: [
|
||||
c.currency,
|
||||
_money(c.balanceReport, c.currency),
|
||||
_money(c.balanceDerived, c.currency),
|
||||
_money(c.delta, c.currency),
|
||||
],
|
||||
highlight: !c.matches,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A quantity the ledger could not produce is an em dash, not a zero: an unknown position
|
||||
/// and an empty one are different findings.
|
||||
static String _qty(String? value) =>
|
||||
value == null || value.isEmpty ? '—' : formatQty(value);
|
||||
|
||||
static String _money(String? value, String currency) =>
|
||||
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency);
|
||||
}
|
||||
|
||||
class _Row {
|
||||
const _Row({required this.cells, required this.highlight});
|
||||
final List<String> cells;
|
||||
final bool highlight;
|
||||
}
|
||||
|
||||
/// A narrow table that scrolls sideways rather than overflowing on a phone.
|
||||
class _ScrollableTable extends StatelessWidget {
|
||||
const _ScrollableTable({required this.columns, required this.rows});
|
||||
|
||||
final List<String> columns;
|
||||
final List<_Row> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 24,
|
||||
headingRowHeight: 36,
|
||||
dataRowMinHeight: 36,
|
||||
dataRowMaxHeight: 48,
|
||||
columns: [for (final c in columns) DataColumn(label: Text(c))],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
color: r.highlight
|
||||
? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4))
|
||||
: null,
|
||||
cells: [
|
||||
for (final cell in r.cells)
|
||||
DataCell(Text(
|
||||
cell,
|
||||
style: r.highlight
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/utils/ru_date.dart';
|
||||
import '../../../core/widgets/money_text.dart';
|
||||
import '../../portfolio/labels.dart' show eventKindLabels, formatQty;
|
||||
import '../data/imports_api.dart';
|
||||
|
||||
/// The first lines of the report as the parser read them. A row already present in the
|
||||
/// ledger (same `dedupe_key`) is marked: committing will update it, not add a second one.
|
||||
class SampleEventsTable extends StatelessWidget {
|
||||
const SampleEventsTable({required this.events, super.key});
|
||||
|
||||
final List<SampleEvent> events;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 20,
|
||||
headingRowHeight: 36,
|
||||
dataRowMinHeight: 36,
|
||||
dataRowMaxHeight: 52,
|
||||
columns: const [
|
||||
DataColumn(label: Text('№')),
|
||||
DataColumn(label: Text('Дата')),
|
||||
DataColumn(label: Text('Тип')),
|
||||
DataColumn(label: Text('Инструмент')),
|
||||
DataColumn(label: Text('Кол-во')),
|
||||
DataColumn(label: Text('Цена')),
|
||||
DataColumn(label: Text('Сумма')),
|
||||
DataColumn(label: Text('')),
|
||||
],
|
||||
rows: [
|
||||
for (final e in events)
|
||||
DataRow(
|
||||
color: e.isDuplicate
|
||||
? WidgetStatePropertyAll(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6))
|
||||
: null,
|
||||
cells: [
|
||||
DataCell(Text('${e.lineNo}')),
|
||||
DataCell(Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!))),
|
||||
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
|
||||
DataCell(
|
||||
Tooltip(
|
||||
message: e.instrumentKey ?? '',
|
||||
child: Text(e.instrumentName ?? e.instrumentKey ?? '—'),
|
||||
),
|
||||
),
|
||||
DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))),
|
||||
DataCell(Text(_money(e.price, e.currency))),
|
||||
DataCell(Text(_money(e.amount, e.currency))),
|
||||
DataCell(e.isDuplicate
|
||||
? Tooltip(
|
||||
message: 'Такое событие уже есть в леджере',
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('дубль'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _money(String? value, String? currency) =>
|
||||
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency ?? 'RUB');
|
||||
}
|
||||
Reference in New Issue
Block a user