909 lines
34 KiB
Python
909 lines
34 KiB
Python
"""Durable, privacy-safe jobs for the accepted company-report processor."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import date, datetime, time as datetime_time, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Callable, Deque, Dict, List, Mapping, Optional, Protocol, Tuple
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from arr_storage.store import ManagedObjectStore
|
|
from arr_web.contracts import PortalError, validate_month
|
|
from arr_web.downloads import ArtifactDescriptor, MAX_DOWNLOAD_BYTES
|
|
from company_reports.contracts import (
|
|
COMPANY_NAMES,
|
|
ENGLISH_MONTH_NAMES,
|
|
RESULT_SCHEMA_VERSION,
|
|
)
|
|
from company_reports.service import CompanyReportService, RunRequest
|
|
|
|
|
|
COMPANY_REPORT_TIME_ZONE = ZoneInfo("Asia/Bangkok")
|
|
PERIOD_KEYS: Tuple[str, ...] = ("01-10", "11-20", "21-month-end")
|
|
ACTIVE_STATES = frozenset({"queued", "running"})
|
|
TERMINAL_STATES = frozenset({"succeeded", "partial_failure", "failed"})
|
|
JOB_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
MAX_JOB_BYTES = 2 * 1024 * 1024
|
|
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
|
def period_to_as_of(report_month: str, period: str) -> date:
|
|
validate_month(report_month)
|
|
if period not in PERIOD_KEYS:
|
|
raise PortalError(
|
|
"COMPANY_REPORT_PERIOD_INVALID",
|
|
"期间必须是 01-10、11-20 或 21-month-end",
|
|
)
|
|
year, month = (int(part) for part in report_month.split("-"))
|
|
if period == "01-10":
|
|
day = 10
|
|
elif period == "11-20":
|
|
day = 20
|
|
else:
|
|
day = calendar.monthrange(year, month)[1]
|
|
return date(year, month, day)
|
|
|
|
|
|
def period_label(report_month: str, period: str) -> str:
|
|
as_of = period_to_as_of(report_month, period)
|
|
if period in {"01-10", "11-20"}:
|
|
return period
|
|
return f"21-{as_of.day:02d}"
|
|
|
|
|
|
def period_complete_at(report_month: str, period: str) -> datetime:
|
|
period_to_as_of(report_month, period)
|
|
year, month = (int(part) for part in report_month.split("-"))
|
|
if period == "01-10":
|
|
released = date(year, month, 11)
|
|
elif period == "11-20":
|
|
released = date(year, month, 21)
|
|
elif month == 12:
|
|
released = date(year + 1, 1, 1)
|
|
else:
|
|
released = date(year, month + 1, 1)
|
|
return datetime.combine(released, datetime_time.min, COMPANY_REPORT_TIME_ZONE)
|
|
|
|
|
|
def period_release_at(report_month: str, period: str) -> datetime:
|
|
"""Backward-compatible alias for the period completion boundary."""
|
|
|
|
return period_complete_at(report_month, period)
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
class CompanyReportExecutor(Protocol):
|
|
def run(
|
|
self,
|
|
report_year: int,
|
|
report_month: int,
|
|
as_of_date: date,
|
|
) -> Mapping[str, Any]:
|
|
...
|
|
|
|
|
|
@dataclass
|
|
class ProgramCompanyReportExecutor:
|
|
"""Calls the accepted deterministic processor; no Agent or shell is involved."""
|
|
|
|
service: CompanyReportService
|
|
|
|
def run(
|
|
self,
|
|
report_year: int,
|
|
report_month: int,
|
|
as_of_date: date,
|
|
) -> Mapping[str, Any]:
|
|
return self.service.run(
|
|
RunRequest(report_year, report_month, as_of_date, COMPANY_NAMES)
|
|
).to_dict()
|
|
|
|
|
|
class CompanyReportCoordinator(Protocol):
|
|
def create(
|
|
self,
|
|
report_month: str,
|
|
period: str,
|
|
*,
|
|
source: Optional[Mapping[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
...
|
|
|
|
def list_jobs(
|
|
self,
|
|
report_month: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[List[Dict[str, Any]], int]:
|
|
...
|
|
|
|
def list_month_counts(self) -> List[Dict[str, Any]]:
|
|
...
|
|
|
|
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
...
|
|
|
|
def resolve_download(self, job_id: str, company: str) -> ArtifactDescriptor:
|
|
...
|
|
|
|
|
|
class UnavailableCompanyReportCoordinator:
|
|
@staticmethod
|
|
def _unavailable() -> PortalError:
|
|
return PortalError(
|
|
"COMPANY_REPORT_GENERATOR_UNAVAILABLE",
|
|
"公司渠道明细生成服务暂不可用",
|
|
503,
|
|
)
|
|
|
|
def create(
|
|
self,
|
|
report_month: str,
|
|
period: str,
|
|
*,
|
|
source: Optional[Mapping[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
_ = source
|
|
raise self._unavailable()
|
|
|
|
def list_jobs(
|
|
self,
|
|
report_month: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[List[Dict[str, Any]], int]:
|
|
raise self._unavailable()
|
|
|
|
def list_month_counts(self) -> List[Dict[str, Any]]:
|
|
return []
|
|
|
|
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
raise self._unavailable()
|
|
|
|
def resolve_download(self, job_id: str, company: str) -> ArtifactDescriptor:
|
|
raise self._unavailable()
|
|
|
|
|
|
class PersistentCompanyReportCoordinator:
|
|
"""One private queue, fixed five-company scope, and controlled artifacts."""
|
|
|
|
def __init__(
|
|
self,
|
|
project_root: Path,
|
|
output_root: Path,
|
|
executor: CompanyReportExecutor,
|
|
*,
|
|
jobs_root: Optional[Path] = None,
|
|
now: Optional[Callable[[], datetime]] = None,
|
|
object_store: Optional[ManagedObjectStore] = None,
|
|
) -> 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("company report output must be inside the project root") from None
|
|
self._output_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
self._jobs_root = (jobs_root or (self._output_root / ".web-jobs")).resolve()
|
|
try:
|
|
self._jobs_root.relative_to(self._output_root)
|
|
except ValueError:
|
|
raise ValueError("company report jobs must be inside the output root") from None
|
|
self._jobs_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chmod(self._jobs_root, 0o700)
|
|
self._executor = executor
|
|
self._object_store = object_store
|
|
self._now = now or (lambda: datetime.now(COMPANY_REPORT_TIME_ZONE))
|
|
self._condition = threading.Condition(threading.RLock())
|
|
self._pending: Deque[str] = deque()
|
|
self._active_job_id: Optional[str] = None
|
|
self._stopping = False
|
|
self._thread = threading.Thread(
|
|
target=self._worker,
|
|
name="arr-company-report-worker",
|
|
daemon=True,
|
|
)
|
|
with self._condition:
|
|
self._recover_locked()
|
|
self._thread.start()
|
|
|
|
def close(self) -> None:
|
|
with self._condition:
|
|
self._stopping = True
|
|
self._condition.notify_all()
|
|
self._thread.join(timeout=10)
|
|
|
|
@staticmethod
|
|
def _source_metadata(
|
|
source: Optional[Mapping[str, Any]],
|
|
) -> Optional[Dict[str, Any]]:
|
|
if source is None:
|
|
return None
|
|
if not isinstance(source, Mapping):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_SOURCE_INVALID",
|
|
"当前 Excel 数据源信息无效",
|
|
409,
|
|
)
|
|
|
|
source_batch_id = source.get("source_batch_id")
|
|
source_type = source.get("source_type")
|
|
filename = source.get("filename")
|
|
activated_at = source.get("activated_at")
|
|
disposition = source.get("disposition")
|
|
if (
|
|
isinstance(source_batch_id, bool)
|
|
or not isinstance(source_batch_id, int)
|
|
or source_batch_id <= 0
|
|
or not isinstance(source_type, str)
|
|
or source_type not in {"excel", "historical"}
|
|
or (
|
|
filename is not None
|
|
and (
|
|
not isinstance(filename, str)
|
|
or not filename
|
|
or len(filename) > 255
|
|
or PurePosixPath(filename).name != filename
|
|
)
|
|
)
|
|
or (
|
|
activated_at is not None
|
|
and (not isinstance(activated_at, str) or len(activated_at) > 80)
|
|
)
|
|
or not isinstance(disposition, str)
|
|
or not disposition
|
|
or len(disposition) > 40
|
|
):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_SOURCE_INVALID",
|
|
"当前 Excel 数据源信息无效",
|
|
409,
|
|
)
|
|
|
|
counts: Dict[str, int] = {}
|
|
for key in (
|
|
"source_rows",
|
|
"worksheet_count",
|
|
"distinct_group_codes",
|
|
"room_quantity",
|
|
):
|
|
value = source.get(key)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
raise PortalError(
|
|
"COMPANY_REPORT_SOURCE_INVALID",
|
|
"当前 Excel 数据源信息无效",
|
|
409,
|
|
)
|
|
counts[key] = value
|
|
|
|
return {
|
|
"source_batch_id": source_batch_id,
|
|
"source_type": source_type,
|
|
"filename": filename,
|
|
**counts,
|
|
"activated_at": activated_at,
|
|
"disposition": disposition,
|
|
}
|
|
|
|
def create(
|
|
self,
|
|
report_month: str,
|
|
period: str,
|
|
*,
|
|
source: Optional[Mapping[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
as_of_date = period_to_as_of(report_month, period)
|
|
source_metadata = self._source_metadata(source)
|
|
current = self._now()
|
|
if current.tzinfo is None:
|
|
current = current.replace(tzinfo=COMPANY_REPORT_TIME_ZONE)
|
|
current = current.astimezone(COMPANY_REPORT_TIME_ZONE)
|
|
report_year, report_month_number = (
|
|
int(part) for part in report_month.split("-")
|
|
)
|
|
if (report_year, report_month_number) > (current.year, current.month):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_MONTH_IN_FUTURE",
|
|
"未来报表月份暂不可生成",
|
|
409,
|
|
)
|
|
with self._condition:
|
|
for existing in self._states_locked():
|
|
if (
|
|
existing.get("state") in ACTIVE_STATES
|
|
and existing.get("report_month") == report_month
|
|
and existing.get("period") == period
|
|
):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_ALREADY_RUNNING",
|
|
"同一月份和期间已有生成任务",
|
|
409,
|
|
)
|
|
job_id = uuid.uuid4().hex
|
|
state: Dict[str, Any] = {
|
|
"schema_version": "1.0",
|
|
"job_id": job_id,
|
|
"state": "queued",
|
|
"stage": "queued",
|
|
"report_month": report_month,
|
|
"period": period,
|
|
"period_label": period_label(report_month, period),
|
|
"as_of_date": as_of_date.isoformat(),
|
|
"requested_companies": list(COMPANY_NAMES),
|
|
"source": source_metadata,
|
|
"created_at": _utc_now(),
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"duration_seconds": None,
|
|
"progress": {"percent": 0, "label": "等待生成"},
|
|
"message": "任务已进入生成队列。",
|
|
"company_results": [],
|
|
"artifact_files": {},
|
|
"failure_code": None,
|
|
}
|
|
self._write_locked(state)
|
|
self._pending.append(job_id)
|
|
self._condition.notify_all()
|
|
return self._public_locked(state)
|
|
|
|
def list_jobs(
|
|
self,
|
|
report_month: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[List[Dict[str, Any]], int]:
|
|
if report_month is not None:
|
|
validate_month(report_month)
|
|
if (
|
|
isinstance(limit, bool)
|
|
or isinstance(offset, bool)
|
|
or not 1 <= limit <= 200
|
|
or not 0 <= offset <= 10_000_000
|
|
):
|
|
raise PortalError("QUERY_INVALID", "查询参数无效")
|
|
with self._condition:
|
|
states = self._states_locked()
|
|
if report_month is not None:
|
|
states = [
|
|
state for state in states if state.get("report_month") == report_month
|
|
]
|
|
total = len(states)
|
|
page = states[offset : offset + limit]
|
|
return [self._public_locked(state) for state in page], total
|
|
|
|
def list_month_counts(self) -> List[Dict[str, Any]]:
|
|
with self._condition:
|
|
counts: Dict[str, int] = {}
|
|
for state in self._states_locked():
|
|
month_key = state.get("report_month")
|
|
if not isinstance(month_key, str):
|
|
continue
|
|
try:
|
|
validate_month(month_key)
|
|
except PortalError:
|
|
continue
|
|
counts[month_key] = counts.get(month_key, 0) + 1
|
|
return [
|
|
{"month_key": month_key, "company_count": counts[month_key]}
|
|
for month_key in sorted(counts, reverse=True)
|
|
]
|
|
|
|
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
with self._condition:
|
|
return self._public_locked(self._read_locked(job_id))
|
|
|
|
def resolve_download(self, job_id: str, company: str) -> ArtifactDescriptor:
|
|
if company not in COMPANY_NAMES:
|
|
raise PortalError("COMPANY_REPORT_COMPANY_INVALID", "公司名称无效")
|
|
with self._condition:
|
|
state = self._read_locked(job_id)
|
|
artifacts = state.get("artifact_files")
|
|
raw = artifacts.get(company) if isinstance(artifacts, Mapping) else None
|
|
if not isinstance(raw, Mapping):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_DOWNLOAD_NOT_FOUND",
|
|
"该公司的正式表格尚不可下载",
|
|
404,
|
|
)
|
|
try:
|
|
descriptor = ArtifactDescriptor(**dict(raw))
|
|
descriptor.validate()
|
|
except (TypeError, PortalError):
|
|
raise PortalError(
|
|
"COMPANY_REPORT_DOWNLOAD_INVALID",
|
|
"文件身份无效",
|
|
500,
|
|
) from None
|
|
return descriptor
|
|
|
|
def _job_path(self, job_id: str) -> Path:
|
|
if JOB_ID_RE.fullmatch(job_id) is None:
|
|
raise PortalError("COMPANY_REPORT_JOB_NOT_FOUND", "任务不存在", 404)
|
|
return self._jobs_root / f"{job_id}.json"
|
|
|
|
def _read_locked(self, job_id: str) -> Dict[str, Any]:
|
|
path = self._job_path(job_id)
|
|
try:
|
|
metadata = path.lstat()
|
|
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_JOB_BYTES:
|
|
raise OSError("unsafe job state")
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
raise PortalError("COMPANY_REPORT_JOB_NOT_FOUND", "任务不存在", 404) from None
|
|
if not isinstance(value, dict) or value.get("job_id") != job_id:
|
|
raise PortalError("COMPANY_REPORT_JOB_NOT_FOUND", "任务不存在", 404)
|
|
return value
|
|
|
|
def _write_locked(self, state: Mapping[str, Any]) -> None:
|
|
job_id = str(state.get("job_id", ""))
|
|
target = self._job_path(job_id)
|
|
temporary = self._jobs_root / f".{job_id}.{uuid.uuid4().hex}.tmp"
|
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as destination:
|
|
descriptor = -1
|
|
json.dump(state, destination, ensure_ascii=False, sort_keys=True, indent=2)
|
|
destination.write("\n")
|
|
destination.flush()
|
|
os.fsync(destination.fileno())
|
|
os.replace(temporary, target)
|
|
os.chmod(target, 0o600)
|
|
finally:
|
|
if descriptor >= 0:
|
|
os.close(descriptor)
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
def _states_locked(self) -> List[Dict[str, Any]]:
|
|
states: List[Dict[str, Any]] = []
|
|
for path in self._jobs_root.glob("*.json"):
|
|
if JOB_ID_RE.fullmatch(path.stem) is None:
|
|
continue
|
|
try:
|
|
states.append(self._read_locked(path.stem))
|
|
except PortalError:
|
|
continue
|
|
return sorted(states, key=lambda value: str(value.get("created_at", "")), reverse=True)
|
|
|
|
def _update_locked(self, job_id: str, **changes: Any) -> Dict[str, Any]:
|
|
state = self._read_locked(job_id)
|
|
state.update(changes)
|
|
self._write_locked(state)
|
|
return state
|
|
|
|
def _recover_locked(self) -> None:
|
|
queued: List[str] = []
|
|
for state in reversed(self._states_locked()):
|
|
job_id = str(state["job_id"])
|
|
if state.get("state") == "running":
|
|
self._fail_locked(
|
|
job_id,
|
|
"COMPANY_REPORT_JOB_INTERRUPTED",
|
|
"服务重启中断了任务,请重新生成。",
|
|
)
|
|
elif state.get("state") == "queued":
|
|
queued.append(job_id)
|
|
self._pending.extend(queued)
|
|
|
|
def _queue_position_locked(self, job_id: str) -> Optional[int]:
|
|
if self._active_job_id == job_id:
|
|
return 0
|
|
try:
|
|
return list(self._pending).index(job_id) + 1
|
|
except ValueError:
|
|
return None
|
|
|
|
def _public_locked(self, state: Mapping[str, Any]) -> Dict[str, Any]:
|
|
job_id = str(state.get("job_id", ""))
|
|
artifacts = state.get("artifact_files")
|
|
artifact_companies = set(artifacts) if isinstance(artifacts, Mapping) else set()
|
|
companies: List[Dict[str, Any]] = []
|
|
downloads: Dict[str, str] = {}
|
|
for raw in state.get("company_results", []):
|
|
if not isinstance(raw, Mapping) or raw.get("company") not in COMPANY_NAMES:
|
|
continue
|
|
company = str(raw["company"])
|
|
item: Dict[str, Any] = {
|
|
"company": company,
|
|
"status": raw.get("status"),
|
|
"row_count": raw.get("row_count", 0),
|
|
"period_row_counts": dict(raw.get("period_row_counts") or {}),
|
|
"warnings": list(raw.get("warnings") or []),
|
|
"errors": list(raw.get("errors") or []),
|
|
"version_no": raw.get("version_no"),
|
|
}
|
|
if raw.get("status") == "success" and company in artifact_companies:
|
|
url = f"/api/company-reports/jobs/{job_id}/downloads/{company}"
|
|
item["download_url"] = url
|
|
downloads[company] = url
|
|
companies.append(item)
|
|
return {
|
|
"job_id": job_id,
|
|
"state": state.get("state"),
|
|
"stage": state.get("stage"),
|
|
"report_month": state.get("report_month"),
|
|
"period": state.get("period"),
|
|
"period_label": state.get("period_label"),
|
|
"as_of_date": state.get("as_of_date"),
|
|
"requested_companies": list(COMPANY_NAMES),
|
|
"source": (
|
|
dict(state["source"])
|
|
if isinstance(state.get("source"), Mapping)
|
|
else None
|
|
),
|
|
"created_at": state.get("created_at"),
|
|
"started_at": state.get("started_at"),
|
|
"finished_at": state.get("finished_at"),
|
|
"duration_seconds": state.get("duration_seconds"),
|
|
"progress": dict(state.get("progress") or {}),
|
|
"message": state.get("message"),
|
|
"failure_code": state.get("failure_code"),
|
|
"queue_position": self._queue_position_locked(job_id),
|
|
"company_results": companies,
|
|
"downloads": downloads,
|
|
"status_url": f"/api/company-reports/jobs/{job_id}",
|
|
}
|
|
|
|
@staticmethod
|
|
def _safe_problem(value: Any) -> Dict[str, Any]:
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("problem must be an object")
|
|
code = value.get("code")
|
|
stage = value.get("stage")
|
|
period = value.get("period")
|
|
record_ids = value.get("record_ids", [])
|
|
if not all(
|
|
isinstance(item, str) and 0 < len(item) <= 100
|
|
for item in (code, stage, period)
|
|
):
|
|
raise ValueError("problem fields are invalid")
|
|
if not isinstance(record_ids, list) or len(record_ids) > 500:
|
|
raise ValueError("problem record ids are invalid")
|
|
safe_ids: List[int] = []
|
|
for item in record_ids:
|
|
if isinstance(item, bool) or not isinstance(item, int) or item < 0:
|
|
raise ValueError("problem record id is invalid")
|
|
safe_ids.append(item)
|
|
return {
|
|
"code": code,
|
|
"stage": stage,
|
|
"period": period,
|
|
"record_ids": safe_ids,
|
|
}
|
|
|
|
def _artifact_descriptor(
|
|
self,
|
|
company: str,
|
|
report_year: int,
|
|
report_month: int,
|
|
value: Any,
|
|
) -> ArtifactDescriptor:
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("artifact is missing")
|
|
filename = value.get("filename")
|
|
storage_key = value.get("storage_key")
|
|
sha256 = value.get("sha256")
|
|
expected_filename = (
|
|
f"{company}-{ENGLISH_MONTH_NAMES[report_month]}-{report_year:04d}.xlsx"
|
|
)
|
|
if (
|
|
filename != expected_filename
|
|
or not isinstance(storage_key, str)
|
|
or not isinstance(sha256, str)
|
|
or SHA256_RE.fullmatch(sha256) is None
|
|
):
|
|
raise ValueError("artifact metadata is invalid")
|
|
storage_provider = str(value.get("storage_provider") or "local")
|
|
bucket_alias = str(value.get("bucket_alias") or "arr-project-root")
|
|
byte_size_value = value.get("byte_size")
|
|
mime_type = str(value.get("mime_type") or XLSX_MIME)
|
|
if storage_provider in {"oss", "s3"}:
|
|
if self._object_store is None:
|
|
raise ValueError("OSS artifact reader is unavailable")
|
|
if (
|
|
isinstance(byte_size_value, bool)
|
|
or not isinstance(byte_size_value, int)
|
|
or byte_size_value <= 0
|
|
or mime_type != XLSX_MIME
|
|
):
|
|
raise ValueError("OSS artifact identity is invalid")
|
|
try:
|
|
stored = self._object_store.inspect_committed(
|
|
storage_key,
|
|
expected_filename,
|
|
)
|
|
except Exception:
|
|
raise ValueError("OSS artifact identity is unavailable") from None
|
|
if (
|
|
stored.role != "company_report"
|
|
or stored.sha256 != sha256
|
|
or stored.byte_size != byte_size_value
|
|
or stored.mime_type != mime_type
|
|
):
|
|
raise ValueError("OSS artifact identity does not match")
|
|
descriptor = ArtifactDescriptor(
|
|
file_kind="company_ten_day_xlsx",
|
|
original_filename=expected_filename,
|
|
storage_key=storage_key,
|
|
sha256=sha256,
|
|
byte_size=byte_size_value,
|
|
mime_type=mime_type,
|
|
storage_provider=storage_provider,
|
|
bucket_alias=bucket_alias,
|
|
)
|
|
descriptor.validate()
|
|
return descriptor
|
|
storage = PurePosixPath(storage_key)
|
|
if storage.is_absolute() or not storage.parts or ".." in storage.parts or "." in storage.parts:
|
|
raise ValueError("artifact path is invalid")
|
|
candidate = self._project_root.joinpath(*storage.parts)
|
|
try:
|
|
metadata = candidate.lstat()
|
|
if candidate.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
|
raise OSError("artifact is not a regular file")
|
|
resolved = candidate.resolve(strict=True)
|
|
resolved.relative_to(self._output_root)
|
|
except (OSError, ValueError):
|
|
raise ValueError("artifact escaped the controlled output") from None
|
|
if resolved.name != expected_filename or resolved.suffix.lower() != ".xlsx":
|
|
raise ValueError("artifact filename is invalid")
|
|
if not 0 < metadata.st_size <= MAX_DOWNLOAD_BYTES:
|
|
raise ValueError("artifact size is invalid")
|
|
if _sha256_file(resolved) != sha256:
|
|
raise ValueError("artifact identity is invalid")
|
|
descriptor = ArtifactDescriptor(
|
|
file_kind="company_ten_day_xlsx",
|
|
original_filename=expected_filename,
|
|
storage_key=storage_key,
|
|
sha256=sha256,
|
|
byte_size=metadata.st_size,
|
|
mime_type=XLSX_MIME,
|
|
storage_provider=storage_provider,
|
|
bucket_alias=bucket_alias,
|
|
)
|
|
descriptor.validate()
|
|
return descriptor
|
|
|
|
def _normalize_result(
|
|
self,
|
|
state: Mapping[str, Any],
|
|
payload: Mapping[str, Any],
|
|
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
|
|
report_year, report_month = (
|
|
int(part) for part in str(state["report_month"]).split("-")
|
|
)
|
|
if (
|
|
payload.get("schema_version") != RESULT_SCHEMA_VERSION
|
|
or payload.get("status") not in {"success", "partial_failure", "failed"}
|
|
or payload.get("report_year") != report_year
|
|
or payload.get("report_month") != report_month
|
|
or payload.get("as_of_date") != state["as_of_date"]
|
|
or payload.get("requested_companies") != list(COMPANY_NAMES)
|
|
or not isinstance(payload.get("companies"), list)
|
|
):
|
|
raise ValueError("batch envelope is invalid")
|
|
raw_companies = payload["companies"]
|
|
if len(raw_companies) != len(COMPANY_NAMES):
|
|
raise ValueError("company result count is invalid")
|
|
by_name: Dict[str, Mapping[str, Any]] = {}
|
|
for item in raw_companies:
|
|
if not isinstance(item, Mapping) or item.get("company") not in COMPANY_NAMES:
|
|
raise ValueError("company identity is invalid")
|
|
company = str(item["company"])
|
|
if company in by_name:
|
|
raise ValueError("company identity is duplicated")
|
|
by_name[company] = item
|
|
if set(by_name) != set(COMPANY_NAMES):
|
|
raise ValueError("company identities are incomplete")
|
|
|
|
expected_periods = {
|
|
"01-10",
|
|
"11-20",
|
|
f"21-{calendar.monthrange(report_year, report_month)[1]:02d}",
|
|
}
|
|
normalized: List[Dict[str, Any]] = []
|
|
artifacts: Dict[str, Dict[str, Any]] = {}
|
|
for company in COMPANY_NAMES:
|
|
item = by_name[company]
|
|
company_status = item.get("status")
|
|
row_count = item.get("row_count")
|
|
counts = item.get("period_row_counts")
|
|
warnings = item.get("warnings", [])
|
|
errors = item.get("errors", [])
|
|
if (
|
|
company_status not in {"success", "failed"}
|
|
or isinstance(row_count, bool)
|
|
or not isinstance(row_count, int)
|
|
or row_count < 0
|
|
or not isinstance(counts, Mapping)
|
|
or set(counts) != expected_periods
|
|
or not isinstance(warnings, list)
|
|
or not isinstance(errors, list)
|
|
):
|
|
raise ValueError("company result fields are invalid")
|
|
safe_counts: Dict[str, int] = {}
|
|
for key, count in counts.items():
|
|
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
raise ValueError("period row count is invalid")
|
|
safe_counts[str(key)] = count
|
|
safe_warnings = [self._safe_problem(value) for value in warnings]
|
|
safe_errors = [self._safe_problem(value) for value in errors]
|
|
version_no = item.get("version_no")
|
|
if company_status == "success":
|
|
if safe_errors or isinstance(version_no, bool) or not isinstance(version_no, int) or version_no <= 0:
|
|
raise ValueError("successful company result is invalid")
|
|
descriptor = self._artifact_descriptor(
|
|
company,
|
|
report_year,
|
|
report_month,
|
|
item.get("artifact"),
|
|
)
|
|
artifacts[company] = asdict(descriptor)
|
|
elif not safe_errors:
|
|
raise ValueError("failed company has no error")
|
|
normalized.append(
|
|
{
|
|
"company": company,
|
|
"status": company_status,
|
|
"row_count": row_count,
|
|
"period_row_counts": safe_counts,
|
|
"warnings": safe_warnings,
|
|
"errors": safe_errors,
|
|
"version_no": version_no if isinstance(version_no, int) else None,
|
|
}
|
|
)
|
|
successful = sum(item["status"] == "success" for item in normalized)
|
|
expected_status = (
|
|
"success"
|
|
if successful == len(COMPANY_NAMES)
|
|
else "failed"
|
|
if successful == 0
|
|
else "partial_failure"
|
|
)
|
|
if payload["status"] != expected_status:
|
|
raise ValueError("batch status is inconsistent")
|
|
job_state = "succeeded" if expected_status == "success" else expected_status
|
|
return job_state, normalized, artifacts
|
|
|
|
def _fail_locked(self, job_id: str, code: str, message: str) -> None:
|
|
state = self._read_locked(job_id)
|
|
company_results = [
|
|
{
|
|
"company": company,
|
|
"status": "failed",
|
|
"row_count": 0,
|
|
"period_row_counts": {},
|
|
"warnings": [],
|
|
"errors": [
|
|
{
|
|
"code": code,
|
|
"stage": "task",
|
|
"period": state["report_month"],
|
|
"record_ids": [],
|
|
}
|
|
],
|
|
"version_no": None,
|
|
}
|
|
for company in COMPANY_NAMES
|
|
]
|
|
self._update_locked(
|
|
job_id,
|
|
state="failed",
|
|
stage="finished",
|
|
finished_at=_utc_now(),
|
|
progress={"percent": 100, "label": "生成未完成"},
|
|
message=message,
|
|
failure_code=code,
|
|
company_results=company_results,
|
|
artifact_files={},
|
|
)
|
|
|
|
def _process(self, job_id: str) -> None:
|
|
started = time.monotonic()
|
|
with self._condition:
|
|
state = self._update_locked(
|
|
job_id,
|
|
state="running",
|
|
stage="loading_source",
|
|
started_at=_utc_now(),
|
|
progress={"percent": 15, "label": "正在读取已校验的 finance 数据"},
|
|
message="正在按公司生成渠道明细表。",
|
|
)
|
|
state = self._update_locked(
|
|
job_id,
|
|
stage="generating_workbooks",
|
|
progress={"percent": 45, "label": "正在生成 5 家公司 Excel"},
|
|
)
|
|
try:
|
|
report_year, report_month = (
|
|
int(part) for part in str(state["report_month"]).split("-")
|
|
)
|
|
payload = self._executor.run(
|
|
report_year,
|
|
report_month,
|
|
date.fromisoformat(str(state["as_of_date"])),
|
|
)
|
|
if not isinstance(payload, Mapping):
|
|
raise ValueError("executor result is not an object")
|
|
job_state, companies, artifacts = self._normalize_result(state, payload)
|
|
message = {
|
|
"succeeded": "5 家公司渠道明细表已生成。",
|
|
"partial_failure": "部分公司已生成,失败公司可按错误代码复核后重跑。",
|
|
"failed": None,
|
|
}[job_state]
|
|
with self._condition:
|
|
self._update_locked(
|
|
job_id,
|
|
state=job_state,
|
|
stage="finished",
|
|
finished_at=_utc_now(),
|
|
duration_seconds=round(time.monotonic() - started, 3),
|
|
progress={
|
|
"percent": 100,
|
|
"label": "生成完成" if job_state == "succeeded" else "生成已结束",
|
|
},
|
|
message=message,
|
|
company_results=companies,
|
|
artifact_files=artifacts,
|
|
failure_code=None,
|
|
)
|
|
except Exception:
|
|
with self._condition:
|
|
self._fail_locked(
|
|
job_id,
|
|
"COMPANY_REPORT_EXECUTION_FAILED",
|
|
"渠道明细生成未完成;未成功发布的公司不会提供下载。",
|
|
)
|
|
self._update_locked(
|
|
job_id,
|
|
duration_seconds=round(time.monotonic() - started, 3),
|
|
)
|
|
|
|
def _worker(self) -> None:
|
|
while True:
|
|
with self._condition:
|
|
while not self._pending and not self._stopping:
|
|
self._condition.wait()
|
|
if self._stopping:
|
|
return
|
|
job_id = self._pending.popleft()
|
|
self._active_job_id = job_id
|
|
try:
|
|
self._process(job_id)
|
|
except Exception:
|
|
with self._condition:
|
|
try:
|
|
self._fail_locked(
|
|
job_id,
|
|
"COMPANY_REPORT_EXECUTION_FAILED",
|
|
"渠道明细生成未完成,请重试。",
|
|
)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
with self._condition:
|
|
self._active_job_id = None
|
|
self._condition.notify_all()
|