Track successful sync runs

This commit is contained in:
liuji
2026-10-10 17:39:58 +08:00
parent baae470d78
commit 50af6ffb52
3 changed files with 84 additions and 21 deletions
+12
View File
@@ -34,6 +34,18 @@ WORKBOOK_ID=your-workbook-id ./update.sh
- 合并结果末尾增加生成的 `LinkName` 列。 - 合并结果末尾增加生成的 `LinkName` 列。
- 看板对仅包含 `Date`、且 `Commission` 与 `GMV` 均为空的日期行进行过滤;该过滤由 `index.html` 完成。 - 看板对仅包含 `Date`、且 `Commission` 与 `GMV` 均为空的日期行进行过滤;该过滤由 `index.html` 完成。
### 同步运行日志
`data/sync-log.json` 保存最后一次成功同步的摘要,字段包括:
- `last_success_at`:UTC ISO-8601 时间,使用 `Z` 表示 UTC。
- `last_success_at_shanghai`:`Asia/Shanghai` 时区的 ISO 时间。
- `worksheet_count`:本次读取的工作表数量。
- `record_count`:合并结果中的数据记录数量,不含标题行。
- `artifact_bytes`:本次生成的 `data/merged.html` 字节数。
只有 Google Sheets 读取、合并和 HTML 生成全部成功后才会生成并发布新的运行日志。同步失败或 Git 推送失败不会用失败时间覆盖已发布的最后成功时间。AgentStage 每次成功运行都会提交 `data/merged.html` 或 `data/sync-log.json` 中发生变化的文件;即使数据内容没有变化,运行日志的成功时间也会变化,因此仍可通过 Git 提交和日志确认运行记录。
## 看板功能 ## 看板功能
看板页面采用上下布局,包含以下内容: 看板页面采用上下布局,包含以下内容:
+35 -8
View File
@@ -132,7 +132,6 @@
</section> </section>
<section class="metrics" id="metrics" aria-label="数据概览" hidden> <section class="metrics" id="metrics" aria-label="数据概览" hidden>
<div class="metric"><span class="metric-label">数据更新时间</span><strong class="metric-value" id="updated-at">—</strong></div>
<div class="metric"><span class="metric-label">记录数</span><strong class="metric-value" id="record-count">—</strong></div> <div class="metric"><span class="metric-label">记录数</span><strong class="metric-value" id="record-count">—</strong></div>
<div class="metric"><span class="metric-label">LinkName 数</span><strong class="metric-value" id="link-count">—</strong></div> <div class="metric"><span class="metric-label">LinkName 数</span><strong class="metric-value" id="link-count">—</strong></div>
<div class="metric"><span class="metric-label">Total Commission</span><strong class="metric-value" id="total-commission">—</strong></div> <div class="metric"><span class="metric-label">Total Commission</span><strong class="metric-value" id="total-commission">—</strong></div>
@@ -167,6 +166,7 @@
'use strict'; 'use strict';
var DATA_URL = 'data/merged.html'; var DATA_URL = 'data/merged.html';
var SYNC_LOG_URL = 'data/sync-log.json';
var RATES_URL = 'https://open.er-api.com/v6/latest/USD'; var RATES_URL = 'https://open.er-api.com/v6/latest/USD';
var REQUIRED_FIELDS = ['Date', 'Commission', 'Order', 'GMV', 'Currency', 'LinkName']; var REQUIRED_FIELDS = ['Date', 'Commission', 'Order', 'GMV', 'Currency', 'LinkName'];
var charts = []; var charts = [];
@@ -190,7 +190,6 @@
rangeButtons: Array.from(document.querySelectorAll('[data-range]')), rangeButtons: Array.from(document.querySelectorAll('[data-range]')),
rateStatus: document.getElementById('rate-status'), rateStatus: document.getElementById('rate-status'),
metrics: document.getElementById('metrics'), metrics: document.getElementById('metrics'),
updatedAt: document.getElementById('updated-at'),
recordCount: document.getElementById('record-count'), recordCount: document.getElementById('record-count'),
linkCount: document.getElementById('link-count'), linkCount: document.getElementById('link-count'),
totalCommission: document.getElementById('total-commission'), totalCommission: document.getElementById('total-commission'),
@@ -205,7 +204,16 @@
linkCommission: document.getElementById('link-commission-chart'), linkCommission: document.getElementById('link-commission-chart'),
linkGmv: document.getElementById('link-gmv-chart') linkGmv: document.getElementById('link-gmv-chart')
}; };
var state = { rawRecords: [], sourceCurrencies: [], selectedCurrency: '', latestDate: null, dateOnlyRows: 0, invalidCurrencyRows: 0 }; var state = {
rawRecords: [],
sourceCurrencies: [],
selectedCurrency: '',
latestDate: null,
dateOnlyRows: 0,
invalidCurrencyRows: 0,
syncLog: null,
syncLogUnavailable: false
};
function setStatus(message, status) { function setStatus(message, status) {
elements.status.textContent = message; elements.status.textContent = message;
@@ -277,6 +285,8 @@
function updateNotice() { function updateNotice() {
var messages = []; var messages = [];
if (state.syncLog) messages.push('最后成功运行:' + state.syncLog.displayTime);
if (state.syncLogUnavailable) messages.push('运行日志暂不可用。');
if (state.dateOnlyRows) messages.push('已过滤 ' + formatNumber(state.dateOnlyRows) + ' 条只有 Date、Commission 和 GMV 均为空的日期行。'); if (state.dateOnlyRows) messages.push('已过滤 ' + formatNumber(state.dateOnlyRows) + ' 条只有 Date、Commission 和 GMV 均为空的日期行。');
if (state.invalidCurrencyRows) messages.push('已排除 ' + formatNumber(state.invalidCurrencyRows) + ' 条币种为空、Unknown 或格式无效且包含金额的记录,请检查原始数据。'); if (state.invalidCurrencyRows) messages.push('已排除 ' + formatNumber(state.invalidCurrencyRows) + ' 条币种为空、Unknown 或格式无效且包含金额的记录,请检查原始数据。');
if (state.sourceCurrencies.length > 1) messages.push('检测到多个已知源币种(' + state.sourceCurrencies.join('、') + '),已统一换算为 USD,不能切换到 IDR。'); if (state.sourceCurrencies.length > 1) messages.push('检测到多个已知源币种(' + state.sourceCurrencies.join('、') + '),已统一换算为 USD,不能切换到 IDR。');
@@ -284,6 +294,15 @@
elements.warning.hidden = messages.length === 0; elements.warning.hidden = messages.length === 0;
} }
function parseSyncLog(payload) {
if (!payload || typeof payload.last_success_at !== 'string' || !payload.last_success_at.trim() || Number.isNaN(Date.parse(payload.last_success_at))) {
throw new Error('运行日志缺少有效的 last_success_at。');
}
var shanghaiTime = typeof payload.last_success_at_shanghai === 'string' ? payload.last_success_at_shanghai.trim() : '';
var displayTime = shanghaiTime && !Number.isNaN(Date.parse(shanghaiTime)) ? shanghaiTime : payload.last_success_at;
return { displayTime: displayTime };
}
function setRateStatus(message) { elements.rateStatus.textContent = message; } function setRateStatus(message) { elements.rateStatus.textContent = message; }
function updateCurrencyOptions() { function updateCurrencyOptions() {
@@ -415,8 +434,7 @@
return total + (record.gmv === null ? 0 : record.gmv); return total + (record.gmv === null ? 0 : record.gmv);
}, 0); }, 0);
elements.title.textContent = 'TTSHOP 销售看板 · ' + state.selectedCurrency; elements.title.textContent = 'TTSHOP 销售看板 · ' + state.selectedCurrency;
elements.subtitle.textContent = 'Commission 与 GMV 趋势(当前加载时间:' + dateFormatter.format(new Date()) + ')'; elements.subtitle.textContent = 'Commission 与 GMV 趋势(数据来自最近一次成功同步)';
elements.updatedAt.textContent = dateFormatter.format(new Date());
elements.recordCount.textContent = formatNumber(records.length); elements.recordCount.textContent = formatNumber(records.length);
elements.linkCount.textContent = formatNumber(links.size); elements.linkCount.textContent = formatNumber(links.size);
elements.totalCommission.textContent = state.selectedCurrency + ' ' + formatNumber(totalCommission); elements.totalCommission.textContent = state.selectedCurrency + ' ' + formatNumber(totalCommission);
@@ -551,11 +569,20 @@
} }
function loadData() { function loadData() {
fetch(DATA_URL, { cache: 'no-store' }).then(function (response) { var dataPromise = fetch(DATA_URL, { cache: 'no-store' }).then(function (response) {
if (!response.ok) throw new Error('无法读取 data/merged.html(HTTP ' + response.status + ')。'); if (!response.ok) throw new Error('无法读取 data/merged.html(HTTP ' + response.status + ')。');
return response.text(); return response.text();
}).then(function (htmlText) { });
var result = readTable(htmlText); var syncLogPromise = fetch(SYNC_LOG_URL, { cache: 'no-store' }).then(function (response) {
if (!response.ok) throw new Error('无法读取 data/sync-log.json(HTTP ' + response.status + ')。');
return response.json();
}).then(parseSyncLog).catch(function () {
state.syncLogUnavailable = true;
return null;
});
Promise.all([dataPromise, syncLogPromise]).then(function (results) {
var result = readTable(results[0]);
state.syncLog = results[1];
state.rawRecords = result.records; state.rawRecords = result.records;
state.dateOnlyRows = result.dateOnlyRows; state.dateOnlyRows = result.dateOnlyRows;
state.invalidCurrencyRows = result.invalidCurrencyRows; state.invalidCurrencyRows = result.invalidCurrencyRows;
+37 -13
View File
@@ -10,9 +10,11 @@ import subprocess
import sys import sys
import tempfile import tempfile
import zipfile import zipfile
from datetime import datetime, timezone
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
from zoneinfo import ZoneInfo
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import requests import requests
@@ -21,6 +23,7 @@ import requests
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
WORKBOOK_ID = os.getenv("WORKBOOK_ID", "1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw") WORKBOOK_ID = os.getenv("WORKBOOK_ID", "1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw")
PUBLISH_PATH = Path("data/merged.html") PUBLISH_PATH = Path("data/merged.html")
SYNC_LOG_PATH = Path("data/sync-log.json")
GVIZ_PREFIX = "google.visualization.Query.setResponse(" GVIZ_PREFIX = "google.visualization.Query.setResponse("
@@ -171,18 +174,28 @@ def run_git(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess
) )
def publish(content: bytes) -> None: def publish(content: bytes, sync_log: dict[str, object]) -> None:
target = ROOT / PUBLISH_PATH log_content = (json.dumps(sync_log, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
if target.exists() and target.read_bytes() == content: targets = {
print("data/merged.html is unchanged") PUBLISH_PATH: content,
SYNC_LOG_PATH: log_content,
}
changed = False
for relative_path, target_content in targets.items():
target = ROOT / relative_path
if not target.exists() or target.read_bytes() != target_content:
changed = True
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(target_content)
if not changed:
print("data/merged.html and data/sync-log.json are unchanged")
return return
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
env = os.environ.copy() env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0" env["GIT_TERMINAL_PROMPT"] = "0"
askpass_path: str | None = None askpass_path: str | None = None
credential = env.get("GIT_CREDENTIAL_SECRET", "") credential = env.get("GIT_CREDENTIAL_SECRET", "")
paths = [str(PUBLISH_PATH), str(SYNC_LOG_PATH)]
if credential: if credential:
with tempfile.NamedTemporaryFile("w", prefix="ttshop-askpass-", delete=False) as file: with tempfile.NamedTemporaryFile("w", prefix="ttshop-askpass-", delete=False) as file:
file.write( file.write(
@@ -197,22 +210,23 @@ def publish(content: bytes) -> None:
env["GIT_ASKPASS"] = askpass_path env["GIT_ASKPASS"] = askpass_path
try: try:
run_git(["add", "--", str(PUBLISH_PATH)], env) run_git(["add", "--", *paths], env)
staged = subprocess.run( staged = subprocess.run(
["git", "diff", "--cached", "--quiet", "--", str(PUBLISH_PATH)], ["git", "diff", "--cached", "--quiet", "--", *paths],
cwd=ROOT, env=env, check=False, cwd=ROOT, env=env, check=False,
) )
if staged.returncode not in (0, 1): if staged.returncode not in (0, 1):
raise RuntimeError("could not inspect staged output") raise RuntimeError("could not inspect staged output")
if staged.returncode == 0: if staged.returncode == 0:
print("data/merged.html is unchanged") print("data/merged.html and data/sync-log.json are unchanged")
return return
run_git([ run_git([
"-c", "user.name=TTSHOP Sync", "-c", "user.email=ttshop-sync@localhost", "-c", "user.name=TTSHOP Sync", "-c", "user.email=ttshop-sync@localhost",
"commit", "--no-gpg-sign", "-m", "Update merged Google Sheet", "commit", "--only", "--no-gpg-sign", "-m", "Update merged Google Sheet and sync log",
"--", *paths,
], env) ], env)
run_git(["push", "origin", "HEAD:main"], env) run_git(["push", "origin", "HEAD:main"], env)
print("published data/merged.html") print("published data/merged.html and data/sync-log.json")
finally: finally:
if askpass_path: if askpass_path:
Path(askpass_path).unlink(missing_ok=True) Path(askpass_path).unlink(missing_ok=True)
@@ -222,13 +236,23 @@ def sync() -> None:
export_url = f"https://docs.google.com/spreadsheets/d/{WORKBOOK_ID}/export?format=xlsx" export_url = f"https://docs.google.com/spreadsheets/d/{WORKBOOK_ID}/export?format=xlsx"
response = requests.get(export_url, timeout=30) response = requests.get(export_url, timeout=30)
response.raise_for_status() response.raise_for_status()
worksheet_names = sheet_names(response.content)
sheets = [] sheets = []
for name in sheet_names(response.content): for name in worksheet_names:
headers, rows = normalize_sheet(name, read_gviz(name)) headers, rows = normalize_sheet(name, read_gviz(name))
if headers: if headers:
sheets.append((name, headers, rows)) sheets.append((name, headers, rows))
headers, rows = merge_sheets(sheets) headers, rows = merge_sheets(sheets)
publish(render_html(headers, rows)) content = render_html(headers, rows)
success_at = datetime.now(timezone.utc)
sync_log = {
"last_success_at": success_at.isoformat().replace("+00:00", "Z"),
"last_success_at_shanghai": success_at.astimezone(ZoneInfo("Asia/Shanghai")).isoformat(),
"worksheet_count": len(worksheet_names),
"record_count": len(rows),
"artifact_bytes": len(content),
}
publish(content, sync_log)
def notify_failure(error: Exception) -> None: def notify_failure(error: Exception) -> None: