feat: add daily manual price review workflow

This commit is contained in:
Wyndham ARR
2026-08-06 22:40:18 +08:00
parent aae3d8e1db
commit ca3e8e18fa
77 changed files with 7750 additions and 602 deletions

View File

@@ -11,3 +11,5 @@ stdout/stderr, temporary paths and source bytes never enter the public response
Older remote-run, signature and callback modules remain as ARR1 compatibility/audit code. The ARR2 Web entrypoint does
not import them, root requirements do not install their HTTP client, and Compose exposes no corresponding service.
When that retired path is composed for audit or a historical callback, it uses its frozen v3 success/failure projection;
it cannot enter `review_required` or accept an artificial price.

View File

@@ -10,6 +10,7 @@ from typing import Any, Dict, Mapping, Optional
from arr_ingestion.contracts import (
ARTIFACT_SIZE_LIMITS,
LEGACY_RESULT_SCHEMA_VERSION,
OPAQUE_ID_RE,
PROCESSOR_VERSION_RE,
RESULT_SCHEMA_VERSION,
@@ -269,7 +270,7 @@ class ProcessingResult:
_fail("PROCESSING_CONTRACT_INVALID", "failed result artifact shape is invalid")
result_schema_version = values.get("result_schema_version")
if result_schema_version != RESULT_SCHEMA_VERSION:
if result_schema_version != LEGACY_RESULT_SCHEMA_VERSION:
_fail("PROCESSING_CONTRACT_INVALID", "result schema version is unsupported")
return cls(
delivery_id=_opaque(values.get("delivery_id"), "delivery identifier"),

View File

@@ -37,9 +37,36 @@ class LocalDailyProcessor:
if not (self.policy.skill_root / "scripts" / "process_daily.py").is_file():
raise ValueError("ARR daily processor is unavailable")
def run(self, source_xml: Path, output_dir: Path) -> LocalProcessingOutput:
def run(
self,
source_xml: Path,
output_dir: Path,
*,
manual_overrides: Optional[Path] = None,
review_job_id: Optional[str] = None,
review_case_id: Optional[str] = None,
manual_override_sha256: Optional[str] = None,
) -> LocalProcessingOutput:
if not source_xml.is_file():
raise IngestionError("SOURCE_NOT_FOUND", "registered source XML is unavailable")
manual_arguments = (
manual_overrides,
review_job_id,
review_case_id,
manual_override_sha256,
)
if any(value is not None for value in manual_arguments):
if (
manual_overrides is None
or not manual_overrides.is_file()
or not review_job_id
or not review_case_id
or not manual_override_sha256
):
raise IngestionError(
"MANUAL_OVERRIDE_INVALID",
"frozen manual-price overrides are unavailable",
)
output_dir.mkdir(parents=True, exist_ok=False, mode=0o700)
result_path = output_dir / "result.json"
structured_path = output_dir / "structured-result.json"
@@ -55,6 +82,19 @@ class LocalDailyProcessor:
"--structured-result-json",
str(structured_path.resolve()),
]
if manual_overrides is not None:
command.extend(
(
"--manual-override-json",
str(manual_overrides.resolve()),
"--review-job-id",
str(review_job_id),
"--review-case-id",
str(review_case_id),
"--manual-override-sha256",
str(manual_override_sha256),
)
)
try:
completed = subprocess.run(
command,
@@ -68,20 +108,22 @@ class LocalDailyProcessor:
raise IngestionError(
"PROCESSOR_TIMEOUT",
"deterministic XML processing exceeded its time limit",
retryable=True,
) from None
except OSError:
raise IngestionError(
"PROCESSOR_UNAVAILABLE",
"deterministic XML processing could not start",
retryable=True,
) from None
result = strict_json_file(result_path, "processor result")
structured = strict_json_file(structured_path, "structured processor result")
status = result.get("status")
if (
status not in {"success", "failed"}
status not in {"success", "failed", "review_required"}
or structured.get("status") != status
or (completed.returncode == 0) != (status == "success")
or (completed.returncode == 0) != (status in {"success", "review_required"})
):
raise IngestionError(
"PROCESSOR_RESULT_INVALID",
@@ -121,6 +163,15 @@ class LocalDailyProcessor:
raise IngestionError(
"PROCESSOR_RESULT_INVALID", "successful processor output is incomplete"
)
elif status == "review_required":
if (
business_date is None
or outputs.get("daily_report") is not None
or outputs.get("exception_report") is not None
):
raise IngestionError(
"PROCESSOR_RESULT_INVALID", "review processor output is inconsistent"
)
else:
artifacts["exception_report"] = self._output_path(
output_dir, outputs.get("exception_report"), ".xlsx"

View File

@@ -9,6 +9,15 @@ from pathlib import Path
from arr_ingestion.validation import ProcessorPolicy
# This is the immutable identity of the retired direct-MCP/callback contract.
# It is not a selectable active processor version: only the compatibility
# replay path uses it, and that path has no review-required state.
LEGACY_DIRECT_PROCESSOR_VERSION = "3.0.0"
LEGACY_DIRECT_RULE_SET_SHA256 = (
"c41257208324a43e711de13ec9776a5e6486db334757f152531bf8292a2018eb"
)
def load_processor_policy(project_root: Path) -> ProcessorPolicy:
skill_root = (project_root / "arr-opera-daily-ingest").resolve()
script = skill_root / "scripts" / "process_daily.py"
@@ -33,3 +42,21 @@ def load_processor_policy(project_root: Path) -> ProcessorPolicy:
rule_set_sha256=rule_set_sha256,
skill_root=skill_root,
)
def load_legacy_direct_processor_policy(project_root: Path) -> ProcessorPolicy:
"""Return the frozen v3 identity for retired direct-MCP replay only.
The active package supplies a narrowly scoped ``--legacy-v3-output``
compatibility replay. Keeping this identity separate prevents the ARR2
XML upload path from accepting or producing a v3 review-less result.
"""
active = load_processor_policy(project_root)
return ProcessorPolicy(
processor_version=LEGACY_DIRECT_PROCESSOR_VERSION,
rule_set_sha256=LEGACY_DIRECT_RULE_SET_SHA256,
skill_root=active.skill_root,
python_binary=active.python_binary,
legacy_direct_v3=True,
)