feat: prepare ARR for controlled public deployment
This commit is contained in:
332
company_reports/service.py
Normal file
332
company_reports/service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""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,
|
||||
"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)
|
||||
Reference in New Issue
Block a user