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()