Automatically resume empty saved source reviews

This commit is contained in:
Wyndham ARR committed 2026-10-09 00:12:06 +08:00
1 parent cd362b3b81
commit 976a4fa7b2
11 files changed
+585 -29

No files matched your search

+5
View File
@@ -46,6 +46,11 @@
后续从已保存来源继续,不重新获取订单、不改写原始捕获。冻结后不能再修改字段,重复确认或恢复不会重复提交日报。
正常完整输入无需人工步骤,直接进入原处理链路。
旧任务若因已核实的来源解释或取消/PM排除而清空整张字段清单,系统会重新验证来源依据,
以系统身份保存处理记录并自动继续同一请求,不再要求对0项字段点击确认。服务重启会恢复这种已滞留的任务,
仍使用已保存的原始数据,不重新取数。清单仍有人工核对项的任务,即使已全部填写,也保留“确认并生成日报”;
后续若缺少处理价,仍进入正常价格核对,系统不代填价格。
字段完善后端接口归属于ARR自身,沿用网页登录权限与POST的CSRF保护;它们不向Oracle写入业务资料:
| 操作 | ARR接口 | 正文 |
+19 -1
View File
@@ -3,7 +3,7 @@ from datetime import date
from pathlib import Path
from arr_web.arr_download_handoff import outcome_from_handoff
from arr_web.arr_downloads import DownloadOutcome, validate_request_id
from arr_web.arr_downloads import DownloadOutcome, validate_report_date, validate_request_id
from integrations.ohip.arr_data import ARRDataSource, VERSION as SOURCE_VERSION
from integrations.ohip import processing_handoff as handoff
from integrations.ohip.audit_arr_capture import protected_read
@@ -34,6 +34,23 @@ class DirectARRExecutor:
def finalize_data_review(self, request_id, revision, actor):
return self.data_reviews.finalize(request_id, revision, actor)
def recover_data_review(self, request_id):
# Recovery may resume only an already collected, exactly bound local
# request. A missing/conflicting checkpoint must not start another fetch.
validate_request_id(request_id)
directory = self.root / "requests" / request_id
stored = strict_json(protected_read(directory / "request.json", 65536))
report_date = validate_report_date(stored.get("report_date"))
identity = {**self.source_identity(), "request_id": request_id, "report_date": report_date}
require(fingerprint(stored) == fingerprint(identity), "data_executor_request_conflict")
checkpoint = strict_json(protected_read(directory / "collected.json", 65536))
require(fingerprint(checkpoint["identity"]) == fingerprint(identity), "data_collection_checkpoint_conflict")
payload, manifest = self.data_reviews.original(request_id)
require(handoff._hash(payload) == checkpoint["data_sha256"]
and manifest == checkpoint["manifest_sha256"]
and strict_json(payload).get("report_date") == report_date, "data_collection_checkpoint_changed")
return self.data_reviews.recover_ready(request_id)
def source_identity(self):
return {"version": "arr-direct-data-executor/v1", "source_version": SOURCE_VERSION,
"hotel_id": self.source.hotel_id, "service_url": SERVICE, "application_id": APPLICATION,
@@ -90,6 +107,7 @@ class DirectARRExecutor:
"manifest_sha256": original_manifest}, replace=False)
manifest_sha256 = original_manifest
if review:
self.recover_data_review(request_id)
completed = self.data_reviews.payload(request_id)
if completed is None:
return DownloadOutcome("needs_data_review")
+51 -24
View File
@@ -31,6 +31,7 @@ VERSION = "arr-source-field-review/v1"
REANALYSIS_POLICY = "oracle-optional-association/v1"
REANALYSIS_VERSION = "arr-source-reanalysis/v1"
STATUS_POLICY = "arr-exclude-cancelled/v1"
EMPTY_REVIEW_ACTOR = "system:arr-empty-source-review"
LIMIT = 100 * 1024 * 1024
OPTIONAL = frozenset({"BLOCK_CODE", "RES_COMMENT", "PRODUCTS", "ROOM_CATEGORY_LABEL"})
LABELS = {
@@ -381,9 +382,11 @@ class DataFieldReviews:
require(fingerprint(actual) == fingerprint(meta), "data_review_prepare_conflict")
review = self._public(directory, actual, original, state)
# A complete ordinary source needs no human step or derived source.
# A reanalysis remains an explicit review until finalized, even if
# its validated source interpretation resolves every listed gap.
return review if review["total_count"] or "source_reanalysis" in state or "reservation_status_evidence" in state else None
# Validated overlays still need a frozen derived source. The
# executor may finish a genuinely empty review under a system actor.
return review if (review["total_count"] or state["decisions"] or state["status"] == "finalized"
or "source_reanalysis" in state or "reservation_status_evidence" in state
or (directory / "finalize-intent.json").exists()) else None
def get(self, request_id):
directory = self._directory(request_id)
@@ -583,33 +586,57 @@ class DataFieldReviews:
return json_bytes(data), binding
def finalize(self, request_id, revision, actor):
directory = self._directory(request_id)
with self._lock(directory):
meta, original, state = self._read(directory)
return self._finalize_locked(directory, meta, original, state, revision, actor)
def recover_ready(self, request_id):
"""Freeze validated zero-item saved reviews, or replay a verified prior freeze.
This internal recovery operation has no HTTP route. A completed manual
list is deliberately not empty: its explicit human confirmation remains
required. Read/derive/guard/freeze share the same revision lock.
"""
directory = self._directory(request_id)
with self._lock(directory):
meta, original, state = self._read(directory)
if state["status"] == "finalized":
self._verify_frozen(directory, meta, original, state)
return self._public(directory, meta, original, state)
self._revision(state, revision)
actor = self._actor(actor)
if self._issues(directory, self._derive(directory, original, state)):
raise _error("INCOMPLETE", "请先完善并确认所有异常字段", 409)
intent = directory / "finalize-intent.json"
prior_sha256 = fingerprint(state)
if intent.exists():
frozen = strict_json(protected_read(intent, LIMIT))
require(frozen["prior_sha256"] == prior_sha256, "data_review_finalize_context_changed")
state = frozen["state"]
else:
state["revision"] += 1
state["status"] = "finalized"
state["events"].append({"revision": state["revision"], "action": "finalize", "actor": actor,
"at": datetime.now(timezone.utc).isoformat()})
atomic_json(intent, {"prior_sha256": prior_sha256, "state": state}, replace=False)
payload, binding = self._frozen(directory, meta, original, state)
_write_once(directory / "reviewed-source.json", payload)
state["derived_sha256"], state["binding_manifest_sha256"] = _hash(payload), binding
self._publish(directory, state)
return True
if original.get("status") not in {"collected", "collected_with_gaps"}:
return False
review = self._public(directory, meta, original, state)
if review["total_count"] != 0 or review["pending_count"] != 0 or not review["can_finalize"]:
return False
self._finalize_locked(directory, meta, original, state, state["revision"], EMPTY_REVIEW_ACTOR)
return True
def _finalize_locked(self, directory, meta, original, state, revision, actor):
if state["status"] == "finalized":
self._verify_frozen(directory, meta, original, state)
return self._public(directory, meta, original, state)
self._revision(state, revision)
actor = self._actor(actor)
if self._issues(directory, self._derive(directory, original, state)):
raise _error("INCOMPLETE", "请先完善并确认所有异常字段", 409)
intent = directory / "finalize-intent.json"
prior_sha256 = fingerprint(state)
if intent.exists():
frozen = strict_json(protected_read(intent, LIMIT))
require(frozen["prior_sha256"] == prior_sha256, "data_review_finalize_context_changed")
state = frozen["state"]
else:
state["revision"] += 1
state["status"] = "finalized"
state["events"].append({"revision": state["revision"], "action": "finalize", "actor": actor,
"at": datetime.now(timezone.utc).isoformat()})
atomic_json(intent, {"prior_sha256": prior_sha256, "state": state}, replace=False)
payload, binding = self._frozen(directory, meta, original, state)
_write_once(directory / "reviewed-source.json", payload)
state["derived_sha256"], state["binding_manifest_sha256"] = _hash(payload), binding
self._publish(directory, state)
return self._public(directory, meta, original, state)
def _verify_frozen(self, directory, meta, original, state):
payload, binding = self._frozen(directory, meta, original, state)
+30
View File
@@ -354,6 +354,7 @@ class PersistentARRDownloads:
def _work(self) -> None:
try:
self._recover_source_reviews()
while True:
with self._condition:
if self._stopping:
@@ -384,6 +385,35 @@ class PersistentARRDownloads:
finally:
os.close(self._lease)
def _recover_source_reviews(self):
"""Recover saved review continuations at startup, without browser reads.
Individual corrupt/conflicting reviews stay pending. A verified freeze
survives a crash before this queue update and is reused on next startup.
"""
recover = getattr(self._executor, "recover_data_review", None)
if not callable(recover):
return
with self._connect() as db:
rows = db.execute("""SELECT request_id FROM downloads WHERE status='needs_data_review'
ORDER BY created_at, request_id""").fetchall()
for row in rows:
with self._condition:
if self._stopping:
return
try:
if recover(row["request_id"]) is not True:
continue
with self._condition:
with self._connect() as db:
db.execute("""UPDATE downloads SET status='queued',retryable=0,updated_at=?
WHERE request_id=? AND status='needs_data_review'""", (_now(), row["request_id"]))
self._condition.notify_all()
except Exception:
# Recovery is local, optional maintenance, not evidence of a
# failed acquisition or permission to skip an unresolved review.
continue
def close(self, *, wait: bool = False) -> None:
"""Stop dispatch; owners must wait before closing executor dependencies."""
with self._condition:
+4
View File
@@ -132,6 +132,10 @@ class AuthorizedExecutor:
def finalize_data_review(self, *args): return self.executor.finalize_data_review(*args)
def recover_data_review(self, request_id):
recover = getattr(self.executor, "recover_data_review", None)
return recover(request_id) if callable(recover) else False
class LocalOHIPPortal(LocalReplayPortal):
allow_xml_upload = True
+7 -1
View File
@@ -542,9 +542,15 @@
function scheduleARRDownloadPoll() {
clearARRDownloadPoll();
if (document.hidden || state.arrDownloadBusy) return;
const review = state.arrDataReview;
const emptySourceReview = arrDownloadNeedsDataReview() && review?.status === "editing"
&& state.arrDataReviewRequestId === state.arrDownloadTask.request_id && review.request_id === state.arrDataReviewRequestId
&& review.report_date === state.arrDownloadTask.report_date && review.total_count === 0 && review.pending_count === 0
&& Array.isArray(review.items) && review.items.length === 0 && review.can_finalize === true
&& !state.arrDataReviewLoading && !state.arrDataReviewDisconnected && !Object.keys(state.arrDataReviewDrafts).length;
if (!state.arrDownloadReady || state.arrDownloadConfigDisconnected) {
state.arrDownloadPollTimer = window.setTimeout(initARRDownload, 10000);
} else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review" || state.arrDataReviewFinalizing)) {
} else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review" || state.arrDataReviewFinalizing || emptySourceReview)) {
state.arrDownloadPollTimer = window.setTimeout(loadARRDownloadTask, 4000);
}
}