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` 列。
- 看板对仅包含 `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 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">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>
@@ -167,6 +166,7 @@
'use strict';
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 REQUIRED_FIELDS = ['Date', 'Commission', 'Order', 'GMV', 'Currency', 'LinkName'];
var charts = [];
@@ -190,7 +190,6 @@
rangeButtons: Array.from(document.querySelectorAll('[data-range]')),
rateStatus: document.getElementById('rate-status'),
metrics: document.getElementById('metrics'),
updatedAt: document.getElementById('updated-at'),
recordCount: document.getElementById('record-count'),
linkCount: document.getElementById('link-count'),
totalCommission: document.getElementById('total-commission'),
@@ -205,7 +204,16 @@
linkCommission: document.getElementById('link-commission-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) {
elements.status.textContent = message;
@@ -277,6 +285,8 @@
function updateNotice() {
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.invalidCurrencyRows) messages.push('已排除 ' + formatNumber(state.invalidCurrencyRows) + ' 条币种为空、Unknown 或格式无效且包含金额的记录,请检查原始数据。');
if (state.sourceCurrencies.length > 1) messages.push('检测到多个已知源币种(' + state.sourceCurrencies.join('、') + '),已统一换算为 USD,不能切换到 IDR。');
@@ -284,6 +294,15 @@
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 updateCurrencyOptions() {
@@ -415,8 +434,7 @@
return total + (record.gmv === null ? 0 : record.gmv);
}, 0);
elements.title.textContent = 'TTSHOP 销售看板 · ' + state.selectedCurrency;
elements.subtitle.textContent = 'Commission 与 GMV 趋势(当前加载时间:' + dateFormatter.format(new Date()) + ')';
elements.updatedAt.textContent = dateFormatter.format(new Date());
elements.subtitle.textContent = 'Commission 与 GMV 趋势(数据来自最近一次成功同步)';
elements.recordCount.textContent = formatNumber(records.length);
elements.linkCount.textContent = formatNumber(links.size);
elements.totalCommission.textContent = state.selectedCurrency + ' ' + formatNumber(totalCommission);
@@ -551,11 +569,20 @@
}
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 + ')。');
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.dateOnlyRows = result.dateOnlyRows;
state.invalidCurrencyRows = result.invalidCurrencyRows;
+37 -13
View File
@@ -10,9 +10,11 @@ import subprocess
import sys
import tempfile
import zipfile
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
import xml.etree.ElementTree as ET
import requests
@@ -21,6 +23,7 @@ import requests
ROOT = Path(__file__).resolve().parent
WORKBOOK_ID = os.getenv("WORKBOOK_ID", "1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw")
PUBLISH_PATH = Path("data/merged.html")
SYNC_LOG_PATH = Path("data/sync-log.json")
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:
target = ROOT / PUBLISH_PATH
if target.exists() and target.read_bytes() == content:
print("data/merged.html is unchanged")
def publish(content: bytes, sync_log: dict[str, object]) -> None:
log_content = (json.dumps(sync_log, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
targets = {
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
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
askpass_path: str | None = None
credential = env.get("GIT_CREDENTIAL_SECRET", "")
paths = [str(PUBLISH_PATH), str(SYNC_LOG_PATH)]
if credential:
with tempfile.NamedTemporaryFile("w", prefix="ttshop-askpass-", delete=False) as file:
file.write(
@@ -197,22 +210,23 @@ def publish(content: bytes) -> None:
env["GIT_ASKPASS"] = askpass_path
try:
run_git(["add", "--", str(PUBLISH_PATH)], env)
run_git(["add", "--", *paths], env)
staged = subprocess.run(
["git", "diff", "--cached", "--quiet", "--", str(PUBLISH_PATH)],
["git", "diff", "--cached", "--quiet", "--", *paths],
cwd=ROOT, env=env, check=False,
)
if staged.returncode not in (0, 1):
raise RuntimeError("could not inspect staged output")
if staged.returncode == 0:
print("data/merged.html is unchanged")
print("data/merged.html and data/sync-log.json are unchanged")
return
run_git([
"-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)
run_git(["push", "origin", "HEAD:main"], env)
print("published data/merged.html")
print("published data/merged.html and data/sync-log.json")
finally:
if askpass_path:
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"
response = requests.get(export_url, timeout=30)
response.raise_for_status()
worksheet_names = sheet_names(response.content)
sheets = []
for name in sheet_names(response.content):
for name in worksheet_names:
headers, rows = normalize_sheet(name, read_gviz(name))
if headers:
sheets.append((name, headers, rows))
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: