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 9abeffd..3870f9b 100644
--- a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md
+++ b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md
@@ -270,3 +270,15 @@ Read: memory-index, project-positioning, current-state latest September sections
- Local preservation evidence: existing Finance snapshot before/after matches exactly (1 prior version and58 audit records, comparing all old columns); newly added excluded_pm_rows remains0 for that historical version. Verified25 original handoff/review files unchanged. This includes preservation of completed10/7 rather than automatic reprocessing. New9/16 request `d89e8c15f9c94bd1a63411fa88e935d7`, job `arrbatch-517912e100e4f0dd6afecf1410b3e028f31eeecd85a0d26d`; old job remains separately auditable/cancelled. Service restarted with all local health flags ready. No production hotel writes, remote deployment/push or primary-checkout integration occurred.
- Product verification: authenticated API and CUA confirm9/16 one pending price key, Oracle amount1000,35 records/35 rooms/35 nights, blank processed price, disabled final generation; original XML upload remains present. Browser result tab retained as deliverable, screenshot `production-validation-20261007/pm-excluded-sept16-price-review-20261008.png`. Cold backup, raw replay, policy intent/completed receipt and upgrade script remain private outside Git. Actual9/16 daily/monthly generation awaits the user's price decision;0 remains accepted when explicitly confirmed.
- Promotion/follow-up: integration owner should promote the approved cancellation→PM→rate→validation→dedup→pricing order and schema021 deployment requirement. Retain the distinction between19 PM scope exclusions and10 removed unmatched PM reservations/4 removed price keys. User can now fill the sole LIAN TAI/WHO2/1000 processed-price key, save and confirm generation through the existing daily/monthly flow. Do not transfer the old PM0 value onto this different key or silently reprocess prior published reports.
+
+## Same-task Follow-up: Price Reviews Across Months
+
+- User reports10/7 unfinished review disappeared after downloading9/16. Concurrent gate Passed, same task/feature/codex/owned worktree/base/branch, no peers or unknown edits. Project Context Loaded: prior required project memory, decisions004/006/007, architecture/rules/evidence/reflection/commitments/stale state retained; task record and planning entry/gate refreshed. Primary remains untouched. Planning gate Passed: preserve every task/price/audit; do not refetch Oracle, reset completed reports or infer a human operator from a shared account.
+- Read-only evidence:10/7 remains succeeded with38 rooms and completed review2/2 (850/1200). Trace has arr-data price saves at21:13:22/33 Beijing and finalization21:13:34, completion21:13:41. This contradicts the user's recollection; account identity alone does not identify who operated the session. No price/state reset authorized.9/16 selection changes Daily Report history toSeptember; selectingOctober reveals10/7 completed/downloadable.
+- Product gap: pending dates currently include source-field reviews only. An actual pending price review can therefore disappear from the convenient entry after another date/month is selected. Plan: expose all authoritative unfinished source/price reviews, keep original compatibility field, filter completed/cancelled jobs against saved repository state, and let entry selection open the proper existing review/month without new acquisition. Test cross-month selection/reload, terminal filtering and no mutation; activate only after checking active local tasks and retain all business values.
+
+- Backend outcome: durable `pending_reviews` lists source-field and price review requests across all dates. Portal reconciles price candidates against authoritative processing status and filters completed/cancelled jobs; unavailable lookup retains the last-known pending entry. Existing `pending_data_reviews` remains source-only for older callers. Local OHIP/XML wrappers forward the combined list without new access checks or acquisition.43 queue/API/local-wrapper tests passed with disposable PostgreSQL enabled and no skips, including restart, cross-month coexistence, terminal filtering, unavailable status lookup and original acquisition coalescing. No Finance schema/business rule changes.
+- Frontend outcome: “待处理日期” labels source-field and price review tasks separately. Selection reads the existing request and opens its original review, syncing the report-history month for price tasks. Authoritative pending lists do not resurrect stale terminal entries; finalization/cancellation reconcile the original request. Source drafts remain per request, and unsaved price inputs prevent review switching until saved, including zero-price drafts. Fixed the missing render after task loading so pending-date buttons unlock.70 JavaScript tests passed, including9 new cases for mixed reviews, terminal filtering, selection, recovery and unsaved-price protection; syntax and whitespace checks passed.
+- Local activation/preservation: checked that no acquisition/processing task was active, then gracefully restarted the same8875 LaunchAgent using this owned checkout. Authenticated pending list now contains9/17 needs_data_review and9/16 needs_review only, excluding the completed10/7 and cancelled superseded9/16 task. Full before/after existing10/7 and9/16 price-review objects match exactly. No Oracle read/write, price entry, review finalization, report generation, Finance mutation, schema/config change or primary-checkout edit occurred in this follow-up.
+- Actual product verification: CUA selected9/17's existing source review (0/0 active items,18 cancellation and11 PM exclusions), then9/16's existing one-key price review (LIAN TAI/WHO2/Oracle1000,35 records/rooms/nights, blank processed price,0/1). Reload retained both enabled pending-date entries; choosingOctober history showed the original10/7 succeeded38-room report and its download link while bothSeptember pending entries remained visible. Did not click generation or enter any price. Private screenshot `production-validation-20261007/pending-review-dates-october-history-20261008.png`; before/after JSON evidence remains alongside it outside Git. Result tab retained; older user tabs were not reloaded.
+- Promotion candidate: integration-owned data-flow/current-state should distinguish cross-month pending review navigation from month-filtered completed-report history. Switching dates preserves original requests and decisions; shared-account audit evidence cannot establish the individual operator. This changes navigation only and has no semantic conflict with accepted processing rules; no additional human business-policy decision is required. Remaining follow-ups: the user supplies the sole9/16 price and explicitly continues9/17 when ready; investigate10/7 operator attribution only if additional evidence is available, without resetting its existing published result.
diff --git a/arr_web/app.py b/arr_web/app.py
index b072991..b4e3964 100644
--- a/arr_web/app.py
+++ b/arr_web/app.py
@@ -362,14 +362,18 @@ class PortalApplication:
),
)
if method == "GET" and route.path == "/api/arr-downloads":
+ pending_reader = getattr(self._arr_downloads, "pending_reviews",
+ getattr(self._arr_downloads, "pending_data_reviews", lambda: []))
+ pending = [self._arr_download_snapshot(task) for task in pending_reader()]
+ pending = [task for task in pending if task and task.get("status") in {"needs_data_review", "needs_review"}]
return Response.json(200, success({
"ready": self._arr_downloads.ready,
"context_id": getattr(self._arr_downloads, "context_id", None),
"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: [])()],
+ "pending_reviews": pending,
+ "pending_data_reviews": [task for task in pending if task["status"] == "needs_data_review"],
}))
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 ba05e93..68cebd9 100644
--- a/arr_web/arr_downloads.py
+++ b/arr_web/arr_downloads.py
@@ -92,6 +92,7 @@ class ARRDownloadCoordinator(Protocol):
def ready(self) -> bool: ...
def latest(self) -> dict | None: ...
def pending_data_reviews(self) -> list[dict]: ...
+ def pending_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: ...
@@ -109,6 +110,9 @@ class UnavailableARRDownloads:
def pending_data_reviews(self) -> list[dict]:
return []
+ def pending_reviews(self) -> list[dict]:
+ return []
+
@staticmethod
def _unavailable() -> PortalError:
return PortalError("ARR_DOWNLOAD_UNAVAILABLE", "自动下载服务暂未就绪", 503)
@@ -208,6 +212,15 @@ class PersistentARRDownloads:
ORDER BY report_date DESC, created_at, request_id""").fetchall()
return [self._public(row) for row in rows]
+ def pending_reviews(self) -> list[dict]:
+ # The portal reconciles price-review jobs against authoritative state;
+ # queue entries retain their acquisition history after review completion.
+ with self._connect() as db:
+ rows = db.execute("""SELECT * FROM downloads
+ WHERE status IN ('needs_data_review','needs_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 b7276bb..90164b5 100644
--- a/arr_web/local_ohip.py
+++ b/arr_web/local_ohip.py
@@ -89,6 +89,7 @@ class PermissionDownloads:
def latest(self): return self.queue.latest()
def pending_data_reviews(self): return self.queue.pending_data_reviews()
+ def pending_reviews(self): return self.queue.pending_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 8f0ce6d..2987d3a 100644
--- a/arr_web/local_xml_replay.py
+++ b/arr_web/local_xml_replay.py
@@ -132,6 +132,9 @@ class SourceDateDownloads:
def pending_data_reviews(self):
return self.queue.pending_data_reviews()
+ def pending_reviews(self):
+ return self.queue.pending_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 8334a45..6f66686 100644
--- a/arr_web/static/app.js
+++ b/arr_web/static/app.js
@@ -329,26 +329,58 @@
return arrDownloadNeedsDataReview() && $("#arr-download-date").value === state.arrDownloadTask.report_date;
}
- function rememberARRPendingReview(task) {
+ function isARRPendingReview(task) {
+ return task && ["needs_data_review", "needs_review"].includes(task.status)
+ && /^[0-9a-f]{32}$/.test(task.request_id) && validARRDate(task.report_date)
+ && (task.status !== "needs_review" || (typeof task.job_id === "string" && task.job_id.length > 0));
+ }
+
+ function rememberARRPendingReview(task, { allowAdd = true } = {}) {
+ const listed = state.arrDownloadPendingReviews.some((item) => item.request_id === task.request_id);
state.arrDownloadPendingReviews = state.arrDownloadPendingReviews.filter((item) => item.request_id !== task.request_id);
- if (task.status === "needs_data_review") state.arrDownloadPendingReviews.push(task);
+ if (isARRPendingReview(task) && (allowAdd || listed)) state.arrDownloadPendingReviews.push(task);
state.arrDownloadPendingReviews.sort((a, b) => b.report_date.localeCompare(a.report_date) || a.request_id.localeCompare(b.request_id));
}
+ async function refreshARRPendingReviewForJob(jobId) {
+ const pending = state.arrDownloadPendingReviews.find((task) => task.job_id === jobId);
+ if (!pending) return;
+ const contextId = state.arrDownloadContextId;
+ try {
+ const task = await api(`/api/arr-downloads/${encodeURIComponent(pending.request_id)}`);
+ if (state.arrDownloadContextId !== contextId) return;
+ if (state.arrDownloadTask?.request_id === task.request_id) {
+ await acceptARRDownloadTask(task, { sync: false });
+ } else {
+ rememberARRPendingReview(task);
+ }
+ } catch (_) { /* Keep the last known entry until the task can be reconciled. */ }
+ renderARRDownload();
+ scheduleARRDownloadPoll();
+ }
+
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();
+ || state.arrDataReviewFinalizing || state.dailyPriceReviewLoading || state.dailyPriceReviewMutating
+ || arrDownloadActive() || arrDownloadNeedsReconciliation();
$("#arr-download-pending-list").innerHTML = state.arrDownloadPendingReviews.map((task) =>
- ``).join("");
+ ``).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;
+ || state.arrDataReviewFinalizing || state.dailyPriceReviewLoading || state.dailyPriceReviewMutating
+ || arrDownloadActive() || arrDownloadNeedsReconciliation()) return;
+ if (dailyPriceReviewHasUnsavedChanges()) {
+ const message = I18N.t("daily_review.save_before_switch");
+ setDailyPriceReviewMessage(message, true);
+ showToast(message, true);
+ return;
+ }
const contextId = state.arrDownloadContextId;
state.arrDownloadBusy = true;
renderARRDownload();
@@ -359,8 +391,25 @@
$("#arr-download-date").value = task.report_date;
await acceptARRDownloadTask(task, { sync: false });
if (arrDownloadNeedsDataReview()) {
+ state.dailyPriceReview = null;
+ state.dailyPriceReviewJobId = "";
+ renderDailyPriceReview(null);
$("#arr-data-review-panel").scrollIntoView({ behavior: "smooth", block: "start" });
$("#arr-data-review-panel").focus({ preventScroll: true });
+ } else if (task.job_id && ["needs_review", "succeeded", "failed"].includes(task.status)) {
+ state.jobsMonth = task.report_date.slice(0, 7);
+ state.jobsOffset = 0;
+ renderHistoryMonthControl("jobs");
+ await selectJob(task.job_id);
+ await loadJobs(false, false);
+ if (task.status === "needs_review") {
+ await openDailyPriceReview(task.job_id, true);
+ } else {
+ state.dailyPriceReview = null;
+ state.dailyPriceReviewJobId = "";
+ renderDailyPriceReview(null);
+ }
+ state.arrDownloadSynced = `${task.request_id}:${task.status}`;
}
} catch (error) {
showToast(error.message, true);
@@ -397,7 +446,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"
+ : arrDownloadNeedsDataReview() || task?.status === "needs_review" ? "arr_download.date_independent"
: reconciling ? "arr_download.date_resume" : "arr_download.help",
{ date: state.arrDownloadIntent?.report_date });
if (locked) closeARRCalendar();
@@ -447,10 +496,10 @@
}
}
- async function acceptARRDownloadTask(task, { sync = true } = {}) {
+ async function acceptARRDownloadTask(task, { sync = true, allowPendingAdd = true } = {}) {
state.arrDownloadSubmissionError = null;
state.arrDownloadTask = task;
- rememberARRPendingReview(task);
+ rememberARRPendingReview(task, { allowAdd: allowPendingAdd });
state.arrDownloadDisconnected = false;
rememberARRIntent({ request_id: task.request_id, report_date: task.report_date });
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date;
@@ -475,13 +524,13 @@
scheduleARRDownloadPoll();
}
- async function loadARRDownloadTask() {
+ async function loadARRDownloadTask({ allowPendingAdd = true } = {}) {
if (state.arrDownloadLoading || !state.arrDownloadIntent) return;
state.arrDownloadLoading = true;
const intent = state.arrDownloadIntent;
try {
const task = await api(`/api/arr-downloads/${intent.request_id}`);
- if (state.arrDownloadIntent?.request_id === intent.request_id) await acceptARRDownloadTask(task);
+ if (state.arrDownloadIntent?.request_id === intent.request_id) await acceptARRDownloadTask(task, { allowPendingAdd });
} catch (error) {
if (state.arrDownloadIntent?.request_id !== intent.request_id) return;
if (error.status === 404) {
@@ -497,6 +546,7 @@
renderARRDownload();
} finally {
state.arrDownloadLoading = false;
+ renderARRDownload();
scheduleARRDownloadPoll();
}
}
@@ -529,10 +579,10 @@
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);
+ const authoritativePendingReviews = Array.isArray(config.pending_reviews);
+ const pendingReviews = authoritativePendingReviews ? config.pending_reviews : config.pending_data_reviews;
+ state.arrDownloadPendingReviews = Array.isArray(pendingReviews)
+ ? pendingReviews.filter(isARRPendingReview) : [];
if (!state.arrDownloadRestored) {
state.arrDownloadRestored = true;
let saved;
@@ -548,10 +598,10 @@
$("#arr-download-date").value = config.default_date;
}
if (state.arrDownloadIntent) {
- await loadARRDownloadTask();
+ await loadARRDownloadTask({ allowPendingAdd: !authoritativePendingReviews });
} else if (config.latest_task && ["queued", "downloading", "processing", "interrupted", "needs_data_review", "needs_review"].includes(config.latest_task.status)
&& !state.arrDownloadBusy && !state.arrDownloadTask) {
- await acceptARRDownloadTask(config.latest_task, { sync: false });
+ await acceptARRDownloadTask(config.latest_task, { sync: false, allowPendingAdd: !authoritativePendingReviews });
}
} catch (_) {
state.arrDownloadReady = false;
@@ -1220,6 +1270,17 @@
return review?.case_status === "open";
}
+ function dailyPriceReviewHasUnsavedChanges() {
+ const review = state.dailyPriceReview;
+ if (!review || !dailyPriceReviewEditable(review)) return false;
+ const inputs = $("#daily-price-review-body")?.querySelectorAll("[data-daily-price-review-price]") || [];
+ return [...inputs].some((input) => {
+ const itemId = Number(input.closest("[data-daily-price-review-item-id]")?.dataset.dailyPriceReviewItemId);
+ const item = review.items?.find((entry) => Number(entry.item_id) === itemId);
+ return item && String(input.value ?? "").trim() !== reviewPriceInput(item.real_price);
+ });
+ }
+
function dailyPriceReviewCanFinalize(review) {
return ["open", "generation_failed"].includes(String(review?.case_status || ""))
&& Number(review?.completed_items || 0) === Number(review?.total_items || 0)
@@ -1365,6 +1426,12 @@
async function openDailyPriceReview(jobId, scroll = true) {
if (!jobId) return;
+ if (dailyPriceReviewHasUnsavedChanges()) {
+ const message = I18N.t("daily_review.save_before_switch");
+ setDailyPriceReviewMessage(message, true);
+ showToast(message, true);
+ return;
+ }
setDailyPriceReviewMessage();
await loadDailyPriceReview(jobId, { reset: jobId !== state.dailyPriceReviewJobId, scroll });
}
@@ -1458,6 +1525,7 @@
} finally {
state.dailyPriceReviewMutating = false;
renderDailyPriceReview(state.dailyPriceReview);
+ await refreshARRPendingReviewForJob(jobId);
}
}
@@ -1532,6 +1600,7 @@
} finally {
state.dailyPriceReviewMutating = false;
renderDailyPriceReview(state.dailyPriceReview);
+ await refreshARRPendingReviewForJob(jobId);
}
}
diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js
index 0c815c0..c0c615b 100644
--- a/arr_web/static/i18n.js
+++ b/arr_web/static/i18n.js
@@ -124,9 +124,10 @@
"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.date_independent": ["可选其他日期下载,当前待处理内容会保留。", "Choose another date to download; this review will be kept.", "เลือกวันอื่นเพื่อดาวน์โหลดได้ ข้อมูลที่รอตรวจสอบจะยังคงอยู่"],
+ "arr_download.pending_dates": ["待处理日期", "Dates awaiting review", "วันที่รอดำเนินการ"],
+ "arr_download.pending_date": ["待补字段", "Complete fields", "รอกรอกข้อมูล"],
+ "arr_download.pending_price": ["待复核价格", "Review prices", "รอตรวจสอบราคา"],
"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.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"],
@@ -267,6 +268,7 @@
"daily_review.rooms_nights": ["房数 / 房晚", "Rooms / room nights", "ห้อง / คืนห้องพัก"],
"daily_review.manual_price": ["人工价格", "Manual price", "ราคาที่กำหนดด้วยตนเอง"],
"daily_review.price_placeholder": ["请输入价格(可填 0)", "Enter price (0 allowed)", "กรอกราคา (กรอก 0 ได้)"],
+ "daily_review.save_before_switch": ["当前价格尚未暂存,请先暂存,再切换待处理日期。", "Save the current price edits before switching to another review.", "โปรดบันทึกราคาที่แก้ไขก่อนเปลี่ยนไปตรวจสอบวันที่อื่น"],
"daily_review.no_candidates": ["暂无同公司 / Rate Code 固定价", "No fixed price for the same company / Rate Code", "ไม่มีราคาคงที่สำหรับบริษัท / Rate Code เดียวกัน"],
"daily_review.price_key": ["价格键", "price key", "คีย์ราคา"],
"daily_review.manual_price_label": ["{company} {rateCode} 的人工价格", "Manual price for {company} {rateCode}", "ราคาที่กำหนดด้วยตนเองสำหรับ {company} {rateCode}"],
diff --git a/tests/javascript/arr_data_review.cjs b/tests/javascript/arr_data_review.cjs
index fc01822..3781ee7 100644
--- a/tests/javascript/arr_data_review.cjs
+++ b/tests/javascript/arr_data_review.cjs
@@ -3,12 +3,158 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
-const {harness,response} = require('./helpers/arr_ui_harness.cjs');
+const {harness,response,scopedKey} = require('./helpers/arr_ui_harness.cjs');
const requestId='a'.repeat(32);
const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false};
const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SYNTHETIC-ROOM',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'BLOCK_CODE',field_label:'团队代码',reason_code:'MISSING',can_be_empty:true,value:null,confirmed:false,...extra});
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));
+const priceTask={request_id:'c'.repeat(32),report_date:'2026-09-16',status:'needs_review',
+ job_id:'fixture-price-september',can_retry:false};
+const priceReview=(price=null)=>({case_id:'fixture-price-case',case_status:'open',revision:1,
+ completed_items:price===null?0:1,total_items:1,
+ items:[{item_id:1,company_key:'Fixture Company',rate_code:'FIXTURE',effective_rate_amount:100,
+ real_price:price,affected_records:1,affected_rooms:1,affected_room_nights:2,candidate_prices:[]}],
+ pagination:{offset:0,limit:50,total:1}});
+function pendingAPI(url,currentPrice=priceTask) {
+ if(url===`/api/arr-downloads/${requestId}`) return response(200,task);
+ if(url===`/api/arr-downloads/${priceTask.request_id}`) return response(200,currentPrice);
+ if(url.endsWith('/data-review')) return response(200,review([item()]));
+ if(url.includes('/review?')) return response(200,priceReview());
+ if(url.endsWith('/trace')) return response(200,{job:{job_id:priceTask.job_id,
+ business_date:priceTask.report_date,status:currentPrice.status,active:currentPrice.status==='needs_review'},logs:[]});
+ if(url.startsWith('/api/jobs?')) return response(200,[]);
+ throw new Error('unexpected request '+url);
+}
+
+test('cross-month field and price dates restore, stay clickable after polling, and open their own pages by GET',async()=>{
+ const h=harness(url=>url==='/api/arr-downloads'
+ ? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
+ pending_reviews:[task,priceTask],pending_data_reviews:[task],latest_task:priceTask})
+ : pendingAPI(url));
+ h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
+ await h.initARRDownload();
+ assert.equal(h.state.arrDownloadPendingReviews.length,2);
+ assert.equal(h.element('#arr-download-pending-title').textContent,'arr_download.pending_dates');
+ assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-10-07.*arr_download.pending_date/);
+ assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-09-16.*arr_download.pending_price/);
+ assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
+ await h.selectARRPendingReview(requestId);
+ h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','OCTOBER-DRAFT').input);
+ await h.selectARRPendingReview(priceTask.request_id);
+ assert.equal(h.state.jobsMonth,'2026-09');
+ assert.equal(h.element('#jobs-history-month').value,'2026-09');
+ assert.equal(h.state.selectedJobId,priceTask.job_id);
+ assert.equal(h.state.dailyPriceReviewJobId,priceTask.job_id);
+ assert.equal(h.element('#daily-price-review-panel').hidden,false);
+ assert.equal(h.element('#arr-data-review-panel').hidden,true);
+ assert.equal(h.element('#arr-download-date').value,'2026-09-16');
+ assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
+ await h.loadARRDownloadTask();
+ assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/,
+ 'a finished status poll must unlock the pending buttons again');
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.element('#daily-price-review-panel').hidden,true);
+ assert.equal(h.element('#arr-data-review-panel').hidden,false);
+ assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'OCTOBER-DRAFT');
+ assert.equal(h.calls.every(call=>call.method==='GET'),true,'restoring reviews never downloads or changes prices');
+});
+
+test('an authoritative empty pending list overrides legacy and stale current review state',async()=>{
+ let cleared=false;
+ const h=harness(url=>url==='/api/arr-downloads'
+ ? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
+ pending_reviews:cleared?[]:[priceTask],pending_data_reviews:[task],latest_task:priceTask})
+ : pendingAPI(url));
+ h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
+ await h.initARRDownload();
+ assert.deepEqual(plain(h.state.arrDownloadPendingReviews).map(x=>x.request_id),[priceTask.request_id]);
+ cleared=true;
+ await h.initARRDownload();
+ assert.equal(h.state.arrDownloadTask.status,'needs_review','the fixture deliberately returns an older task snapshot');
+ assert.equal(h.state.arrDownloadPendingReviews.length,0);
+ assert.equal(h.element('#arr-download-pending').hidden,true);
+ assert.equal(h.calls.every(call=>call.method==='GET'),true);
+});
+
+test('a source request can return as a price review after processing with the same request id',async()=>{
+ const h=harness(()=>response(200,review([item()])));
+ await h.acceptARRDownloadTask(task,{sync:false});
+ await h.acceptARRDownloadTask({...task,status:'processing'},{sync:false});
+ assert.equal(h.state.arrDownloadPendingReviews.length,0);
+ await h.acceptARRDownloadTask({...task,status:'needs_review',job_id:'same-request-price-job'},{sync:false});
+ assert.equal(h.state.arrDownloadPendingReviews.length,1);
+ assert.equal(h.state.arrDownloadPendingReviews[0].status,'needs_review');
+ assert.match(h.element('#arr-download-pending-list').innerHTML,/arr_download.pending_price/);
+});
+
+for(const status of ['succeeded','failed']) {
+ test(`a stale price date whose authoritative task is ${status} leaves the pending list without reopening prices`,async()=>{
+ const h=harness(url=>pendingAPI(url,{...priceTask,status}));
+ h.state.arrDownloadPendingReviews=[priceTask];
+ await h.selectARRPendingReview(priceTask.request_id);
+ assert.equal(h.state.arrDownloadTask.status,status);
+ assert.equal(h.state.arrDownloadPendingReviews.length,0);
+ assert.equal(h.element('#arr-download-pending').hidden,true);
+ assert.equal(h.element('#daily-price-review-panel').hidden,true);
+ assert.equal(h.calls.some(call=>call.url.includes('/review?')),false);
+ assert.equal(h.calls.every(call=>call.method==='GET'),true);
+ });
+}
+
+test('unsaved prices prevent a pending date switch without clearing the current price input',async()=>{
+ const h=harness(()=>{throw new Error('switch must not send a request');});
+ h.state.arrDownloadPendingReviews=[task,priceTask];
+ h.state.arrDownloadTask=priceTask;
+ h.state.dailyPriceReviewJobId=priceTask.job_id;
+ h.state.dailyPriceReview=priceReview();
+ const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
+ h.element('#daily-price-review-body').querySelectorAll=()=>[input];
+ assert.equal(h.dailyPriceReviewHasUnsavedChanges(),true);
+ await h.selectARRPendingReview(requestId);
+ assert.equal(h.calls.length,0);
+ assert.equal(h.state.arrDownloadTask.request_id,priceTask.request_id);
+ assert.equal(input.value,'0');
+ assert.equal(h.element('#daily-price-review-message').textContent,'daily_review.save_before_switch');
+ h.state.dailyPriceReview=priceReview('0');
+ assert.equal(h.dailyPriceReviewHasUnsavedChanges(),false,'a saved zero is not an unsaved draft');
+});
+
+for(const operation of ['finalize','cancel']) {
+ test(`price ${operation} refreshes the same request and removes its terminal pending date`,async()=>{
+ const h=harness((url,options)=>{
+ if(options.method==='POST') {
+ assert.equal(url,`/api/jobs/${priceTask.job_id}/review/${operation}`);
+ return response(200,{status:operation==='finalize'?'succeeded':'cancelled'});
+ }
+ return pendingAPI(url,{...priceTask,status:operation==='finalize'?'succeeded':'failed'});
+ });
+ h.state.arrDownloadPendingReviews=[priceTask];
+ h.state.arrDownloadTask=priceTask;
+ h.state.dailyPriceReviewJobId=priceTask.job_id;
+ h.state.dailyPriceReview=priceReview('0');
+ if(operation==='finalize') await h.finalizeDailyPriceReview();
+ else await h.confirmDailyPriceReviewCancel();
+ assert.equal(h.state.arrDownloadPendingReviews.length,0);
+ assert.equal(h.element('#arr-download-pending').hidden,true);
+ assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
+ assert.equal(h.calls.some(call=>call.url===`/api/arr-downloads/${priceTask.request_id}`),true);
+ assert.equal(h.calls.some(call=>call.url==='/api/arr-downloads'),false,'no replacement download is created');
+ assert.equal(h.state.arrDownloadTask.status,operation==='finalize'?'succeeded':'failed');
+ });
+}
+
+test('pending date labels have Chinese, English and Thai translations',()=>{
+ const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
+ vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
+ const catalog=context.window.ARRI18n.CATALOG;
+ for(const key of ['arr_download.pending_dates','arr_download.pending_date','arr_download.pending_price','daily_review.save_before_switch']) {
+ assert.equal(catalog[key].length,3);
+ catalog[key].forEach(text=>assert.equal(typeof text==='string' && text.length>0,true));
+ }
+ assert.equal(catalog['arr_download.pending_dates'][0],'待处理日期');
+ assert.equal(catalog['arr_download.pending_price'][0],'待复核价格');
+});
test('cancelled source rows need no fields and the exclusion stays visible before continuation',async()=>{
const h=harness(()=>response(200,{...review([]),excluded_cancelled_count:6}));
diff --git a/tests/javascript/helpers/arr_ui_harness.cjs b/tests/javascript/helpers/arr_ui_harness.cjs
index 8e822cb..8d19dd8 100644
--- a/tests/javascript/helpers/arr_ui_harness.cjs
+++ b/tests/javascript/helpers/arr_ui_harness.cjs
@@ -11,18 +11,18 @@ assert(source.endsWith(boot+'\n') || source.endsWith(boot));
function harness(respond) {
const elements=new Map(), storage=new Map(), storageReads=[], calls=[];
const element=selector => {
- if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',
- classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){},scrollIntoView(){},focus(){}});
+ if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',style:{},
+ classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){},removeAttribute(){},querySelectorAll(){return [];},scrollIntoView(){},focus(){}});
return elements.get(selector);
};
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
document:{hidden:false,querySelector:element},
- window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value)},crypto:webcrypto,
+ window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value),formatMonth:value=>value},crypto:webcrypto,
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
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,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
});
- const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, dailyPriceReviewCanFinalize';
+ const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel';
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 dcc921d..7e89e31 100644
--- a/tests/test_arr_local_ohip.py
+++ b/tests/test_arr_local_ohip.py
@@ -78,11 +78,14 @@ class LocalOHIPTests(unittest.TestCase):
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
+ queue.pending_reviews.return_value = pending + [{"request_id": "b" * 32,
+ "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-prices"}]
access = Mock()
access.status.return_value = {"ready": False}
downloads = local.PermissionDownloads(queue, access)
self.assertFalse(downloads.ready)
self.assertEqual(downloads.pending_data_reviews(), pending)
+ self.assertEqual(downloads.pending_reviews(), queue.pending_reviews.return_value)
queue.pending_data_reviews.assert_called_once_with()
access.require_ready.assert_not_called()
queue.create.assert_not_called()
diff --git a/tests/test_arr_web_downloads.py b/tests/test_arr_web_downloads.py
index c06bb49..1d20919 100644
--- a/tests/test_arr_web_downloads.py
+++ b/tests/test_arr_web_downloads.py
@@ -141,6 +141,24 @@ class DownloadTests(unittest.TestCase):
self.assertEqual([task["report_date"] for task in expected], ["2026-10-07", "2026-09-17"])
self.assertEqual(len(self.executor.calls), 2)
+ def test_mixed_pending_reviews_survive_month_switch_and_restart_without_acquisition(self):
+ self.executor.release.set()
+ self.executor.outcome = DownloadOutcome("needs_review", "arrjob-october-prices")
+ self.service.create("2026-10-07", "a" * 32)
+ october = self.finished()
+ self.executor.outcome = DownloadOutcome("needs_data_review")
+ self.service.create("2026-09-17", "b" * 32)
+ september = self.finished("b" * 32)
+ self.executor.outcome = DownloadOutcome("succeeded", "arrjob-finished")
+ self.service.create("2026-09-16", "c" * 32)
+ self.finished("c" * 32)
+ self.assertEqual(self.service.pending_reviews(), [october, september])
+ self.assertEqual(self.service.pending_data_reviews(), [september])
+ self.service.close(wait=True)
+ self.service = PersistentARRDownloads(self.root, self.executor)
+ self.assertEqual(self.service.pending_reviews(), [october, september])
+ self.assertEqual(len(self.executor.calls), 3)
+
def test_pending_source_review_list_excludes_other_outcomes(self):
self.assertEqual(self.service.pending_data_reviews(), [])
self.executor.release.set()
@@ -271,6 +289,7 @@ class DownloadRoutesTests(unittest.TestCase):
self.assertFalse(config["ready"])
self.assertIsNone(config["latest_task"])
self.assertEqual(config["pending_data_reviews"], [])
+ self.assertEqual(config["pending_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)
@@ -314,6 +333,33 @@ class DownloadRoutesTests(unittest.TestCase):
with patch.object(self.repo, "get_job_trace", return_value={"job": {"status": "accepted"}}):
self.assertEqual(self.app._arr_download_snapshot(task)["status"], "succeeded")
+ def test_configuration_mixed_pending_reviews_filters_completed_and_cancelled_price_jobs(self):
+ tasks = [
+ {"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"},
+ {"request_id": "b" * 32, "report_date": "2026-09-17", "status": "needs_data_review", "job_id": None},
+ {"request_id": "c" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-completed"},
+ {"request_id": "d" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-cancelled"},
+ ]
+ states = {"arrjob-open": "review_required", "arrjob-completed": "succeeded", "arrjob-cancelled": "cancelled"}
+ with patch.object(self.service, "pending_reviews", return_value=tasks), \
+ patch.object(self.repo, "get_job_trace", side_effect=lambda job: {"job": {"status": states[job]}}):
+ response = self.app.handle("GET", "/api/arr-downloads", self.headers)
+ self.assertEqual(response.status, 200)
+ config = json.loads(response.body)["data"]
+ self.assertEqual(config["pending_reviews"], tasks[:2])
+ self.assertEqual(config["pending_data_reviews"], [tasks[1]])
+ self.assertEqual(self.executor.calls, [])
+ self.assertIsNone(self.service.latest())
+
+ def test_pending_price_review_is_not_lost_when_job_lookup_is_unavailable(self):
+ from arr_web.repository import PortalDataError
+ task = {"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"}
+ with patch.object(self.service, "pending_reviews", return_value=[task]), \
+ patch.object(self.repo, "get_job_trace", side_effect=PortalDataError("UNAVAILABLE", "not available")):
+ response = self.app.handle("GET", "/api/arr-downloads", self.headers)
+ self.assertEqual(response.status, 200)
+ self.assertEqual(json.loads(response.body)["data"]["pending_reviews"], [task])
+
if __name__ == "__main__":
unittest.main()