dy-dashboard/auto_update.py
2026-10-28 11:14:17 +08:00

307 lines
11 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

#!/usr/bin/env python3
"""
蝶雅采购智能体平台 - 自动更新脚本
从钉钉云盘下载日出库数据及日库存数据,自动导入并刷新系统计算
用法:
python3 auto_update.py # 全量更新
python3 auto_update.py --download-only # 仅下载不导入
python3 auto_update.py --import-only # 仅导入不下载
python3 auto_update.py --clear-cache # 仅清除系统缓存
定时任务(每天 7:00/12:00/18:00 执行):
0 7,12,18 * * * cd /home/gxd/.openclaw/workspace/dy-dashboard && python3 auto_update.py >> logs/auto_update.log 2>&1
"""
import os, sys, csv, json, glob, subprocess
from datetime import datetime, timedelta
from io import StringIO
# 项目根目录
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 自动检测虚拟环境 Python
_PYTHON = os.path.join(BASE_DIR, 'venv', 'bin', 'python3')
if os.path.exists(_PYTHON):
PYTHON = _PYTHON
else:
PYTHON = 'python3'
CSV_DIR = os.path.join(BASE_DIR, 'tmp_csv')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
DATA_DIR = os.path.join(CSV_DIR, 'downloads')
# 钉盘配置 - 请按实际路径修改
# 钉盘配置
SPACE_ID = '28814565790'
OUBOUND_FOLDER_ID = '3NwLYZXWynyBjRB4tZjwNxDvVkyEqBQm'
INVENTORY_FOLDER_ID = 'NkDwLng8ZLGrXmrnFxYq3vRqVKMEvZBY'
def log(msg):
"""带时间戳的日志输出"""
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f'[{ts}] {msg}')
def ensure_dirs():
"""确保目录存在"""
for d in [CSV_DIR, LOG_DIR, DATA_DIR]:
os.makedirs(d, exist_ok=True)
def download_from_dingtalk():
"""从钉钉云盘下载数据文件"""
log('=' * 60)
log('步骤1/4: 从钉钉云盘下载数据文件')
log('=' * 60)
downloaded = []
ensure_dirs()
def _download_files(folder_id, folder_name, target_dir):
"""列出并下载指定文件夹下的新 CSV 文件"""
log(f' 扫描{folder_name}...')
try:
result = subprocess.run(
['dws', 'drive', 'list', '--space-id', SPACE_ID,
'--folder', folder_id, '--limit', '50', '--format', 'json'],
capture_output=True, text=True, timeout=30
)
data = json.loads(result.stdout)
files = [f for f in data.get('items', []) if f.get('extension') == 'csv']
log(f' {folder_name}: {len(files)} 个 CSV 文件')
new_count = 0
for f in files:
fname = f.get('name', '')
file_id = f.get('fileId', '')
fpath = os.path.join(target_dir, fname)
# 如果文件已存在则跳过
if os.path.exists(fpath):
continue
# 下载新文件
log(f' 下载: {fname}')
subprocess.run(
['dws', 'drive', 'download', '--space-id', SPACE_ID,
'--file-id', file_id, '--output', target_dir],
capture_output=True, text=True, timeout=60
)
new_count += 1
if new_count > 0:
log(f' ✅ 下载 {new_count} 个新文件')
else:
log(f' ℹ️ 无新文件需下载')
return True
except Exception as e:
log(f' ⚠️ {folder_name}操作失败: {e}')
return False
# 下载日出库数据到 tmp_csv/
if _download_files(OUBOUND_FOLDER_ID, '日出库文件夹', CSV_DIR):
downloaded.append('outbound')
# 下载库存数据到 tmp_csv/
if _download_files(INVENTORY_FOLDER_ID, '库存文件夹', CSV_DIR):
downloaded.append('inventory')
if not downloaded:
log(f' ⚠️ 未找到任何数据文件')
return downloaded
def import_data():
"""导入数据到数据库(增量,不清表)"""
log('=' * 60)
log('步骤2/4: 导入数据到数据库(增量模式)')
log('=' * 60)
# 导入出库数据(reimport.py 默认增量,不清表)
log(' 📦 导入日出库数据...')
result = os.system(f'cd {BASE_DIR} && {PYTHON} reimport.py 2>&1')
if result == 0:
log(' ✅ 日出库数据导入完成')
else:
log(f' ⚠️ 日出库数据导入异常(code={result})')
# 导入库存数据(使用 import_inventory.py 脚本)
log(' 📦 导入日库存数据...')
result = os.system(f'cd {BASE_DIR} && {PYTHON} import_inventory.py 2>&1')
if result == 0:
log(' ✅ 库存数据导入完成')
else:
log(f' ⚠️ 库存数据导入异常(code={result})')
def refresh_calculations():
"""刷新所有计算数据(优先使用 RefreshableCache,再通过API触发)"""
log('=' * 60)
log('步骤3/4: 刷新系统计算数据')
log('=' * 60)
# 1. 使用 RefreshableCache 机制直接刷新内存缓存
try:
from services.cache import refresh_all_caches
results = refresh_all_caches()
log(f' ✅ 内存缓存刷新完成({len(results)} 模块)')
except Exception as e:
log(f' ⚠️ 内存缓存刷新失败: {e}')
import urllib.request
import json as jsonlib
# 登录获取会话(用于验证API是否正常工作)
login_url = 'http://127.0.0.1:18888/api/login'
# API 预热使用独立环境变量,数据库中不再存放可还原的登录密码。
password = os.environ.get('DY_AUTO_UPDATE_PASSWORD', '')
if not password:
log(' ⚠️ 未配置 DY_AUTO_UPDATE_PASSWORD,跳过API触发')
return
import http.cookiejar
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
try:
req = urllib.request.Request(
login_url,
data=jsonlib.dumps({'username': '甘旭东', 'password': password}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
resp = opener.open(req, timeout=5)
result = jsonlib.loads(resp.read())
if result.get('code') != 0:
log(' ⚠️ 系统登录失败,跳过API触发')
return
log(' ✅ 系统登录成功')
except Exception:
log(' ⚠️ 系统登录失败,跳过API触发')
return
# 清除仪表盘概览缓存,下次访问自动重建
try:
url = 'http://127.0.0.1:18888/api/dashboard/refresh-cache'
resp = opener.open(url, timeout=5)
result = jsonlib.loads(resp.read())
if result.get('code') == 0:
log(f' ✅ 仪表盘概览缓存已清除')
except Exception as e:
log(f' ⚠️ 仪表盘概览缓存清除失败: {e}')
# 触发各页面API以预热缓存
api_calls = [
('/api/dashboard/stats', '仪表盘概览数据'),
('/api/planning/list?page=1&size=1', '计划预测数据'),
('/api/products/list?page=1&size=1', '产品基础表数据'),
]
for path, name in api_calls:
try:
url = f'http://127.0.0.1:18888{path}'
resp = opener.open(url, timeout=10)
result = jsonlib.loads(resp.read())
if result.get('code') == 0:
log(f' ✅ {name}刷新成功')
else:
log(f' ⚠️ {name}刷新异常')
except Exception as e:
log(f' ⚠️ {name}刷新失败: {e}')
def verify_data():
"""验证数据完整性"""
log('=' * 60)
log('步骤4/4: 验证数据完整性')
log('=' * 60)
import pymysql
from config import DB_CONFIG
try:
conn = pymysql.connect(**DB_CONFIG)
with conn.cursor() as cur:
# 检查出库数据
cur.execute("SELECT COUNT(*) AS cnt, COALESCE(SUM(货品数量),0) AS total FROM daily_outbound")
row = cur.fetchone()
log(f' 📊 日出库数据: {row[0]} 条记录, 共 {row[1]:.0f} 件')
# 检查库存数据
cur.execute("SELECT COUNT(DISTINCT product_code) AS cnt, COALESCE(SUM(available_qty),0) AS total FROM inventory_detail")
row = cur.fetchone()
log(f' 📊 日库存数据: {row[0]} 个SKU, 共 {row[1]:.0f} 件')
# 检查产品数据
cur.execute("SELECT COUNT(*) FROM products")
total = cur.fetchone()[0]
log(f' 📊 产品基础数据: {total} 条')
conn.close()
log(f' ✅ 数据完整性检查通过')
except Exception as e:
log(f' ❌ 数据完整性检查失败: {e}')
def clear_cache():
"""清除系统缓存(刷新 RefreshableCache)"""
log('=' * 60)
log('清除系统缓存')
log('=' * 60)
try:
from services.cache import refresh_all_caches
results = refresh_all_caches()
log(f' ✅ 已刷新 {len(results)} 个缓存模块')
except Exception as e:
log(f' ⚠️ 缓存刷新失败: {e}')
log(' ✅ 缓存刷新完成')
def main():
"""主流程"""
import argparse
parser = argparse.ArgumentParser(description='蝶雅采购智能体平台自动更新脚本')
parser.add_argument('--download-only', action='store_true', help='仅下载数据')
parser.add_argument('--import-only', action='store_true', help='仅导入数据')
parser.add_argument('--clear-cache', action='store_true', help='仅清除缓存')
args = parser.parse_args()
ensure_dirs()
start_time = datetime.now()
log(f'🚀 蝶雅采购智能体平台自动更新开始')
log(f' 时间: {start_time.strftime("%Y-%m-%d %H:%M:%S")}')
log('')
if args.clear_cache:
clear_cache()
log(f'\n✅ 缓存清除完成,耗时: {(datetime.now()-start_time).seconds}s')
return
if args.download_only:
download_from_dingtalk()
log(f'\n✅ 下载完成,耗时: {(datetime.now()-start_time).seconds}s')
return
if args.import_only:
import_data()
log(f'\n✅ 导入完成,耗时: {(datetime.now()-start_time).seconds}s')
return
# 全量更新
ensure_dirs()
downloaded = download_from_dingtalk()
if not downloaded:
log(f'⚠️ 未下载到新数据,尝试使用本地数据')
import_data()
refresh_calculations()
verify_data()
total_time = (datetime.now() - start_time).total_seconds()
log('')
log('=' * 60)
log(f'✅ 自动更新完成!总耗时: {total_time:.0f}s')
log(f' 完成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
log('=' * 60)
if __name__ == '__main__':
main()