Files
InboundVerify/state_store.py
Misaka 0be3399165 业务日期端到端:状态库快照 + 下载写入 + 报告读库
- state_store: site_status 加 expected/actual/undelivered_business_date 列(建表+旧库迁移);set_data_state 加可选 business_date(None 时保留,心跳不覆盖下载快照)
- runtime: dispatch_task 下载成功后快照业务日期(today−offset);4 站 undelivered 一并补写 expected/actual/undelivered 三列(_site_undelivered_handler 内部连下绕过 dispatch,否则应到/实到业务日期丢失)
- expected_undelivered: 报告数据日期列改读状态库(_read_business_dates),退役 mtime 反推;KPI 卡 2-3-2-2 等宽对齐 + 柱状图图例 overlay 分行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 14:09:08 +08:00

429 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# state_store.py
# 阶段0站点状态持久化SQLite。记录各站登录态 + 应到/实到数据文件生成时间,
# 供后台心跳刷新、菜单状态盘展示,以及未来 FastAPI / Web 前端读取。
# 重启不丢——程序重启后状态从本库恢复(登录态会随心跳重新探测校正)。
#
# 设计:纯 Pythonsqlite3 标准库,无新依赖),每次读写开短连接,主线程使用。
import os
import sqlite3
from datetime import datetime
from paths import STATE_DB_PATH
# 登录态枚举
LOGIN_UNKNOWN = "unknown" # 尚未探测过
LOGIN_IN = "logged_in"
LOGIN_OUT = "logged_out"
# 任务状态枚举task_history.status
TASK_PENDING = "pending" # 已入队,待执行
TASK_RUNNING = "running" # 正在执行
TASK_SUCCESS = "success" # 成功(有数据)
TASK_NO_DATA = "no_data" # 成功但本站本次无数据
TASK_FAILED = "failed" # 失败(重试耗尽 / 未登录 / 异常)
def _now():
"""本地时间的字符串(到秒),用于时间戳列。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def init_db():
"""建库建表(幂等)。确保 state 目录存在。"""
os.makedirs(os.path.dirname(STATE_DB_PATH), exist_ok=True)
with sqlite3.connect(STATE_DB_PATH) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS site_status (
site TEXT PRIMARY KEY,
login_state TEXT,
login_checked_at TEXT,
expected_ready INTEGER,
expected_generated_at TEXT,
actual_ready INTEGER,
actual_generated_at TEXT,
undelivered_ready INTEGER,
undelivered_generated_at TEXT,
expected_business_date TEXT,
actual_business_date TEXT,
undelivered_business_date TEXT,
updated_at TEXT
)
""")
# 旧库迁移:补 undelivered 两列(新库已含;重复添加抛 OperationalError忽略
for _col, _typedef in [
("undelivered_ready", "INTEGER NOT NULL DEFAULT 0"),
("undelivered_generated_at", "TEXT NOT NULL DEFAULT ''"),
("expected_business_date", "TEXT NOT NULL DEFAULT ''"),
("actual_business_date", "TEXT NOT NULL DEFAULT ''"),
("undelivered_business_date", "TEXT NOT NULL DEFAULT ''"),
]:
try:
conn.execute(f"ALTER TABLE site_status ADD COLUMN {_col} {_typedef}")
except sqlite3.OperationalError:
pass
conn.execute("""
CREATE TABLE IF NOT EXISTS task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site TEXT,
kind TEXT,
status TEXT,
started_at TEXT,
finished_at TEXT,
error TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS site_config (
site TEXT PRIMARY KEY,
expected_offset INTEGER NOT NULL DEFAULT 0,
actual_offset INTEGER NOT NULL DEFAULT 0,
schedule_enabled INTEGER NOT NULL DEFAULT 0,
schedule_time TEXT NOT NULL DEFAULT '',
updated_at TEXT
)
""")
# 旧库迁移:补 schedule 两列 + expected/actual 偏移(新库已含;重复添加抛错忽略)
for _col, _typedef in [
("schedule_enabled", "INTEGER NOT NULL DEFAULT 0"),
("schedule_time", "TEXT NOT NULL DEFAULT ''"),
("expected_offset", "INTEGER NOT NULL DEFAULT 0"),
("actual_offset", "INTEGER NOT NULL DEFAULT 0"),
]:
try:
conn.execute(f"ALTER TABLE site_config ADD COLUMN {_col} {_typedef}")
except sqlite3.OperationalError:
pass
# 旧库若有 date_offset 列,把值搬到 expected/actual一次性新库无此列则跳过
try:
conn.execute(
"UPDATE site_config SET expected_offset=date_offset, actual_offset=date_offset "
"WHERE expected_offset=0 AND date_offset IS NOT NULL AND date_offset>0"
)
except sqlite3.OperationalError:
pass
conn.execute("""
CREATE TABLE IF NOT EXISTS site_settings (
site TEXT,
key TEXT,
value TEXT,
PRIMARY KEY (site, key)
)
""")
conn.commit()
def _upsert(conn, site, **fields):
"""更新(或插入)单站:保留未传字段,刷新 updated_at。"""
row = conn.execute(
"SELECT login_state, login_checked_at, expected_ready, expected_generated_at, "
"expected_business_date, actual_ready, actual_generated_at, actual_business_date, "
"undelivered_ready, undelivered_generated_at, undelivered_business_date "
"FROM site_status WHERE site = ?",
(site,),
).fetchone()
cur = {
"login_state": LOGIN_UNKNOWN,
"login_checked_at": "",
"expected_ready": 0,
"expected_generated_at": "",
"expected_business_date": "",
"actual_ready": 0,
"actual_generated_at": "",
"actual_business_date": "",
"undelivered_ready": 0,
"undelivered_generated_at": "",
"undelivered_business_date": "",
}
if row:
(
cur["login_state"],
cur["login_checked_at"],
cur["expected_ready"],
cur["expected_generated_at"],
cur["expected_business_date"],
cur["actual_ready"],
cur["actual_generated_at"],
cur["actual_business_date"],
cur["undelivered_ready"],
cur["undelivered_generated_at"],
cur["undelivered_business_date"],
) = row
cur.update(fields)
cur["updated_at"] = _now()
values = (
site,
cur["login_state"],
cur["login_checked_at"],
cur["expected_ready"],
cur["expected_generated_at"],
cur["expected_business_date"],
cur["actual_ready"],
cur["actual_generated_at"],
cur["actual_business_date"],
cur["undelivered_ready"],
cur["undelivered_generated_at"],
cur["undelivered_business_date"],
cur["updated_at"],
)
if row:
conn.execute(
"UPDATE site_status SET login_state=?, login_checked_at=?, expected_ready=?, "
"expected_generated_at=?, expected_business_date=?, actual_ready=?, "
"actual_generated_at=?, actual_business_date=?, undelivered_ready=?, "
"undelivered_generated_at=?, undelivered_business_date=?, updated_at=? "
"WHERE site=?",
values[1:] + (site,),
)
else:
conn.execute(
"INSERT INTO site_status (site, login_state, login_checked_at, expected_ready, "
"expected_generated_at, expected_business_date, actual_ready, actual_generated_at, "
"actual_business_date, undelivered_ready, undelivered_generated_at, "
"undelivered_business_date, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
values,
)
conn.commit()
def set_login_state(site, logged_in):
"""更新单站登录态。logged_in: bool。"""
state = LOGIN_IN if logged_in else LOGIN_OUT
with sqlite3.connect(STATE_DB_PATH) as conn:
_upsert(conn, site, login_state=state, login_checked_at=_now())
def reset_login_states(sites):
"""启动时把给定站点的登录态重置为 unknown避免显示上一会话的陈旧登录态
登录态是会话级的;数据态(文件就绪)会话无关、保留不动,心跳就绪后会重新探测。"""
with sqlite3.connect(STATE_DB_PATH) as conn:
for site in sites:
_upsert(conn, site, login_state=LOGIN_UNKNOWN, login_checked_at="")
def set_data_state(site, kind, ready, generated_at, business_date=None):
"""更新单站数据态。kind: 'expected'/'actual'/'undelivered'ready: bool
generated_at: str。business_date: str 或 None——None 时保留原值(心跳不覆盖下载快照)。"""
fields = {
f"{kind}_ready": 1 if ready else 0,
f"{kind}_generated_at": generated_at or "",
}
if business_date is not None:
fields[f"{kind}_business_date"] = business_date or ""
with sqlite3.connect(STATE_DB_PATH) as conn:
_upsert(conn, site, **fields)
def get_all_status():
"""返回 {site: {各字段}};库不存在则返回 {}"""
if not os.path.exists(STATE_DB_PATH):
return {}
with sqlite3.connect(STATE_DB_PATH) as conn:
rows = conn.execute(
"SELECT site, login_state, login_checked_at, expected_ready, "
"expected_generated_at, expected_business_date, actual_ready, "
"actual_generated_at, actual_business_date, undelivered_ready, "
"undelivered_generated_at, undelivered_business_date, updated_at "
"FROM site_status"
).fetchall()
return {
r[0]: {
"login_state": r[1],
"login_checked_at": r[2],
"expected_ready": bool(r[3]),
"expected_generated_at": r[4],
"expected_business_date": r[5],
"actual_ready": bool(r[6]),
"actual_generated_at": r[7],
"actual_business_date": r[8],
"undelivered_ready": bool(r[9]),
"undelivered_generated_at": r[10],
"undelivered_business_date": r[11],
"updated_at": r[12],
}
for r in rows
}
# ============================ 下载日期偏移site_config============================
MAX_DATE_OFFSET = 30 # 0=今天,最大回溯 30 天
def get_offset(site, kind="expected"):
"""读取单站下载日期偏移kind: 'expected'/'actual'0=今天1=昨天…);未配置返回 0。"""
col = "expected_offset" if kind == "expected" else "actual_offset"
if not os.path.exists(STATE_DB_PATH):
return 0
with sqlite3.connect(STATE_DB_PATH) as conn:
row = conn.execute(
f"SELECT {col} FROM site_config WHERE site=?", (site,)
).fetchone()
return int(row[0]) if row else 0
def set_offset(site, kind, offset):
"""设置单站下载日期偏移kind: 'expected'/'actual'),钳制到 [0, MAX_DATE_OFFSET]。"""
col = "expected_offset" if kind == "expected" else "actual_offset"
offset = max(0, min(MAX_DATE_OFFSET, int(offset)))
with sqlite3.connect(STATE_DB_PATH) as conn:
conn.execute(
f"INSERT INTO site_config (site, {col}, updated_at) VALUES (?, ?, ?) "
f"ON CONFLICT(site) DO UPDATE SET {col}=excluded.{col}, "
f"updated_at=excluded.updated_at",
(site, offset, _now()),
)
conn.commit()
return offset
def set_schedule(site, enabled, time_str):
"""设置单站每日定时下载enabled: booltime_str: 'HH:MM''')。"""
enabled_int = 1 if enabled else 0
with sqlite3.connect(STATE_DB_PATH) as conn:
conn.execute(
"INSERT INTO site_config (site, schedule_enabled, schedule_time, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(site) DO UPDATE SET "
"schedule_enabled=excluded.schedule_enabled, "
"schedule_time=excluded.schedule_time, updated_at=excluded.updated_at",
(site, enabled_int, time_str or "", _now()),
)
conn.commit()
return bool(enabled_int), (time_str or "")
def get_all_config():
"""返回 {site: {expected_offset, actual_offset, schedule_enabled, schedule_time}}。"""
if not os.path.exists(STATE_DB_PATH):
return {}
with sqlite3.connect(STATE_DB_PATH) as conn:
rows = conn.execute(
"SELECT site, expected_offset, actual_offset, schedule_enabled, schedule_time "
"FROM site_config"
).fetchall()
return {
r[0]: {
"expected_offset": int(r[1]),
"actual_offset": int(r[2]),
"schedule_enabled": bool(r[3]),
"schedule_time": r[4] or "",
}
for r in rows
}
# ============================ 站点键值配置site_settings============================
# 各站专属配置(百世密码、韵达账密、安能 exe 路径…),由前端配置弹窗设置。
def get_setting(site, key):
"""读取单站某个配置值;未设置返回 ''"""
if not os.path.exists(STATE_DB_PATH):
return ""
with sqlite3.connect(STATE_DB_PATH) as conn:
row = conn.execute(
"SELECT value FROM site_settings WHERE site=? AND key=?", (site, key)
).fetchone()
return row[0] if row else ""
def set_setting(site, key, value):
"""设置单站某个配置值upsert"""
with sqlite3.connect(STATE_DB_PATH) as conn:
conn.execute(
"INSERT INTO site_settings (site, key, value) VALUES (?, ?, ?) "
"ON CONFLICT(site, key) DO UPDATE SET value=excluded.value",
(site, key, value if value is not None else ""),
)
conn.commit()
def get_site_settings(site):
"""返回单站全部配置 {key: value}。"""
if not os.path.exists(STATE_DB_PATH):
return {}
with sqlite3.connect(STATE_DB_PATH) as conn:
rows = conn.execute(
"SELECT key, value FROM site_settings WHERE site=?", (site,)
).fetchall()
return {r[0]: r[1] for r in rows}
# ============================ 任务历史 ============================
def create_task(site, kind):
"""新建一条 pending 任务,返回其 id。"""
now = _now()
with sqlite3.connect(STATE_DB_PATH) as conn:
cur = conn.execute(
"INSERT INTO task_history (site, kind, status, started_at, finished_at, error) "
"VALUES (?, ?, ?, ?, '', '')",
(site, kind, TASK_PENDING, now),
)
conn.commit()
return cur.lastrowid
def update_task(task_id, status, error=None):
"""更新任务状态。终态(success/no_data/failed)写入 finished_at。"""
finished = _now() if status in (TASK_SUCCESS, TASK_NO_DATA, TASK_FAILED) else ""
with sqlite3.connect(STATE_DB_PATH) as conn:
if finished:
conn.execute(
"UPDATE task_history SET status=?, finished_at=?, error=? WHERE id=?",
(status, finished, error or "", task_id),
)
else:
conn.execute(
"UPDATE task_history SET status=?, error=? WHERE id=?",
(status, error or "", task_id),
)
conn.commit()
def get_task(task_id):
"""返回单条任务 dict不存在返回 None。"""
with sqlite3.connect(STATE_DB_PATH) as conn:
row = conn.execute(
"SELECT id, site, kind, status, started_at, finished_at, error "
"FROM task_history WHERE id=?",
(task_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"site": row[1],
"kind": row[2],
"status": row[3],
"started_at": row[4],
"finished_at": row[5],
"error": row[6],
}
def list_tasks(limit=20):
"""返回最近 limit 条任务(按 id 倒序)。"""
with sqlite3.connect(STATE_DB_PATH) as conn:
rows = conn.execute(
"SELECT id, site, kind, status, started_at, finished_at, error "
"FROM task_history ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [
{
"id": r[0],
"site": r[1],
"kind": r[2],
"status": r[3],
"started_at": r[4],
"finished_at": r[5],
"error": r[6],
}
for r in rows
]