"""Загрузка выгрузки сделок QUIK: проверка файла, предпросмотр и сохранение операций одной транзакцией (D17, D19, D20).""" from __future__ import annotations import logging from dataclasses import dataclass from typing import Any import streamlit as st from app.db.base import run_db from app.imports.quik import QuikFormatError, QuikParseResult, parse_quik_xlsx from app.services import quik_import from app.services.quik_import import ( EXCLUDE, INCLUDE, UNKNOWN, ImportContext, ImportPlan, PlannedRow, QuikImportError, RowStatus, ) from app.ui.common import as_table, call_db, current_user logger = logging.getLogger(__name__) _FILE_KEY = "quik_file" _UPLOAD_KEY = "quik_upload" _SAVED_KEY = "quik_saved" # Сброс поля загрузки после сохранения: у виджета меняется ключ. _NONCE_KEY = "quik_file_nonce" _PER_ROW = "Решить по строкам" _ALL_EXCLUDE = "Исключить все" _NO_DECISION = "—" _EXCLUDE_LABEL = "Исключить" _STATUS_LABELS = { RowStatus.OWN: "в портфель", RowStatus.UNKNOWN_INCLUDED: "в портфель (решение пользователя)", RowStatus.UNKNOWN_EXCLUDED: "исключена", RowStatus.UNKNOWN_PENDING: "нужно решение", RowStatus.OTHER: "другой портфель", RowStatus.LOADED_ELSEWHERE: "уже загружена", RowStatus.LOADED_HERE: "уже загружена", } _TRADE_COLUMNS = { "row": "Строка", "trade_date": "Дата", "trade_time": "Время", "instrument": "Инструмент", "isin": "ISIN", "operation": "Операция", "quantity": "Кол-во", "price": "Цена", "amount": "Объем", "settlement_amount": "Расчетная сумма", "currency": "Валюта", "comment": "Комментарий", "order_number": "Заявка", "commission_ts": "Комиссия ТС", "status": "Статус", } _TOTAL_COLUMNS = { "operation": "Операция", "currency": "Валюта", "trades": "Сделок", "quantity": "Кол-во", "amount": "Объем", "settlement_amount": "Расчетная сумма", } _COMMISSION_COLUMNS = { "currency": "Валюта", "commission_ts": "Комиссия ТС", "commission_clearing": "Клиринговая", "commission_exchange": "ФБ", "commission_tc": "ТЦ", "commission_broker": "Брокера", } @dataclass(frozen=True) class QuikUpload: file_name: str result: QuikParseResult | None error: str | None = None def render() -> None: st.header("Загрузка сделок QUIK") saved = st.session_state.pop(_SAVED_KEY, None) if saved: st.success(saved) file_key = f"{_FILE_KEY}_{st.session_state.get(_NONCE_KEY, 0)}" st.file_uploader( "Выгрузка сделок QUIK (.xlsx, таблица с комментарием, заявкой и комиссиями)", type=["xlsx"], key=file_key, on_change=_on_file_change, args=(file_key,), ) upload = st.session_state.get(_UPLOAD_KEY) if not isinstance(upload, QuikUpload): return if upload.error or upload.result is None: st.error(upload.error or "Не удалось разобрать файл.") return _render_result(upload.file_name, upload.result) def _on_file_change(file_key: str) -> None: file = st.session_state.get(file_key) if file is None: st.session_state.pop(_UPLOAD_KEY, None) return try: upload = QuikUpload(file.name, parse_quik_xlsx(file.getvalue())) except QuikFormatError as exc: upload = QuikUpload(file.name, None, str(exc)) except Exception: # noqa: BLE001 logger.exception("Не удалось разобрать выгрузку QUIK %s", file.name) upload = QuikUpload( file.name, None, "Не удалось разобрать файл — подробности в логе." ) st.session_state[_UPLOAD_KEY] = upload def _render_result(file_name: str, result: QuikParseResult) -> None: st.caption( f"{file_name}: лист «{result.sheet}», сделок {len(result.trades)}" + ( f", период {result.date_from:%d.%m.%Y} – {result.date_to:%d.%m.%Y}" if result.trades else "" ) ) if result.errors: st.error( f"Ошибок в файле: {len(result.errors)}. Исправьте выгрузку и " "загрузите её заново — файл с ошибками не загружается." ) st.dataframe(_issues_table(result.errors), hide_index=True) return if result.warnings: st.warning(f"Предупреждений: {len(result.warnings)} — загрузке не мешают.") with st.expander("Предупреждения"): st.dataframe(_issues_table(result.warnings), hide_index=True) context = call_db( quik_import.load_context(result), "Не удалось проверить файл по базе" ) if context is None: return portfolio_code = _select_portfolio(result, context) if portfolio_code is None: return plan = quik_import.build_plan(result.trades, context, portfolio_code, {}) _render_file_summary(plan, context) decisions = _render_unknown_rows(plan, result.content_hash or "") if decisions: plan = quik_import.build_plan(result.trades, context, portfolio_code, decisions) _render_plan(plan) _render_save(plan, file_name, result, decisions) def _select_portfolio(result: QuikParseResult, context: ImportContext) -> str | None: codes = [p.code for p in context.portfolios] names = {p.code: p.name for p in context.portfolios} suggested = quik_import.suggest_portfolio(result.trades) return st.selectbox( "Портфель", codes, index=codes.index(suggested) if suggested in codes else None, format_func=lambda code: f"{code} — {names[code]}", placeholder="Выберите портфель", help="По умолчанию — по самому частому комментарию (/uw, /pp). Каждая " "загрузка берёт только строки своего портфеля.", ) def _render_file_summary(plan: ImportPlan, context: ImportContext) -> None: summary = plan.summary columns = st.columns(len(summary.loaded) + 2) columns[0].metric("Строк в файле", summary.rows_total) for column, (code, count) in zip(columns[1:], summary.loaded.items()): column.metric(f"Загружено в {code}", count) columns[-1].metric("Не загружено никуда", summary.nowhere) if plan.duplicate is not None: batch = plan.duplicate st.error( f"Этот файл уже загружен в {batch.portfolio_code} " f"{batch.created_at:%d.%m.%Y %H:%M} ({batch.created_by}): " f"строк загружено {batch.rows_loaded}, исключено {batch.rows_excluded}. " "Повторная загрузка того же файла в тот же портфель запрещена." ) for batch in plan.overlapping: st.warning( f"Период пересекается с другим файлом «{batch.file_name}» в " f"{batch.portfolio_code} ({batch.date_from:%d.%m.%Y} – " f"{batch.date_to:%d.%m.%Y}, {batch.created_by}). Проверьте, что " "сделки не задваиваются." ) def _render_unknown_rows(plan: ImportPlan, content_hash: str) -> dict[int, str]: """Строки без портфеля (пусто, /trde, неизвестный комментарий) — решает пользователь: отнести к выбранному портфелю или явно исключить (D19).""" unknown = [row for row in plan.rows if row.status in UNKNOWN] if not unknown: return {} code = plan.portfolio_code include_label = f"В {code}" st.subheader(f"Строки без портфеля: {len(unknown)}") st.caption( "Пустой или неизвестный комментарий, а также /trde. " f"Отнесите их к {code} или явно исключите — молча они не пропускаются." ) key = f"quik_{content_hash[:16]}_{code}" mode = st.radio( "Решение", [_PER_ROW, f"Отнести все к {code}", _ALL_EXCLUDE], horizontal=True, key=f"{key}_mode", label_visibility="collapsed", ) ordinals = [row.trade.ordinal for row in unknown] if mode == _ALL_EXCLUDE: return dict.fromkeys(ordinals, EXCLUDE) if mode != _PER_ROW: return dict.fromkeys(ordinals, INCLUDE) table = [ {"Решение": _NO_DECISION, **row} for row in as_table([_trade_row(r) for r in unknown], _TRADE_COLUMNS) ] for row in table: row.pop("Статус") edited = st.data_editor( table, key=f"{key}_rows", hide_index=True, disabled=[c for c in table[0] if c != "Решение"], column_config={ "Решение": st.column_config.SelectboxColumn( options=[_NO_DECISION, include_label, _EXCLUDE_LABEL], required=True ) }, ) decisions = {} for ordinal, row in zip(ordinals, edited): if row["Решение"] == include_label: decisions[ordinal] = INCLUDE elif row["Решение"] == _EXCLUDE_LABEL: decisions[ordinal] = EXCLUDE return decisions def _render_plan(plan: ImportPlan) -> None: code = plan.portfolio_code to_load = plan.to_load st.subheader(f"Пойдёт в {code}: {len(to_load)}") if to_load: st.dataframe( as_table([_trade_row(r) for r in to_load], _TRADE_COLUMNS), hide_index=True, ) trades = [row.trade for row in to_load] st.dataframe( as_table(quik_import.totals(trades), _TOTAL_COLUMNS), hide_index=True ) st.dataframe( as_table(quik_import.commission_totals(trades), _COMMISSION_COLUMNS), hide_index=True, ) st.caption( "Комиссии показаны по отдельности: «Комиссия ТС» уже включает " "клиринговую и ФБ, складывать их нельзя. «Комиссии МБ» в отчёте = ТС." ) if plan.unmatched_instruments: st.warning( "Не найдены в справочнике бумаг (сопоставление по краткому имени " "MOEX): " + ", ".join( f"{name} ({count} стр.)" for name, count in plan.unmatched_instruments.items() ) + ". Добавьте их на странице «Бумаги»." ) for name, isins in plan.ambiguous_instruments.items(): st.warning( f"«{name}» — несколько бумаг ({', '.join(isins)}). Краткое имя " "должно однозначно указывать на выпуск — исправьте справочник бумаг." ) _render_other_rows(plan) if plan.ready: st.success( f"Файл проверен: {len(to_load)} строк в {code}, исключено " f"{plan.excluded}." ) elif plan.pending: st.info(f"Ждут решения строк без портфеля: {plan.pending}.") def _render_save( plan: ImportPlan, file_name: str, result: QuikParseResult, decisions: dict[int, str] ) -> None: code = plan.portfolio_code rows = [row.trade.ordinal for row in plan.to_load] if plan.blockers: st.caption("Загрузить нельзя: " + "; ".join(plan.blockers) + ".") clicked = st.button( f"Загрузить в {code}: {len(rows)} стр.", type="primary", disabled=not plan.ready, ) if not clicked: return try: with st.spinner("Сохраняем операции…"): saved = run_db( quik_import.save_import( result, file_name=file_name, portfolio_code=code, decisions=decisions, expected_rows=rows, user=current_user(), ) ) except QuikImportError as exc: st.error(str(exc)) return except Exception as exc: # noqa: BLE001 logger.exception("Ошибка сохранения выгрузки QUIK %s в %s", file_name, code) st.error(f"Не удалось сохранить операции: {exc}") return message = ( f"«{file_name}»: загружено в {saved.portfolio_code} {saved.rows_loaded} " f"стр., исключено {saved.rows_excluded}." ) rest = len(plan.with_status(RowStatus.OTHER)) if rest: message += ( f" Строк другого портфеля: {rest} — загрузите этот же файл ещё раз, " "выбрав другой портфель." ) st.session_state[_SAVED_KEY] = message st.session_state.pop(_UPLOAD_KEY, None) st.session_state[_NONCE_KEY] = st.session_state.get(_NONCE_KEY, 0) + 1 st.rerun() def _render_other_rows(plan: ImportPlan) -> None: groups = [ ( "Строки другого портфеля", plan.with_status(RowStatus.OTHER), "Загрузите этот же файл, выбрав другой портфель.", ), ( "Уже загружены из этого файла", plan.with_status(RowStatus.LOADED_ELSEWHERE, RowStatus.LOADED_HERE), "", ), ("Исключены", plan.with_status(RowStatus.UNKNOWN_EXCLUDED), ""), ] for title, rows, hint in groups: if not rows: continue with st.expander(f"{title}: {len(rows)}"): if hint: st.caption(hint) st.dataframe( as_table([_trade_row(r) for r in rows], _TRADE_COLUMNS), hide_index=True, ) def _trade_row(row: PlannedRow) -> dict[str, Any]: trade = row.trade status = _STATUS_LABELS[row.status] if row.loaded_in: status += f" в {row.loaded_in}" return { "row": trade.row, "trade_date": trade.trade_date.strftime("%d.%m.%Y"), "trade_time": trade.trade_time.isoformat(), "instrument": trade.instrument, "isin": _isin_label(row), "operation": trade.operation, "quantity": trade.quantity, "price": trade.price, "amount": trade.amount, "settlement_amount": trade.settlement_amount, "currency": trade.currency, "comment": trade.comment, "order_number": trade.order_number, "commission_ts": trade.commission_ts, "status": status, } def _isin_label(row: PlannedRow) -> str: if not row.securities: return "не найдена" return ", ".join(s.isin for s in row.securities) def _issues_table(issues: list[Any]) -> list[dict[str, Any]]: return [ {"Строка": issue.row, "Колонка": issue.column, "Что не так": issue.message} for issue in issues ]