feat(pchome): automate authorized sales acquisition
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-11 01:30:42 +08:00
parent f68223f0ed
commit 6e65ef00bb
21 changed files with 2428 additions and 617 deletions

View File

@@ -1,6 +1,6 @@
{% extends "ewoooc_base.html" %}
{% block title %}當日業績報表匯入{% endblock %}
{% block title %}業績資料自動化{% endblock %}
{% block page_attrs %}data-page-group="monitor" data-page-id="auto-import"{% endblock %}
{% block extra_head %}
@@ -11,429 +11,357 @@
{% block content %}
<div class="auto-import-page">
<div class="container">
{# ─────────── 頁首 ─────────── #}
<header class="ai-head">
<span class="ai-head__icon" aria-hidden="true"><i class="fas fa-cloud-download-alt"></i></span>
<span class="ai-head__icon" aria-hidden="true"><i class="fas fa-arrows-rotate"></i></span>
<div class="ai-head__main">
<h1 class="ai-head__title">當日業績報表匯入</h1>
<p class="ai-head__subtitle">
<i class="fas fa-info-circle" aria-hidden="true"></i>
保持 PChome 業績新鮮,讓評估、分析與建議有可靠資料。
</p>
<h1 class="ai-head__title">業績資料自動化</h1>
<p class="ai-head__subtitle" id="pageRuntimeSummary">正在讀取正式資料狀態</p>
</div>
<div class="ai-head__actions">
<button class="ai-icon-button" type="button" onclick="loadAll()" title="重新整理" aria-label="重新整理">
<i class="fas fa-rotate"></i>
</button>
<button class="btn btn-primary" type="button" id="runAutomationButton" onclick="runAutomation()">
<i class="fas fa-play me-1" aria-hidden="true"></i>執行自動取得
</button>
</div>
</header>
{# ─────────── 配置區 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-cog"></i>Google Drive 自動匯入配置
</h2>
</header>
<div class="ai-card__body">
<div id="configAlert"></div>
<div class="ai-notice">
<i class="fas fa-sync-alt ai-notice__icon" aria-hidden="true"></i>
<p class="ai-notice__body">每 30 分鐘檢查雲端業績檔,讓日報、成長分析與作戰清單保持新鮮。</p>
</div>
<div class="ai-form-grid">
<div>
<label class="ai-field__label" for="folderPath">Google Drive 資料夾路徑</label>
<input type="text" class="ai-field__input form-control" id="folderPath" placeholder="例如:業績報表/當日業績">
<small class="ai-field__hint">設定要監控的 Google Drive 資料夾路徑</small>
</div>
<div>
<label class="ai-field__label" for="filePattern">檔案名稱模式(選填)</label>
<input type="text" class="ai-field__input form-control" id="filePattern" placeholder="例如即時業績_當日">
<small class="ai-field__hint">用於過濾特定名稱的檔案</small>
</div>
</div>
<div class="ai-toolbar">
<button class="btn btn-primary" onclick="saveConfig()">
<i class="fas fa-save me-1"></i>儲存配置
</button>
<button class="btn btn-secondary" onclick="testConnection()">
<i class="fas fa-plug me-1"></i>測試連接
</button>
<button class="btn btn-secondary" onclick="listFiles()">
<i class="fas fa-list me-1"></i>列出檔案
</button>
<button class="btn btn-success" onclick="manualImport()">
<i class="fas fa-play me-1"></i>立即匯入
</button>
</div>
<section class="ai-cockpit" aria-label="業績資料狀態">
<div class="ai-metric">
<span class="ai-metric__label">最新業績</span>
<strong class="ai-metric__value" id="freshnessValue">--</strong>
<span class="ai-metric__meta" id="freshnessMeta">讀取中</span>
</div>
<div class="ai-metric">
<span class="ai-metric__label">決策狀態</span>
<strong class="ai-metric__value" id="closureValue">--</strong>
<span class="ai-metric__meta" id="closureMeta">讀取中</span>
</div>
<div class="ai-metric">
<span class="ai-metric__label">來源覆蓋</span>
<strong class="ai-metric__value" id="sourceCoverageValue">--</strong>
<span class="ai-metric__meta" id="sourceCoverageMeta">讀取中</span>
</div>
<div class="ai-metric">
<span class="ai-metric__label">最近執行</span>
<strong class="ai-metric__value ai-metric__value--compact" id="lastRunValue">--</strong>
<span class="ai-metric__meta" id="lastRunMeta">尚無 receipt</span>
</div>
</section>
{# ─────────── 手動上傳 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-upload"></i>手動上傳匯入
</h2>
<div class="ai-inline-status" id="automationAlert" role="status" hidden></div>
<section class="ai-section" aria-labelledby="sourcesTitle">
<header class="ai-section__head">
<div>
<span class="ai-section__eyebrow">授權來源</span>
<h2 class="ai-section__title" id="sourcesTitle">資料來源</h2>
</div>
<span class="ai-mode-badge"><i class="fas fa-shield-halved" aria-hidden="true"></i>自動安全執行</span>
</header>
<div class="ai-card__body">
<div id="uploadAlert"></div>
<div class="ai-notice ai-notice--info">
<i class="fas fa-info-circle ai-notice__icon" aria-hidden="true"></i>
<div>
<p class="ai-notice__title">每日業績快照</p>
<p class="ai-notice__body" style="margin-bottom: var(--momo-space-1);">
上傳當日業績檔,更新日報、成長分析與今日作戰清單。
</p>
<p class="ai-notice__body">
<small>檔名建議:<code>即時業績_當日_YYYYMMDD.xlsx</code>;送出後更新日報、成長分析與今日作戰清單。</small>
</p>
</div>
</div>
<div class="ai-uploadrow">
<div>
<label class="ai-field__label" for="manualUploadFile">選擇檔案</label>
<input type="file" class="ai-field__input form-control" id="manualUploadFile" accept=".xlsx,.xls">
</div>
<div>
<button class="btn btn-primary" onclick="uploadManualFile()" style="height: calc(var(--momo-space-2) * 2 + var(--momo-text-sm) * 1.4 + 2px);">
<i class="fas fa-upload me-1"></i>上傳並匯入
</button>
</div>
</div>
<div class="ai-source-list" id="sourceList" aria-live="polite">
<div class="ai-source-row ai-source-row--loading">正在讀取來源狀態</div>
</div>
<details class="ai-disclosure">
<summary><i class="fab fa-google-drive" aria-hidden="true"></i>Drive 來源設定</summary>
<div class="ai-disclosure__body">
<div id="configAlert"></div>
<div class="ai-form-grid">
<div>
<label class="ai-field__label" for="folderPath">資料夾路徑</label>
<input type="text" class="ai-field__input form-control" id="folderPath" autocomplete="off">
</div>
<div>
<label class="ai-field__label" for="filePattern">檔名模式</label>
<input type="text" class="ai-field__input form-control" id="filePattern" autocomplete="off">
</div>
</div>
<div class="ai-toolbar">
<button class="btn btn-primary" type="button" onclick="saveConfig()"><i class="fas fa-floppy-disk me-1"></i>儲存</button>
<button class="btn btn-secondary" type="button" onclick="testConnection()"><i class="fas fa-plug me-1"></i>測試</button>
<button class="btn btn-secondary" type="button" onclick="listFiles()"><i class="fas fa-list me-1"></i>檔案</button>
</div>
</div>
</details>
</section>
{# ─────────── 任務歷史 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-tasks"></i>匯入任務歷史
</h2>
<button class="btn btn-warning btn-sm" onclick="resetStuckJobs()">
<i class="fas fa-redo me-1"></i>重置卡住的任務
<section class="ai-section" aria-labelledby="jobsTitle">
<header class="ai-section__head">
<div>
<span class="ai-section__eyebrow">驗證紀錄</span>
<h2 class="ai-section__title" id="jobsTitle">匯入紀錄</h2>
</div>
<button class="ai-icon-button" type="button" onclick="resetStuckJobs()" title="重置逾時任務" aria-label="重置逾時任務">
<i class="fas fa-clock-rotate-left"></i>
</button>
</header>
<div class="ai-card__body">
<div id="jobsLoading" class="ai-loading" style="display: none;">
<div class="spinner-border ai-loading__spinner" role="status">
<span class="visually-hidden">載入中…</span>
</div>
<p class="ai-loading__text">載入中…</p>
</div>
<div id="jobsEmpty" class="ai-empty" style="display: none;">
<i class="fas fa-inbox ai-empty__icon" aria-hidden="true"></i>
<p class="ai-empty__text">尚無匯入記錄</p>
</div>
<div id="jobsTableContainer" style="display: none;">
<div class="table-responsive">
<table class="ai-jobtable" id="jobsTable">
<thead>
<tr>
<th>任務</th>
<th>檔案名稱</th>
<th>狀態</th>
<th>進度</th>
<th>成功 / 總</th>
<th>開始時間</th>
<th>完成時間</th>
<th>處置提醒</th>
<th>操作</th>
</tr>
</thead>
<tbody id="jobsTableBody"></tbody>
</table>
</div>
</div>
<div id="jobsLoading" class="ai-loading" hidden>
<div class="spinner-border ai-loading__spinner" role="status"><span class="visually-hidden">載入中</span></div>
</div>
<div id="jobsEmpty" class="ai-empty" hidden>
<i class="fas fa-inbox ai-empty__icon" aria-hidden="true"></i>
<p class="ai-empty__text">尚無匯入紀錄</p>
</div>
<div id="jobsTableContainer" class="table-responsive" hidden>
<table class="ai-jobtable" id="jobsTable">
<thead>
<tr>
<th>來源檔案</th>
<th>狀態</th>
<th>進度</th>
<th>筆數</th>
<th>完成時間</th>
<th>驗證結果</th>
</tr>
</thead>
<tbody id="jobsTableBody"></tbody>
</table>
</div>
</section>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// ───── 配置:載入 / 儲存 ─────
const SOURCE_LABELS = {
google_drive: 'Google Drive',
authorized_https: '授權 HTTPS',
authorized_imap: '授權信箱',
controlled_local_drop: '受控落地目錄'
};
function escapeHtml(value) {
const node = document.createElement('div');
node.textContent = String(value || '');
return node.innerHTML;
}
function formatTime(value) {
if (!value) return '--';
return new Date(value).toLocaleString('zh-TW', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
});
}
function setAlert(elementId, type, message) {
const element = document.getElementById(elementId);
element.className = `ai-inline-status ai-inline-status--${type}`;
element.innerHTML = message;
element.hidden = false;
}
function sourceStatusLabel(source) {
if (source.ready) return '可用';
if (!source.enabled) return '未啟用';
return source.status === 'incomplete' ? '設定未完整' : source.status === 'path_missing' ? '路徑不存在' : '不可用';
}
function receiptStatusLabel(status) {
return ({
completed: '完成',
completed_no_write: '無需更新',
partial: '部分完成',
blocked_with_safe_next_action: '等待來源',
degraded_no_write: '來源異常',
failed_rolled_back: '已回滾'
})[status] || '已記錄';
}
function renderReadiness(payload) {
const freshness = payload.freshness || {};
const coverage = payload.asset_coverage || {};
const receipt = payload.last_receipt || null;
const lag = freshness.data_lag_days;
document.getElementById('freshnessValue').textContent = freshness.latest_sales_date || '無資料';
document.getElementById('freshnessMeta').textContent = lag === null || lag === undefined ? '尚未建立基準' : lag === 0 ? '今天已更新' : `落後 ${lag}`;
document.getElementById('closureValue').textContent = freshness.decision_ready ? '可決策' : '受阻';
document.getElementById('closureValue').dataset.state = freshness.decision_ready ? 'ready' : 'blocked';
document.getElementById('closureMeta').textContent = freshness.decision_ready ? '新鮮度守門通過' : '等待最新業績';
document.getElementById('sourceCoverageValue').textContent = `${coverage.ready || 0}/${coverage.total || 0}`;
document.getElementById('sourceCoverageMeta').textContent = `${coverage.percent || 0}% 來源可用`;
document.getElementById('lastRunValue').textContent = receipt ? formatTime(receipt.completed_at) : '尚無執行';
document.getElementById('lastRunMeta').textContent = receipt ? receiptStatusLabel(receipt.status) : '尚無執行紀錄';
document.getElementById('pageRuntimeSummary').textContent = freshness.decision_ready
? '正式業績可供分析與決策'
: `正式業績${lag === null || lag === undefined ? '尚未建立' : `已落後 ${lag}`}`;
const list = document.getElementById('sourceList');
list.innerHTML = (payload.sources || []).map(source => `
<div class="ai-source-row">
<span class="ai-source-row__indicator ai-source-row__indicator--${source.ready ? 'ready' : source.enabled ? 'degraded' : 'disabled'}" aria-hidden="true"></span>
<span class="ai-source-row__name">${escapeHtml(SOURCE_LABELS[source.id] || source.id)}</span>
<span class="ai-source-row__state">${escapeHtml(sourceStatusLabel(source))}</span>
</div>
`).join('');
}
async function loadReadiness() {
try {
const response = await fetch('/api/auto_import/readiness');
const payload = await response.json();
if (!response.ok || !payload.success) throw new Error(payload.message || '狀態讀取失敗');
renderReadiness(payload);
} catch (error) {
document.getElementById('pageRuntimeSummary').textContent = '正式狀態暫時無法讀取';
setAlert('automationAlert', 'danger', '<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>狀態讀取失敗');
}
}
async function runAutomation() {
const button = document.getElementById('runAutomationButton');
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1" aria-hidden="true"></i>執行中';
setAlert('automationAlert', 'info', '<i class="fas fa-arrows-rotate fa-spin" aria-hidden="true"></i>正在探測授權來源並執行交易式驗證');
try {
const response = await fetch('/api/auto_import/run', { method: 'POST' });
const payload = await response.json();
const type = payload.success ? 'success' : payload.status === 'blocked_with_safe_next_action' ? 'warning' : 'danger';
setAlert('automationAlert', type, `<i class="fas fa-${payload.success ? 'circle-check' : 'circle-exclamation'}" aria-hidden="true"></i>${escapeHtml(payload.message)}`);
await Promise.all([loadReadiness(), loadJobs()]);
} catch (error) {
setAlert('automationAlert', 'danger', '<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>自動取得執行失敗');
} finally {
button.disabled = false;
button.innerHTML = '<i class="fas fa-play me-1" aria-hidden="true"></i>執行自動取得';
}
}
async function loadConfig() {
try {
const r = await fetch('/api/import_config');
const result = await r.json();
if (result.success) {
document.getElementById('folderPath').value = result.data.folder_path || '';
document.getElementById('filePattern').value = result.data.file_pattern || '';
const response = await fetch('/api/import_config');
const payload = await response.json();
if (payload.success) {
document.getElementById('folderPath').value = payload.data.folder_path || '';
document.getElementById('filePattern').value = payload.data.file_pattern || '';
}
} catch (e) {
console.error('載入配置失敗:', e);
} catch (error) {
console.error('Drive config read failed', error);
}
}
async function saveConfig() {
const folderPath = document.getElementById('folderPath').value;
const filePattern = document.getElementById('filePattern').value;
try {
const r = await fetch('/api/import_config', {
const response = await fetch('/api/import_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
body: JSON.stringify({
folder_path: document.getElementById('folderPath').value,
file_pattern: document.getElementById('filePattern').value
})
});
const result = await r.json();
if (result.success) {
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>配置已儲存');
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>儲存配置失敗:' + e.message);
const payload = await response.json();
setAlert('configAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
if (payload.success) loadReadiness();
} catch (error) {
setAlert('configAlert', 'danger', '設定儲存失敗');
}
}
async function testConnection() {
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在測試 Google Drive 連接…');
try {
const r = await fetch('/api/test_drive_connection', { method: 'POST' });
const result = await r.json();
showAlert('configAlert', result.success ? 'success' : 'danger',
'<i class="fas fa-' + (result.success ? 'check' : 'exclamation') + '-circle me-2"></i>' + result.message);
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>測試失敗:' + e.message);
const response = await fetch('/api/test_drive_connection', { method: 'POST' });
const payload = await response.json();
setAlert('configAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
loadReadiness();
} catch (error) {
setAlert('configAlert', 'danger', 'Drive 測試失敗');
}
}
async function listFiles() {
const folderPath = document.getElementById('folderPath').value;
const filePattern = document.getElementById('filePattern').value;
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在列出 Google Drive 檔案…');
try {
const r = await fetch('/api/list_drive_files', {
const response = await fetch('/api/list_drive_files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
body: JSON.stringify({
folder_path: document.getElementById('folderPath').value,
file_pattern: document.getElementById('filePattern').value
})
});
const result = await r.json();
if (result.success) {
const list = result.data.map(f => `<li class="mb-1"><i class="fas fa-file-excel me-1"></i>${f.name}</li>`).join('');
showAlert('configAlert', 'success',
`<i class="fas fa-check-circle me-2"></i>找到 ${result.count} 個檔案:<ul class="mt-2 mb-0">${list || '<li>無</li>'}</ul>`);
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>列出檔案失敗:' + e.message);
const payload = await response.json();
setAlert('configAlert', payload.success ? 'success' : 'danger', payload.success ? `待處理檔案 ${payload.count}` : escapeHtml(payload.message));
} catch (error) {
setAlert('configAlert', 'danger', 'Drive 檔案讀取失敗');
}
}
async function manualImport() {
if (!confirm('確定要立即執行匯入嗎?')) return;
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在執行匯入,請稍候…');
try {
const r = await fetch('/api/manual_import', { method: 'POST' });
const result = await r.json();
if (result.success) {
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>' + result.message);
setTimeout(() => loadJobs(), 1000);
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>匯入失敗:' + e.message);
}
function jobStatusText(status) {
return ({ pending: '等待', downloading: '取得', importing: '驗證', completed: '完成', failed: '失敗' })[status] || status;
}
// ───── 任務列表 ─────
async function loadJobs() {
document.getElementById('jobsLoading').style.display = 'block';
document.getElementById('jobsEmpty').style.display = 'none';
document.getElementById('jobsTableContainer').style.display = 'none';
try {
const r = await fetch('/api/import_jobs?limit=50');
const result = await r.json();
document.getElementById('jobsLoading').style.display = 'none';
if (result.success && result.data.length > 0) {
document.getElementById('jobsTableContainer').style.display = 'block';
renderJobs(result.data);
} else {
document.getElementById('jobsEmpty').style.display = 'block';
}
} catch (e) {
document.getElementById('jobsLoading').style.display = 'none';
document.getElementById('jobsEmpty').style.display = 'block';
console.error('載入任務列表失敗:', e);
}
function jobResult(job) {
if (job.status === 'completed') return '雙表已驗證';
if (job.status === 'failed') return job.display_error_message || '正式資料未變更';
return job.current_step || '執行中';
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
tbody.innerHTML = '';
jobs.forEach(job => {
const tr = document.createElement('tr');
const fileName = job.drive_file_name || '未記錄檔名';
const actionHint = buildImportActionHint(job);
const isRunning = job.status === 'downloading' || job.status === 'importing';
tr.innerHTML = `
<td><span class="ai-jobtable__id">#${job.id}</span></td>
<td><span class="ai-jobtable__file"><i class="fas fa-file-excel"></i>${escapeHtml(fileName)}</span></td>
<td><span class="ai-status ai-status--${job.status}">${getStatusText(job.status)}</span></td>
<td style="min-width: 160px;">
<div class="ai-progress">
<div class="ai-progress__bar" style="width: ${job.progress_percent}%"
role="progressbar" aria-valuenow="${job.progress_percent}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="ai-progress__label">${Math.round(job.progress_percent)}% · ${escapeHtml(job.current_step || '')}</span>
</td>
<td class="text-center">
<span class="ai-count ai-count--ok">${job.success_rows || 0}</span>
<span class="text-muted mx-1">/</span>
<span class="ai-count ai-count--total">${job.total_rows || 0}</span>
</td>
<td><span class="ai-jobtable__time">${formatTime(job.started_at)}</span></td>
<td><span class="ai-jobtable__time">${formatTime(job.completed_at)}</span></td>
<td class="ai-jobtable__error">${escapeHtml(actionHint)}</td>
<td class="text-center">
${isRunning
? `<button class="btn btn-sm btn-outline-danger" onclick="failJob(${job.id})" title="取消任務"><i class="fas fa-times"></i></button>`
: '<span class="text-muted">—</span>'}
</td>
`;
tbody.appendChild(tr);
});
const body = document.getElementById('jobsTableBody');
body.innerHTML = jobs.map(job => {
const running = ['pending', 'downloading', 'importing'].includes(job.status);
const progress = Math.max(0, Math.min(100, Number(job.progress_percent || 0)));
return `
<tr>
<td><span class="ai-jobtable__file"><i class="fas fa-file-excel" aria-hidden="true"></i>${escapeHtml(job.drive_file_name || '未記錄檔名')}</span></td>
<td><span class="ai-status ai-status--${escapeHtml(job.status)}">${escapeHtml(jobStatusText(job.status))}</span></td>
<td>
<div class="ai-progress"><div class="ai-progress__bar" style="width:${progress}%" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100"></div></div>
<span class="ai-progress__label">${Math.round(progress)}%</span>
</td>
<td><span class="ai-count ai-count--ok">${job.success_rows || 0}</span><span class="ai-count-separator">/</span><span class="ai-count ai-count--total">${job.total_rows || 0}</span></td>
<td><span class="ai-jobtable__time">${formatTime(job.completed_at)}</span></td>
<td class="ai-jobtable__error">${escapeHtml(jobResult(job))}${running ? `<button class="ai-row-action" onclick="failJob(${job.id})" title="取消任務" aria-label="取消任務"><i class="fas fa-xmark"></i></button>` : ''}</td>
</tr>`;
}).join('');
}
function buildImportActionHint(job) {
const status = String(job?.status || '');
const raw = String(job?.display_error_message || job?.error_message || '').trim();
const text = raw.toLowerCase();
if (status === 'completed') {
return '已更新業績資料,可回到今日作戰看建議。';
}
if (status === 'downloading' || status === 'importing' || status === 'pending') {
return '匯入進行中,完成後會更新日報與今日作戰。';
}
if (status === 'failed') {
if (text.includes('google drive') || raw.includes('雲端') || raw.includes('授權')) {
return '重新確認 Google Drive 授權,確認後再執行立即匯入。';
}
if (raw.includes('格式') || raw.includes('日期') || raw.includes('商品') || raw.includes('業績') || text.includes('excel')) {
return '改用當日業績明細檔,確認包含日期、商品與業績金額後重新匯入。';
}
if (raw.includes('同步') || raw.includes('未完整') || raw.includes('重啟')) {
return '重新匯入最新檔案;若重複失敗,請通知維護人員檢查同步流程。';
}
return '檢查檔案後重新匯入;若重複失敗,請通知維護人員。';
}
return '等待系統更新任務狀態;若重複停在異常,請通知維護人員。';
}
// ───── 工具 ─────
function escapeHtml(text) {
if (!text) return '';
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function getStatusText(status) {
return ({
'pending': '⏳ 等待中',
'downloading': '⬇️ 下載中',
'importing': '📥 匯入中',
'completed': '✅ 已完成',
'failed': '❌ 失敗'
})[status] || status;
}
function formatTime(timeStr) {
if (!timeStr) return '—';
return new Date(timeStr).toLocaleString('zh-TW', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
function showAlert(elementId, type, message) {
const el = document.getElementById(elementId);
el.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>`;
if (type === 'success' || type === 'danger') {
setTimeout(() => {
const a = el.querySelector('.alert');
if (a) bootstrap.Alert.getOrCreateInstance(a).close();
}, 5000);
}
}
// ───── 手動上傳 ─────
async function uploadManualFile() {
const input = document.getElementById('manualUploadFile');
const file = input.files[0];
if (!file) {
showAlert('uploadAlert', 'warning', '<i class="fas fa-exclamation-triangle me-2"></i>請先選擇檔案');
return;
}
if (!file.name.includes('即時業績') || !file.name.includes('當日')) {
if (!confirm('檔名似乎不符合格式(應包含「即時業績」和「當日」),確定要繼續嗎?')) return;
}
const formData = new FormData();
formData.append('file', file);
showAlert('uploadAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在上傳並匯入檔案,請稍候…');
async function loadJobs() {
const loading = document.getElementById('jobsLoading');
const empty = document.getElementById('jobsEmpty');
const table = document.getElementById('jobsTableContainer');
loading.hidden = false;
empty.hidden = true;
table.hidden = true;
try {
const r = await fetch('/api/import_excel', { method: 'POST', body: formData });
const result = await r.json();
if (result.status === 'success') {
showAlert('uploadAlert', 'success',
`<i class="fas fa-check-circle me-2"></i>${result.message}<br><small>共更新 ${result.rows} 筆業績資料</small>`);
input.value = '';
setTimeout(() => loadJobs(), 1000);
const response = await fetch('/api/import_jobs?limit=20');
const payload = await response.json();
const jobs = payload.success ? payload.data : [];
if (jobs.length) {
renderJobs(jobs);
table.hidden = false;
} else {
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
empty.hidden = false;
}
} catch (e) {
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>上傳失敗:' + e.message);
} catch (error) {
empty.hidden = false;
} finally {
loading.hidden = true;
}
}
async function resetStuckJobs() {
if (!confirm('確定要重置所有卡住超過 1 小時的任務嗎?')) return;
try {
const r = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
const result = await r.json();
if (result.success) {
alert(result.message);
loadJobs();
} else {
alert('重置失敗:' + result.message);
}
} catch (e) {
alert('重置失敗:' + e.message);
}
const response = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
const payload = await response.json();
setAlert('automationAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
loadJobs();
}
async function failJob(jobId) {
if (!confirm(`確定要取消任務 #${jobId} 嗎?`)) return;
try {
const r = await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
const result = await r.json();
if (result.success) { alert(result.message); loadJobs(); }
else { alert('取消失敗:' + result.message); }
} catch (e) {
alert('取消失敗:' + e.message);
}
await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
loadJobs();
}
// ───── 初始化 ─────
loadConfig();
loadJobs();
async function loadAll() {
await Promise.all([loadReadiness(), loadConfig(), loadJobs()]);
}
loadAll();
setInterval(loadJobs, 10000);
setInterval(loadReadiness, 30000);
</script>
{% endblock %}