Files
wyndham-ARR/arr_web/static/app.js
2026-07-29 16:38:05 +08:00

776 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 `<span class="status-chip ${escapeHtml(style)}">${escapeHtml(label)}</span>`;
}
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 = '<tr><td class="empty-cell" colspan="7">本月暂无日报处理记录</td></tr>';
$("#metric-arrival").textContent = "—";
$("#metric-duration").textContent = "—";
$("#metric-rooms").textContent = "—";
return;
}
body.innerHTML = jobs.map((job) => `
<tr>
<td><span class="filename" title="${escapeHtml(job.filename)}">${escapeHtml(job.filename)}</span></td>
<td>${chip(job.status, jobStatus)}</td>
<td>${escapeHtml(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)}">下载</a>` : '<span class="cell-subtle">—</span>'}</td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="7">日报记录暂时无法读取</td></tr>';
if (showErrors) showToast(error.message, true);
}
}
function renderMonthly(runs) {
const body = $("#monthly-body");
if (!runs.length) {
body.innerHTML = '<tr><td class="empty-cell" colspan="7">该月份暂无月报处理记录</td></tr>';
return;
}
body.innerHTML = runs.map((run) => `
<tr>
<td><strong>V${String(run.version_no).padStart(2, "0")}</strong></td>
<td>${escapeHtml(run.as_of_date || "—")}</td>
<td>${chip(run.status, reportStatus)}</td>
<td>${formatInteger(run.row_count)}</td>
<td>${formatInteger(run.channel_count)}</td>
<td class="cell-subtle">${formatDate(run.updated_at, true)}</td>
<td>${state.health.download_ready && run.artifact_sha256 ? `<a class="download-link" href="/api/download/monthly?report_id=${encodeURIComponent(run.report_id)}">下载</a>` : '<span class="cell-subtle">—</span>'}</td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="7">月报记录暂时无法读取</td></tr>';
if (showErrors) showToast(error.message, true);
}
}
function populateMonths(months) {
const select = $("#bi-month");
const current = select.value;
if (!months.length) {
select.innerHTML = `<option value="${localMonth()}">${localMonth()}</option>`;
return;
}
select.innerHTML = months.map((item) => `<option value="${escapeHtml(item.month_key)}">${escapeHtml(item.month_key)}</option>`).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) => `
<div class="rank-row">
<span class="rank-label" title="${escapeHtml(item.worksheet)}">${escapeHtml(item.worksheet)}</span>
<span class="rank-track"><i data-width="${Math.max(0, Number(item.totals.total_price || 0)) / maximum * 100}"></i></span>
<span class="rank-value">${formatMoney(item.totals.total_price, true)}</span>
</div>`).join("") : '<p class="empty-state">暂无公司销售数据</p>';
$$(".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) => `
<span class="legend-item"><i data-color="${colors[index % colors.length]}"></i><span title="${escapeHtml(item.worksheet)}">${escapeHtml(item.worksheet)}</span></span>`).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 = '<p class="empty-state">暂无渠道与房型数据</p>';
return;
}
const heading = roomTypes.map((room) => `<th>${escapeHtml(room)}</th>`).join("");
const rows = data.channels.map((channel) => {
const values = new Map(channel.room_types.map((room) => [room.room_type, room.rooms_sold]));
return `<tr><td><strong>${escapeHtml(channel.worksheet)}</strong></td>${roomTypes.map((room) => `<td>${formatInteger(values.get(room) || 0)}</td>`).join("")}<td class="matrix-total">${formatInteger(channel.totals.rooms_sold)}</td></tr>`;
}).join("");
const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold]));
$("#room-matrix").innerHTML = `<table><thead><tr><th>渠道</th>${heading}<th>合计</th></tr></thead><tbody>${rows}<tr><td class="matrix-total">合计</td>${roomTypes.map((room) => `<td class="matrix-total">${formatInteger(totals.get(room) || 0)}</td>`).join("")}<td class="matrix-total">${formatInteger(data.overall.totals.rooms_sold)}</td></tr></tbody></table>`;
}
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 = `<p class="empty-state">${escapeHtml(message)}</p>`;
$("#donut-legend").innerHTML = "";
$("#company-donut").style.background = "#d8e0ec";
$("#room-matrix").innerHTML = `<p class="empty-state">${escapeHtml(message)}</p>`;
}
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 "0110";
if (period === "11-20") return "1120";
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 `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result is-pending">待生成</span></td>`;
}
if (!result) {
return `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result ${isCompanyReportActive(job) ? "is-waiting" : "is-empty"}">${isCompanyReportActive(job) ? "等待生成" : "无结果"}</span></td>`;
}
if (result.status === "failed") {
return `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result is-failed">未生成</span></td>`;
}
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 `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result ${count === null ? "is-empty" : "is-complete"}">${count === null ? "未记录" : `${formatInteger(count)}`}</span></td>`;
}
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) => `<span class="company-problem is-${item.kind}">${escapeHtml(companyProblemLabel(item.code))}<small>${escapeHtml(item.period || "—")}</small></span>`).join("")
: '<span class="cell-subtle">—</span>';
const download = result?.download_url?.startsWith("/api/company-reports/")
? `<a class="download-link" href="${escapeHtml(result.download_url)}">下载</a>`
: `<span class="cell-subtle">${isCompanyReportActive(job) ? "待生成" : "—"}</span>`;
return `<tr class="company-result-row ${rowStyle}">
<td class="company-name-cell" data-label="公司">${escapeHtml(company)}</td>
${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")}
<td data-label="合计">${result ? formatInteger(result.row_count) : "—"}</td>
<td data-label="状态"><span class="status-chip ${resultStyle}">${escapeHtml(resultStatus)}</span></td>
<td data-label="复核 / 异常"><div class="company-problem-list">${problemHtml}</div></td>
<td class="company-version-cell" data-label="版本">${Number.isInteger(result?.version_no) ? `v${result.version_no}` : "—"}</td>
<td data-label="Excel">${download}</td>
</tr>`;
}).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 = '<tr><td class="empty-cell" colspan="7">该月份还没有公司渠道明细任务</td></tr>';
return;
}
body.innerHTML = jobs.map((job) => `<tr>
<td>${formatDate(job.created_at, true)}</td>
<td><strong>${escapeHtml(job.report_month || "—")}</strong></td>
<td>${escapeHtml(companyCutoffLabel(job))}</td>
<td><span class="status-chip ${companyStateStyle(job.state)}">${escapeHtml(companyStateLabel(job.state))}</span></td>
<td>${companySuccessCount(job)} / 5</td>
<td>${companyWarningCount(job)}</td>
<td><button class="company-detail-button" type="button" data-company-job-id="${escapeHtml(job.job_id)}">查看</button></td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="7">任务记录暂时无法读取</td></tr>';
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();
})();