diff --git a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md
index 0978905..461799d 100644
--- a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md
+++ b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md
@@ -74,3 +74,14 @@ Read: memory-index, project-positioning, current-state latest September sections
2. Durable revisioned field review before handoff; required candidate gaps only; immutable derived source and audit; same-request continuation.
3. Authenticated review UI and context-scoped recovery.
4. Synthetic regression and saved real 10/7 validation; activate local service safely and leave actual unresolved business values for user.
+
+## Same-task Follow-up: Independent Report Dates
+
+- User asks whether unfinished10/7 prevents downloading9/17. Desired behavior: each report date retains its own pending field review; staff may start another date and return to earlier pending dates.
+- Concurrent gate resumed the same task/worktree successfully; registry confirms feature/planning/codex and no other owners. Existing project context above remains applicable; September restrictions are superseded only by later explicit authorization. This fix needs no new real Oracle query or business rule change.
+- Planning gate Passed: current backend already deduplicates by date, not globally. The frontend unconditionally redirects any new submission to the current field review. The service exposes only the latest task, so earlier pending field reviews also need a persistent return entry.
+- Plan: allow explicit submissions for a different selected date, expose pending field-review dates, retain per-date review/draft identity, test independent-day queues and UI switching, then refresh the local page without confirming actual business values.
+- Outcome: another selected date now starts its own download; selecting the current review date still opens its existing review without reacquisition. A server-backed pending-date list allows return to all earlier source reviews, including after reload and after another day's task finishes. Same-day deduplication remains unchanged. In-page unsaved drafts are isolated by request and cleared on source-context change; the documentation tells users to save before refreshing. Uncertain/active requests cannot be abandoned through the pending-date entry.
+- Verification:74 Python tests passed without skips (40 backend/config/local-wrapper tests and34 existing source-review/runtime tests, disposable local PostgreSQL enabled),31 JavaScript tests passed, node syntax and Git whitespace checks passed. Tests cover two pending days, same-day deduplication, queue restart, server list restoration, per-request drafts, lost new submission and reopening a completed review.
+- Local activation: gracefully restarted the existing private local service only after verifying zero active queued/downloading/processing tasks. No actual business value was changed and no new Oracle request was made. CUA verified choosing9/17 enables “下载并处理” while10/7 still shows0/26; the pending-date entry returns to10/7 unchanged. The page is left with9/17 selected for the user's explicit submission. Screenshot: private `production-validation-20261007/independent-report-dates-20261008.png`.
+- Promotion candidate: document independent report-date progress and the persistent pending-field-review entry in canonical product flow at integration. This preserves existing business rules and user-approved date interaction; no policy reversal is involved.
diff --git a/arr_web/DIRECT_DATA_ENTRY.md b/arr_web/DIRECT_DATA_ENTRY.md
index 7d015d4..bab6418 100644
--- a/arr_web/DIRECT_DATA_ENTRY.md
+++ b/arr_web/DIRECT_DATA_ENTRY.md
@@ -12,6 +12,9 @@
点击日期文字或右侧日历图标,再点击日历中的某一天即可选择。任务进行中仍可预选下一日期;
当前任务结束后需再次点击下载,预选不会自动提交或修改原任务。页面进度始终显示原任务的日期。
任务结果待确认时,先检查或继续按钮上标明日期的原任务,避免重复提交。
+某一天等待人工完善不阻塞其他日期:另选日期后可点击“下载并处理”,此前任务仍保存在“待完善日期”中。
+点击待完善日期可返回该日继续,刷新后列表仍保留;同一天的未完成任务沿用原编号。
+页面内切换日期会分别保留尚未保存的输入草稿,刷新前仍应逐项保存;已保存的决定由系统长期保留。
完整采集中的缺字段、多个候选或不满足原规则的字段先进入“待完善数据”(`needs_data_review`)。
全部阻塞字段通过并确认后,原任务自动继续处理;随后只有缺少pureprice时进入原有价格复核(`needs_review`)。
字段完善和价格复核是前后两步,不会用补零或未经确认的空值跳过前一步。
diff --git a/arr_web/app.py b/arr_web/app.py
index 6ec2c11..b072991 100644
--- a/arr_web/app.py
+++ b/arr_web/app.py
@@ -368,6 +368,8 @@ class PortalApplication:
"default_date": default_report_date(),
"business_time_zone": "Asia/Bangkok",
"latest_task": self._arr_download_snapshot(self._arr_downloads.latest()),
+ "pending_data_reviews": [self._arr_download_snapshot(task) for task in
+ getattr(self._arr_downloads, "pending_data_reviews", lambda: [])()],
}))
if method == "POST" and route.path == "/api/arr-downloads":
self._require_csrf(normalized_headers)
diff --git a/arr_web/arr_downloads.py b/arr_web/arr_downloads.py
index 2012d26..ba05e93 100644
--- a/arr_web/arr_downloads.py
+++ b/arr_web/arr_downloads.py
@@ -91,6 +91,7 @@ class ARRDownloadCoordinator(Protocol):
@property
def ready(self) -> bool: ...
def latest(self) -> dict | None: ...
+ def pending_data_reviews(self) -> list[dict]: ...
def create(self, report_date: str, request_id: str) -> dict: ...
def get(self, request_id: str) -> dict: ...
def retry(self, request_id: str) -> dict: ...
@@ -105,6 +106,9 @@ class UnavailableARRDownloads:
def latest(self) -> None:
return None
+ def pending_data_reviews(self) -> list[dict]:
+ return []
+
@staticmethod
def _unavailable() -> PortalError:
return PortalError("ARR_DOWNLOAD_UNAVAILABLE", "自动下载服务暂未就绪", 503)
@@ -198,6 +202,12 @@ class PersistentARRDownloads:
row = db.execute("SELECT * FROM downloads ORDER BY created_at DESC, rowid DESC LIMIT 1").fetchone()
return self._public(row) if row else None
+ def pending_data_reviews(self) -> list[dict]:
+ with self._connect() as db:
+ rows = db.execute("""SELECT * FROM downloads WHERE status='needs_data_review'
+ ORDER BY report_date DESC, created_at, request_id""").fetchall()
+ return [self._public(row) for row in rows]
+
def get(self, request_id: str) -> dict:
validate_request_id(request_id)
with self._connect() as db:
diff --git a/arr_web/local_ohip.py b/arr_web/local_ohip.py
index fd6fc8a..71dd6c3 100644
--- a/arr_web/local_ohip.py
+++ b/arr_web/local_ohip.py
@@ -88,6 +88,7 @@ class PermissionDownloads:
return self.queue.ready and self.access.status()["ready"]
def latest(self): return self.queue.latest()
+ def pending_data_reviews(self): return self.queue.pending_data_reviews()
def get(self, request_id): return self.queue.get(request_id)
@property
diff --git a/arr_web/local_xml_replay.py b/arr_web/local_xml_replay.py
index 2f0ea5c..8f0ce6d 100644
--- a/arr_web/local_xml_replay.py
+++ b/arr_web/local_xml_replay.py
@@ -129,6 +129,9 @@ class SourceDateDownloads:
def latest(self):
return self.queue.latest()
+ def pending_data_reviews(self):
+ return self.queue.pending_data_reviews()
+
def get(self, request_id):
return self.queue.get(request_id)
diff --git a/arr_web/static/app.js b/arr_web/static/app.js
index 8217390..61afd50 100644
--- a/arr_web/static/app.js
+++ b/arr_web/static/app.js
@@ -24,6 +24,7 @@
arrDownloadStorageKey: "",
arrDownloadUsername: "",
arrDownloadContextId: "",
+ arrDownloadPendingReviews: [],
arrDataReview: null,
arrDataReviewRequestId: "",
arrDataReviewLoading: false,
@@ -31,6 +32,7 @@
arrDataReviewDisconnected: false,
arrDataReviewFinalizing: false,
arrDataReviewDrafts: {},
+ arrDataReviewDraftsByRequest: {},
arrDataReviewMessage: "",
arrDataReviewMessageError: false,
companySourceFile: null,
@@ -322,6 +324,53 @@
return state.arrDownloadTask?.status === "needs_data_review";
}
+ function selectedARRDateNeedsDataReview() {
+ return arrDownloadNeedsDataReview() && $("#arr-download-date").value === state.arrDownloadTask.report_date;
+ }
+
+ function rememberARRPendingReview(task) {
+ state.arrDownloadPendingReviews = state.arrDownloadPendingReviews.filter((item) => item.request_id !== task.request_id);
+ if (task.status === "needs_data_review") state.arrDownloadPendingReviews.push(task);
+ state.arrDownloadPendingReviews.sort((a, b) => b.report_date.localeCompare(a.report_date) || a.request_id.localeCompare(b.request_id));
+ }
+
+ function renderARRPendingReviews() {
+ const panel = $("#arr-download-pending");
+ panel.hidden = !state.arrDownloadPendingReviews.length;
+ $("#arr-download-pending-title").textContent = I18N.t("arr_download.pending_dates");
+ const locked = state.arrDownloadBusy || state.arrDownloadLoading || state.arrDataReviewLoading || state.arrDataReviewMutating
+ || state.arrDataReviewFinalizing || arrDownloadActive() || arrDownloadNeedsReconciliation();
+ $("#arr-download-pending-list").innerHTML = state.arrDownloadPendingReviews.map((task) =>
+ ``).join("");
+ }
+
+ async function selectARRPendingReview(requestId) {
+ if (!state.arrDownloadPendingReviews.some((task) => task.request_id === requestId)
+ || state.arrDownloadBusy || state.arrDownloadLoading || state.arrDataReviewLoading || state.arrDataReviewMutating
+ || state.arrDataReviewFinalizing || arrDownloadActive() || arrDownloadNeedsReconciliation()) return;
+ const contextId = state.arrDownloadContextId;
+ state.arrDownloadBusy = true;
+ renderARRDownload();
+ try {
+ const task = await api(`/api/arr-downloads/${encodeURIComponent(requestId)}`);
+ if (state.arrDownloadContextId !== contextId) return;
+ state.arrDownloadDateEdited = false;
+ $("#arr-download-date").value = task.report_date;
+ await acceptARRDownloadTask(task, { sync: false });
+ if (arrDownloadNeedsDataReview()) {
+ $("#arr-data-review-panel").scrollIntoView({ behavior: "smooth", block: "start" });
+ $("#arr-data-review-panel").focus({ preventScroll: true });
+ }
+ } catch (error) {
+ showToast(error.message, true);
+ } finally {
+ state.arrDownloadBusy = false;
+ renderARRDataReview();
+ renderARRDownload();
+ scheduleARRDownloadPoll();
+ }
+ }
+
function handleARRDownloadDateChange() {
state.arrDownloadDateEdited = true;
// Choosing the next report date must not abandon a running or uncertain request.
@@ -347,6 +396,7 @@
$("#arr-date-toggle").setAttribute("aria-label", I18N.t("arr_download.choose_date"));
$("#arr-download-help").textContent = I18N.t(locked ? "arr_download.submitting"
: arrDownloadActive() ? "arr_download.date_next"
+ : arrDownloadNeedsDataReview() ? "arr_download.date_independent"
: reconciling ? "arr_download.date_resume" : "arr_download.help",
{ date: state.arrDownloadIntent?.report_date });
if (locked) closeARRCalendar();
@@ -358,7 +408,7 @@
if (state.arrDownloadBusy) label = "arr_download.submitting";
else if (arrDownloadActive()) label = "arr_download.running";
else if (pending || state.arrDownloadDisconnected) label = "arr_download.check";
- else if (arrDownloadNeedsDataReview()) label = "arr_download.complete_data";
+ else if (selectedARRDateNeedsDataReview()) label = "arr_download.complete_data";
else if (arrDownloadCanResume() || task?.status === "not_received") label = "arr_download.retry_date";
button.textContent = I18N.t(label, { date: task?.report_date });
button.setAttribute("aria-busy", String(state.arrDownloadBusy || arrDownloadActive()));
@@ -375,6 +425,7 @@
$("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id;
$("#arr-download-data-review").hidden = !arrDownloadNeedsDataReview();
$("#arr-download-data-review").disabled = locked;
+ renderARRPendingReviews();
}
function clearARRDownloadPoll() {
@@ -394,6 +445,7 @@
async function acceptARRDownloadTask(task, { sync = true } = {}) {
state.arrDownloadTask = task;
+ rememberARRPendingReview(task);
state.arrDownloadDisconnected = false;
rememberARRIntent({ request_id: task.request_id, report_date: task.report_date });
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date;
@@ -465,8 +517,13 @@
state.arrDownloadTask = null;
state.arrDownloadDisconnected = false;
state.arrDownloadSynced = "";
- resetARRDataReview();
+ state.arrDownloadPendingReviews = [];
+ resetARRDataReview({ clearDrafts: true });
}
+ state.arrDownloadPendingReviews = Array.isArray(config.pending_data_reviews)
+ ? config.pending_data_reviews.filter((task) => task.status === "needs_data_review"
+ && /^[0-9a-f]{32}$/.test(task.request_id) && validARRDate(task.report_date)) : [];
+ if (state.arrDownloadTask) rememberARRPendingReview(state.arrDownloadTask);
if (!state.arrDownloadRestored) {
state.arrDownloadRestored = true;
let saved;
@@ -505,7 +562,7 @@
await loadARRDownloadTask();
return;
}
- if (arrDownloadNeedsDataReview()) {
+ if (selectedARRDateNeedsDataReview()) {
await loadARRDataReview(state.arrDownloadTask.request_id, { scroll: true });
return;
}
@@ -522,6 +579,7 @@
}
rememberARRIntent(intent); // Persist before the HTTP call, including a lost response.
state.arrDownloadTask = null;
+ resetARRDataReview();
state.arrDownloadBusy = true;
clearARRDownloadPoll();
renderARRDownload();
@@ -553,7 +611,11 @@
}
}
- function resetARRDataReview() {
+ function resetARRDataReview({ clearDrafts = false } = {}) {
+ if (clearDrafts) state.arrDataReviewDraftsByRequest = {};
+ else if (state.arrDataReviewRequestId) {
+ state.arrDataReviewDraftsByRequest[state.arrDataReviewRequestId] = { ...state.arrDataReviewDrafts };
+ }
state.arrDataReview = null;
state.arrDataReviewRequestId = "";
state.arrDataReviewLoading = false;
@@ -572,7 +634,8 @@
}
function arrDataReviewCanFinalize(review = state.arrDataReview) {
- return Boolean(review && review.status === "editing" && review.can_finalize && Number(review.pending_count) === 0
+ return Boolean(arrDownloadNeedsDataReview() && state.arrDownloadTask.request_id === state.arrDataReviewRequestId
+ && !state.arrDownloadBusy && review && review.status === "editing" && review.can_finalize && Number(review.pending_count) === 0
&& Array.isArray(review.items) && review.items.every((item) => item.confirmed)
&& !Object.keys(state.arrDataReviewDrafts).length && !state.arrDataReviewLoading && !state.arrDataReviewMutating
&& !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing);
@@ -589,7 +652,8 @@
function renderARRDataReview() {
const panel = $("#arr-data-review-panel");
const review = state.arrDataReview;
- panel.hidden = !state.arrDataReviewRequestId || !arrDownloadNeedsDataReview();
+ panel.hidden = !state.arrDataReviewRequestId || !arrDownloadNeedsDataReview()
+ || state.arrDownloadTask.request_id !== state.arrDataReviewRequestId;
if (panel.hidden) return;
const busy = state.arrDataReviewLoading || state.arrDataReviewMutating;
const editable = review?.status === "editing" && !busy && !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing;
@@ -637,6 +701,7 @@
if (state.arrDataReviewRequestId !== requestId) {
resetARRDataReview();
state.arrDataReviewRequestId = requestId;
+ state.arrDataReviewDrafts = { ...state.arrDataReviewDraftsByRequest[requestId] };
}
const contextId = state.arrDownloadContextId;
state.arrDataReviewLoading = true;
@@ -3172,7 +3237,7 @@
});
$("#upload-button").addEventListener("click", handleUpload);
$("#arr-download-form").addEventListener("submit", submitARRDownload);
- $("#arr-download-date").addEventListener("input", () => { state.arrDownloadDateEdited = true; });
+ $("#arr-download-date").addEventListener("input", handleARRDownloadDateChange);
$("#arr-download-date").addEventListener("change", handleARRDownloadDateChange);
$("#arr-download-review").addEventListener("click", async () => {
const jobId = state.arrDownloadTask?.job_id;
@@ -3180,6 +3245,10 @@
try { await openDailyPriceReview(jobId, true); } catch (error) { showToast(error.message, true); }
});
$("#arr-download-data-review").addEventListener("click", () => loadARRDataReview(state.arrDownloadTask?.request_id, { scroll: true }));
+ $("#arr-download-pending-list").addEventListener("click", (event) => {
+ const button = event.target.closest("[data-arr-pending-review]");
+ if (button) selectARRPendingReview(button.dataset.arrPendingReview);
+ });
$("#arr-data-review-refresh").addEventListener("click", () => loadARRDataReview());
$("#arr-data-review-finalize").addEventListener("click", finalizeARRDataReview);
$("#arr-data-review-body").addEventListener("input", (event) => {
diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js
index 4d2c02a..40280bd 100644
--- a/arr_web/static/i18n.js
+++ b/arr_web/static/i18n.js
@@ -124,6 +124,9 @@
"arr_download.needs_review": ["等待人工价格复核", "Awaiting price review", "รอตรวจสอบราคาด้วยตนเอง"],
"arr_download.needs_data_review": ["等待人工完善报表字段", "Awaiting source field review", "รอตรวจสอบข้อมูลรายงานด้วยตนเอง"],
"arr_download.complete_data": ["完善报表字段", "Complete report fields", "กรอกข้อมูลรายงานให้ครบ"],
+ "arr_download.date_independent": ["可选其他日期下载,当前待完善内容会保留。", "Choose another date to download; this review will be kept.", "เลือกวันอื่นเพื่อดาวน์โหลดได้ ข้อมูลที่รอตรวจสอบจะยังคงอยู่"],
+ "arr_download.pending_dates": ["待完善日期", "Dates awaiting field review", "วันที่รอตรวจสอบข้อมูล"],
+ "arr_download.pending_date": ["待完善", "Complete fields", "รอตรวจสอบ"],
"arr_download.source_failed": ["到店数据获取未完成,请继续原任务重试。", "Arrival data could not be fetched. Resume the original task to retry.", "ยังดึงข้อมูลผู้เข้าพักไม่สำเร็จ โปรดดำเนินงานเดิมต่อเพื่อลองอีกครั้ง"],
"arr_download.source_interrupted": ["到店数据获取中断,请继续原任务。", "Arrival data fetch was interrupted. Resume the original task.", "การดึงข้อมูลผู้เข้าพักขัดจังหวะ โปรดดำเนินงานเดิมต่อ"],
"arr_download.failed": ["任务未完成,请查看任务日志", "Task failed. Check the task log.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"],
diff --git a/arr_web/static/index.html b/arr_web/static/index.html
index 5f9c3d3..c4785fc 100644
--- a/arr_web/static/index.html
+++ b/arr_web/static/index.html
@@ -74,6 +74,10 @@
正在连接下载服务…
+
diff --git a/arr_web/static/styles.css b/arr_web/static/styles.css
index ee8952c..6ae6912 100644
--- a/arr_web/static/styles.css
+++ b/arr_web/static/styles.css
@@ -201,6 +201,13 @@ button { color: inherit; }
.arr-download-actions .primary-button { min-height: 34px; margin: 0; padding: 0 12px; border-radius: 8px; font-size: 11px; }
.arr-download-review { padding: 4px 0; border: 0; color: var(--blue-dark); background: transparent; font-size: 11px; font-weight: 650; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
.arr-download-review:hover { color: var(--blue); }
+.arr-download-pending { margin: 10px 0 12px; }
+.arr-download-pending[hidden] { display: none; }
+.arr-download-pending > span { display: block; margin-bottom: 5px; color: var(--muted); font-size: 11px; }
+#arr-download-pending-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 110px; overflow-y: auto; }
+#arr-download-pending-list button { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--blue-dark); background: #fff; font: inherit; font-size: 11px; cursor: pointer; }
+#arr-download-pending-list button[aria-pressed="true"] { border-color: var(--blue); background: #f0f4ff; }
+#arr-download-pending-list button:disabled { opacity: .55; cursor: default; }
.arr-data-review-panel { margin: 20px 0; border-color: #e6d4b5; overflow: hidden; }
.arr-data-review-panel[hidden] { display: none; }
.arr-data-review-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px; background: #fffcf5; }
diff --git a/tests/javascript/arr_data_review.cjs b/tests/javascript/arr_data_review.cjs
index 127f098..3ee79c9 100644
--- a/tests/javascript/arr_data_review.cjs
+++ b/tests/javascript/arr_data_review.cjs
@@ -7,20 +7,112 @@ const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_n
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
const plain=value=>JSON.parse(JSON.stringify(value));
-test('source review opens before a job exists and choosing another date retains the original request',async()=>{
- const h=harness(()=>response(200,review([item()])));
+test('source review opens before a job exists and another date can start its own download',async()=>{
+ const h=harness((url,options)=>options.method==='POST'
+ ? response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false})
+ : response(200,review([item()])));
await h.acceptARRDownloadTask(task,{sync:false});
assert.equal(h.element('#arr-download-data-review').hidden,false);
assert.equal(h.element('#arr-data-review-panel').hidden,false);
assert.equal(h.element('#arr-download-review').hidden,true);
assert.equal(h.element('#arr-data-review-finalize').disabled,true);
- h.element('#arr-download-date').value='2026-10-08';
+ h.element('#arr-download-date').value='2026-09-17';
h.handleARRDownloadDateChange();
+ assert.equal(h.state.arrDownloadIntent.request_id,requestId,'choosing a date alone keeps the earlier task');
+ assert.equal(h.element('#arr-download-button').textContent,'arr_download.start');
+ assert.equal(h.element('#arr-download-button').disabled,false);
+ await h.submit();
+ assert.notEqual(h.state.arrDownloadIntent.request_id,requestId);
+ assert.equal(h.state.arrDownloadTask.report_date,'2026-09-17');
+ assert.equal(h.element('#arr-download-date').value,'2026-09-17');
+ const post=h.calls.find(call=>call.method==='POST');
+ assert.equal(post.url,'/api/arr-downloads');
+ assert.equal(JSON.parse(post.body).report_date,'2026-09-17');
+ assert.equal(h.state.arrDownloadPendingReviews[0].request_id,requestId);
+ assert.equal(h.element('#arr-data-review-panel').hidden,true);
+});
+
+test('submitting the pending review date only opens that review without another download',async()=>{
+ const h=harness(()=>response(200,review([item()])));
+ await h.acceptARRDownloadTask(task,{sync:false});
+ assert.equal(h.element('#arr-download-button').textContent,'arr_download.complete_data');
await h.submit();
assert.equal(h.state.arrDownloadIntent.request_id,requestId);
- assert.equal(h.state.arrDownloadTask.report_date,'2026-10-07');
- assert.equal(h.element('#arr-download-date').value,'2026-10-08');
- assert.equal(h.calls.every(call=>call.method==='GET'),true,'review never creates a replacement download');
+ assert.equal(h.calls.every(call=>call.method==='GET'),true);
+});
+
+test('pending dates and unsaved drafts remain separate when moving between two field reviews',async()=>{
+ const otherId='b'.repeat(32);
+ const otherTask={...task,request_id:otherId,report_date:'2026-09-17'};
+ const h=harness(url=>url.endsWith('/data-review')
+ ? response(200,{...review([item()]),request_id:url.includes(otherId)?otherId:requestId})
+ : response(200,url.endsWith(otherId)?otherTask:task));
+ await h.acceptARRDownloadTask(task,{sync:false});
+ h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','OCTOBER-DRAFT').input);
+ await h.acceptARRDownloadTask(otherTask,{sync:false});
+ assert.equal(h.state.arrDownloadPendingReviews.length,2);
+ assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],undefined);
+ h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','SEPTEMBER-DRAFT').input);
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.state.arrDownloadTask.request_id,requestId);
+ assert.equal(h.element('#arr-download-date').value,'2026-10-07');
+ assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'OCTOBER-DRAFT');
+ await h.selectARRPendingReview(otherId);
+ assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'SEPTEMBER-DRAFT');
+ assert.equal(h.calls.every(call=>call.method==='GET'),true,'returning to a review never re-fetches Oracle');
+});
+
+test('pending dates restore from the server even when the latest task is no longer a review',async()=>{
+ const h=harness(url=>url==='/api/arr-downloads'
+ ? response(200,{context_id:'production',ready:true,default_date:'2026-10-07',
+ pending_data_reviews:[task],latest_task:{...task,request_id:'b'.repeat(32),report_date:'2026-09-17',status:'failed'}})
+ : response(200,url.endsWith('/data-review')?review([item()]):task));
+ await h.initARRDownload();
+ assert.equal(h.state.arrDownloadTask,null);
+ assert.equal(h.element('#arr-download-pending').hidden,false);
+ assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-10-07/);
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.state.arrDownloadTask.request_id,requestId);
+ assert.equal(h.state.arrDataReview.pending_count,1);
+});
+
+test('an uncertain new date cannot be abandoned by opening an earlier field review',async()=>{
+ const h=harness(()=>{throw new Error('unexpected request');});
+ h.state.arrDownloadPendingReviews=[task];
+ h.rememberARRIntent({request_id:'b'.repeat(32),report_date:'2026-09-17'});
+ h.state.arrDownloadDisconnected=true;
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.calls.length,0);
+ assert.equal(h.state.arrDownloadIntent.report_date,'2026-09-17');
+});
+
+test('a completed pending review can still be finalized after reopening it',async()=>{
+ const h=harness(url=>response(200,url.endsWith('/data-review')
+ ? review([item({confirmed:true,value:'VERIFIED'})]) : task));
+ h.state.arrDownloadPendingReviews=[task];
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.state.arrDownloadBusy,false);
+ assert.equal(h.arrDataReviewCanFinalize(),true);
+ assert.equal(h.element('#arr-data-review-finalize').disabled,false);
+});
+
+test('lost submission for another date retains both its uncertain intent and the earlier pending review',async()=>{
+ const h=harness((url,options)=>{
+ if(options.method==='POST') throw new TypeError('lost response');
+ if(url.endsWith('/data-review')) return response(200,review([item()]));
+ return response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
+ });
+ await h.acceptARRDownloadTask(task,{sync:false});
+ h.element('#arr-download-date').value='2026-09-17';
+ h.handleARRDownloadDateChange();
+ await h.submit();
+ const intent=plain(h.state.arrDownloadIntent);
+ assert.equal(intent.report_date,'2026-09-17');
+ assert.notEqual(intent.request_id,requestId);
+ assert.equal(h.state.arrDownloadTask.status,'not_received');
+ assert.equal(h.state.arrDownloadPendingReviews[0].request_id,requestId);
+ await h.selectARRPendingReview(requestId);
+ assert.deepEqual(plain(h.state.arrDownloadIntent),intent);
});
test('incomplete review cannot finalize even when server flag says it can',async()=>{
diff --git a/tests/javascript/helpers/arr_ui_harness.cjs b/tests/javascript/helpers/arr_ui_harness.cjs
index 8be7b81..aded347 100644
--- a/tests/javascript/helpers/arr_ui_harness.cjs
+++ b/tests/javascript/helpers/arr_ui_harness.cjs
@@ -22,7 +22,7 @@ function harness(respond) {
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);},
});
- const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue';
+ const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview';
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
const subject=context.subject;
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});
diff --git a/tests/test_arr_local_ohip.py b/tests/test_arr_local_ohip.py
index 733091a..dcc921d 100644
--- a/tests/test_arr_local_ohip.py
+++ b/tests/test_arr_local_ohip.py
@@ -74,6 +74,19 @@ class LocalOHIPTests(unittest.TestCase):
with self.assertRaises(PortalError): local.AuthorizedExecutor(executor, self.access).execute(request_id="a" * 32)
executor.execute.assert_not_called()
+ def test_pending_source_reviews_are_readable_without_new_access_or_acquisition(self):
+ pending = [{"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_data_review"}]
+ queue = Mock(ready=True)
+ queue.pending_data_reviews.return_value = pending
+ access = Mock()
+ access.status.return_value = {"ready": False}
+ downloads = local.PermissionDownloads(queue, access)
+ self.assertFalse(downloads.ready)
+ self.assertEqual(downloads.pending_data_reviews(), pending)
+ queue.pending_data_reviews.assert_called_once_with()
+ access.require_ready.assert_not_called()
+ queue.create.assert_not_called()
+
def test_source_policy_identity_cannot_be_rebound_to_demo_or_another_hotel(self):
config = document(self.root / "instance.json")
config["hotel_id"] = "OTHER"
diff --git a/tests/test_arr_local_xml_replay.py b/tests/test_arr_local_xml_replay.py
index 265c70d..4b6f2e1 100644
--- a/tests/test_arr_local_xml_replay.py
+++ b/tests/test_arr_local_xml_replay.py
@@ -9,7 +9,7 @@ import tempfile
import time
import unittest
import sys
-from unittest.mock import patch
+from unittest.mock import Mock, patch
from arr_ingestion.repository import InMemoryIngestionRepository
from arr_ingestion.service import IngestionService
@@ -22,7 +22,7 @@ from arr_web.auth import LoginCredentials
from arr_web.local_replay import create_instance, open_instance
from arr_web.local_replay_database import ReplayDatabase
from arr_web.local_xml_replay import (COOKIE, LocalReplayPortal, NativeXMLSnapshot,
- NativeXMLReplayExecutor, document)
+ NativeXMLReplayExecutor, SourceDateDownloads, document)
from tests.test_arr_opera_daily_ingest import reservation, xml_document
from tests.test_ohip_processing_handoff import CountingProcessor
from integrations.ohip.collect_arr_source import CollectionError
@@ -87,6 +87,15 @@ class NativeReplayTests(unittest.TestCase):
NativeXMLSnapshot.create(self.root / "bad", self.source, pin, day)
self.assertFalse((self.root / "bad").exists())
+ def test_source_date_wrapper_preserves_pending_review_list(self):
+ pending = [{"request_id": REQUEST, "report_date": DAY.isoformat(), "status": "needs_data_review"}]
+ queue = Mock()
+ queue.pending_data_reviews.return_value = pending
+ downloads = SourceDateDownloads(queue, self.snapshot)
+ self.assertEqual(downloads.pending_data_reviews(), pending)
+ queue.pending_data_reviews.assert_called_once_with()
+ queue.create.assert_not_called()
+
def test_source_tampering_stops_before_processing_or_database(self):
(self.root / "fixture/source.xml").write_bytes(self.raw + b" ")
with self.assertRaises(ValueError):
diff --git a/tests/test_arr_web_downloads.py b/tests/test_arr_web_downloads.py
index 3949dbc..c06bb49 100644
--- a/tests/test_arr_web_downloads.py
+++ b/tests/test_arr_web_downloads.py
@@ -110,6 +110,50 @@ class DownloadTests(unittest.TestCase):
self.assertEqual(self.service.retry("a" * 32), result)
self.assertEqual(len(self.executor.calls), 1)
+ def test_pending_source_reviews_coexist_for_different_dates_and_coalesce_per_day(self):
+ self.executor.outcome = DownloadOutcome("needs_data_review")
+ self.executor.release.set()
+ self.service.create("2026-10-07", "a" * 32)
+ october = self.finished()
+ self.service.create("2026-09-17", "b" * 32)
+ september = self.finished("b" * 32)
+ self.assertEqual(october["status"], "needs_data_review")
+ self.assertEqual(september["status"], "needs_data_review")
+ self.assertEqual(self.service.pending_data_reviews(), [october, september])
+ self.assertEqual(self.service.latest(), september)
+ self.assertEqual(self.service.create("2026-10-07", "c" * 32), october)
+ self.assertEqual(self.service.create("2026-09-17", "d" * 32), september)
+ self.assertEqual([call["from_date"] for call in self.executor.calls],
+ [date(2026, 10, 7), date(2026, 9, 17)])
+
+ def test_pending_source_review_list_survives_restart_without_reexecuting(self):
+ self.executor.outcome = DownloadOutcome("needs_data_review")
+ self.executor.release.set()
+ self.service.create("2026-09-17", "a" * 32)
+ self.finished()
+ self.service.create("2026-10-07", "b" * 32)
+ self.finished("b" * 32)
+ expected = self.service.pending_data_reviews()
+ self.service.close(wait=True)
+ self.service = PersistentARRDownloads(self.root, self.executor)
+ self.assertEqual(self.service.pending_data_reviews(), expected)
+ self.assertEqual(self.service.pending_data_reviews(), expected)
+ self.assertEqual([task["report_date"] for task in expected], ["2026-10-07", "2026-09-17"])
+ self.assertEqual(len(self.executor.calls), 2)
+
+ def test_pending_source_review_list_excludes_other_outcomes(self):
+ self.assertEqual(self.service.pending_data_reviews(), [])
+ self.executor.release.set()
+ self.service.create("2026-09-17", "a" * 32)
+ self.finished()
+ self.executor.outcome = DownloadOutcome("needs_review", "arrjob-price-review")
+ self.service.create("2026-09-18", "b" * 32)
+ self.finished("b" * 32)
+ self.executor.outcome = DownloadOutcome("failed")
+ self.service.create("2026-09-19", "c" * 32)
+ self.finished("c" * 32)
+ self.assertEqual(self.service.pending_data_reviews(), [])
+
def test_capture_only_success_without_processing_job_is_not_accepted(self):
self.executor.outcome = DownloadOutcome("succeeded")
self.executor.release.set()
@@ -226,10 +270,39 @@ class DownloadRoutesTests(unittest.TestCase):
config = json.loads(app.handle("GET", "/api/arr-downloads", headers).body)["data"]
self.assertFalse(config["ready"])
self.assertIsNone(config["latest_task"])
+ self.assertEqual(config["pending_data_reviews"], [])
response = app.handle("POST", "/api/arr-downloads", headers,
json.dumps({"report_date": "2026-09-15", "request_id": "a" * 32}).encode())
self.assertEqual(response.status, 503)
+ def test_configuration_lists_pending_dates_without_changing_latest_task(self):
+ self.executor.outcome = DownloadOutcome("needs_data_review")
+ self.executor.release.set()
+ for day, request_id in (("2026-10-07", "a" * 32), ("2026-09-17", "b" * 32)):
+ self.assertEqual(self.post({"report_date": day, "request_id": request_id}).status, 202)
+ deadline = time.monotonic() + 4
+ while time.monotonic() < deadline:
+ if self.service.get(request_id)["status"] == "needs_data_review":
+ break
+ time.sleep(.01)
+ else:
+ self.fail("worker did not reach source review")
+ snapshot = self.app._arr_download_snapshot
+ with patch.object(self.app, "_arr_download_snapshot", wraps=snapshot) as snapshot_call:
+ response = self.app.handle("GET", "/api/arr-downloads", self.headers)
+ self.assertEqual(response.status, 200)
+ config = json.loads(response.body)["data"]
+ self.assertEqual(config["latest_task"]["request_id"], "b" * 32)
+ self.assertEqual([task["report_date"] for task in config["pending_data_reviews"]],
+ ["2026-10-07", "2026-09-17"])
+ self.assertEqual(config["pending_data_reviews"], self.service.pending_data_reviews())
+ self.assertEqual(snapshot_call.call_count, 3)
+ self.assertEqual(self.app.handle("GET", "/api/arr-downloads", {}).status, 401)
+ for task in config["pending_data_reviews"]:
+ self.assertIsNone(task["job_id"])
+ self.assertNotIn("attempts", task)
+ self.assertNotIn("records", task)
+
def test_default_day_uses_bangkok_calendar_at_year_boundary(self):
with patch("arr_web.arr_downloads.datetime") as clock:
clock.now.return_value = datetime(2026, 1, 1, 0, 1, tzinfo=ZoneInfo("Asia/Bangkok"))