Files
wyndham-ARR/company_reports/service.py
2026-08-04 12:37:26 +08:00

337 lines
11 KiB
Python

"""Company-isolated orchestration and privacy-minimized batch results."""
from __future__ import annotations
import json
import os
import uuid
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any, Dict, Mapping, Optional, Protocol, Sequence, Tuple
from company_reports.contracts import (
COMPANY_NAMES,
RESULT_SCHEMA_VERSION,
CompanyReport,
ErrorCode,
ReportProblem,
)
from company_reports.core import build_all_company_reports, validate_as_of_date
from company_reports.publishing import (
BuildError,
BuiltWorkbook,
PublicationError,
PublicationOutcome,
private_staging_directory,
)
from company_reports.repository import ReportRepository, RepositoryError, ReservedReport
BUSINESS_ERROR_CODES = {
ErrorCode.SOURCE_VERSION_MISSING,
ErrorCode.GROUP_CODE_MISSING,
ErrorCode.GROUP_CODE_NOT_FOUND,
ErrorCode.BOOKING_PARSE_FAILED,
ErrorCode.ROOM_ITEMS_MISSING,
ErrorCode.STAY_DATE_INVALID,
ErrorCode.NIGHTS_CONFLICT,
ErrorCode.TOTAL_PRICE_INVALID,
}
class WorkbookBuilder(Protocol):
def build(self, report: CompanyReport, work_dir: Path) -> BuiltWorkbook:
...
class ReportPublisher(Protocol):
def publish(
self,
report: CompanyReport,
reservation: ReservedReport,
built: BuiltWorkbook,
repository: ReportRepository,
work_dir: Path,
) -> PublicationOutcome:
...
@dataclass(frozen=True)
class RunRequest:
report_year: int
report_month: int
as_of_date: date
companies: Tuple[str, ...] = COMPANY_NAMES
def validate(self) -> None:
validate_as_of_date(self.report_year, self.report_month, self.as_of_date)
if not self.companies or len(set(self.companies)) != len(self.companies):
raise ValueError("company selection is empty or contains duplicates")
unsupported = [company for company in self.companies if company not in COMPANY_NAMES]
if unsupported:
raise ValueError("company selection is unsupported")
def _safe_problem(problem: ReportProblem) -> Dict[str, Any]:
return {
"code": problem.code,
"stage": problem.stage,
"period": problem.period,
"record_ids": list(problem.record_ids),
}
def _runtime_problem(code: str, period: str, stage: str) -> Dict[str, Any]:
return {
"code": code,
"stage": stage,
"period": period,
"record_ids": [],
}
@dataclass(frozen=True)
class CompanyRunResult:
company: str
status: str
row_count: int
period_row_counts: Mapping[str, int]
report_version_id: Optional[int] = None
version_no: Optional[int] = None
artifact: Optional[Mapping[str, Any]] = None
warnings: Tuple[Mapping[str, Any], ...] = tuple()
errors: Tuple[Mapping[str, Any], ...] = tuple()
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"company": self.company,
"status": self.status,
"row_count": self.row_count,
"period_row_counts": dict(self.period_row_counts),
"warnings": [dict(warning) for warning in self.warnings],
"errors": [dict(error) for error in self.errors],
}
if self.report_version_id is not None:
payload["report_version_id"] = self.report_version_id
if self.version_no is not None:
payload["version_no"] = self.version_no
if self.artifact is not None:
payload["artifact"] = dict(self.artifact)
return payload
@dataclass(frozen=True)
class BatchRunResult:
request: RunRequest
status: str
companies: Tuple[CompanyRunResult, ...]
@property
def exit_code(self) -> int:
if self.status == "success":
return 0
if self.status == "partial_failure":
return 2
codes = {
str(error.get("code", ""))
for company in self.companies
for error in company.errors
}
return 2 if codes and codes.issubset(BUSINESS_ERROR_CODES) else 4
def to_dict(self) -> Dict[str, Any]:
return {
"schema_version": RESULT_SCHEMA_VERSION,
"status": self.status,
"report_year": self.request.report_year,
"report_month": self.request.report_month,
"as_of_date": self.request.as_of_date.isoformat(),
"requested_companies": list(self.request.companies),
"companies": [company.to_dict() for company in self.companies],
}
class CompanyReportService:
def __init__(
self,
repository: ReportRepository,
builder: WorkbookBuilder,
publisher: ReportPublisher,
staging_root: Path,
) -> None:
self._repository = repository
self._builder = builder
self._publisher = publisher
self._staging_root = staging_root
@staticmethod
def _status(results: Sequence[CompanyRunResult]) -> str:
successful = sum(result.status == "success" for result in results)
if successful == len(results):
return "success"
if successful == 0:
return "failed"
return "partial_failure"
@staticmethod
def _failed(
company: str,
code: str,
period: str,
stage: str,
row_count: int = 0,
period_counts: Optional[Mapping[str, int]] = None,
) -> CompanyRunResult:
return CompanyRunResult(
company=company,
status="failed",
row_count=row_count,
period_row_counts=period_counts or {},
errors=(_runtime_problem(code, period, stage),),
)
def run(self, request: RunRequest) -> BatchRunResult:
request.validate()
period = f"{request.report_year:04d}-{request.report_month:02d}"
try:
snapshot = self._repository.load_snapshot(
request.report_year,
request.report_month,
request.as_of_date,
)
except RepositoryError as error:
failed = tuple(
self._failed(company, error.code, period, "source")
for company in request.companies
)
return BatchRunResult(request, "failed", failed)
reports = build_all_company_reports(
request.report_year,
request.report_month,
request.as_of_date,
snapshot,
request.companies,
)
results = []
for report in reports:
period_counts = {period.key: len(period.rows) for period in report.periods}
if not report.valid:
results.append(
CompanyRunResult(
company=report.company,
status="failed",
row_count=report.row_count,
period_row_counts=period_counts,
warnings=tuple(_safe_problem(item) for item in report.warnings),
errors=tuple(_safe_problem(item) for item in report.errors),
)
)
continue
reservation: Optional[ReservedReport] = None
try:
reservation = self._repository.reserve_report(report)
with private_staging_directory(self._staging_root) as temp_dir:
work_dir = Path(temp_dir)
built = self._builder.build(report, work_dir)
outcome = self._publisher.publish(
report,
reservation,
built,
self._repository,
work_dir,
)
results.append(
CompanyRunResult(
company=report.company,
status="success",
row_count=report.row_count,
period_row_counts=period_counts,
report_version_id=reservation.report_version_id,
version_no=reservation.version_no,
artifact={
"filename": report.filename,
"storage_key": outcome.artifact.storage_key,
"sha256": outcome.artifact.sha256,
"byte_size": outcome.artifact.byte_size,
"mime_type": outcome.artifact.mime_type,
"storage_provider": outcome.artifact.storage_provider,
"bucket_alias": outcome.artifact.bucket_alias,
"semantic_sha256": str(
built.summary.get("semantic_sha256", "")
),
},
warnings=tuple(_safe_problem(item) for item in report.warnings),
)
)
except BuildError as error:
if reservation is not None:
try:
self._repository.mark_failed(
reservation, error.code, error.safe_message
)
except Exception:
pass
results.append(
self._failed(
report.company,
error.code,
period,
"xlsx",
report.row_count,
period_counts,
)
)
except (PublicationError, RepositoryError) as error:
results.append(
self._failed(
report.company,
error.code,
period,
"publish",
report.row_count,
period_counts,
)
)
except Exception:
if reservation is not None:
try:
self._repository.mark_failed(
reservation,
ErrorCode.INTERNAL_ERROR,
"company report processing failed",
)
except Exception:
pass
results.append(
self._failed(
report.company,
ErrorCode.INTERNAL_ERROR,
period,
"internal",
report.row_count,
period_counts,
)
)
company_results = tuple(results)
return BatchRunResult(request, self._status(company_results), company_results)
def write_batch_result(path: Path, result: BatchRunResult) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = path.parent / f".{path.name}.tmp-{uuid.uuid4().hex}"
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(result.to_dict(), handle, ensure_ascii=False, sort_keys=True, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
finally:
temporary.unlink(missing_ok=True)