前端
This commit is contained in:
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "/Users/jm/anaconda3/envs/lzwc/bin/python",
|
||||
"python.condaPath": "/Users/jm/anaconda3/bin/conda"
|
||||
}
|
||||
75
build.spec
Normal file
75
build.spec
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# 获取当前工作目录(项目根目录)
|
||||
PROJECT_ROOT = os.path.abspath('.')
|
||||
|
||||
# 动态组装需要打包的静态数据文件
|
||||
datas = [
|
||||
('web_ui/templates', 'web_ui/templates')
|
||||
]
|
||||
|
||||
if os.path.exists(os.path.join(PROJECT_ROOT, 'web_ui', 'static')):
|
||||
datas.append(('web_ui/static', 'web_ui/static'))
|
||||
|
||||
if os.path.exists(os.path.join(PROJECT_ROOT, 'browser_login', '.env')):
|
||||
datas.append(('browser_login/.env', 'browser_login'))
|
||||
|
||||
a = Analysis(
|
||||
['web_ui/app.py'], # 主入口点
|
||||
pathex=[
|
||||
PROJECT_ROOT,
|
||||
os.path.join(PROJECT_ROOT, 'browser_login') # 确保能找到抓取脚本的路径
|
||||
],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=[
|
||||
'flask',
|
||||
'apscheduler.schedulers.background',
|
||||
'apscheduler.triggers.interval', # 必须显式声明,否则打包后定时任务可能找不到触发器
|
||||
'DrissionPage',
|
||||
'dotenv',
|
||||
'sqlite3',
|
||||
# 我们通过字符串动态导入的模块,必须声明让 PyInstaller 去追踪
|
||||
'fetch_receipt_details_incremental',
|
||||
'fetch_bom_cost_full_tree',
|
||||
'config',
|
||||
'login',
|
||||
'auto_launcher',
|
||||
'bom_query'
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='DatieERP', # 生成的 .exe 文件名称
|
||||
debug=False, # 生产环境关闭 Debug 模式
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True, # 启用 UPX 压缩以减小包体积(如果环境中有的话)
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None, # 使用默认临时目录 (即 sys._MEIPASS)
|
||||
console=True, # 是否保留黑色命令行窗口(开发初期建议保留看日志,稳定后可改为 False)
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
49
project_context_summary.md
Normal file
49
project_context_summary.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Datie ERP 数据自动化项目 - 上下文与进度总结
|
||||
|
||||
## 1. 项目概况与架构
|
||||
- **业务目标**:从 ERP 系统(云MES)自动抓取收货明细与 BOM 成本数据,进行本地清洗、持久化与二次计算,最后通过 Web UI(成本雷达图、期间对比分析表)提供极高数据密度的商业智能看板。
|
||||
- **技术栈**:
|
||||
- **爬虫端**:Python + DrissionPage (无头浏览器/监听API拦截)。
|
||||
- **后端**:Flask + SQLite (单文件存储,`erp_data.db`)。
|
||||
- **前端**:Vue 2 + ElementUI + Axios + ECharts,通过 `v-text` 避免与 Jinja2 语法冲突。
|
||||
- **部署计划**:最终打包为 Windows `.exe` 单文件应用(PyInstaller),利用 `webbrowser` 开机自启,并引入 `APScheduler` 实现后台每半小时自动增量同步。
|
||||
|
||||
## 2. 核心业务逻辑与计算公式 (非常重要!)
|
||||
|
||||
### 2.1 数量字段定义(解决“件数”与“重量”冲突)
|
||||
在 ERP 原始数据中,部分物料(如 `1PZJ` 开头的床身毛胚)同时存在件数和转换后的重量。为了保证所有公式正确:
|
||||
- **进货数量 (purchase_qty)**:固定读取 `plannedPurchaseQuantity`(例如:8件)。**只代表件数**。
|
||||
- **收货数量 (receive_qty)**:优先读取带有转换单位的 `convertGoodsQuantity`(例如:6520 KG),如果没有转换单位则回退到 `goodsQuantity`。**代表实际计价的重量/体积**。
|
||||
|
||||
### 2.2 核心派生字段计算逻辑
|
||||
基于上述字段定义,后端在 [app.py](file:///Users/jm/workplace/Datie/web_ui/app.py) 中动态计算以下指标:
|
||||
- **物料单价 (receive_price)**:
|
||||
- **公式**:`ROUND(COALESCE(total_amount / NULLIF(receive_qty, 0), receive_price, 0), 2)`
|
||||
- **说明**:收货总金额 ÷ 收货数量(重量)。例如:44988元 ÷ 6520 KG = 6.9 元/KG。
|
||||
- **铸件重量 (casting_weight)**:
|
||||
- **公式**:`receive_qty / purchase_qty`
|
||||
- **说明**:收货数量(重量) ÷ 进货数量(件数)。例如:6520 KG ÷ 8 件 = 815 KG/件。该逻辑仅针对物料代码以 `1PZJ` 开头的数据生效。
|
||||
- **BOM 用量合并展示**:前端将 `usage_qty` 与 `unit_name` 拼接展示(如 "2PCS")。
|
||||
|
||||
## 3. UI/UX 设计规范
|
||||
- **极端空间压缩**:表格行高极度压缩,去除 padding,字体使用 15px/16px,数据完美居中(Flexbox `justify-content: center`)。
|
||||
- **斑马纹设计**:表格行采用白/浅蓝交替背景色,增强可读性。
|
||||
- **状态指示器精简**:
|
||||
- 移除原先的橙色点(🟠)。
|
||||
- **红点 (🔴)**:仅表示该物料在选定期间内无采购数据,当前显示的价格是**追溯至期间以前的最新历史基准价**。
|
||||
- **横杠 (-)**:表示该节点(无论是父件还是底层子件)彻底没有历史采购价格,或者成本加总为 0。
|
||||
|
||||
## 4. 最新进度与修复记录 (2026-04)
|
||||
1. **数据抓取逻辑重构**:
|
||||
- 修复了爬虫脚本 [fetch_receipt_details_full.py](file:///Users/jm/workplace/Datie/browser_login/fetch_receipt_details_full.py) 和增量脚本的字段映射,确保精准提取 `convert` 系列字段。
|
||||
- 优化了入库脚本 [import_to_sqlite.py](file:///Users/jm/workplace/Datie/browser_login/import_to_sqlite.py),加入了 `UPSERT` (存在即更新) 的防重复逻辑。
|
||||
2. **后端查询重构**:
|
||||
- 修正了 `app.py` 中所有的 SQL 查询,将 `NULLIF(purchase_qty, 0)` 替换为 `NULLIF(receive_qty, 0)`,彻底解决了 8件 vs 6520公斤的计算冲突。
|
||||
3. **异步调度集成**:
|
||||
- 实现了 [home.html](file:///Users/jm/workplace/Datie/web_ui/templates/home.html) 控制台,通过 `/api/sync_receipts` 等接口利用 `subprocess.run` 异步执行爬虫脚本,前端展示 Loading 状态。
|
||||
|
||||
## 5. 下一步开发计划 (Next Steps)
|
||||
1. **本地测试与验证**:在前端完成所有 BOM 树、期间对比的价格和铸件重量的视觉核对。
|
||||
2. **Windows 部署准备**:
|
||||
- 配置 `PyInstaller` 的 `.spec` 文件,解决静态资源(templates, static)和数据库外部挂载路径(`sys._MEIPASS`)问题。
|
||||
- 在 Flask 启动生命周期中集成 `APScheduler` 以替代目前手动点击按钮触发的增量更新。
|
||||
684
web_ui/app.py
Normal file
684
web_ui/app.py
Normal 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)
|
||||
539
web_ui/templates/bom_compare.html
Normal file
539
web_ui/templates/bom_compare.html
Normal 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>
|
||||
366
web_ui/templates/bom_tree.html
Normal file
366
web_ui/templates/bom_tree.html
Normal 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
220
web_ui/templates/home.html
Normal 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
186
web_ui/templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user