From 89b01bb522fe825f2869b2a500e1082531b3eec1 Mon Sep 17 00:00:00 2001 From: houhuan Date: Wed, 29 Apr 2026 16:18:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20automation=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E6=A8=A1=E5=9D=97=E5=92=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 22 ++++ automation/__init__.py | 8 ++ automation/scheduler.py | 131 ++++++++++++++++++++ automation/secsion.py | 261 ++++++++++++++++++++++++++++++++++++++++ automation/uploader.py | 56 +++++++++ config.py | 159 ++++++++++++++++++++++++ 6 files changed, 637 insertions(+) create mode 100644 .env.example create mode 100644 automation/__init__.py create mode 100644 automation/scheduler.py create mode 100644 automation/secsion.py create mode 100644 automation/uploader.py create mode 100644 config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8424d9a --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# SaleShow 环境变量配置示例 +# 复制此文件为 .env 并填写实际值 + +# Flask 配置 +FLASK_DEBUG=False +PORT=5000 + +# Docker 端口映射(docker-compose 使用) +APP_PORT=5000 + +# secsion.com 登录凭据 +# 也可通过 Web UI 设置页面配置(优先级更高) +SECSION_USERNAME= +SECSION_PASSWORD= + +# 店铺 ID(留空则导出所有店铺数据) +SECSION_SHOP_ID= + +# 定时任务配置 +SCHEDULER_ENABLED=true +SCHEDULER_HOUR=1 +SCHEDULER_MINUTE=0 diff --git a/automation/__init__.py b/automation/__init__.py new file mode 100644 index 0000000..e51c629 --- /dev/null +++ b/automation/__init__.py @@ -0,0 +1,8 @@ +""" +自动化模块 - 从 secsion.com 自动下载报表并导入 SaleShow +""" +from .secsion import SecsionDownloader +from .uploader import import_excel_file +from .scheduler import init_scheduler + +__all__ = ['SecsionDownloader', 'import_excel_file', 'init_scheduler'] diff --git a/automation/scheduler.py b/automation/scheduler.py new file mode 100644 index 0000000..ed15e8d --- /dev/null +++ b/automation/scheduler.py @@ -0,0 +1,131 @@ +""" +定时调度模块 +使用 APScheduler 实现每日自动下载前一天的销售数据 +""" +import asyncio +import logging +from datetime import datetime, timedelta +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +logger = logging.getLogger(__name__) + +scheduler = None + + +def auto_download_job(): + """ + 定时任务:自动下载前一天的销售数据 + 每日凌晨 1 点执行 + """ + logger.info("========== 定时任务触发:自动下载前一天数据 ==========") + + try: + from config import Config + from automation.secsion import SecsionDownloader + from automation.uploader import import_excel_file, cleanup_download + + # 获取凭据 + creds = Config.get_secsion_credentials() + if not creds: + logger.error("未配置 secsion.com 登录凭据,跳过自动下载") + return + + username, password = creds + + # 计算前一天日期 + yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') + logger.info(f"下载日期: {yesterday}") + + # 获取店铺 ID + shop_id = Config.get_shop_id() + + # 执行下载 + downloader = SecsionDownloader(username, password, download_dir='downloads', shop_id=shop_id) + file_path = asyncio.run(downloader.download_report(yesterday, yesterday)) + + if not file_path: + logger.error("自动下载失败:未获取到文件") + return + + # 导入到 SaleShow + imported_name = import_excel_file(file_path, upload_dir='uploads') + if imported_name: + logger.info(f"自动导入成功: {imported_name}") + else: + logger.error("自动导入失败") + + # 清理下载的临时文件 + cleanup_download(file_path) + + logger.info("========== 定时任务完成 ==========") + + except Exception as e: + logger.error(f"定时任务执行异常: {e}", exc_info=True) + + +def init_scheduler(app=None): + """ + 初始化定时调度器 + + Args: + app: Flask app 实例(可选,用于获取配置) + + Returns: + BackgroundScheduler: 调度器实例 + """ + global scheduler + + try: + from config import Config + schedule_config = Config.get_schedule_config() + except Exception: + schedule_config = {'enabled': True, 'hour': 1, 'minute': 0} + + if not schedule_config.get('enabled', True): + logger.info("定时任务已禁用") + return None + + hour = schedule_config.get('hour', 1) + minute = schedule_config.get('minute', 0) + + scheduler = BackgroundScheduler() + scheduler.add_job( + func=auto_download_job, + trigger=CronTrigger(hour=hour, minute=minute), + id='daily_download', + name='每日自动下载销售数据', + replace_existing=True, + misfire_grace_time=3600 # 允许 1 小时的延迟执行 + ) + scheduler.start() + + logger.info(f"定时任务已启动:每日 {hour:02d}:{minute:02d} 自动下载前一天数据") + return scheduler + + +def get_scheduler_status(): + """获取调度器状态""" + if scheduler is None: + return {'running': False, 'jobs': []} + + jobs = [] + for job in scheduler.get_jobs(): + jobs.append({ + 'id': job.id, + 'name': job.name, + 'next_run': job.next_run_time.strftime('%Y-%m-%d %H:%M:%S') if job.next_run_time else None + }) + + return { + 'running': scheduler.running, + 'jobs': jobs + } + + +def shutdown_scheduler(): + """关闭调度器""" + global scheduler + if scheduler and scheduler.running: + scheduler.shutdown(wait=False) + logger.info("定时任务已关闭") diff --git a/automation/secsion.py b/automation/secsion.py new file mode 100644 index 0000000..96662e8 --- /dev/null +++ b/automation/secsion.py @@ -0,0 +1,261 @@ +""" +secsion.com 自动化下载模块 +使用 Playwright 登录 secsion.com,导出销售报表 +""" +import asyncio +import os +import logging +import argparse +from datetime import datetime +from playwright.async_api import async_playwright + +logger = logging.getLogger(__name__) + + +class SecsionDownloader: + """从 secsion.com 自动下载销售报表""" + + LOGIN_URL = "https://secsion.com:8000/login?redirect=%252Fhomepage" + STATS_URL = "https://secsion.com:8000/commodityStatistics" + + def __init__(self, username, password, download_dir=None, shop_id=None): + self.username = username + self.password = password + self.shop_id = shop_id or '' + self.download_dir = download_dir or os.path.join(os.getcwd(), "downloads") + os.makedirs(self.download_dir, exist_ok=True) + + async def download_report(self, start_date, end_date): + """ + 下载指定日期范围的销售报表 + + Args: + start_date: 开始日期 (YYYY-MM-DD) + end_date: 结束日期 (YYYY-MM-DD) + + Returns: + str: 下载文件的本地路径,失败返回 None + """ + logger.info(f"开始下载报表: {start_date} ~ {end_date}") + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + ignore_https_errors=True, + viewport={'width': 1280, 'height': 800} + ) + page = await context.new_page() + + try: + await self._login(page) + file_path = await self._export_report(page, start_date, end_date) + logger.info(f"报表下载完成: {file_path}") + return file_path + except Exception as e: + logger.error(f"下载报表失败: {e}") + return None + finally: + await browser.close() + + async def _login(self, page): + """登录 secsion.com""" + logger.info(f"打开登录页面: {self.LOGIN_URL}") + await page.goto(self.LOGIN_URL) + + # 选择角色 "店铺" + logger.info("选择角色: 店铺") + try: + await page.get_by_text("店铺", exact=True).click() + except Exception: + await page.click("text=店铺") + + # 输入账号密码 + logger.info(f"输入账号: {self.username}") + await page.get_by_placeholder("请输入用户名").fill(self.username) + await page.get_by_placeholder("请输入密码").fill(self.password) + + # 勾选记住密码 + if await page.get_by_text("记住密码").is_visible(): + await page.get_by_text("记住密码").click() + + # 点击登录 + logger.info("点击登录按钮") + try: + await page.click("button:has-text('登录')", timeout=5000) + except Exception: + await page.click("button[type='submit']") + + # 等待跳转 + logger.info("等待登录跳转...") + await page.wait_for_url("**/homePage", timeout=20000) + logger.info("登录成功") + + async def _export_report(self, page, start_date, end_date): + """访问统计页面并导出报表""" + logger.info(f"访问统计页面: {self.STATS_URL}") + await page.goto(self.STATS_URL) + await page.wait_for_load_state("networkidle") + + export_btn = page.get_by_role("button", name="导出报表") + await export_btn.wait_for(state="visible", timeout=20000) + + logger.info(f"设置查询日期范围: {start_date} ~ {end_date}") + + start_input = page.get_by_role("textbox", name="请选择日期").nth(0) + end_input = page.get_by_role("textbox", name="请选择日期").nth(1) + + # 设置开始日期(内部已处理 Enter 确认 + Escape 关闭) + await self._set_date(page, start_input, start_date) + await page.wait_for_timeout(500) + + # 设置结束日期 + await self._set_date(page, end_input, end_date) + await page.wait_for_timeout(500) + + # 验证日期设置结果 + start_val = await start_input.input_value() + end_val = await end_input.input_value() + logger.info(f"日期设置结果: 开始={start_val}, 结束={end_val}") + + # 等待数据请求完成 + logger.info("等待数据请求完成...") + await asyncio.sleep(3) + + # 如果配置了 shop_id,拦截导出请求注入 shop_id + if self.shop_id: + import json + + async def inject_shop_id(route): + request = route.request + body = json.loads(request.post_data) + body['shop_id'] = self.shop_id + logger.info(f"注入 shop_id: {self.shop_id}") + await route.continue_(post_data=json.dumps(body)) + + await page.route('**/api/bill/export', inject_shop_id) + logger.info(f"已设置 shop_id 拦截: {self.shop_id}") + + # 点击导出报表并捕获下载 + logger.info("点击导出报表...") + async with page.expect_download(timeout=60000) as download_info: + await export_btn.click() + + download = await download_info.value + filename = download.suggested_filename + save_path = os.path.join(self.download_dir, filename) + await download.save_as(save_path) + logger.info(f"报表已保存至: {save_path}") + return save_path + + async def _set_date(self, page, input_box, date_str): + """ + 设置 TDesign 日期选择器的值 + + TDesign 的 needconfirm="true" 模式要求: + 1. 点击输入框打开日历 + 2. 点击日期格子选择日期 + 3. 在输入框上按 Enter 确认(关键!不确认则关闭时回滚) + 4. Escape 关闭日历 + """ + for attempt in range(3): + logger.info(f"设置日期: {date_str} (第 {attempt + 1} 次尝试)") + + # 1. 点击输入框打开日历 + await input_box.click() + await page.wait_for_timeout(500) + + # 2. 点击目标日期格子 + target_day = str(int(date_str.split("-")[2])) + day_cells = page.get_by_role("cell", name=target_day) + cell_count = await day_cells.count() + + if cell_count > 0: + await day_cells.first.click() + await page.wait_for_timeout(500) + else: + logger.warning(f"未找到日期格子: {target_day}") + continue + + # 3. Enter 确认(needconfirm="true" 必须显式确认) + await input_box.press("Enter") + await page.wait_for_timeout(500) + + # 4. Escape 关闭日历 + await page.keyboard.press("Escape") + await page.wait_for_timeout(500) + + # 5. 验证 + val = await input_box.input_value() + if date_str in val: + logger.info(f"日期设置成功: {val}") + return + + logger.warning(f"日期设置验证失败: 期望包含 '{date_str}', 实际 '{val}'") + + logger.error(f"日期设置失败(3次尝试后): {date_str}") + + +async def download_report(start_date, end_date, username=None, password=None, download_dir=None, shop_id=None): + """ + 便捷函数:下载指定日期范围的报表 + + Args: + start_date: 开始日期 (YYYY-MM-DD) + end_date: 结束日期 (YYYY-MM-DD) + username: secsion.com 用户名(可选,优先使用 config) + password: secsion.com 密码(可选,优先使用 config) + download_dir: 下载目录(可选) + + Returns: + str: 下载文件路径,失败返回 None + """ + if not username or not password: + from config import Config + creds = Config.get_secsion_credentials() + if not creds: + logger.error("未配置 secsion.com 登录凭据") + return None + username, password = creds + + if not shop_id: + try: + from config import Config + shop_id = Config.get_shop_id() + except Exception: + pass + + downloader = SecsionDownloader(username, password, download_dir, shop_id) + return await downloader.download_report(start_date, end_date) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('automation.log', encoding='utf-8') + ] + ) + + parser = argparse.ArgumentParser(description='secsion.com 报表自动下载工具') + parser.add_argument('--start', type=str, help='开始日期 (YYYY-MM-DD)', default=datetime.now().strftime('%Y-%m-%d')) + parser.add_argument('--end', type=str, help='结束日期 (YYYY-MM-DD)') + parser.add_argument('--username', type=str, help='secsion.com 用户名') + parser.add_argument('--password', type=str, help='secsion.com 密码') + + args = parser.parse_args() + end_date = args.end or args.start + + result = asyncio.run(download_report( + start_date=args.start, + end_date=end_date, + username=args.username, + password=args.password + )) + + if result: + print(f"下载成功: {result}") + else: + print("下载失败") + exit(1) diff --git a/automation/uploader.py b/automation/uploader.py new file mode 100644 index 0000000..8edbe74 --- /dev/null +++ b/automation/uploader.py @@ -0,0 +1,56 @@ +""" +本地文件导入模块 +将下载的 Excel 文件直接导入 SaleShow 的 uploads 目录 +""" +import os +import shutil +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def import_excel_file(source_path, upload_dir='uploads'): + """ + 将 Excel 文件导入 SaleShow 的 uploads 目录 + + Args: + source_path: 源文件路径 + upload_dir: SaleShow 的上传目录路径 + + Returns: + str: 导入后的文件名(带时间戳前缀),失败返回 None + """ + try: + if not os.path.exists(source_path): + logger.error(f"源文件不存在: {source_path}") + return None + + # 确保上传目录存在 + os.makedirs(upload_dir, exist_ok=True) + + # 生成带时间戳的文件名(与 SaleShow 手动上传的命名规则一致) + original_name = os.path.basename(source_path) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_') + new_filename = timestamp + original_name + dest_path = os.path.join(upload_dir, new_filename) + + # 复制文件 + shutil.copy2(source_path, dest_path) + logger.info(f"文件已导入: {source_path} -> {dest_path}") + + return new_filename + + except Exception as e: + logger.error(f"导入文件失败: {e}") + return None + + +def cleanup_download(filepath): + """清理下载的临时文件""" + try: + if filepath and os.path.exists(filepath): + os.remove(filepath) + logger.info(f"已清理临时文件: {filepath}") + except Exception as e: + logger.warning(f"清理临时文件失败: {e}") diff --git a/config.py b/config.py new file mode 100644 index 0000000..5328505 --- /dev/null +++ b/config.py @@ -0,0 +1,159 @@ +""" +配置管理模块 +优先级:Web UI 设置 (data/config.json) > 环境变量 > 默认值 +""" +import os +import json +import logging + +logger = logging.getLogger(__name__) + +CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'config.json') + +# 默认配置 +DEFAULT_CONFIG = { + 'secsion': { + 'username': '', + 'password': '' + }, + 'scheduler': { + 'enabled': True, + 'hour': 1, + 'minute': 0 + } +} + + +def _ensure_config_dir(): + """确保配置目录存在""" + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) + + +def _load_config(): + """从 config.json 加载配置""" + try: + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.warning(f"读取配置文件失败: {e}") + return {} + + +def _save_config(config): + """保存配置到 config.json""" + _ensure_config_dir() + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + logger.info("配置已保存") + return True + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + return False + + +class Config: + """配置管理器""" + + @staticmethod + def get_secsion_credentials(): + """ + 获取 secsion.com 登录凭据 + + 优先级:config.json > 环境变量 + + Returns: + tuple: (username, password) 或 None + """ + # 1. 从 config.json 读取 + config = _load_config() + secsion = config.get('secsion', {}) + username = secsion.get('username', '').strip() + password = secsion.get('password', '').strip() + + if username and password: + return (username, password) + + # 2. 从环境变量读取 + env_username = os.environ.get('SECSION_USERNAME', '').strip() + env_password = os.environ.get('SECSION_PASSWORD', '').strip() + + if env_username and env_password: + return (env_username, env_password) + + return None + + @staticmethod + def save_secsion_credentials(username, password): + """保存 secsion.com 登录凭据""" + config = _load_config() + config.setdefault('secsion', {}) + config['secsion']['username'] = username + config['secsion']['password'] = password + return _save_config(config) + + @staticmethod + def get_schedule_config(): + """ + 获取定时任务配置 + + Returns: + dict: {'enabled': bool, 'hour': int, 'minute': int} + """ + config = _load_config() + schedule = config.get('scheduler', {}) + + # 回退到环境变量 + env_enabled = os.environ.get('SCHEDULER_ENABLED', '').lower() + env_hour = os.environ.get('SCHEDULER_HOUR', '') + env_minute = os.environ.get('SCHEDULER_MINUTE', '') + + return { + 'enabled': schedule.get('enabled', env_enabled != 'false' if env_enabled else True), + 'hour': schedule.get('hour', int(env_hour) if env_hour else 1), + 'minute': schedule.get('minute', int(env_minute) if env_minute else 0) + } + + @staticmethod + def save_schedule_config(enabled=True, hour=1, minute=0): + """保存定时任务配置""" + config = _load_config() + config.setdefault('scheduler', {}) + config['scheduler']['enabled'] = enabled + config['scheduler']['hour'] = hour + config['scheduler']['minute'] = minute + return _save_config(config) + + @staticmethod + def get_shop_id(): + """获取店铺 ID""" + config = _load_config() + secsion = config.get('secsion', {}) + shop_id = secsion.get('shop_id', '').strip() + if shop_id: + return shop_id + return os.environ.get('SECSION_SHOP_ID', '').strip() or None + + @staticmethod + def save_shop_id(shop_id): + """保存店铺 ID""" + config = _load_config() + config.setdefault('secsion', {}) + config['secsion']['shop_id'] = shop_id + return _save_config(config) + + @staticmethod + def get_all_config(): + """获取所有配置(密码脱敏)""" + config = _load_config() + secsion = config.get('secsion', {}) + + return { + 'secsion': { + 'username': secsion.get('username', ''), + 'password': '******' if secsion.get('password') else '', + 'shop_id': secsion.get('shop_id', '') + }, + 'scheduler': config.get('scheduler', DEFAULT_CONFIG['scheduler']) + }