"""Mutation seams for uploads and deterministic monthly report generation.""" from __future__ import annotations import os import tempfile import threading import uuid from dataclasses import dataclass from datetime import date from pathlib import Path from typing import Any, Dict, Protocol from arr_ingestion.contracts import IngestionError from arr_ingestion.direct_contracts import SubmissionGrant from arr_ingestion.repository import JobRegistration from arr_processing.callbacks import AgentResultWriteback from arr_processing.contracts import ProcessingRequest from arr_processing.errors import ProcessingError from arr_processing.runner import processing_dispatch_idempotency_key from arr_storage.store import ManagedObjectStore from arr_web.contracts import ( PortalError, validate_upload_filename, validate_xml_payload, ) from monthly_reports.service import MonthlyReportService, RunRequest class UploadCoordinator(Protocol): def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]: ... class MonthlyCoordinator(Protocol): def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]: ... class AgentResultCoordinator(Protocol): def accept(self, raw_signed_result: bytes) -> Dict[str, Any]: ... class ProcessingStarter(Protocol): def start(self, request: ProcessingRequest) -> Any: ... class DirectJobRepository(Protocol): def register_job(self, registration: JobRegistration) -> None: ... def issue_grant( self, job_id: str, attempt_no: int, *, ttl_seconds: int, ) -> SubmissionGrant: ... class UnavailableUploadCoordinator: """Production-safe default until private OSS and the remote file API are wired.""" def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]: raise PortalError( "PROCESSING_GATEWAY_UNAVAILABLE", "文件接收服务尚未完成生产接线", 503, ) class UnavailableMonthlyCoordinator: def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]: raise PortalError( "MONTHLY_GENERATOR_UNAVAILABLE", "月报生成服务暂不可用", 503, ) class UnavailableAgentResultCoordinator: def accept(self, raw_signed_result: bytes) -> Dict[str, Any]: raise PortalError( "AGENT_WRITEBACK_UNAVAILABLE", "Agent 结果回写服务尚未启用", 503, ) @dataclass class ProcessingAgentResultCoordinator: """Web-safe adapter around the authenticated ARR result pipeline.""" writeback: AgentResultWriteback def accept(self, raw_signed_result: bytes) -> Dict[str, Any]: try: return self.writeback.accept(raw_signed_result).to_dict() except (IngestionError, ProcessingError) as error: code = error.code if code in { "PROCESSING_SIGNATURE_INVALID", "PROCESSING_SIGNATURE_EXPIRED", }: status = 401 message = "Agent 结果签名无效或已过期" elif code.endswith("_NOT_FOUND") or code in { "JOB_NOT_FOUND", "PROCESSING_JOB_NOT_FOUND", }: status = 404 message = "Agent 结果对应的业务任务不存在" elif "CONFLICT" in code or code in { "JOB_TERMINAL", "PROCESSING_RESULT_MISMATCH", }: status = 409 message = "Agent 结果与现有业务任务冲突" elif getattr(error, "retryable", False) or code.startswith("DATABASE_"): status = 503 message = "Agent 结果暂时无法写入,请使用相同回调重试" else: status = 422 message = "Agent 结果未通过业务验收" raise PortalError(code, message, status) from None @dataclass class ObjectStoreUploadCoordinator: """ARR upload boundary: private object -> DB registration -> remote dispatch.""" object_store: ManagedObjectStore ingestion_repository: DirectJobRepository processing_starter: ProcessingStarter processor_version: str rule_set_sha256: str submission_grant_ttl_seconds: int = 900 def __post_init__(self) -> None: if ( not isinstance(self.submission_grant_ttl_seconds, int) or isinstance(self.submission_grant_ttl_seconds, bool) or not 30 <= self.submission_grant_ttl_seconds <= 1800 ): raise ValueError("submission grant lifetime is invalid") def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]: validate_upload_filename(original_filename) validate_xml_payload(payload) job_id = "arrjob-" + uuid.uuid4().hex attempt_no = 1 try: with tempfile.TemporaryDirectory(prefix="arr-web-upload-") as temporary: source_path = Path(temporary) / "source.xml" descriptor = os.open( source_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, ) try: with os.fdopen(descriptor, "wb") as target: descriptor = -1 target.write(payload) target.flush() os.fsync(target.fileno()) finally: if descriptor >= 0: os.close(descriptor) stored = self.object_store.upload_committed( job_id=job_id, attempt_no=attempt_no, role="source_xml", source=source_path, original_filename="source.xml", ) source_file_id = "arr_file_" + uuid.uuid4().hex request_without_grant = ProcessingRequest( job_id=job_id, attempt_no=attempt_no, source_file_id=source_file_id, processor_version=self.processor_version, rule_set_sha256=self.rule_set_sha256, ) self.ingestion_repository.register_job( JobRegistration( job_id=job_id, source=stored.to_artifact_ref(), processor_version=self.processor_version, rule_set_sha256=self.rule_set_sha256, attempt_no=attempt_no, idempotency_key=processing_dispatch_idempotency_key( request_without_grant ), ) ) grant = self.ingestion_repository.issue_grant( job_id, attempt_no, ttl_seconds=self.submission_grant_ttl_seconds, ) if grant.job_id != job_id or grant.attempt_no != attempt_no: raise IngestionError( "SUBMISSION_GRANT_UNAVAILABLE", "direct result submission grant identity is invalid", retryable=True, ) request = ProcessingRequest( job_id=job_id, attempt_no=attempt_no, source_file_id=source_file_id, processor_version=self.processor_version, rule_set_sha256=self.rule_set_sha256, submission_grant=grant.submission_grant, ) record = self.processing_starter.start(request) except (IngestionError, ProcessingError) as error: status = ( 503 if getattr(error, "retryable", False) or error.code.startswith("DATABASE_") or error.code in { "OBJECT_STORE_UNAVAILABLE", } else 422 ) raise PortalError(error.code, error.safe_message, status) from None except Exception: raise PortalError( "PROCESSING_SUBMISSION_FAILED", "文件已安全接收,但处理任务未能启动", 503, ) from None remote_status = str(getattr(record, "remote_status", "queued")) return { "job_id": job_id, "status": "queued" if remote_status in {"reserved", "pending"} else remote_status, "attempt_no": attempt_no, "source_sha256": stored.sha256, "source_byte_size": stored.byte_size, } @dataclass class ProgramMonthlyCoordinator: """Runs the ordinary database program; it never invokes an Agent.""" service: MonthlyReportService def __post_init__(self) -> None: self._lock = threading.Lock() def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]: year, month = (int(part) for part in month_key.split("-")) request = RunRequest(year, month, as_of_date) try: request.validate() except Exception: raise PortalError("MONTHLY_REQUEST_INVALID", "月报日期范围无效") from None if not self._lock.acquire(blocking=False): raise PortalError("MONTHLY_ALREADY_RUNNING", "该服务正在生成月报", 409) try: result = self.service.run(request) finally: self._lock.release() payload = result.to_dict() if result.status != "success": error = payload.get("errors", [{}])[0] code = str(error.get("code") or "MONTHLY_GENERATION_FAILED") raise PortalError(code, "月报生成未完成", 422) return payload