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) => {
+81
View File
@@ -118,6 +118,9 @@
"upload.creating_task": ["正在创建任务", "Creating task", "กำลังสร้างงาน"],
"upload.processing_done": ["处理完成", "Processing complete", "ประมวลผลเสร็จแล้ว"],
"upload.processing_failed": ["处理失败", "Processing failed", "ประมวลผลไม่สำเร็จ"],
"upload.waiting_manual_price": ["等待人工定价", "Waiting for manual pricing", "รอกำหนดราคาด้วยตนเอง"],
"upload.daily_generating": ["日报生成中", "Generating daily report", "กำลังสร้างรายงานรายวัน"],
"upload.review_required": ["发现缺少固定价,请完成人工价格复核。", "Some fixed prices are missing. Complete the manual price review.", "พบราคาคงที่ที่ขาดหาย โปรดตรวจสอบราคาด้วยตนเองให้ครบ"],
"upload.success": ["ARR.XML 已处理并完成入库", "ARR.XML was processed and committed.", "ประมวลผลและบันทึก ARR.XML แล้ว"],
"upload.failure_log": ["ARR.XML 处理失败,请查看任务日志", "ARR.XML processing failed. Check the task log.", "การประมวลผล ARR.XML ไม่สำเร็จ โปรดดูบันทึกงาน"],
"upload.service_unready": ["文件接收服务尚未完成生产接线。", "The file intake service is not ready for production.", "บริการรับไฟล์ยังไม่พร้อมใช้งานจริง"],
@@ -140,6 +143,54 @@
"daily.file": ["文件", "File", "ไฟล์"],
"daily.submitted_at": ["提交时间", "Submitted", "เวลาส่ง"],
"daily.output": ["日报", "Daily report", "รายงานรายวัน"],
"status.needs_review": ["待人工处理", "Manual review", "รอตรวจสอบด้วยตนเอง"],
"daily_review.open": ["打开人工价格复核,已填写 {completed} / {total}", "Open manual price review, {completed} of {total} completed", "เปิดการตรวจสอบราคาด้วยตนเอง กรอกแล้ว {completed} / {total}"],
"daily_review.progress_short": ["复核 {completed} / {total}", "Review {completed} / {total}", "ตรวจสอบ {completed} / {total}"],
"daily_review.step": ["价格复核 / 待处理", "PRICE REVIEW / REQUIRED", "ตรวจสอบราคา / ต้องดำเนินการ"],
"daily_review.title": ["人工价格复核", "Manual price review", "ตรวจสอบราคาด้วยตนเอง"],
"daily_review.default_instruction": ["请为所有缺少固定价的价格键填写每晚价格。相同键只需填写一次。", "Enter a nightly price for every price key missing a fixed price. Enter each identical key only once.", "กรอกราคาต่อคืนสำหรับทุกคีย์ราคาที่ไม่มีราคาคงที่ โดยคีย์เดียวกันกรอกเพียงครั้งเดียว"],
"daily_review.processing_instruction": ["正在使用已冻结的价格清单重放原始 XML;此时无法修改。", "The original XML is being replayed with the frozen price list. Prices cannot be changed now.", "กำลังประมวลผล XML ต้นทางซ้ำด้วยรายการราคาที่ตรึงไว้ ขณะนี้ไม่สามารถแก้ไขราคาได้"],
"daily_review.progress_aria": ["人工价格复核进度", "Manual price review progress", "ความคืบหน้าการตรวจสอบราคาด้วยตนเอง"],
"daily_review.completed": ["已填写", "Completed", "กรอกแล้ว"],
"daily_review.pending": ["待处理", "Remaining", "รอดำเนินการ"],
"daily_review.cancel": ["取消本次复核", "Cancel this review", "ยกเลิกการตรวจสอบครั้งนี้"],
"daily_review.finalize": ["确认并生成日报", "Confirm and generate daily report", "ยืนยันและสร้างรายงานรายวัน"],
"daily_review.finalizing": ["正在生成日报…", "Generating daily report…", "กำลังสร้างรายงานรายวัน…"],
"daily_review.retry": ["重试生成日报", "Retry daily report generation", "ลองสร้างรายงานรายวันอีกครั้ง"],
"daily_review.items_aria": ["待人工定价项", "Items awaiting manual pricing", "รายการที่รอกำหนดราคาด้วยตนเอง"],
"daily_review.rate_code": ["Rate Code", "Rate Code", "Rate Code"],
"daily_review.opera_price": ["Opera 价", "Opera price", "ราคา Opera"],
"daily_review.fixed_candidates": ["固定价候选", "Fixed-price candidates", "ตัวเลือกราคาคงที่"],
"daily_review.affected_records": ["影响记录", "Affected records", "รายการที่ได้รับผลกระทบ"],
"daily_review.rooms_nights": ["房数 / 房晚", "Rooms / room nights", "ห้อง / คืนห้องพัก"],
"daily_review.manual_price": ["人工价格", "Manual price", "ราคาที่กำหนดด้วยตนเอง"],
"daily_review.no_candidates": ["暂无同公司 / Rate Code 固定价", "No fixed price for the same company / Rate Code", "ไม่มีราคาคงที่สำหรับบริษัท / Rate Code เดียวกัน"],
"daily_review.price_key": ["价格键", "price key", "คีย์ราคา"],
"daily_review.manual_price_label": ["{company} {rateCode} 的人工价格", "Manual price for {company} {rateCode}", "ราคาที่กำหนดด้วยตนเองสำหรับ {company} {rateCode}"],
"daily_review.saved": ["已暂存", "Saved", "บันทึกแล้ว"],
"daily_review.awaiting_input": ["待填写", "Not entered", "รอกรอก"],
"daily_review.save": ["暂存", "Save", "บันทึก"],
"daily_review.empty": ["没有可显示的待人工定价项。", "No manual-pricing items to display.", "ไม่มีรายการกำหนดราคาด้วยตนเองให้แสดง"],
"daily_review.pagination_aria": ["人工价格复核分页", "Manual price review pagination", "การแบ่งหน้าการตรวจสอบราคาด้วยตนเอง"],
"daily_review.pagination_zero": ["共 0 项", "0 items", "0 รายการ"],
"daily_review.pagination_summary": ["共 {total} 项 · 本页 {start} 至 {end}", "{total} items · Showing {start}–{end}", "{total} รายการ · แสดง {start}–{end}"],
"daily_review.pagination_page": ["第 {page} / {pages} 页", "Page {page} of {pages}", "หน้า {page} จาก {pages}"],
"daily_review.integer_required": ["人工价格必须是非负整数,例如 0。", "The manual price must be a non-negative integer, such as 0.", "ราคาที่กำหนดต้องเป็นจำนวนเต็มไม่ติดลบ เช่น 0"],
"daily_review.saving": ["正在暂存人工价格…", "Saving the manual price…", "กำลังบันทึกราคาที่กำหนด…"],
"daily_review.saved_message": ["人工价格已暂存。", "Manual price saved.", "บันทึกราคาที่กำหนดแล้ว"],
"daily_review.complete_first": ["请先填写全部缺价项后再确认生成。", "Enter every missing price before generating the report.", "กรอกราคาที่ขาดให้ครบก่อนสร้างรายงาน"],
"daily_review.generation_message": ["正在冻结清单、重放原始 XML 并生成日报…", "Freezing the price list, replaying the original XML and generating the daily report…", "กำลังตรึงรายการราคา ประมวลผล XML ต้นทางซ้ำ และสร้างรายงานรายวัน…"],
"daily_review.success": ["人工价格已验收,正式日报已生成。", "The manual prices were validated and the official daily report was generated.", "ตรวจสอบราคาที่กำหนดแล้วและสร้างรายงานรายวันฉบับทางการเรียบร้อย"],
"daily_review.generation_registered": ["冻结清单已登记,正在生成正式日报。", "The frozen price list was recorded and the official daily report is being generated.", "บันทึกรายการราคาที่ตรึงแล้ว และกำลังสร้างรายงานรายวันฉบับทางการ"],
"daily_review.validation_failed": ["正式日报未通过确定性验收,请重新上传原始 XML。", "The official daily report failed deterministic validation. Upload the original XML again.", "รายงานรายวันฉบับทางการไม่ผ่านการตรวจสอบ โปรดอัปโหลด XML ต้นทางอีกครั้ง"],
"daily_review.cancelling": ["正在取消…", "Cancelling…", "กำลังยกเลิก…"],
"daily_review.cancelling_message": ["正在取消本次复核…", "Cancelling this review…", "กำลังยกเลิกการตรวจสอบครั้งนี้…"],
"daily_review.cancelled": ["本次人工价格复核已取消。", "The manual price review was cancelled.", "ยกเลิกการตรวจสอบราคาด้วยตนเองแล้ว"],
"daily_review.cancel_dialog_title": ["取消人工价格复核", "Cancel manual price review", "ยกเลิกการตรวจสอบราคาด้วยตนเอง"],
"daily_review.cancel_dialog_description": ["确认取消本次人工价格复核?", "Cancel this manual price review?", "ยืนยันยกเลิกการตรวจสอบราคาด้วยตนเองครั้งนี้หรือไม่"],
"daily_review.cancel_dialog_note": ["复核记录会永久保留审计痕迹;如需更正,请重新上传原始 XML。", "The review audit trail will be retained permanently. To correct it, upload the original XML again.", "ประวัติการตรวจสอบจะถูกเก็บไว้อย่างถาวร หากต้องการแก้ไข โปรดอัปโหลด XML ต้นทางอีกครั้ง"],
"daily_review.cancel_dialog_back": ["返回复核", "Return to review", "กลับไปตรวจสอบ"],
"daily_review.cancel_dialog_confirm": ["确认取消", "Confirm cancellation", "ยืนยันการยกเลิก"],
"monthly.processing": ["月报处理", "Monthly processing", "ประมวลผลรายงานประจำเดือน"],
"monthly.auto_update": ["自动更新 · 4 秒", "Auto-refresh · 4 sec", "อัปเดตอัตโนมัติ · 4 วินาที"],
"monthly.loading": ["正在读取月报记录…", "Loading monthly report records…", "กำลังโหลดรายการรายงานประจำเดือน…"],
@@ -368,6 +419,7 @@
"error.unrecognized_response": ["服务返回了无法识别的结果", "The service returned an unrecognized response", "บริการส่งผลลัพธ์ที่ไม่รู้จักกลับมา"],
"error.database_unavailable": ["数据库连接失败", "Database connection failed", "เชื่อมต่อฐานข้อมูลไม่สำเร็จ"],
"error.database_read_unavailable": ["数据库读取服务暂不可用", "Database read service is temporarily unavailable", "บริการอ่านฐานข้อมูลไม่พร้อมใช้งานชั่วคราว"],
"error.database_write_failed": ["数据库写入未完成,已保留当前复核清单,可重试生成", "The database write did not complete. The current review manifest was preserved and can be retried.", "การเขียนฐานข้อมูลไม่เสร็จสมบูรณ์ ระบบเก็บรายการตรวจสอบปัจจุบันไว้และสามารถลองสร้างใหม่ได้"],
"error.query_invalid": ["查询参数无效", "Invalid query parameters", "พารามิเตอร์การค้นหาไม่ถูกต้อง"],
"error.auth_required": ["请先登录", "Please log in first", "กรุณาเข้าสู่ระบบก่อน"],
"error.login_failed": ["用户名或密码不正确", "Incorrect username or password", "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"],
@@ -411,6 +463,20 @@
"error.booking_filename": ["请选择 .xlsx 格式的 Excel 文件", "Choose an .xlsx Excel file", "เลือกไฟล์ Excel รูปแบบ .xlsx"],
"error.review_request_invalid": ["人工确认请求无效", "The review request is invalid", "คำขอตรวจสอบไม่ถูกต้อง"],
"error.review_item_invalid": ["待确认记录编号无效", "Invalid review item ID", "รหัสรายการตรวจสอบไม่ถูกต้อง"],
"error.daily_review_request_invalid": ["人工价格复核请求无效", "The manual price review request is invalid", "คำขอตรวจสอบราคาด้วยตนเองไม่ถูกต้อง"],
"error.daily_review_not_found": ["待人工处理任务不存在", "The manual price review task was not found", "ไม่พบงานตรวจสอบราคาด้วยตนเอง"],
"error.daily_review_item_not_found": ["待人工处理项不存在", "The manual pricing item was not found", "ไม่พบรายการกำหนดราคาด้วยตนเอง"],
"error.daily_review_revision_conflict": ["复核清单已被其他会话更新,请刷新后重试", "The review list was updated in another session. Refresh and try again.", "รายการตรวจสอบถูกอัปเดตในเซสชันอื่น โปรดรีเฟรชแล้วลองอีกครั้ง"],
"error.daily_review_immutable": ["复核清单已冻结,不能修改", "The review list is frozen and cannot be changed", "รายการตรวจสอบถูกตรึงแล้วและไม่สามารถแก้ไขได้"],
"error.daily_review_not_open": ["复核任务当前不能确认生成", "This review cannot be finalized in its current state", "ไม่สามารถยืนยันการสร้างจากสถานะการตรวจสอบปัจจุบันได้"],
"error.daily_review_not_cancellable": ["复核任务当前不能取消", "This review cannot be cancelled in its current state", "ไม่สามารถยกเลิกการตรวจสอบในสถานะปัจจุบันได้"],
"error.daily_review_rules_changed": ["处理器或规则已变更,请取消后重新上传", "The processor or rules changed. Cancel this review and upload the original XML again.", "ตัวประมวลผลหรือกฎมีการเปลี่ยนแปลง โปรดยกเลิกการตรวจสอบแล้วอัปโหลด XML ต้นทางอีกครั้ง"],
"error.daily_review_state_invalid": ["人工复核确认状态无效", "The manual review state is invalid", "สถานะการยืนยันการตรวจสอบด้วยตนเองไม่ถูกต้อง"],
"error.daily_review_incomplete": ["请先填写全部缺价项", "Enter every missing price first", "กรอกราคาที่ขาดให้ครบก่อน"],
"error.daily_review_price_invalid": ["人工价格必须是非负整数", "The manual price must be a non-negative integer", "ราคาที่กำหนดต้องเป็นจำนวนเต็มไม่ติดลบ"],
"error.daily_review_page_invalid": ["复核分页参数无效", "The review pagination parameters are invalid", "พารามิเตอร์การแบ่งหน้ารายการตรวจสอบไม่ถูกต้อง"],
"error.daily_review_actor_invalid": ["复核会话用户无效", "The review session user is invalid", "ผู้ใช้ในเซสชันการตรวจสอบไม่ถูกต้อง"],
"error.daily_review_generation_failed": ["正式日报生成暂未完成,可使用同一清单重试", "Daily report generation did not complete. Retry with the same frozen list.", "การสร้างรายงานรายวันยังไม่เสร็จ สามารถลองใหม่ด้วยรายการเดิมที่ตรึงไว้"],
"error.company_request_invalid": ["渠道明细请求字段无效", "Invalid company detail request fields", "ฟิลด์คำขอรายละเอียดบริษัทไม่ถูกต้อง"],
"error.company_source_already_active": ["这份 Excel 已经启用,无需重复提取", "This Excel is already active; no duplicate extraction is needed", "Excel นี้เปิดใช้งานอยู่แล้ว ไม่จำเป็นต้องแยกข้อมูลซ้ำ"],
"error.company_period_invalid": ["期间必须是 01-10、11-20 或 21-month-end", "Period must be 01-10, 11-20 or 21-month-end", "ช่วงเวลาต้องเป็น 01-10, 11-20 หรือ 21-month-end"],
@@ -504,6 +570,7 @@
DATABASE_CONFIG_MISSING: "error.database_read_unavailable",
DATABASE_TARGET_INVALID: "error.database_read_unavailable",
DATABASE_QUERY_FAILED: "error.database_read_unavailable",
DATABASE_WRITE_FAILED: "error.database_write_failed",
DOWNLOAD_NOT_FOUND: "error.download_not_found",
DOWNLOAD_REQUEST_INVALID: "error.download_invalid",
DOWNLOAD_REFERENCE_INVALID: "error.download_invalid",
@@ -534,6 +601,20 @@
BOOKING_EXCEL_FILENAME_INVALID: "error.booking_filename",
BOOKING_EXCEL_REVIEW_REQUEST_INVALID: "error.review_request_invalid",
BOOKING_EXCEL_REVIEW_ITEM_INVALID: "error.review_item_invalid",
REVIEW_REQUEST_INVALID: "error.daily_review_request_invalid",
REVIEW_NOT_FOUND: "error.daily_review_not_found",
REVIEW_ITEM_NOT_FOUND: "error.daily_review_item_not_found",
REVIEW_REVISION_CONFLICT: "error.daily_review_revision_conflict",
REVIEW_IMMUTABLE: "error.daily_review_immutable",
REVIEW_NOT_OPEN: "error.daily_review_not_open",
REVIEW_NOT_CANCELLABLE: "error.daily_review_not_cancellable",
REVIEW_RULESET_CHANGED: "error.daily_review_rules_changed",
REVIEW_STATE_INVALID: "error.daily_review_state_invalid",
REVIEW_INCOMPLETE: "error.daily_review_incomplete",
REVIEW_PRICE_INVALID: "error.daily_review_price_invalid",
REVIEW_PAGE_INVALID: "error.daily_review_page_invalid",
REVIEW_ACTOR_INVALID: "error.daily_review_actor_invalid",
REVIEW_GENERATION_FAILED: "error.daily_review_generation_failed",
COMPANY_REPORT_REQUEST_INVALID: "error.company_request_invalid",
COMPANY_REPORT_PERIOD_INVALID: "error.company_period_invalid",
COMPANY_REPORT_COMPANY_INVALID: "error.invalid_company",
+53
View File
@@ -91,6 +91,44 @@
</article>
</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">
<span class="step-label">价格复核 / 待处理</span>
<h2 id="daily-price-review-title">人工价格复核</h2>
<p id="daily-price-review-copy" role="status">请为所有缺少固定价的价格键填写每晚价格。相同键只需填写一次。</p>
</div>
<dl class="daily-price-review-metrics" aria-label="人工价格复核进度">
<div><dt>已填写</dt><dd id="daily-price-review-completed">0</dd></div>
<div class="is-review"><dt>待处理</dt><dd id="daily-price-review-pending">0</dd></div>
</dl>
</header>
<div class="daily-price-review-toolbar">
<p class="daily-price-review-message" id="daily-price-review-message" role="status"></p>
<div class="daily-price-review-actions">
<button class="daily-price-review-cancel" id="daily-price-review-cancel" type="button">取消本次复核</button>
<button class="daily-price-review-finalize" id="daily-price-review-finalize" type="button" disabled>确认并生成日报</button>
</div>
</div>
<div class="daily-price-review-scroll" role="region" aria-label="待人工定价项" tabindex="0">
<table class="daily-price-review-table">
<thead>
<tr><th scope="col">公司</th><th scope="col">Rate Code</th><th scope="col">Opera 价</th><th scope="col">固定价候选</th><th scope="col">影响记录</th><th scope="col">房数 / 房晚</th><th scope="col">人工价格</th><th scope="col">操作</th></tr>
</thead>
<tbody id="daily-price-review-body"></tbody>
</table>
</div>
<p class="daily-price-review-empty" id="daily-price-review-empty" hidden>没有可显示的待人工定价项。</p>
<nav class="daily-price-review-pagination" aria-label="人工价格复核分页">
<span id="daily-price-review-page-summary">共 0 项</span>
<div>
<button id="daily-price-review-prev" type="button" disabled>上一页</button>
<strong id="daily-price-review-page-label">第 1 / 1 页</strong>
<button id="daily-price-review-next" type="button" disabled>下一页</button>
</div>
</nav>
</section>
<section class="panel table-panel" aria-labelledby="daily-history-title">
<div class="panel-heading table-heading">
<div>
@@ -417,6 +455,21 @@
</section>
</dialog>
<dialog class="daily-price-review-confirm-dialog" id="daily-price-review-confirm-dialog" aria-labelledby="daily-price-review-confirm-title" aria-describedby="daily-price-review-confirm-description daily-price-review-confirm-note">
<div class="daily-price-review-confirm-card">
<div class="daily-price-review-confirm-copy">
<h2 id="daily-price-review-confirm-title">取消人工价格复核</h2>
<p id="daily-price-review-confirm-description">确认取消本次人工价格复核?</p>
<p class="daily-price-review-confirm-note" id="daily-price-review-confirm-note">复核记录会永久保留审计痕迹;如需更正,请重新上传原始 XML。</p>
<p class="daily-price-review-confirm-error" id="daily-price-review-confirm-error" role="alert" hidden></p>
</div>
<div class="daily-price-review-confirm-actions">
<button class="daily-price-review-confirm-cancel" id="daily-price-review-confirm-cancel" type="button">返回复核</button>
<button class="daily-price-review-confirm-submit" id="daily-price-review-confirm-submit" type="button">确认取消</button>
</div>
</div>
</dialog>
<dialog class="company-review-confirm-dialog" id="company-review-confirm-dialog" aria-labelledby="company-review-confirm-title" aria-describedby="company-review-confirm-description company-review-confirm-note">
<div class="company-review-confirm-card">
<div class="company-review-confirm-copy">
+98
View File
@@ -225,6 +225,7 @@ button { color: inherit; }
.trace-live-state { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; font-weight: 700; white-space: nowrap; }
.trace-live-state.is-live, .trace-live-state.is-running { color: #175cd3; }
.trace-live-state.is-complete, .trace-live-state.is-success { color: #087a55; }
.trace-live-state.is-review { color: #9a4b0a; }
.trace-live-state.is-error { color: #b42318; }
.trace-refresh-button { min-height: 30px; padding: 0 9px; border: 1px solid var(--line-strong); border-radius: 4px; color: #344054; background: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; font-weight: 700; cursor: pointer; }
.trace-refresh-button:hover:not(:disabled) { border-color: #98a2b3; background: #f9fafb; }
@@ -306,8 +307,86 @@ tbody tr:hover td { background: #fafcff; }
.status-chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-chip.success { color: #087a55; background: #e9f8f2; }
.status-chip.running { color: #175cd3; background: #eaf1ff; }
.status-chip.review { color: #9a4b0a; background: #fff3e3; }
.status-chip.failed { color: #b42318; background: #feeceb; }
.status-chip.current { box-shadow: inset 0 0 0 1px #9de0c7; }
.job-review-link { display: inline-flex; padding: 0; flex-direction: column; align-items: flex-start; gap: 5px; border: 0; color: #9a4b0a; background: transparent; text-align: left; cursor: pointer; }
.job-review-progress { font-size: 9px; font-weight: 800; text-decoration: underline; text-decoration-color: #efcf8c; text-underline-offset: 3px; }
.job-review-link:hover { color: #7a3b08; }
.job-review-link:hover .job-review-progress { text-decoration-color: currentColor; }
.job-review-link:hover .status-chip { box-shadow: inset 0 0 0 1px #e5ba63; }
.daily-price-review-panel { margin: 0 0 20px; overflow: hidden; scroll-margin-top: 18px; border: 1px solid #f1d49c; border-radius: 16px; background: #fff; box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 14px 32px rgba(146, 93, 13, .08); }
.daily-price-review-panel:focus-visible { outline: 3px solid rgba(168, 90, 10, .2); outline-offset: 3px; }
.daily-price-review-header { padding: 18px 20px 16px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid #f5e4bf; background: linear-gradient(100deg, #fffaf0, #fff); }
.daily-price-review-title-block { min-width: 0; flex: 1 1 auto; display: grid; gap: 5px; }
.daily-price-review-title-block .step-label { color: #a85a0a; }
.daily-price-review-title-block h2 { margin: 0; color: #3c280e; font-size: 18px; letter-spacing: -.025em; }
.daily-price-review-title-block p { margin: 0; color: #7a5a2c; font-size: 11px; line-height: 1.5; }
.daily-price-review-metrics { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(78px, 1fr)); gap: 8px; }
.daily-price-review-metrics > div { min-width: 78px; padding: 8px 10px; border: 1px solid #ead9b4; border-radius: 9px; background: rgba(255, 255, 255, .82); }
.daily-price-review-metrics > div.is-review { border-color: #f0c970; background: #fff7e5; }
.daily-price-review-metrics dt { color: #7a6340; font-size: 9px; font-weight: 750; }
.daily-price-review-metrics dd { margin: 2px 0 0; color: #3c280e; font-size: 17px; font-weight: 800; font-variant-numeric: tabular-nums; }
.daily-price-review-metrics .is-review dd { color: #a85a0a; }
.daily-price-review-toolbar { min-height: 58px; padding: 10px 14px 10px 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); }
.daily-price-review-message { min-width: 0; margin: 0; color: var(--muted); font-size: 10px; font-weight: 650; line-height: 1.45; }
.daily-price-review-message.is-error { color: #b42318; }
.daily-price-review-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
.daily-price-review-cancel, .daily-price-review-finalize { min-height: 34px; padding: 0 12px; border-radius: 8px; font-size: 10px; font-weight: 800; cursor: pointer; transition: border-color .16s ease, color .16s ease, background-color .16s ease, box-shadow .16s ease; }
.daily-price-review-cancel { border: 1px solid #d7dfec; color: #475467; background: #fff; }
.daily-price-review-cancel:hover:not(:disabled) { border-color: #a9b5c7; color: var(--ink); background: #f8fafc; }
.daily-price-review-finalize { border: 1px solid #a85a0a; color: #fff; background: #a85a0a; box-shadow: 0 5px 14px rgba(168, 90, 10, .18); }
.daily-price-review-finalize:hover:not(:disabled) { border-color: #854507; background: #854507; }
.daily-price-review-cancel:disabled, .daily-price-review-finalize:disabled { border-color: #d7dde7; color: #667085; background: #e9edf3; box-shadow: none; cursor: not-allowed; }
.daily-price-review-scroll { max-width: 100%; overflow: auto; }
.daily-price-review-table { width: 100%; min-width: 1080px; border-collapse: collapse; }
.daily-price-review-table th { padding: 10px 12px; color: #735d39; background: #fffaf0; font-size: 9px; font-weight: 800; letter-spacing: .04em; text-align: left; text-transform: uppercase; white-space: nowrap; }
.daily-price-review-table td { padding: 10px 12px; border-top: 1px solid #f0f1f4; color: var(--ink); font-size: 10px; vertical-align: middle; }
.daily-price-review-row:hover td { background: #fffdf8; }
.daily-price-review-row.is-complete td { background: #fbfefc; }
.daily-price-review-row.is-complete:hover td { background: #f5fcf8; }
.daily-price-review-table code { color: #475467; font-size: 10px; font-weight: 750; }
.daily-price-review-candidates { min-width: 180px; white-space: normal; }
.daily-price-review-candidate { display: block; color: #475467; font-size: 9px; line-height: 1.45; white-space: nowrap; }
.daily-price-review-none { color: var(--subtle); font-size: 9px; }
.daily-price-review-input { width: 112px; min-height: 34px; padding: 0 9px; border: 1px solid #cfd7e3; border-radius: 8px; color: var(--ink); background: #fff; font-size: 11px; font-variant-numeric: tabular-nums; }
.daily-price-review-input:focus-visible { border-color: #a85a0a; outline: 3px solid rgba(168, 90, 10, .16); outline-offset: 1px; }
.daily-price-review-input:disabled { color: #667085; background: #f6f7f9; cursor: not-allowed; }
.daily-price-review-row-actions { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
.daily-price-review-status { display: inline-flex; min-height: 24px; padding: 0 8px; align-items: center; border-radius: 99px; font-size: 9px; font-weight: 800; }
.daily-price-review-status.is-pending { color: #9a4b0a; background: #fff1cf; }
.daily-price-review-status.is-complete { color: #087a55; background: #e9f8f2; }
.daily-price-review-save { min-height: 30px; padding: 0 9px; border: 1px solid #d7c39a; border-radius: 7px; color: #82500f; background: #fff; font-size: 9px; font-weight: 800; cursor: pointer; transition: border-color .16s ease, color .16s ease, background-color .16s ease; }
.daily-price-review-save:hover:not(:disabled) { border-color: #c48628; color: #6c3d08; background: #fff9ec; }
.daily-price-review-save:disabled { border-color: #d7dde7; color: #98a2b3; background: #f2f4f7; cursor: not-allowed; }
.daily-price-review-empty { margin: 0; padding: 24px 18px; color: var(--muted); font-size: 11px; text-align: center; }
.daily-price-review-pagination { min-height: 48px; padding: 8px 14px 8px 20px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
.daily-price-review-pagination > div { display: flex; align-items: center; gap: 8px; }
.daily-price-review-pagination button { min-height: 30px; padding: 0 9px; border: 1px solid #d7dfec; border-radius: 7px; color: #475467; background: #fff; font-size: 9px; font-weight: 750; cursor: pointer; }
.daily-price-review-pagination button:hover:not(:disabled) { border-color: #a9b5c7; color: var(--ink); background: #f8fafc; }
.daily-price-review-pagination button:disabled { color: #98a2b3; background: #f2f4f7; cursor: not-allowed; }
.daily-price-review-pagination strong { min-width: 70px; color: #475467; text-align: center; }
.daily-price-review-panel.is-busy { opacity: .74; }
.daily-price-review-panel.is-retryable { border-color: #e5bf6d; }
.daily-price-review-panel.is-processing { border-color: #c9dafa; }
.daily-price-review-cancel:focus-visible, .daily-price-review-finalize:focus-visible, .daily-price-review-save:focus-visible, .daily-price-review-pagination button:focus-visible, .job-review-link:focus-visible { outline: 3px solid rgba(37, 99, 235, .2); outline-offset: 2px; }
.daily-price-review-confirm-dialog { width: min(430px, calc(100% - 32px)); max-width: none; padding: 0; overflow: hidden; border: 1px solid #e0d2b8; border-radius: 14px; color: var(--ink); background: #fff; box-shadow: 0 28px 80px rgba(16, 24, 40, .3); }
.daily-price-review-confirm-dialog::backdrop { background: rgba(15, 23, 42, .48); backdrop-filter: blur(3px); }
.daily-price-review-confirm-dialog[open] { animation: panel-in .16s ease both; }
.daily-price-review-confirm-copy { padding: 22px 22px 18px; }
.daily-price-review-confirm-copy h2 { margin: 0; font-size: 18px; line-height: 1.3; letter-spacing: -.025em; }
.daily-price-review-confirm-copy > p { margin: 10px 0 0; color: #344054; font-size: 13px; line-height: 1.6; }
.daily-price-review-confirm-copy .daily-price-review-confirm-note { margin-top: 8px; color: var(--muted); font-size: 11px; }
.daily-price-review-confirm-copy .daily-price-review-confirm-error { padding: 9px 10px; border-radius: 8px; color: #b42318; background: #feeceb; font-size: 11px; font-weight: 650; }
.daily-price-review-confirm-actions { padding: 14px 18px; display: flex; justify-content: flex-end; gap: 9px; border-top: 1px solid var(--line); background: #fbfcfe; }
.daily-price-review-confirm-actions button { min-height: 38px; padding: 0 15px; border-radius: 9px; font-size: 11px; font-weight: 800; cursor: pointer; }
.daily-price-review-confirm-cancel { border: 1px solid #d3dae6; color: #344054; background: #fff; }
.daily-price-review-confirm-cancel:hover:not(:disabled) { border-color: #98a2b3; background: #f8fafc; }
.daily-price-review-confirm-submit { border: 1px solid #b54708; color: #fff; background: #b54708; box-shadow: 0 5px 14px rgba(181, 71, 8, .16); }
.daily-price-review-confirm-submit:hover:not(:disabled) { border-color: #8f3a07; background: #8f3a07; }
.daily-price-review-confirm-actions button:disabled { opacity: .58; cursor: wait; box-shadow: none; }
.daily-price-review-confirm-actions button:focus-visible { outline: 3px solid rgba(37, 99, 235, .2); outline-offset: 2px; }
.split-heading { align-items: center; }
.report-controls { display: flex; align-items: flex-end; gap: 10px; }
@@ -689,6 +768,25 @@ input:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgb
.company-review-pagination { align-items: stretch; flex-direction: column; }
.company-review-pagination > div { justify-content: space-between; }
.company-review-confirm-dialog { width: calc(100% - 20px); }
.daily-price-review-header { align-items: stretch; flex-direction: column; gap: 12px; }
.daily-price-review-metrics { width: 100%; }
.daily-price-review-toolbar { align-items: stretch; flex-direction: column; }
.daily-price-review-actions { display: grid; grid-template-columns: 1fr 1fr; }
.daily-price-review-scroll { overflow: visible; }
.daily-price-review-table, .daily-price-review-table tbody { min-width: 0; display: block; }
.daily-price-review-table thead { display: none; }
.daily-price-review-table tbody { padding: 10px; display: grid; gap: 10px; }
.daily-price-review-row { padding: 10px 12px; display: grid; border: 1px solid #ecd8ac; border-radius: 10px; background: #fff; }
.daily-price-review-row.is-complete { border-color: #b6e1cd; }
.daily-price-review-table .daily-price-review-row td { min-width: 0; padding: 7px 0; display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 10px; border: 0; border-bottom: 1px solid #edf0f4; background: transparent; white-space: normal; }
.daily-price-review-table .daily-price-review-row td::before { content: attr(data-label); color: var(--muted); font-size: 9px; font-weight: 750; }
.daily-price-review-table .daily-price-review-row td:last-child { border-bottom: 0; }
.daily-price-review-candidates { min-width: 0; }
.daily-price-review-input { width: 100%; }
.daily-price-review-row-actions { justify-content: flex-end; }
.daily-price-review-pagination { align-items: stretch; flex-direction: column; }
.daily-price-review-pagination > div { justify-content: space-between; }
.daily-price-review-confirm-dialog { width: calc(100% - 20px); }
.company-setup-heading { align-items: center; }
.company-month-field { width: min(180px, 52vw); }
.company-report-card-row { grid-template-columns: 1fr; gap: 14px; padding-top: 16px; padding-bottom: 18px; }