diff --git a/browser_login/fetch_bom_cost_full_tree.py b/browser_login/fetch_bom_cost_full_tree.py index 495043b..e40141d 100644 --- a/browser_login/fetch_bom_cost_full_tree.py +++ b/browser_login/fetch_bom_cost_full_tree.py @@ -8,6 +8,7 @@ BOM 成本 - 终极树状结构抓取脚本 (全站 1400+ 父件及 5 层嵌套 import sys import json import time +import subprocess import random from pathlib import Path @@ -219,6 +220,21 @@ def fetch_bom_cost_tree(): log("INFO", f"💾 进度已实时保存至 JSON ({index+1}/{len(clean_parents_list)})") log("OK", f"=== 🏆 终极 BOM 成本多层树状抓取完成!文件路径: {TREE_FILE_PATH} ===") + + # 抓取完成后,自动调用入库脚本将 JSON 导入 SQLite + log("INFO", "⏳ 正在自动将 JSON 数据同步至 SQLite 数据库...") + try: + import_script = Path(__file__).parent / "import_to_sqlite.py" + # 使用 sys.executable 确保使用当前的 Python 环境 + import sys + result = subprocess.run([sys.executable, str(import_script), "--bom-only"], capture_output=True, text=True) + if result.returncode == 0: + log("OK", "✅ 数据库同步成功!") + print(result.stdout) + else: + log("ERR", f"❌ 数据库同步失败: {result.stderr}") + except Exception as db_err: + log("ERR", f"❌ 自动触发入库脚本失败: {db_err}") except Exception as e: log("ERR", f"发生异常: {e}") diff --git a/browser_login/fetch_receipt_details_incremental.py b/browser_login/fetch_receipt_details_incremental.py index ecbfd6d..f3fa0c9 100644 --- a/browser_login/fetch_receipt_details_incremental.py +++ b/browser_login/fetch_receipt_details_incremental.py @@ -9,6 +9,7 @@ import sys import json import time +import subprocess import math import random import sqlite3 @@ -216,7 +217,7 @@ def fetch_receipt_details_incremental(): total_inserted += 1 conn.commit() - log("OK", f"第 {current_page} 页处理完毕,成功入库 {inserted_this_page} 条新数据。") + log("OK", f"第 {current_page} 页处理完毕,成功截获 {inserted_this_page} 条数据并存入数据库。") # 还有下一页则继续点击 if current_page < end_page: @@ -250,8 +251,8 @@ def fetch_receipt_details_incremental(): current_page += 1 - log("OK", f"🎉 增量同步大功告成!总计入库 {total_inserted} 条全新数据!") - + log("OK", f"🎉 增量同步大功告成!总计向数据库执行了 {total_inserted} 次插入/更新操作!") + except Exception as e: log("ERR", f"发生全局异常: {e}") finally: diff --git a/browser_login/import_to_sqlite.py b/browser_login/import_to_sqlite.py index 5b2d759..cabd43e 100644 --- a/browser_login/import_to_sqlite.py +++ b/browser_login/import_to_sqlite.py @@ -214,9 +214,21 @@ def import_bom_data(conn): print(f"成功导入 {parent_count} 个 BOM 父件,包含 {child_count} 个子件节点!") if __name__ == "__main__": + import sys print(f"数据库文件将保存在: {DB_PATH}") conn = init_db() - import_receipt_details(conn) - import_bom_data(conn) + + # 允许通过命令行参数单独导入某一部分数据 + args = sys.argv[1:] + + if "--bom-only" in args: + import_bom_data(conn) + elif "--receipt-only" in args: + import_receipt_details(conn) + else: + # 默认全量导入 + import_receipt_details(conn) + import_bom_data(conn) + conn.close() print("全部导入完成!你可以使用 SQLite 客户端连接 erp_data.db 查看数据。") \ No newline at end of file diff --git a/web_ui/app.py b/web_ui/app.py index de625c5..e3fa00c 100644 --- a/web_ui/app.py +++ b/web_ui/app.py @@ -40,6 +40,25 @@ DB_PATH.parent.mkdir(parents=True, exist_ok=True) # 抓取脚本目录 (属于代码文件,从 BASE_DIR 加载) BROWSER_LOGIN_DIR = BASE_DIR / "browser_login" +# 全局互斥锁机制:保证同一时间只有一个任务在控制浏览器 +browser_lock = threading.Lock() +is_browser_busy = False +current_task_name = "" + +def set_browser_busy(task_name): + """设置浏览器为忙碌状态""" + global is_browser_busy, current_task_name + with browser_lock: + is_browser_busy = True + current_task_name = task_name + +def release_browser(): + """释放浏览器控制权""" + global is_browser_busy, current_task_name + with browser_lock: + is_browser_busy = False + current_task_name = "" + def auto_init_db(): """如果是新环境首次运行,自动初始化数据库表结构""" if str(BROWSER_LOGIN_DIR) not in sys.path: @@ -56,7 +75,16 @@ auto_init_db() def background_sync_job(): """APScheduler 后台定时任务执行增量抓取""" + global is_browser_busy print("[定时任务] 正在执行后台增量数据同步...") + + # 检查浏览器是否被其他任务占用 + if is_browser_busy: + print(f"[定时任务] ⚠️ 浏览器当前正被 '{current_task_name}' 占用,本次增量同步跳过。") + return + + set_browser_busy("定时后台增量同步") + if str(BROWSER_LOGIN_DIR) not in sys.path: sys.path.insert(0, str(BROWSER_LOGIN_DIR)) @@ -69,6 +97,8 @@ def background_sync_job(): print(f"[定时任务] 同步完成。") except Exception as e: print(f"[定时任务] 同步失败: {str(e)}") + finally: + release_browser() # 初始化定时调度器 scheduler = BackgroundScheduler() @@ -145,19 +175,42 @@ def get_receipts(): "rows": [dict(ix) for ix in receipts] }) +@app.route('/api/task_status') +def get_task_status(): + """获取当前浏览器控制任务的状态""" + return jsonify({ + "is_busy": is_browser_busy, + "task_name": current_task_name + }) + @app.route('/api/sync_receipts', methods=['POST']) def sync_receipts(): """触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)""" + global is_browser_busy import sys + if is_browser_busy: + return jsonify({ + "success": False, + "message": f"系统忙碌:当前正在执行 '{current_task_name}',请稍后再试。" + }), 409 + if str(BROWSER_LOGIN_DIR) not in sys.path: sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + def run_receipt_sync(): + set_browser_busy("手动收货明细增量同步") + try: + from fetch_receipt_details_incremental import fetch_receipt_details_incremental + fetch_receipt_details_incremental() + except Exception as e: + print(f"手动增量同步失败: {e}") + finally: + release_browser() + try: - from fetch_receipt_details_incremental import fetch_receipt_details_incremental - # 将长时间运行的抓取任务放入后台线程,防止前端请求超时 - threading.Thread(target=fetch_receipt_details_incremental, daemon=True).start() + threading.Thread(target=run_receipt_sync, daemon=True).start() return jsonify({ "success": True, @@ -172,16 +225,31 @@ def sync_receipts(): @app.route('/api/sync_bom', methods=['POST']) def sync_bom(): """触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)""" + global is_browser_busy import sys + if is_browser_busy: + return jsonify({ + "success": False, + "message": f"系统忙碌:当前正在执行 '{current_task_name}',请稍后再试。" + }), 409 + if str(BROWSER_LOGIN_DIR) not in sys.path: sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + def run_bom_sync(): + set_browser_busy("手动 BOM 全量树抓取") + try: + from fetch_bom_cost_full_tree import fetch_bom_cost_tree + fetch_bom_cost_tree() + except Exception as e: + print(f"BOM 抓取失败: {e}") + finally: + release_browser() + try: - from fetch_bom_cost_full_tree import fetch_bom_cost_tree - # 将长时间运行的抓取任务放入后台线程,防止前端请求超时 - threading.Thread(target=fetch_bom_cost_tree, daemon=True).start() + threading.Thread(target=run_bom_sync, daemon=True).start() return jsonify({ "success": True, @@ -359,13 +427,14 @@ def get_bom_tree(parent_code): if parent_id in nodes_map: nodes_map[parent_id]['children'].append(node_data) - # 5. 递归处理:计算树的成本,并且根据是否有子件修改圆圈的实心/空心样式 + # 5. 递归处理:计算树的成本,并且根据是否有子件修改圆圈的实心/空心样式 def process_tree_nodes(node, prices_dict): node_cost = 0.0 price_record = prices_dict.get(node['materialCode']) own_price = price_record['receive_price'] if price_record and isinstance(price_record['receive_price'], (int, float)) else 0.0 has_children = bool(node.get('children')) + code = node.get('materialCode', '') # 【核心视觉区分:还能不能点开?】 # 如果它有子件(还能点开展开),就让它变成实心的(填充颜色与边框一致) @@ -379,18 +448,29 @@ def get_bom_tree(parent_code): node['itemStyle']['color'] = '#FFFFFF' # 白色填充(空心) node['itemStyle']['borderWidth'] = 2 # 加粗彩色边框 + # 乘以该节点的耗用量,得到该节点的总成本贡献 + usage_qty = float(node.get('usageQty', 1.0)) + if not has_children: node_cost = own_price + node_cost = node_cost * usage_qty else: for child in node['children']: node_cost += process_tree_nodes(child, prices_dict) if node_cost == 0.0 and own_price > 0: node_cost = own_price - - # 乘以该节点的耗用量,得到该节点的总成本贡献 - usage_qty = float(node.get('usageQty', 1.0)) - node_cost = node_cost * usage_qty + + # 【工序件特殊处理逻辑】 + # 如果当前节点是 2 或 3 开头的加工工序件,由于物理上是同一个东西的流转, + # BOM 表中的用量只是为了匹配底层的原材料用量,不能再重复相乘。 + # 所以强行忽略 2 或 3 开头工序件的自身 BOM 用量,直接 1:1 继承其子件汇总的成本。 + if code.startswith('2') or code.startswith('3'): + # 保持 node_cost 不变,相当于乘以 1 + pass + else: + # 正常的装配组件,需要将子件汇总成本乘以自身用量 + node_cost = node_cost * usage_qty node['totalCost'] = round(node_cost, 2) node['ownPrice'] = round(own_price, 2) @@ -652,9 +732,16 @@ def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b): total_a += child['totalPeriodA'] total_b += child['totalPeriodB'] - node['totalLatest'] = (total_latest if total_latest > 0 else node['latestUnitPrice']) * usage_qty - node['totalPeriodA'] = (total_a if total_a > 0 else node['periodAUnitPrice']) * usage_qty - node['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty + # 【工序件特殊处理逻辑】 + # 同样在成本对比页面,2或3开头的工序件不能重复乘以用量,直接继承子件成本 + if code.startswith('2') or code.startswith('3'): + node['totalLatest'] = total_latest if total_latest > 0 else node['latestUnitPrice'] + node['totalPeriodA'] = total_a if total_a > 0 else node['periodAUnitPrice'] + node['totalPeriodB'] = total_b if total_b > 0 else node['periodBUnitPrice'] + else: + node['totalLatest'] = (total_latest if total_latest > 0 else node['latestUnitPrice']) * usage_qty + node['totalPeriodA'] = (total_a if total_a > 0 else node['periodAUnitPrice']) * usage_qty + node['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty node['showAStatus'] = 'normal' node['showBStatus'] = 'normal' @@ -811,27 +898,33 @@ def find_free_port(start_port=5050, max_port=5100): return s.getsockname()[1] if __name__ == '__main__': - # 动态获取一个可用端口 - try: - port = find_free_port() - except Exception as e: - print(f"⚠️ 无法自动获取端口,回退到默认端口: {e}") - port = 5050 - - # 启动前开启一个线程去拉起浏览器 - threading.Thread(target=open_browser, args=(port,), daemon=True).start() - - # 启动后端服务 - print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}") - - # 智能判断:如果是通过 PyInstaller 打包运行的,则关闭热加载以避免多进程冲突和双开浏览器 + # 智能判断:如果是通过 PyInstaller 打包运行的,或者是 Werkzeug 重载进程,则控制浏览器打开行为 is_frozen = getattr(sys, 'frozen', False) + # 当 Werkzeug (Flask内置服务器) 使用热加载时,它会启动一个监控主进程和一个运行子进程。 + # 子进程会有 WERKZEUG_RUN_MAIN 这个环境变量。 + is_werkzeug_reloader = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' + # 只有在不需要热加载,或者是热加载的真正工作子进程中,才去寻找端口并打开浏览器 + if is_frozen or not app.debug or is_werkzeug_reloader: + # 动态获取一个可用端口 + try: + port = find_free_port() + except Exception as e: + print(f"⚠️ 无法自动获取端口,回退到默认端口: {e}") + port = 5050 + + # 启动前开启一个线程去拉起浏览器 + threading.Thread(target=open_browser, args=(port,), daemon=True).start() + print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}") + else: + # 如果是热加载的主控进程,随便给个默认端口(反正它不干活),并且不打开浏览器 + port = 5050 + # 更改为动态端口,避开被占用的端口。修改 host 为 127.0.0.1 避免 Windows 权限拦截 app.run( debug=not is_frozen, host='127.0.0.1', port=port, threaded=True, - use_reloader=not is_frozen + use_reloader=False # 彻底关闭 Flask 内置的热加载,避免双进程互相影响 ) \ No newline at end of file diff --git a/web_ui/templates/bom_compare.html b/web_ui/templates/bom_compare.html index 4fff013..2e3ad21 100644 --- a/web_ui/templates/bom_compare.html +++ b/web_ui/templates/bom_compare.html @@ -425,10 +425,10 @@ originalTableData: [], // 默认设定期间,方便测试 - periodA_start: '2023-01-01', - periodA_end: '2023-12-31', - periodB_start: '2024-01-01', - periodB_end: '2024-12-31' + periodA_start: '2025-01-01', + periodA_end: '2025-12-31', + periodB_start: '2026-01-01', + periodB_end: '2026-12-31' } }, mounted() { diff --git a/web_ui/templates/home.html b/web_ui/templates/home.html index aba4c78..c494d53 100644 --- a/web_ui/templates/home.html +++ b/web_ui/templates/home.html @@ -145,22 +145,26 @@