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