feat: add daily manual price review workflow
This commit is contained in:
@@ -6,13 +6,14 @@ from datetime import date, datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
ACTIVE_RUN_STATUSES = {"received", "queued", "running", "validating"}
|
||||
ACTIVE_RUN_STATUSES = {"received", "queued", "running", "validating", "awaiting_review"}
|
||||
|
||||
_CURRENT_STAGE = {
|
||||
"received": "upload",
|
||||
"queued": "processor",
|
||||
"running": "processor",
|
||||
"validating": "validation",
|
||||
"awaiting_review": "review",
|
||||
"accepted": "database",
|
||||
"rejected": "validation",
|
||||
"failed": "processor",
|
||||
@@ -24,6 +25,7 @@ _CURRENT_COPY = {
|
||||
"queued": ("当前:等待程序处理", "任务已进入 ARR 处理队列。"),
|
||||
"running": ("当前:ARR 正在处理", "固定处理器正在生成日报和完整结构化结果。"),
|
||||
"validating": ("当前:正在独立验收", "ARR 正在复核文件身份、业务结果并准备数据库提交。"),
|
||||
"awaiting_review": ("当前:等待人工定价", "请完成全部缺价项后确认生成正式日报。"),
|
||||
}
|
||||
|
||||
_LOG_ORDER = {
|
||||
@@ -33,6 +35,13 @@ _LOG_ORDER = {
|
||||
"ARTIFACT_RESULT_RECEIVED": 40,
|
||||
"DIRECT_RESULT_RECEIVED": 40,
|
||||
"ARTIFACT_RESULT_VALIDATED": 50,
|
||||
"ARTIFACT_RESULT_RECORDED_FOR_REVIEW": 55,
|
||||
"PRICE_REVIEW_REQUIRED": 56,
|
||||
"PRICE_REVIEW_UPDATED": 57,
|
||||
"PRICE_REVIEW_FINALIZED": 58,
|
||||
"PRICE_REVIEW_GENERATION_FAILED": 59,
|
||||
"PRICE_REVIEW_FAILED": 60,
|
||||
"PRICE_REVIEW_CANCELLED": 61,
|
||||
"DIRECT_VALIDATION_STARTED": 50,
|
||||
"FINANCE_VERSION_CREATED": 60,
|
||||
"FINANCE_VERSION_VALIDATED": 70,
|
||||
@@ -167,7 +176,11 @@ def _latest(values: Sequence[Mapping[str, Any]], key: str) -> Optional[Mapping[s
|
||||
return max(values, key=lambda item: int(item.get(key) or 0))
|
||||
|
||||
|
||||
def _attempt_logs(logs: List[Dict[str, Any]], attempts: Sequence[Mapping[str, Any]]) -> None:
|
||||
def _attempt_logs(
|
||||
logs: List[Dict[str, Any]],
|
||||
attempts: Sequence[Mapping[str, Any]],
|
||||
review_case: Optional[Mapping[str, Any]],
|
||||
) -> None:
|
||||
for attempt in attempts:
|
||||
number = int(attempt.get("attempt_no") or 0)
|
||||
details = {
|
||||
@@ -199,6 +212,15 @@ def _attempt_logs(logs: List[Dict[str, Any]], attempts: Sequence[Mapping[str, An
|
||||
details=details,
|
||||
)
|
||||
status = str(attempt.get("attempt_status") or "")
|
||||
retryable_review_failure = (
|
||||
status == "failed"
|
||||
and str((review_case or {}).get("case_status") or "")
|
||||
== "generation_failed"
|
||||
)
|
||||
if retryable_review_failure:
|
||||
# The review audit event records this as a retryable warning. It is
|
||||
# deliberately not presented as a terminal processing failure.
|
||||
continue
|
||||
if status in {"failed", "cancelled"} and attempt.get("finished_at"):
|
||||
failure_code = attempt.get("failure_code")
|
||||
_append_log(
|
||||
@@ -268,22 +290,40 @@ def _delivery_logs(
|
||||
)
|
||||
status = str(delivery.get("delivery_status") or "")
|
||||
if delivery.get("committed_at"):
|
||||
review_recorded = (
|
||||
status == "recorded_review"
|
||||
and str(delivery.get("result_status") or "") == "review_required"
|
||||
)
|
||||
failed = status in {"recorded_failure", "rejected"}
|
||||
failure_code = delivery.get("failure_code")
|
||||
_append_log(
|
||||
logs,
|
||||
log_id=f"delivery.{identity}.finished",
|
||||
timestamp=delivery.get("committed_at"),
|
||||
stage="validation" if failed else "database",
|
||||
level="error" if failed else "success",
|
||||
stage="validation" if failed else "review" if review_recorded else "database",
|
||||
level="error" if failed else "info" if review_recorded else "success",
|
||||
code=str(
|
||||
failure_code
|
||||
or ("ARTIFACT_RESULT_REJECTED" if failed else "ARTIFACT_RESULT_COMMITTED")
|
||||
or (
|
||||
"ARTIFACT_RESULT_REJECTED"
|
||||
if failed
|
||||
else "ARTIFACT_RESULT_RECORDED_FOR_REVIEW"
|
||||
if review_recorded
|
||||
else "ARTIFACT_RESULT_COMMITTED"
|
||||
)
|
||||
),
|
||||
title=(
|
||||
"结果制品未通过验收"
|
||||
if failed
|
||||
else "待人工定价已登记"
|
||||
if review_recorded
|
||||
else "结果制品已提交数据库"
|
||||
),
|
||||
title="结果制品未通过验收" if failed else "结果制品已提交数据库",
|
||||
message=(
|
||||
_safe_text(delivery.get("failure_message"))
|
||||
if failed
|
||||
else "缺价项已登记,尚未创建 Finance 日报版本。"
|
||||
if review_recorded
|
||||
else "验收结果已原子提交到 Finance 数据库。"
|
||||
)
|
||||
or "结果制品未通过验收。",
|
||||
@@ -379,6 +419,9 @@ def _version_logs(logs: List[Dict[str, Any]], versions: Sequence[Mapping[str, An
|
||||
"duplicate_rows": version.get("duplicate_rows"),
|
||||
"validation_failed_rows": version.get("validation_failed_rows"),
|
||||
"price_unmatched_rows": version.get("price_unmatched_rows"),
|
||||
"review_case_id": version.get("review_case_id"),
|
||||
"manually_priced_rows": version.get("manually_priced_rows"),
|
||||
"manual_override_sha256": version.get("manual_override_sha256"),
|
||||
}
|
||||
_append_log(
|
||||
logs,
|
||||
@@ -491,6 +534,64 @@ def _outbox_logs(logs: List[Dict[str, Any]], events: Sequence[Mapping[str, Any]]
|
||||
)
|
||||
|
||||
|
||||
def _review_logs(
|
||||
logs: List[Dict[str, Any]],
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
) -> None:
|
||||
event_copy = {
|
||||
"PRICE_REVIEW_REQUIRED": (
|
||||
"info",
|
||||
"人工价格复核已创建",
|
||||
"仅缺少固定价的键已登记;正式日报和 Finance 版本尚未创建。",
|
||||
),
|
||||
"PRICE_REVIEW_UPDATED": (
|
||||
"info",
|
||||
"人工价格已暂存",
|
||||
"一项缺价键已由受控会话更新,待全部完成后统一确认。",
|
||||
),
|
||||
"PRICE_REVIEW_FINALIZED": (
|
||||
"info",
|
||||
"人工价格清单已冻结",
|
||||
"ARR 将用原始 XML、固定价表和同一冻结清单重新生成并独立验收日报。",
|
||||
),
|
||||
"PRICE_REVIEW_GENERATION_FAILED": (
|
||||
"warning",
|
||||
"正式日报生成可重试",
|
||||
"技术生成未完成;冻结的人工价格清单已保留,可再次确认重试。",
|
||||
),
|
||||
"PRICE_REVIEW_FAILED": (
|
||||
"error",
|
||||
"人工价格复核生成失败",
|
||||
"最终重放未通过确定性业务验收,任务未形成可接受的日报版本。",
|
||||
),
|
||||
"PRICE_REVIEW_CANCELLED": (
|
||||
"warning",
|
||||
"人工价格复核已取消",
|
||||
"复核记录保留审计痕迹;如需更正,请重新上传原始 XML。",
|
||||
),
|
||||
}
|
||||
for index, event in enumerate(events, 1):
|
||||
code = str(event.get("event_type") or "")
|
||||
level, title, message = event_copy.get(
|
||||
code,
|
||||
("info", "人工价格复核事件", "复核状态已更新。"),
|
||||
)
|
||||
_append_log(
|
||||
logs,
|
||||
log_id=f"review.{index}.{code or 'event'}",
|
||||
timestamp=event.get("created_at"),
|
||||
stage="validation" if code == "PRICE_REVIEW_FAILED" else "review",
|
||||
level=level,
|
||||
code=code or "PRICE_REVIEW_EVENT",
|
||||
title=title,
|
||||
message=message,
|
||||
details={
|
||||
"review_revision": event.get("revision"),
|
||||
"actor_username": event.get("actor_username"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_job_trace(
|
||||
run: Mapping[str, Any],
|
||||
*,
|
||||
@@ -499,6 +600,8 @@ def build_job_trace(
|
||||
submissions: Sequence[Mapping[str, Any]] = (),
|
||||
versions: Sequence[Mapping[str, Any]] = (),
|
||||
outbox_events: Sequence[Mapping[str, Any]] = (),
|
||||
review_case: Optional[Mapping[str, Any]] = None,
|
||||
review_events: Sequence[Mapping[str, Any]] = (),
|
||||
refreshed_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build one complete trace using only explicitly allowlisted facts."""
|
||||
@@ -521,10 +624,11 @@ def build_job_trace(
|
||||
"source_sha256": run.get("source_sha256"),
|
||||
},
|
||||
)
|
||||
_attempt_logs(logs, attempts)
|
||||
_attempt_logs(logs, attempts, review_case)
|
||||
_delivery_logs(logs, deliveries)
|
||||
_submission_logs(logs, submissions)
|
||||
_version_logs(logs, versions)
|
||||
_review_logs(logs, review_events)
|
||||
_outbox_logs(logs, outbox_events)
|
||||
|
||||
run_status = str(run.get("run_status") or "")
|
||||
@@ -571,6 +675,33 @@ def build_job_trace(
|
||||
"delivery_mode": run.get("delivery_mode"),
|
||||
},
|
||||
)
|
||||
elif run_status == "awaiting_review":
|
||||
review_status = str((review_case or {}).get("case_status") or "open")
|
||||
if review_status == "processing":
|
||||
title = "当前:正在生成正式日报"
|
||||
message = "ARR 正在用冻结人工价格清单重放原始 XML 并进行独立验收。"
|
||||
elif review_status == "generation_failed":
|
||||
title = "当前:正式日报生成可重试"
|
||||
message = "冻结价格清单已保留;可再次确认以重试技术生成。"
|
||||
else:
|
||||
title, message = _CURRENT_COPY["awaiting_review"]
|
||||
_append_log(
|
||||
logs,
|
||||
log_id="run.current.awaiting_review",
|
||||
timestamp=run.get("updated_at") or run.get("created_at"),
|
||||
stage="review",
|
||||
level="info",
|
||||
code="CURRENT_AWAITING_REVIEW",
|
||||
title=title,
|
||||
message=message,
|
||||
details={
|
||||
"job_id": run.get("job_id"),
|
||||
"review_case_id": (review_case or {}).get("case_id"),
|
||||
"review_status": review_status,
|
||||
"completed_items": (review_case or {}).get("completed_items"),
|
||||
"total_items": (review_case or {}).get("total_items"),
|
||||
},
|
||||
)
|
||||
elif run_status in ACTIVE_RUN_STATUSES:
|
||||
title, message = _CURRENT_COPY[run_status]
|
||||
_append_log(
|
||||
@@ -603,7 +734,9 @@ def build_job_trace(
|
||||
latest_attempt = _latest(attempts, "attempt_no")
|
||||
latest_version = _latest(versions, "version_no")
|
||||
current_stage = (
|
||||
_failure_stage(failure_code)
|
||||
"review"
|
||||
if run_status == "awaiting_review"
|
||||
else _failure_stage(failure_code)
|
||||
if failure_code
|
||||
else _CURRENT_STAGE.get(run_status, logs[-1]["stage"] if logs else "upload")
|
||||
)
|
||||
@@ -613,7 +746,7 @@ def build_job_trace(
|
||||
resolved_refreshed_at = refreshed_at or datetime.now(timezone.utc)
|
||||
|
||||
return {
|
||||
"trace_version": "arr-job-trace-3",
|
||||
"trace_version": "arr-job-trace-4",
|
||||
"job": {
|
||||
"job_id": str(run.get("job_id") or ""),
|
||||
"status": public_status,
|
||||
@@ -667,6 +800,23 @@ def build_job_trace(
|
||||
"delivered_processor_version": _safe_text(
|
||||
run.get("delivered_processor_version"), 64
|
||||
),
|
||||
"review": (
|
||||
{
|
||||
"case_id": _safe_text(review_case.get("case_id"), 64),
|
||||
"status": _safe_text(review_case.get("case_status"), 64),
|
||||
"revision": int(review_case.get("revision") or 0),
|
||||
"completed_items": int(review_case.get("completed_items") or 0),
|
||||
"total_items": int(review_case.get("total_items") or 0),
|
||||
"manual_override_sha256": _safe_text(
|
||||
review_case.get("manual_override_sha256"), 64
|
||||
),
|
||||
"frozen_at": _iso(review_case.get("frozen_at")),
|
||||
"completed_at": _iso(review_case.get("completed_at")),
|
||||
"cancelled_at": _iso(review_case.get("cancelled_at")),
|
||||
}
|
||||
if review_case is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"evidence": {
|
||||
"attempts": len(attempts),
|
||||
@@ -674,6 +824,7 @@ def build_job_trace(
|
||||
"legacy_direct_submissions": len(submissions),
|
||||
"finance_versions": len(versions),
|
||||
"outbox_events": len(outbox_events),
|
||||
"review_events": len(review_events),
|
||||
},
|
||||
"logs": logs,
|
||||
"refreshed_at": resolved_refreshed_at.isoformat(),
|
||||
|
||||
Reference in New Issue
Block a user