From 0cea74ad973a6ee01d4a2c0c534c8600aed92107 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 27 Apr 2026 15:24:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 4 + build.spec | 75 ++++ project_context_summary.md | 49 +++ web_ui/app.py | 684 ++++++++++++++++++++++++++++++ web_ui/templates/bom_compare.html | 539 +++++++++++++++++++++++ web_ui/templates/bom_tree.html | 366 ++++++++++++++++ web_ui/templates/home.html | 220 ++++++++++ web_ui/templates/index.html | 186 ++++++++ 8 files changed, 2123 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 build.spec create mode 100644 project_context_summary.md create mode 100644 web_ui/app.py create mode 100644 web_ui/templates/bom_compare.html create mode 100644 web_ui/templates/bom_tree.html create mode 100644 web_ui/templates/home.html create mode 100644 web_ui/templates/index.html diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..beba246 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.defaultInterpreterPath": "/Users/jm/anaconda3/envs/lzwc/bin/python", + "python.condaPath": "/Users/jm/anaconda3/bin/conda" +} \ No newline at end of file diff --git a/build.spec b/build.spec new file mode 100644 index 0000000..950ad0f --- /dev/null +++ b/build.spec @@ -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, +) \ No newline at end of file diff --git a/project_context_summary.md b/project_context_summary.md new file mode 100644 index 0000000..9382e2f --- /dev/null +++ b/project_context_summary.md @@ -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` 以替代目前手动点击按钮触发的增量更新。 \ No newline at end of file diff --git a/web_ui/app.py b/web_ui/app.py new file mode 100644 index 0000000..76a2528 --- /dev/null +++ b/web_ui/app.py @@ -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/') +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/') +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/') +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) \ No newline at end of file diff --git a/web_ui/templates/bom_compare.html b/web_ui/templates/bom_compare.html new file mode 100644 index 0000000..4fcb454 --- /dev/null +++ b/web_ui/templates/bom_compare.html @@ -0,0 +1,539 @@ + + + + + + BOM 成本期间对比分析表 + + + + + + + + + + +
+
+

📊 BOM 成本期间对比分析表

+
+ 返回雷达图 + 返回收货明细 +
+
+ +
+ + + + +
+ +
+ + + + 期间 A (基准期) + + + + + + + + + 期间 B (对比期) + + + + + + + + 执行对比 + + + + + + + + + + + + + 图例说明: + 期间内无数据,已回溯取历史最近价 + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ 请在左侧选择一个成品父件进行分析 +
+
+
+
+ + + + \ No newline at end of file diff --git a/web_ui/templates/bom_tree.html b/web_ui/templates/bom_tree.html new file mode 100644 index 0000000..5562de2 --- /dev/null +++ b/web_ui/templates/bom_tree.html @@ -0,0 +1,366 @@ + + + + + + BOM 成本动态雷达图 - 数据展示看板 + + + + + + + + + + + + +
+
+

🕸️ ERP 动态 BOM 成本核算雷达图

+
+ 切换至 BOM 期间成本对比 + 返回收货明细 +
+
+ +
+ + + + +
+
+ + + +
+ +
+
+
+ 代码:
+ 层级:
+ 用量: +
+ +
+ +
+
该组件累加总成本 (其下所有子件之和)
+
¥
+
+ + +
+
自身最新收货单价
+
¥
+
+ + + +
+
采购单号:
+
供应商:
+
收货时间:
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/web_ui/templates/home.html b/web_ui/templates/home.html new file mode 100644 index 0000000..aba4c78 --- /dev/null +++ b/web_ui/templates/home.html @@ -0,0 +1,220 @@ + + + + + + ERP 成本与数据看板 - 主控台 + + + + + + + + + + + + + + + +
+
ERP 数据分析与成本管控台
+
请选择您需要进入的业务模块
+ + + +
+ + + + + + + +
+
+ + + + diff --git a/web_ui/templates/index.html b/web_ui/templates/index.html new file mode 100644 index 0000000..fd5150a --- /dev/null +++ b/web_ui/templates/index.html @@ -0,0 +1,186 @@ + + + + + + 收货明细报表 - 数据展示看板 + + + + + + + + + + + + + + +
+
+

📦 ERP 数据展示看板 - 收货明细报表

+ 切换至 BOM 成本雷达图 +
+ +
+ +
+ + + + + + + + + + + + 查询 + 重置 + + +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + \ No newline at end of file