SFM/app/pipeline/service.py
2026-07-07 10:04:09 +03:00

379 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import tempfile
import time
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_dir_write_access,
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, StreamingReportWriter
EMPTY_BATCH_STATUS = "info"
@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)
duration_seconds: float = 0.0
def run_batch(
smb_base_path: str,
input_dir: str,
samba_user: str,
samba_password: str,
launcher: str,
) -> BatchResult:
started_at = time.perf_counter()
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)
checks = [
(input_dir, "Входная папка (перемещение файлов)"),
(run_context.run_dir, "Папка Out (текущий запуск)"),
(run_context.processed_dir, "Папка Processed"),
(run_context.error_dir, "Папка Err"),
]
preflight_labels: list[str] = []
for target_dir, label in checks:
is_ok, write_error = check_dir_write_access(
target_dir=target_dir,
samba_conn=samba_conn,
source_name=label,
)
if not is_ok:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[write_error or f"Нет прав на запись в: {target_dir}"],
)
preflight_labels.append(label)
messages.append(
"Проверка прав (чтение/запись/копирование/удаление) пройдена: "
+ ", ".join(preflight_labels)
+ "."
)
protocol_rows: list[ProtocolRow] = []
log_lines: list[str] = []
log_lines.append(f"{_now()} | preflight access checks: ok")
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)} "
)
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)
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"
report_writer = StreamingReportWriter()
_process_files(
scan_result=scan_result,
run_context=run_context,
samba_conn=samba_conn,
launcher=launcher,
report_writer=report_writer,
protocol_rows=protocol_rows,
log_lines=log_lines,
)
if not protocol_rows:
protocol_rows.append(
ProtocolRow(
file_name="-",
status=EMPTY_BATCH_STATUS,
message="Новых файлов для обработки нет.",
operations_total=0,
rows_written=0,
launcher=launcher,
processed_at=datetime.now(),
)
)
log_lines.append(f"{_now()} | empty batch")
report_writer.save(local_report)
write_protocol(protocol_rows, local_protocol)
# Use CRLF to keep one-record-per-line in Windows viewers.
local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8")
_upload_artifacts(
local_report=local_report,
local_protocol=local_protocol,
local_log=local_log,
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.append(
_build_result_message(
status=status, processed=processed, partial=partial, errors=errors
)
)
duration_seconds = round(time.perf_counter() - started_at, 2)
messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.")
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,
duration_seconds=duration_seconds,
)
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 _process_files(
scan_result,
run_context,
samba_conn,
launcher: str,
report_writer: StreamingReportWriter,
protocol_rows: list[ProtocolRow],
log_lines: list[str],
) -> None:
for remote_file in scan_result.invalid_files:
reason = "Некорректное имя XML-файла"
moved_to = ""
try:
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,
)
except Exception as exc: # noqa: BLE001
reason = f"{reason}. Не удалось переместить в Err: {exc}"
log_lines.append(
f"{_now()} | move to Err failed: {remote_file.path}. {exc}"
)
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(),
)
)
if moved_to:
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)
for row in parse_result.rows:
report_writer.append_row(
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,
)
)
status, message = _resolve_parse_status(parse_result)
if status == "success":
try:
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}"
)
except Exception as exc: # noqa: BLE001
status = "partial"
message = f"{message} Не удалось переместить в Processed: {exc}"
log_lines.append(
f"{_now()} | move to Processed failed: {remote_file.path}. {exc}"
)
else:
try:
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}"
)
except Exception as exc: # noqa: BLE001
message = f"{message} Не удалось переместить в Err: {exc}"
if status == "success":
status = "partial"
log_lines.append(
f"{_now()} | move to Err failed: {remote_file.path}. {exc}"
)
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(),
)
)
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(
local_report: Path,
local_protocol: Path,
local_log: Path,
report_path: str,
protocol_path: str,
log_path: str,
samba_conn,
) -> None:
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")
def _build_result_message(
*, status: str, processed: int, partial: int, errors: int
) -> str:
if status == "success":
if processed > 0:
return f"Все успешно: обработано файлов {processed}."
return "Успешно: новых файлов для обработки нет."
if status == "partial":
return (
"Частично успешно: "
f"успешно={processed}, частично={partial}, с ошибками={errors}."
)
return f"Ошибка обработки: успешно={processed}, частично={partial}, с ошибками={errors}."
def _format_duration(duration_seconds: float) -> str:
total_seconds = max(0, int(round(duration_seconds)))
minutes, seconds = divmod(total_seconds, 60)
if minutes:
return f"{minutes} мин {seconds} сек"
return f"{seconds} сек"