diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c05ae2..8b30b4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,7 @@ repos: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.12.7 hooks: - id: ruff + - id: ruff-format diff --git a/README.md b/README.md index 8108120..f35cd4e 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,50 @@ -[![linting: ruff](https://img.shields.io/badge/linting-ruff-f9c700)](https://github.com/astral-sh/ruff) -__________________________________________________________________________________________________________ -# sfm, v 0.0.1 -__sfm__ +# SFM Streamlit + +Production-ready Streamlit приложение для обработки ФЭС XML файлов в Excel-отчет и протокол с доступом к файлам через SAMBA (`rail_connectors`). -| | Исполнитель | Заказчик | -|-----------|:----------------------------------------:|:----------------------------------------------:| -| Сотрудник | raykov-mse | raykov-mse | -| Отдел | aurora | aurora | -| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru | ------ ## Архитектура ```text main.py app/ - config.py # переменные окружения с префиксом OPENBAO__SETTINGS__ - ui.py # ui-компоненты + app_config.py # константы приложения и user-id + path_store.py # JSON-хранилище персональных путей + path_settings.py # UI-модалка изменения пути + samba_access.py # проверка доступа к SAMBA папке auth/ - service.py # orchestration auth/session - providers.py # local/mock и prod/raisa провайдеры - mock_provider.py # мок для локального запуска + service.py # orchestration auth/session + providers.py + mock_provider.py + pipeline/ + config.py # шаблоны имен/путей пайплайна + file_manager.py # scan/move/upload/download в SAMBA + parser.py # namespace-aware парсер ФЭС XML + report.py # генерация report.xlsx + protocol.py # генерация protocol.xlsx + service.py # run_batch orchestration +tests/ + unit/ + smoke/ ``` -## Режимы запуска +## Основной поток -- `OPENBAO__SETTINGS__LOCATION=local` -> локальный mock вход через форму. -- `OPENBAO__SETTINGS__LOCATION=prod` (или пусто) -> вход через `raisa_streamlit_oauth`. +1. Пользователь вводит SAMBA логин/пароль. +2. При необходимости меняет персональный `input_dir` (с pre-check доступа). +3. Нажимает `Запустить обработку xml в excel`. +4. Пайплайн: + - сканирует входную папку; + - невалидные имена переносит в `Err`; + - валидные XML парсит и формирует строки отчета; + - создает `report.xlsx`, `protocol.xlsx`, `process.log`; + - сохраняет артефакты в `Out/YYYY/MM_YYYY/YYYYMMDD/run_number/`; + - успешные файлы переносит в `Processed`. +5. В UI отображаются счетчики и пути к артефактам. -## Важно по raisa-streamlit-oauth +## Переменные окружения -- Используется версия `raisa-streamlit-oauth==1.0.3`. -- До старта приложения выполняется `load_dotenv()`. -- Переменные `OPENBAO__SETTINGS__*` автоматически маппятся в обычные - (`AUTHORIZE_URL`, `TOKEN_URL`, `APP_NAMESPACE` и т.д.), чтобы библиотека - работала по каноничному шаблону из документации. +Используются переменные авторизации Raisa (`OPENBAO__SETTINGS__*`). +SMBA source и дефолтный путь входа задаются в коде (`app/app_config.py`). ## Быстрый старт @@ -45,11 +56,13 @@ copy .env.example .env streamlit run main.py ``` -## Линт и формат +## Тесты и качество ```bash +pytest ruff check . -ruff format . +black --check . +isort --check-only . ``` ## Pre-commit @@ -59,7 +72,8 @@ pre-commit install pre-commit run --all-files ``` -Используются хуки: +Хуки: - `black` - `isort` -- `ruff` и `ruff-format` +- `ruff` +- `ruff-format` diff --git a/app/app_config.py b/app/app_config.py new file mode 100644 index 0000000..5e5c357 --- /dev/null +++ b/app/app_config.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +import streamlit as st + +SFM_SMB_BASE_PATH = "//sgo-fc01-r13/inbox-intech" +SFM_DEFAULT_INPUT_DIR = r"Exchange\SMBDEMO\test" +ACCESS_SUPPORT_CONTACT = "администратору СФМ" + +USER_PATHS_KEY = "user_input_dirs" +EDIT_PATH_OPEN_KEY = "edit_path_open" +EDIT_PATH_VALUE_KEY = "edit_path_value" +PATH_STORE_FILE = Path(__file__).resolve().parents[1] / ".user_paths.json" + + +def resolve_user_id() -> str: + profile = st.session_state.get("user_profile", {}) + if not isinstance(profile, dict): + return "anonymous|anonymous" + + email = str( + profile.get("email") or profile.get("preferred_username") or "anonymous" + ) + full_name = str(profile.get("name") or profile.get("full_name") or "anonymous") + return f"{email}|{full_name}" diff --git a/app/auth/providers.py b/app/auth/providers.py index d037f81..5eb01b5 100644 --- a/app/auth/providers.py +++ b/app/auth/providers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Any, Protocol from .mock_provider import get_jwt_token as get_jwt_token_local @@ -30,4 +31,9 @@ class RaisaProvider: def build_auth_provider() -> AuthProvider: + location = os.getenv( + "OPENBAO__SETTINGS__LOCATION", os.getenv("LOCATION", "prod") + ).lower() + if location == "local": + return LocalMockProvider() return RaisaProvider() diff --git a/app/auth/service.py b/app/auth/service.py index 3df1c90..d597e16 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -12,7 +12,13 @@ def clear_session() -> None: class AuthService: def run(self) -> bool: - if "user_profile" in st.session_state: + if "oauth_authenticated" not in st.session_state: + st.session_state["oauth_authenticated"] = False + + if ( + st.session_state.get("oauth_authenticated") + and "user_profile" in st.session_state + ): return True try: @@ -20,7 +26,8 @@ class AuthService: except ImportError: st.error( "Библиотека raisa_streamlit_oauth недоступна. " - "Для локальной разработки установите LOCATION=local." + "Для локальной разработки установите LOCATION=local " + "или OPENBAO__SETTINGS__LOCATION=local." ) return False @@ -37,4 +44,5 @@ class AuthService: st.session_state["token_data"] = token_data st.session_state["user_profile"] = token_data + st.session_state["oauth_authenticated"] = True return True diff --git a/app/path_settings.py b/app/path_settings.py new file mode 100644 index 0000000..f7e3516 --- /dev/null +++ b/app/path_settings.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import streamlit as st + +from app.app_config import ( + EDIT_PATH_OPEN_KEY, + EDIT_PATH_VALUE_KEY, + SFM_DEFAULT_INPUT_DIR, + SFM_SMB_BASE_PATH, +) +from app.path_store import get_user_path + + +def ensure_user_path_state(user_id: str) -> str: + current_path = get_user_path(user_id) + st.session_state.setdefault(EDIT_PATH_OPEN_KEY, False) + return current_path + + +def open_path_editor(current_path: str) -> None: + st.session_state[EDIT_PATH_OPEN_KEY] = True + st.session_state[EDIT_PATH_VALUE_KEY] = current_path + + +def close_path_editor() -> None: + st.session_state[EDIT_PATH_OPEN_KEY] = False + + +def render_path_editor() -> str | None: + if not st.session_state.get(EDIT_PATH_OPEN_KEY): + return None + + with st.container(border=True): + title_col, close_col = st.columns([9, 1]) + with title_col: + st.markdown( + "**Введите путь по которому обрабатывать файлы только для вас**" + ) + with close_col: + if st.button("✕", key="close_edit_path"): + close_path_editor() + st.rerun() + + edited_path = st.text_input( + "Путь", + key=EDIT_PATH_VALUE_KEY, + placeholder=r"Exchange\SMBDEMO\test", + help=( + "Укажите путь внутри SMB-шары относительно базового пути " + f"`{SFM_SMB_BASE_PATH}`." + ), + ) + save_col, cancel_col = st.columns(2) + with save_col: + if st.button("Сохранить", use_container_width=True): + return edited_path + with cancel_col: + if st.button("Отменить", use_container_width=True): + close_path_editor() + st.rerun() + return None + + +def get_resolved_input_dir(user_id: str) -> str: + return get_user_path(user_id) or SFM_DEFAULT_INPUT_DIR diff --git a/app/path_store.py b/app/path_store.py new file mode 100644 index 0000000..020a1fd --- /dev/null +++ b/app/path_store.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json + +from app.app_config import PATH_STORE_FILE, SFM_DEFAULT_INPUT_DIR + + +def get_user_path(user_id: str) -> str: + payload = _load_data() + value = payload.get(user_id) + if isinstance(value, str) and value.strip(): + return value + return SFM_DEFAULT_INPUT_DIR + + +def set_user_path(user_id: str, input_dir: str) -> None: + payload = _load_data() + payload[user_id] = input_dir + _save_data(payload) + + +def _load_data() -> dict[str, str]: + if not PATH_STORE_FILE.exists(): + return {} + try: + raw = json.loads(PATH_STORE_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + if not isinstance(raw, dict): + return {} + normalized: dict[str, str] = {} + for key, value in raw.items(): + if isinstance(key, str) and isinstance(value, str): + normalized[key] = value + return normalized + + +def _save_data(payload: dict[str, str]) -> None: + PATH_STORE_FILE.parent.mkdir(parents=True, exist_ok=True) + PATH_STORE_FILE.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000..f167d70 --- /dev/null +++ b/app/pipeline/__init__.py @@ -0,0 +1 @@ +"""Пайплайн обработки XML в Excel.""" diff --git a/app/pipeline/config.py b/app/pipeline/config.py new file mode 100644 index 0000000..c8555cd --- /dev/null +++ b/app/pipeline/config.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import re +from datetime import datetime + +FILE_NAME_PATTERNS = ( + re.compile(r"^SKO115FZ_\d{2}_[A-Za-zА-Яа-я0-9]{9}_\d{8}_[ХX]\d{5}\.xml$"), +) + +DEFAULT_OUTPUT_DIR_NAME = "Out" +DEFAULT_ERROR_DIR_NAME = "Err" +DEFAULT_PROCESSED_DIR_NAME = "Processed" + +REPORT_FILE_TEMPLATE = "report_{date_key}_{run_number}.xlsx" +PROTOCOL_FILE_TEMPLATE = "protocol_{date_key}_{run_number}.xlsx" +PROCESS_LOG_FILE_TEMPLATE = "process_{date_key}_{run_number}.log" + + +def validate_file_name(file_name: str) -> bool: + return any(pattern.match(file_name) for pattern in FILE_NAME_PATTERNS) + + +def build_date_parts(now: datetime | None = None) -> tuple[str, str, str]: + current = now or datetime.now() + year = current.strftime("%Y") + month_year = current.strftime("%m_%Y") + date_key = current.strftime("%Y%m%d") + return year, month_year, date_key + + +def build_out_dir(base_dir: str, now: datetime | None = None) -> str: + year, month_year, date_key = build_date_parts(now) + return join_path(base_dir, DEFAULT_OUTPUT_DIR_NAME, year, month_year, date_key) + + +def build_run_dir(base_dir: str, run_number: int, now: datetime | None = None) -> str: + out_dir = build_out_dir(base_dir, now) + return join_path(out_dir, str(run_number)) + + +def build_error_dir(base_dir: str) -> str: + return join_path(base_dir, DEFAULT_ERROR_DIR_NAME) + + +def build_processed_dir(base_dir: str) -> str: + return join_path(base_dir, DEFAULT_PROCESSED_DIR_NAME) + + +def build_report_file_name(date_key: str, run_number: int) -> str: + return REPORT_FILE_TEMPLATE.format(date_key=date_key, run_number=run_number) + + +def build_protocol_file_name(date_key: str, run_number: int) -> str: + return PROTOCOL_FILE_TEMPLATE.format(date_key=date_key, run_number=run_number) + + +def build_process_log_file_name(date_key: str, run_number: int) -> str: + return PROCESS_LOG_FILE_TEMPLATE.format(date_key=date_key, run_number=run_number) + + +def join_path(*parts: str) -> str: + cleaned = [part.strip("/\\") for part in parts if part.strip("/\\")] + return "\\".join(cleaned) + + +def get_parent_dir(path: str) -> str: + normalized = path.rstrip("/\\") + if "\\" in normalized: + return normalized.rsplit("\\", maxsplit=1)[0] + if "/" in normalized: + return normalized.rsplit("/", maxsplit=1)[0] + return normalized diff --git a/app/pipeline/file_manager.py b/app/pipeline/file_manager.py new file mode 100644 index 0000000..1f8de2a --- /dev/null +++ b/app/pipeline/file_manager.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from .config import ( + build_date_parts, + build_error_dir, + build_out_dir, + build_processed_dir, + get_parent_dir, + join_path, + validate_file_name, +) + + +@dataclass +class RunContext: + run_dir: str + out_day_dir: str + error_dir: str + processed_dir: str + run_number: int + date_key: str + + +@dataclass +class RemoteFile: + path: str + name: str + + +@dataclass +class InputFileScanResult: + valid_files: list[RemoteFile] + invalid_files: list[RemoteFile] + + +class SMBClientConnection: + def __init__( + self, smb_base_path: str, user_params: dict[str, str] | None = None + ) -> None: + self.smb_base_path = _normalize_unc_path(smb_base_path) + self.user_name = str((user_params or {}).get("user_name") or "") + self.password = str((user_params or {}).get("password") or "") + self._registered = False + + def __enter__(self) -> SMBClientConnection: + self._ensure_registered() + return self + + def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 + return False + + def path_exists(self, path: str) -> bool: + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + try: + smbclient.stat(full_path) + return True + except Exception: # noqa: BLE001 + return False + + def listdir(self, path: str) -> list[str]: + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + with smbclient.scandir(full_path) as entries: + return [entry.name for entry in entries] + + def is_file(self, path: str) -> bool: + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + try: + with smbclient.open_file(full_path, mode="rb"): + return True + except Exception: # noqa: BLE001 + return False + + def mkdir(self, path: str, parent: bool = False) -> None: + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + if parent: + smbclient.makedirs(full_path, exist_ok=True) + return + smbclient.mkdir(full_path) + + def download( + self, + remote_file_path: str | None = None, + local_file_path: str | None = None, + path_from: str | None = None, + path_to: str | None = None, + replace: bool = False, + ) -> None: + remote = remote_file_path or path_from + local = local_file_path or path_to + if not remote or not local: + raise RuntimeError("Некорректные параметры download().") + target = Path(local) + if target.exists() and not replace: + raise FileExistsError(str(target)) + target.parent.mkdir(parents=True, exist_ok=True) + with self.open(remote, mode="rb") as remote_file: + target.write_bytes(remote_file.read()) + + def upload( + self, + remote_file_path: str | None = None, + local_file_path: str | None = None, + path_from: str | None = None, + path_to: str | None = None, + replace: bool = False, + ) -> None: + remote = remote_file_path or path_to + local = local_file_path or path_from + if not remote or not local: + raise RuntimeError("Некорректные параметры upload().") + local_path = Path(local) + if not local_path.exists(): + raise FileNotFoundError(str(local_path)) + full_remote = self._resolve_path(remote) + if self.path_exists(remote) and not replace: + raise FileExistsError(full_remote) + self.mkdir(get_parent_dir(remote), parent=True) + smbclient = _import_smbclient() + with smbclient.open_file(full_remote, mode="wb") as remote_file: + remote_file.write(local_path.read_bytes()) + + def copy(self, path_from: str, path_to: str, replace: bool = False) -> None: + if self.path_exists(path_to) and not replace: + raise FileExistsError(path_to) + with self.open(path_from, mode="rb") as source_file: + data = source_file.read() + self.mkdir(get_parent_dir(path_to), parent=True) + with self.open(path_to, mode="wb") as target_file: + target_file.write(data) + + def delete(self, path: str) -> None: + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + smbclient.remove(full_path) + + def open(self, path: str, mode: str = "rb"): + smbclient = _import_smbclient() + full_path = self._resolve_path(path) + return smbclient.open_file(full_path, mode=mode) + + def _ensure_registered(self) -> None: + if self._registered: + return + if not self.user_name: + raise RuntimeError("Введите логин SAMBA.") + smbclient = _import_smbclient() + server = _extract_server(self.smb_base_path) + try: + smbclient.register_session( + server=server, + username=self.user_name, + password=self.password, + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"Ошибка подключения к SMB: {exc}") from exc + self._registered = True + + def _resolve_path(self, path: str) -> str: + if path.startswith("//") or path.startswith("\\\\"): + return _normalize_unc_path(path) + return _normalize_unc_path(join_path(self.smb_base_path, path)) + + +def connect_samba( + smb_base_path: str, user_params: dict[str, str] | None = None +) -> SMBClientConnection: + return SMBClientConnection(smb_base_path=smb_base_path, user_params=user_params) + + +def check_input_dir_access( + input_dir: Path | str, samba_conn +) -> tuple[bool, str | None]: + return _check_samba_dir_access(str(input_dir), samba_conn, "Папка") + + +def check_network_dir_access( + network_dir: str | Path, samba_conn +) -> tuple[bool, str | None]: + path = Path(network_dir) + if not str(path).startswith("\\\\"): + return False, f"Путь не является сетевым UNC-путем: {path}" + return _check_samba_dir_access(str(path), samba_conn, "Сетевая папка") + + +def create_run_context( + input_dir: str, samba_conn, now: datetime | None = None +) -> RunContext: + base_dir = get_parent_dir(input_dir) + out_day_dir = build_out_dir(base_dir, now) + error_dir = build_error_dir(base_dir) + processed_dir = build_processed_dir(base_dir) + + ensure_dir_exists(out_day_dir, samba_conn) + ensure_dir_exists(error_dir, samba_conn) + ensure_dir_exists(processed_dir, samba_conn) + + run_number = next_run_number(out_day_dir, samba_conn) + run_dir = join_path(out_day_dir, str(run_number)) + ensure_dir_exists(run_dir, samba_conn) + _, _, date_key = build_date_parts(now) + return RunContext( + run_dir=run_dir, + out_day_dir=out_day_dir, + error_dir=error_dir, + processed_dir=processed_dir, + run_number=run_number, + date_key=date_key, + ) + + +def next_run_number(out_day_dir: str, samba_conn) -> int: + with samba_conn: + if not samba_conn.path_exists(out_day_dir): + return 1 + entries = samba_conn.listdir(out_day_dir) + + numbers: list[int] = [] + for entry in entries: + if str(entry).isdigit(): + numbers.append(int(entry)) + return max(numbers, default=0) + 1 + + +def scan_input_files(input_dir: str, samba_conn) -> InputFileScanResult: + valid_files: list[RemoteFile] = [] + invalid_files: list[RemoteFile] = [] + + for file_name in _list_samba_files(input_dir, samba_conn): + remote_path = join_path(input_dir, file_name) + if validate_file_name(file_name): + valid_files.append(RemoteFile(path=remote_path, name=file_name)) + else: + invalid_files.append(RemoteFile(path=remote_path, name=file_name)) + + return InputFileScanResult(valid_files=valid_files, invalid_files=invalid_files) + + +def move_file_to_error( + file_path: str, file_name: str, error_dir: str, samba_conn +) -> str: + target = _next_available_samba_target(error_dir, file_name, samba_conn) + _samba_move_file(file_path, target, samba_conn) + return target + + +def move_file_to_processed( + file_path: str, file_name: str, processed_dir: str, samba_conn +) -> str: + target = _next_available_samba_target(processed_dir, file_name, samba_conn) + _samba_move_file(file_path, target, samba_conn) + return target + + +def ensure_dir_exists(path: str, samba_conn) -> None: + with samba_conn: + if not samba_conn.path_exists(path): + samba_conn.mkdir(path, parent=True) + + +def read_remote_file_bytes(remote_path: str, samba_conn) -> bytes: + if hasattr(samba_conn, "download"): + with tempfile.TemporaryDirectory() as temp_dir: + local_path = Path(temp_dir) / Path(remote_path).name + with samba_conn: + try: + samba_conn.download( + remote_file_path=remote_path, + local_file_path=str(local_path), + replace=True, + ) + except TypeError: + samba_conn.download(path_from=remote_path, path_to=str(local_path)) + return local_path.read_bytes() + + if hasattr(samba_conn, "open"): + with samba_conn: + with samba_conn.open(remote_path, "rb") as remote_file: + return remote_file.read() + + raise RuntimeError("Коннектор SAMBA не поддерживает чтение файла (download/open).") + + +def upload_local_file(local_path: Path, remote_path: str, samba_conn) -> None: + remote_dir = get_parent_dir(remote_path) + ensure_dir_exists(remote_dir, samba_conn) + if hasattr(samba_conn, "upload"): + with samba_conn: + try: + samba_conn.upload( + remote_file_path=remote_path, + local_file_path=str(local_path), + replace=True, + ) + except TypeError: + samba_conn.upload(path_from=str(local_path), path_to=remote_path) + return + if hasattr(samba_conn, "copy"): + with samba_conn: + samba_conn.copy( + path_from=str(local_path), path_to=remote_path, replace=True + ) + return + raise RuntimeError("Коннектор SAMBA не поддерживает запись файла (upload/copy).") + + +def _check_samba_dir_access( + target_dir: str, samba_conn, source_name: str +) -> tuple[bool, str | None]: + try: + with samba_conn: + if not samba_conn.path_exists(target_dir): + return False, f"{source_name} не найдена: {target_dir}" + _ = samba_conn.listdir(target_dir) + return True, None + except Exception as exc: # noqa: BLE001 + return False, f"Ошибка доступа к папке: {target_dir}. {exc}" + + +def _list_samba_files(input_dir: str, samba_conn) -> list[str]: + items: list[str] = [] + with samba_conn: + for name in sorted(samba_conn.listdir(input_dir)): + full_path = join_path(input_dir, name) + if not samba_conn.is_file(full_path): + continue + items.append(name) + return items + + +def _next_available_samba_target(dir_path: str, file_name: str, samba_conn) -> str: + with samba_conn: + if not samba_conn.path_exists(dir_path): + samba_conn.mkdir(dir_path, parent=True) + + base_target = join_path(dir_path, file_name) + if not samba_conn.path_exists(base_target): + return base_target + + base = Path(file_name) + index = 1 + while True: + candidate_name = f"{base.stem}_{index}{base.suffix}" + candidate = join_path(dir_path, candidate_name) + if not samba_conn.path_exists(candidate): + return candidate + index += 1 + + +def _samba_move_file(path_from: str, path_to: str, samba_conn) -> None: + with samba_conn: + samba_conn.copy(path_from=path_from, path_to=path_to, replace=False) + samba_conn.delete(path_from) + + +def _extract_server(smb_base_path: str) -> str: + server = urlparse(smb_base_path).netloc + if not server: + raise RuntimeError(f"Некорректный SMB путь: {smb_base_path}") + return server + + +def _normalize_unc_path(path: str) -> str: + normalized = path.replace("\\", "/").strip() + while normalized.startswith("///"): + normalized = normalized[1:] + if not normalized.startswith("//"): + normalized = "//" + normalized.lstrip("/") + return normalized + + +def _import_smbclient(): + try: + import smbclient # type: ignore[import-not-found] + except ImportError as exc: # noqa: BLE001 + raise RuntimeError("Библиотека smbprotocol/smbclient не установлена.") from exc + return smbclient diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py new file mode 100644 index 0000000..ffc529f --- /dev/null +++ b/app/pipeline/mapping.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class MappingRule: + column_name: str + candidate_keys: tuple[str, ...] + source: str = "any" + + +MAPPING_DATA_FILE = Path(__file__).resolve().with_name("mapping_table.json") +TARGET_REPORT_COLUMNS_COUNT = 203 + +_MANUAL_COLUMN_NAME_FIXES: dict[int, str] = { + 1: "Имя XML файла", + 178: "Место государственной регистрации ЕИО/Бенефициара (структура полностью)", + 183: "Номер", +} + +_INVALID_TAGS = {"", "-", "Источник", "путь", "подразумеваются", "тэга", "нашла"} + + +def _load_mapping_data() -> list[dict[str, str | int]]: + raw = json.loads(MAPPING_DATA_FILE.read_text(encoding="utf-8")) + if not isinstance(raw, list): + return [] + result: list[dict[str, str | int]] = [] + for item in raw: + if not isinstance(item, dict): + continue + idx = int(item.get("index", 0)) + if not 1 <= idx <= TARGET_REPORT_COLUMNS_COUNT: + continue + column_name = str(item.get("column_name", "")).strip() + xml_tag = str(item.get("xml_tag", "")).strip() + xml_path = str(item.get("xml_path", "")).strip() + if idx in _MANUAL_COLUMN_NAME_FIXES: + column_name = _MANUAL_COLUMN_NAME_FIXES[idx] + column_name = _cleanup_column_name(column_name) + result.append( + { + "index": idx, + "column_name": column_name, + "xml_tag": xml_tag, + "xml_path": xml_path, + } + ) + return sorted(result, key=lambda row: int(row["index"])) + + +def _cleanup_column_name(value: str) -> str: + cleaned = re.sub(r"\s+", " ", value).strip() + cleaned = re.sub(r"\s+не\s+нашла.*$", "", cleaned, flags=re.IGNORECASE) + return cleaned.strip(" -") + + +def _build_fixed_columns(mapping_data: list[dict[str, str | int]]) -> tuple[str, ...]: + columns: list[str] = [] + for row in mapping_data: + column = str(row.get("column_name", "")).strip() + columns.append(column or f"Колонка {row['index']}") + if len(columns) < TARGET_REPORT_COLUMNS_COUNT: + for index in range(len(columns) + 1, TARGET_REPORT_COLUMNS_COUNT + 1): + columns.append(f"Колонка {index}") + return tuple(columns[:TARGET_REPORT_COLUMNS_COUNT]) + + +def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, ...]: + rules: list[MappingRule] = [] + participant_columns = { + "Признак резидента участника", + "Тип участника", + "ИНН участника", + "Наименование участника", + "Счет участника", + } + for row in mapping_data: + column = str(row.get("column_name", "")).strip() + tag = str(row.get("xml_tag", "")).strip() + path = str(row.get("xml_path", "")).strip() + if not column or tag in _INVALID_TAGS: + continue + candidates = [tag] + if column.startswith("ИНН "): + candidates = ["ИННФЛ", "ИННЮЛ", "ИНН", *candidates] + if column == "Тип участника": + candidates = ["Тип", "ТипУчастника", *candidates] + if path: + path_tag = path.strip("/").split("/")[-1] + if path_tag and path_tag not in candidates: + candidates.append(path_tag) + rules.append( + MappingRule( + column_name=column, + candidate_keys=tuple(candidates), + source=( + "participant" + if column in participant_columns or column.startswith("ИНН ") + else "any" + ), + ) + ) + return tuple(rules) + + +_MAPPING_DATA = _load_mapping_data() +FIXED_REPORT_COLUMNS: tuple[str, ...] = _build_fixed_columns(_MAPPING_DATA) +REPORT_MAPPING_RULES: tuple[MappingRule, ...] = _build_rules(_MAPPING_DATA) + + +def build_fixed_row( + *, + file_name: str, + record_id: str, + operation_index: int, + operation_fields: dict[str, str], + participant_fields: dict[str, str], +) -> dict[str, str]: + row: dict[str, str] = { + "Имя XML файла": file_name, + "Идентификатор записи": record_id, + "Уникальный номер операции": str(operation_index), + } + merged = _merge_fields(operation_fields, participant_fields) + for rule in REPORT_MAPPING_RULES: + if row.get(rule.column_name): + continue + source_payload = participant_fields if rule.source == "participant" else merged + row[rule.column_name] = _pick_value(source_payload, rule.candidate_keys) + for column in FIXED_REPORT_COLUMNS: + row.setdefault(column, "") + return row + + +def _merge_fields( + operation_fields: dict[str, str], + participant_fields: dict[str, str], +) -> dict[str, str]: + merged: dict[str, str] = {} + merged.update(operation_fields) + merged.update(participant_fields) + return merged + + +def _pick_value(payload: dict[str, str], candidates: tuple[str, ...]) -> str: + for key in candidates: + if key in payload and payload[key]: + return payload[key] + short = key.split(".")[-1] + if short in payload and payload[short]: + return payload[short] + return "" diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json new file mode 100644 index 0000000..869dc60 --- /dev/null +++ b/app/pipeline/mapping_table.json @@ -0,0 +1,1829 @@ +[ + { + "index": 1, + "column_name": "Имя XML файла", + "xml_tag": "-", + "xml_path": "Имя входного файла", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Данные берутся из названия входящего файла в формате \"ДД/ММ/ГГГГ\"" + }, + { + "index": 2, + "column_name": "Дата сообщения", + "xml_tag": "ДатаСообщения", + "xml_path": "/СообщОперКО/СлужЧасть/ДатаСообщения", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Дата формирования ФЭС. Совпадает с датой, указанной в наименовании файла ФЭС." + }, + { + "index": 3, + "column_name": "Тип информации", + "xml_tag": "ТипИнф", + "xml_path": "/СообщОперКО/СлужЧасть/ТипИнф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовое поле." + }, + { + "index": 4, + "column_name": "ОКАТО", + "xml_tag": "ОКАТОФл", + "xml_path": "/СообщОперКО/ИнформЧасть/ИнфБанк/ОКАТОФл", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле. Код территории кредитной организации. Показатель отсутствует только в случае заграничных кредитных организаций." + }, + { + "index": 5, + "column_name": "БИК", + "xml_tag": "БИКФл", + "xml_path": "/СообщОперКО/ИнформЧасть/ИнфБанк/БИКФл", + "multiplies": "нет", + "required": "необязательно", + "comment": "числовое поле. Значения может быть пустое." + }, + { + "index": 6, + "column_name": "Номер филиала", + "xml_tag": "НомФЛ", + "xml_path": "/СообщОперКО/ИнформЧасть/ИнфФилиал/", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовое поле, порядковый номер филиала." + }, + { + "index": 7, + "column_name": "Идентификатор записи", + "xml_tag": "НомерЗаписи", + "xml_path": "/ СообщОперКО/Операция/НомерЗаписи", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле" + }, + { + "index": 8, + "column_name": "Тип записи", + "xml_tag": "ТипЗаписи", + "xml_path": "/СообщОперКО/Операция/ТипЗаписи", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Не нашла информацию о \"типе записи\" в документации. Есть тип записи ФЭС, таб. 3.2 пункт 4 Это одно и тоже?" + }, + { + "index": 9, + "column_name": "Уникальный номер операции", + "xml_tag": "НомерОперация", + "xml_path": "/СообщОперКО/Операция/НомерОперация", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может быть пустым так как данные могут не передаваться в XML" + }, + { + "index": 10, + "column_name": "Признак операции, связанной с фин. терроризма", + "xml_tag": "ПризнФТр", + "xml_path": "/СообщОперКО/Операция/ПризнФТр", + "multiplies": "Множится", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 11, + "column_name": "Основание приостановления", + "xml_tag": "ОснПриостановление", + "xml_path": "Просмотрела все XML и XSD схемы и не нашла путь тэга", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым так как данные могут не передаваться в XML" + }, + { + "index": 12, + "column_name": "Дата совершения(приостановл.)", + "xml_tag": "ДатаОперации", + "xml_path": "/СообщОперКО/Операция/ДатаОперации", + "multiplies": "нет", + "required": "обязательно", + "comment": "Формат дата \"ДД/ММ/ГГГГ\"" + }, + { + "index": 13, + "column_name": "Дата выявления", + "xml_tag": "ДатаВыявления", + "xml_path": "/СообщОперКО/Операция/ДатаВыявления", + "multiplies": "нет", + "required": "необязательно", + "comment": "Поле дата может оставаться пустым не поступать в XML.." + }, + { + "index": 14, + "column_name": "Код призн.", + "xml_tag": "КодПризнОперации", + "xml_path": "/СообщОперКО/Операция/КодПризнОперации", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовое поле." + }, + { + "index": 15, + "column_name": "Призн. Совершения оп. с испол. ЭСП", + "xml_tag": "ПризнакОперацияЭСП", + "xml_path": "/СообщОперКО/Операция/ПризнакОперацияЭСП", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 16, + "column_name": "Наименование плат. системы на стороне лица соверш. операцию", + "xml_tag": "НаимПлатежнаяСистема1", + "xml_path": "/СообщОперКО/Операция/НаимПлатежнаяСистема1", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 17, + "column_name": "Наименование плат. системы на стороне получателя", + "xml_tag": "НаимПлатежнаяСистема2", + "xml_path": "/СообщОперКО/Операция/НаимПлатежнаяСистема2", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 18, + "column_name": "Время совершения транзакции с испол. ЭСП", + "xml_tag": "ВремяТранзакцияЭСП", + "xml_path": "/СообщОперКО/Операция/ВремяТранзакцияЭСП", + "multiplies": "нет", + "required": "необязательно", + "comment": "Указывается точное время операции Пример: 09:37:51 (МСК-01:00) (г. Калининград) ; 11:45:43 (МСК+03:00) (г. Омск) ; 10:50:40 (МСК+00:00) (г. Москва) ." + }, + { + "index": 19, + "column_name": "Код вида операции", + "xml_tag": "КодОперации", + "xml_path": "/СообщОперКО/Операция/КодОперации", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле" + }, + { + "index": 20, + "column_name": "Доп. коды", + "xml_tag": "ДопКодОперации", + "xml_path": "/СообщОперКО/Операция/ДопКодОперации", + "multiplies": "Множится", + "required": "необязательно", + "comment": "Числовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 21, + "column_name": "Коды призн. необычной операции", + "xml_tag": "ПризнНеобОперации", + "xml_path": "СообщОперКО/Операция/ПризнНеобОперации", + "multiplies": "Множится", + "required": "необязательно", + "comment": "Числовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 22, + "column_name": "Валюта", + "xml_tag": "КодВал", + "xml_path": "/СообщОперКО/Операция/КодВал", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле, может оставаться пустым и не поступать в XML" + }, + { + "index": 23, + "column_name": "Сумма в валюте проведения", + "xml_tag": "СумОперации", + "xml_path": "/СообщОперКО/Операция/СумОперации", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле, допускаются не целые числа. может оставаться пустым и не поступать в XML" + }, + { + "index": 24, + "column_name": "В рубл. эквиваленте", + "xml_tag": "СумРуб", + "xml_path": "/СообщОперКО/Операция/СумРуб", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовое поле, допускаются не целые числа. может оставаться пустым и не поступать в XML" + }, + { + "index": 25, + "column_name": "Продаваемая валюта", + "xml_tag": "КодВалКонверсия", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/", + "multiplies": "множится", + "required": "необязательно", + "comment": "может оставаться пустым и не поступать в XML" + }, + { + "index": 26, + "column_name": "Сумма прод. валюты в ед. соответ. валюты", + "xml_tag": "СумКонверсия", + "xml_path": "/СообщОперКО/Операция/СумКонверсия", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 27, + "column_name": "Признак валютной операции", + "xml_tag": "ПризнВалОперации", + "xml_path": "/СообщОперКО/Операция/ПризнВалОперации", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 28, + "column_name": "Идентификатор подозрительной деятельности", + "xml_tag": "ИдентификаторПД", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/", + "multiplies": "нет", + "required": "обязательно", + "comment": "Поле помечено как обязательное, но ни в одном XML и XSD его не нашла." + }, + { + "index": 29, + "column_name": "Код драг. металла", + "xml_tag": "КодДрагМеталл", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведДрагМеталл", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Поле помечено как обязательное, но ни в одном XML и XSD его не нашла." + }, + { + "index": 30, + "column_name": "Наименование металла", + "xml_tag": "НаимДрагМеталл", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведДрагМеталл", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустым и не поступать в XML" + }, + { + "index": 31, + "column_name": "Предмет купли-продажи", + "xml_tag": "КодПредмет", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведДрагМеталл", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустым и не поступать в XML" + }, + { + "index": 32, + "column_name": "Характеристика операции", + "xml_tag": "ХарактерОп", + "xml_path": "/СообщОперКО/ОснованиеОп/ХарактерОп", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустыми не поступать в XML" + }, + { + "index": 33, + "column_name": "Назначение платежа", + "xml_tag": "НазначениеПлатежа", + "xml_path": "/СообщОперКО/ОснованиеОп/НазначениеПлатежа", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустыми не поступать в XML" + }, + { + "index": 34, + "column_name": "Доп. сведения", + "xml_tag": "КомментУчастник", + "xml_path": "/СообщОперКО/УчастникФЛИП/КомментУчастник", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель. Поле может оставаться пустым. и не поступать в XML" + }, + { + "index": 35, + "column_name": "Вид перевода", + "xml_tag": "ВидПереводДС", + "xml_path": "/СообщОперКО/СвединияПереводыДС/ВидПереводДС", + "multiplies": "нет", + "required": "Обязательно", + "comment": "числовой показатель" + }, + { + "index": 36, + "column_name": "Код территории (5016)", + "xml_tag": "КодТерИнГос", + "xml_path": "/СообщОперКО/ СведенияПереводыДС/КодТерИнГос", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 37, + "column_name": "Тип оператора", + "xml_tag": "ТипОператорДС", + "xml_path": "/СообщОперКО/СвединияПереводыДС/ТипОператорДС", + "multiplies": "нет", + "required": "Обязательно", + "comment": "числовой показатель" + }, + { + "index": 38, + "column_name": "Номер счета плательщика", + "xml_tag": "НомерСчетПлательщик", + "xml_path": "/СообщОперКО/СвединияПереводыДС/НомерСчетПлательщик", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 39, + "column_name": "Идентификатор ЭСП плательщика", + "xml_tag": "ИдентЭСППлательщик", + "xml_path": "/СообщОперКО/СведенияПереводыДС/ИдентЭСППлательщик", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 40, + "column_name": "Наименование банка плательщика", + "xml_tag": "СведБанкПлательщик", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СведБанкПлательщик/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Данные передаются в XML в виде <НаимКО> АО \"АЛЬФА-БАНК\" в отчет заносятся в виде: Банк ВТБ (ПАО), филиал Филиал \"Центральный\" Банка ВТБ (ПАО)" + }, + { + "index": 41, + "column_name": "БИК банка плательщика", + "xml_tag": "БИККО", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СведБанкПлательщик/БИККО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Данные передаются в XML в виде: <БИККО> 044525593 в отчет заносятся в виде: 044525411" + }, + { + "index": 42, + "column_name": "Наименование банка получателя", + "xml_tag": "СведБанкПолучатель", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СведБанкПолучатель", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 43, + "column_name": "БИК банка получателя", + "xml_tag": "СведБанкПолучатель", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СведБанкПолучатель", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 44, + "column_name": "Номер счета банка плательщика", + "xml_tag": "СчетБанкПлательщик", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СчетБанкПлательщик", + "multiplies": "нет", + "required": "необязательно", + "comment": "Данные передаются в XML в виде: <СчетБанкПлательщик> 30101810300000000711 в отчет заносятся в виде: 30101810300000000711" + }, + { + "index": 45, + "column_name": "Номер счета банка получателя", + "xml_tag": "СчетБанкПолучатель", + "xml_path": "/СообщОперКО/СвединияПереводыДС/СчетБанкПолучатель", + "multiplies": "нет", + "required": "необязательно", + "comment": "Данные передаются в XML в виде: <СчетБанкПолучатель> 30101810200000000704 в отчет заносятся в виде: 30101810200000000704" + }, + { + "index": 46, + "column_name": "Номер счета получателя", + "xml_tag": "НомерСчетПолучатель", + "xml_path": "/СообщОперКО/СвединияПереводыДС/НомерСчетПолучатель", + "multiplies": "нет", + "required": "необязательно", + "comment": "Данные передаются в XML в виде: <НомерСчетПолучатель> 40703810000000000000 в отчет заносятся в виде: 40703810000000000000" + }, + { + "index": 47, + "column_name": "Идентификатор ЭСП получателя", + "xml_tag": "ИдентЭСППолучателя", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 48, + "column_name": "Номер банкомата", + "xml_tag": "ИдТерминал", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведМестоОперация/ИдТерминал", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 49, + "column_name": "Адрес места приема наличных денежных средств(в одной строке)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 50, + "column_name": "АДРЕС (ПО СТРУКТУРЕ полный)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML Чем они отличаются?" + }, + { + "index": 51, + "column_name": "Индекс", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ /Индекс", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 52, + "column_name": "Код страны", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодОКСМ", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 53, + "column_name": "Код ОКАТО", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодСубъектаПоОКАТО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 54, + "column_name": "Район", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Район", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 55, + "column_name": "Населенный пункт", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Пункт", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 56, + "column_name": "Улица", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Улица", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 57, + "column_name": "Дом", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Дом", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 58, + "column_name": "Корпус/строение", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Корп", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 59, + "column_name": "Квартира", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Оф", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 60, + "column_name": "Наименование банка, обслуживающего участника операции", + "xml_tag": "НаимКО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 61, + "column_name": "БИК банка, обслуживающего участника операции", + "xml_tag": "БИКК", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/БИККО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовой показатель" + }, + { + "index": 62, + "column_name": "Статус перевода", + "xml_tag": "СтатусПеревод", + "xml_path": "/СообщОперКО/СведПриемНалДС/АдрМестаПриемаВыдача/СтатусПеревод", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым. Не нашла в XML" + }, + { + "index": 63, + "column_name": "Номер банкомата", + "xml_tag": "ИдТерминал", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведМестоОперация/ИдТерминал", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 64, + "column_name": "Адрес места выдачи наличных денежных средств ( в одной строке)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 65, + "column_name": "АДРЕС (ПО СТРУКТУРЕ полный)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML Чем они отличаются?" + }, + { + "index": 66, + "column_name": "Индекс", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ /Индекс", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 67, + "column_name": "Код страны", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодОКСМ", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 68, + "column_name": "Код ОКАТО", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодСубъектаПоОКАТО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 69, + "column_name": "Район", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 70, + "column_name": "Населенный пункт", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Пункт", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 71, + "column_name": "Улица", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Улица", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 72, + "column_name": "Дом", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Дом", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 73, + "column_name": "Корпус/строение", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 74, + "column_name": "Квартира", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 75, + "column_name": "Наименование банка, обслуживающего участника операции", + "xml_tag": "НаимКО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 76, + "column_name": "БИК банка, обслуживающего участника операции", + "xml_tag": "БИКК", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/БИККО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовой показатель" + }, + { + "index": 77, + "column_name": "IP-адрес сетевого оборудования плательщика", + "xml_tag": "АдресIP", + "xml_path": "/СообщОперКО/СведенияПереводыДС/АдресIP", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 78, + "column_name": "MAC-адрес сетевого оборудования плательщика", + "xml_tag": "АдресMAC", + "xml_path": "/СообщОперКО/СведенияПереводыДС/АдресMAC", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 79, + "column_name": "Код характера операции", + "xml_tag": "КодХарактерОп", + "xml_path": "/СообщОперКО/СведенияВнесениеПолучениеНалДС/КодХарактерОп", + "multiplies": "нет", + "required": "обязательное", + "comment": "Числовой показатель" + }, + { + "index": 80, + "column_name": "Номер счета клиента", + "xml_tag": "НомерСчетКлиент", + "xml_path": "/СообщОперКО/СведенияВнесениеПолучениеНалДС/НомерСчетКлиент", + "multiplies": "нет", + "required": "обязательное", + "comment": "Числовой показатель" + }, + { + "index": 81, + "column_name": "Номер платежной карты", + "xml_tag": "НомерКарта", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведМестоОперация/дрМестаПриемаВыдача/НомерКарта", + "multiplies": "нет", + "required": "необязательно", + "comment": "Показатель в XML передается зашифрованными ***************, в отчет попадают цифры" + }, + { + "index": 82, + "column_name": "Номер банкомата", + "xml_tag": "ИдТерминал", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведМестоОперация/ИдТерминал", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 83, + "column_name": "Адрес места приема наличных денежных средств ( в одной строке)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 84, + "column_name": "АДРЕС (ПО СТРУКТУРЕ полный)", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML Чем они отличаются?" + }, + { + "index": 85, + "column_name": "Индекс", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ /Индекс", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 86, + "column_name": "Код страны", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодОКСМ", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 87, + "column_name": "Код ОКАТО", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодСубъектаПоОКАТО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 88, + "column_name": "Район", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 89, + "column_name": "Населенный пункт", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Пункт", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 90, + "column_name": "Улица", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Улица", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 91, + "column_name": "Дом", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/Дом", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 92, + "column_name": "Корпус/строение", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 93, + "column_name": "Квартира", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "Уточнить, какой именно пункт в XML подразумевается", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 94, + "column_name": "Наименование банка, обслуживающего участника операции", + "xml_tag": "НаимКО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 95, + "column_name": "БИК банка, обслуживающего участника операции", + "xml_tag": "БИКК", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/БИККО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовой показатель" + }, + { + "index": 96, + "column_name": "Код территории", + "xml_tag": "КодТерИнГос", + "xml_path": "/СообщОперКО/ СведенияПереводыДС/КодТерИнГос", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + }, + { + "index": 97, + "column_name": "Код страны", + "xml_tag": "дрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача/ КодОКСМ", + "multiplies": "нет", + "required": "необязательно", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 98, + "column_name": "Адрес места приема наличных денежных средств", + "xml_tag": "АдрМестаПриемаВыдача", + "xml_path": "/СообщОперКО/СведПриемВыдача/АдрМестаПриемаВыдача", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 99, + "column_name": "Наименование банка, обслуживающего участника операции", + "xml_tag": "НаимКО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 100, + "column_name": "Наименование банка, обслуживающего участника операции", + "xml_tag": "НаимКО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/НаимКО", + "multiplies": "нет", + "required": "необязательно", + "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 101, + "column_name": "БИК банка, обслуживающего участника операции", + "xml_tag": "БИККО", + "xml_path": "/СообщОперКО/СведПриемВыдача/ <АдрМестаПриемаВыдача/БИККО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовой показатель" + }, + { + "index": 102, + "column_name": "Номер платежной карты", + "xml_tag": "НомерКарта", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведМестоОперация/дрМестаПриемаВыдача/НомерКарта", + "multiplies": "нет", + "required": "необязательно", + "comment": "Показатель в XML передается зашифрованными ***************, в отчет попадают цифры" + }, + { + "index": 103, + "column_name": "Сведения о держателе платежной карты", + "xml_tag": "СведДержательКарты", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/СведДержательКарты", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 104, + "column_name": "Признак совершения операции с участием уп. сотрудника КО", + "xml_tag": "ПризнакСотрудник", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/ПризнакСотрудник", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 105, + "column_name": "Наименование иностранного банка", + "xml_tag": "НаимИнБанк", + "xml_path": "/СообщОперКО/СведенияКартаИнБанк/НаимИнБанк", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 106, + "column_name": "СВИФТ", + "xml_tag": "СВИФТИнБанк", + "xml_path": "СообщОперКО/ИнфорЧасть/СведКО/Операция/СведенияКартаИнБанк/СВИФТИнБанк", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 107, + "column_name": "Сведения о документе, подтверждающем совершение операции", + "xml_tag": "СодДок", + "xml_path": "/ СообщОперКО/ ОснованиеОп/СодДок", + "multiplies": "нет", + "required": "необязательно", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 108, + "column_name": "Код статуса участника операции (сделки)", + "xml_tag": "СтатусУчастника", + "xml_path": "/ СообщОперКО/ УчастникОп/СтатусУчастника", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 109, + "column_name": "Тип участника", + "xml_tag": "ТипУчастника", + "xml_path": "/ СообщОперКО/ УчастникОп/ТипУчастника", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 110, + "column_name": "Признак резидента", + "xml_tag": "ПризнУчастника", + "xml_path": "/ СообщОперКО/ УчастникОп/ПризнУчастника", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 111, + "column_name": "Признак участника операции (сделки)", + "xml_tag": "ПризнКлиент", + "xml_path": "/ СообщОперКО/ УчастникОп/ПризнКлиент", + "multiplies": "нет", + "required": "обязательно", + "comment": "Числовой показатель" + }, + { + "index": 112, + "column_name": "Ид уч операции, включенного в Перечень,Решение, и (или) пер. ФРОМУ", + "xml_tag": "КодЮЛФЛ", + "xml_path": "/ СообщОперКО/ УчастникОп/КодЮЛФЛ", + "multiplies": "нет", + "required": "необязательный", + "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" + }, + { + "index": 113, + "column_name": "Наименование участника операции", + "xml_tag": "для Юр лиц : НаимЮЛ для ИСБОЮЛ : ПолноеНаимИНБОЮЛ", + "xml_path": "для Юр лиц : / СообщОперКО/ УчастникЮЛ/СведЮЛ/НаимЮЛ для ИСБОЮЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/полноеНаимИНБОЮЛ", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 114, + "column_name": "ФИО по структуре (полностью) разделять поля запятыми", + "xml_tag": "ФИОСтрока", + "xml_path": "/ СообщОперКО / УчастникФЛИП//СведФЛИП/ФИОФЛИП/ФИОСтрока", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 115, + "column_name": "Фамилия", + "xml_tag": "Фам", + "xml_path": "/ СообщОперКО / УчастникФЛИП/ СведФЛИП/ФИОФЛИП/Фам", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 116, + "column_name": "Имя", + "xml_tag": "Имя", + "xml_path": "/ СообщОперКО / УчастникФЛИП/ СведФЛИП/ФИОФЛИП/Имя", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 117, + "column_name": "Отчество", + "xml_tag": "Отч", + "xml_path": "/ СообщОперКО / УчастникФЛИП/ СведФЛИП/ФИОФЛИП/Отч", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML" + }, + { + "index": 118, + "column_name": "ФИО (в одной строке)", + "xml_tag": "ФИОСтрока", + "xml_path": "/ СообщОперКО / УчастникФЛИП//СведФЛИП/ФИОФЛИП/ФИОСтрока", + "multiplies": "нет", + "required": "необязательный", + "comment": "поле может остаться пустым и не передаваться в XML Уточнить это одно и то же с пунктом 114" + }, + { + "index": 119, + "column_name": "Идентификатор ППЦР", + "xml_tag": "ПризнУчастника", + "xml_path": "/СообщОперКО/ УчастникОп/ПризнУчастника", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель" + }, + { + "index": 120, + "column_name": "ИНН", + "xml_tag": "ИННЮЛ (для юр.лиц) / ИННФЛИП (для ФЛ,ИП, ФЛЧП)", + "xml_path": "Путь для юр лиц - /СообщОперКО/УчастникЮЛ/СведЮЛ/ИННЮЛ Путь для ФЛ,ИП, ФЛЧП - /СообщОперКО /УчастникФЛИП/ СведФЛИП/ФИОФЛИП/ИННФЛИП", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 121, + "column_name": "ОГРН", + "xml_tag": "ЮЛ: ОГРНЮЛ ИП: ОГРНИП ФЛЧП: РегнНомер ИСБОЮЛ: РегИНБОЮЛ |", + "xml_path": "/СообщОперКО/УчастникЮЛ/СведЮЛ/ОГРНЮЛ СообщОперКО/УчастникФЛИП/СведФЛИП/ФИОФЛИП/ОГРНИП ФЛЧП: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/РегнНомер ИСБОЮЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СвебИНБОЮЛ/РегИНБОЮЛ (но внутри него 2 значения)", + "multiplies": "нет", + "required": "обязательно", + "comment": "Уточнить, что значит \"(совокупность показателей таб.2.11 1)\"" + }, + { + "index": 122, + "column_name": "КПП (ПРИЗНАК ИСБОЮЛ) /Пр ид-ии ФЛ:", + "xml_tag": "КППЮЛ(для юр. лиц), ИдентификацияФЛ (ФЛ,ИП,ФЛЧП), ПризнакОргФормаИНБОЮЛ (для ИСБОЮЛ)", + "xml_path": "Путь для юр. лиц - /СообщОперКО/УчастникОп/УчастникЮЛ/СведЮЛ/КППЮЛ ФЛ,ИП,ФЛЧП: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/ИдентификацияФЛ ИНБОЮЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/ПризнакОргФормаИНБОЮЛ", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 123, + "column_name": "Дата государственной регистрации :/ Дата рождения:", + "xml_tag": "ДатаРегЮЛ ДатаРождения", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ДатаРегЮЛ /СообщОперКО/СведДокУдЛичн/ДатаРождения", + "multiplies": "нет", + "required": "необязательно", + "comment": "Показатель в формате ДД/ММ/ГГГГ." + }, + { + "index": 124, + "column_name": "Признак филиала: / Тип ФЛЧП", + "xml_tag": "ПризнакПубЛицо", + "xml_path": "/СообщОперКО/СведДокУдЛичн/ПризнакПубЛицо", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 125, + "column_name": "МС/СНИЛС/Номер телефона", + "xml_tag": "СНИЛСФЛ ПолисНомер ТелефонНомер", + "xml_path": "СНИЛСФЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СНИЛСФЛ ПолисНомер: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ПолисНомер ТелефонНомер: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ТелефонНомер", + "multiplies": "нет", + "required": "необязательно", + "comment": "Все показатели записываются в одном столбце, поле может оставаться пустым." + }, + { + "index": 126, + "column_name": "Код страны гражданства", + "xml_tag": "КодОКСМ", + "xml_path": "/СообщОперКО/ УчастникОп/СведенияКартаИнБанк/СведМестоОперация/дрМестаПриемаВыдача/КодОКСМ", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель. Пример СНИЛС: | ОМС: | Номер телефона:" + }, + { + "index": 127, + "column_name": "ДУЛ (структура полностью)", + "xml_tag": "ВидДокКод ИноеНаименованиеДок СерияДок НомДок ДатВыдачиДок КемВыданДок КодПодр СведДокУдЛичн", + "xml_path": "Общий путь, внутри которого все лежит: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокУдЛичн |", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные собранные в одном поле. Не все теги могут быть переданы в XML пример Вид документа: 21 | Серия документа: 0104 | Номер документа: 476635 | Орган, выдавший документ: Отделом внутренних дел Красногорского района Алтайского края | Код подразделения: 222-022 | Дата выдачи: 23/08/2005" + }, + { + "index": 128, + "column_name": "Признак ДУЛ", + "xml_tag": "ДокУдЛичн", + "xml_path": "/СообщОперКО / УчастникФЛИП/СведФЛИП/ФИОФЛИП/ДокУдЛичн", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + }, + { + "index": 129, + "column_name": "Код вида документа/Наименование", + "xml_tag": "ВидДокКод", + "xml_path": "/СообщОперКО / СведДокУдЛичн/ВидДокКод", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель." + }, + { + "index": 130, + "column_name": "Серия", + "xml_tag": "СерияДок", + "xml_path": "/СообщОперКО / СведДокУдЛичн/СерияДок", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель" + }, + { + "index": 131, + "column_name": "Номер", + "xml_tag": "НомДок", + "xml_path": "/СообщОперКО / СведДокУдЛичн/НомДок", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель" + }, + { + "index": 132, + "column_name": "Орган, выдавший документ", + "xml_tag": "КемВыданДок", + "xml_path": "СообщОперКО / СведДокУдЛичн/КемВыданДок", + "multiplies": "нет", + "required": "необязательно", + "comment": "текст" + }, + { + "index": 133, + "column_name": "КП", + "xml_tag": "|", + "xml_path": "|", + "multiplies": "|", + "required": "|", + "comment": "|" + }, + { + "index": 134, + "column_name": "Дата выдачи", + "xml_tag": "ДатВыдачиДок", + "xml_path": "СообщОперКО / СведДокУдЛичн/ДатВыдачиДок", + "multiplies": "нет", + "required": "обязательно", + "comment": "дата, формат ДД/ММ/ГГГГ" + }, + { + "index": 135, + "column_name": "Код вида документа/Наименование", + "xml_tag": "ВидДокКод", + "xml_path": "СообщОперКО / СведДокУдЛичн/ВидДокКод", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 136, + "column_name": "Серия", + "xml_tag": "СерияДокПреб", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокПреб/СерияДокПреб", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 137, + "column_name": "Номер", + "xml_tag": "НомДокПреб", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокПреб/НомДокПреб", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 138, + "column_name": "Дата начала/окончания срока", + "xml_tag": "НачПравПреб", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокПреб/НачПравПреб", + "multiplies": "нет", + "required": "обязательно", + "comment": "|" + }, + { + "index": 139, + "column_name": "Сведения миграционной карты", + "xml_tag": "не поняла где найти в таблице ворд", + "xml_path": "|", + "multiplies": "|", + "required": "|", + "comment": "|" + }, + { + "index": 140, + "column_name": "Адрес в одной строкеи ( начинать с \", , , , , \")", + "xml_tag": "АдресСтрока", + "xml_path": "СообщОперКО / УчастникФЛИП/АдресСтрока", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле" + }, + { + "index": 141, + "column_name": "Адрес по структуре (целый)", + "xml_tag": "Индекс КодОКСМ КодСубъектаПоОКАТО Район Пункт Улица Дом Корп Оф", + "xml_path": "СообщОперКО / АдрРег/Индекс СообщОперКО / АдрРег/КодОКСМ СообщОперКО / АдрРег/КодСубъектаПоОКАТО СообщОперКО / АдрРег/Район СообщОперКО / АдрРег/Пункт СообщОперКО / АдрРег/Улица СообщОперКО / АдрРег/Дом СообщОперКО / АдрРег/Корп СообщОперКО / АдрРег/Оф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле. Пример заполнения 674500, 643, , 76, КРАЙ ЗАБАЙКАЛЬСКИЙ, Оловяннинский р-н, Оловянная пгт, Новая ул, 5, , 1," + }, + { + "index": 142, + "column_name": "Индекс", + "xml_tag": "Индекс", + "xml_path": "СообщОперКО / АдрРег/Индекс", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текст" + }, + { + "index": 143, + "column_name": "Код страны", + "xml_tag": "КодОКСМ", + "xml_path": "СообщОперКО / АдрРег/КодОКСМ", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 144, + "column_name": "Код ОКАТО", + "xml_tag": "КодСубъектаПоОКАТО", + "xml_path": "СообщОперКО / АдрРег/КодСубъектаПоОКАТО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 145, + "column_name": "Район", + "xml_tag": "Район", + "xml_path": "СообщОперКО / АдрРег/Район", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 146, + "column_name": "Населенный пункт", + "xml_tag": "Пункт", + "xml_path": "СообщОперКО / АдрРег/Пункт", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 147, + "column_name": "Улица", + "xml_tag": "Улица", + "xml_path": "СообщОперКО / АдрРег/Улица", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 148, + "column_name": "Дом", + "xml_tag": "Дом", + "xml_path": "СообщОперКО / АдрРег/Дом", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 149, + "column_name": "Корпус/строение", + "xml_tag": "Корп", + "xml_path": "СообщОперКО / АдрРег/Корп", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 150, + "column_name": "Квартира", + "xml_tag": "Оф", + "xml_path": "СообщОперКО / АдрРег/Оф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 151, + "column_name": "Место государственной регистрации ( в одной строке)", + "xml_tag": "АдресСтрока", + "xml_path": "СообщОперКО / УчастникФЛИП/АдресСтрока", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле" + }, + { + "index": 152, + "column_name": "Место государственной регистрации ( по структуре)", + "xml_tag": "Индекс КодОКСМ КодСубъектаПоОКАТО Район Пункт Улица Дом Корп Оф", + "xml_path": "СообщОперКО / АдрРег/Индекс СообщОперКО / АдрРег/КодОКСМ СообщОперКО / АдрРег/КодСубъектаПоОКАТО СообщОперКО / АдрРег/Район СообщОперКО / АдрРег/Пункт СообщОперКО / АдрРег/Улица СообщОперКО / АдрРег/Дом СообщОперКО / АдрРег/Корп СообщОперКО / АдрРег/Оф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле. Пример заполнения 674500, 643, , 76, КРАЙ ЗАБАЙКАЛЬСКИЙ, Оловяннинский р-н, Оловянная пгт, Новая ул, 5, , 1," + }, + { + "index": 153, + "column_name": "Дополнительная информация об участнике операции (сделки)", + "xml_tag": "КомментУчастник", + "xml_path": "СообщОперКО/ УчастникОп/КомментУчастник", + "multiplies": "нет", + "required": "обязательно", + "comment": "текстовое поле" + }, + { + "index": 154, + "column_name": "Тип ЕИО/Бенефициара", + "xml_tag": "ТипЕИО/ИдентБенефициар", + "xml_path": "ТипЕИО: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ТипЕИО ИдентБенефициар: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ИдентБенефициар", + "multiplies": "нет", + "required": "обязательно", + "comment": "поле может оставаться пустым" + }, + { + "index": 155, + "column_name": "Признак ЕИО/Бенефициара", + "xml_tag": "ПризнЕИО/ПризнБенефициар", + "xml_path": "ПризнЕИО: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ПризнЕИО ПризнБенефициар: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ПризнБенефициар", + "multiplies": "нет", + "required": "обязательно", + "comment": "поле может оставаться пустым" + }, + { + "index": 156, + "column_name": "Наименование ЮЛ/ФИО ( по структуре полностью через \",\")", + "xml_tag": "НаимЮЛ/ФИОФЛИП/НаимУчредитель", + "xml_path": "НаимЮЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ ФИОФЛИП: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам (Имя) (Отч) НаимУчредитель: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ и тут берем либо /ЮрЛицо либо /ФизЛицо/Фам (Имя) (Отч)", + "multiplies": "|", + "required": "|", + "comment": "|" + }, + { + "index": 157, + "column_name": "ФИО ( в одной строке)", + "xml_tag": "ФИОФЛИП", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/ФИОСтрока", + "multiplies": "нетнет", + "required": "обязательнообязательно", + "comment": "Текстовый формат. Пример: Иванов Иван ИвановичТекстовый формат. Пример: Иванов Иван Иванович" + }, + { + "index": 158, + "column_name": "Фамилия", + "xml_tag": "Фам", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам", + "multiplies": "нет", + "required": "условно необязательныйусловно необязательный", + "comment": "в какие части XML смотреть для верного раскладывания данных" + }, + { + "index": 159, + "column_name": "Имя", + "xml_tag": "Имя", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Имя", + "multiplies": "нет", + "required": "условно необязательный", + "comment": "|" + }, + { + "index": 160, + "column_name": "Отчество", + "xml_tag": "Отч", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Очт", + "multiplies": "нет", + "required": "условно необязательный", + "comment": "|" + }, + { + "index": 161, + "column_name": "ИНН ЕИО/Бенефициара", + "xml_tag": "ИННЮЛ/ИННФЛИП |", + "xml_path": "СообщОперКО/ УчастникЮЛ/СведЮЛ/ИННЮЛ УчастникФЛИП/СведФЛИП/ФИОФЛИП/ИННФЛИП |", + "multiplies": "нет", + "required": "обязательно", + "comment": "Поле может оставаться пустым. Для юр лиц используется \"ИННЮЛ\" Для физ лиц и ИП \"ИННФЛИП\"" + }, + { + "index": 162, + "column_name": "ОГРН ЕИО/Бенефициара", + "xml_tag": "ОГРНЮЛ ОГРНИП ТипФЛЧастнаяПрактика", + "xml_path": "ОГРНЮЛ: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ ОГРНИП: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП ТипФЛЧастнаяПрактика: СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ТипФЛЧастнаяПрактика", + "multiplies": "нет", + "required": "обязательно", + "comment": "Поле может оставаться пустым. | |" + }, + { + "index": 163, + "column_name": "Иные сведения ЕИО/Бенефициара", + "xml_tag": "СНИЛСФЛ - нет пути в XML ПолисНомер - нет пути в XML ТелефонНомер - нет пути в XML ДатаРождения - нет пути в XML Для ФЛЧП: ТипФЛЧастнаяПрактика - нет пути в XML |", + "xml_path": "не ясно откуда именно брать данные, т.к. у всех (ФЛ,ИП,ЮЛ,ИНБОЮЛ) если бенефициары", + "multiplies": "нет нет", + "required": "Обязательно Обязательно", + "comment": "Текстовое поле, может оставаться пустым и не передаваться в XML Текстовое поле, может оставаться пустым и не передаваться в XML |" + }, + { + "index": 164, + "column_name": "Код страны гражданства ЕИО/Бенефициара", + "xml_tag": "2 ?", + "xml_path": "?", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Уточнить, что подразумевается под цифрой \"2\"" + }, + { + "index": 165, + "column_name": "Признак принадлежности к публичным лицам ЕИО/Бенефициара", + "xml_tag": "ПризнакПубЛицо", + "xml_path": "СообщОперКО / СведДокУдЛичн/ПризнакПубЛицо", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Числовое поле, может оставаться пустым и не передаваться в XML" + }, + { + "index": 166, + "column_name": "Адрес ЮЛ/ Адрес места жительства (места нахождения) ЕИО/Бенефициара (одной строкой)", + "xml_tag": "АдресСтрока для ИСБОЮЛ: АдрУчредитель нет пути в XML", + "xml_path": "СообщОперКО / УчастникФЛИП/СведФЛИП/АдрРег/АдресСтрока", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле , может оставаться пустым" + }, + { + "index": 167, + "column_name": "Адрес ЮЛ/ Адрес места жительства (места нахождения) ЕИО/Бенефициара (структура полностью)", + "xml_tag": "Индекс КодОКСМ КодСубъектаПоОКАТО Район Пункт Улица Дом Корп Оф для ИСБОЮЛ - АдрУчредитель - нет пути в XML", + "xml_path": "СообщОперКО / АдрРег/Индекс СообщОперКО / АдрРег/КодОКСМ СообщОперКО / АдрРег/КодСубъектаПоОКАТО СообщОперКО / АдрРег/Район СообщОперКО / АдрРег/Пункт СообщОперКО / АдрРег/Улица СообщОперКО / АдрРег/Дом СообщОперКО / АдрРег/Корп СообщОперКО / АдрРег/Оф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле, не все показатели моут присутствовать в XML Пример заполнения: 121467, 643, , 45, Г МОСКВА, , Москва г, Молодогвардейская ул, 8, 1, КВ. 28, |" + }, + { + "index": 168, + "column_name": "Индекс", + "xml_tag": "Индекс", + "xml_path": "СообщОперКО / АдрРег/Индекс", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текст" + }, + { + "index": 169, + "column_name": "Код страны", + "xml_tag": "КодОКСМ", + "xml_path": "СообщОперКО / АдрРег/КодОКСМ", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 170, + "column_name": "ОКАТО", + "xml_tag": "КодСубъектаПоОКАТО", + "xml_path": "СообщОперКО / АдрРег/КодСубъектаПоОКАТО", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 171, + "column_name": "Район", + "xml_tag": "Район", + "xml_path": "СообщОперКО / АдрРег/Район", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 172, + "column_name": "Населенный пункт", + "xml_tag": "Пункт", + "xml_path": "СообщОперКО / АдрРег/Пункт", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 173, + "column_name": "Улица", + "xml_tag": "Улица", + "xml_path": "СообщОперКО / АдрРег/Улица", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 174, + "column_name": "Дом", + "xml_tag": "Дом", + "xml_path": "СообщОперКО / АдрРег/Дом", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 175, + "column_name": "Корпус", + "xml_tag": "Корп", + "xml_path": "СообщОперКО / АдрРег/Корп", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 176, + "column_name": "Квартира", + "xml_tag": "Оф", + "xml_path": "СообщОперКО / АдрРег/Оф", + "multiplies": "нет", + "required": "Обязательно", + "comment": "текст" + }, + { + "index": 177, + "column_name": "Место государственной регистрации ЕИО/Бенефициара (одной строкой)", + "xml_tag": "АдресСтрока", + "xml_path": "СообщОперКО / УчастникФЛИП/АдресСтрока", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле" + }, + { + "index": 178, + "column_name": "Место государственной регистрации ЕИО/Бенефициара (структура полностью)", + "xml_tag": "Индекс КодОКСМ КодСубъектаПоОКАТО Район Пункт Улица Дом Корп Оф |", + "xml_path": "СообщОперКО / АдрРег/Индекс СообщОперКО / АдрРег/КодОКСМ СообщОперКО / АдрРег/КодСубъектаПоОКАТО СообщОперКО / АдрРег/Район СообщОперКО / АдрРег/Пункт СообщОперКО / АдрРег/Улица СообщОперКО / АдрРег/Дом СообщОперКО / АдрРег/Корп СообщОперКО / АдрРег/Оф |", + "multiplies": "нет", + "required": "Обязательно", + "comment": "Текстовое поле, не все показатели моут присутствовать в XML Пример заполнения: 121467, 643, , 45, Г МОСКВА, , Москва г, Молодогвардейская ул, 8, 1, КВ. 28, |" + }, + { + "index": 179, + "column_name": "ДУЛ ЕИО/Бенефициара (структура полностью)", + "xml_tag": "ВидДокКод ИноеНаименованиеДок СерияДок НомДок ДатВыдачиДок КемВыданДок КодПодр СведДокУдЛичн", + "xml_path": "/СообщОперКО / СведДокУдЛичн/ВидДокКод ИноеНаименованиеДок - путь не нашла /СообщОперКО/ ОснованиеОп/НомДок СообщОперКО / СведДокУдЛичн/ДатВыдачиДок СообщОперКО / СведДокУдЛичн/КемВыданДок СообщОперКО / СведДокУдЛичн/КодПодр СообщОперКО / СведДокУдЛичн/СведДокУдЛичн |", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные собранные в одном поле. Не все теги могут быть переданы в XML пример Вид документа: 21 Серия документа: 0104 Номер документа: 476635 Орган, выдавший документ: Отделом внутренних дел Красногорского района Алтайского края Код подразделения: 222-022Дата выдачи: 23/08/2005 | Серия документа: 0104 | Номер документа: 476635 | Орган, выдавший документ: Отделом внутренних дел Красногорского района Алтайского края | Код подразделения: 222-022 | Дата выдачи: 23/08/2005" + }, + { + "index": 180, + "column_name": "Признак ДУЛ", + "xml_tag": "ДокУдЛичн", + "xml_path": "/СообщОперКО / УчастникФЛИП/СведФЛИП/ФИОФЛИП/ДокУдЛичн /", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + }, + { + "index": 181, + "column_name": "Код вида документа/Наименование", + "xml_tag": "ВидДокКод", + "xml_path": "/СообщОперКО / СведДокУдЛичн/ВидДокКод /", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель." + }, + { + "index": 182, + "column_name": "Серия", + "xml_tag": "СерияДок", + "xml_path": "/СообщОперКО / СведДокУдЛичн/СерияДок /", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель" + }, + { + "index": 183, + "column_name": "Номер", + "xml_tag": "НомДок", + "xml_path": "/СообщОперКО / СведДокУдЛичн/НомДок", + "multiplies": "нет", + "required": "обязательно", + "comment": "числовой показатель" + }, + { + "index": 184, + "column_name": "Орган, выдавший документ", + "xml_tag": "КемВыданДок", + "xml_path": "СообщОперКО / СведДокУдЛичн/КемВыданДок", + "multiplies": "нет", + "required": "необязательно", + "comment": "текст" + }, + { + "index": 185, + "column_name": "КП", + "xml_tag": "|", + "xml_path": "|", + "multiplies": "|", + "required": "|", + "comment": "|" + }, + { + "index": 186, + "column_name": "Дата выдачи", + "xml_tag": "ДатВыдачиДок", + "xml_path": "СообщОперКО / СведДокУдЛичн/ДатВыдачиДок", + "multiplies": "нет", + "required": "обязательно", + "comment": "дата, формат ДД/ММ/ГГГГ" + }, + { + "index": 187, + "column_name": "Подтверждающий документ (структура полностью)", + "xml_tag": "ВидДокКод ИноеНаименованиеДок путь не нашла Серnf,kияДокПреб - не понятный тэг НомДокПреб - путь не нашла НачПравПреб - путь не нашла ОкончПравПреб - путь не нашла", + "xml_path": "/СообщОперКО/УчастникФЛИП/СведФЛИП/ СведДокУдЛичн/ВидДокКод | |", + "multiplies": "нет", + "required": "обязательно", + "comment": "текстовый показатель. данные могут оставаться пустыми текстовый показатель. данные могут оставаться пустыми" + }, + { + "index": 188, + "column_name": "Код вида документа/Наименование", + "xml_tag": "ВидДокКод", + "xml_path": "/СообщОперКО/УчастникФЛИП/СведФЛИП/ СведДокУдЛичн/ВидДокКод |", + "multiplies": "нет", + "required": "необязательно", + "comment": "текстовый показатель. данные могут оставаться пустыми" + }, + { + "index": 189, + "column_name": "Серия", + "xml_tag": "Серnf,kияДокПреб", + "xml_path": "|", + "multiplies": "нет", + "required": "необязательно", + "comment": "данные могут не передаваться в XML и оставаться пустыми" + }, + { + "index": 190, + "column_name": "Номер", + "xml_tag": "НомДокПреб", + "xml_path": "|", + "multiplies": "нет", + "required": "необязательно", + "comment": "данные могут не передаваться в XML и оставаться пустыми" + }, + { + "index": 191, + "column_name": "Дата начала/окончания срока", + "xml_tag": "НачПравПребН", + "xml_path": "|", + "multiplies": "нет", + "required": "необязательно", + "comment": "данные могут не передаваться в XML и оставаться пустыми. Формат: ДД/ММ/ГГ" + }, + { + "index": 192, + "column_name": "Сведения миграционной карты ЕИО/Бенефициара", + "xml_tag": "уточнить, что за сведения тут подразумеваются", + "xml_path": "|", + "multiplies": "|", + "required": "|", + "comment": "|" + }, + { + "index": 193, + "column_name": "Вид ЦП", + "xml_tag": "ВидЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 194, + "column_name": "Идентиф номер выпуска ЦП", + "xml_tag": "ИдентификаторЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 195, + "column_name": "Код валюты", + "xml_tag": "КодВалЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + }, + { + "index": 196, + "column_name": "Стоимость ЦП", + "xml_tag": "Стоимость ЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + }, + { + "index": 197, + "column_name": "Количество ЦП", + "xml_tag": "КолЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 198, + "column_name": "Наименование/ ФИО эмитента ЦП", + "xml_tag": "ПризнЭмитентЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 199, + "column_name": "Тип эмитента", + "xml_tag": "ТипЭмитентЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 200, + "column_name": "Признак резидентства", + "xml_tag": "ПризнЭмитентЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 201, + "column_name": "ИНН эмитента", + "xml_tag": "ИННЭмитентЦПИНН", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + }, + { + "index": 202, + "column_name": "ОГРН (ОГРНИП) эмитента", + "xml_tag": "ОГРНЭмитентЦПОГРН |", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "показатель считается обязательным, но в XML нет данных" + }, + { + "index": 203, + "column_name": "Дополнительные сведения", + "xml_tag": "КомментЦП", + "xml_path": "не нашла путь", + "multiplies": "нет", + "required": "обязательно", + "comment": "Данные могут оставаться пустыми" + } +] \ No newline at end of file diff --git a/app/pipeline/parser.py b/app/pipeline/parser.py new file mode 100644 index 0000000..f1c896c --- /dev/null +++ b/app/pipeline/parser.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + +from .config import validate_file_name as validate_pipeline_file_name + + +@dataclass +class ParticipantRow: + file_name: str + operation_index: int + record_id: str + operation_fields: dict[str, str] + participant_fields: dict[str, str] + + +@dataclass +class OperationParseError: + operation_index: int + record_id: str + reason: str + + +@dataclass +class FileParseResult: + file_name: str + rows: list[ParticipantRow] = field(default_factory=list) + total_operations: int = 0 + operation_errors: list[OperationParseError] = field(default_factory=list) + fatal_error: str | None = None + + @property + def is_fatal(self) -> bool: + return self.fatal_error is not None + + +def validate_file_name(file_name: str) -> bool: + return validate_pipeline_file_name(file_name) + + +def parse_xml_file(file_path: Path) -> FileParseResult: + try: + content = file_path.read_bytes() + except OSError as exc: + result = FileParseResult(file_name=file_path.name) + result.fatal_error = f"Ошибка чтения файла: {exc}" + return result + return parse_xml_content(file_name=file_path.name, xml_content=content) + + +def parse_xml_content(file_name: str, xml_content: bytes | str) -> FileParseResult: + result = FileParseResult(file_name=file_name) + + if not validate_file_name(file_name): + result.fatal_error = "Некорректное имя файла" + return result + + try: + root = ET.fromstring(xml_content) + except ET.ParseError as exc: + result.fatal_error = f"Ошибка XML: {exc}" + return result + + try: + operations = _get_operations(root) + common_fields = _extract_common_fields(root) + except ValueError as exc: + result.fatal_error = str(exc) + return result + + for index, operation in enumerate(operations, start=1): + result.total_operations += 1 + record_id = f"ОПЕРАЦИЯ_{index}" + try: + operation_fields = _extract_direct_fields( + operation, excluded_tags={"УчастникОп"} + ) + operation_fields = {**common_fields, **operation_fields} + record_id = _extract_record_id(operation_fields, index) + participants = _find_children_by_name(operation, "УчастникОп") + + if participants: + for participant in participants: + participant_fields = _extract_direct_fields(participant) + result.rows.append( + ParticipantRow( + file_name=file_name, + operation_index=index, + record_id=record_id, + operation_fields=operation_fields, + participant_fields=participant_fields, + ) + ) + else: + result.rows.append( + ParticipantRow( + file_name=file_name, + operation_index=index, + record_id=record_id, + operation_fields=operation_fields, + participant_fields={}, + ) + ) + except Exception as exc: # noqa: BLE001 + result.operation_errors.append( + OperationParseError( + operation_index=index, + record_id=record_id, + reason=f"Ошибка обработки операции: {exc}", + ) + ) + + return result + + +def _get_operations(root: ET.Element) -> list[ET.Element]: + if _normalize_tag(root.tag) != "СообщОперКО": + raise ValueError("Некорректный корневой элемент XML (ожидался СообщОперКО)") + + inform_part = _find_child_by_name(root, "ИнформЧасть") + if inform_part is None: + raise ValueError("В XML отсутствует элемент ИнформЧасть") + + details = _find_child_by_name(inform_part, "СведКО") + if details is None: + raise ValueError("В XML отсутствует элемент СведКО") + + return _find_children_by_name(details, "Операция") + + +def _extract_direct_fields( + element: ET.Element, + excluded_tags: Iterable[str] | None = None, +) -> dict[str, str]: + excluded = set(excluded_tags or ()) + fields: dict[str, str] = {} + + for child in element: + tag = _normalize_tag(child.tag) + if tag in excluded: + continue + _collect_leaf_fields(child, tag, fields) + + return fields + + +def _extract_record_id(operation_fields: dict[str, str], operation_index: int) -> str: + possible_keys = ( + "ИдентификаторЗаписи", + "НомерЗаписи", + "ИдЗаписи", + "ИдЗап", + "ИдОпер", + "ID", + ) + for key in possible_keys: + if operation_fields.get(key): + return operation_fields[key] + return f"ОПЕРАЦИЯ_{operation_index}" + + +def _normalize_tag(tag: str) -> str: + if "}" in tag: + return tag.split("}", maxsplit=1)[1] + return tag + + +def _find_child_by_name(parent: ET.Element, child_name: str) -> ET.Element | None: + for child in parent: + if _normalize_tag(child.tag) == child_name: + return child + return None + + +def _find_children_by_name(parent: ET.Element, child_name: str) -> list[ET.Element]: + return [child for child in parent if _normalize_tag(child.tag) == child_name] + + +def _extract_common_fields(root: ET.Element) -> dict[str, str]: + common: dict[str, str] = {} + + service_part = _find_child_by_name(root, "СлужЧасть") + if service_part is not None: + common.update(_extract_direct_fields(service_part)) + + inform_part = _find_child_by_name(root, "ИнформЧасть") + if inform_part is None: + return common + + info_bank = _find_child_by_name(inform_part, "ИнфБанк") + if info_bank is not None: + common.update(_extract_direct_fields(info_bank)) + + details = _find_child_by_name(inform_part, "СведКО") + if details is None: + return common + + common.update( + _extract_direct_fields(details, excluded_tags={"Операция", "ИнфФилиал"}) + ) + branch_info = _find_child_by_name(details, "ИнфФилиал") + if branch_info is not None: + common.update(_extract_direct_fields(branch_info)) + + return common + + +def _collect_leaf_fields( + element: ET.Element, current_path: str, fields: dict[str, str] +) -> None: + children = list(element) + if not children: + _append_value(fields, current_path, (element.text or "").strip()) + return + + for child in children: + child_name = _normalize_tag(child.tag) + _collect_leaf_fields(child, f"{current_path}.{child_name}", fields) + + +def _append_value(fields: dict[str, str], key: str, value: str) -> None: + if key not in fields: + fields[key] = value + return + + existing = fields[key] + if not value: + return + if not existing: + fields[key] = value + return + if value in existing.split("; "): + return + + fields[key] = f"{existing}; {value}" diff --git a/app/pipeline/protocol.py b/app/pipeline/protocol.py new file mode 100644 index 0000000..be49c75 --- /dev/null +++ b/app/pipeline/protocol.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + + +@dataclass +class ProtocolRow: + file_name: str + status: str + message: str + operations_total: int + rows_written: int + launcher: str + processed_at: datetime + + +def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: + row_list = list(rows) + workbook = Workbook() + details = workbook.active + details.title = "Протокол" + details.freeze_panes = "A2" + + columns = [ + "Имя файла", + "Статус", + "Комментарий", + "Операций", + "Строк отчета", + "Запустил", + "Время обработки", + ] + _write_header(details, columns) + for row_index, row in enumerate(row_list, start=2): + values = [ + row.file_name, + row.status, + row.message, + row.operations_total, + row.rows_written, + row.launcher, + row.processed_at.strftime("%Y-%m-%d %H:%M:%S"), + ] + for col_index, value in enumerate(values, start=1): + details.cell(row=row_index, column=col_index, value=value) + + _auto_width(details, columns) + _write_summary(workbook, row_list) + destination.parent.mkdir(parents=True, exist_ok=True) + workbook.save(destination) + + +def _write_summary(workbook: Workbook, rows: list[ProtocolRow]) -> None: + summary = workbook.create_sheet("Итог") + total = len(rows) + success = sum(1 for row in rows if row.status == "success") + partial = sum(1 for row in rows if row.status == "partial") + errors = sum(1 for row in rows if row.status == "error") + metrics = [ + ("Всего файлов", total), + ("Успешно", success), + ("Частично", partial), + ("Ошибки", errors), + ] + for index, (name, value) in enumerate(metrics, start=1): + summary.cell(row=index, column=1, value=name).font = Font(bold=True) + summary.cell(row=index, column=2, value=value) + summary.column_dimensions[get_column_letter(1)].width = 24 + summary.column_dimensions[get_column_letter(2)].width = 14 + + +def _write_header(sheet, columns: list[str]) -> None: + fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") + for index, name in enumerate(columns, start=1): + cell = sheet.cell(row=1, column=index, value=name) + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.fill = fill + + +def _auto_width(sheet, columns: list[str]) -> None: + for index, name in enumerate(columns, start=1): + width = max(14, min(70, len(name) + 6)) + sheet.column_dimensions[get_column_letter(index)].width = width diff --git a/app/pipeline/report.py b/app/pipeline/report.py new file mode 100644 index 0000000..b0354aa --- /dev/null +++ b/app/pipeline/report.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row + + +@dataclass +class ReportRow: + file_name: str + record_id: str + operation_index: int + operation_fields: dict[str, str] + participant_fields: dict[str, str] + + +def write_report(rows: Iterable[ReportRow], destination: Path) -> None: + row_list = list(rows) + columns = list(FIXED_REPORT_COLUMNS) + workbook = Workbook() + sheet = workbook.active + sheet.title = "Отчет" + sheet.freeze_panes = "A2" + + _write_header(sheet, columns) + for row_index, row in enumerate(row_list, start=2): + data = build_fixed_row( + file_name=row.file_name, + record_id=row.record_id, + operation_index=row.operation_index, + operation_fields=row.operation_fields, + participant_fields=row.participant_fields, + ) + for col_index, column_name in enumerate(columns, start=1): + cell = sheet.cell( + row=row_index, column=col_index, value=data.get(column_name, "") + ) + if "Дата" in column_name: + cell.number_format = "DD.MM.YYYY" + if "Сумм" in column_name or "Сумма" in column_name: + cell.number_format = "#,##0.00" + + _auto_width(sheet, columns) + destination.parent.mkdir(parents=True, exist_ok=True) + workbook.save(destination) + + +def _write_header(sheet, columns: list[str]) -> None: + fill = PatternFill(start_color="E2F0D9", end_color="E2F0D9", fill_type="solid") + for index, name in enumerate(columns, start=1): + cell = sheet.cell(row=1, column=index, value=name) + cell.font = Font(bold=True) + cell.alignment = Alignment( + horizontal="center", vertical="center", wrap_text=True + ) + cell.fill = fill + + +def _auto_width(sheet, columns: list[str]) -> None: + for index, column_name in enumerate(columns, start=1): + width = max(12, min(60, len(column_name) + 4)) + column_letter = get_column_letter(index) + sheet.column_dimensions[column_letter].width = width diff --git a/app/pipeline/service.py b/app/pipeline/service.py new file mode 100644 index 0000000..66f319e --- /dev/null +++ b/app/pipeline/service.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from .config import ( + build_process_log_file_name, + build_protocol_file_name, + build_report_file_name, + join_path, +) +from .file_manager import ( + check_input_dir_access, + connect_samba, + create_run_context, + move_file_to_error, + move_file_to_processed, + read_remote_file_bytes, + scan_input_files, + upload_local_file, +) +from .parser import FileParseResult, parse_xml_content +from .protocol import ProtocolRow, write_protocol +from .report import ReportRow, write_report + + +@dataclass +class BatchResult: + status: str + processed: int + partial: int + errors: int + report_path: str + protocol_path: str + log_path: str + messages: list[str] = field(default_factory=list) + + +def run_batch( + smb_base_path: str, + input_dir: str, + samba_user: str, + samba_password: str, + launcher: str, +) -> BatchResult: + messages: list[str] = [] + if not samba_user: + return BatchResult( + status="error", + processed=0, + partial=0, + errors=1, + report_path="", + protocol_path="", + log_path="", + messages=["Введите логин SAMBA."], + ) + + samba_conn = connect_samba( + smb_base_path=smb_base_path, + user_params={"user_name": samba_user, "password": samba_password}, + ) + is_ok, access_error = check_input_dir_access( + input_dir=input_dir, samba_conn=samba_conn + ) + if not is_ok: + return BatchResult( + status="error", + processed=0, + partial=0, + errors=1, + report_path="", + protocol_path="", + log_path="", + messages=[access_error or "Ошибка доступа к входной папке."], + ) + + run_context = create_run_context(input_dir=input_dir, samba_conn=samba_conn) + report_rows: list[ReportRow] = [] + protocol_rows: list[ProtocolRow] = [] + log_lines: list[str] = [] + + scan_result = scan_input_files(input_dir=input_dir, samba_conn=samba_conn) + log_lines.append( + f"{_now()} scan files: valid={len(scan_result.valid_files)} invalid={len(scan_result.invalid_files)}" + ) + + for remote_file in scan_result.invalid_files: + reason = "Некорректное имя файла" + moved_to = move_file_to_error( + file_path=remote_file.path, + file_name=remote_file.name, + error_dir=run_context.error_dir, + samba_conn=samba_conn, + ) + protocol_rows.append( + ProtocolRow( + file_name=remote_file.name, + status="error", + message=reason, + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + log_lines.append(f"{_now()} invalid -> err: {remote_file.path} => {moved_to}") + + for remote_file in scan_result.valid_files: + parse_result = _parse_remote_xml(remote_file.name, remote_file.path, samba_conn) + report_rows.extend(_report_rows_from_parse(parse_result)) + status, message = _resolve_parse_status(parse_result) + protocol_rows.append( + ProtocolRow( + file_name=remote_file.name, + status=status, + message=message, + operations_total=parse_result.total_operations, + rows_written=len(parse_result.rows), + launcher=launcher, + processed_at=datetime.now(), + ) + ) + if status == "success": + moved_to = move_file_to_processed( + file_path=remote_file.path, + file_name=remote_file.name, + processed_dir=run_context.processed_dir, + samba_conn=samba_conn, + ) + log_lines.append( + f"{_now()} success -> processed: {remote_file.path} => {moved_to}" + ) + else: + moved_to = move_file_to_error( + file_path=remote_file.path, + file_name=remote_file.name, + error_dir=run_context.error_dir, + samba_conn=samba_conn, + ) + log_lines.append( + f"{_now()} {status} -> err: {remote_file.path} => {moved_to}" + ) + + if not protocol_rows: + protocol_rows.append( + ProtocolRow( + file_name="-", + status="success", + message="Новых файлов для обработки нет.", + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + log_lines.append(f"{_now()} empty batch") + + report_file_name = build_report_file_name( + run_context.date_key, run_context.run_number + ) + protocol_file_name = build_protocol_file_name( + run_context.date_key, run_context.run_number + ) + log_file_name = build_process_log_file_name( + run_context.date_key, run_context.run_number + ) + + report_path = join_path(run_context.run_dir, report_file_name) + protocol_path = join_path(run_context.run_dir, protocol_file_name) + log_path = join_path(run_context.run_dir, log_file_name) + + _upload_artifacts( + report_rows=report_rows, + protocol_rows=protocol_rows, + log_lines=log_lines, + report_path=report_path, + protocol_path=protocol_path, + log_path=log_path, + samba_conn=samba_conn, + ) + + processed = sum(1 for row in protocol_rows if row.status == "success") + partial = sum(1 for row in protocol_rows if row.status == "partial") + errors = sum(1 for row in protocol_rows if row.status == "error") + status = ( + "success" + if errors == 0 and partial == 0 + else ("partial" if partial > 0 else "error") + ) + messages.extend(log_lines[-5:]) + return BatchResult( + status=status, + processed=processed, + partial=partial, + errors=errors, + report_path=report_path, + protocol_path=protocol_path, + log_path=log_path, + messages=messages, + ) + + +def _parse_remote_xml(file_name: str, remote_path: str, samba_conn) -> FileParseResult: + try: + content = read_remote_file_bytes(remote_path=remote_path, samba_conn=samba_conn) + except Exception as exc: # noqa: BLE001 + result = FileParseResult(file_name=file_name) + result.fatal_error = f"Ошибка чтения файла из SAMBA: {exc}" + return result + return parse_xml_content(file_name=file_name, xml_content=content) + + +def _report_rows_from_parse(parse_result: FileParseResult) -> list[ReportRow]: + result: list[ReportRow] = [] + for row in parse_result.rows: + result.append( + ReportRow( + file_name=row.file_name, + record_id=row.record_id, + operation_index=row.operation_index, + operation_fields=row.operation_fields, + participant_fields=row.participant_fields, + ) + ) + return result + + +def _resolve_parse_status(parse_result: FileParseResult) -> tuple[str, str]: + if parse_result.fatal_error: + return "error", parse_result.fatal_error + if parse_result.operation_errors: + details = "; ".join( + f"операция {item.operation_index}: {item.reason}" + for item in parse_result.operation_errors + ) + return "partial", details + return "success", "Успешно обработан." + + +def _upload_artifacts( + report_rows: list[ReportRow], + protocol_rows: list[ProtocolRow], + log_lines: list[str], + report_path: str, + protocol_path: str, + log_path: str, + samba_conn, +) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir_path = Path(temp_dir) + local_report = temp_dir_path / "report.xlsx" + local_protocol = temp_dir_path / "protocol.xlsx" + local_log = temp_dir_path / "process.log" + + write_report(report_rows, local_report) + write_protocol(protocol_rows, local_protocol) + local_log.write_text("\n".join(log_lines) + "\n", encoding="utf-8") + + upload_local_file(local_report, report_path, samba_conn=samba_conn) + upload_local_file(local_protocol, protocol_path, samba_conn=samba_conn) + upload_local_file(local_log, log_path, samba_conn=samba_conn) + + +def _now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/app/samba_access.py b/app/samba_access.py new file mode 100644 index 0000000..20f8713 --- /dev/null +++ b/app/samba_access.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from app.pipeline.file_manager import check_input_dir_access, connect_samba + + +def build_user_params(samba_user: str, samba_password: str) -> dict[str, str]: + return { + "user_name": samba_user, + "password": samba_password, + } + + +def check_access_and_list_files( + smb_base_path: str, + input_dir: str, + samba_user: str, + samba_password: str, +) -> tuple[bool, str, list[str]]: + if not samba_user: + return False, "Введите логин SAMBA.", [] + + user_params = build_user_params(samba_user, samba_password) + try: + samba_conn = connect_samba(smb_base_path=smb_base_path, user_params=user_params) + except RuntimeError as exc: + return False, str(exc), [] + + ok, error = check_input_dir_access(Path(input_dir), samba_conn=samba_conn) + if not ok: + return False, error or "Ошибка доступа к входной папке.", [] + + with samba_conn: + entries = sorted(samba_conn.listdir(input_dir)) + files: list[str] = [] + for name in entries: + full_path = input_dir.rstrip("/\\") + "\\" + name + if samba_conn.is_file(full_path): + files.append(name) + + return True, "", files diff --git a/main.py b/main.py index d058d9a..e79b7ee 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,18 @@ import streamlit as st from dotenv import load_dotenv +from app.app_config import ACCESS_SUPPORT_CONTACT, SFM_SMB_BASE_PATH, resolve_user_id from app.auth.service import AuthService, clear_session +from app.path_settings import ( + close_path_editor, + ensure_user_path_state, + get_resolved_input_dir, + open_path_editor, + render_path_editor, +) +from app.path_store import set_user_path +from app.pipeline.service import run_batch +from app.samba_access import check_access_and_list_files def main() -> None: @@ -15,11 +26,100 @@ def main() -> None: _, center_col, _ = st.columns([1, 2, 1]) with center_col: - st.button( + user_id = resolve_user_id() + current_input_dir = ensure_user_path_state(user_id) + resolved_input_dir = get_resolved_input_dir(user_id) + + st.subheader("Подключение к сетевому диску") + samba_user = st.text_input( + "Логин SAMBA", + placeholder="Ivanov-IIv", + help="Логин учетной записи для доступа к SMB-шаре.", + ) + samba_password = st.text_input( + "Пароль SAMBA", + type="password", + placeholder="12345abc", + help="Пароль учетной записи для доступа к SMB-шаре.", + ) + if st.button("Изменить путь", use_container_width=True): + ok, message, _ = check_access_and_list_files( + smb_base_path=SFM_SMB_BASE_PATH, + input_dir=resolved_input_dir, + samba_user=samba_user, + samba_password=samba_password, + ) + if ok: + open_path_editor(current_input_dir) + else: + st.error(f"{message} Обратитесь к {ACCESS_SUPPORT_CONTACT}.") + + edited_path = render_path_editor() + if edited_path is not None: + ok, message, _ = check_access_and_list_files( + smb_base_path=SFM_SMB_BASE_PATH, + input_dir=edited_path, + samba_user=samba_user, + samba_password=samba_password, + ) + if ok: + set_user_path(user_id, edited_path) + close_path_editor() + st.success("Путь сохранен.") + st.rerun() + else: + st.error(f"{message} Обратитесь к {ACCESS_SUPPORT_CONTACT}.") + resolved_input_dir = get_resolved_input_dir(user_id) + st.caption(f"Текущий путь: `{resolved_input_dir}`") + + if st.button( "Запустить обработку xml в excel", type="primary", use_container_width=True, - ) + ): + with st.spinner("Выполняется обработка..."): + batch_result = run_batch( + smb_base_path=SFM_SMB_BASE_PATH, + input_dir=resolved_input_dir, + samba_user=samba_user, + samba_password=samba_password, + launcher=user_id, + ) + if batch_result.status == "success": + st.success("Обработка завершена успешно.") + elif batch_result.status == "partial": + st.warning("Обработка завершена частично.") + else: + st.error("Обработка завершена с ошибками.") + st.write( + { + "processed": batch_result.processed, + "partial": batch_result.partial, + "errors": batch_result.errors, + "report_path": batch_result.report_path, + "protocol_path": batch_result.protocol_path, + "log_path": batch_result.log_path, + } + ) + if batch_result.messages: + st.caption("Последние сообщения обработки:") + for message in batch_result.messages: + st.write(f"- {message}") + if st.button("Проверить доступ к папке", use_container_width=True): + ok, message, files = check_access_and_list_files( + smb_base_path=SFM_SMB_BASE_PATH, + input_dir=resolved_input_dir, + samba_user=samba_user, + samba_password=samba_password, + ) + if not ok: + st.error(f"{message} Обратитесь к {ACCESS_SUPPORT_CONTACT}.") + else: + if files: + st.success(f"Папка доступна. Найдено файлов: {len(files)}") + st.write(files) + else: + st.info("Папка доступна, но пуста.") if st.button("Выйти", type="secondary"): clear_session() diff --git a/requirements.txt b/requirements.txt index e2c9153..5676b34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ ruff==0.12.7 black==24.4.2 isort==5.13.2 pre-commit==3.7.1 +openpyxl +pytest +smbprotocol==1.15.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f96b4d8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/smoke/test_batch_smoke.py b/tests/smoke/test_batch_smoke.py new file mode 100644 index 0000000..3b921e3 --- /dev/null +++ b/tests/smoke/test_batch_smoke.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from app.pipeline.service import run_batch + + +class FakeSamba: + def __init__(self, root: Path) -> None: + self.root = root + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): # noqa: ANN001 + return False + + def _to_local(self, remote_path: str) -> Path: + cleaned = remote_path.replace("/", "\\").strip("\\") + return self.root / Path(cleaned) + + def path_exists(self, path: str) -> bool: + return self._to_local(path).exists() + + def listdir(self, path: str) -> list[str]: + target = self._to_local(path) + if not target.exists(): + return [] + return [item.name for item in target.iterdir()] + + def is_file(self, path: str) -> bool: + return self._to_local(path).is_file() + + def mkdir(self, path: str, parent: bool = False) -> None: + target = self._to_local(path) + if parent: + target.mkdir(parents=True, exist_ok=True) + else: + target.mkdir(exist_ok=True) + + def copy(self, path_from: str, path_to: str, replace: bool = False) -> None: + source = self._to_local(path_from) + target = self._to_local(path_to) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and not replace: + raise FileExistsError(path_to) + shutil.copy2(source, target) + + def delete(self, path: str) -> None: + target = self._to_local(path) + if target.exists(): + target.unlink() + + def open(self, path: str, mode: str): + return self._to_local(path).open(mode) + + def upload(self, path_from: str, path_to: str) -> None: + source = Path(path_from) + target = self._to_local(path_to) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _valid_xml() -> str: + return """ +<СообщОперКО xmlns="urn:test"> + <СлужЧасть><ИдФайл>ID1 + <ИнформЧасть> + <СведКО> + <Операция> + <ИдентификаторЗаписи>OP1 + <УчастникОп><Тип>01 + + + + +""" + + +def test_run_batch_smoke_and_idempotency(tmp_path: Path, monkeypatch) -> None: + samba = FakeSamba(tmp_path) + in_dir_local = tmp_path / "Exchange" / "SMBDEMO" / "test" + in_dir_local.mkdir(parents=True) + (in_dir_local / "SKO115FZ_01_123456789_20260616_X00001.xml").write_text( + _valid_xml(), + encoding="utf-8", + ) + + from app.pipeline import service + + monkeypatch.setattr( + service, "connect_samba", lambda smb_base_path, user_params: samba + ) + + first = run_batch( + smb_base_path="//test-server/share", + input_dir="Exchange\\SMBDEMO\\test", + samba_user="user", + samba_password="pass", + launcher="tester", + ) + assert first.status in {"success", "partial"} + assert first.protocol_path + assert first.report_path + + second = run_batch( + smb_base_path="//test-server/share", + input_dir="Exchange\\SMBDEMO\\test", + samba_user="user", + samba_password="pass", + launcher="tester", + ) + assert second.status == "success" + assert second.processed >= 1 diff --git a/tests/unit/test_file_manager.py b/tests/unit/test_file_manager.py new file mode 100644 index 0000000..d195c50 --- /dev/null +++ b/tests/unit/test_file_manager.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from app.pipeline.file_manager import ( + create_run_context, + move_file_to_error, + next_run_number, + scan_input_files, +) + + +class FakeSamba: + def __init__(self, root: Path) -> None: + self.root = root + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): # noqa: ANN001 + return False + + def _to_local(self, remote_path: str) -> Path: + cleaned = remote_path.replace("/", "\\").strip("\\") + return self.root / Path(cleaned) + + def path_exists(self, path: str) -> bool: + return self._to_local(path).exists() + + def listdir(self, path: str) -> list[str]: + target = self._to_local(path) + if not target.exists(): + return [] + return [item.name for item in target.iterdir()] + + def is_file(self, path: str) -> bool: + return self._to_local(path).is_file() + + def mkdir(self, path: str, parent: bool = False) -> None: + target = self._to_local(path) + if parent: + target.mkdir(parents=True, exist_ok=True) + else: + target.mkdir(exist_ok=True) + + def copy(self, path_from: str, path_to: str, replace: bool = False) -> None: + source = self._to_local(path_from) + target = self._to_local(path_to) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and not replace: + raise FileExistsError(path_to) + shutil.copy2(source, target) + + def delete(self, path: str) -> None: + target = self._to_local(path) + if target.exists(): + target.unlink() + + +def test_scan_input_files_splits_valid_and_invalid(tmp_path: Path) -> None: + samba = FakeSamba(tmp_path) + in_dir = "Exchange\\SMBDEMO\\test" + local_dir = tmp_path / "Exchange" / "SMBDEMO" / "test" + local_dir.mkdir(parents=True) + (local_dir / "SKO115FZ_01_123456789_20260616_X00001.xml").write_text( + "ok", encoding="utf-8" + ) + (local_dir / "wrong_name.xml").write_text("bad", encoding="utf-8") + + result = scan_input_files(in_dir, samba) + assert len(result.valid_files) == 1 + assert len(result.invalid_files) == 1 + + +def test_next_run_number_reads_existing_dirs(tmp_path: Path) -> None: + samba = FakeSamba(tmp_path) + out_day = ( + tmp_path / "Exchange" / "SMBDEMO" / "Out" / "2026" / "06_2026" / "20260616" + ) + (out_day / "1").mkdir(parents=True) + (out_day / "2").mkdir(parents=True) + + number = next_run_number("Exchange\\SMBDEMO\\Out\\2026\\06_2026\\20260616", samba) + assert number == 3 + + +def test_create_run_context_creates_paths(tmp_path: Path) -> None: + samba = FakeSamba(tmp_path) + context = create_run_context("Exchange\\SMBDEMO\\test", samba) + + assert context.run_number == 1 + assert (tmp_path / Path(context.run_dir)).exists() + assert (tmp_path / Path(context.error_dir)).exists() + assert (tmp_path / Path(context.processed_dir)).exists() + + +def test_move_file_to_error_adds_suffix_on_collision(tmp_path: Path) -> None: + samba = FakeSamba(tmp_path) + error_dir = tmp_path / "Exchange" / "SMBDEMO" / "Err" + error_dir.mkdir(parents=True) + (error_dir / "file.xml").write_text("existing", encoding="utf-8") + source = tmp_path / "Exchange" / "SMBDEMO" / "test" / "file.xml" + source.parent.mkdir(parents=True) + source.write_text("new", encoding="utf-8") + + target = move_file_to_error( + file_path="Exchange\\SMBDEMO\\test\\file.xml", + file_name="file.xml", + error_dir="Exchange\\SMBDEMO\\Err", + samba_conn=samba, + ) + assert target.endswith("file_1.xml") + assert (error_dir / "file_1.xml").exists() diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py new file mode 100644 index 0000000..d249618 --- /dev/null +++ b/tests/unit/test_mapping.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from app.pipeline.mapping import FIXED_REPORT_COLUMNS, build_fixed_row + + +def _column_with(prefix: str) -> str: + for column in FIXED_REPORT_COLUMNS: + if column.startswith(prefix): + return column + raise AssertionError(f"Колонка с префиксом '{prefix}' не найдена") + + +def test_build_fixed_row_maps_known_fields() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=2, + operation_fields={ + "ИдФайл": "FILE_01", + "КодОперации": "5007", + "СумОперации": "100.55", + }, + participant_fields={ + "ИННФЛ": "123456789012", + "Тип": "01", + }, + ) + + assert row["Имя XML файла"] == "f.xml" + assert row["Идентификатор записи"] == "R1" + assert row["Уникальный номер операции"] == "2" + assert row["Код вида операции"] == "5007" + assert row["Сумма в валюте проведения"] == "100.55" + assert row[_column_with("ИНН ")] == "123456789012" + assert row["Тип участника"] == "01" + + +def test_build_fixed_row_keeps_missing_values_empty() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + for column in FIXED_REPORT_COLUMNS: + assert column in row + assert row["Код вида операции"] == "" + assert row[_column_with("ИНН ")] == "" + + +def test_build_fixed_row_does_not_take_operation_inn_for_participant() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ИНН": "0000000000", + "Тип": "99", + }, + participant_fields={ + "ИННФЛ": "111111111111", + "Тип": "01", + }, + ) + assert row[_column_with("ИНН ")] == "111111111111" + assert row["Тип участника"] == "01" + + +def test_fixed_report_columns_over_200() -> None: + assert len(FIXED_REPORT_COLUMNS) == 203 diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py new file mode 100644 index 0000000..4c13a82 --- /dev/null +++ b/tests/unit/test_parser.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from app.pipeline.parser import parse_xml_content + + +def _base_xml(operations: str) -> str: + return f""" +<СообщОперКО xmlns="urn:test"> + <СлужЧасть><ИдФайл>ID1 + <ИнформЧасть> + <ИнфБанк><БИК>123 + <СведКО> + {operations} + + + +""" + + +def test_parse_xml_with_invalid_name_returns_fatal() -> None: + result = parse_xml_content("broken.xml", "<СообщОперКО/>") + assert result.is_fatal + assert "Некорректное имя файла" in (result.fatal_error or "") + + +def test_parse_xml_with_malformed_xml_returns_fatal() -> None: + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", " None: + xml = """<СообщОперКО>""" + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert result.is_fatal + assert "ИнформЧасть" in (result.fatal_error or "") + + +def test_parse_xml_without_participants_creates_single_row() -> None: + xml = _base_xml( + "<Операция><ИдентификаторЗаписи>OP1<СуммаОпер>100" + ) + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert not result.is_fatal + assert len(result.rows) == 1 + assert result.rows[0].record_id == "OP1" + + +def test_parse_xml_with_namespace_and_two_participants() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP2 + <УчастникОп><Тип>01 + <УчастникОп><Тип>02 + +""" + ) + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert not result.is_fatal + assert len(result.rows) == 2 + assert result.rows[0].participant_fields["Тип"] == "01" + assert result.rows[1].participant_fields["Тип"] == "02" + + +def test_parse_xml_operation_error_marks_partial(monkeypatch) -> None: + xml = _base_xml( + "<Операция><ИдентификаторЗаписи>OP3<УчастникОп><Тип>01" + ) + from app.pipeline import parser + + original = parser._extract_direct_fields + + def _boom(element, excluded_tags=None): # noqa: ANN001 + tag_name = parser._normalize_tag(element.tag) + if tag_name == "УчастникОп": + raise ValueError("bad participant") + return original(element, excluded_tags=excluded_tags) + + monkeypatch.setattr(parser, "_extract_direct_fields", _boom) + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert not result.is_fatal + assert result.operation_errors + assert result.operation_errors[0].record_id == "OP3"