feat: add daily manual price review workflow
This commit is contained in:
1 parent
aae3d8e1db
commit
ca3e8e18fa
77 files changed
+7750
-602
No files matched your search
+450
-6
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user