html逻辑优化
This commit is contained in:
@@ -8,6 +8,7 @@ BOM 成本 - 终极树状结构抓取脚本 (全站 1400+ 父件及 5 层嵌套
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
@@ -220,6 +221,21 @@ def fetch_bom_cost_tree():
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@@ -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,7 +251,7 @@ 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}")
|
||||
|
||||
@@ -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 查看数据。")
|
||||
149
web_ui/app.py
149
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))
|
||||
|
||||
try:
|
||||
from fetch_receipt_details_incremental import fetch_receipt_details_incremental
|
||||
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:
|
||||
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||
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))
|
||||
|
||||
try:
|
||||
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
|
||||
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:
|
||||
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||
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,8 +448,12 @@ 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)
|
||||
@@ -388,9 +461,16 @@ def get_bom_tree(parent_code):
|
||||
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,21 +898,27 @@ 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(
|
||||
@@ -833,5 +926,5 @@ if __name__ == '__main__':
|
||||
host='127.0.0.1',
|
||||
port=port,
|
||||
threaded=True,
|
||||
use_reloader=not is_frozen
|
||||
use_reloader=False # 彻底关闭 Flask 内置的热加载,避免双进程互相影响
|
||||
)
|
||||
@@ -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() {
|
||||
|
||||
@@ -145,22 +145,26 @@
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<div v-if="globalTaskName" style="position: absolute; top: -30px; width: 100%; text-align: center; color: #E6A23C; font-weight: bold; font-size: 14px;">
|
||||
<i class="el-icon-loading"></i> 系统忙碌中:正在执行 {{ globalTaskName }},在此期间无法发起新的抓取。
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
:icon="syncing ? '' : 'el-icon-refresh'"
|
||||
:loading="syncing"
|
||||
:icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh'"
|
||||
:disabled="isSystemBusy"
|
||||
@click="syncReceipts"
|
||||
round>
|
||||
<span v-text="syncing ? '正在后台同步增量数据,请稍候...' : '读取最新收货明细报表'"></span>
|
||||
<span v-text="syncing ? '请求已发送...' : '读取最新收货明细报表'"></span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="syncingBom ? '' : 'el-icon-refresh-right'"
|
||||
:loading="syncingBom"
|
||||
:icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh-right'"
|
||||
:disabled="isSystemBusy"
|
||||
@click="syncBom"
|
||||
round>
|
||||
<span v-text="syncingBom ? '正在后台抓取 BOM 树,耗时较长,请稍候...' : '读取最新 BOM 表'"></span>
|
||||
<span v-text="syncingBom ? '请求已发送...' : '读取最新 BOM 表'"></span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,24 +175,53 @@
|
||||
data() {
|
||||
return {
|
||||
syncing: false,
|
||||
syncingBom: false
|
||||
syncingBom: false,
|
||||
isSystemBusy: false,
|
||||
globalTaskName: "",
|
||||
statusTimer: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 页面加载时立刻检查一次
|
||||
this.checkTaskStatus();
|
||||
// 之后每隔 3 秒轮询一次后端状态
|
||||
this.statusTimer = setInterval(this.checkTaskStatus, 3000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.statusTimer) {
|
||||
clearInterval(this.statusTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkTaskStatus() {
|
||||
axios.get('/api/task_status')
|
||||
.then(res => {
|
||||
this.isSystemBusy = res.data.is_busy;
|
||||
this.globalTaskName = res.data.task_name;
|
||||
})
|
||||
.catch(err => {
|
||||
// 忽略检查错误
|
||||
});
|
||||
},
|
||||
syncReceipts() {
|
||||
this.syncing = true;
|
||||
|
||||
axios.post('/api/sync_receipts')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
this.$message.success('明细同步成功!' + res.data.message);
|
||||
this.$message.success('已触发!' + res.data.message);
|
||||
// 立即主动检查一次状态更新按钮
|
||||
setTimeout(this.checkTaskStatus, 500);
|
||||
} else {
|
||||
this.$message.error('同步失败:' + res.data.message);
|
||||
this.$message.error('触发失败:' + res.data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.$message.error('请求发生异常,请检查后端日志。');
|
||||
console.error(err);
|
||||
if (err.response && err.response.status === 409) {
|
||||
this.$message.warning(err.response.data.message);
|
||||
} else {
|
||||
this.$message.error('请求发生异常,请检查后端日志。');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.syncing = false;
|
||||
@@ -200,14 +233,18 @@
|
||||
axios.post('/api/sync_bom')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
this.$message.success('BOM 同步成功!' + res.data.message);
|
||||
this.$message.success('已触发!' + res.data.message);
|
||||
setTimeout(this.checkTaskStatus, 500);
|
||||
} else {
|
||||
this.$message.error('同步失败:' + res.data.message);
|
||||
this.$message.error('触发失败:' + res.data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.$message.error('请求发生异常,这可能需要较长时间,请检查控制台日志。');
|
||||
console.error(err);
|
||||
if (err.response && err.response.status === 409) {
|
||||
this.$message.warning(err.response.data.message);
|
||||
} else {
|
||||
this.$message.error('请求发生异常,请检查后端日志。');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.syncingBom = false;
|
||||
|
||||
Reference in New Issue
Block a user