Keep pending field and price reviews accessible across report dates
This commit is contained in:
1 parent
f46efd6d9a
commit
52e5d7433b
11 files changed
+325
-26
No files matched your search
+6
-2
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+85
-16
@@ -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) =>
|
||||
`<button type="button" data-arr-pending-review="${escapeHtml(task.request_id)}" aria-pressed="${state.arrDownloadTask?.request_id === task.request_id}" ${locked ? "disabled" : ""}>${escapeHtml(task.report_date)} · ${escapeHtml(I18N.t("arr_download.pending_date"))}</button>`).join("");
|
||||
`<button type="button" data-arr-pending-review="${escapeHtml(task.request_id)}" aria-pressed="${state.arrDownloadTask?.request_id === task.request_id}" ${locked ? "disabled" : ""}>${escapeHtml(task.report_date)} · ${escapeHtml(I18N.t(task.status === "needs_review" ? "arr_download.pending_price" : "arr_download.pending_date"))}</button>`).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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"],
|
||||
|
||||
Reference in new issue
Block a user