(() => { "use strict"; const state = { csrf: "", selectedFile: null, health: { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false }, analytics: null, toastTimer: null, companyReportJobs: [], companyReportCurrentJob: null, companyReportPollTimer: null, companyReportSubmittingPeriod: "", companyReportsReady: false, }; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"]; const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"]; const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const jobStatus = { uploaded: ["已上传", "running"], queued: ["等待处理", "running"], running: ["处理中", "running"], delivered: ["等待验收", "running"], validating: ["正在验收", "running"], succeeded: ["已完成", "success"], failed: ["处理失败", "failed"], cancelled: ["已取消", "failed"], }; const reportStatus = { generating: ["生成中", "running"], validated: ["已验收", "running"], active: ["当前版本", "success current"], superseded: ["历史版本", ""], failed: ["生成失败", "failed"], }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function bangkokDateParts() { return Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(new Date()).map((part) => [part.type, part.value])); } function localMonth() { const parts = bangkokDateParts(); return `${parts.year}-${parts.month}`; } function localDate() { const parts = bangkokDateParts(); return `${parts.year}-${parts.month}-${parts.day}`; } function showToast(message, isError = false) { const toast = $("#toast"); toast.textContent = message; toast.classList.toggle("is-error", isError); toast.classList.add("is-visible"); window.clearTimeout(state.toastTimer); state.toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), 3600); } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", state.csrf); const response = await fetch(path, { ...options, headers, credentials: "same-origin" }); let payload; try { payload = await response.json(); } catch (_) { throw new Error("服务返回了无法识别的结果"); } if (!response.ok || !payload.ok) { throw new Error(payload?.error?.message || "请求未完成"); } return payload.data; } function formatInteger(value) { return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0)); } function formatMoney(value, compact = false) { const amount = Number(value || 0); if (compact && Math.abs(amount) >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`; return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount); } function formatDate(value, withTime = false) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return escapeHtml(value); return new Intl.DateTimeFormat("zh-CN", withTime ? { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" } : { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit" } ).format(date); } function duration(job) { if (!job.created_at || !(job.finished_at || job.updated_at)) return "—"; const seconds = Math.max(0, Math.round((new Date(job.finished_at || job.updated_at) - new Date(job.created_at)) / 1000)); if (!Number.isFinite(seconds)) return "—"; if (seconds < 60) return `${seconds} 秒`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return `${minutes} 分 ${rest} 秒`; } function chip(status, map) { const [label, style] = map[status] || [status || "未知", ""]; return `${escapeHtml(label)}`; } function activateTab(name, updateHash = true) { const valid = ["daily", "monthly", "bi", "usage"].includes(name) ? name : "daily"; $$("[data-tab]").forEach((tab) => { const active = tab.dataset.tab === valid; tab.classList.toggle("is-active", active); tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; }); $$("[data-panel]").forEach((panel) => { const active = panel.dataset.panel === valid; panel.hidden = !active; panel.classList.toggle("is-active", active); }); if (updateHash) history.replaceState(null, "", `#${valid}`); if (valid === "bi" && !state.analytics) loadAnalytics(); if (valid === "usage" && state.csrf) loadCompanyReportHistory(true); } async function initSession() { const session = await api("/api/session"); state.csrf = session.csrf_token; } async function loadHealth() { try { state.health = await api("/api/health"); } catch (_) { state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false }; } const node = $("#system-status"); node.classList.toggle("is-ready", state.health.database_ready); node.classList.toggle("is-error", !state.health.database_ready); $("span", node).textContent = state.health.database_ready ? "数据库已连接" : "数据服务未连接"; const fileInput = $("#xml-file"); fileInput.disabled = !state.health.processing_ready; $("#dropzone").classList.toggle("is-disabled", !state.health.processing_ready); $("#upload-hint").textContent = state.health.processing_ready ? "" : "文件接收服务尚未完成生产接线。"; $("#generate-monthly").disabled = !state.health.monthly_ready; if (!state.health.monthly_ready) $("#generate-monthly").title = "月报生成服务尚未接线"; state.companyReportsReady = Boolean(state.health.company_reports_ready); $("#company-report-unavailable").hidden = state.companyReportsReady; updateCompanyReportControls(); } function renderJobs(jobs) { const body = $("#jobs-body"); if (!jobs.length) { body.innerHTML = '本月暂无日报处理记录'; $("#metric-arrival").textContent = "—"; $("#metric-duration").textContent = "—"; $("#metric-rooms").textContent = "—"; return; } body.innerHTML = jobs.map((job) => ` ${escapeHtml(job.filename)} ${chip(job.status, jobStatus)} ${escapeHtml(job.arrival_date || "—")} ${formatInteger(job.no_of_rooms)} ${duration(job)} ${formatDate(job.created_at, true)} ${state.health.download_ready && job.daily_report_sha256 ? `下载` : ''} `).join(""); const latest = jobs.find((job) => job.status === "succeeded"); $("#metric-arrival").textContent = latest?.arrival_date || "—"; $("#metric-duration").textContent = latest ? duration(latest) : "—"; $("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—"; } async function loadJobs(showErrors = false) { try { renderJobs(await api(`/api/jobs?month=${encodeURIComponent(localMonth())}`)); } catch (error) { $("#jobs-body").innerHTML = '日报记录暂时无法读取'; if (showErrors) showToast(error.message, true); } } function renderMonthly(runs) { const body = $("#monthly-body"); if (!runs.length) { body.innerHTML = '该月份暂无月报处理记录'; return; } body.innerHTML = runs.map((run) => ` V${String(run.version_no).padStart(2, "0")} ${escapeHtml(run.as_of_date || "—")} ${chip(run.status, reportStatus)} ${formatInteger(run.row_count)} ${formatInteger(run.channel_count)} ${formatDate(run.updated_at, true)} ${state.health.download_ready && run.artifact_sha256 ? `下载` : ''} `).join(""); } async function loadMonthly(showErrors = false) { const month = $("#monthly-month").value || localMonth(); try { renderMonthly(await api(`/api/monthly-runs?month=${encodeURIComponent(month)}`)); } catch (error) { $("#monthly-body").innerHTML = '月报记录暂时无法读取'; if (showErrors) showToast(error.message, true); } } function populateMonths(months) { const select = $("#bi-month"); const current = select.value; if (!months.length) { select.innerHTML = ``; return; } select.innerHTML = months.map((item) => ``).join(""); if (months.some((item) => item.month_key === current)) select.value = current; } function renderCompanySales(channels) { const sorted = [...channels].sort((a, b) => Number(b.totals.total_price) - Number(a.totals.total_price)); const maximum = Math.max(1, ...sorted.map((item) => Number(item.totals.total_price || 0))); $("#company-ranking").innerHTML = sorted.length ? sorted.map((item) => `
${escapeHtml(item.worksheet)} ${formatMoney(item.totals.total_price, true)}
`).join("") : '

暂无公司销售数据

'; $$(".rank-track i", $("#company-ranking")).forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; }); const total = sorted.reduce((sum, item) => sum + Number(item.totals.total_price || 0), 0); let cursor = 0; const stops = sorted.map((item, index) => { const start = cursor; cursor += total > 0 ? Number(item.totals.total_price || 0) / total * 100 : 0; return `${colors[index % colors.length]} ${start.toFixed(3)}% ${cursor.toFixed(3)}%`; }); $("#company-donut").style.background = total > 0 ? `conic-gradient(${stops.join(",")})` : "#d8e0ec"; $("#donut-total").textContent = formatMoney(total, true); $("#donut-legend").innerHTML = sorted.map((item, index) => ` ${escapeHtml(item.worksheet)}`).join(""); $$(".legend-item i", $("#donut-legend")).forEach((marker) => { marker.style.backgroundColor = marker.dataset.color; }); } function renderMatrix(data) { const roomTypes = data.overall.room_types.map((item) => item.room_type); if (!roomTypes.length || !data.channels.length) { $("#room-matrix").innerHTML = '

暂无渠道与房型数据

'; return; } const heading = roomTypes.map((room) => `${escapeHtml(room)}`).join(""); const rows = data.channels.map((channel) => { const values = new Map(channel.room_types.map((room) => [room.room_type, room.rooms_sold])); return `${escapeHtml(channel.worksheet)}${roomTypes.map((room) => `${formatInteger(values.get(room) || 0)}`).join("")}${formatInteger(channel.totals.rooms_sold)}`; }).join(""); const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold])); $("#room-matrix").innerHTML = `${heading}${rows}${roomTypes.map((room) => ``).join("")}
渠道合计
合计${formatInteger(totals.get(room) || 0)}${formatInteger(data.overall.totals.rooms_sold)}
`; } function renderAnalytics(data) { state.analytics = data; const totals = data.overall.totals; $("#bi-rooms").textContent = formatInteger(totals.rooms_sold); $("#bi-revenue").textContent = formatMoney(totals.total_price, true); $("#bi-nights").textContent = formatInteger(totals.room_nights); $("#bi-channels").textContent = formatInteger(totals.channel_count); $("#bi-subtitle").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`; renderCompanySales(data.channels); renderMatrix(data); } function resetAnalytics(message) { ["#bi-rooms", "#bi-revenue", "#bi-nights", "#bi-channels", "#donut-total"].forEach((id) => $(id).textContent = "—"); $("#company-ranking").innerHTML = `

${escapeHtml(message)}

`; $("#donut-legend").innerHTML = ""; $("#company-donut").style.background = "#d8e0ec"; $("#room-matrix").innerHTML = `

${escapeHtml(message)}

`; } async function loadAnalytics(showErrors = false) { const month = $("#bi-month").value || localMonth(); try { renderAnalytics(await api(`/api/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { resetAnalytics("该月份暂无可用看板数据"); if (showErrors) showToast(error.message, true); } } async function handleUpload() { if (!state.selectedFile || !state.health.processing_ready) return; const button = $("#upload-button"); button.disabled = true; $("#process-state").className = "process-state is-running"; $("#process-copy").textContent = "正在处理ARR.XML文件"; $("#process-detail").textContent = "正在安全上传并创建处理任务。"; try { const filenameBytes = new TextEncoder().encode(state.selectedFile.name); let binary = ""; filenameBytes.forEach((byte) => { binary += String.fromCharCode(byte); }); const encoded = btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); const receipt = await api("/api/jobs", { method: "POST", headers: { "Content-Type": "application/xml", "X-ARR-Filename-B64": encoded }, body: await state.selectedFile.arrayBuffer(), }); $("#process-state").className = "process-state is-success"; $("#process-copy").textContent = "文件已进入处理队列"; $("#process-detail").textContent = receipt.job_id ? `任务 ${receipt.job_id}` : "处理状态将自动更新。"; state.selectedFile = null; $("#xml-file").value = ""; $("#selected-file").textContent = "尚未选择文件"; showToast("ARR.XML 已提交"); await loadJobs(); } catch (error) { $("#process-state").className = "process-state is-error"; $("#process-copy").textContent = "文件未能提交"; $("#process-detail").textContent = error.message; showToast(error.message, true); } finally { button.disabled = !state.selectedFile || !state.health.processing_ready; } } async function generateMonthly() { const button = $("#generate-monthly"); if (!state.health.monthly_ready) return; button.disabled = true; button.textContent = "正在生成…"; try { await api("/api/monthly-runs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ month: $("#monthly-month").value, as_of_date: $("#monthly-as-of").value }), }); showToast("标准月报已生成并留存"); await Promise.all([loadMonthly(), loadMonthsAndAnalytics()]); } catch (error) { showToast(error.message, true); } finally { button.disabled = !state.health.monthly_ready; button.textContent = "生成标准月报"; } } async function loadMonthsAndAnalytics() { try { const months = await api("/api/months"); populateMonths(months); if (months.length) $("#bi-month").value = months[0].month_key; } catch (_) { populateMonths([]); } await loadAnalytics(); } function companyMonthEndDay(reportMonth) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return null; return new Date(Date.UTC(Number(match[1]), Number(match[2]), 0)).getUTCDate(); } function companyPeriodRange(reportMonth, period) { if (period === "01-10") return "01–10"; if (period === "11-20") return "11–20"; const monthEnd = companyMonthEndDay(reportMonth); return monthEnd ? `21–${monthEnd}` : "21–月末"; } function companyPeriodCountKey(reportMonth, period) { if (period !== "21-month-end") return period; const monthEnd = companyMonthEndDay(reportMonth); return monthEnd ? `21-${monthEnd}` : ""; } function companyPeriodReleaseDate(reportMonth, period) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return ""; if (period === "01-10") return `${reportMonth}-11`; if (period === "11-20") return `${reportMonth}-21`; if (period !== "21-month-end") return ""; let year = Number(match[1]); let month = Number(match[2]) + 1; if (month === 13) { year += 1; month = 1; } return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`; } function companyPeriodIncluded(cutoff, period) { return COMPANY_REPORT_PERIODS.indexOf(period) <= COMPANY_REPORT_PERIODS.indexOf(cutoff); } function monthLabel(value) { const match = /^(\d{4})-(\d{2})$/.exec(value || ""); return match ? `${match[1]}年${match[2]}月` : "—"; } function formatDurationSeconds(value) { const seconds = Number(value); if (!Number.isFinite(seconds)) return "—"; if (seconds < 60) return `${Math.max(1, Math.round(seconds))} 秒`; return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`; } function isCompanyReportActive(job = state.companyReportCurrentJob) { return Boolean(job && ["queued", "running"].includes(job.state)); } function setCompanyReportError(message = "") { const node = $("#company-report-error"); node.textContent = message; node.hidden = !message; } function updateCompanyReportControls() { const reportMonth = $("#company-report-month").value || ""; const monthValid = /^\d{4}-(0[1-9]|1[0-2])$/.test(reportMonth); const active = isCompanyReportActive() || Boolean(state.companyReportSubmittingPeriod); const today = localDate(); const locked = []; $("#company-report-month").disabled = active; $$('[data-company-period]').forEach((button) => { const period = button.dataset.companyPeriod; const releaseDate = companyPeriodReleaseDate(reportMonth, period); const periodReady = Boolean(releaseDate) && today >= releaseDate; const range = companyPeriodRange(reportMonth, period); const rangeNode = $('[data-company-period-range]', button); const releaseNode = $('[data-company-period-release]', button); const coNode = $('[data-company-period-co]', button); const statusNode = $('[data-company-period-state]', button); if (rangeNode) rangeNode.textContent = range; if (releaseNode) releaseNode.textContent = releaseDate ? `${releaseDate} 00:00 开放` : "选择月份后显示开放时间"; if (coNode) coNode.textContent = `C/O:${range}`; statusNode.className = "company-period-state"; if (!monthValid) { statusNode.textContent = "选择月份"; } else if (!periodReady) { statusNode.classList.add("is-locked"); statusNode.textContent = "待开放"; locked.push({ period, releaseDate }); } else if (!state.companyReportsReady) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "服务未就绪"; } else { statusNode.classList.add("is-open"); statusNode.textContent = "已开放"; } button.disabled = active || !state.companyReportsReady || !monthValid || !periodReady; button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period)); if (!monthValid) button.title = "请先选择有效的报表月份"; else if (!periodReady) button.title = `按规则从曼谷时间 ${releaseDate} 00:00 起可生成`; else if (!state.companyReportsReady) button.title = "公司渠道明细服务尚未就绪"; else button.removeAttribute("title"); }); const availability = $("#company-period-availability"); availability.classList.toggle("has-locked-periods", locked.length > 0); if (!monthValid) { availability.textContent = "请选择有效月份,系统会按泰国曼谷时间核对期间是否已经闭合。"; } else if (locked.length) { availability.textContent = `尚未开放:${locked.map((item) => `${companyPeriodRange(reportMonth, item.period)}(曼谷时间 ${item.releaseDate} 00:00 起)`).join("、")}。`; } else { availability.textContent = "所选月份的三个期间均已开放(泰国曼谷时间),可以生成或重跑。"; } } function companyStateLabel(value) { return { queued: "已排队", running: "生成中", succeeded: "全部成功", partial_failure: "部分成功", failed: "未完成", }[value] || "未知"; } function companyStateStyle(value) { if (value === "succeeded") return "success"; if (value === "partial_failure") return "review"; if (value === "failed") return "failed"; return "running"; } function companyCutoffLabel(job) { if (job.period === "01-10") return "截至 10 日"; if (job.period === "11-20") return "截至 20 日"; return "截至月末"; } function companyProblemLabel(code) { return { COMPANY_REPORT_MULTI_PRICE_REVIEW: "同房型存在不同静态价格,需人工复核", COMPANY_REPORT_SOURCE_VERSION_MISSING: "数据源版本缺失", COMPANY_REPORT_GROUP_CODE_MISSING: "Group Code 缺失", COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "Group Code 未找到", COMPANY_REPORT_BOOKING_PARSE_FAILED: "预订解析异常", COMPANY_REPORT_ROOM_ITEMS_MISSING: "房型明细缺失", COMPANY_REPORT_STAY_DATE_INVALID: "住宿日期异常", COMPANY_REPORT_NIGHTS_CONFLICT: "间夜数据冲突", COMPANY_REPORT_TOTAL_PRICE_INVALID: "静态价格异常", COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "Excel 输出校验失败", COMPANY_REPORT_PUBLISH_FAILED: "正式表格发布失败", COMPANY_REPORT_INTERNAL_ERROR: "处理服务异常", COMPANY_REPORT_EXECUTION_FAILED: "任务执行未完成", COMPANY_REPORT_JOB_INTERRUPTED: "服务重启中断任务", }[code] || "需复核处理结果"; } function companySuccessCount(job) { return (job.company_results || []).filter((item) => item.status === "success").length; } function companyWarningCount(job) { return (job.company_results || []).reduce((sum, item) => sum + (item.warnings || []).length, 0); } function companyPeriodCell(job, result, period) { const range = companyPeriodRange(job.report_month, period); if (!companyPeriodIncluded(job.period, period)) { return `待生成`; } if (!result) { return `${isCompanyReportActive(job) ? "等待生成" : "无结果"}`; } if (result.status === "failed") { return `未生成`; } const countKey = companyPeriodCountKey(job.report_month, period); const count = Object.prototype.hasOwnProperty.call(result.period_row_counts || {}, countKey) ? result.period_row_counts[countKey] : null; return `${count === null ? "未记录" : `✓ ${formatInteger(count)} 条`}`; } function renderCompanyResultRows(job) { const byCompany = new Map((job.company_results || []).map((item) => [item.company, item])); $("#company-report-result-rows").innerHTML = COMPANY_REPORT_NAMES.map((company) => { const result = byCompany.get(company); const warnings = result?.warnings || []; const errors = result?.errors || []; const rowStyle = result?.status === "failed" ? "is-failed" : warnings.length ? "is-review" : ""; let resultStatus = isCompanyReportActive(job) ? "等待生成" : "无结果"; let resultStyle = isCompanyReportActive(job) ? "running" : "failed"; if (result?.status === "success") { resultStatus = warnings.length ? "成功 · 待复核" : "成功"; resultStyle = warnings.length ? "review" : "success"; } else if (result?.status === "failed") { resultStatus = "失败"; resultStyle = "failed"; } const problems = [...warnings.map((item) => ({ ...item, kind: "warning" })), ...errors.map((item) => ({ ...item, kind: "error" }))]; const problemHtml = problems.length ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "—")}`).join("") : ''; const download = result?.download_url?.startsWith("/api/company-reports/") ? `下载` : `${isCompanyReportActive(job) ? "待生成" : "—"}`; return ` ${escapeHtml(company)} ${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")} ${result ? formatInteger(result.row_count) : "—"} ${escapeHtml(resultStatus)}
${problemHtml}
${Number.isInteger(result?.version_no) ? `v${result.version_no}` : "—"} ${download} `; }).join(""); } function renderCompanyReportJob(job) { state.companyReportCurrentJob = job; $("#company-report-job-panel").hidden = false; const terminal = ["succeeded", "partial_failure", "failed"].includes(job.state); $("#company-report-job-kicker").textContent = terminal ? "TASK FINISHED" : "TASK STATUS"; $("#company-report-job-title").textContent = `${monthLabel(job.report_month)} · ${companyCutoffLabel(job)}`; $("#company-report-job-message").textContent = job.message || "正在读取任务状态。"; const status = $("#company-report-job-state"); status.className = `status-chip ${companyStateStyle(job.state)}`; status.textContent = companyStateLabel(job.state); $("#company-report-success-count").textContent = `${companySuccessCount(job)} / 5`; const percent = Math.max(0, Math.min(100, Number(job.progress?.percent) || 0)); $("#company-report-progress-label").textContent = job.progress?.label || "等待生成"; $("#company-report-progress-percent").textContent = `${percent}%`; $("#company-report-progress-track").setAttribute("aria-valuenow", String(percent)); $("#company-report-progress-bar").style.transform = `scaleX(${percent / 100})`; $("#company-report-job-month").textContent = monthLabel(job.report_month); $("#company-report-job-as-of").textContent = job.as_of_date || "—"; $("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds); $("#company-report-warning-count").textContent = `${companyWarningCount(job)} 项`; $("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end"); renderCompanyResultRows(job); localStorage.setItem("arr:last-company-report-job", job.job_id); updateCompanyReportControls(); } function renderCompanyReportHistory(jobs) { state.companyReportJobs = jobs; $("#company-history-count").textContent = `${jobs.length} 条记录`; const body = $("#company-history-rows"); if (!jobs.length) { body.innerHTML = '该月份还没有公司渠道明细任务'; return; } body.innerHTML = jobs.map((job) => ` ${formatDate(job.created_at, true)} ${escapeHtml(job.report_month || "—")} ${escapeHtml(companyCutoffLabel(job))} ${escapeHtml(companyStateLabel(job.state))} ${companySuccessCount(job)} / 5 ${companyWarningCount(job)} `).join(""); $$('[data-company-job-id]', body).forEach((button) => button.addEventListener("click", () => loadCompanyReportJob(button.dataset.companyJobId, true))); } async function loadCompanyReportHistory(restore = false) { if (!state.companyReportsReady) { renderCompanyReportHistory([]); return; } const month = $("#company-report-month").value || localMonth(); try { const jobs = await api(`/api/company-reports/jobs?month=${encodeURIComponent(month)}&limit=100`); renderCompanyReportHistory(jobs); if (restore) { const remembered = localStorage.getItem("arr:last-company-report-job"); const target = jobs.find((job) => job.job_id === remembered) || jobs.find((job) => ["queued", "running"].includes(job.state)); if (target) await loadCompanyReportJob(target.job_id); } } catch (error) { $("#company-history-rows").innerHTML = '任务记录暂时无法读取'; if (restore) showToast(error.message, true); } } async function loadCompanyReportJob(jobId, scroll = false) { if (state.companyReportPollTimer) window.clearTimeout(state.companyReportPollTimer); try { const job = await api(`/api/company-reports/jobs/${encodeURIComponent(jobId)}`); renderCompanyReportJob(job); if (scroll) $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); if (isCompanyReportActive(job)) { state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 1200); } else { await loadCompanyReportHistory(false); } } catch (error) { showToast(error.message, true); state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 2500); } } async function startCompanyReport(period) { if (!state.companyReportsReady || isCompanyReportActive() || state.companyReportSubmittingPeriod) return; const reportMonth = $("#company-report-month").value; if (!reportMonth) return; const confirmed = window.confirm(`${monthLabel(reportMonth)} ${companyPeriodRange(reportMonth, period)}:将一次生成五家公司的正式 Excel,是否继续?`); if (!confirmed) return; setCompanyReportError(); state.companyReportSubmittingPeriod = period; updateCompanyReportControls(); try { const created = await api("/api/company-reports/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ report_month: reportMonth, period }), }); localStorage.setItem("arr:last-company-report-job", created.job_id); renderCompanyReportJob(created); $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); showToast("五家公司渠道明细任务已提交"); await loadCompanyReportJob(created.job_id); } catch (error) { setCompanyReportError(error.message); showToast(error.message, true); } finally { state.companyReportSubmittingPeriod = ""; updateCompanyReportControls(); } } async function refreshCompanyReports() { setCompanyReportError(); await loadHealth(); await loadCompanyReportHistory(true); showToast("公司渠道明细任务已刷新"); } function bindEvents() { $$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab))); $("#xml-file").addEventListener("change", (event) => { state.selectedFile = event.target.files?.[0] || null; $("#selected-file").textContent = state.selectedFile ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` : "尚未选择文件"; $("#upload-button").disabled = !state.selectedFile || !state.health.processing_ready; }); const dropzone = $("#dropzone"); ["dragenter", "dragover"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); if (state.health.processing_ready) dropzone.classList.add("is-over"); })); ["dragleave", "drop"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); dropzone.classList.remove("is-over"); })); dropzone.addEventListener("drop", (event) => { if (!state.health.processing_ready) return; const file = event.dataTransfer?.files?.[0]; if (!file) return; state.selectedFile = file; $("#selected-file").textContent = `${file.name} · ${formatInteger(file.size / 1024)} KB`; $("#upload-button").disabled = false; }); $("#upload-button").addEventListener("click", handleUpload); $("#refresh-jobs").addEventListener("click", () => loadJobs(true)); $("#refresh-monthly").addEventListener("click", () => loadMonthly(true)); $("#monthly-month").addEventListener("change", () => loadMonthly(true)); $("#generate-monthly").addEventListener("click", generateMonthly); $("#bi-month").addEventListener("change", () => loadAnalytics(true)); $("#refresh-company-reports").addEventListener("click", refreshCompanyReports); $("#company-report-month").addEventListener("change", async () => { state.companyReportCurrentJob = null; $("#company-report-job-panel").hidden = true; updateCompanyReportControls(); await loadCompanyReportHistory(false); }); $$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod))); window.addEventListener("hashchange", () => activateTab(location.hash.slice(1), false)); } async function boot() { $("#monthly-month").value = localMonth(); $("#monthly-as-of").value = localDate(); $("#company-report-month").value = localMonth(); populateMonths([]); bindEvents(); activateTab(location.hash.slice(1) || "daily", false); try { await initSession(); } catch (_) { showToast("页面会话初始化失败", true); } await loadHealth(); await Promise.all([loadJobs(), loadMonthly(), loadMonthsAndAnalytics(), loadCompanyReportHistory(location.hash.slice(1) === "usage")]); } boot(); })();