This commit is contained in:
Jimmy
2026-04-27 15:24:41 +08:00
parent 29954a7af0
commit 0cea74ad97
8 changed files with 2123 additions and 0 deletions

684
web_ui/app.py Normal file
View File

@@ -0,0 +1,684 @@
from flask import Flask, render_template, jsonify, request
import sqlite3
import subprocess
from pathlib import Path
import sys
import os
import io
import contextlib
import logging
import threading
import time
import webbrowser
from apscheduler.schedulers.background import BackgroundScheduler
def get_base_dir():
"""获取程序运行时的基础目录(静态资源、脚本代码等只读文件存放处)"""
if getattr(sys, 'frozen', False):
return Path(sys._MEIPASS)
return Path(__file__).parent.parent
def get_data_dir():
"""获取持久化数据存放目录(数据库、输出文件等,保证重启不丢失)"""
if getattr(sys, 'frozen', False):
return Path(os.path.dirname(sys.executable))
return Path(__file__).parent.parent
BASE_DIR = get_base_dir()
DATA_DIR = get_data_dir()
# Web 资源路径 (在 PyInstaller _MEIPASS 目录中)
TEMPLATE_DIR = BASE_DIR / "web_ui" / "templates"
STATIC_DIR = BASE_DIR / "web_ui" / "static"
app = Flask(__name__, template_folder=str(TEMPLATE_DIR), static_folder=str(STATIC_DIR))
# 数据库路径:指向外部持久化数据目录,确保数据不丢失
DB_PATH = DATA_DIR / "browser_login" / "output" / "erp_data.db"
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
# 抓取脚本目录 (属于代码文件,从 BASE_DIR 加载)
BROWSER_LOGIN_DIR = BASE_DIR / "browser_login"
def background_sync_job():
"""APScheduler 后台定时任务执行增量抓取"""
print("[定时任务] 正在执行后台增量数据同步...")
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
# 重定向输出,避免污染终端,同时捕获可能的信息
f = io.StringIO()
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
fetch_receipt_details_incremental()
print(f"[定时任务] 同步完成。")
except Exception as e:
print(f"[定时任务] 同步失败: {str(e)}")
# 初始化定时调度器
scheduler = BackgroundScheduler()
scheduler.add_job(func=background_sync_job, trigger="interval", minutes=30)
# 启动调度器
scheduler.start()
# 确保在应用退出时关闭调度器
import atexit
atexit.register(lambda: scheduler.shutdown())
def get_db_connection():
"""获取数据库连接"""
conn = sqlite3.connect(DB_PATH)
# 将查询结果转换为字典形式
conn.row_factory = sqlite3.Row
return conn
@app.route('/')
def index():
"""渲染导航主页"""
return render_template('home.html')
@app.route('/receipts')
def receipts_page():
"""渲染收货明细数据看板"""
return render_template('index.html')
@app.route('/api/receipts')
def get_receipts():
"""获取收货明细数据(支持分页和多条件搜索)"""
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 50))
offset = (page - 1) * limit
# 获取搜索参数
supplier_name = request.args.get('supplier_name', '').strip()
material_name = request.args.get('material_name', '').strip()
po_code = request.args.get('po_code', '').strip()
conn = get_db_connection()
# 构建动态 SQL 查询
query_conditions = []
params = []
if supplier_name:
query_conditions.append("supplier_name LIKE ?")
params.append(f"%{supplier_name}%")
if material_name:
query_conditions.append("material_name LIKE ?")
params.append(f"%{material_name}%")
if po_code:
query_conditions.append("purchase_order_code LIKE ?")
params.append(f"%{po_code}%")
where_clause = ""
if query_conditions:
where_clause = " WHERE " + " AND ".join(query_conditions)
# 获取总数
count_query = f"SELECT COUNT(*) FROM receipt_details{where_clause}"
total = conn.execute(count_query, params).fetchone()[0]
# 获取分页数据
data_query = f"SELECT * FROM receipt_details{where_clause} ORDER BY receipt_time DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
receipts = conn.execute(data_query, params).fetchall()
conn.close()
return jsonify({
"total": total,
"rows": [dict(ix) for ix in receipts]
})
@app.route('/api/sync_receipts', methods=['POST'])
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))
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()
logs = f.getvalue()
return jsonify({
"success": True,
"message": "增量同步执行完成",
"logs": logs
})
except ImportError:
return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404
except Exception as e:
return jsonify({"success": False, "message": f"系统错误: {str(e)}"}), 500
@app.route('/api/sync_bom', methods=['POST'])
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))
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()
logs = f.getvalue()
return jsonify({
"success": True,
"message": "BOM 同步执行完成",
"logs": logs
})
except ImportError:
return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404
except Exception as e:
return jsonify({"success": False, "message": f"系统错误: {str(e)}"}), 500
@app.route('/bom')
def bom_page():
"""渲染 BOM 成本树状图页面"""
return render_template('bom_tree.html')
@app.route('/api/bom_parents')
def get_bom_parents():
"""获取所有父件列表,供左侧菜单选择"""
keyword = request.args.get('keyword', '').strip()
conn = get_db_connection()
if keyword:
parents = conn.execute(
'SELECT parent_material_code, parent_material_name FROM bom_parent WHERE parent_material_name LIKE ? OR parent_material_code LIKE ? LIMIT 100',
(f'%{keyword}%', f'%{keyword}%')
).fetchall()
else:
parents = conn.execute('SELECT parent_material_code, parent_material_name FROM bom_parent LIMIT 100').fetchall()
conn.close()
return jsonify([dict(ix) for ix in parents])
def calculate_bom_cost(node, prices_dict):
"""
递归计算 BOM 树的成本。
如果该节点是叶子节点(底层采购件),直接从 prices_dict 取价格。
如果该节点是中间组件/父件,则成本为其所有子节点成本的总和。
注意:这里假设每个物料数量为 1。如果你的 ERP 数据里有 BOM 用量(quantity)字段,
应该用 `子件单价 * 用量` 进行累加。由于目前只抓了基本结构,先做单纯的层级累加。
"""
node_cost = 0.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
if not node.get('children'):
# 叶子节点,直接使用自己的采购价
node_cost = own_price
else:
# 有子件的组件,成本等于所有子件成本的累加
for child in node['children']:
node_cost += calculate_bom_cost(child, prices_dict)
# 兜底机制:如果在 ERP 里这是一个组件,但没查到子件成本(比如没抓全),
# 则尝试看看它自己有没有直接的采购记录
if node_cost == 0.0 and own_price > 0:
node_cost = own_price
# 把计算出来的汇总成本和自身单价都挂载到节点上,供前端使用
node['totalCost'] = round(node_cost, 2)
node['ownPrice'] = round(own_price, 2)
# 如果有采购记录,把时间和单号也带上
if price_record:
node['receiptTime'] = price_record['receipt_time']
node['poCode'] = price_record['purchase_order_code']
node['supplierName'] = price_record['supplier_name']
else:
node['receiptTime'] = '-'
node['poCode'] = '-'
node['supplierName'] = '-'
return node_cost
@app.route('/api/bom_tree/<parent_code>')
def get_bom_tree(parent_code):
"""根据父件代码,组装其下所有的子件,并计算整个 BOM 树的累加成本"""
conn = get_db_connection()
# 1. 查出该父件的基本信息作为根节点
parent_info = conn.execute(
'SELECT parent_material_code, parent_material_name FROM bom_parent WHERE parent_material_code = ?',
(parent_code,)
).fetchone()
if not parent_info:
conn.close()
return jsonify({"error": "找不到该父件"}), 404
# 2. 查出该家族的所有子件
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 = ?',
(parent_code,)
).fetchall()
# 3. 提前批量查出这棵树里所有物料的【最新采购价】
# 避免前端每次点击都去查一次,也为了在后端能直接算好总成本
material_codes = [row['node_material_code'] for row in children_records]
material_codes.append(parent_info['parent_material_code'])
prices_dict = {}
if material_codes:
# 使用 IN 语句批量查询,并按物料分组取最新时间的价格
placeholders = ','.join(['?'] * len(material_codes))
# SQLite 分组取最新记录的经典写法
price_records = conn.execute(f'''
SELECT material_code,
ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2) AS receive_price,
receipt_time, purchase_order_code, supplier_name
FROM receipt_details
WHERE material_code IN ({placeholders})
GROUP BY material_code
HAVING receipt_time = MAX(receipt_time)
''', material_codes).fetchall()
for r in price_records:
prices_dict[r['material_code']] = dict(r)
conn.close()
# 为不同层级预设好看的主题色(从父件 0 到 第 5 层)
level_colors = {
0: '#F56C6C', # 红色:顶级父件成品
1: '#E6A23C', # 橙色:一级子件(大组件)
2: '#67C23A', # 绿色:二级子件
3: '#409EFF', # 蓝色:三级子件
4: '#909399', # 灰色:四级子件
5: '#DDA0DD' # 紫色:五级或更深子件
}
# 4. 开始在 Python 内存中“找爸爸”组装树
nodes_map = {}
for row in children_records:
node_id = row['id']
level = row['bom_level']
nodes_map[node_id] = {
"name": f"[{row['node_material_code']}]\n{row['node_material_name']}",
"materialCode": row['node_material_code'],
"materialName": row['node_material_name'],
"bomLevel": level,
"parentNodeId": row['parent_node_id'],
"usageQty": row['usage_qty'],
# 基础颜色配置,稍后根据是否有子节点调整实心/空心
"itemStyle": {
"color": level_colors.get(level, '#DDA0DD'),
"borderColor": level_colors.get(level, '#DDA0DD')
},
"children": []
}
root_tree = {
"name": f"[{parent_info['parent_material_code']}]\n{parent_info['parent_material_name']}",
"materialCode": parent_info['parent_material_code'],
"materialName": parent_info['parent_material_name'],
"bomLevel": 0,
"usageQty": 1.0,
"itemStyle": {
"color": level_colors[0],
"borderColor": level_colors[0]
},
"children": []
}
for node_id, node_data in nodes_map.items():
parent_id = node_data['parentNodeId']
if parent_id is None:
root_tree['children'].append(node_data)
else:
if parent_id in nodes_map:
nodes_map[parent_id]['children'].append(node_data)
# 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'))
# 【核心视觉区分:还能不能点开?】
# 如果它有子件(还能点开展开),就让它变成实心的(填充颜色与边框一致)
# 如果它是最底层的零件(没有子件了,点不开了),就让它变成空心的(填充白色,只保留彩色边框)
if has_children:
node['itemStyle']['color'] = node['itemStyle']['borderColor']
# 给可以点击的节点加个发光效果
node['itemStyle']['shadowBlur'] = 10
node['itemStyle']['shadowColor'] = 'rgba(0, 0, 0, 0.2)'
else:
node['itemStyle']['color'] = '#FFFFFF' # 白色填充(空心)
node['itemStyle']['borderWidth'] = 2 # 加粗彩色边框
if not has_children:
node_cost = own_price
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
node['totalCost'] = round(node_cost, 2)
node['ownPrice'] = round(own_price, 2)
if price_record:
node['receiptTime'] = price_record['receipt_time']
node['poCode'] = price_record['purchase_order_code']
node['supplierName'] = price_record['supplier_name']
else:
node['receiptTime'] = '-'
node['poCode'] = '-'
node['supplierName'] = '-'
return node_cost
process_tree_nodes(root_tree, prices_dict)
return jsonify(root_tree)
@app.route('/api/latest_price/<material_code>')
def get_latest_price(material_code):
"""根据物料代码,去收货明细表中查询最新一次的收货价格和时间"""
conn = get_db_connection()
# 按时间倒序,取第一条(最新一条)
latest_record = conn.execute('''
SELECT ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2) AS receive_price,
receipt_time, purchase_order_code, supplier_name
FROM receipt_details
WHERE material_code = ?
ORDER BY receipt_time DESC
LIMIT 1
''', (material_code,)).fetchone()
conn.close()
if latest_record:
return jsonify(dict(latest_record))
else:
return jsonify({"receive_price": "无采购记录", "receipt_time": "-", "purchase_order_code": "-", "supplier_name": "-"})
@app.route('/compare')
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')
conn = get_db_connection()
# 1. 查出基本结构
parent_info = conn.execute(
'SELECT parent_material_code, parent_material_name FROM bom_parent WHERE parent_material_code = ?',
(parent_code,)
).fetchone()
if not parent_info:
conn.close()
return jsonify({"error": "找不到该父件"}), 404
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 = ?',
(parent_code,)
).fetchall()
material_codes = [row['node_material_code'] for row in children_records]
material_codes.append(parent_info['parent_material_code'])
# 获取唯一的物料代码列表以进行查询
unique_codes = list(set(material_codes))
prices_dict = {code: {
'latest_price': 0.0,
'unit_name': '',
'casting_weight': None,
'period_a_price': 0.0, 'period_a_status': 'normal', # normal, fallback(黄色), missing(红色)
'period_b_price': 0.0, 'period_b_status': 'normal'
} for code in unique_codes}
if unique_codes:
placeholders = ','.join(['?'] * len(unique_codes))
# --- A. 查询历史最新价格及铸件重量/单位 ---
latest_records = conn.execute(f'''
SELECT material_code,
unit_name,
purchase_qty,
receive_qty,
ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2) AS receive_price
FROM receipt_details
WHERE material_code IN ({placeholders})
GROUP BY material_code
HAVING receipt_time = MAX(receipt_time)
''', unique_codes).fetchall()
for r in latest_records:
code = r['material_code']
prices_dict[code]['latest_price'] = float(r['receive_price'] or 0)
prices_dict[code]['unit_name'] = r['unit_name'] or ''
# 计算铸件重量逻辑
if code.startswith('1PZJ') and r['purchase_qty'] and float(r['purchase_qty']) > 0:
casting_weight = float(r['receive_qty'] or 0) / float(r['purchase_qty'])
prices_dict[code]['casting_weight'] = round(casting_weight, 4) # 保留4位小数更精确
# 辅助函数:处理某个期间的价格逻辑
def process_period(start_date, end_date, period_key, status_key):
if not start_date or not end_date:
return
# 给结束日期加上 23:59:59确保包含最后一天全天的数据
end_date_full = f"{end_date} 23:59:59"
# 1. 先查询该期间内的最新收货价
params = unique_codes + [start_date, end_date_full]
period_latest_records = conn.execute(f'''
SELECT material_code,
ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2) AS receive_price
FROM receipt_details
WHERE material_code IN ({placeholders})
AND receipt_time >= ? AND receipt_time <= ?
GROUP BY material_code
HAVING receipt_time = MAX(receipt_time)
''', params).fetchall()
found_codes = set()
for r in period_latest_records:
code = r['material_code']
prices_dict[code][period_key] = float(r['receive_price'] or 0)
prices_dict[code][status_key] = 'normal'
found_codes.add(code)
# 2. 对于在期间内没有采购记录的物料,触发降级策略(找期间之前的最新价)
missing_codes = [c for c in unique_codes if c not in found_codes]
if missing_codes:
missing_ph = ','.join(['?'] * len(missing_codes))
fallback_params = missing_codes + [start_date]
fallback_records = conn.execute(f'''
SELECT material_code,
ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2) AS receive_price
FROM receipt_details
WHERE material_code IN ({missing_ph})
AND receipt_time < ?
GROUP BY material_code
HAVING receipt_time = MAX(receipt_time)
''', fallback_params).fetchall()
fallback_found = set()
for r in fallback_records:
code = r['material_code']
prices_dict[code][period_key] = float(r['receive_price'] or 0)
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 ---
process_period(start_a, end_a, 'period_a_price', 'period_a_status')
# --- C. 处理期间 B ---
process_period(start_b, end_b, 'period_b_price', 'period_b_status')
conn.close()
# 4. 组装树结构
nodes_map = {}
for row in children_records:
node_id = row['id']
nodes_map[node_id] = {
"id": node_id,
"materialCode": row['node_material_code'],
"materialName": row['node_material_name'],
"bomLevel": row['bom_level'],
"usageQty": row['usage_qty'],
"parentNodeId": row['parent_node_id'],
"unitName": "",
"castingWeight": None,
"children": []
}
root_tree = {
"id": "root",
"materialCode": parent_info['parent_material_code'],
"materialName": parent_info['parent_material_name'],
"bomLevel": "0",
"usageQty": 1.0,
"unitName": "",
"castingWeight": None,
"children": []
}
for node_id, node_data in nodes_map.items():
parent_id = node_data['parentNodeId']
if parent_id is None:
root_tree['children'].append(node_data)
else:
if parent_id in nodes_map:
nodes_map[parent_id]['children'].append(node_data)
# 5. 递归计算累加成本
def calc_compare_costs(node):
code = node['materialCode']
usage_qty = float(node.get('usageQty', 1.0))
price_info = prices_dict.get(code, {})
# 获取基础单价
node['latestUnitPrice'] = price_info.get('latest_price', 0.0)
node['periodAUnitPrice'] = price_info.get('period_a_price', 0.0)
node['periodBUnitPrice'] = price_info.get('period_b_price', 0.0)
# 获取单位名称和铸件单件重量
node['unitName'] = price_info.get('unit_name', '')
node['castingWeight'] = price_info.get('casting_weight')
node['periodAStatus'] = price_info.get('period_a_status', 'missing')
node['periodBStatus'] = price_info.get('period_b_status', 'missing')
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:
# 叶子节点,总成本 = 单价 * 耗用量
node['totalLatest'] = node['latestUnitPrice'] * usage_qty
node['totalPeriodA'] = node['periodAUnitPrice'] * usage_qty
node['totalPeriodB'] = node['periodBUnitPrice'] * usage_qty
# 只要它是叶子节点,我们就给它标颜色。
# 如果它真的是虚拟件(基准价是 0且 A B 都是 missing那就大大方方给它标红点
# 用户刚才反馈说有的子件有基准价但是连点都没标,就是因为之前那个 is_real_purchased_leaf 过滤得太严格或者有 Bug。
node['showAStatus'] = node['periodAStatus']
node['showBStatus'] = node['periodBStatus']
else:
total_latest = 0.0
total_a = 0.0
total_b = 0.0
for child in node['children']:
calc_compare_costs(child)
total_latest += child['totalLatest']
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)
# 为了配合 ElementUI 的树形表格,根节点必须包装在数组里
return jsonify([root_tree])
def open_browser():
"""延迟 1.5 秒后自动打开系统默认浏览器"""
time.sleep(1.5)
url = "http://127.0.0.1:5050"
print(f"🚀 正在自动打开浏览器: {url}")
webbrowser.open(url)
if __name__ == '__main__':
# 启动前开启一个线程去拉起浏览器
threading.Thread(target=open_browser, daemon=True).start()
# 启动后端服务
print("🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:5050")
# 更改默认端口为 5050避开 macOS 控制中心的 5000 端口占用
app.run(debug=False, host='0.0.0.0', port=5050)

