优化前端

This commit is contained in:
Jimmy
2026-05-07 15:18:30 +08:00
parent 5c7e489e1c
commit 031ec4d289
5 changed files with 377 additions and 92 deletions

Binary file not shown.

View File

@@ -149,8 +149,6 @@ def get_receipts():
def sync_receipts():
"""触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)"""
import sys
import io
import contextlib
if str(BROWSER_LOGIN_DIR) not in sys.path:
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
@@ -158,16 +156,13 @@ def sync_receipts():
try:
from fetch_receipt_details_incremental import fetch_receipt_details_incremental
# 捕获函数内部的 print/log 输出
f = io.StringIO()
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
fetch_receipt_details_incremental()
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
threading.Thread(target=fetch_receipt_details_incremental, daemon=True).start()
logs = f.getvalue()
return jsonify({
"success": True,
"message": "增量同步执行完成",
"logs": logs
"message": "增量同步任务已在后台启动!请观察黑框控制台的运行日志。",
"logs": "任务已在后台运行..."
})
except ImportError:
return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404
@@ -178,8 +173,6 @@ def sync_receipts():
def sync_bom():
"""触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)"""
import sys
import io
import contextlib
if str(BROWSER_LOGIN_DIR) not in sys.path:
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
@@ -187,16 +180,13 @@ def sync_bom():
try:
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
# 捕获函数内部的 print/log 输出
f = io.StringIO()
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
fetch_bom_cost_tree()
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
threading.Thread(target=fetch_bom_cost_tree, daemon=True).start()
logs = f.getvalue()
return jsonify({
"success": True,
"message": "BOM 同步执行完成",
"logs": logs
"message": "BOM 树抓取任务已在后台启动!预计耗时 10-20 分钟,请观察黑框控制台的运行日志。",
"logs": "任务已在后台运行..."
})
except ImportError:
return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404
@@ -447,19 +437,8 @@ def compare_page():
"""渲染 BOM 成本期间对比分析页面"""
return render_template('bom_compare.html')
@app.route('/api/bom_tree_compare/<parent_code>')
def get_bom_tree_compare(parent_code):
"""
为成本对比页面提供带时间段价格分析的 BOM 树数据。
前端需要传入两个时间段参数:
start_a, end_a (期间 A)
start_b, end_b (期间 B)
"""
start_a = request.args.get('start_a')
end_a = request.args.get('end_a')
start_b = request.args.get('start_b')
end_b = request.args.get('end_b')
def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b):
"""构建用于成本对比分析的 BOM 树数据"""
conn = get_db_connection()
# 1. 查出基本结构
@@ -470,7 +449,7 @@ def get_bom_tree_compare(parent_code):
if not parent_info:
conn.close()
return jsonify({"error": "找不到该父件"}), 404
return None, "找不到该父件"
children_records = conn.execute(
'SELECT id, node_material_code, node_material_name, parent_node_id, bom_level, usage_qty FROM bom_child WHERE parent_material_code = ?',
@@ -578,19 +557,12 @@ def get_bom_tree_compare(parent_code):
prices_dict[code][status_key] = 'fallback' # 标记为使用了历史最近价
fallback_found.add(code)
# 3. 如果连期间之前的历史记录都没有,但它有基准价(说明是在期间之后才采购的),也算是 fallback 还是 missing
# 我们之前定义的是“无任何历史记录”才标红。所以如果它有最新基准价latest_price > 0
# 说明这东西买过,只是在 start_date 之前没买过。我们不应该把它标红missing而应该标为普通的没价格或者某种特殊颜色。
# 但为了严谨:既然我们在 start_date 之前没买过,意味着期初无库存/无价格,期间也没买,那这期间的价格只能是 0。
# 并且如果 latest_price > 0说明它并非“历史从未采购”我们把它标为 fallback 但价格是 0。
for code in missing_codes:
if code not in fallback_found:
prices_dict[code][period_key] = 0.0
if prices_dict[code].get('latest_price', 0) > 0:
# 有基准价,但在该期间和该期间之前都没买过,这不算“从未采购过”
prices_dict[code][status_key] = 'fallback'
else:
# 彻底查不到任何记录,才是真正的 missing
prices_dict[code][status_key] = 'missing'
# --- B. 处理期间 A ---
@@ -655,19 +627,11 @@ def get_bom_tree_compare(parent_code):
has_children = bool(node.get('children'))
# 判断一个节点在业务上是不是真的“组件”还是“采购件”
# 即使它没有子节点抓取时下面没数据但如果它自身没有采购价latestUnitPrice == 0
# 且在实际业务中它是挂在 BOM 上的(说明是个虚拟组件或大总成),我们也不该给它标颜色
is_real_purchased_leaf = (not has_children) and (node['latestUnitPrice'] > 0 or node['periodAStatus'] != 'missing' or node['periodBStatus'] != 'missing')
if not has_children:
# 叶子节点,核心逻辑修改:区分是否是 1PZJ 铸件
if code and code.startswith('1PZJ') and node.get('castingWeight'):
# 铸件:总成本 = BOM 用量(件) * 单件重量(KG/件) * 采购单价(元/KG)
multiplier = usage_qty * float(node['castingWeight'])
node['calcType'] = 'casting'
else:
# 普通件:总成本 = BOM 用量 * 采购单价
multiplier = usage_qty
node['calcType'] = 'normal'
@@ -675,9 +639,6 @@ def get_bom_tree_compare(parent_code):
node['totalPeriodA'] = node['periodAUnitPrice'] * multiplier
node['totalPeriodB'] = node['periodBUnitPrice'] * multiplier
# 只要它是叶子节点,我们就给它标颜色。
# 如果它真的是虚拟件(基准价是 0且 A B 都是 missing那就大大方方给它标红点
# 用户刚才反馈说有的子件有基准价但是连点都没标,就是因为之前那个 is_real_purchased_leaf 过滤得太严格或者有 Bug。
node['showAStatus'] = node['periodAStatus']
node['showBStatus'] = node['periodBStatus']
else:
@@ -691,33 +652,186 @@ def get_bom_tree_compare(parent_code):
total_a += child['totalPeriodA']
total_b += child['totalPeriodB']
# 父件总成本 = (子件成本之和) * 父件自身的耗用量
# 如果累加为0但自身有价格使用 (自身单价 * 自身耗用量) 作为兜底
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'
calc_compare_costs(root_tree)
return root_tree, None
@app.route('/api/bom_tree_compare/<parent_code>')
def get_bom_tree_compare(parent_code):
"""
为成本对比页面提供带时间段价格分析的 BOM 树数据。
"""
start_a = request.args.get('start_a')
end_a = request.args.get('end_a')
start_b = request.args.get('start_b')
end_b = request.args.get('end_b')
root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b)
if error:
return jsonify({"error": error}), 404
# 为了配合 ElementUI 的树形表格,根节点必须包装在数组里
return jsonify([root_tree])
def open_browser():
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from flask import send_file
@app.route('/api/export_compare/<parent_code>')
def export_compare(parent_code):
start_a = request.args.get('start_a')
end_a = request.args.get('end_a')
start_b = request.args.get('start_b')
end_b = request.args.get('end_b')
root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b)
if error:
return jsonify({"error": error}), 404
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "BOM成本对比"
# 定义样式
header_fill = PatternFill(start_color="409EFF", end_color="409EFF", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True)
center_align = Alignment(horizontal="center", vertical="center")
border = Border(left=Side(style='thin'), right=Side(style='thin'),
top=Side(style='thin'), bottom=Side(style='thin'))
headers = [
"物料代码", "物料名称", "BOM层级", "用量", "单位",
"最新单价", "最新总成本",
f"期间A单价 ({start_a or ''}{end_a or ''})", f"期间A总成本",
f"期间B单价 ({start_b or ''}{end_b or ''})", f"期间B总成本",
"单价差异 (B-A)", "总成本差异 (B-A)"
]
ws.append(headers)
for col_idx, cell in enumerate(ws[1], 1):
cell.fill = header_fill
cell.font = header_font
cell.alignment = center_align
cell.border = border
ws.column_dimensions[get_column_letter(col_idx)].width = 15
ws.column_dimensions['A'].width = 18
ws.column_dimensions['B'].width = 30
ws.column_dimensions['H'].width = 25
ws.column_dimensions['J'].width = 25
def write_node_to_excel(node, level=0):
# 差异计算逻辑(同前端)
period_a_unit = node.get('periodAUnitPrice', 0) or 0
period_b_unit = node.get('periodBUnitPrice', 0) or 0
diff_unit = period_b_unit - period_a_unit
period_a_total = node.get('totalPeriodA', 0) or 0
period_b_total = node.get('totalPeriodB', 0) or 0
diff_total = period_b_total - period_a_total
prefix = " " * level
row_data = [
node.get('materialCode', ''),
prefix + node.get('materialName', ''),
node.get('bomLevel', ''),
node.get('usageQty', 1),
node.get('unitName', ''),
node.get('latestUnitPrice', 0),
node.get('totalLatest', 0),
period_a_unit,
period_a_total,
period_b_unit,
period_b_total,
diff_unit,
diff_total
]
ws.append(row_data)
current_row = ws.max_row
# 简单格式化
for col_idx, cell in enumerate(ws[current_row], 1):
cell.border = border
if col_idx in [6, 7, 8, 9, 10, 11, 12, 13]:
cell.number_format = '0.00'
# 递归子节点
if node.get('children'):
for child in node['children']:
write_node_to_excel(child, level + 1)
write_node_to_excel(root_tree)
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f"BOM成本对比_{parent_code}.xlsx"
return send_file(
output,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
def open_browser(port):
"""延迟 1.5 秒后自动打开系统默认浏览器"""
time.sleep(1.5)
url = "http://127.0.0.1:5050"
url = f"http://127.0.0.1:{port}"
print(f"🚀 正在自动打开浏览器: {url}")
webbrowser.open(url)
def find_free_port(start_port=5050, max_port=5100):
"""自动寻找未被占用的端口,避免 Windows 10013 端口被拒错误"""
import socket
for port in range(start_port, max_port):
# 尝试通过创建 socket 并监听来确认端口是否真的可用
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# 开启端口复用,避免 TIME_WAIT 状态导致误判
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
# 绑定到 127.0.0.1 而不是 0.0.0.0
s.bind(('127.0.0.1', port))
return port
except OSError:
continue
# 如果 5050-5100 都被占用或被拦截,尝试让系统随机分配一个端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
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, daemon=True).start()
threading.Thread(target=open_browser, args=(port,), daemon=True).start()
# 启动后端服务
print("🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:5050")
# 更改默认端口为 5050避开 macOS 控制中心的 5000 端口占用
app.run(debug=False, host='0.0.0.0', port=5050)
print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}")
# 智能判断:如果是通过 PyInstaller 打包运行的,则关闭热加载以避免多进程冲突和双开浏览器
is_frozen = getattr(sys, 'frozen', False)
# 更改为动态端口,避开被占用的端口。修改 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
)

