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

@@ -15,6 +15,7 @@ from arr_ingestion.contracts import (
ARTIFACT_ROLES,
DELIVERY_SCHEMA_VERSION,
RESULT_SCHEMA_VERSION,
ArtifactRef,
DeliveryEnvelope,
IngestionError,
)
@@ -116,11 +117,10 @@ class ProgrammaticUploadCoordinator:
terminal = True
return {
"job_id": job_id,
"status": (
"failed"
if outcome.status == "recorded_failure"
else "succeeded"
),
"status": {
"recorded_failure": "failed",
"recorded_review": "needs_review",
}.get(outcome.status, "succeeded"),
"ingestion_status": outcome.status,
"attempt_no": attempt_no,
"business_date": (
@@ -132,6 +132,10 @@ class ProgrammaticUploadCoordinator:
"version_no": outcome.version_no,
"source_sha256": source.sha256,
"source_byte_size": source.byte_size,
"review_case_id": outcome.review_case_id,
"review_revision": outcome.review_revision,
"review_completed_items": outcome.review_completed_items,
"review_total_items": outcome.review_total_items,
}
except PortalError:
raise
@@ -159,6 +163,233 @@ class ProgrammaticUploadCoordinator:
503,
) from None
def get_price_review(self, job_id: str, limit: int, offset: int) -> Dict[str, Any]:
try:
return self.ingestion_repository.get_price_review(job_id, limit, offset)
except IngestionError as error:
self._raise_review_portal_error(error)
def update_price_review_item(
self,
job_id: str,
item_id: int,
case_id: str,
revision: int,
real_price: str,
actor_username: str,
) -> Dict[str, Any]:
try:
return self.ingestion_repository.update_price_review_item(
job_id,
item_id,
case_id,
revision,
real_price,
actor_username,
)
except IngestionError as error:
self._raise_review_portal_error(error)
def finalize_price_review(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
) -> Dict[str, Any]:
idempotency_key = hashlib.sha256(uuid.uuid4().bytes).hexdigest()
try:
plan = self.ingestion_repository.begin_price_review_generation(
job_id,
case_id,
revision,
actor_username,
self.processor_version,
self.rule_set_sha256,
idempotency_key,
)
except IngestionError as error:
self._raise_review_portal_error(error)
if plan.status == "completed":
return {
"job_id": job_id,
"status": "succeeded",
"ingestion_status": "already_committed",
"attempt_no": None,
"business_date": plan.business_date.isoformat(),
"daily_version_id": plan.daily_version_id,
"version_no": plan.version_no,
"review_case_id": plan.review_case_id,
}
if plan.status == "processing":
return {
"job_id": job_id,
"status": "needs_review",
"ingestion_status": "generation_processing",
"attempt_no": plan.attempt_no,
"business_date": plan.business_date.isoformat(),
"review_case_id": plan.review_case_id,
}
if (
plan.status != "ready"
or plan.attempt_no is None
or plan.manual_override_bytes is None
or plan.manual_override_sha256 is None
or plan.source is None
):
raise PortalError("REVIEW_STATE_INVALID", "复核生成状态无效", 409)
try:
with tempfile.TemporaryDirectory(prefix="arr-review-finalize-") as temporary:
root = Path(temporary)
source_path = root / "processor-input" / "source.xml"
self.object_store.materialize(
plan.source.object_key,
source_path,
plan.source.byte_size,
)
manifest_path = root / "review-input" / "manual-override.json"
self._write_private(manifest_path, plan.manual_override_bytes)
self.ingestion_repository.mark_running(job_id, plan.attempt_no)
processed = self.processor.run(
source_path,
root / "processor-output",
manual_overrides=manifest_path,
review_job_id=job_id,
review_case_id=plan.review_case_id,
manual_override_sha256=plan.manual_override_sha256,
)
stored_outputs: Dict[str, StoredObject] = {}
if processed.status == "success":
stored_outputs["manual_override_json"] = self.object_store.upload_committed(
job_id=job_id,
attempt_no=plan.attempt_no,
role="manual_override_json",
source=manifest_path,
original_filename="manual-override.json",
)
for role, path in processed.artifacts.items():
stored_outputs[role] = self.object_store.upload_committed(
job_id=job_id,
attempt_no=plan.attempt_no,
role=role,
source=path,
original_filename=path.name,
)
envelope = self._delivery(
job_id=job_id,
attempt_no=plan.attempt_no,
source=plan.source,
outputs=stored_outputs,
status=processed.status,
business_date=(
processed.business_date.isoformat()
if processed.business_date is not None
else None
),
)
raw_envelope = (
json.dumps(
envelope.to_dict(),
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
+ "\n"
).encode("utf-8")
outcome = self.ingestion_service.ingest(raw_envelope)
return {
"job_id": job_id,
"status": "failed" if outcome.status == "recorded_failure" else "succeeded",
"ingestion_status": outcome.status,
"attempt_no": plan.attempt_no,
"business_date": (
outcome.business_date.isoformat() if outcome.business_date is not None else None
),
"daily_version_id": outcome.daily_version_id,
"version_no": outcome.version_no,
"review_case_id": plan.review_case_id,
}
except IngestionError as error:
if self._is_retryable_review_generation_error(error):
self._best_effort_review_generation_failure(
job_id, plan.review_case_id, plan.attempt_no, error.code
)
else:
self._best_effort_failure(job_id, plan.attempt_no, error.code)
self._raise_review_portal_error(error)
except Exception:
self._best_effort_review_generation_failure(
job_id,
plan.review_case_id,
plan.attempt_no,
"REVIEW_GENERATION_FAILED",
)
raise PortalError("REVIEW_GENERATION_FAILED", "正式日报生成暂未完成,可使用同一清单重试", 503) from None
def cancel_price_review(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
) -> Dict[str, Any]:
try:
return self.ingestion_repository.cancel_price_review(
job_id, case_id, revision, actor_username
)
except IngestionError as error:
self._raise_review_portal_error(error)
@staticmethod
def _raise_review_portal_error(error: IngestionError) -> None:
status = 422
if error.code in {
"REVIEW_REVISION_CONFLICT",
"REVIEW_IMMUTABLE",
"REVIEW_NOT_OPEN",
"REVIEW_NOT_CANCELLABLE",
"REVIEW_RULESET_CHANGED",
"REVIEW_STATE_INVALID",
}:
status = 409
elif error.code in {"REVIEW_NOT_FOUND", "REVIEW_ITEM_NOT_FOUND", "JOB_NOT_FOUND"}:
status = 404
elif error.retryable or error.code.startswith("DATABASE_"):
status = 503
raise PortalError(error.code, error.safe_message, status) from None
def _best_effort_review_generation_failure(
self,
job_id: str,
case_id: str,
attempt_no: int,
failure_code: str,
) -> None:
try:
self.ingestion_repository.record_price_review_generation_failure(
job_id, case_id, attempt_no, failure_code
)
except Exception:
pass
@staticmethod
def _is_retryable_review_generation_error(error: IngestionError) -> bool:
"""Keep a frozen case open only for failures external to its facts."""
return (
error.retryable
or error.code.startswith("DATABASE_")
or error.code
in {
"ARTIFACT_UNREADABLE",
"OBJECT_STORE_UNAVAILABLE",
"PROCESSOR_TIMEOUT",
"PROCESSOR_UNAVAILABLE",
"SOURCE_NOT_FOUND",
}
)
def _best_effort_failure(
self,
job_id: str,
@@ -177,7 +408,7 @@ class ProgrammaticUploadCoordinator:
*,
job_id: str,
attempt_no: int,
source: StoredObject,
source: StoredObject | ArtifactRef,
outputs: Dict[str, StoredObject],
status: str,
business_date: object,
@@ -185,7 +416,8 @@ class ProgrammaticUploadCoordinator:
artifacts: Dict[str, object] = {
role: None for role in ARTIFACT_ROLES
}
artifacts["source_xml"] = source.to_artifact_ref().to_dict()
source_ref = source.to_artifact_ref() if isinstance(source, StoredObject) else source
artifacts["source_xml"] = source_ref.to_dict()
for role, stored in outputs.items():
artifacts[role] = stored.to_artifact_ref().to_dict()
return DeliveryEnvelope.from_dict(