Manage daily reports by date and refresh monthly reports after deletion

This commit is contained in:
Wyndham ARR committed 2026-10-09 11:09:33 +08:00
1 parent 976a4fa7b2
commit 2a54838d59
41 files changed
+2757 -89

No files matched your search

+389 -42
View File
@@ -54,6 +54,18 @@
biPollLoading: false,
toastTimer: null,
jobs: [],
dailyReports: [],
dailyOverviewDate: "",
dailyOverview: null,
dailyOverviewTimer: null,
dailyOverviewRequest: 0,
dailyHistoryDate: "",
dailyHistoryItems: [],
dailyHistoryTotal: 0,
dailyHistoryOffset: 0,
dailyHistoryLoading: false,
dailyDeleteRequest: null,
dailyDeleting: false,
selectedJobId: "",
jobTrace: null,
jobTraceLoading: false,
@@ -119,6 +131,7 @@
rejected: ["status.validation_failed", "failed"],
failed: ["status.failed", "failed"],
cancelled: ["status.cancelled", "failed"],
deleted: ["daily.deleted", ""],
};
const reportStatus = {
reserved: ["status.generating", "running"],
@@ -522,16 +535,18 @@
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) || rejected ? " 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 = rejected
const viewingAnotherDate = task && task.report_date !== dateInput.value && !arrDownloadActive() && !reconciling;
const text = viewingAnotherDate ? I18N.t("arr_download.ready") : rejected
? I18N.t("arr_download.submission_rejected", { reason: task.submission_error })
: I18N.t(`arr_download.${taskError}`);
const taskDate = task?.report_date || state.arrDownloadIntent?.report_date;
const taskDate = viewingAnotherDate ? dateInput.value : 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-review").hidden = viewingAnotherDate || task?.status !== "needs_review" || !task.job_id;
$("#arr-download-data-review").hidden = !selectedARRDateNeedsDataReview();
$("#arr-download-data-review").disabled = locked;
renderARRAcquisitionProgress(task);
renderARRPendingReviews();
syncDailyOverviewDate(dateInput.value);
}
function clearARRDownloadPoll() {
@@ -560,7 +575,12 @@
state.arrDownloadTask = task;
rememberARRPendingReview(task, { allowAdd: allowPendingAdd });
state.arrDownloadDisconnected = false;
rememberARRIntent({ request_id: task.request_id, report_date: task.report_date });
rememberARRIntent(task.status === "deleted" ? null : { request_id: task.request_id, report_date: task.report_date });
if (task.status === "deleted" && state.dailyPriceReviewJobId === task.job_id) {
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
renderDailyPriceReview(null);
}
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date;
renderARRDownload();
if (arrDownloadNeedsDataReview()) {
@@ -576,7 +596,7 @@
state.jobsOffset = 0;
renderHistoryMonthControl("jobs");
await loadJobs(false, false);
await selectJob(task.job_id);
await selectJob(task.job_id, { syncDate: !state.arrDownloadDateEdited });
state.arrDownloadSynced = signature;
if (task.status === "succeeded" && $("#panel-monthly").classList.contains("is-active")) await loadMonthly(false, true);
}
@@ -627,6 +647,7 @@
}
const storageKey = `arr:last-download:v2:${encodeURIComponent(config.context_id)}:${encodeURIComponent(state.arrDownloadUsername)}`;
if (state.arrDownloadStorageKey !== storageKey) {
const contextChanged = state.arrDownloadContextId && state.arrDownloadContextId !== config.context_id;
state.arrDownloadStorageKey = storageKey;
state.arrDownloadContextId = config.context_id;
state.arrDownloadRestored = false;
@@ -636,6 +657,19 @@
state.arrDownloadDisconnected = false;
state.arrDownloadSynced = "";
state.arrDownloadPendingReviews = [];
state.dailyOverview = null;
state.dailyOverviewDate = "";
state.dailyOverviewRequest += 1;
window.clearTimeout(state.dailyOverviewTimer);
if (contextChanged) {
state.dailyReports = [];
state.jobs = [];
state.dailyHistoryItems = [];
state.dailyHistoryTotal = 0;
state.dailyHistoryDate = "";
renderJobs();
renderDailyHistory();
}
state.manualReviewMode = "";
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
@@ -1285,40 +1319,280 @@
updateCompanyReportControls();
}
function renderJobs(jobs) {
const body = $("#jobs-body");
if (!jobs.length) {
body.innerHTML = `<tr><td class="empty-cell" colspan="7">${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 Daily Report 记录")}</td></tr>`;
$("#metric-arrival").textContent = "—";
$("#metric-duration").textContent = "—";
$("#metric-rooms").textContent = "—";
function dailyJobSource(job) {
return job?.filename === "ARR.json" ? I18N.t("arr_download.source_label") : job?.filename || "—";
}
function dailyJobStatus(job) {
if (!job) return `<span class="cell-subtle">${escapeHtml(I18N.t("daily.no_current"))}</span>`;
const completed = formatInteger(job.review_completed_items || 0);
const total = formatInteger(job.review_total_items || 0);
return job.status === "needs_review" && job.review_case_id
? `<button class="job-review-link" type="button" data-open-price-review="${escapeHtml(job.job_id)}" aria-label="${escapeHtml(I18N.t("daily_review.open", { completed, total }))}">${chip(job.status, jobStatus)}<span class="job-review-progress">${escapeHtml(I18N.t("daily_review.progress_short", { completed, total }))}</span></button>`
: chip(job.status, jobStatus);
}
function dailyJobDownload(job) {
return state.health.download_ready && job?.status === "succeeded" && job.daily_report_sha256 && !job.is_retired
? `<a class="download-link" href="/api/download/daily?job_id=${encodeURIComponent(job.job_id)}">${escapeHtml(I18N.t("common.download"))}</a>`
: '<span class="cell-subtle">—</span>';
}
function dailyOverviewRow() {
return (state.dailyOverview?.business_date === state.dailyOverviewDate ? state.dailyOverview : null)
|| state.dailyReports.find((day) => day.business_date === state.dailyOverviewDate);
}
function renderDailyOverview() {
const date = state.dailyOverviewDate;
const day = dailyOverviewRow();
const task = state.arrDownloadTask?.report_date === date ? state.arrDownloadTask : null;
const latest = day?.latest_job;
const current = day?.current_job;
const taskPending = task && ["queued", "downloading", "processing", "needs_data_review", "needs_review", "interrupted"].includes(task.status);
const replacement = latest && latest.job_id !== current?.job_id && !latest.is_retired;
const jobPending = replacement && !["succeeded", "accepted", "cancelled", "rejected", "failed"].includes(latest.status);
const failed = !taskPending && !jobPending && (task?.status === "failed" || (replacement && ["failed", "rejected"].includes(latest.status)));
const pending = Boolean(taskPending || jobPending);
const status = taskPending ? task.status : jobPending ? latest.status : "";
const review = ["needs_data_review", "needs_review"].includes(status);
let hint = I18N.t("daily.no_current");
if (pending) hint = I18N.t(review ? "daily.overview_review" : ["queued", "downloading"].includes(status) ? "daily.overview_fetching" : "daily.overview_processing");
else if (current) hint = I18N.t("daily.current");
else if (failed) hint = I18N.t("status.failed");
$("#metric-arrival").textContent = validARRDate(date) ? formatDate(date) : "—";
$("#metric-arrival-note").textContent = hint;
$("#metric-current-version").hidden = !current || (!pending && !failed);
$("#metric-current-version").textContent = I18N.t("daily.current_preserved");
const showResult = Boolean(current && !pending);
$("#metric-duration").textContent = showResult ? duration(current) : "—";
$("#metric-rooms").textContent = showResult ? formatInteger(current.no_of_rooms) : "—";
$("#metric-duration-note").textContent = I18N.t(showResult ? "metric.duration_from_submit" : "daily.after_generation");
$("#metric-rooms-note").textContent = I18N.t(showResult ? "metric.rooms" : "daily.after_generation");
}
function syncDailyOverviewDate(date) {
if (!validARRDate(date)) {
state.dailyOverviewDate = "";
state.dailyOverview = null;
state.dailyOverviewRequest += 1;
window.clearTimeout(state.dailyOverviewTimer);
renderDailyOverview();
return;
}
body.innerHTML = jobs.map((job) => {
const sourceLabel = job.filename === "ARR.json"
? (I18N?.t("arr_download.source_label") || "接口获取")
: (job.filename || "—");
const completedItems = formatInteger(job.review_completed_items || 0);
const totalItems = formatInteger(job.review_total_items || 0);
const statusMarkup = job.status === "needs_review" && job.review_case_id
? `<button class="job-review-link" type="button" data-open-price-review="${escapeHtml(job.job_id)}" aria-label="${escapeHtml(I18N?.t("daily_review.open", { completed: completedItems, total: totalItems }) || `打开人工价格复核,已填写 ${completedItems} / ${totalItems}`)}">${chip(job.status, jobStatus)}<span class="job-review-progress">${escapeHtml(I18N?.t("daily_review.progress_short", { completed: completedItems, total: totalItems }) || `复核 ${completedItems} / ${totalItems}`)}</span></button>`
: chip(job.status, jobStatus);
const canDownload = state.health.download_ready && job.status === "succeeded" && job.daily_report_sha256;
return `
<tr>
<td><span class="filename" title="${escapeHtml(sourceLabel)}">${escapeHtml(sourceLabel)}</span></td>
<td>${statusMarkup}${job.failure_code ? `<code class="job-failure-code" title="${escapeHtml(job.failure_code)}">${escapeHtml(job.failure_code)}</code>` : ""}</td>
<td>${escapeHtml(job.arrival_date ? formatDate(job.arrival_date) : "—")}</td>
<td>${formatInteger(job.no_of_rooms)}</td>
<td class="cell-subtle">${duration(job)}</td>
<td class="cell-subtle">${formatDate(job.created_at, true)}</td>
<td>${canDownload ? `<a class="download-link" href="/api/download/daily?job_id=${encodeURIComponent(job.job_id)}">${escapeHtml(I18N?.t("common.download") || "下载")}</a>` : '<span class="cell-subtle">—</span>'}</td>
if (state.dailyOverviewDate !== date) {
state.dailyOverviewDate = date;
state.dailyOverview = null;
state.dailyOverviewRequest += 1;
window.clearTimeout(state.dailyOverviewTimer);
if (!state.dailyReports.some((day) => day.business_date === date)) {
state.dailyOverviewTimer = window.setTimeout(() => loadDailyOverview(date), 250);
}
renderJobs();
return;
}
renderDailyOverview();
}
async function loadDailyOverview(date = state.dailyOverviewDate) {
if (!validARRDate(date)) return;
const sequence = ++state.dailyOverviewRequest;
const context = state.arrDownloadContextId;
try {
const day = await api(`/api/daily-reports/${encodeURIComponent(date)}`);
if (state.dailyOverviewDate !== date || state.dailyOverviewRequest !== sequence || state.arrDownloadContextId !== context) return;
if (day?.business_date === date) state.dailyOverview = day;
renderDailyOverview();
} catch (_) { /* Do not substitute a different date's result when this day cannot be read. */ }
}
async function selectDailyReportDate(date) {
if (!validARRDate(date) || state.dailyDeleting || state.arrDownloadBusy || dailyPriceReviewHasUnsavedChanges()) {
if (dailyPriceReviewHasUnsavedChanges()) showToast(I18N.t("daily_review.save_before_switch"), true);
return;
}
$("#arr-download-date").value = date;
state.arrDownloadDateEdited = true;
renderARRDownload();
if (!dailyOverviewRow()) await loadDailyOverview(date);
}
function renderJobs() {
const body = $("#jobs-body");
if (!state.dailyReports.length) {
body.innerHTML = `<tr><td class="empty-cell" colspan="7">${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 Daily Report 记录")}</td></tr>`;
renderDailyOverview();
return;
}
body.innerHTML = state.dailyReports.map((day) => {
const current = day.current_job;
const latest = day.latest_job;
const replacement = latest && latest.job_id !== current?.job_id && !latest.is_retired ? latest : null;
const primary = current || replacement;
const historicalOnly = !current && latest && ["succeeded", "accepted"].includes(latest.status);
const date = day.business_date;
const deleteButton = primary?.can_delete
? `<button class="daily-delete-button" type="button" data-daily-delete="${escapeHtml(primary.job_id)}" data-daily-date="${escapeHtml(date)}" data-daily-current="${Boolean(current)}" ${state.dailyDeleting ? "disabled" : ""}>${escapeHtml(I18N.t("common.delete"))}</button>` : "";
const pendingMarkup = current && replacement
? `<div class="daily-replacement"><span>${escapeHtml(I18N.t("daily.replacement"))}</span>${dailyJobStatus(replacement)}</div>` : "";
return `<tr>
<td><span class="filename" title="${escapeHtml(dailyJobSource(primary))}">${escapeHtml(dailyJobSource(primary))}</span>${current || historicalOnly ? `<small class="daily-version-label">${escapeHtml(I18N.t(current ? "daily.current" : "daily.history_only"))}</small>` : ""}</td>
<td>${historicalOnly ? `<span class="cell-subtle">${escapeHtml(I18N.t("daily.no_current"))}</span>` : dailyJobStatus(primary)}${pendingMarkup}</td>
<td><button class="daily-date-button" type="button" data-daily-date-select="${escapeHtml(date)}" aria-pressed="${date === state.dailyOverviewDate}">${escapeHtml(formatDate(date))}</button></td>
<td>${current ? formatInteger(current.no_of_rooms) : "—"}</td>
<td class="cell-subtle">${current ? duration(current) : "—"}</td>
<td class="cell-subtle">${primary?.created_at ? formatDate(primary.created_at, true) : "—"}</td>
<td><div class="daily-row-actions">${dailyJobDownload(current)}${deleteButton}<button class="daily-history-button" type="button" data-daily-history="${escapeHtml(date)}">${escapeHtml(I18N.t("daily.history_versions", { count: formatInteger(day.history_count || 0) }))}</button></div></td>
</tr>`;
}).join("");
const latest = jobs.find((job) => job.status === "succeeded");
$("#metric-arrival").textContent = latest?.arrival_date ? formatDate(latest.arrival_date) : "—";
$("#metric-duration").textContent = latest ? duration(latest) : "—";
$("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—";
renderDailyOverview();
}
function renderDailyHistory() {
$("#daily-history-description").textContent = I18N.t("daily.history_date", { date: state.dailyHistoryDate });
const items = state.dailyHistoryItems;
$("#daily-history-body").innerHTML = items.length ? items.map((job) => {
const label = I18N.t(job.is_retired ? "daily.deleted" : job.is_current ? "daily.current" : "daily.historical");
const remove = job.can_delete && !job.is_retired
? `<button class="daily-delete-button" type="button" data-daily-delete="${escapeHtml(job.job_id)}" data-daily-date="${escapeHtml(state.dailyHistoryDate)}" data-daily-current="${Boolean(job.is_current)}" ${state.dailyDeleting ? "disabled" : ""}>${escapeHtml(I18N.t("common.delete"))}</button>` : "";
return `<tr><td><span class="filename">${escapeHtml(dailyJobSource(job))}</span><small class="daily-version-label">${escapeHtml(label)}</small></td><td>${job.is_retired ? `<span class="cell-subtle">${escapeHtml(label)}</span>` : dailyJobStatus(job)}</td><td>${job.status === "succeeded" ? formatInteger(job.no_of_rooms) : "—"}</td><td class="cell-subtle">${formatDate(job.created_at, true)}</td><td><div class="daily-row-actions">${dailyJobDownload(job)}${remove}<button class="daily-history-button" type="button" data-daily-job-log="${escapeHtml(job.job_id)}">${escapeHtml(I18N.t("daily.view_log"))}</button></div></td></tr>`;
}).join("") : `<tr><td colspan="5" class="empty-cell">${escapeHtml(I18N.t(state.dailyHistoryLoading ? "daily.loading" : "daily.history_empty"))}</td></tr>`;
const total = state.dailyHistoryTotal;
const offset = state.dailyHistoryOffset;
$("#daily-history-page-summary").textContent = I18N.t("pagination.summary", { total: formatInteger(total), start: formatInteger(total ? offset + 1 : 0), end: formatInteger(Math.min(total, offset + HISTORY_PAGE_SIZE)) });
$("#daily-history-prev").disabled = state.dailyHistoryLoading || state.dailyDeleting || offset === 0;
$("#daily-history-next").disabled = state.dailyHistoryLoading || state.dailyDeleting || offset + HISTORY_PAGE_SIZE >= total;
$("#daily-history-close").disabled = state.dailyDeleting;
}
async function loadDailyHistory(date = state.dailyHistoryDate) {
if (!validARRDate(date) || state.dailyHistoryLoading) return;
state.dailyHistoryDate = date;
state.dailyHistoryLoading = true;
renderDailyHistory();
const context = state.arrDownloadContextId;
const offset = state.dailyHistoryOffset;
try {
const page = readPage(await api(`/api/daily-reports/${encodeURIComponent(date)}/history?limit=${HISTORY_PAGE_SIZE}&offset=${offset}`, { returnEnvelope: true }));
if (state.dailyHistoryDate !== date || state.arrDownloadContextId !== context) return;
state.dailyHistoryItems = page.items;
state.dailyHistoryTotal = page.total;
$("#daily-history-error").hidden = true;
} catch (error) {
if (state.dailyHistoryDate !== date || state.arrDownloadContextId !== context) return;
$("#daily-history-error").textContent = error.message;
$("#daily-history-error").hidden = false;
} finally {
state.dailyHistoryLoading = false;
renderDailyHistory();
}
}
async function openDailyHistory(date, trigger) {
if (!validARRDate(date) || state.dailyDeleting || state.dailyHistoryLoading) return;
state.dailyHistoryDate = date;
state.dailyHistoryItems = [];
state.dailyHistoryOffset = 0;
state.dailyHistoryTotal = 0;
$("#daily-history-error").hidden = true;
const dialog = $("#daily-history-dialog");
dialog._returnFocus = trigger;
renderDailyHistory();
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
$("#daily-history-close").focus();
await loadDailyHistory(date);
}
function closeDailyHistory() {
if (state.dailyDeleting) return;
const dialog = $("#daily-history-dialog");
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
if (dialog._returnFocus?.isConnected) dialog._returnFocus.focus();
}
function openDailyDelete(button) {
if (dailyPriceReviewHasUnsavedChanges()) {
showToast(I18N.t("daily_review.save_before_switch"), true);
return;
}
if (state.dailyDeleting || state.dailyPriceReviewMutating || state.arrDataReviewMutating) return;
const jobId = button.dataset.dailyDelete;
const date = button.dataset.dailyDate;
const current = button.dataset.dailyCurrent === "true";
if (!jobId || !validARRDate(date)) return;
state.dailyDeleteRequest = { jobId, date, current, trigger: button };
$("#daily-delete-description").textContent = I18N.t(current ? "daily.delete_current_description" : "daily.delete_history_description", { date });
$("#daily-delete-note").textContent = I18N.t("daily.delete_note");
$("#daily-delete-error").hidden = true;
const dialog = $("#daily-delete-dialog");
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
$("#daily-delete-cancel").focus();
}
function closeDailyDelete(force = false) {
if (state.dailyDeleting && !force) return;
const request = state.dailyDeleteRequest;
state.dailyDeleteRequest = null;
const dialog = $("#daily-delete-dialog");
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
if (request?.trigger?.isConnected) request.trigger.focus();
}
async function confirmDailyDelete() {
const request = state.dailyDeleteRequest;
if (!request || state.dailyDeleting) return;
state.dailyDeleting = true;
$("#daily-delete-dialog").setAttribute("aria-busy", "true");
$("#daily-delete-cancel").disabled = true;
$("#daily-delete-submit").disabled = true;
$("#daily-delete-error").hidden = true;
renderJobs();
renderDailyHistory();
let deleted = false;
try {
const result = await api(`/api/jobs/${encodeURIComponent(request.jobId)}`, {
method: "DELETE", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ business_date: request.date }),
});
deleted = true;
// Once deletion succeeds, never present a later refresh failure as a failed deletion.
if (state.dailyPriceReviewJobId === request.jobId) {
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
renderDailyPriceReview(null);
}
state.arrDownloadPendingReviews = state.arrDownloadPendingReviews.filter((task) => task.job_id !== request.jobId);
if (state.arrDownloadTask?.job_id === request.jobId) {
await acceptARRDownloadTask({ ...state.arrDownloadTask, status: "deleted", can_retry: false, is_retired: true }, { sync: false });
}
closeDailyDelete(true);
showToast(I18N.t(result.current_removed ? "daily.deleted_current" : "daily.deleted_history"));
await Promise.allSettled([loadJobs(false, false), loadDailyOverview(state.dailyOverviewDate), loadHistoryMonths()]);
if ($("#daily-history-dialog").open) await loadDailyHistory(request.date);
if ($("#panel-monthly").classList.contains("is-active")) await loadMonthly(false, true);
if ($("#panel-bi").classList.contains("is-active")) await loadMonthsAndAnalytics();
} catch (error) {
$("#daily-delete-error").textContent = error.message;
$("#daily-delete-error").hidden = false;
} finally {
state.dailyDeleting = false;
$("#daily-delete-dialog").setAttribute("aria-busy", "false");
$("#daily-delete-cancel").disabled = false;
$("#daily-delete-submit").disabled = false;
renderJobs();
renderDailyHistory();
if (deleted) {
const focusTarget = $("#daily-history-dialog").open ? $("#daily-history-close")
: $(`[data-daily-date-select="${request.date}"]`) || $("#refresh-jobs");
focusTarget.focus();
}
}
}
function reviewPrice(value) {
@@ -1525,6 +1799,11 @@
}
setDailyPriceReviewMessage();
await loadDailyPriceReview(jobId, { reset: jobId !== state.dailyPriceReviewJobId, scroll });
if (state.dailyPriceReviewJobId === jobId && validARRDate(state.dailyPriceReview?.business_date)) {
$("#arr-download-date").value = state.dailyPriceReview.business_date;
state.arrDownloadDateEdited = true;
renderARRDownload();
}
}
async function saveDailyPriceReviewItem(button) {
@@ -1924,7 +2203,7 @@
}
}
async function selectJob(jobId) {
async function selectJob(jobId, { syncDate = true } = {}) {
if (!jobId) return;
clearTracePoll();
const changed = state.selectedJobId !== jobId;
@@ -1932,30 +2211,47 @@
if (changed) state.jobTrace = null;
renderJobs(state.jobs);
await loadJobTrace(true, !changed);
const date = state.jobs.find((job) => job.job_id === jobId)?.arrival_date
|| (state.jobTrace?.job?.job_id === jobId ? state.jobTrace.job.business_date : "");
if (syncDate && state.selectedJobId === jobId && validARRDate(date)) {
$("#arr-download-date").value = date;
renderARRDownload();
}
}
async function loadJobs(showErrors = false, loadTrace = true) {
if (state.jobsLoading) return;
state.jobsLoading = true;
const requestedMonth = historyMonth("jobs");
const context = state.arrDownloadContextId;
let requestedOffset = state.jobsOffset;
const isCurrent = () => historyMonth("jobs") === requestedMonth
&& state.arrDownloadContextId === context && state.jobsOffset === requestedOffset;
renderPagination("jobs", state.jobsTotal, state.jobsOffset, true);
try {
const month = encodeURIComponent(historyMonth("jobs"));
const month = encodeURIComponent(requestedMonth);
let page = readPage(await api(
`/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
`/api/daily-reports?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${requestedOffset}`,
{ returnEnvelope: true },
));
if (!isCurrent()) return;
if (!page.items.length && page.total > 0 && state.jobsOffset >= page.total) {
state.jobsOffset = pageOffset(page.total, state.jobsOffset, -1);
requestedOffset = state.jobsOffset;
page = readPage(await api(
`/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
`/api/daily-reports?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${requestedOffset}`,
{ returnEnvelope: true },
));
if (!isCurrent()) return;
}
state.jobs = page.items;
state.dailyReports = page.items;
if (page.items.some((day) => day.business_date === state.dailyOverviewDate)) state.dailyOverview = null;
state.jobs = [...new Map(page.items.flatMap((day) => [day.current_job, day.latest_job])
.filter(Boolean).map((job) => [job.job_id, job])).values()];
state.jobsTotal = page.total;
renderJobs(state.jobs);
if (!state.selectedJobId && state.jobs.length) {
await selectJob(state.jobs[0].job_id);
await selectJob(state.jobs[0].job_id, { syncDate: false });
} else if (loadTrace && state.selectedJobId) {
await loadJobTrace(showErrors, Boolean(state.jobTrace));
} else if (!state.jobs.length && !state.selectedJobId) {
@@ -1966,11 +2262,13 @@
setTraceLiveState(I18N?.t("task.live_unselected") || "未选择任务");
}
} catch (error) {
if (!isCurrent()) return;
$("#jobs-body").innerHTML = `<tr><td class="empty-cell" colspan="7">${escapeHtml(I18N?.t("daily.unavailable") || "日报记录暂时无法读取")}</td></tr>`;
if (showErrors) showToast(error.message, true);
} finally {
state.jobsLoading = false;
renderPagination("jobs", state.jobsTotal, state.jobsOffset);
if (!isCurrent()) await loadJobs(false, false);
}
}
@@ -3341,6 +3639,11 @@
renderARRDataReview();
renderARRCalendar();
renderJobs(state.jobs);
renderDailyHistory();
if (state.dailyDeleteRequest) {
$("#daily-delete-description").textContent = I18N.t(state.dailyDeleteRequest.current ? "daily.delete_current_description" : "daily.delete_history_description", { date: state.dailyDeleteRequest.date });
$("#daily-delete-note").textContent = I18N.t("daily.delete_note");
}
renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading);
renderMonthly(state.monthlyRuns);
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, state.monthlyLoading);
@@ -3553,12 +3856,56 @@
changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false))
);
$("#jobs-body").addEventListener("click", (event) => {
const dateTrigger = event.target.closest("[data-daily-date-select]");
if (dateTrigger) { selectDailyReportDate(dateTrigger.dataset.dailyDateSelect); return; }
const historyTrigger = event.target.closest("[data-daily-history]");
if (historyTrigger) { openDailyHistory(historyTrigger.dataset.dailyHistory, historyTrigger); return; }
const deleteTrigger = event.target.closest("[data-daily-delete]");
if (deleteTrigger) { openDailyDelete(deleteTrigger); return; }
const reviewTrigger = event.target.closest("[data-open-price-review]");
if (reviewTrigger) {
event.preventDefault();
openDailyPriceReview(reviewTrigger.dataset.openPriceReview, true);
}
});
$("#daily-history-body").addEventListener("click", async (event) => {
const deleteTrigger = event.target.closest("[data-daily-delete]");
if (deleteTrigger) { openDailyDelete(deleteTrigger); return; }
const reviewTrigger = event.target.closest("[data-open-price-review]");
if (reviewTrigger) {
closeDailyHistory();
await selectDailyReportDate(state.dailyHistoryDate);
await openDailyPriceReview(reviewTrigger.dataset.openPriceReview, true);
return;
}
const logTrigger = event.target.closest("[data-daily-job-log]");
if (logTrigger) {
closeDailyHistory();
await selectJob(logTrigger.dataset.dailyJobLog);
openTaskLog();
}
});
$("#daily-history-close").addEventListener("click", closeDailyHistory);
$("#daily-history-dialog").addEventListener("cancel", (event) => {
event.preventDefault();
closeDailyHistory();
});
$("#daily-history-prev").addEventListener("click", async () => {
if (state.dailyHistoryLoading || state.dailyDeleting) return;
state.dailyHistoryOffset = Math.max(0, state.dailyHistoryOffset - HISTORY_PAGE_SIZE);
await loadDailyHistory();
});
$("#daily-history-next").addEventListener("click", async () => {
if (state.dailyHistoryLoading || state.dailyDeleting) return;
state.dailyHistoryOffset += HISTORY_PAGE_SIZE;
await loadDailyHistory();
});
$("#daily-delete-cancel").addEventListener("click", () => closeDailyDelete());
$("#daily-delete-submit").addEventListener("click", confirmDailyDelete);
$("#daily-delete-dialog").addEventListener("cancel", (event) => {
event.preventDefault();
closeDailyDelete();
});
$("#daily-price-review-body").addEventListener("click", (event) => {
const save = event.target.closest("[data-daily-price-review-save]");
if (save) saveDailyPriceReviewItem(save);
+39 -1
View File
@@ -250,10 +250,41 @@
"upload.stage_processor": ["正在运行固定处理器", "Running the fixed processor", "กำลังเรียกใช้ตัวประมวลผล"],
"upload.stage_validate": ["正在独立验收结果", "Independently validating the result", "กำลังตรวจสอบผลอย่างอิสระ"],
"upload.stage_commit": ["正在提交数据库", "Committing to the database", "กำลังบันทึกลงฐานข้อมูล"],
"metric.arrival_date": ["最近成功处理日期", "Latest successful processing date", "วันที่ประมวลผลสำเร็จล่าสุด"],
"metric.arrival_date": ["当前查看日期", "Selected report date", "วันที่รายงานที่เลือก"],
"metric.duration": ["处理耗时", "Processing time", "เวลาประมวลผล"],
"metric.duration_from_submit": ["从提交至完成", "From submission to completion", "ตั้งแต่ส่งจนเสร็จ"],
"metric.rooms": ["保留记录的售出房数", "Sold rooms in retained records", "จำนวนห้องที่ขายในรายการที่เก็บไว้"],
"daily.current": ["当前有效日报", "Current daily report", "รายงานรายวันที่ใช้อยู่"],
"daily.no_current": ["无当前有效日报", "No current daily report", "ไม่มีรายงานรายวันที่ใช้อยู่"],
"daily.history_only": ["仅保留历史版本", "Historical versions only", "มีเฉพาะรุ่นในประวัติ"],
"daily.current_preserved": ["当前有效日报仍保留", "The current daily report remains valid", "รายงานรายวันปัจจุบันยังคงใช้ได้"],
"daily.overview_review": ["待人工核对", "Awaiting manual review", "รอการตรวจสอบด้วยตนเอง"],
"daily.overview_fetching": ["正在获取该日数据", "Fetching data for this date", "กำลังดึงข้อมูลของวันที่นี้"],
"daily.overview_processing": ["正在生成该日日报", "Generating this daily report", "กำลังสร้างรายงานรายวันของวันที่นี้"],
"daily.after_generation": ["待新日报生成后显示", "Shown after the new report is generated", "แสดงเมื่อสร้างรายงานใหม่แล้ว"],
"daily.replacement": ["新版本", "New version", "รุ่นใหม่"],
"daily.history_versions": ["历史记录({count})", "History ({count})", "ประวัติ ({count})"],
"daily.historical": ["历史版本", "Historical version", "รุ่นในประวัติ"],
"daily.deleted": ["已删除", "Deleted", "ลบแล้ว"],
"daily.history_title": ["日报历史记录", "Daily report history", "ประวัติรายงานรายวัน"],
"daily.history_date": ["{date} · 全部处理记录", "{date} · All processing records", "{date} · ประวัติการประมวลผลทั้งหมด"],
"daily.history_empty": ["暂无历史记录", "No history records", "ยังไม่มีประวัติ"],
"daily.history_note": ["每天仅展示当前有效日报;新版本处理成功后替换,月报同步更新。", "One current report per date. A successfully processed version replaces it and updates the monthly report.", "แสดงรายงานที่ใช้อยู่หนึ่งฉบับต่อวัน รุ่นใหม่จะแทนที่เมื่อประมวลผลสำเร็จและอัปเดตรายงานประจำเดือน"],
"daily.history_dialog_note": ["旧版本仅供追溯,不计入月报。已删除记录保留操作痕迹,不会自动恢复。", "Old versions are kept for reference and excluded from the monthly report. Deleted records retain their audit history and are not restored automatically.", "เก็บรุ่นเก่าไว้ตรวจสอบย้อนหลังและไม่นับในรายงานประจำเดือน รายการที่ลบยังมีประวัติการดำเนินการและจะไม่กู้คืนอัตโนมัติ"],
"daily.history_region": ["日报历史版本", "Daily report versions", "รุ่นของรายงานรายวัน"],
"daily.history_pagination": ["日报历史分页", "Daily report history pages", "การแบ่งหน้าประวัติรายงานรายวัน"],
"daily.view_log": ["查看日志", "View log", "ดูบันทึก"],
"daily.delete_title": ["删除日报", "Delete daily report", "ลบรายงานรายวัน"],
"daily.delete_current_description": ["删除 {date} 当前有效日报?该日数据将从月报中移除。", "Delete the current report for {date}? This date will be removed from the monthly report.", "ลบรายงานที่ใช้อยู่ของวันที่ {date} หรือไม่ ข้อมูลวันนี้จะถูกลบออกจากรายงานประจำเดือน"],
"daily.delete_history_description": ["删除 {date} 的这条处理记录?当前有效日报和月报不变。", "Delete this processing record for {date}? The current daily and monthly reports will not change.", "ลบประวัติการประมวลผลนี้ของวันที่ {date} หรือไม่ รายงานรายวันปัจจุบันและรายงานประจำเดือนจะไม่เปลี่ยน"],
"daily.delete_note": ["原始数据和操作记录保留;删除后不会自动恢复旧版本。", "Original data and audit history are retained. Deleting a report will not reactivate an older version.", "เก็บข้อมูลต้นฉบับและประวัติการดำเนินการไว้ การลบจะไม่เปิดใช้รุ่นเก่าโดยอัตโนมัติ"],
"daily.confirm_delete": ["确认删除", "Confirm delete", "ยืนยันการลบ"],
"daily.deleted_current": ["该日日报已移除,月报正在同步更新", "Daily report removed; the monthly report is updating", "ลบรายงานรายวันแล้ว กำลังอัปเดตรายงานประจำเดือน"],
"daily.deleted_history": ["处理记录已删除,当前日报和月报保持不变", "Record deleted; the current daily and monthly reports are unchanged", "ลบประวัติแล้ว รายงานรายวันปัจจุบันและรายงานประจำเดือนไม่เปลี่ยน"],
"arr_download.deleted": ["报表已删除,可重新获取该日数据", "Report deleted; you may fetch this date again", "ลบรายงานแล้ว สามารถดึงข้อมูลวันนี้ใหม่ได้"],
"error.daily_delete_invalid": ["删除请求无效,请刷新日报列表后重试", "Invalid delete request. Refresh the daily list and try again.", "คำขอลบไม่ถูกต้อง โปรดรีเฟรชรายการรายวันแล้วลองใหม่"],
"error.daily_delete_busy": ["这份报表正在处理,请完成后再删除", "This report is being processed. Try deleting it after processing finishes.", "รายงานนี้กำลังประมวลผล โปรดลองลบเมื่อประมวลผลเสร็จ"],
"error.daily_delete_unavailable": ["日报删除服务暂不可用,请稍后重试", "Report deletion is temporarily unavailable. Try again later.", "บริการลบรายงานไม่พร้อมใช้งานชั่วคราว โปรดลองภายหลัง"],
"daily.history": ["Daily Report", "Daily Report", "Daily Report"],
"daily.refresh": ["刷新", "Refresh", "รีเฟรช"],
"daily.download": ["日报", "Daily report", "รายงานรายวัน"],
@@ -678,6 +709,13 @@
};
const ERROR_KEYS = {
DAILY_DELETE_REQUEST_INVALID: "error.daily_delete_invalid",
DAILY_DATE_CONFLICT: "error.daily_delete_invalid",
DAILY_JOB_BUSY: "error.daily_delete_busy",
DAILY_RETIREMENT_UNAVAILABLE: "error.daily_delete_unavailable",
DAILY_RETIREMENT_CONFLICT: "error.daily_delete_invalid",
DAILY_JOB_NOT_FOUND: "error.not_found",
JOB_RETIRED: "arr_download.deleted",
ARR_DOWNLOAD_UNAVAILABLE: "arr_download.unavailable",
ARR_DOWNLOAD_DATE_INVALID: "arr_download.date_invalid",
ARR_DOWNLOAD_REQUEST_INVALID: "arr_download.request_invalid",
+29 -4
View File
@@ -124,17 +124,18 @@
<article class="metric-card">
<span>ARRIVAL DATE</span>
<strong id="metric-arrival">—</strong>
<small>最近成功处理日期</small>
<small id="metric-arrival-note">当前查看日期</small>
<small id="metric-current-version" hidden>当前有效日报仍保留</small>
</article>
<article class="metric-card">
<span>处理耗时</span>
<strong id="metric-duration">—</strong>
<small>从提交至完成</small>
<small id="metric-duration-note">从提交至完成</small>
</article>
<article class="metric-card accent-card">
<span>NO. OF ROOM</span>
<strong id="metric-rooms">—</strong>
<small>保留记录的售出房数</small>
<small id="metric-rooms-note">保留记录的售出房数</small>
</article>
</section>
@@ -214,6 +215,7 @@
<div class="panel-heading table-heading">
<div>
<h2 id="daily-history-title">Daily Report</h2>
<p class="daily-history-note">每天仅展示当前有效日报;新版本处理成功后替换,月报同步更新。</p>
</div>
<div class="history-heading-actions">
<div class="history-month-control" data-history-scope="jobs">
@@ -228,7 +230,7 @@
</div>
<div class="table-scroll">
<table>
<thead><tr><th>文件</th><th>状态</th><th>ARRIVAL DATE</th><th>NO. OF ROOM</th><th>处理耗时</th><th>提交时间</th><th>日报</th></tr></thead>
<thead><tr><th>文件</th><th>状态</th><th>ARRIVAL DATE</th><th>NO. OF ROOM</th><th>处理耗时</th><th>提交时间</th><th>操作</th></tr></thead>
<tbody id="jobs-body"><tr><td class="empty-cell" colspan="7">正在读取本月记录…</td></tr></tbody>
</table>
</div>
@@ -510,6 +512,29 @@
</section>
</main>
<dialog class="daily-history-dialog" id="daily-history-dialog" aria-labelledby="daily-history-dialog-title" aria-describedby="daily-history-description">
<div class="daily-history-dialog-heading">
<div><h2 id="daily-history-dialog-title">日报历史记录</h2><p id="daily-history-description"></p></div>
<button id="daily-history-close" type="button">关闭</button>
</div>
<p class="daily-history-dialog-note">旧版本仅供追溯,不计入月报。已删除记录保留操作痕迹,不会自动恢复。</p>
<p id="daily-history-error" class="daily-delete-error" role="alert" hidden></p>
<div class="table-scroll" tabindex="0" role="region" aria-label="日报历史版本">
<table><thead><tr><th scope="col">文件</th><th scope="col">状态</th><th scope="col">NO. OF ROOM</th><th scope="col">提交时间</th><th scope="col">操作</th></tr></thead><tbody id="daily-history-body"></tbody></table>
</div>
<nav class="pagination-bar" aria-label="日报历史分页"><span id="daily-history-page-summary"></span><div class="pagination-actions"><button class="pagination-button" id="daily-history-prev" type="button">上一页</button><button class="pagination-button" id="daily-history-next" type="button">下一页</button></div></nav>
</dialog>
<dialog class="daily-delete-dialog" id="daily-delete-dialog" aria-labelledby="daily-delete-title" aria-describedby="daily-delete-description daily-delete-note">
<div class="daily-delete-card">
<h2 id="daily-delete-title">删除日报</h2>
<p id="daily-delete-description"></p>
<p id="daily-delete-note">原始数据和操作记录保留;删除后不会自动恢复旧版本。</p>
<p id="daily-delete-error" class="daily-delete-error" role="alert" hidden></p>
<div class="daily-delete-actions"><button id="daily-delete-cancel" type="button">取消</button><button id="daily-delete-submit" type="button">确认删除</button></div>
</div>
</dialog>
<dialog class="task-log-dialog" id="task-log-dialog" aria-labelledby="process-title" aria-describedby="task-log-description">
<section class="panel process-panel task-log-panel">
<p class="sr-only" id="task-log-description">显示当前选中的日报处理任务从上传到下游通知的处理日志。</p>
+31
View File
@@ -390,6 +390,37 @@ button { color: inherit; }
.text-button:hover { color: var(--blue); border-color: #a9c1f5; }
.download-link { color: var(--blue); font-size: 12px; font-weight: 700; text-decoration: none; }
.download-link:hover { text-decoration: underline; text-underline-offset: 3px; }
.daily-history-note { max-width: 58ch; margin: 7px 0 0; color: var(--subtle); font-size: 12px; line-height: 1.6; }
.daily-version-label { display: block; margin-top: 5px; color: var(--subtle); font-size: 11px; }
.daily-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.daily-date-button, .daily-history-button, .daily-delete-button { padding: 5px 0; border: 0; border-radius: 4px; background: transparent; color: var(--blue); font: inherit; font-size: 12px; cursor: pointer; }
.daily-date-button { font-size: 13px; font-variant-numeric: tabular-nums; }
.daily-date-button[aria-pressed="true"] { font-weight: 750; text-decoration: underline; text-underline-offset: 4px; }
.daily-date-button:hover, .daily-history-button:hover, .daily-delete-button:hover { text-decoration: underline; text-underline-offset: 3px; }
.daily-delete-button { color: #b42318; }
.daily-row-actions button:disabled { opacity: .45; cursor: wait; }
.daily-date-button:focus-visible, .daily-history-button:focus-visible, .daily-delete-button:focus-visible { outline: 3px solid rgba(37, 99, 235, .2); outline-offset: 3px; }
.daily-replacement { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 5px 9px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--line); }
.daily-replacement > span { padding-top: 4px; color: var(--subtle); font-size: 11px; }
#metric-current-version { color: #9a4b0a; line-height: 1.5; }
.daily-history-dialog { width: min(1100px, calc(100% - 32px)); max-width: none; max-height: calc(100vh - 48px); padding: 0; overflow: auto; border: 1px solid var(--line); border-radius: 16px; color: var(--ink); background: var(--surface); box-shadow: 0 28px 80px rgba(16, 24, 40, .3); }
.daily-history-dialog::backdrop, .daily-delete-dialog::backdrop { background: rgba(15, 23, 42, .48); }
.daily-history-dialog-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding: 24px 26px 12px; }
.daily-history-dialog h2, .daily-delete-dialog h2 { margin: 0; font-size: 20px; }
.daily-history-dialog-heading p { margin: 8px 0 0; color: var(--blue); font-size: 13px; }
.daily-history-dialog-heading > button, .daily-delete-actions button { min-height: 40px; padding: 9px 16px; border: 1px solid var(--line); border-radius: 8px; background: white; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
.daily-history-dialog-note { margin: 0; padding: 0 26px 20px; color: var(--subtle); font-size: 12px; line-height: 1.6; }
.daily-history-dialog table { min-width: 650px; }
.daily-delete-dialog { width: min(460px, calc(100% - 32px)); max-width: none; padding: 0; border: 1px solid var(--line); border-radius: 16px; color: var(--ink); background: var(--surface); box-shadow: 0 28px 80px rgba(16, 24, 40, .3); }
.daily-delete-card { padding: 26px; }
.daily-delete-card > p { margin: 16px 0 0; font-size: 14px; line-height: 1.7; }
#daily-delete-note { color: var(--subtle); font-size: 12px; }
.daily-delete-error { margin: 0; padding: 12px 26px; color: #b42318; font-size: 13px; line-height: 1.6; }
.daily-delete-card .daily-delete-error { padding: 0; }
.daily-delete-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
#daily-delete-submit { border-color: #b42318; color: white; background: #b42318; }
.daily-delete-actions button:disabled { opacity: .5; cursor: wait; }
.daily-delete-actions button:focus-visible, .daily-history-dialog-heading > button:focus-visible { outline: 3px solid rgba(37, 99, 235, .2); outline-offset: 3px; }
.table-scroll { overflow: auto; border-top: 1px solid var(--line); }
.pagination-bar { min-height: 58px; padding: 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-top: 1px solid var(--line); background: #fbfcfe; }
.pagination-summary, .pagination-page { color: var(--muted); font-size: 11px; font-weight: 650; font-variant-numeric: tabular-nums; }