415 lines
14 KiB
Python
415 lines
14 KiB
Python
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()
|
||
stage_timings: dict[str, float] = {
|
||
"smb_read_seconds": 0.0,
|
||
"parse_seconds": 0.0,
|
||
"report_append_seconds": 0.0,
|
||
"move_seconds": 0.0,
|
||
}
|
||
_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,
|
||
stage_timings=stage_timings,
|
||
)
|
||
|
||
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")
|
||
|
||
log_lines.append(
|
||
f"{_now()} | stage timings (sec): "
|
||
f"smb_read={stage_timings['smb_read_seconds']:.3f}, "
|
||
f"parse={stage_timings['parse_seconds']:.3f}, "
|
||
f"report_append={stage_timings['report_append_seconds']:.3f}, "
|
||
f"move={stage_timings['move_seconds']:.3f}"
|
||
)
|
||
|
||
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, stage_timings: dict[str, float]
|
||
) -> FileParseResult:
|
||
try:
|
||
read_started = time.perf_counter()
|
||
content = read_remote_file_bytes(remote_path=remote_path, samba_conn=samba_conn)
|
||
stage_timings["smb_read_seconds"] += time.perf_counter() - read_started
|
||
except Exception as exc: # noqa: BLE001
|
||
result = FileParseResult(file_name=file_name)
|
||
result.fatal_error = f"Ошибка чтения файла из SAMBA: {exc}"
|
||
return result
|
||
parse_started = time.perf_counter()
|
||
result = parse_xml_content(file_name=file_name, xml_content=content)
|
||
stage_timings["parse_seconds"] += time.perf_counter() - parse_started
|
||
return result
|
||
|
||
|
||
def _process_files(
|
||
scan_result,
|
||
run_context,
|
||
samba_conn,
|
||
launcher: str,
|
||
report_writer: StreamingReportWriter,
|
||
protocol_rows: list[ProtocolRow],
|
||
log_lines: list[str],
|
||
stage_timings: dict[str, float],
|
||
) -> None:
|
||
for remote_file in scan_result.invalid_files:
|
||
reason = "Некорректное имя XML-файла"
|
||
moved_to = ""
|
||
try:
|
||
move_started = time.perf_counter()
|
||
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,
|
||
)
|
||
stage_timings["move_seconds"] += time.perf_counter() - move_started
|
||
except Exception as exc: # noqa: BLE001
|
||
stage_timings["move_seconds"] += time.perf_counter() - move_started
|
||
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, stage_timings
|
||
)
|
||
for row in parse_result.rows:
|
||
report_started = time.perf_counter()
|
||
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,
|
||
)
|
||
)
|
||
stage_timings["report_append_seconds"] += (
|
||
time.perf_counter() - report_started
|
||
)
|
||
status, message = _resolve_parse_status(parse_result)
|
||
if status == "success":
|
||
move_started = time.perf_counter()
|
||
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}"
|
||
)
|
||
finally:
|
||
stage_timings["move_seconds"] += time.perf_counter() - move_started
|
||
else:
|
||
move_started = time.perf_counter()
|
||
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}"
|
||
)
|
||
finally:
|
||
stage_timings["move_seconds"] += time.perf_counter() - move_started
|
||
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} сек"
|