Track successful sync runs
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user