html逻辑优化

This commit is contained in:
Jimmy
2026-05-25 16:32:53 +08:00
parent 031ec4d289
commit 66eecd0daa
6 changed files with 212 additions and 53 deletions

View File

@@ -8,6 +8,7 @@ BOM 成本 - 终极树状结构抓取脚本 (全站 1400+ 父件及 5 层嵌套
import sys import sys
import json import json
import time import time
import subprocess
import random import random
from pathlib import Path from pathlib import Path
@@ -220,6 +221,21 @@ def fetch_bom_cost_tree():
log("OK", f"=== 🏆 终极 BOM 成本多层树状抓取完成!文件路径: {TREE_FILE_PATH} ===") 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: except Exception as e:
log("ERR", f"发生异常: {e}") log("ERR", f"发生异常: {e}")

View File

@@ -9,6 +9,7 @@
import sys import sys
import json import json
import time import time
import subprocess
import math import math
import random import random
import sqlite3 import sqlite3
@@ -216,7 +217,7 @@ def fetch_receipt_details_incremental():
total_inserted += 1 total_inserted += 1
conn.commit() conn.commit()
log("OK", f"{current_page} 页处理完毕,成功入库 {inserted_this_page}数据。") log("OK", f"{current_page} 页处理完毕,成功截获 {inserted_this_page}数据并存入数据")
# 还有下一页则继续点击 # 还有下一页则继续点击
if current_page < end_page: if current_page < end_page:
@@ -250,7 +251,7 @@ def fetch_receipt_details_incremental():
current_page += 1 current_page += 1
log("OK", f"🎉 增量同步大功告成!总计入库 {total_inserted} 条全新数据") log("OK", f"🎉 增量同步大功告成!总计向数据库执行了 {total_inserted} 次插入/更新操作")
except Exception as e: except Exception as e:
log("ERR", f"发生全局异常: {e}") log("ERR", f"发生全局异常: {e}")

View File

@@ -214,9 +214,21 @@ def import_bom_data(conn):
print(f"成功导入 {parent_count} 个 BOM 父件,包含 {child_count} 个子件节点!") print(f"成功导入 {parent_count} 个 BOM 父件,包含 {child_count} 个子件节点!")
if __name__ == "__main__": if __name__ == "__main__":
import sys
print(f"数据库文件将保存在: {DB_PATH}") print(f"数据库文件将保存在: {DB_PATH}")
conn = init_db() conn = init_db()
# 允许通过命令行参数单独导入某一部分数据
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_receipt_details(conn)
import_bom_data(conn) import_bom_data(conn)
conn.close() conn.close()
print("全部导入完成!你可以使用 SQLite 客户端连接 erp_data.db 查看数据。") print("全部导入完成!你可以使用 SQLite 客户端连接 erp_data.db 查看数据。")

View File

@@ -40,6 +40,25 @@ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
# 抓取脚本目录 (属于代码文件,从 BASE_DIR 加载) # 抓取脚本目录 (属于代码文件,从 BASE_DIR 加载)
BROWSER_LOGIN_DIR = BASE_DIR / "browser_login" 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(): def auto_init_db():
"""如果是新环境首次运行,自动初始化数据库表结构""" """如果是新环境首次运行,自动初始化数据库表结构"""
if str(BROWSER_LOGIN_DIR) not in sys.path: if str(BROWSER_LOGIN_DIR) not in sys.path:
@@ -56,7 +75,16 @@ auto_init_db()
def background_sync_job(): def background_sync_job():
"""APScheduler 后台定时任务执行增量抓取""" """APScheduler 后台定时任务执行增量抓取"""
global is_browser_busy
print("[定时任务] 正在执行后台增量数据同步...") print("[定时任务] 正在执行后台增量数据同步...")
# 检查浏览器是否被其他任务占用
if is_browser_busy:
print(f"[定时任务] ⚠️ 浏览器当前正被 '{current_task_name}' 占用,本次增量同步跳过。")
return
set_browser_busy("定时后台增量同步")
if str(BROWSER_LOGIN_DIR) not in sys.path: if str(BROWSER_LOGIN_DIR) not in sys.path:
sys.path.insert(0, str(BROWSER_LOGIN_DIR)) sys.path.insert(0, str(BROWSER_LOGIN_DIR))
@@ -69,6 +97,8 @@ def background_sync_job():
print(f"[定时任务] 同步完成。") print(f"[定时任务] 同步完成。")
except Exception as e: except Exception as e:
print(f"[定时任务] 同步失败: {str(e)}") print(f"[定时任务] 同步失败: {str(e)}")
finally:
release_browser()
# 初始化定时调度器 # 初始化定时调度器
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
@@ -145,19 +175,42 @@ def get_receipts():
"rows": [dict(ix) for ix in 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']) @app.route('/api/sync_receipts', methods=['POST'])
def sync_receipts(): def sync_receipts():
"""触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)""" """触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)"""
global is_browser_busy
import sys 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: if str(BROWSER_LOGIN_DIR) not in sys.path:
sys.path.insert(0, str(BROWSER_LOGIN_DIR)) sys.path.insert(0, str(BROWSER_LOGIN_DIR))
def run_receipt_sync():
set_browser_busy("手动收货明细增量同步")
try: try:
from fetch_receipt_details_incremental import fetch_receipt_details_incremental 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({ return jsonify({
"success": True, "success": True,
@@ -172,16 +225,31 @@ def sync_receipts():
@app.route('/api/sync_bom', methods=['POST']) @app.route('/api/sync_bom', methods=['POST'])
def sync_bom(): def sync_bom():
"""触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)""" """触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)"""
global is_browser_busy
import sys 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: if str(BROWSER_LOGIN_DIR) not in sys.path:
sys.path.insert(0, str(BROWSER_LOGIN_DIR)) sys.path.insert(0, str(BROWSER_LOGIN_DIR))
def run_bom_sync():
set_browser_busy("手动 BOM 全量树抓取")
try: try:
from fetch_bom_cost_full_tree import fetch_bom_cost_tree 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({ return jsonify({
"success": True, "success": True,
@@ -366,6 +434,7 @@ def get_bom_tree(parent_code):
own_price = price_record['receive_price'] if price_record and isinstance(price_record['receive_price'], (int, float)) else 0.0 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')) 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']['color'] = '#FFFFFF' # 白色填充(空心)
node['itemStyle']['borderWidth'] = 2 # 加粗彩色边框 node['itemStyle']['borderWidth'] = 2 # 加粗彩色边框
# 乘以该节点的耗用量,得到该节点的总成本贡献
usage_qty = float(node.get('usageQty', 1.0))
if not has_children: if not has_children:
node_cost = own_price node_cost = own_price
node_cost = node_cost * usage_qty
else: else:
for child in node['children']: for child in node['children']:
node_cost += process_tree_nodes(child, prices_dict) node_cost += process_tree_nodes(child, prices_dict)
@@ -388,8 +461,15 @@ def get_bom_tree(parent_code):
if node_cost == 0.0 and own_price > 0: if node_cost == 0.0 and own_price > 0:
node_cost = own_price node_cost = own_price
# 乘以该节点的耗用量,得到该节点的总成本贡献 # 【工序件特殊处理逻辑】
usage_qty = float(node.get('usageQty', 1.0)) # 如果当前节点是 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_cost = node_cost * usage_qty
node['totalCost'] = round(node_cost, 2) node['totalCost'] = round(node_cost, 2)
@@ -652,6 +732,13 @@ def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b):
total_a += child['totalPeriodA'] total_a += child['totalPeriodA']
total_b += child['totalPeriodB'] total_b += child['totalPeriodB']
# 【工序件特殊处理逻辑】
# 同样在成本对比页面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['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['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['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty
@@ -811,6 +898,14 @@ def find_free_port(start_port=5050, max_port=5100):
return s.getsockname()[1] return s.getsockname()[1]
if __name__ == '__main__': if __name__ == '__main__':
# 智能判断:如果是通过 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: try:
port = find_free_port() port = find_free_port()
@@ -820,12 +915,10 @@ if __name__ == '__main__':
# 启动前开启一个线程去拉起浏览器 # 启动前开启一个线程去拉起浏览器
threading.Thread(target=open_browser, args=(port,), daemon=True).start() threading.Thread(target=open_browser, args=(port,), daemon=True).start()
# 启动后端服务
print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}") print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}")
else:
# 智能判断:如果是通过 PyInstaller 打包运行的,则关闭热加载以避免多进程冲突和双开浏览器 # 如果是热加载的主控进程,随便给个默认端口(反正它不干活),并且不打开浏览器
is_frozen = getattr(sys, 'frozen', False) port = 5050
# 更改为动态端口,避开被占用的端口。修改 host 为 127.0.0.1 避免 Windows 权限拦截 # 更改为动态端口,避开被占用的端口。修改 host 为 127.0.0.1 避免 Windows 权限拦截
app.run( app.run(
@@ -833,5 +926,5 @@ if __name__ == '__main__':
host='127.0.0.1', host='127.0.0.1',
port=port, port=port,
threaded=True, threaded=True,
use_reloader=not is_frozen use_reloader=False # 彻底关闭 Flask 内置的热加载,避免双进程互相影响
) )

View File

@@ -425,10 +425,10 @@
originalTableData: [], originalTableData: [],
// 默认设定期间,方便测试 // 默认设定期间,方便测试
periodA_start: '2023-01-01', periodA_start: '2025-01-01',
periodA_end: '2023-12-31', periodA_end: '2025-12-31',
periodB_start: '2024-01-01', periodB_start: '2026-01-01',
periodB_end: '2024-12-31' periodB_end: '2026-12-31'
} }
}, },
mounted() { mounted() {

View File

@@ -145,22 +145,26 @@
</div> </div>
<div class="action-group"> <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 <el-button
type="success" type="success"
:icon="syncing ? '' : 'el-icon-refresh'" :icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh'"
:loading="syncing" :disabled="isSystemBusy"
@click="syncReceipts" @click="syncReceipts"
round> round>
<span v-text="syncing ? '正在后台同步增量数据,请稍候...' : '读取最新收货明细报表'"></span> <span v-text="syncing ? '请求已发送...' : '读取最新收货明细报表'"></span>
</el-button> </el-button>
<el-button <el-button
type="primary" type="primary"
:icon="syncingBom ? '' : 'el-icon-refresh-right'" :icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh-right'"
:loading="syncingBom" :disabled="isSystemBusy"
@click="syncBom" @click="syncBom"
round> round>
<span v-text="syncingBom ? '正在后台抓取 BOM 树,耗时较长,请稍候...' : '读取最新 BOM 表'"></span> <span v-text="syncingBom ? '请求已发送...' : '读取最新 BOM 表'"></span>
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -171,24 +175,53 @@
data() { data() {
return { return {
syncing: false, 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: { methods: {
checkTaskStatus() {
axios.get('/api/task_status')
.then(res => {
this.isSystemBusy = res.data.is_busy;
this.globalTaskName = res.data.task_name;
})
.catch(err => {
// 忽略检查错误
});
},
syncReceipts() { syncReceipts() {
this.syncing = true; this.syncing = true;
axios.post('/api/sync_receipts') axios.post('/api/sync_receipts')
.then(res => { .then(res => {
if (res.data.success) { if (res.data.success) {
this.$message.success('明细同步成功' + res.data.message); this.$message.success('已触发' + res.data.message);
// 立即主动检查一次状态更新按钮
setTimeout(this.checkTaskStatus, 500);
} else { } else {
this.$message.error('同步失败:' + res.data.message); this.$message.error('触发失败:' + res.data.message);
} }
}) })
.catch(err => { .catch(err => {
if (err.response && err.response.status === 409) {
this.$message.warning(err.response.data.message);
} else {
this.$message.error('请求发生异常,请检查后端日志。'); this.$message.error('请求发生异常,请检查后端日志。');
console.error(err); }
}) })
.finally(() => { .finally(() => {
this.syncing = false; this.syncing = false;
@@ -200,14 +233,18 @@
axios.post('/api/sync_bom') axios.post('/api/sync_bom')
.then(res => { .then(res => {
if (res.data.success) { if (res.data.success) {
this.$message.success('BOM 同步成功' + res.data.message); this.$message.success('已触发' + res.data.message);
setTimeout(this.checkTaskStatus, 500);
} else { } else {
this.$message.error('同步失败:' + res.data.message); this.$message.error('触发失败:' + res.data.message);
} }
}) })
.catch(err => { .catch(err => {
this.$message.error('请求发生异常,这可能需要较长时间,请检查控制台日志。'); if (err.response && err.response.status === 409) {
console.error(err); this.$message.warning(err.response.data.message);
} else {
this.$message.error('请求发生异常,请检查后端日志。');
}
}) })
.finally(() => { .finally(() => {
this.syncingBom = false; this.syncingBom = false;