#!/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()