View File

@@ -211,7 +211,7 @@
<div class="toolbar" v-if="currentParentCode">
<!-- 第一行:期间选择 -->
<el-row :gutter="20" type="flex" align="middle" style="flex-wrap: wrap; margin-bottom: 10px;">
<el-col :span="11">
<el-col :span="10">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 A (基准期)</span>
<el-date-picker
v-model="periodA_start"
@@ -232,7 +232,7 @@
</el-date-picker>
</el-col>
<el-col :span="11">
<el-col :span="10">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
<el-date-picker
v-model="periodB_start"
@@ -252,8 +252,9 @@
style="width: 140px;">
</el-date-picker>
</el-col>
<el-col :span="2">
<el-col :span="4" style="display: flex; gap: 10px;">
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchTreeData" :loading="loadingData">执行对比</el-button>
<el-button type="success" size="small" icon="el-icon-download" @click="exportExcel" :loading="exporting">导出 Excel</el-button>
</el-col>
</el-row>
@@ -418,6 +419,7 @@
parents: [],
loadingParents: false,
loadingData: false,
exporting: false,
currentParentCode: null,
tableData: [],
originalTableData: [],
@@ -562,6 +564,59 @@
};
this.tableData = filterInvisibleNodes(newData);
},
exportExcel() {
if (!this.currentParentCode) {
this.$message.warning('请先选择一个成品父件并执行对比');
return;
}
if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) {
this.$message.warning('请完整选择期间A和期间B的时间范围');
return;
}
this.exporting = true;
const params = new URLSearchParams({
start_a: this.periodA_start,
end_a: this.periodA_end,
start_b: this.periodB_start,
end_b: this.periodB_end
});
axios({
url: `/api/export_compare/${this.currentParentCode}?${params.toString()}`,
method: 'GET',
responseType: 'blob' // 重要:设置响应类型为 blob
})
.then(response => {
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// 从响应头中获取文件名,如果没有则使用默认文件名
const contentDisposition = response.headers['content-disposition'];
let fileName = `BOM成本对比_${this.currentParentCode}.xlsx`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (fileNameMatch && fileNameMatch.length === 2) {
fileName = decodeURIComponent(fileNameMatch[1]);
}
}
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
this.exporting = false;
this.$message.success('导出成功');
})
.catch(error => {
this.exporting = false;
this.$message.error('导出失败,请重试');
});
}
}
})