View File

@@ -0,0 +1,539 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOM 成本期间对比分析表</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ebeef5;
flex-shrink: 0;
}
.header h2 { margin: 0; color: #303133; }
.main-container {
display: flex;
flex: 1;
gap: 20px;
min-height: 0;
}
.left-sidebar {
width: 250px;
background: #fff;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
flex-shrink: 0;
}
.parent-list {
flex: 1;
overflow-y: auto;
margin-top: 15px;
border-top: 1px solid #ebeef5;
}
.parent-item {
padding: 12px 10px;
border-bottom: 1px solid #ebeef5;
cursor: pointer;
transition: all 0.3s;
}
.parent-item:hover { background-color: #f0f9eb; }
.parent-item.active {
background-color: #ecf5ff;
border-left: 4px solid #409EFF;
}
.parent-code { font-size: 12px; color: #909399; margin-bottom: 4px; }
.parent-name { font-size: 14px; color: #303133; font-weight: 500; }
.right-content {
flex: 1;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
.toolbar {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px dashed #ebeef5;
}
.table-container {
flex: 1;
overflow: hidden;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.dot-normal, .dot-missing { display: none; }
.dot-fallback { background-color: #F56C6C; } /* 红色:沿用历史价 */
.price-up { color: #F56C6C; }
.price-down { color: #67C23A; }
/* 紧凑型表格样式 */
.el-table--small td, .el-table--small th, .el-table--mini td, .el-table--mini th {
padding: 0px !important;
}
.el-table .cell {
line-height: 1.1 !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
font-size: 15px !important; /* 字体放大 */
display: flex;
align-items: center;
}
/* 修复 flex 布局导致 el-table-column 的 align 属性失效的问题 */
.el-table td.is-center .cell {
justify-content: center;
}
.el-table td.is-right .cell {
justify-content: flex-end;
}
/* 修复表头由于 display: flex 导致的错位问题 */
.el-table th .cell {
display: block; /* 恢复表头原有的显示方式 */
}
/* 针对左侧树形结构特殊处理,让前面的箭头和内容对齐 */
.el-table__expand-icon {
height: 16px !important;
line-height: 16px !important;
margin-right: 5px; /* 让箭头与文字间距正常 */
}
/* 表格斑马纹样式:隔行换色(白色和非常淡的蓝色) */
.el-table__row--striped td {
background-color: #F4F9FF !important;
}
/* 鼠标悬停时的颜色覆盖(稍微加深一点点) */
.el-table__body tr:hover > td {
background-color: #ecf5ff !important;
}
/* 过滤隐藏行的样式 */
.filtered-out {
display: none !important;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>📊 BOM 成本期间对比分析表</h2>
<div>
<el-button type="text" @click="goToBomTree">返回雷达图</el-button>
<el-button type="text" @click="goToReceipts">返回收货明细</el-button>
</div>
</div>
<div class="main-container">
<!-- 左侧 1/4父件选择列表 -->
<div class="left-sidebar">
<el-input
placeholder="搜索成品/父件"
v-model="searchKeyword"
size="small"
clearable
@keyup.enter.native="searchParents"
@clear="searchParents">
<el-button slot="append" icon="el-icon-search" @click="searchParents"></el-button>
</el-input>
<div class="parent-list" v-loading="loadingParents">
<div v-if="parents.length === 0" style="text-align: center; color: #909399; padding: 20px;">
没有找到成品数据
</div>
<div
v-for="item in parents"
:key="item.parent_material_code"
class="parent-item"
:class="{ active: currentParentCode === item.parent_material_code }"
@click="loadCompareData(item.parent_material_code)">
<div class="parent-code" v-text="item.parent_material_code"></div>
<div class="parent-name" v-text="item.parent_material_name"></div>
</div>
</div>
</div>
<!-- 右侧 3/4树形表格展示区 -->
<div class="right-content">
<!-- 顶部期间选择器 -->
<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">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 A (基准期)</span>
<el-date-picker
v-model="periodA_start"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="开始日期"
style="width: 140px;">
</el-date-picker>
<span style="margin: 0 5px; color: #909399;"></span>
<el-date-picker
v-model="periodA_end"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="结束日期"
style="width: 140px;">
</el-date-picker>
</el-col>
<el-col :span="11">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
<el-date-picker
v-model="periodB_start"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="开始日期"
style="width: 140px;">
</el-date-picker>
<span style="margin: 0 5px; color: #909399;"></span>
<el-date-picker
v-model="periodB_end"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="结束日期"
style="width: 140px;">
</el-date-picker>
</el-col>
<el-col :span="2">
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchTreeData" :loading="loadingData">执行对比</el-button>
</el-col>
</el-row>
<!-- 第二行:子件搜索与图例 -->
<el-row :gutter="20" type="flex" align="middle" justify="space-between">
<el-col :span="12">
<el-input
placeholder="输入物料名称搜索子件"
v-model="filterName"
size="small"
clearable
style="width: 200px; margin-right: 10px;"
@input="filterNode">
</el-input>
<el-input
placeholder="输入物料代码搜索子件"
v-model="filterCode"
size="small"
clearable
style="width: 200px;"
@input="filterNode">
</el-input>
</el-col>
<el-col :span="12" style="text-align: right; font-size: 12px; color: #909399;">
图例说明:
<span class="status-dot dot-fallback" style="display:inline-block"></span> 期间内无数据,已回溯取历史最近价
</el-col>
</el-row>
</div>
<!-- 核心树形表格 -->
<div class="table-container" v-if="currentParentCode">
<el-table
v-loading="loadingData"
:data="tableData"
size="mini"
stripe
style="width: 100%; height: 100%;"
row-key="id"
border
default-expand-all
height="100%"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}">
<el-table-column prop="materialName" label="BOM 结构 (物料代码 - 物料名称)" min-width="350" show-overflow-tooltip>
<template slot-scope="scope">
<div :class="{'filtered-out': !scope.row.visible}" style="display: flex; align-items: center;">
<span style="font-family: monospace; font-size: 16px; color: #409EFF; margin-right: 10px; flex-shrink: 0;" v-text="scope.row.materialCode"></span>
<strong style="font-size: 16px;" v-text="scope.row.materialName"></strong>
</div>
</template>
</el-table-column>
<el-table-column label="BOM" align="center" min-width="80">
<template slot-scope="scope">
<span v-text="scope.row.usageQty + (scope.row.unitName || '')"></span>
</template>
</el-table-column>
<el-table-column label="铸件" align="center" min-width="80">
<template slot-scope="scope">
<span v-if="scope.row.castingWeight" v-text="scope.row.castingWeight + 'KG'"></span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
<el-table-column label="历史最新基准价 (¥)" align="center" min-width="120">
<template slot-scope="scope">
<span v-if="scope.row.totalLatest > 0" v-text="scope.row.totalLatest.toFixed(2)"></span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
<el-table-column label="期间 A 最新价 (¥)" align="center" min-width="140">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="getStatusText(scope.row.showAStatus)" placement="top" :disabled="scope.row.showAStatus !== 'fallback'">
<span style="display: flex; justify-content: center; align-items: center;">
<span class="status-dot" :class="'dot-' + scope.row.showAStatus"></span>
<span v-if="scope.row.totalPeriodA > 0" v-text="scope.row.totalPeriodA.toFixed(2)"></span>
<span v-else style="color: #C0C4CC; margin-left: 2px;">-</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="期间 B 最新价 (¥)" align="center" min-width="140">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="getStatusText(scope.row.showBStatus)" placement="top" :disabled="scope.row.showBStatus !== 'fallback'">
<span style="display: flex; justify-content: center; align-items: center;">
<span class="status-dot" :class="'dot-' + scope.row.showBStatus"></span>
<span v-if="scope.row.totalPeriodB > 0" v-text="scope.row.totalPeriodB.toFixed(2)"></span>
<span v-else style="color: #C0C4CC; margin-left: 2px;">-</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="差异对比 (B - A)" align="center" min-width="120">
<template slot-scope="scope">
<span v-if="scope.row.totalPeriodA > 0 && scope.row.totalPeriodB > 0">
<span v-if="scope.row.totalPeriodB > scope.row.totalPeriodA" class="price-up">
<i class="el-icon-top"></i> <span v-text="(scope.row.totalPeriodB - scope.row.totalPeriodA).toFixed(2)"></span>
</span>
<span v-else-if="scope.row.totalPeriodB < scope.row.totalPeriodA" class="price-down">
<i class="el-icon-bottom"></i> <span v-text="(scope.row.totalPeriodA - scope.row.totalPeriodB).toFixed(2)"></span>
</span>
<span v-else style="color: #909399;">持平</span>
</span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-else style="flex: 1; display: flex; justify-content: center; align-items: center; color: #909399;">
<i class="el-icon-mouse" style="font-size: 24px; margin-right: 10px;"></i> 请在左侧选择一个成品父件进行分析
</div>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
searchKeyword: '',
filterName: '',
filterCode: '',
parents: [],
loadingParents: false,
loadingData: false,
currentParentCode: null,
tableData: [],
originalTableData: [],
// 默认设定期间,方便测试
periodA_start: '2023-01-01',
periodA_end: '2023-12-31',
periodB_start: '2024-01-01',
periodB_end: '2024-12-31'
}
},
mounted() {
this.searchParents();
},
methods: {
goToReceipts() { window.location.href = '/'; },
goToBomTree() { window.location.href = '/bom'; },
getStatusText(status) {
if (status === 'fallback') return '期间内无数据,已取历史最近价';
return '';
},
searchParents() {
this.loadingParents = true;
axios.get(`/api/bom_parents?keyword=${this.searchKeyword}`)
.then(response => {
this.parents = response.data;
this.loadingParents = false;
})
.catch(error => {
this.$message.error('加载父件列表失败');
this.loadingParents = false;
});
},
loadCompareData(code) {
this.currentParentCode = code;
this.fetchTreeData();
},
fetchTreeData() {
if (!this.currentParentCode) return;
if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) {
this.$message.warning('请完整选择期间A和期间B的时间范围');
return;
}
this.loadingData = 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.get(`/api/bom_tree_compare/${this.currentParentCode}?${params.toString()}`)
.then(response => {
// 初始化所有节点的可见性
const initVisible = (nodes) => {
nodes.forEach(node => {
node.visible = true;
if (node.children && node.children.length > 0) {
initVisible(node.children);
}
});
};
initVisible(response.data);
this.originalTableData = JSON.parse(JSON.stringify(response.data));
this.tableData = response.data;
this.loadingData = false;
// 如果有残留的搜索条件,加载完自动应用过滤
if (this.filterName || this.filterCode) {
this.filterNode();
}
})
.catch(error => {
this.$message.error('执行成本对比计算失败');
this.loadingData = false;
});
},
filterNode() {
if (!this.filterName && !this.filterCode) {
// 恢复全部可见
this.tableData = JSON.parse(JSON.stringify(this.originalTableData));
return;
}
const nameKeyword = this.filterName.toLowerCase();
const codeKeyword = this.filterCode.toLowerCase();
// 深度克隆一份原始数据进行过滤
let newData = JSON.parse(JSON.stringify(this.originalTableData));
const checkNode = (node) => {
let isMatch = true;
if (nameKeyword && !node.materialName.toLowerCase().includes(nameKeyword)) {
isMatch = false;
}
if (codeKeyword && !node.materialCode.toLowerCase().includes(codeKeyword)) {
isMatch = false;
}
node.visible = isMatch;
let hasVisibleChild = false;
if (node.children && node.children.length > 0) {
for (let i = 0; i < node.children.length; i++) {
if (checkNode(node.children[i])) {
hasVisibleChild = true;
}
}
}
// 如果子节点有匹配的,父节点也必须可见,否则树形结构会断裂
if (hasVisibleChild) {
node.visible = true;
return true;
}
return node.visible;
};
newData.forEach(rootNode => checkNode(rootNode));
// Element UI 树形表格不支持直接隐藏节点,需要从数据结构中过滤掉
const filterInvisibleNodes = (nodes) => {
return nodes.filter(node => {
if (!node.visible) return false;
if (node.children && node.children.length > 0) {
node.children = filterInvisibleNodes(node.children);
}
return true;
});
};
this.tableData = filterInvisibleNodes(newData);
}
}
})
</script>
</body>
</html>

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOM 成本动态雷达图 - 数据展示看板</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- 引入 ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
height: 100vh; /* 严格锁定整个页面的高度为屏幕高度 */
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden; /* 禁止整个网页出现最外层大滚动条 */
}
#app {
display: flex;
flex-direction: column;
height: 100%; /* 关键:让 Vue 根节点也撑满 100vh */
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ebeef5;
flex-shrink: 0; /* 防止头部被压缩 */
}
.header h2 { margin: 0; color: #303133; }
.main-container {
display: flex;
flex: 1; /* 占据剩下的所有垂直空间 */
gap: 20px;
min-height: 0; /* Flex 布局核心:防止子元素把父容器撑爆 */
}
.left-sidebar {
width: 300px;
background: #fff;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
height: 100%; /* 左侧菜单撑满容器 */
box-sizing: border-box;
}
.parent-list {
flex: 1; /* 占据左侧剩下的空间 */
overflow-y: auto; /* 关键:让左侧列表自己出现滚动条 */
margin-top: 15px;
border-top: 1px solid #ebeef5;
}
.right-content {
flex: 1;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
position: relative;
height: 100%; /* 右侧内容区严格撑满,不再随着网页滚动 */
overflow: hidden; /* 防止 ECharts 画布撑爆右侧容器 */
}
#echarts-container {
width: 100%;
height: 100%; /* 完全填满 right-content */
position: absolute; /* 让 ECharts 完全接管这块区域,解决高度坍塌 */
top: 0;
left: 0;
}
/* 悬浮信息卡片(必须要保留,不然卡片样式全乱) */
.info-card {
position: absolute;
top: 20px;
right: 20px;
width: 320px;
z-index: 10;
}
.parent-item {
padding: 12px 10px;
border-bottom: 1px solid #ebeef5;
cursor: pointer;
transition: all 0.3s;
}
.parent-item:hover { background-color: #f0f9eb; }
.parent-item.active {
background-color: #ecf5ff;
border-left: 4px solid #409EFF;
}
.parent-code { font-size: 12px; color: #909399; margin-bottom: 4px; }
.parent-name { font-size: 14px; color: #303133; font-weight: 500; }
.price-highlight {
font-size: 24px;
color: #F56C6C;
font-weight: bold;
margin: 10px 0;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>🕸️ ERP 动态 BOM 成本核算雷达图</h2>
<div>
<el-button type="primary" @click="goToCompare" plain>切换至 BOM 期间成本对比</el-button>
<el-button type="text" @click="goToReceipts">返回收货明细</el-button>
</div>
</div>
<div class="main-container">
<!-- 左侧:父件选择列表 -->
<div class="left-sidebar">
<el-input
placeholder="搜索成品/父件"
v-model="searchKeyword"
class="input-with-select"
clearable
@keyup.enter.native="searchParents"
@clear="searchParents">
<el-button slot="append" icon="el-icon-search" @click="searchParents"></el-button>
</el-input>
<div class="parent-list" v-loading="loadingParents">
<div v-if="parents.length === 0" style="text-align: center; color: #909399; padding: 20px;">
没有找到成品数据
</div>
<div
v-for="item in parents"
:key="item.parent_material_code"
class="parent-item"
:class="{ active: currentParentCode === item.parent_material_code }"
@click="loadBomTree(item.parent_material_code, item.parent_material_name)">
<div class="parent-code" v-text="item.parent_material_code"></div>
<div class="parent-name" v-text="item.parent_material_name"></div>
</div>
</div>
</div>
<!-- 右侧ECharts 树图展示区 -->
<div class="right-content" v-loading="loadingTree">
<div id="echarts-container"></div>
<!-- 悬浮的最新价格信息卡片 -->
<el-card class="info-card" v-show="currentNode">
<div slot="header" class="clearfix">
<span style="font-weight: bold;" v-text="currentNode ? currentNode.materialName : ''"></span>
</div>
<div v-if="currentNode">
<div style="font-size: 13px; color: #606266; margin-bottom: 15px;">
代码: <span v-text="currentNode.materialCode"></span><br>
层级: <el-tag size="mini" type="info"><span v-text="currentNode.bomLevel"></span></el-tag><br>
用量: <span v-text="currentNode.usageQty"></span>
</div>
<div v-loading="loadingPrice">
<!-- 展示组件/成品的汇总成本 -->
<div v-if="currentNode.children && currentNode.children.length > 0">
<div style="color: #909399; font-size: 12px;">该组件累加总成本 (其下所有子件之和)</div>
<div class="price-highlight" style="color: #E6A23C;" v-pre>¥ </div><span class="price-highlight" style="color: #E6A23C;" v-text="currentNode.totalCost.toFixed(2)"></span>
</div>
<!-- 展示单个物料的最新采购价 -->
<div v-if="!currentNode.children || currentNode.children.length === 0 || currentNode.ownPrice > 0" style="margin-top: 15px;">
<div style="color: #909399; font-size: 12px;">自身最新收货单价</div>
<div class="price-highlight" v-pre>¥ </div><span class="price-highlight" v-text="currentNode.ownPrice.toFixed(2)"></span>
</div>
<el-divider></el-divider>
<div style="font-size: 12px; color: #606266; line-height: 1.8;">
<div><strong>采购单号:</strong> <span v-text="currentNode.poCode"></span></div>
<div><strong>供应商:</strong> <span v-text="currentNode.supplierName"></span></div>
<div><strong>收货时间:</strong> <span v-text="currentNode.receiptTime"></span></div>
</div>
</div>
</div>
</el-card>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
searchKeyword: '',
parents: [],
loadingParents: false,
loadingTree: false,
loadingPrice: false,
currentParentCode: null,
myChart: null,
currentNode: null,
latestPrice: null
}
},
mounted() {
this.searchParents();
// 初始化 ECharts
this.myChart = echarts.init(document.getElementById('echarts-container'));
// 监听 ECharts 节点的点击事件
this.myChart.on('click', (params) => {
if (params.componentType === 'series' && params.data) {
this.handleNodeClick(params.data);
}
});
// 窗口大小改变时重绘图表
window.addEventListener('resize', () => {
this.myChart.resize();
});
},
methods: {
goToReceipts() {
window.location.href = '/';
},
goToCompare() {
window.location.href = '/compare';
},
searchParents() {
this.loadingParents = true;
axios.get(`/api/bom_parents?keyword=${this.searchKeyword}`)
.then(response => {
this.parents = response.data;
this.loadingParents = false;
})
.catch(error => {
this.$message.error('加载父件列表失败');
this.loadingParents = false;
});
},
loadBomTree(code, name) {
this.currentParentCode = code;
this.currentNode = null; // 切换树时清空卡片
this.loadingTree = true;
axios.get(`/api/bom_tree/${code}`)
.then(response => {
const treeData = response.data;
this.renderECharts(treeData);
this.loadingTree = false;
})
.catch(error => {
this.$message.error('加载 BOM 树结构失败');
this.loadingTree = false;
});
},
handleNodeClick(nodeData) {
// 当点击圆圈节点时触发
this.currentNode = nodeData;
// 我们已经在后端计算好了所有价格信息并附带在 nodeData 中,
// 所以不再需要单独发起网络请求了!实现真正的秒级响应。
this.loadingPrice = false;
},
renderECharts(treeData) {
const option = {
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
// 让提示框紧紧跟随鼠标,避免遮挡目标
position: 'right',
formatter: function(params) {
// 鼠标悬浮时,除了名称也显示它的成本
return params.data.name.replace('\n', '<br/>') + '<br/>用量: ' + params.data.usageQty + '<br/>总成本: ¥ ' + params.data.totalCost.toFixed(2);
}
},
series: [
{
type: 'tree',
data: [treeData],
// 留出足够的内边距,方便缩放拖拽
top: '15%',
left: '10%',
bottom: '15%',
right: '10%',
// 恢复小圆圈,放弃把 10 位数长编码强塞进圆圈的执念,否则必定重叠
symbolSize: 22,
// 恢复为正圆
symbol: 'circle',
// 【核心修改 1改变布局方向为从上到下】
orient: 'TB', // Top to Bottom
// 【核心修改 2美化连线为优雅的贝塞尔曲线】
edgeShape: 'curve',
// 【核心修改 3真正的自由画布】
roam: true,
// 【核心修改 4解决极端拥挤问题】
// 如果某个父件下面挂了上百个零件,原来的间距还是不够用
nodePadding: 200, // 暴力拉开!兄弟节点之间的横向距离强制设定为 200px
layerPadding: 180, // 父子层级间距
// 默认只展开第一层(直接点开的话只看到顶级成品和它的一级子件)
// 这样用户可以自己一层一层慢慢点开,绝不会一上来就满屏爆炸
initialTreeDepth: 1,
// 我们把物料名称放到圆圈的外面(比如正下方),
// 物料编码隐藏,只在点击后的右上角卡片或悬浮 tooltip 中显示
label: {
position: 'bottom', // 统一放在圆圈下方
distance: 10, // 距离圆圈 10px
verticalAlign: 'middle',
align: 'center',
// 只返回物料名称
formatter: function(params) {
return params.data.materialName;
},
color: '#606266', // 名称用深灰色
fontSize: 12,
backgroundColor: 'transparent', // 彻底变透明
padding: [4, 6],
// 如果名称太长,用省略号截断(最大宽度 100px
width: 100,
overflow: 'truncate'
},
// 关键修改:只让圆圈本体响应事件,让长长的标签变透明
// 避免鼠标还没碰到圆圈,就被旁边的长名字抢了焦点
triggerEvent: false,
// 如果同级子件太多,为了防止下面的牌子打架,我们让它们错位或倾斜一点点
leaves: {
label: {
position: 'bottom',
distance: 10,
rotate: -25 // 叶子节点倾斜 25 度,完美错开
}
},
emphasis: {
focus: 'descendant'
},
expandAndCollapse: true,
animationDuration: 550,
animationDurationUpdate: 750
}
]
};
this.myChart.setOption(option);
}
}
})
</script>
</body>
</html>

220
web_ui/templates/home.html Normal file
View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ERP 成本与数据看板 - 主控台</title>
<!-- 引入 ElementUI 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<!-- 引入 ElementUI 组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.dashboard-container {
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 80%;
max-width: 900px;
text-align: center;
}
.header-title {
color: #303133;
font-size: 28px;
margin-bottom: 10px;
font-weight: 600;
}
.header-subtitle {
color: #909399;
font-size: 16px;
margin-bottom: 40px;
}
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
}
.nav-card {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 30px 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(.25,.8,.25,1);
background-color: #fafafa;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.08);
border-color: #409EFF;
background-color: #ecf5ff;
}
.nav-card i {
font-size: 48px;
color: #409EFF;
margin-bottom: 15px;
}
.nav-card h3 {
margin: 0 0 10px 0;
color: #303133;
font-size: 20px;
}
.nav-card p {
margin: 0;
color: #606266;
font-size: 14px;
line-height: 1.5;
}
/* 特定卡片的颜色定制 */
.card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; }
.card-receipt i { color: #67C23A; }
.card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; }
.card-bom-tree i { color: #409EFF; }
.card-bom-compare:hover { border-color: #E6A23C; background-color: #fdf6ec; }
.card-bom-compare i { color: #E6A23C; }
.action-group {
margin-top: 40px;
padding-top: 30px;
border-top: 1px dashed #ebeef5;
display: flex;
justify-content: center;
gap: 20px;
}
</style>
</head>
<body>
<div class="dashboard-container" id="app">
<div class="header-title">ERP 数据分析与成本管控台</div>
<div class="header-subtitle">请选择您需要进入的业务模块</div>
<div class="nav-grid">
<!-- 卡片 1: 收货明细表 -->
<div class="nav-card card-receipt" onclick="window.location.href='/receipts'">
<i class="el-icon-document"></i>
<h3>收货明细报表</h3>
<p>查看、搜索所有历史收货记录及详细价格数据。</p>
</div>
<!-- 卡片 2: BOM 雷达图 -->
<div class="nav-card card-bom-tree" onclick="window.location.href='/bom'">
<i class="el-icon-share"></i>
<h3>BOM 成本雷达图</h3>
<p>以关系图谱形式可视化展示产品的层级结构与汇总成本。</p>
</div>
<!-- 卡片 3: 期间成本对比 -->
<div class="nav-card card-bom-compare" onclick="window.location.href='/compare'">
<i class="el-icon-data-line"></i>
<h3>期间成本对比分析表</h3>
<p>跨时间段核算 BOM 最新价差异,支持虚拟件过滤与历史价回溯。</p>
</div>
</div>
<div class="action-group">
<el-button
type="success"
:icon="syncing ? '' : 'el-icon-refresh'"
:loading="syncing"
@click="syncReceipts"
round>
<span v-text="syncing ? '正在后台同步增量数据,请稍候...' : '读取最新收货明细报表'"></span>
</el-button>
<el-button
type="primary"
:icon="syncingBom ? '' : 'el-icon-refresh-right'"
:loading="syncingBom"
@click="syncBom"
round>
<span v-text="syncingBom ? '正在后台抓取 BOM 树,耗时较长,请稍候...' : '读取最新 BOM 表'"></span>
</el-button>
</div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
syncing: false,
syncingBom: false
}
},
methods: {
syncReceipts() {
this.syncing = true;
axios.post('/api/sync_receipts')
.then(res => {
if (res.data.success) {
this.$message.success('明细同步成功!' + res.data.message);
} else {
this.$message.error('同步失败:' + res.data.message);
}
})
.catch(err => {
this.$message.error('请求发生异常,请检查后端日志。');
console.error(err);
})
.finally(() => {
this.syncing = false;
});
},
syncBom() {
this.syncingBom = true;
axios.post('/api/sync_bom')
.then(res => {
if (res.data.success) {
this.$message.success('BOM 同步成功!' + res.data.message);
} else {
this.$message.error('同步失败:' + res.data.message);
}
})
.catch(err => {
this.$message.error('请求发生异常,这可能需要较长时间,请检查控制台日志。');
console.error(err);
})
.finally(() => {
this.syncingBom = false;
});
}
}
});
</script>
</body>
</html>

