feat: prepare ARR for controlled public deployment

This commit is contained in:
Wyndham ARR
2026-07-29 16:38:05 +08:00
commit a701de9f0e
271 changed files with 48472 additions and 0 deletions

775
arr_web/static/app.js Normal file
View File

@@ -0,0 +1,775 @@
(() => {
"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();
})();