(() => { "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 = '
暂无公司销售数据
'; $$(".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) => `| 渠道 | ${heading}合计 | |
|---|---|---|
| 合计 | ${roomTypes.map((room) => `${formatInteger(totals.get(room) || 0)} | `).join("")}${formatInteger(data.overall.totals.rooms_sold)} |
${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 `