Automatically resume empty saved source reviews
This commit is contained in:
1 parent
cd362b3b81
commit
976a4fa7b2
11 files changed
+585
-29
No files matched your search
@@ -46,6 +46,11 @@
|
||||
后续从已保存来源继续,不重新获取订单、不改写原始捕获。冻结后不能再修改字段,重复确认或恢复不会重复提交日报。
|
||||
正常完整输入无需人工步骤,直接进入原处理链路。
|
||||
|
||||
旧任务若因已核实的来源解释或取消/PM排除而清空整张字段清单,系统会重新验证来源依据,
|
||||
以系统身份保存处理记录并自动继续同一请求,不再要求对0项字段点击确认。服务重启会恢复这种已滞留的任务,
|
||||
仍使用已保存的原始数据,不重新取数。清单仍有人工核对项的任务,即使已全部填写,也保留“确认并生成日报”;
|
||||
后续若缺少处理价,仍进入正常价格核对,系统不代填价格。
|
||||
|
||||
字段完善后端接口归属于ARR自身,沿用网页登录权限与POST的CSRF保护;它们不向Oracle写入业务资料:
|
||||
|
||||
| 操作 | ARR接口 | 正文 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user