parser
This commit is contained in:
parent
04fed17c9d
commit
9cbfbd041f
@ -11,6 +11,7 @@ repos:
|
|||||||
- id: black
|
- id: black
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.5.2
|
rev: v0.12.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
- id: ruff-format
|
||||||
|
|||||||
68
README.md
68
README.md
@ -1,39 +1,50 @@
|
|||||||
[](https://github.com/astral-sh/ruff)
|
# SFM Streamlit
|
||||||
__________________________________________________________________________________________________________
|
|
||||||
# sfm, v 0.0.1
|
Production-ready Streamlit приложение для обработки ФЭС XML файлов в Excel-отчет и протокол с доступом к файлам через SAMBA (`rail_connectors`).
|
||||||
__sfm__
|
|
||||||
|
|
||||||
| | Исполнитель | Заказчик |
|
|
||||||
|-----------|:----------------------------------------:|:----------------------------------------------:|
|
|
||||||
| Сотрудник | raykov-mse | raykov-mse |
|
|
||||||
| Отдел | aurora | aurora |
|
|
||||||
| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru |
|
|
||||||
-----
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```text
|
```text
|
||||||
main.py
|
main.py
|
||||||
app/
|
app/
|
||||||
config.py # переменные окружения с префиксом OPENBAO__SETTINGS__
|
app_config.py # константы приложения и user-id
|
||||||
ui.py # ui-компоненты
|
path_store.py # JSON-хранилище персональных путей
|
||||||
|
path_settings.py # UI-модалка изменения пути
|
||||||
|
samba_access.py # проверка доступа к SAMBA папке
|
||||||
auth/
|
auth/
|
||||||
service.py # orchestration auth/session
|
service.py # orchestration auth/session
|
||||||
providers.py # local/mock и prod/raisa провайдеры
|
providers.py
|
||||||
mock_provider.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 вход через форму.
|
1. Пользователь вводит SAMBA логин/пароль.
|
||||||
- `OPENBAO__SETTINGS__LOCATION=prod` (или пусто) -> вход через `raisa_streamlit_oauth`.
|
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`.
|
Используются переменные авторизации Raisa (`OPENBAO__SETTINGS__*`).
|
||||||
- До старта приложения выполняется `load_dotenv()`.
|
SMBA source и дефолтный путь входа задаются в коде (`app/app_config.py`).
|
||||||
- Переменные `OPENBAO__SETTINGS__*` автоматически маппятся в обычные
|
|
||||||
(`AUTHORIZE_URL`, `TOKEN_URL`, `APP_NAMESPACE` и т.д.), чтобы библиотека
|
|
||||||
работала по каноничному шаблону из документации.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@ -45,11 +56,13 @@ copy .env.example .env
|
|||||||
streamlit run main.py
|
streamlit run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Линт и формат
|
## Тесты и качество
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pytest
|
||||||
ruff check .
|
ruff check .
|
||||||
ruff format .
|
black --check .
|
||||||
|
isort --check-only .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pre-commit
|
## Pre-commit
|
||||||
@ -59,7 +72,8 @@ pre-commit install
|
|||||||
pre-commit run --all-files
|
pre-commit run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
Используются хуки:
|
Хуки:
|
||||||
- `black`
|
- `black`
|
||||||
- `isort`
|
- `isort`
|
||||||
- `ruff` и `ruff-format`
|
- `ruff`
|
||||||
|
- `ruff-format`
|
||||||
|
|||||||
26
app/app_config.py
Normal file
26
app/app_config.py
Normal file
@ -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}"
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from .mock_provider import get_jwt_token as get_jwt_token_local
|
from .mock_provider import get_jwt_token as get_jwt_token_local
|
||||||
@ -30,4 +31,9 @@ class RaisaProvider:
|
|||||||
|
|
||||||
|
|
||||||
def build_auth_provider() -> AuthProvider:
|
def build_auth_provider() -> AuthProvider:
|
||||||
|
location = os.getenv(
|
||||||
|
"OPENBAO__SETTINGS__LOCATION", os.getenv("LOCATION", "prod")
|
||||||
|
).lower()
|
||||||
|
if location == "local":
|
||||||
|
return LocalMockProvider()
|
||||||
return RaisaProvider()
|
return RaisaProvider()
|
||||||
|
|||||||
@ -12,7 +12,13 @@ def clear_session() -> None:
|
|||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
def run(self) -> bool:
|
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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -20,7 +26,8 @@ class AuthService:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
st.error(
|
st.error(
|
||||||
"Библиотека raisa_streamlit_oauth недоступна. "
|
"Библиотека raisa_streamlit_oauth недоступна. "
|
||||||
"Для локальной разработки установите LOCATION=local."
|
"Для локальной разработки установите LOCATION=local "
|
||||||
|
"или OPENBAO__SETTINGS__LOCATION=local."
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -37,4 +44,5 @@ class AuthService:
|
|||||||
|
|
||||||
st.session_state["token_data"] = token_data
|
st.session_state["token_data"] = token_data
|
||||||
st.session_state["user_profile"] = token_data
|
st.session_state["user_profile"] = token_data
|
||||||
|
st.session_state["oauth_authenticated"] = True
|
||||||
return True
|
return True
|
||||||
|
|||||||
65
app/path_settings.py
Normal file
65
app/path_settings.py
Normal file
@ -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
|
||||||
43
app/path_store.py
Normal file
43
app/path_store.py
Normal file
@ -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",
|
||||||
|
)
|
||||||
1
app/pipeline/__init__.py
Normal file
1
app/pipeline/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Пайплайн обработки XML в Excel."""
|
||||||
72
app/pipeline/config.py
Normal file
72
app/pipeline/config.py
Normal file
@ -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
|
||||||
386
app/pipeline/file_manager.py
Normal file
386
app/pipeline/file_manager.py
Normal file
@ -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
|
||||||
157
app/pipeline/mapping.py
Normal file
157
app/pipeline/mapping.py
Normal file
@ -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 ""
|
||||||
1829
app/pipeline/mapping_table.json
Normal file
1829
app/pipeline/mapping_table.json
Normal file
File diff suppressed because it is too large
Load Diff
238
app/pipeline/parser.py
Normal file
238
app/pipeline/parser.py
Normal file
@ -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}"
|
||||||
91
app/pipeline/protocol.py
Normal file
91
app/pipeline/protocol.py
Normal file
@ -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
|
||||||
69
app/pipeline/report.py
Normal file
69
app/pipeline/report.py
Normal file
@ -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
|
||||||
268
app/pipeline/service.py
Normal file
268
app/pipeline/service.py
Normal file
@ -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")
|
||||||
42
app/samba_access.py
Normal file
42
app/samba_access.py
Normal file
@ -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
|
||||||
102
main.py
102
main.py
@ -1,7 +1,18 @@
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
from dotenv import load_dotenv
|
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.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:
|
def main() -> None:
|
||||||
@ -15,11 +26,100 @@ def main() -> None:
|
|||||||
|
|
||||||
_, center_col, _ = st.columns([1, 2, 1])
|
_, center_col, _ = st.columns([1, 2, 1])
|
||||||
with center_col:
|
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",
|
"Запустить обработку xml в excel",
|
||||||
type="primary",
|
type="primary",
|
||||||
use_container_width=True,
|
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"):
|
if st.button("Выйти", type="secondary"):
|
||||||
clear_session()
|
clear_session()
|
||||||
|
|||||||
@ -5,3 +5,6 @@ ruff==0.12.7
|
|||||||
black==24.4.2
|
black==24.4.2
|
||||||
isort==5.13.2
|
isort==5.13.2
|
||||||
pre-commit==3.7.1
|
pre-commit==3.7.1
|
||||||
|
openpyxl
|
||||||
|
pytest
|
||||||
|
smbprotocol==1.15.0
|
||||||
|
|||||||
8
tests/conftest.py
Normal file
8
tests/conftest.py
Normal file
@ -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))
|
||||||
115
tests/smoke/test_batch_smoke.py
Normal file
115
tests/smoke/test_batch_smoke.py
Normal file
@ -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 """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<СообщОперКО 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
|
||||||
114
tests/unit/test_file_manager.py
Normal file
114
tests/unit/test_file_manager.py
Normal file
@ -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()
|
||||||
71
tests/unit/test_mapping.py
Normal file
71
tests/unit/test_mapping.py
Normal file
@ -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
|
||||||
84
tests/unit/test_parser.py
Normal file
84
tests/unit/test_parser.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.pipeline.parser import parse_xml_content
|
||||||
|
|
||||||
|
|
||||||
|
def _base_xml(operations: str) -> str:
|
||||||
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<СообщОперКО 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", "<broken")
|
||||||
|
assert result.is_fatal
|
||||||
|
assert "Ошибка XML" in (result.fatal_error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_xml_without_inform_part_returns_fatal() -> None:
|
||||||
|
xml = """<?xml version="1.0" encoding="UTF-8"?><СообщОперКО></СообщОперКО>"""
|
||||||
|
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"
|
||||||
Loading…
x
Reference in New Issue
Block a user