feat: add daily manual price review workflow

This commit is contained in:
Wyndham ARR committed 2026-08-06 22:40:18 +08:00
1 parent aae3d8e1db
commit ca3e8e18fa
77 files changed
+7750 -602

No files matched your search

+450 -6
View File
@@ -33,6 +33,12 @@
jobsTotal: 0,
jobsOffset: 0,
jobsMonth: "",
dailyPriceReview: null,
dailyPriceReviewJobId: "",
dailyPriceReviewLoading: false,
dailyPriceReviewMutating: false,
dailyPriceReviewOffset: 0,
dailyPriceReviewCancelTrigger: null,
monthlyRuns: [],
monthlyLoaded: false,
monthlyLoading: false,
@@ -61,6 +67,7 @@
const BI_POLL_INTERVAL = 5000;
const HISTORY_PAGE_SIZE = 50;
const COMPANY_REVIEW_PAGE_SIZE = 50;
const DAILY_PRICE_REVIEW_PAGE_SIZE = 50;
const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"];
const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"];
const DAILY_UPLOAD_PROGRESS_STAGES = [
@@ -77,6 +84,7 @@
running: ["status.processing", "running"],
delivered: ["status.waiting_validation", "running"],
validating: ["status.validating", "running"],
needs_review: ["status.needs_review", "review"],
accepted: ["status.completed", "success"],
succeeded: ["status.completed", "success"],
rejected: ["status.validation_failed", "failed"],
@@ -394,22 +402,388 @@
$("#metric-rooms").textContent = "—";
return;
}
body.innerHTML = jobs.map((job) => `
body.innerHTML = jobs.map((job) => {
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 class="job-row${job.job_id === state.selectedJobId ? " is-selected" : ""}" data-job-id="${escapeHtml(job.job_id)}" tabindex="0" role="button" aria-selected="${job.job_id === state.selectedJobId}" aria-label="${escapeHtml(I18N?.t("task.view_job", { jobId: job.job_id }) || `查看任务 ${job.job_id} 的全流程日志`)}">
<td><span class="filename" title="${escapeHtml(job.filename || "—")}">${escapeHtml(job.filename || "—")}</span></td>
<td>${chip(job.status, jobStatus)}${job.failure_code ? `<code class="job-failure-code" title="${escapeHtml(job.failure_code)}">${escapeHtml(job.failure_code)}</code>` : ""}</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>${state.health.download_ready && job.daily_report_sha256 ? `<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>
</tr>`).join("");
<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>
</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) : "—";
}
function reviewPrice(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "—";
return formatInteger(numeric);
}
function reviewPriceInput(value) {
if (value === null || value === undefined || value === "") return "";
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 && Number.isInteger(numeric)
? String(numeric)
: String(value);
}
function reviewCandidateMarkup(candidates) {
if (!Array.isArray(candidates) || !candidates.length) {
return `<span class="daily-price-review-none">${escapeHtml(I18N?.t("daily_review.no_candidates") || "暂无同公司 / Rate Code 固定价")}</span>`;
}
return candidates.map((candidate) => {
const opera = reviewPrice(candidate?.effective_rate_amount);
const price = reviewPrice(candidate?.real_price);
return `<span class="daily-price-review-candidate">Opera ${escapeHtml(opera)} → ${escapeHtml(price)}</span>`;
}).join("");
}
function strictReviewPrice(value) {
return /^(?:0|[1-9]\d{0,15})$/.test(String(value || "").trim());
}
function setDailyPriceReviewMessage(copy = "", isError = false) {
const node = $("#daily-price-review-message");
node.textContent = I18N?.text(copy) || copy;
node.classList.toggle("is-error", Boolean(isError));
}
function dailyPriceReviewEditable(review) {
return review?.case_status === "open";
}
function dailyPriceReviewCanFinalize(review) {
return ["open", "generation_failed"].includes(String(review?.case_status || ""))
&& Number(review?.completed_items || 0) === Number(review?.total_items || 0)
&& Number(review?.total_items || 0) > 0;
}
function renderDailyPriceReviewPagination(review, loading = false) {
const total = Number(review?.total_items || 0);
const pagination = review?.pagination || {};
const offset = Number(pagination.offset || 0);
const limit = Number(pagination.limit || DAILY_PRICE_REVIEW_PAGE_SIZE);
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, limit)));
const page = Math.min(pageCount, Math.floor(offset / Math.max(1, limit)) + 1);
const start = total > 0 ? offset + 1 : 0;
const end = total > 0 ? Math.min(offset + limit, total) : 0;
$("#daily-price-review-page-summary").textContent = total
? (I18N?.t("daily_review.pagination_summary", {
total: formatInteger(total),
start: formatInteger(start),
end: formatInteger(end),
}) || `共 ${formatInteger(total)} 项 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`)
: (I18N?.t("daily_review.pagination_zero") || "共 0 项");
$("#daily-price-review-page-label").textContent = I18N?.t("daily_review.pagination_page", {
page: formatInteger(page),
pages: formatInteger(pageCount),
}) || `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`;
$("#daily-price-review-prev").disabled = loading || offset <= 0;
$("#daily-price-review-next").disabled = loading || offset + limit >= total;
}
function renderDailyPriceReview(review = state.dailyPriceReview) {
const panel = $("#daily-price-review-panel");
const body = $("#daily-price-review-body");
const empty = $("#daily-price-review-empty");
if (!review) {
panel.hidden = true;
body.innerHTML = "";
empty.hidden = true;
return;
}
panel.hidden = false;
const busy = state.dailyPriceReviewLoading || state.dailyPriceReviewMutating;
const editable = dailyPriceReviewEditable(review);
const retryable = review.case_status === "generation_failed";
const processing = review.case_status === "processing";
const completed = Number(review.completed_items || 0);
const total = Number(review.total_items || 0);
panel.classList.toggle("is-busy", busy);
panel.classList.toggle("is-retryable", retryable);
panel.classList.toggle("is-processing", processing);
$("#daily-price-review-completed").textContent = formatInteger(completed);
$("#daily-price-review-pending").textContent = formatInteger(Math.max(0, total - completed));
const reviewCopy = $("#daily-price-review-copy");
reviewCopy.hidden = retryable;
reviewCopy.textContent = processing
? (I18N?.t("daily_review.processing_instruction") || "正在使用已冻结的价格清单重放原始 XML;此时无法修改。")
: (I18N?.t("daily_review.default_instruction") || "请为所有缺少固定价的价格键填写每晚价格。相同键只需填写一次。");
const finalize = $("#daily-price-review-finalize");
const cancel = $("#daily-price-review-cancel");
finalize.disabled = busy || !dailyPriceReviewCanFinalize(review);
finalize.textContent = processing
? (I18N?.t("daily_review.finalizing") || "正在生成日报…")
: retryable
? (I18N?.t("daily_review.retry") || "重试生成日报")
: (I18N?.t("daily_review.finalize") || "确认并生成日报");
cancel.disabled = busy || !["open", "generation_failed"].includes(String(review.case_status || ""));
cancel.textContent = I18N?.t("daily_review.cancel") || "取消本次复核";
const items = Array.isArray(review.items) ? review.items : [];
empty.hidden = items.length > 0;
empty.textContent = I18N?.t("daily_review.empty") || "没有可显示的待人工定价项。";
const labels = {
company: I18N?.t("common.company") || "公司",
rateCode: I18N?.t("daily_review.rate_code") || "Rate Code",
operaPrice: I18N?.t("daily_review.opera_price") || "Opera 价",
candidates: I18N?.t("daily_review.fixed_candidates") || "固定价候选",
affectedRecords: I18N?.t("daily_review.affected_records") || "影响记录",
roomsNights: I18N?.t("daily_review.rooms_nights") || "房数 / 房晚",
manualPrice: I18N?.t("daily_review.manual_price") || "人工价格",
actions: I18N?.t("common.operation") || "操作",
};
body.innerHTML = items.map((item) => {
const inputId = `daily-price-review-input-${Number(item.item_id)}`;
const saved = item.real_price !== null && item.real_price !== undefined && item.real_price !== "";
const disabled = !editable || busy;
const priceKey = item.company_key || I18N?.t("daily_review.price_key") || "价格键";
const inputLabel = I18N?.t("daily_review.manual_price_label", {
company: priceKey,
rateCode: item.rate_code || "",
}) || `${priceKey} ${item.rate_code || ""} 的人工价格`;
return `
<tr class="daily-price-review-row${saved ? " is-complete" : ""}" data-daily-price-review-item-id="${Number(item.item_id)}">
<td data-label="${escapeHtml(labels.company)}"><strong>${escapeHtml(item.company_key || "—")}</strong></td>
<td data-label="${escapeHtml(labels.rateCode)}"><code>${escapeHtml(item.rate_code || "—")}</code></td>
<td data-label="${escapeHtml(labels.operaPrice)}">${escapeHtml(reviewPrice(item.effective_rate_amount))}</td>
<td class="daily-price-review-candidates" data-label="${escapeHtml(labels.candidates)}">${reviewCandidateMarkup(item.candidate_prices)}</td>
<td data-label="${escapeHtml(labels.affectedRecords)}">${formatInteger(item.affected_records)}</td>
<td data-label="${escapeHtml(labels.roomsNights)}">${formatInteger(item.affected_rooms)} / ${formatInteger(item.affected_room_nights)}</td>
<td data-label="${escapeHtml(labels.manualPrice)}">
<label class="sr-only" for="${inputId}">${escapeHtml(inputLabel)}</label>
<input class="daily-price-review-input" id="${inputId}" data-daily-price-review-price value="${escapeHtml(reviewPriceInput(item.real_price))}" inputmode="numeric" autocomplete="off" pattern="(?:0|[1-9][0-9]{0,15})" placeholder="0" aria-describedby="daily-price-review-copy" ${disabled ? "disabled" : ""} />
</td>
<td class="daily-price-review-row-actions" data-label="${escapeHtml(labels.actions)}">
<span class="daily-price-review-status${saved ? " is-complete" : " is-pending"}">${escapeHtml(saved ? (I18N?.t("daily_review.saved") || "已暂存") : (I18N?.t("daily_review.awaiting_input") || "待填写"))}</span>
<button class="daily-price-review-save" type="button" data-daily-price-review-save ${disabled ? "disabled" : ""}>${escapeHtml(I18N?.t("daily_review.save") || "暂存")}</button>
</td>
</tr>`;
}).join("");
renderDailyPriceReviewPagination(review, busy);
}
async function loadDailyPriceReview(jobId = state.dailyPriceReviewJobId, { reset = false, scroll = false, showErrors = true } = {}) {
if (!jobId || state.dailyPriceReviewLoading) return;
if (reset || jobId !== state.dailyPriceReviewJobId) {
state.dailyPriceReviewJobId = jobId;
state.dailyPriceReviewOffset = 0;
state.dailyPriceReview = null;
}
const expectedJobId = state.dailyPriceReviewJobId;
state.dailyPriceReviewLoading = true;
renderDailyPriceReview(state.dailyPriceReview);
try {
const review = await api(`/api/jobs/${encodeURIComponent(expectedJobId)}/review?limit=${DAILY_PRICE_REVIEW_PAGE_SIZE}&offset=${state.dailyPriceReviewOffset}`);
if (state.dailyPriceReviewJobId !== expectedJobId) return;
state.dailyPriceReview = review;
renderDailyPriceReview(review);
if (scroll) {
const panel = $("#daily-price-review-panel");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
panel.focus({ preventScroll: true });
}
} catch (error) {
if (state.dailyPriceReviewJobId === expectedJobId && showErrors) {
setDailyPriceReviewMessage(error.message, true);
showToast(error.message, true);
}
} finally {
if (state.dailyPriceReviewJobId === expectedJobId) {
state.dailyPriceReviewLoading = false;
renderDailyPriceReview(state.dailyPriceReview);
}
}
}
async function openDailyPriceReview(jobId, scroll = true) {
if (!jobId) return;
setDailyPriceReviewMessage();
await loadDailyPriceReview(jobId, { reset: jobId !== state.dailyPriceReviewJobId, scroll });
}
async function saveDailyPriceReviewItem(button) {
const row = button.closest("[data-daily-price-review-item-id]");
const review = state.dailyPriceReview;
const jobId = state.dailyPriceReviewJobId;
const itemId = Number(row?.dataset.dailyPriceReviewItemId);
const input = $("[data-daily-price-review-price]", row);
const realPrice = String(input?.value || "").trim();
if (!review || !jobId || !Number.isInteger(itemId) || itemId < 1) return;
if (!strictReviewPrice(realPrice)) {
setDailyPriceReviewMessage(I18N?.t("daily_review.integer_required") || "人工价格必须是非负整数,例如 0。", true);
input?.focus();
return;
}
state.dailyPriceReviewMutating = true;
setDailyPriceReviewMessage(I18N?.t("daily_review.saving") || "正在暂存人工价格…");
renderDailyPriceReview(review);
try {
await api(`/api/jobs/${encodeURIComponent(jobId)}/review/items/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ case_id: review.case_id, revision: review.revision, real_price: realPrice }),
});
state.dailyPriceReviewOffset = 0;
state.dailyPriceReviewMutating = false;
await loadDailyPriceReview(jobId, { showErrors: true });
await loadJobs(false, false);
setDailyPriceReviewMessage(I18N?.t("daily_review.saved_message") || "人工价格已暂存。");
} catch (error) {
setDailyPriceReviewMessage(error.message, true);
showToast(error.message, true);
} finally {
state.dailyPriceReviewMutating = false;
renderDailyPriceReview(state.dailyPriceReview);
}
}
function syncUploadProgressToDailyPriceReview(review) {
if (!review) return;
if (review.case_status === "processing") {
setUploadProgress(100, I18N?.t("upload.daily_generating") || "日报生成中", "running");
return;
}
finishUploadProgress(true, I18N?.t("upload.waiting_manual_price") || "等待人工定价");
}
async function finalizeDailyPriceReview() {
const review = state.dailyPriceReview;
const jobId = state.dailyPriceReviewJobId;
if (!review || !jobId || !dailyPriceReviewCanFinalize(review)) {
setDailyPriceReviewMessage(I18N?.t("daily_review.complete_first") || "请先填写全部缺价项后再确认生成。", true);
return;
}
state.dailyPriceReviewMutating = true;
setDailyPriceReviewMessage(I18N?.t("daily_review.generation_message") || "正在冻结清单、重放原始 XML 并生成日报…");
setUploadProgress(100, I18N?.t("upload.daily_generating") || "日报生成中", "running");
renderDailyPriceReview(review);
try {
const result = await api(`/api/jobs/${encodeURIComponent(jobId)}/review/finalize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ case_id: review.case_id, revision: review.revision }),
});
await loadJobs(false, false);
if (result.status === "succeeded") {
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
renderDailyPriceReview(null);
finishUploadProgress(true, I18N?.t("upload.processing_done") || "处理完成");
showToast(I18N?.t("daily_review.success") || "人工价格已验收,正式日报已生成。");
} else if (result.status === "needs_review") {
await loadDailyPriceReview(jobId, { showErrors: true });
syncUploadProgressToDailyPriceReview(state.dailyPriceReview);
showToast(I18N?.t("daily_review.generation_registered") || "冻结清单已登记,正在生成正式日报。");
} else {
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
renderDailyPriceReview(null);
finishUploadProgress(false, I18N?.t("upload.processing_failed") || "处理失败");
showToast(I18N?.t("daily_review.validation_failed") || "正式日报未通过确定性验收,请重新上传原始 XML。", true);
}
if (state.selectedJobId === jobId) await loadJobTrace(false, true);
} catch (error) {
setDailyPriceReviewMessage(error.message, true);
showToast(error.message, true);
await loadDailyPriceReview(jobId, { showErrors: false });
syncUploadProgressToDailyPriceReview(state.dailyPriceReview);
} finally {
state.dailyPriceReviewMutating = false;
renderDailyPriceReview(state.dailyPriceReview);
}
}
function dailyPriceReviewCancelIsOpen() {
return Boolean($("#daily-price-review-confirm-dialog")?.open);
}
function setDailyPriceReviewCancelBusy(busy) {
const dialog = $("#daily-price-review-confirm-dialog");
const cancel = $("#daily-price-review-confirm-cancel");
const submit = $("#daily-price-review-confirm-submit");
dialog.setAttribute("aria-busy", String(busy));
cancel.disabled = busy;
submit.disabled = busy;
submit.textContent = busy
? (I18N?.t("daily_review.cancelling") || "正在取消…")
: (I18N?.t("daily_review.cancel_dialog_confirm") || "确认取消");
}
function closeDailyPriceReviewCancelConfirm({ force = false, restoreFocus = true } = {}) {
if (state.dailyPriceReviewMutating && !force) return;
const dialog = $("#daily-price-review-confirm-dialog");
const trigger = state.dailyPriceReviewCancelTrigger;
state.dailyPriceReviewCancelTrigger = null;
$("#daily-price-review-confirm-error").textContent = "";
$("#daily-price-review-confirm-error").hidden = true;
dialog.setAttribute("aria-busy", "false");
if (dialog.open && typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
if (restoreFocus && trigger?.isConnected && !trigger.disabled) {
window.requestAnimationFrame(() => trigger.focus());
}
}
function openDailyPriceReviewCancelConfirm(trigger) {
if (!state.dailyPriceReview || state.dailyPriceReviewMutating || dailyPriceReviewCancelIsOpen()) return;
const dialog = $("#daily-price-review-confirm-dialog");
state.dailyPriceReviewCancelTrigger = trigger || document.activeElement;
setDailyPriceReviewCancelBusy(false);
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
window.requestAnimationFrame(() => $("#daily-price-review-confirm-cancel").focus());
}
async function confirmDailyPriceReviewCancel() {
const review = state.dailyPriceReview;
const jobId = state.dailyPriceReviewJobId;
if (!review || !jobId) return;
state.dailyPriceReviewMutating = true;
setDailyPriceReviewCancelBusy(true);
setDailyPriceReviewMessage(I18N?.t("daily_review.cancelling_message") || "正在取消本次复核…");
renderDailyPriceReview(review);
try {
await api(`/api/jobs/${encodeURIComponent(jobId)}/review/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ case_id: review.case_id, revision: review.revision }),
});
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
renderDailyPriceReview(null);
closeDailyPriceReviewCancelConfirm({ force: true, restoreFocus: false });
await loadJobs(false, false);
if (state.selectedJobId === jobId) await loadJobTrace(false, true);
showToast(I18N?.t("daily_review.cancelled") || "本次人工价格复核已取消。");
} catch (error) {
setDailyPriceReviewMessage(error.message, true);
const node = $("#daily-price-review-confirm-error");
node.textContent = error.message;
node.hidden = false;
setDailyPriceReviewCancelBusy(false);
} finally {
state.dailyPriceReviewMutating = false;
renderDailyPriceReview(state.dailyPriceReview);
}
}
function cancelDailyPriceReview(trigger) {
openDailyPriceReviewCancelConfirm(trigger);
}
function taskLogIsOpen() {
return Boolean($("#task-log-dialog")?.open);
}
@@ -530,6 +904,7 @@
`trace_version=${traceConsoleValue(trace.trace_version)} status=${traceConsoleValue(job.status)} active=${Boolean(job.active)} current_stage=${traceConsoleValue(job.current_stage)}`,
`attempt_no=${traceConsoleValue(job.attempt_no)} execution_scope=${traceConsoleValue(job.execution_scope || "unknown")} processor_mode=${traceConsoleValue(job.processor_mode || "unknown")} remote_dispatch=${traceConsoleValue(job.remote_dispatch || "unknown")} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`,
`created_at=${traceConsoleValue(job.created_at)} updated_at=${traceConsoleValue(job.updated_at)} finished_at=${traceConsoleValue(job.finished_at)}`,
`review=${JSON.stringify(job.review || null)}`,
`evidence=${JSON.stringify(trace.evidence || {})}`,
"--------------------------------------------------------------------------------",
...logs.map((log) => {
@@ -564,6 +939,13 @@
item.arrival_date = summary.business_date || item.arrival_date;
item.output_rows = summary.output_rows ?? item.output_rows;
item.version_no = summary.version_no ?? item.version_no;
if (summary.review) {
item.review_case_id = summary.review.case_id || item.review_case_id;
item.review_status = summary.review.status || item.review_status;
item.review_revision = summary.review.revision ?? item.review_revision;
item.review_completed_items = summary.review.completed_items ?? item.review_completed_items;
item.review_total_items = summary.review.total_items ?? item.review_total_items;
}
renderJobs(state.jobs);
}
@@ -572,6 +954,8 @@
syncTraceToJobs(trace);
if (job.failure) {
setTraceLiveState(I18N?.t("trace.failed_status", { stage: job.current_stage || "unknown", code: job.failure.code || "unknown" }) || `FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error");
} else if (job.active && job.status === "needs_review") {
setTraceLiveState("NEEDS REVIEW / awaiting staff price", "is-review");
} else if (job.active) {
setTraceLiveState(I18N?.t("trace.running_status", { seconds: TRACE_POLL_INTERVAL / 1000 }) || `RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live");
} else if (job.status === "succeeded") {
@@ -997,8 +1381,23 @@
$("#xml-file").value = "";
$("#selected-file").textContent = I18N?.t("upload.file_not_selected") || "尚未选择文件";
const failed = receipt.status === "failed";
finishUploadProgress(!failed, failed ? (I18N?.t("upload.processing_failed") || "处理失败") : (I18N?.t("upload.processing_done") || "处理完成"));
showToast(failed ? (I18N?.t("upload.failure_log") || "ARR.XML 处理失败,请查看任务日志") : (I18N?.t("upload.success") || "ARR.XML 已处理并完成入库"), failed);
const needsReview = receipt.status === "needs_review";
finishUploadProgress(
!failed,
failed
? (I18N?.t("upload.processing_failed") || "处理失败")
: needsReview
? (I18N?.t("upload.waiting_manual_price") || "等待人工定价")
: (I18N?.t("upload.processing_done") || "处理完成"),
);
showToast(
failed
? (I18N?.t("upload.failure_log") || "ARR.XML 处理失败,请查看任务日志")
: needsReview
? (I18N?.t("upload.review_required") || "发现缺少固定价,请完成人工价格复核。")
: (I18N?.t("upload.success") || "ARR.XML 已处理并完成入库"),
failed,
);
const receiptMonth = String(receipt.business_date || receipt.arrival_date || "").slice(0, 7);
state.jobsMonth = validMonth(receiptMonth) ? receiptMonth : localMonth();
state.jobsOffset = 0;
@@ -1006,6 +1405,7 @@
await loadJobs(false, false);
if (receipt.job_id) {
await selectJob(receipt.job_id);
if (needsReview) await openDailyPriceReview(receipt.job_id, true);
} else {
renderTracePlaceholder(I18N?.t("task.trace_processing") || "处理已结束", I18N?.t("task.no_job_id") || "任务编号暂不可用,请刷新 Daily Report。", "success");
}
@@ -2032,6 +2432,7 @@
if (state.companyReportCurrentJob) renderCompanyReportJob(state.companyReportCurrentJob);
if (state.jobTrace) renderTrace(state.jobTrace);
else if (!state.selectedJobId) renderTracePlaceholder(I18N?.t("task.waiting_upload") || "等待上传 ARR.XML", I18N?.t("task.after_create") || "新任务创建后,这里会显示服务端保存的全流程日志。");
renderDailyPriceReview(state.dailyPriceReview);
updateCompanySourceUploadControls();
updateCompanyReportControls();
I18N?.translateDom();
@@ -2058,6 +2459,10 @@
state.jobsTotal = 0;
state.selectedJobId = "";
state.jobTrace = null;
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
state.dailyPriceReviewOffset = 0;
renderDailyPriceReview(null);
clearTracePoll();
renderHistoryMonthControl(scope);
await loadJobs(true, false);
@@ -2202,17 +2607,40 @@
changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false))
);
$("#jobs-body").addEventListener("click", (event) => {
const reviewTrigger = event.target.closest("[data-open-price-review]");
if (reviewTrigger) {
event.preventDefault();
openDailyPriceReview(reviewTrigger.dataset.openPriceReview, true);
return;
}
if (event.target.closest("a, button")) return;
const row = event.target.closest("[data-job-id]");
if (row) selectJob(row.dataset.jobId, { showLog: true });
});
$("#jobs-body").addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
if (event.target.closest("a, button")) return;
const row = event.target.closest("[data-job-id]");
if (!row) return;
event.preventDefault();
selectJob(row.dataset.jobId, { showLog: true });
});
$("#daily-price-review-body").addEventListener("click", (event) => {
const save = event.target.closest("[data-daily-price-review-save]");
if (save) saveDailyPriceReviewItem(save);
});
$("#daily-price-review-finalize").addEventListener("click", finalizeDailyPriceReview);
$("#daily-price-review-cancel").addEventListener("click", () => cancelDailyPriceReview($("#daily-price-review-cancel")));
$("#daily-price-review-prev").addEventListener("click", async () => {
if (state.dailyPriceReviewLoading || state.dailyPriceReviewMutating) return;
state.dailyPriceReviewOffset = Math.max(0, state.dailyPriceReviewOffset - DAILY_PRICE_REVIEW_PAGE_SIZE);
await loadDailyPriceReview(state.dailyPriceReviewJobId, { showErrors: true });
});
$("#daily-price-review-next").addEventListener("click", async () => {
if (state.dailyPriceReviewLoading || state.dailyPriceReviewMutating || !state.dailyPriceReview) return;
state.dailyPriceReviewOffset += DAILY_PRICE_REVIEW_PAGE_SIZE;
await loadDailyPriceReview(state.dailyPriceReviewJobId, { showErrors: true });
});
$("#task-log-trigger").addEventListener("click", (event) => {
event.preventDefault();
openTaskLog();
@@ -2222,6 +2650,22 @@
$("#task-log-dialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeTaskLog();
});
$("#daily-price-review-confirm-cancel").addEventListener("click", () => closeDailyPriceReviewCancelConfirm());
$("#daily-price-review-confirm-submit").addEventListener("click", confirmDailyPriceReviewCancel);
$("#daily-price-review-confirm-dialog").addEventListener("cancel", (event) => {
if (state.dailyPriceReviewMutating) event.preventDefault();
});
$("#daily-price-review-confirm-dialog").addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeDailyPriceReviewCancelConfirm();
});
$("#daily-price-review-confirm-dialog").addEventListener("close", () => {
if (state.dailyPriceReviewCancelTrigger) closeDailyPriceReviewCancelConfirm({ force: true });
});
$("#daily-price-review-confirm-dialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeDailyPriceReviewCancelConfirm();
});
$("#company-review-confirm-cancel").addEventListener("click", () => closeCompanyReviewConfirm());
$("#company-review-confirm-submit").addEventListener("click", confirmCompanyReviewAction);
$("#company-review-confirm-dialog").addEventListener("cancel", (event) => {