"""Workbook build adapter and recoverable atomic publication.""" from __future__ import annotations import hashlib import json import os import shutil import tempfile import uuid from dataclasses import dataclass from datetime import date, datetime from pathlib import Path from typing import Any, Dict, Mapping, Optional, Tuple from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.worksheet.worksheet import Worksheet from company_reports.contracts import CompanyReport, ErrorCode, RESULT_SCHEMA_VERSION from company_reports.repository import ( FileMetadata, ReportRepository, RepositoryError, ReservedReport, ) XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" REPORT_HEADERS = ( "ARRIVAL", "DEPARTURE", "NIGHTS", "BLOCK_CODE", "RES_COMMENT", "Booking Room", "Total Booking Price", ) BODY_FONT = "222222" DUPLICATE_FILL = "FFF2CC" DUPLICATE_FONT = "9C6500" REVIEW_FILL = "FCE4D6" REVIEW_FONT = "C65911" HEADER_FILL = "FFFFFF" class BuildError(RuntimeError): def __init__(self, code: str, safe_message: str): super().__init__(safe_message) self.code = code self.safe_message = safe_message class PublicationError(RuntimeError): def __init__(self, code: str, safe_message: str): super().__init__(safe_message) self.code = code self.safe_message = safe_message @dataclass(frozen=True) class BuiltWorkbook: path: Path sha256: str byte_size: int summary: Mapping[str, Any] @dataclass(frozen=True) class PublicationOutcome: current_path: Path archive_path: Path result_path: Path artifact: FileMetadata result_json: FileMetadata def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _private_json(path: Path, payload: Mapping[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, sort_keys=True, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) except Exception: try: os.close(descriptor) except OSError: pass raise class ArtifactToolBuilder: def __init__( self, builder_script: Path, node_binary: Optional[str] = None, artifact_tool_module: Optional[Path] = None, timeout_seconds: int = 120, ) -> None: _ = (node_binary, artifact_tool_module, timeout_seconds) self._builder_script = builder_script.resolve() @staticmethod def _safe_text(value: Any) -> str: text = str(value or "") return "'" + text if text[:1] in {"=", "+", "-", "@"} else text @staticmethod def _date_key(value: Any) -> str: if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() if isinstance(value, str) and len(value) >= 10: return value[:10] return "" @staticmethod def _cell_text(value: Any) -> str: return "" if value is None else str(value) @staticmethod def _semantic_sha256(payload: Mapping[str, Any]) -> str: encoded = json.dumps( { "headers": payload["headers"], "periods": payload["periods"], }, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _write_sheet( self, worksheet: Worksheet, period: Any, ) -> None: worksheet.append(list(REPORT_HEADERS)) for row in period.rows: worksheet.append( [ row.arrival, row.departure, row.nights, self._safe_text(row.block_code), self._safe_text(row.res_comment), self._safe_text(row.booking_room), self._safe_text(row.total_booking_price), ] ) worksheet.column_dimensions["A"].width = 13 worksheet.column_dimensions["B"].width = 13 worksheet.column_dimensions["C"].width = 9 worksheet.column_dimensions["D"].width = 20 worksheet.column_dimensions["E"].width = 25 worksheet.column_dimensions["F"].width = 34 worksheet.column_dimensions["G"].width = 48 worksheet.freeze_panes = "A2" worksheet.auto_filter.ref = f"A1:G{max(1, worksheet.max_row)}" thin_bottom = Border( bottom=Side(style="thin", color="7F7F7F"), ) header_fill = PatternFill("solid", fgColor=HEADER_FILL) duplicate_fill = PatternFill("solid", fgColor=DUPLICATE_FILL) review_fill = PatternFill("solid", fgColor=REVIEW_FILL) for cell in worksheet[1]: cell.fill = header_fill cell.font = Font(name="Arial", size=10, bold=True, color="000000") cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = thin_bottom worksheet.row_dimensions[1].height = 24 for row_index in range(2, worksheet.max_row + 1): worksheet.row_dimensions[row_index].height = 30 for cell in worksheet[row_index]: cell.font = Font(name="Arial", size=10, color=BODY_FONT) cell.alignment = Alignment(vertical="center") for column in ("A", "B", "C", "D"): worksheet[f"{column}{row_index}"].alignment = Alignment( horizontal="center", vertical="center", ) for column in ("E", "F", "G"): worksheet[f"{column}{row_index}"].alignment = Alignment( horizontal="left", vertical="center", wrap_text=True, ) worksheet[f"A{row_index}"].number_format = "yyyy-mm-dd" worksheet[f"B{row_index}"].number_format = "yyyy-mm-dd" for offset, source_row in enumerate(period.rows, start=2): if source_row.duplicate_group: for column in range(1, 8): worksheet.cell(offset, column).fill = duplicate_fill worksheet[f"E{offset}"].font = Font( name="Arial", size=10, bold=True, color=DUPLICATE_FONT, ) if source_row.multi_price_review: worksheet[f"G{offset}"].fill = review_fill worksheet[f"G{offset}"].font = Font( name="Arial", size=10, bold=True, color=REVIEW_FONT, ) def _validate_workbook(self, output_path: Path, report: CompanyReport) -> int: workbook = load_workbook(output_path, data_only=False) expected_names = [period.sheet_name for period in report.periods] if workbook.sheetnames != expected_names: raise ValueError("worksheet names do not match") formula_count = 0 for period in report.periods: worksheet = workbook[period.sheet_name] expected_rows = len(period.rows) + 1 if worksheet.max_row != expected_rows or worksheet.max_column != 7: raise ValueError("worksheet dimensions do not match") headers = [worksheet.cell(1, column).value for column in range(1, 8)] if headers != list(REPORT_HEADERS): raise ValueError("worksheet headers do not match") for row_index, row in enumerate(period.rows, start=2): expected = [ row.arrival.isoformat(), row.departure.isoformat(), row.nights, self._safe_text(row.block_code), self._safe_text(row.res_comment), self._safe_text(row.booking_room), self._safe_text(row.total_booking_price), ] actual = [ self._date_key(worksheet.cell(row_index, 1).value), self._date_key(worksheet.cell(row_index, 2).value), worksheet.cell(row_index, 3).value, self._cell_text(worksheet.cell(row_index, 4).value), self._cell_text(worksheet.cell(row_index, 5).value), self._cell_text(worksheet.cell(row_index, 6).value), self._cell_text(worksheet.cell(row_index, 7).value), ] if actual != expected: raise ValueError("worksheet values do not match") for cells in worksheet.iter_rows(): for cell in cells: if cell.data_type == "f": formula_count += 1 if formula_count != 0: raise ValueError("workbook must contain no formulas") return formula_count def build(self, report: CompanyReport, work_dir: Path) -> BuiltWorkbook: work_dir.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(work_dir, 0o700) payload_path = work_dir / "workbook-payload.json" output_path = work_dir / report.filename preview_dir = work_dir / "previews" summary_path = work_dir / "workbook-summary.json" try: payload = report.to_workbook_payload() _private_json(payload_path, payload) preview_dir.mkdir(parents=True, exist_ok=True, mode=0o700) workbook = Workbook() for index, period in enumerate(report.periods): worksheet = workbook.active if index == 0 else workbook.create_sheet() worksheet.title = period.sheet_name self._write_sheet(worksheet, period) workbook.save(output_path) os.chmod(output_path, 0o600) formula_count = self._validate_workbook(output_path, report) expected_rows = [len(period.rows) for period in report.periods] expected_names = [period.sheet_name for period in report.periods] summary = { "status": "success", "schema_version": payload["schema_version"], "company": report.company, "filename": report.filename, "sheet_names": expected_names, "row_counts": expected_rows, "formula_count": formula_count, "semantic_sha256": self._semantic_sha256(payload), "preview_count": 0, "byte_size": output_path.stat().st_size, } if ( summary.get("status") != "success" or summary.get("company") != report.company or summary.get("filename") != report.filename or summary.get("sheet_names") != expected_names or summary.get("row_counts") != expected_rows or summary.get("formula_count") != 0 or not isinstance(summary.get("semantic_sha256"), str) or len(summary.get("semantic_sha256")) != 64 or not output_path.is_file() or output_path.stat().st_size <= 0 ): raise ValueError("summary mismatch") _private_json(summary_path, summary) except (OSError, ValueError, TypeError, json.JSONDecodeError): raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "the XLSX builder result could not be validated", ) from None return BuiltWorkbook( path=output_path, sha256=sha256_file(output_path), byte_size=output_path.stat().st_size, summary=summary, ) class AtomicReportPublisher: def __init__(self, project_root: Path, output_root: Path) -> None: self._project_root = project_root.resolve() self._output_root = output_root.resolve() try: self._output_root.relative_to(self._project_root) except ValueError: raise ValueError("output_root must be inside project_root") from None @staticmethod def _fsync_parent(path: Path) -> None: descriptor = os.open(path.parent, os.O_RDONLY) try: os.fsync(descriptor) finally: os.close(descriptor) @classmethod def _atomic_copy(cls, source: Path, target: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}" try: shutil.copyfile(source, temporary) os.chmod(temporary, 0o600) with temporary.open("rb") as handle: os.fsync(handle.fileno()) os.replace(temporary, target) cls._fsync_parent(target) finally: temporary.unlink(missing_ok=True) @classmethod def _atomic_json(cls, target: Path, payload: Mapping[str, Any]) -> None: target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}" try: _private_json(temporary, payload) os.replace(temporary, target) cls._fsync_parent(target) finally: temporary.unlink(missing_ok=True) @classmethod def _install_once(cls, source: Path, target: Path, expected_sha256: str) -> bool: if target.exists(): if sha256_file(target) != expected_sha256: raise PublicationError( ErrorCode.PUBLISH_FAILED, "an immutable archive path already contains different content", ) return False cls._atomic_copy(source, target) return True def _storage_key(self, path: Path) -> str: try: return path.resolve().relative_to(self._project_root).as_posix() except ValueError: raise PublicationError( ErrorCode.PUBLISH_FAILED, "an artifact path escaped the controlled project root", ) from None @staticmethod def _is_regular_file(path: Path) -> bool: return path.is_file() and not path.is_symlink() @staticmethod def _is_sha256(value: Any) -> bool: return ( isinstance(value, str) and len(value) == 64 and all(character in "0123456789abcdef" for character in value) ) def _existing_publication_metadata( self, report: CompanyReport, reservation: ReservedReport, built: BuiltWorkbook, archive_path: Path, result_path: Path, ) -> Optional[Tuple[FileMetadata, FileMetadata]]: archive_present = archive_path.exists() or archive_path.is_symlink() result_present = result_path.exists() or result_path.is_symlink() if not archive_present and not result_present: return None if not self._is_regular_file(archive_path) or not self._is_regular_file( result_path ): raise PublicationError( ErrorCode.PUBLISH_FAILED, "an existing company report publication is incomplete", ) semantic_sha256 = built.summary.get("semantic_sha256") if not self._is_sha256(semantic_sha256): raise PublicationError( ErrorCode.PUBLISH_FAILED, "the generated company report semantic identity is invalid", ) try: result_payload = json.loads(result_path.read_text(encoding="utf-8")) if not isinstance(result_payload, dict): raise ValueError("result JSON must be an object") artifact_payload = result_payload.get("artifact") if not isinstance(artifact_payload, dict): raise ValueError("result JSON artifact identity is missing") existing_sha256 = artifact_payload.get("sha256") if not self._is_sha256(existing_sha256): raise ValueError("result JSON artifact hash is invalid") archive_sha256 = sha256_file(archive_path) if archive_sha256 != existing_sha256: raise ValueError("archive and result JSON hashes differ") artifact_storage_key = self._storage_key(archive_path) expected_payload = self._success_result( report, reservation, artifact_storage_key, existing_sha256, semantic_sha256, ) if result_payload != expected_payload: raise ValueError("result JSON identity differs") artifact = FileMetadata( file_kind="company_ten_day_xlsx", original_filename=report.filename, storage_key=artifact_storage_key, sha256=existing_sha256, byte_size=archive_path.stat().st_size, mime_type=XLSX_MIME, ) result_json = FileMetadata( file_kind="result_json", original_filename=result_path.name, storage_key=self._storage_key(result_path), sha256=sha256_file(result_path), byte_size=result_path.stat().st_size, mime_type="application/json", ) artifact.validate() result_json.validate() return artifact, result_json except (OSError, UnicodeError, TypeError, ValueError, RepositoryError): raise PublicationError( ErrorCode.PUBLISH_FAILED, "an existing company report publication identity is invalid", ) from None def _archive_untracked_current(self, current_path: Path, archive_root: Path) -> None: if not current_path.is_file(): return current_sha = sha256_file(current_path) legacy_path = archive_root / "legacy" / current_sha[:16] / current_path.name self._install_once(current_path, legacy_path, current_sha) @staticmethod def _success_result( report: CompanyReport, reservation: ReservedReport, artifact_storage_key: str, artifact_sha256: str, semantic_sha256: str, ) -> Dict[str, Any]: return { "schema_version": RESULT_SCHEMA_VERSION, "status": "success", "company": report.company, "report_year": report.report_year, "report_month": report.report_month, "as_of_date": report.as_of_date.isoformat(), "report_version_id": reservation.report_version_id, "version_no": reservation.version_no, "artifact": { "filename": report.filename, "storage_key": artifact_storage_key, "sha256": artifact_sha256, "semantic_sha256": semantic_sha256, }, "row_count": report.row_count, "period_row_counts": { period.key: len(period.rows) for period in report.periods }, "daily_version_count": len(report.daily_versions), "booking_version_count": len(report.booking_versions), "warnings": [ { "code": warning.code, "stage": warning.stage, "period": warning.period, "record_ids": list(warning.record_ids), } for warning in report.warnings ], "errors": [], } def publish( self, report: CompanyReport, reservation: ReservedReport, built: BuiltWorkbook, repository: ReportRepository, work_dir: Path, ) -> PublicationOutcome: month_root = ( self._output_root / f"{report.report_year:04d}" / f"{report.report_month:02d}" ) archive_root = month_root / "archive" version_root = archive_root / f"v{reservation.version_no:04d}" current_path = month_root / report.filename archive_path = version_root / report.filename result_path = version_root / f"{report.company}-v{reservation.version_no:04d}.result.json" backup_path = work_dir / "previous-current.xlsx" current_existed = current_path.is_file() archive_created = False result_created = False current_replaced = False try: month_root.mkdir(parents=True, exist_ok=True, mode=0o700) existing = self._existing_publication_metadata( report, reservation, built, archive_path, result_path, ) if existing is not None: artifact, result_json = existing if not current_existed: self._atomic_copy(archive_path, current_path) current_replaced = True repository.activate_report(reservation, artifact, result_json) return PublicationOutcome( current_path=current_path, archive_path=archive_path, result_path=result_path, artifact=artifact, result_json=result_json, ) self._archive_untracked_current(current_path, archive_root) if current_existed: shutil.copyfile(current_path, backup_path) os.chmod(backup_path, 0o600) archive_created = self._install_once( built.path, archive_path, built.sha256 ) artifact_storage_key = self._storage_key(archive_path) result_payload = self._success_result( report, reservation, artifact_storage_key, built.sha256, str(built.summary.get("semantic_sha256", "")), ) if result_path.exists(): expected = json.dumps( result_payload, ensure_ascii=False, sort_keys=True, indent=2, ) + "\n" if result_path.read_text(encoding="utf-8") != expected: raise PublicationError( ErrorCode.PUBLISH_FAILED, "an immutable result path already contains different content", ) else: self._atomic_json(result_path, result_payload) result_created = True self._atomic_copy(built.path, current_path) current_replaced = True artifact = FileMetadata( file_kind="company_ten_day_xlsx", original_filename=report.filename, storage_key=artifact_storage_key, sha256=built.sha256, byte_size=built.byte_size, mime_type=XLSX_MIME, ) result_json = FileMetadata( file_kind="result_json", original_filename=result_path.name, storage_key=self._storage_key(result_path), sha256=sha256_file(result_path), byte_size=result_path.stat().st_size, mime_type="application/json", ) repository.activate_report(reservation, artifact, result_json) return PublicationOutcome( current_path=current_path, archive_path=archive_path, result_path=result_path, artifact=artifact, result_json=result_json, ) except Exception as error: if current_replaced: if current_existed and backup_path.is_file(): self._atomic_copy(backup_path, current_path) else: current_path.unlink(missing_ok=True) if result_created: result_path.unlink(missing_ok=True) if archive_created: archive_path.unlink(missing_ok=True) code = getattr(error, "code", ErrorCode.PUBLISH_FAILED) safe_message = getattr(error, "safe_message", "company report publication failed") try: repository.mark_failed(reservation, code, safe_message) except Exception: pass if isinstance(error, PublicationError): raise if isinstance(error, RepositoryError): raise PublicationError(error.code, error.safe_message) from None raise PublicationError( ErrorCode.PUBLISH_FAILED, "company report publication failed", ) from None def private_staging_directory(parent: Path): parent.mkdir(parents=True, exist_ok=True, mode=0o700) return tempfile.TemporaryDirectory(prefix="company-report-", dir=parent)