Fix production ARR reads and add audited source field review

This commit is contained in:
Wyndham ARR committed 2026-10-08 17:01:54 +08:00
1 parent 2417b1a41c
commit fe8735aac2
26 files changed
+2417 -157

No files matched your search

+294 -22
View File
@@ -22,6 +22,17 @@
arrDownloadPollTimer: null,
arrDownloadSynced: "",
arrDownloadStorageKey: "",
arrDownloadUsername: "",
arrDownloadContextId: "",
arrDataReview: null,
arrDataReviewRequestId: "",
arrDataReviewLoading: false,
arrDataReviewMutating: false,
arrDataReviewDisconnected: false,
arrDataReviewFinalizing: false,
arrDataReviewDrafts: {},
arrDataReviewMessage: "",
arrDataReviewMessageError: false,
companySourceFile: null,
companySource: null,
companySourceDraft: null,
@@ -286,6 +297,7 @@
function rememberARRIntent(intent) {
state.arrDownloadIntent = intent;
if (!state.arrDownloadStorageKey) return;
try {
if (intent) localStorage.setItem(state.arrDownloadStorageKey, JSON.stringify(intent));
else localStorage.removeItem(state.arrDownloadStorageKey);
@@ -306,10 +318,14 @@
|| $("#arr-download-date").value === state.arrDownloadTask.report_date));
}
function arrDownloadNeedsDataReview() {
return state.arrDownloadTask?.status === "needs_data_review";
}
function handleARRDownloadDateChange() {
state.arrDownloadDateEdited = true;
// Choosing the next report date must not abandon a running or uncertain request.
if (!arrDownloadActive() && !state.arrDownloadBusy && !arrDownloadNeedsReconciliation()) {
if (!arrDownloadActive() && !state.arrDownloadBusy && !arrDownloadNeedsReconciliation() && !arrDownloadNeedsDataReview()) {
state.arrDownloadTask = null;
state.arrDownloadDisconnected = false;
rememberARRIntent(null);
@@ -322,7 +338,7 @@
function renderARRDownload() {
const task = state.arrDownloadTask;
const pending = Boolean(state.arrDownloadIntent && !task);
const locked = state.arrDownloadBusy;
const locked = state.arrDownloadBusy || state.arrDataReviewMutating;
const reconciling = arrDownloadNeedsReconciliation();
const dateInput = $("#arr-download-date");
const button = $("#arr-download-button");
@@ -334,7 +350,7 @@
: reconciling ? "arr_download.date_resume" : "arr_download.help",
{ date: state.arrDownloadIntent?.report_date });
if (locked) closeARRCalendar();
button.disabled = !state.arrDownloadLoaded || !state.arrDownloadReady || state.arrDownloadBusy || arrDownloadActive()
button.disabled = !state.arrDownloadLoaded || !state.arrDownloadReady || locked || arrDownloadActive() || state.arrDataReviewFinalizing
|| (!reconciling && !arrDownloadCanResume() && !validARRDate(dateInput.value))
|| task?.status === "date_unavailable"
|| (task?.status === "interrupted" && !task.can_retry);
@@ -342,6 +358,7 @@
if (state.arrDownloadBusy) label = "arr_download.submitting";
else if (arrDownloadActive()) label = "arr_download.running";
else if (pending || state.arrDownloadDisconnected) label = "arr_download.check";
else if (arrDownloadNeedsDataReview()) label = "arr_download.complete_data";
else if (arrDownloadCanResume() || task?.status === "not_received") label = "arr_download.retry_date";
button.textContent = I18N.t(label, { date: task?.report_date });
button.setAttribute("aria-busy", String(state.arrDownloadBusy || arrDownloadActive()));
@@ -349,11 +366,15 @@
if (state.arrDownloadConfigDisconnected) status = "service_reconnecting";
if (state.arrDownloadDisconnected || pending) status = "reconnecting";
const feedback = $("#arr-download-feedback");
feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : status === "needs_review" ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`;
const text = I18N.t(`arr_download.${status}`);
feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`;
const taskError = !task?.job_id && status === "failed" && task.error_code === "ARR_SOURCE_FETCH_FAILED" ? "source_failed"
: !task?.job_id && status === "interrupted" && task.error_code === "ARR_DOWNLOAD_INTERRUPTED" ? "source_interrupted" : status;
const text = I18N.t(`arr_download.${taskError}`);
const taskDate = task?.report_date || state.arrDownloadIntent?.report_date;
$("#arr-download-status").textContent = taskDate ? `${taskDate} · ${text}` : text;
$("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id;
$("#arr-download-data-review").hidden = !arrDownloadNeedsDataReview();
$("#arr-download-data-review").disabled = locked;
}
function clearARRDownloadPoll() {
@@ -366,7 +387,7 @@
if (document.hidden || state.arrDownloadBusy) return;
if (!state.arrDownloadReady || state.arrDownloadConfigDisconnected) {
state.arrDownloadPollTimer = window.setTimeout(initARRDownload, 10000);
} else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review")) {
} else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review" || state.arrDataReviewFinalizing)) {
state.arrDownloadPollTimer = window.setTimeout(loadARRDownloadTask, 4000);
}
}
@@ -377,6 +398,13 @@
rememberARRIntent({ request_id: task.request_id, report_date: task.report_date });
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date;
renderARRDownload();
if (arrDownloadNeedsDataReview()) {
if (state.arrDataReviewRequestId !== task.request_id || !state.arrDataReview || state.arrDataReviewFinalizing) {
await loadARRDataReview(task.request_id, { showErrors: false });
}
} else {
resetARRDataReview();
}
const signature = `${task.request_id}:${task.status}`;
if (sync && task.job_id && ["succeeded", "needs_review", "failed"].includes(task.status) && state.arrDownloadSynced !== signature) {
state.jobsMonth = task.report_date.slice(0, 7);
@@ -414,23 +442,40 @@
}
async function initARRDownload() {
if (!state.arrDownloadStorageKey || document.hidden || state.arrDownloadConfigLoading || state.arrDownloadBusy) return;
if (!state.arrDownloadUsername || document.hidden || state.arrDownloadConfigLoading || state.arrDownloadBusy || state.arrDataReviewMutating) return;
state.arrDownloadConfigLoading = true;
clearARRDownloadPoll();
// Restore only once, even when the first readiness request fails. Reconnecting
// must not discard an uncertain POST or reset an operator's chosen date.
if (!state.arrDownloadRestored) {
state.arrDownloadRestored = true;
let saved;
try { saved = JSON.parse(localStorage.getItem(state.arrDownloadStorageKey)); } catch (_) { /* No saved task. */ }
if (saved && /^[0-9a-f]{32}$/.test(saved.request_id) && validARRDate(saved.report_date)) {
state.arrDownloadIntent = saved;
$("#arr-download-date").value = saved.report_date;
}
renderARRDownload();
}
try {
const config = await api("/api/arr-downloads");
// The server identity must be known before reading any saved request. Never
// restore the legacy username-only key, including when config is unavailable.
if (typeof config.context_id !== "string" || !config.context_id) {
if (config.ready) throw new Error("ARR context unavailable");
state.arrDownloadReady = false;
state.arrDownloadConfigDisconnected = false;
if (!state.arrDownloadDateEdited && !state.arrDownloadIntent && validARRDate(config.default_date)) $("#arr-download-date").value = config.default_date;
return;
}
const storageKey = `arr:last-download:v2:${encodeURIComponent(config.context_id)}:${encodeURIComponent(state.arrDownloadUsername)}`;
if (state.arrDownloadStorageKey !== storageKey) {
state.arrDownloadStorageKey = storageKey;
state.arrDownloadContextId = config.context_id;
state.arrDownloadRestored = false;
state.arrDownloadIntent = null;
state.arrDownloadTask = null;
state.arrDownloadDisconnected = false;
state.arrDownloadSynced = "";
resetARRDataReview();
}
if (!state.arrDownloadRestored) {
state.arrDownloadRestored = true;
let saved;
try { saved = JSON.parse(localStorage.getItem(storageKey)); } catch (_) { /* No saved task. */ }
if (saved && /^[0-9a-f]{32}$/.test(saved.request_id) && validARRDate(saved.report_date)) {
state.arrDownloadIntent = { request_id: saved.request_id, report_date: saved.report_date };
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = saved.report_date;
}
}
state.arrDownloadReady = Boolean(config.ready);
state.arrDownloadConfigDisconnected = false;
if (!state.arrDownloadDateEdited && !state.arrDownloadIntent && !state.arrDownloadTask && !state.arrDownloadBusy && validARRDate(config.default_date)) {
@@ -438,7 +483,8 @@
}
if (state.arrDownloadIntent) {
await loadARRDownloadTask();
} else if (config.latest_task && !state.arrDownloadBusy && !state.arrDownloadTask) {
} 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 });
}
} catch (_) {
@@ -454,11 +500,15 @@
async function submitARRDownload(event) {
event.preventDefault();
if (!state.arrDownloadReady || state.arrDownloadBusy || arrDownloadActive()) return;
if (!state.arrDownloadReady || state.arrDownloadBusy || state.arrDataReviewMutating || state.arrDataReviewFinalizing || arrDownloadActive()) return;
if (state.arrDownloadDisconnected || (state.arrDownloadIntent && !state.arrDownloadTask)) {
await loadARRDownloadTask();
return;
}
if (arrDownloadNeedsDataReview()) {
await loadARRDataReview(state.arrDownloadTask.request_id, { scroll: true });
return;
}
const task = state.arrDownloadTask;
if (task?.status === "interrupted" && !task.can_retry) return;
const resume = arrDownloadCanResume();
@@ -503,6 +553,216 @@
}
}
function resetARRDataReview() {
state.arrDataReview = null;
state.arrDataReviewRequestId = "";
state.arrDataReviewLoading = false;
state.arrDataReviewDisconnected = false;
state.arrDataReviewFinalizing = false;
state.arrDataReviewDrafts = {};
state.arrDataReviewMessage = "";
state.arrDataReviewMessageError = false;
renderARRDataReview();
}
function arrDataReviewInputValue(item) {
// Unconfirmed source values may be ambiguous. Require an explicit entry.
if (!item.confirmed) return "";
return Array.isArray(item.value) ? item.value.join("\n") : String(item.value ?? "");
}
function arrDataReviewCanFinalize(review = state.arrDataReview) {
return Boolean(review && review.status === "editing" && review.can_finalize && Number(review.pending_count) === 0
&& Array.isArray(review.items) && review.items.every((item) => item.confirmed)
&& !Object.keys(state.arrDataReviewDrafts).length && !state.arrDataReviewLoading && !state.arrDataReviewMutating
&& !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing);
}
function setARRDataReviewMessage(message = "", isError = false) {
state.arrDataReviewMessage = message;
state.arrDataReviewMessageError = isError;
const node = $("#arr-data-review-message");
node.textContent = message.startsWith("data_review.") ? I18N.t(message) : message;
node.classList.toggle("is-error", isError);
}
function renderARRDataReview() {
const panel = $("#arr-data-review-panel");
const review = state.arrDataReview;
panel.hidden = !state.arrDataReviewRequestId || !arrDownloadNeedsDataReview();
if (panel.hidden) return;
const busy = state.arrDataReviewLoading || state.arrDataReviewMutating;
const editable = review?.status === "editing" && !busy && !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing;
panel.setAttribute("aria-busy", String(busy));
$("#arr-data-review-title").textContent = I18N.t("data_review.title");
$("#arr-data-review-copy").textContent = I18N.t(state.arrDataReviewFinalizing || review?.status === "finalized" ? "data_review.finalizing_copy" : "data_review.copy");
$("#arr-data-review-progress").textContent = review ? I18N.t("data_review.progress", {
date: review.report_date, completed: Number(review.total_count) - Number(review.pending_count), total: review.total_count,
}) : I18N.t("data_review.loading");
$("#arr-data-review-refresh").textContent = I18N.t("data_review.refresh");
$("#arr-data-review-refresh").disabled = busy;
const finalize = $("#arr-data-review-finalize");
finalize.textContent = I18N.t(state.arrDataReviewFinalizing ? "data_review.finalizing" : "data_review.finalize");
finalize.disabled = !arrDataReviewCanFinalize(review);
setARRDataReviewMessage(state.arrDataReviewMessage, state.arrDataReviewMessageError);
const items = Array.isArray(review?.items) ? review.items : [];
$("#arr-data-review-body").innerHTML = items.map((item, index) => {
const field = String(item.field || "").toUpperCase();
const labelKey = `data_review.field_${field.toLowerCase()}`;
const translatedLabel = I18N.t(labelKey);
const label = translatedLabel === labelKey ? (item.field_label || field) : translatedLabel;
const reason = String(item.reason_code || "").toUpperCase();
const reasonKey = item.confirmed ? "confirmed_value" : /CONFLICT|AMBIGUOUS/.test(reason) ? "conflict_value" : /INVALID|MALFORMED/.test(reason) ? "invalid_value" : "missing_value";
const inputId = `arr-data-review-value-${index}`;
const value = Object.prototype.hasOwnProperty.call(state.arrDataReviewDrafts, item.item_id)
? state.arrDataReviewDrafts[item.item_id] : arrDataReviewInputValue(item);
const inputMode = ["ADULTS", "CHILDREN", "NO_OF_ROOMS"].includes(field) ? "numeric" : field === "EFFECTIVE_RATE_AMOUNT" ? "decimal" : "text";
const placeholder = ["ARRIVAL", "DEPARTURE"].includes(field) ? "YYYY-MM-DD" : field === "PRODUCTS" ? I18N.t("data_review.product_hint") : "";
const input = field === "PRODUCTS" || field === "RES_COMMENT"
? `<textarea id="${inputId}" data-arr-data-review-value rows="2" placeholder="${escapeHtml(placeholder)}" ${editable ? "" : "disabled"}>${escapeHtml(value)}</textarea>`
: `<input id="${inputId}" data-arr-data-review-value type="text" inputmode="${inputMode}" autocomplete="off" value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" ${editable ? "" : "disabled"} />`;
return `<tr data-arr-data-review-item-id="${escapeHtml(item.item_id)}" class="${item.confirmed ? "is-complete" : ""}">
<td data-label="${escapeHtml(I18N.t("data_review.reservation"))}"><strong>${escapeHtml(item.confirmation_no || "—")}</strong><small>${escapeHtml(I18N.t("data_review.source_row", { sequence: item.source_sequence }))} · ${escapeHtml(I18N.t("data_review.room", { room: item.room_no || "—" }))}</small><small>${escapeHtml(item.company_name || "—")} · ${escapeHtml(item.rate_code || "—")}</small></td>
<td data-label="${escapeHtml(I18N.t("data_review.field"))}"><label for="${inputId}">${escapeHtml(label)}</label><small>${escapeHtml(I18N.t(`data_review.${reasonKey}`))}</small></td>
<td data-label="${escapeHtml(I18N.t("data_review.value"))}">${input}</td>
<td class="arr-data-review-row-actions" data-label="${escapeHtml(I18N.t("common.operation"))}"><span class="arr-data-review-item-status">${escapeHtml(I18N.t(item.confirmed ? "data_review.confirmed" : "data_review.pending"))}</span><button type="button" data-arr-data-review-save ${editable ? "" : "disabled"}>${escapeHtml(I18N.t("data_review.save"))}</button>${item.can_be_empty ? `<button type="button" data-arr-data-review-empty ${editable ? "" : "disabled"}>${escapeHtml(I18N.t("data_review.confirm_empty"))}</button>` : ""}</td>
</tr>`;
}).join("");
$("#arr-data-review-empty").hidden = items.length > 0;
$("#arr-data-review-empty").textContent = I18N.t(state.arrDataReviewLoading ? "data_review.loading" : state.arrDataReviewDisconnected ? "data_review.disconnected" : "data_review.no_items");
}
async function loadARRDataReview(requestId = state.arrDownloadTask?.request_id, { scroll = false, showErrors = true } = {}) {
if (!requestId || state.arrDataReviewLoading || !arrDownloadNeedsDataReview()) return null;
if (state.arrDataReviewRequestId !== requestId) {
resetARRDataReview();
state.arrDataReviewRequestId = requestId;
}
const contextId = state.arrDownloadContextId;
state.arrDataReviewLoading = true;
renderARRDataReview();
try {
const review = await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review`);
if (state.arrDataReviewRequestId !== requestId || state.arrDownloadContextId !== contextId) return null;
state.arrDataReview = review;
state.arrDataReviewDisconnected = false;
state.arrDataReviewFinalizing = review.status === "finalized";
review.items?.forEach((item) => {
if (item.confirmed && state.arrDataReviewDrafts[item.item_id] === arrDataReviewInputValue(item)) delete state.arrDataReviewDrafts[item.item_id];
});
if (scroll) {
$("#arr-data-review-panel").scrollIntoView({ behavior: "smooth", block: "start" });
$("#arr-data-review-panel").focus({ preventScroll: true });
}
return review;
} catch (error) {
if (state.arrDataReviewRequestId === requestId && state.arrDownloadContextId === contextId) {
state.arrDataReviewDisconnected = true;
setARRDataReviewMessage(error.message || I18N.t("data_review.disconnected"), true);
if (showErrors) showToast(error.message, true);
}
return null;
} finally {
if (state.arrDataReviewRequestId === requestId && state.arrDownloadContextId === contextId) {
state.arrDataReviewLoading = false;
renderARRDataReview();
renderARRDownload();
scheduleARRDownloadPoll();
}
}
}
function trackARRDataReviewDraft(input) {
const row = input.closest("[data-arr-data-review-item-id]");
const item = state.arrDataReview?.items.find((candidate) => candidate.item_id === row?.dataset.arrDataReviewItemId);
if (!item) return;
if (input.value === arrDataReviewInputValue(item)) delete state.arrDataReviewDrafts[item.item_id];
else state.arrDataReviewDrafts[item.item_id] = input.value;
$("#arr-data-review-finalize").disabled = !arrDataReviewCanFinalize();
}
function parseARRDataReviewValue(item, raw, empty) {
const field = String(item.field || "").toUpperCase();
if (empty) {
if (!item.can_be_empty) throw new Error(I18N.t("data_review.required"));
return field === "PRODUCTS" ? [] : "";
}
const value = String(raw ?? "").trim();
if (!value) throw new Error(I18N.t(item.can_be_empty ? "data_review.use_empty" : "data_review.required"));
if (["ADULTS", "CHILDREN", "NO_OF_ROOMS"].includes(field) && !/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(I18N.t("data_review.integer_required"));
if (field === "EFFECTIVE_RATE_AMOUNT" && !/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(value)) throw new Error(I18N.t("data_review.decimal_required"));
if (["ARRIVAL", "DEPARTURE"].includes(field) && !validARRDate(value)) throw new Error(I18N.t("data_review.date_required"));
return field === "PRODUCTS" ? value.split(/\r?\n/).map((code) => code.trim()).filter(Boolean) : value;
}
async function saveARRDataReviewItem(button, { empty = false } = {}) {
const row = button.closest("[data-arr-data-review-item-id]");
const review = state.arrDataReview;
const requestId = state.arrDataReviewRequestId;
const item = review?.items.find((candidate) => candidate.item_id === row?.dataset.arrDataReviewItemId);
if (!item || !requestId || review.status !== "editing" || state.arrDataReviewLoading || state.arrDataReviewMutating || state.arrDataReviewDisconnected || state.arrDataReviewFinalizing) return;
const input = $("[data-arr-data-review-value]", row);
let value;
try { value = parseARRDataReviewValue(item, input?.value, empty); }
catch (error) { setARRDataReviewMessage(error.message, true); input?.focus(); return; }
state.arrDataReviewMutating = true;
setARRDataReviewMessage("data_review.saving");
renderARRDataReview();
renderARRDownload();
try {
await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review/items/${encodeURIComponent(item.item_id)}`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision: review.revision, value }),
});
delete state.arrDataReviewDrafts[item.item_id];
const updated = await loadARRDataReview(requestId);
if (updated) setARRDataReviewMessage("data_review.saved");
} catch (error) {
// Re-read even after a dropped response: the save may already be durable.
const updated = await loadARRDataReview(requestId, { showErrors: false });
setARRDataReviewMessage(error.code === "ARR_DATA_REVIEW_CONFLICT" ? (updated ? "data_review.conflict" : "data_review.disconnected") : error.message, true);
showToast(error.message, true);
} finally {
state.arrDataReviewMutating = false;
renderARRDataReview();
renderARRDownload();
}
}
async function finalizeARRDataReview() {
if (!arrDataReviewCanFinalize()) {
setARRDataReviewMessage("data_review.complete_first", true);
return;
}
const review = state.arrDataReview;
const requestId = state.arrDataReviewRequestId;
state.arrDataReviewMutating = true;
state.arrDataReviewFinalizing = true;
setARRDataReviewMessage("data_review.finalizing_copy");
renderARRDataReview();
renderARRDownload();
clearARRDownloadPoll();
try {
const task = await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review/finalize`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision: review.revision }),
});
await acceptARRDownloadTask(task);
if (arrDownloadNeedsDataReview() && state.arrDataReview?.status === "editing") setARRDataReviewMessage("data_review.complete_first", true);
} catch (error) {
// Reconcile the same request before offering another action. Never create
// a second download after an uncertain finalize response.
state.arrDataReviewDisconnected = true;
await loadARRDownloadTask();
if (arrDownloadNeedsDataReview()) await loadARRDataReview(requestId, { showErrors: false });
if (state.arrDataReviewRequestId === requestId) setARRDataReviewMessage(error.code === "ARR_DATA_REVIEW_CONFLICT" ? "data_review.conflict" : error.message, true);
if (error.status && error.status < 500) showToast(error.message, true);
} finally {
state.arrDataReviewMutating = false;
renderARRDataReview();
renderARRDownload();
scheduleARRDownloadPoll();
}
}
function showToast(message, isError = false) {
const toast = $("#toast");
toast.textContent = I18N?.text(message) || message;
@@ -707,7 +967,7 @@
async function initSession() {
const session = await api("/api/session");
state.csrf = session.csrf_token;
state.arrDownloadStorageKey = `arr:last-download:${session.username || "operator"}`;
state.arrDownloadUsername = session.username || "operator";
state.maxUploadBytes = Number(session.max_upload_bytes) || state.maxUploadBytes;
const button = $("#logout-button");
if (!session.username) return;
@@ -2799,6 +3059,7 @@
function refreshLocalizedView() {
renderARRDownload();
renderARRDataReview();
renderARRCalendar();
renderJobs(state.jobs);
renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading);
@@ -2918,6 +3179,17 @@
if (!jobId) return;
try { await openDailyPriceReview(jobId, true); } catch (error) { showToast(error.message, true); }
});
$("#arr-download-data-review").addEventListener("click", () => loadARRDataReview(state.arrDownloadTask?.request_id, { scroll: true }));
$("#arr-data-review-refresh").addEventListener("click", () => loadARRDataReview());
$("#arr-data-review-finalize").addEventListener("click", finalizeARRDataReview);
$("#arr-data-review-body").addEventListener("input", (event) => {
if (event.target.matches("[data-arr-data-review-value]")) trackARRDataReviewDraft(event.target);
});
$("#arr-data-review-body").addEventListener("click", (event) => {
const save = event.target.closest("[data-arr-data-review-save]");
const empty = event.target.closest("[data-arr-data-review-empty]");
if (save || empty) saveARRDataReviewItem(save || empty, { empty: Boolean(empty) });
});
$("#company-excel-file").addEventListener("change", (event) => {
selectCompanySourceFile(event.target.files?.[0] || null);
});
+63
View File
@@ -122,6 +122,10 @@
"arr_download.processing": ["正在处理并校验报表", "Processing and validating report", "กำลังประมวลผลและตรวจสอบรายงาน"],
"arr_download.succeeded": ["日报已处理并入库", "Daily report processed and committed", "ประมวลผลและบันทึกรายงานรายวันแล้ว"],
"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.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.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"],
"arr_download.interrupted": ["任务中断,继续原任务以核对处理结果", "Task interrupted. Resume to reconcile the result.", "งานขัดจังหวะ ดำเนินงานเดิมต่อเพื่อตรวจสอบผล"],
"arr_download.reconnecting": ["正在重新查询原任务状态…", "Checking the original task status…", "กำลังตรวจสอบสถานะงานเดิม…"],
@@ -138,6 +142,58 @@
"arr_download.date_conflict": ["原任务日期不能修改", "The original task date cannot be changed", "ไม่สามารถเปลี่ยนวันที่ของงานเดิม"],
"arr_download.retry_unavailable": ["该任务不能继续重试,请查看任务日志", "This task cannot be retried. Check the task log.", "ไม่สามารถลองงานนี้อีกครั้ง โปรดดูบันทึกงาน"],
"arr_download.other_active": ["该日期已有进行中的下载任务", "A download for this date is already in progress", "มีงานดาวน์โหลดสำหรับวันที่นี้กำลังดำเนินการอยู่"],
"data_review.title": ["人工完善报表字段", "Complete report fields", "ตรวจสอบและกรอกข้อมูลรายงาน"],
"data_review.copy": ["请核实并逐项保存缺失或有冲突的字段。允许为空的字段也须明确确认,全部完成后才能生成日报。", "Verify and save each missing or conflicting field. Explicitly confirm any allowed empty values, then generate the daily report when all fields are complete.", "ตรวจสอบและบันทึกข้อมูลที่ขาดหายหรือขัดแย้งทีละรายการ ต้องยืนยันค่าที่เว้นว่างได้อย่างชัดเจน ก่อนสร้างรายงานเมื่อครบทุกช่อง"],
"data_review.progress": ["{date} · 已确认 {completed} / {total} 项", "{date} · {completed} of {total} confirmed", "{date} · ยืนยันแล้ว {completed} / {total} รายการ"],
"data_review.reservation": ["预订信息", "Reservation", "ข้อมูลการจอง"],
"data_review.field": ["待完善字段", "Field to review", "ข้อมูลที่ต้องตรวจสอบ"],
"data_review.value": ["核实后的值", "Verified value", "ค่าที่ตรวจสอบแล้ว"],
"data_review.source_row": ["原始记录 {sequence}", "Source row {sequence}", "รายการต้นฉบับ {sequence}"],
"data_review.room": ["房号 {room}", "Room {room}", "ห้อง {room}"],
"data_review.missing_value": ["来源字段缺失,请核实", "Source value missing. Verify it.", "ไม่มีข้อมูลต้นฉบับ โปรดตรวจสอบ"],
"data_review.conflict_value": ["来源值有冲突,请核实", "Source values conflict. Verify them.", "ข้อมูลต้นฉบับขัดแย้งกัน โปรดตรวจสอบ"],
"data_review.invalid_value": ["来源值格式无效,请核实", "Source value is invalid. Verify it.", "รูปแบบข้อมูลต้นฉบับไม่ถูกต้อง โปรดตรวจสอบ"],
"data_review.confirmed_value": ["已使用人工核实值", "Manually verified value saved", "บันทึกค่าที่ตรวจสอบด้วยตนเองแล้ว"],
"data_review.confirmed": ["已确认", "Confirmed", "ยืนยันแล้ว"],
"data_review.pending": ["待确认", "Awaiting confirmation", "รอยืนยัน"],
"data_review.save": ["保存并确认", "Save and confirm", "บันทึกและยืนยัน"],
"data_review.confirm_empty": ["确认无此信息", "Confirm no value", "ยืนยันว่าไม่มีข้อมูล"],
"data_review.product_hint": ["每行填写一个包价代码", "One package code per line", "กรอกรหัสแพ็กเกจหนึ่งรหัสต่อบรรทัด"],
"data_review.refresh": ["刷新字段", "Refresh fields", "รีเฟรชข้อมูล"],
"data_review.loading": ["正在读取待完善字段…", "Loading fields for review…", "กำลังโหลดข้อมูลที่ต้องตรวจสอบ…"],
"data_review.no_items": ["没有待完善字段。", "No fields need review.", "ไม่มีข้อมูลที่ต้องตรวจสอบ"],
"data_review.region": ["待完善报表字段", "Report fields to review", "ข้อมูลรายงานที่ต้องตรวจสอบ"],
"data_review.disconnected": ["字段读取中断,请刷新字段后继续。", "Field connection lost. Refresh the fields to continue.", "การเชื่อมต่อข้อมูลขัดข้อง โปรดรีเฟรชข้อมูลเพื่อดำเนินการต่อ"],
"data_review.saving": ["正在保存并确认字段…", "Saving and confirming field…", "กำลังบันทึกและยืนยันข้อมูล…"],
"data_review.saved": ["字段已保存并确认。", "Field saved and confirmed.", "บันทึกและยืนยันข้อมูลแล้ว"],
"data_review.required": ["该字段不可为空,请填写核实后的值。", "This field is required. Enter the verified value.", "ต้องกรอกข้อมูลนี้ โปรดกรอกค่าที่ตรวจสอบแล้ว"],
"data_review.use_empty": ["如已核实无此信息,请点击“确认无此信息”。", "If you verified there is no value, choose “Confirm no value”.", "หากตรวจสอบแล้วว่าไม่มีข้อมูล โปรดเลือก “ยืนยันว่าไม่มีข้อมูล”"],
"data_review.integer_required": ["请填写非负整数。", "Enter a non-negative integer.", "กรอกจำนวนเต็มตั้งแต่ศูนย์ขึ้นไป"],
"data_review.decimal_required": ["请填写非负金额,使用小数点分隔。", "Enter a non-negative amount using a decimal point.", "กรอกจำนวนเงินตั้งแต่ศูนย์ขึ้นไป โดยใช้จุดทศนิยม"],
"data_review.date_required": ["请填写有效日期,格式为 YYYY-MM-DD。", "Enter a valid date in YYYY-MM-DD format.", "กรอกวันที่ที่ถูกต้องในรูปแบบ YYYY-MM-DD"],
"data_review.finalize": ["确认并生成日报", "Confirm and generate daily report", "ยืนยันและสร้างรายงานรายวัน"],
"data_review.finalizing": ["正在确认生成状态…", "Checking report generation…", "กำลังตรวจสอบการสร้างรายงาน…"],
"data_review.finalizing_copy": ["已提交字段确认,正在核对原任务的生成状态。", "Field confirmation submitted. Checking report generation for the original task.", "ส่งการยืนยันข้อมูลแล้ว กำลังตรวจสอบการสร้างรายงานของงานเดิม"],
"data_review.complete_first": ["请先保存并确认全部字段,再生成日报。尚未保存的修改也须先确认。", "Save and confirm all fields before generating the report, including any unsaved changes.", "โปรดบันทึกและยืนยันข้อมูลทุกช่อง รวมถึงการแก้ไขที่ยังไม่บันทึก ก่อนสร้างรายงาน"],
"data_review.invalid": ["字段值无效,请核实后重新填写。", "Invalid field value. Verify and enter it again.", "ข้อมูลไม่ถูกต้อง โปรดตรวจสอบและกรอกใหม่"],
"data_review.conflict": ["字段已被更新,已重新读取最新版本,请核对后继续。", "Fields changed. The latest revision has been reloaded; check it before continuing.", "ข้อมูลมีการเปลี่ยนแปลง โหลดฉบับล่าสุดแล้ว โปรดตรวจสอบก่อนดำเนินการต่อ"],
"data_review.not_found": ["未找到该任务的字段复核,请刷新任务状态。", "No field review found for this task. Refresh the task status.", "ไม่พบการตรวจสอบข้อมูลของงานนี้ โปรดรีเฟรชสถานะงาน"],
"data_review.frozen": ["字段确认已冻结,正在核对原任务状态。", "Confirmed fields are frozen. Checking the original task status.", "ข้อมูลที่ยืนยันถูกล็อกแล้ว กำลังตรวจสอบสถานะงานเดิม"],
"data_review.field_block_code": ["团队代码", "Block code", "รหัสกลุ่ม"],
"data_review.field_adults": ["成人数", "Adults", "จำนวนผู้ใหญ่"],
"data_review.field_children": ["儿童数", "Children", "จำนวนเด็ก"],
"data_review.field_company_name": ["公司名称", "Company name", "ชื่อบริษัท"],
"data_review.field_confirmation_no": ["确认号", "Confirmation number", "หมายเลขยืนยัน"],
"data_review.field_disp_room_no": ["房号", "Room number", "หมายเลขห้อง"],
"data_review.field_effective_rate_amount": ["Opera 房价", "Opera room rate", "ราคาห้อง Opera"],
"data_review.field_full_name": ["住客姓名", "Guest name", "ชื่อผู้เข้าพัก"],
"data_review.field_res_comment": ["预订备注", "Reservation comment", "หมายเหตุการจอง"],
"data_review.field_no_of_rooms": ["房间数", "Number of rooms", "จำนวนห้อง"],
"data_review.field_products": ["包价代码", "Package codes", "รหัสแพ็กเกจ"],
"data_review.field_rate_code": ["房价代码", "Rate code", "รหัสราคา"],
"data_review.field_room_category_label": ["房型", "Room category", "ประเภทห้อง"],
"data_review.field_arrival": ["到店日期", "Arrival date", "วันที่เข้าพัก"],
"data_review.field_departure": ["离店日期", "Departure date", "วันที่ออก"],
"upload.xml.title": ["上传 ARR.XML", "Upload ARR.XML", "อัปโหลด ARR.XML"],
"upload.xml.drop": ["拖放 XML 文件到这里", "Drop an XML file here", "ลากไฟล์ XML มาที่นี่"],
"upload.xml.choose": ["或点击选择文件,单个文件最大 25 MB", "or click to choose a file; maximum 25 MB", "หรือคลิกเพื่อเลือกไฟล์ ขนาดไม่เกิน 25 MB"],
@@ -595,6 +651,13 @@
ARR_DOWNLOAD_RETRY_UNAVAILABLE: "arr_download.retry_unavailable",
ARR_DOWNLOAD_OTHER_ACTIVE: "arr_download.other_active",
ARR_DOWNLOAD_NOT_FOUND: "arr_download.not_received",
ARR_SOURCE_FETCH_FAILED: "arr_download.source_failed",
ARR_DOWNLOAD_INTERRUPTED: "arr_download.source_interrupted",
ARR_DATA_REVIEW_INVALID: "data_review.invalid",
ARR_DATA_REVIEW_CONFLICT: "data_review.conflict",
ARR_DATA_REVIEW_INCOMPLETE: "data_review.complete_first",
ARR_DATA_REVIEW_NOT_FOUND: "data_review.not_found",
ARR_DATA_REVIEW_FROZEN: "data_review.frozen",
REPLAY_DATE_NOT_AVAILABLE: "arr_download.date_unavailable",
LOGIN_FAILED: "error.login_failed",
LOGIN_RATE_LIMITED: "error.login_rate_limited",
+25
View File
@@ -75,6 +75,7 @@
<span id="arr-download-status">正在连接下载服务…</span>
</div>
<div class="arr-download-actions">
<button class="arr-download-review" id="arr-download-data-review" type="button" hidden>完善报表字段</button>
<button class="arr-download-review" id="arr-download-review" type="button" hidden>查看价格复核</button>
<button class="primary-button" id="arr-download-button" type="submit" disabled>下载并处理</button>
</div>
@@ -127,6 +128,30 @@
</article>
</section>
<section class="panel arr-data-review-panel" id="arr-data-review-panel" aria-labelledby="arr-data-review-title" tabindex="-1" hidden>
<header class="arr-data-review-header">
<div>
<h2 id="arr-data-review-title">人工完善报表字段</h2>
<p id="arr-data-review-copy">请核实并逐项保存缺失或有冲突的字段。允许为空的字段也须明确确认,全部完成后才能生成日报。</p>
</div>
<p id="arr-data-review-progress" role="status" aria-live="polite"></p>
</header>
<div class="arr-data-review-toolbar">
<p id="arr-data-review-message" role="status" aria-live="polite"></p>
<div class="arr-data-review-actions">
<button id="arr-data-review-refresh" type="button">刷新字段</button>
<button class="primary-button" id="arr-data-review-finalize" type="button" disabled>确认并生成日报</button>
</div>
</div>
<div class="arr-data-review-scroll" role="region" aria-label="待完善报表字段" tabindex="0">
<table class="arr-data-review-table">
<thead><tr><th scope="col">预订信息</th><th scope="col">待完善字段</th><th scope="col">核实后的值</th><th scope="col">操作</th></tr></thead>
<tbody id="arr-data-review-body"></tbody>
</table>
</div>
<p class="arr-data-review-empty" id="arr-data-review-empty" hidden>没有待完善字段。</p>
</section>
<section class="daily-price-review-panel" id="daily-price-review-panel" aria-labelledby="daily-price-review-title" tabindex="-1" hidden>
<header class="daily-price-review-header">
<div class="daily-price-review-title-block">
+37
View File
@@ -201,6 +201,43 @@ button { color: inherit; }
.arr-download-actions .primary-button { min-height: 34px; margin: 0; padding: 0 12px; border-radius: 8px; font-size: 11px; }
.arr-download-review { padding: 4px 0; border: 0; color: var(--blue-dark); background: transparent; font-size: 11px; font-weight: 650; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
.arr-download-review:hover { color: var(--blue); }
.arr-data-review-panel { margin: 20px 0; border-color: #e6d4b5; overflow: hidden; }
.arr-data-review-panel[hidden] { display: none; }
.arr-data-review-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px; background: #fffcf5; }
.arr-data-review-header h2 { margin: 0 0 6px; font-size: 17px; }
.arr-data-review-header p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
#arr-data-review-progress { flex-shrink: 0; color: var(--amber); font-variant-numeric: tabular-nums; }
.arr-data-review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
#arr-data-review-message { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
#arr-data-review-message.is-error { color: #b42318; }
.arr-data-review-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
.arr-data-review-actions button, .arr-data-review-row-actions button { min-height: 34px; padding: 6px 10px; border: 1px solid var(--line-strong); border-radius: 7px; background: #fff; color: var(--ink); cursor: pointer; font-size: 12px; }
.arr-data-review-actions .primary-button { margin: 0; border-color: var(--blue); color: #fff; background: var(--blue); }
.arr-data-review-actions button:disabled, .arr-data-review-row-actions button:disabled { opacity: .5; cursor: default; }
.arr-data-review-scroll { max-height: 560px; overflow: auto; }
.arr-data-review-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.arr-data-review-table th { position: sticky; top: 0; z-index: 1; padding: 10px 16px; color: var(--muted); background: var(--surface-soft); text-align: left; white-space: nowrap; }
.arr-data-review-table td { padding: 12px 16px; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
.arr-data-review-table td:first-child { width: 30%; }
.arr-data-review-table td:nth-child(2) { width: 20%; }
.arr-data-review-table td:nth-child(3) { min-width: 210px; }
.arr-data-review-table small { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; line-height: 1.5; }
.arr-data-review-table label { font-weight: 650; }
.arr-data-review-table input, .arr-data-review-table textarea { width: 100%; min-width: 0; min-height: 36px; padding: 7px 9px; border: 1px solid var(--line-strong); border-radius: 7px; background: #fff; color: var(--ink); font: inherit; }
.arr-data-review-table textarea { resize: vertical; }
.arr-data-review-table input:disabled, .arr-data-review-table textarea:disabled { background: var(--surface-soft); }
.arr-data-review-item-status { display: block; margin-bottom: 7px; color: var(--amber); font-size: 11px; }
.arr-data-review-table .is-complete .arr-data-review-item-status { color: #087a55; }
.arr-data-review-row-actions button { margin: 0 4px 4px 0; white-space: nowrap; }
.arr-data-review-empty { padding: 20px; color: var(--muted); font-size: 12px; }
.arr-data-review-panel :focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
@media (max-width: 640px) {
.arr-data-review-header, .arr-data-review-toolbar { align-items: flex-start; flex-direction: column; gap: 12px; padding: 16px; }
.arr-data-review-table thead { display: none; }
.arr-data-review-table tr { display: grid; padding: 12px 16px; border-bottom: 1px solid var(--line); }
.arr-data-review-table td { display: block; width: auto !important; min-width: 0 !important; padding: 5px 0; border: 0; }
.arr-data-review-table td::before { content: attr(data-label); display: block; margin-bottom: 5px; color: var(--muted); font-size: 11px; }
}
.upload-panel { min-height: 0; overflow: hidden; border-color: #dbe3ef; box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 18px 44px rgba(31, 51, 82, .07); }
.daily-overview > .metric-card { align-self: stretch; display: flex; flex-direction: column; justify-content: center; height: auto; min-height: 0; padding: 24px 22px; }
.daily-overview > .metric-card::after { right: -24px; bottom: -30px; width: 82px; height: 82px; border-width: 12px; }