186
web_ui/templates/index.html Normal file
View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收货明细报表 - 数据展示看板</title>
<!-- 引入 ElementUI (Vue的经典UI库) 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<!-- 引入 ElementUI 组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 axios 用于发送 HTTP 请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ebeef5;
}
.header h2 {
margin: 0;
color: #303133;
}
.card {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>📦 ERP 数据展示看板 - 收货明细报表</h2>
<el-button type="primary" @click="goToBomTree" icon="el-icon-data-analysis">切换至 BOM 成本雷达图</el-button>
</div>
<div class="card">
<!-- 搜索工具栏 -->
<div style="margin-bottom: 20px;">
<el-form :inline="true" :model="searchForm" class="demo-form-inline">
<el-form-item label="供应商名称">
<el-input v-model="searchForm.supplier_name" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item label="物料名称">
<el-input v-model="searchForm.material_name" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item label="采购订单号">
<el-input v-model="searchForm.po_code" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button>
<el-button @click="resetSearch" icon="el-icon-refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 数据表格 -->
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%">
<el-table-column prop="receipt_time" label="收货时间" width="160" sortable></el-table-column>
<el-table-column prop="purchase_order_code" label="采购订单号" width="130"></el-table-column>
<el-table-column prop="row_no" label="行号" width="60" align="center"></el-table-column>
<el-table-column prop="material_code" label="物料代码" width="130"></el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="material_specification" label="规格" min-width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="warehouse_name" label="收货仓库" width="120"></el-table-column>
<el-table-column prop="supplier_name" label="供应商名称" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="receive_price" label="收货单价" width="100" align="right">
<template slot-scope="scope">
<span style="color: #F56C6C; font-weight: bold;" v-pre>¥ </span><span style="color: #F56C6C; font-weight: bold;" v-text="scope.row.receive_price.toFixed(2)"></span>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[20, 50, 100, 500]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="totalRows">
</el-pagination>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
tableData: [],
loading: false,
currentPage: 1,
pageSize: 50,
totalRows: 0,
searchForm: {
supplier_name: '',
material_name: '',
po_code: ''
}
}
},
mounted() {
// 页面加载完成后立即拉取第一页数据
this.fetchData();
},
methods: {
goToBomTree() {
window.location.href = '/bom';
},
fetchData() {
this.loading = true;
// 构造带有搜索参数的请求 URL
const params = new URLSearchParams({
page: this.currentPage,
limit: this.pageSize,
supplier_name: this.searchForm.supplier_name,
material_name: this.searchForm.material_name,
po_code: this.searchForm.po_code
});
axios.get(`/api/receipts?${params.toString()}`)
.then(response => {
this.tableData = response.data.rows;
this.totalRows = response.data.total;
this.loading = false;
})
.catch(error => {
console.error("数据加载失败:", error);
this.$message.error('抱歉,获取数据失败了!');
this.loading = false;
});
},
handleSearch() {
this.currentPage = 1;
this.fetchData();
},
resetSearch() {
this.searchForm = {
supplier_name: '',
material_name: '',
po_code: ''
};
this.currentPage = 1;
this.fetchData();
},
handleSizeChange(val) {
this.pageSize = val;
this.fetchData();
},
handleCurrentChange(val) {
this.currentPage = val;
this.fetchData();
}
}
})
</script>
</body>
</html>