diff --git a/browser_login/analysis_service.py b/browser_login/analysis_service.py new file mode 100644 index 0000000..29238a6 --- /dev/null +++ b/browser_login/analysis_service.py @@ -0,0 +1,249 @@ +import sqlite3 +import re +from pathlib import Path +import sys + +# 把当前目录加入 sys.path +sys.path.insert(0, str(Path(__file__).parent)) +from config import OUTPUT_DIR + +DB_PATH = OUTPUT_DIR / 'erp_data.db' + +class MaterialReconciliationService: + def __init__(self): + self.db_path = DB_PATH + + def get_conn(self): + return sqlite3.connect(self.db_path) + + def get_data_period(self): + """获取当前数据库中发料记录的时间周期,用于前端展示对账月份""" + import datetime + import calendar + + now = datetime.datetime.now() + first_day = datetime.date(now.year, now.month, 1).strftime('%Y-%m-%d') + last_day = datetime.date(now.year, now.month, calendar.monthrange(now.year, now.month)[1]).strftime('%Y-%m-%d') + + return {"start": first_day, "end": last_day} + + def step1_extract_and_match_work_orders(self): + """ + 第一步:匹配生产工单。 + 对于有生产工单的,直接赋值。 + 对于没有生产工单的,从备注中通过正则提取潜在的工单号(如 SFC... 或 连续数字)。 + 并将其更新到 inferred_production_order_no 字段中。 + """ + print("[开始] 正在连接数据库并准备清洗工单数据...") + conn = self.get_conn() + cursor = conn.cursor() + + # 确保表有 inferred_production_order_no 字段 + try: + cursor.execute("ALTER TABLE issue_receipt_details ADD COLUMN inferred_production_order_no TEXT;") + print("[信息] 已在数据库中新建 inferred_production_order_no 字段。") + except sqlite3.OperationalError: + pass # 已经存在 + + print("[执行] 正在读取所有发料单明细...") + # 获取所有发料单 + cursor.execute("SELECT id, production_order_no, work_orders_remark, detailed_remark, production_order_remark FROM issue_receipt_details") + rows = cursor.fetchall() + print(f"[执行] 共读取到 {len(rows)} 条发料记录,开始进行正则比对...") + + # 预先加载 abnormal_report 中的有效工单号作为验证库 + cursor.execute("SELECT DISTINCT work_orders_number FROM abnormal_report") + valid_sfcs = {r[0].upper() for r in cursor.fetchall() if r[0]} + + updates = [] + matched_count = 0 + + for row in rows: + row_id = row[0] + prod_no = row[1] + work_remark = row[2] or '' + detail_remark = row[3] or '' + prod_remark = row[4] or '' + + inferred_sfc = '' + + # 如果系统本身已经有工单号 + if prod_no and prod_no.strip(): + inferred_sfc = prod_no.strip() + else: + # 合并所有备注信息 + combined_remarks = f"{work_remark} {detail_remark} {prod_remark}" + if combined_remarks.strip(): + # 优先匹配完整的 SFC 格式 (例如 SFC018845) + sfc_match = re.search(r'(SFC\d+)', combined_remarks, re.IGNORECASE) + if sfc_match: + inferred_sfc = sfc_match.group(1).upper() + else: + # 尝试匹配纯数字格式 (长度大于等于4位的连续数字) + num_match = re.search(r'(\d{4,})', combined_remarks) + if num_match: + candidate_num = num_match.group(1) + # 尝试拼凑 SFC 前缀并校验是否存在于有效库中 + candidate_sfc = f"SFC0{candidate_num}" + if candidate_sfc in valid_sfcs: + inferred_sfc = candidate_sfc + else: + candidate_sfc2 = f"SFC{candidate_num}" + if candidate_sfc2 in valid_sfcs: + inferred_sfc = candidate_sfc2 + else: + inferred_sfc = candidate_sfc # 哪怕没在库里也提取出来,作为参考 + + if inferred_sfc: + matched_count += 1 + updates.append((inferred_sfc, row_id)) + + print(f"[信息] 匹配完成。有 {matched_count} 条数据成功匹配到工单号。正在将结果写回数据库...") + # 批量更新数据库 + cursor.executemany( + "UPDATE issue_receipt_details SET inferred_production_order_no = ? WHERE id = ?", + updates + ) + conn.commit() + conn.close() + print(f"[完成] 数据库更新完毕!本次清洗共处理了 {len(updates)} 条数据。") + return len(updates) + + def step2_get_unmatched_materials(self, start_date=None, end_date=None): + """ + 第二步:整理出没有生产工单的物料明细表 + """ + conn = self.get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + where_clause = "WHERE (inferred_production_order_no = '' OR inferred_production_order_no IS NULL)" + params = [] + + if start_date and end_date: + where_clause += " AND execution_time >= ? AND execution_time <= ?" + params.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"]) + + sql = f""" + SELECT + work_orders_number as issue_receipt_no, + material_code, + material_name, + material_specification, + issue_number, + work_orders_remark, + detailed_remark, + production_order_remark, + execution_time, + executor_user_name, + warehouse_name + FROM issue_receipt_details + {where_clause} + ORDER BY execution_time DESC + """ + cursor.execute(sql, params) + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + + def step3_bom_reconciliation(self, start_date=None, end_date=None): + """ + 第三步:BOM 校准物料,比对发料与BOM差异 + 返回按工单分组的差异列表。 + """ + conn = self.get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + where_actual = "WHERE inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL" + params_actual = [] + + if start_date and end_date: + where_actual += " AND execution_time >= ? AND execution_time <= ?" + params_actual.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"]) + + sql_actual = f""" + SELECT + inferred_production_order_no as sfc, + material_code, + material_name, + SUM(issue_number) as total_actual_issue + FROM issue_receipt_details + {where_actual} + GROUP BY inferred_production_order_no, material_code + """ + + where_bom = "" + params_bom = [] + if start_date and end_date: + where_bom = "WHERE order_date >= ? AND order_date <= ?" + params_bom.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"]) + + sql_bom = f""" + SELECT + work_orders_number as sfc, + material_code, + material_name, + theoretical_issue_qty as bom_qty + FROM abnormal_report + {where_bom} + """ + + cursor.execute(sql_actual, params_actual) + actuals = cursor.fetchall() + + cursor.execute(sql_bom, params_bom) + boms = cursor.fetchall() + + conn.close() + + reconciliation = {} + + for r in boms: + key = (r['sfc'], r['material_code']) + reconciliation[key] = { + 'sfc': r['sfc'], + 'material_code': r['material_code'], + 'material_name': r['material_name'], + 'bom_qty': r['bom_qty'] or 0, + 'actual_qty': 0, + 'diff_qty': 0 - (r['bom_qty'] or 0), + 'status': '未发料' + } + + for r in actuals: + key = (r['sfc'], r['material_code']) + if key not in reconciliation: + reconciliation[key] = { + 'sfc': r['sfc'], + 'material_code': r['material_code'], + 'material_name': r['material_name'], + 'bom_qty': 0, + 'actual_qty': 0, + 'diff_qty': 0, + 'status': 'BOM外发料' + } + reconciliation[key]['actual_qty'] += r['total_actual_issue'] + reconciliation[key]['diff_qty'] = round(reconciliation[key]['actual_qty'] - reconciliation[key]['bom_qty'], 4) + + if reconciliation[key]['diff_qty'] > 0 and reconciliation[key]['bom_qty'] > 0: + reconciliation[key]['status'] = '超领发料' + elif reconciliation[key]['diff_qty'] < 0: + reconciliation[key]['status'] = '少领发料' + elif reconciliation[key]['diff_qty'] == 0 and reconciliation[key]['bom_qty'] > 0: + reconciliation[key]['status'] = '发料正常' + + return list(reconciliation.values()) + +if __name__ == '__main__': + service = MaterialReconciliationService() + print("Executing Step 1: Matching work orders...") + updated = service.step1_extract_and_match_work_orders() + print(f"Updated {updated} issue receipts with inferred production orders.") + + unmatched = service.step2_get_unmatched_materials() + print(f"Found {len(unmatched)} unmatched materials.") + + recon = service.step3_bom_reconciliation() + abnormal = [r for r in recon if r['status'] in ['超领发料', 'BOM外发料']] + print(f"Found {len(abnormal)} abnormal BOM issues.") diff --git a/browser_login/auto_fetch_abnormal_report.py b/browser_login/auto_fetch_abnormal_report.py index be64451..202d1bc 100644 --- a/browser_login/auto_fetch_abnormal_report.py +++ b/browser_login/auto_fetch_abnormal_report.py @@ -83,20 +83,39 @@ def fetch_report_data(page): var doc = iframes[j].contentDocument || iframes[j].contentWindow.document; var win = iframes[j].contentWindow; - // 1. 设置开始日期 - var startInputs = doc.querySelectorAll('.input_StartValue.datebox-f'); - if (startInputs.length > 0) {{ - win.$(startInputs[0]).datebox('setValue', '{first_day}'); + // 1. 自动设置下单日期为当月 + // (暴力匹配所有的 datebox,前两个一般就是开始和结束) + var dates = doc.querySelectorAll('.datebox-f, .datetimebox-f'); + if (dates.length >= 2) {{ + try {{ win.$(dates[0]).datetimebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}} + try {{ win.$(dates[0]).datebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}} + + try {{ win.$(dates[1]).datetimebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}} + try {{ win.$(dates[1]).datebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}} + }} else {{ + // 备用方案:寻找附近元素 + var allSpans = doc.querySelectorAll('span, td, th'); + for(var k=0; k 0) {{ - win.$(endInputs[0]).datebox('setValue', '{last_day}'); - }} - - // 3. 清理所有下拉框(包括发料情况) - var combos = doc.querySelectorAll('.combobox-f, .textbox-f'); + // 3. 清理发料情况下拉框 (千万不能选 textbox-f,否则会把刚才填的日期清空!) + var combos = doc.querySelectorAll('.combobox-f'); for(var i=0; i 0 else 50 + total_pages = (total_count + actual_page_size - 1) // actual_page_size if total_count > 0 else 1 + print("===================================") - print(f"✅ 成功拦截到报表数据API (第 {current_page} 页)") + print(f"✅ 成功拦截到报表数据API (第 {current_page}/{total_pages} 页)") print(f"✅ 数据总条数: {total_count}, 当前页条数: {len(items)}") print("===================================") - total_pages = (total_count + 499) // 500 if total_count > 0 else 1 - # Import and save to database try: import import_to_sqlite @@ -197,20 +218,13 @@ def fetch_report_data(page): pass if not found_data: - print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 第 {current_page} 页等待了3秒,没有拦截到匹配的报表数据...") + print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 第 {current_page} 页等待了超时,没有拦截到匹配的报表数据...") # 再给一次机会等3秒 print("再等待3秒重试...") time.sleep(3) - retry_packets = target_tab.listen.steps() - print(f"重试收集到 {len(retry_packets)} 个数据包。") - if not retry_packets: - print(f"彻底没有数据,停止抓取。") - break - else: - packets.extend(retry_packets) - # 重新让上面解析 - continue + # 重新让上面解析 + continue if current_page >= total_pages: print(f"已到达最后一页 (共 {total_pages} 页),抓取完成!") diff --git a/browser_login/fetch_bom_cost_full_tree.py b/browser_login/fetch_bom_cost_full_tree.py index e40141d..fc6236d 100644 --- a/browser_login/fetch_bom_cost_full_tree.py +++ b/browser_login/fetch_bom_cost_full_tree.py @@ -100,10 +100,13 @@ def fetch_bom_cost_tree(): data = body if isinstance(body, (dict, list)) else json.loads(body) if isinstance(data, dict) and "result" in data: - items = data["result"].get("items", []) - total_records = data["result"].get("totalCount", 0) + result_data = data.get("result") or {} + items = result_data.get("items", []) + total_records = result_data.get("totalCount", 0) for item in items: + if not item: + continue # 注意:我们要拿的是 parentMaterialId,因为这是传给 BOM 成本 API 的关键参数 materialId clean_parent = { "_id": item.get("id"), # 这个是 partBomCostAccountingId @@ -237,7 +240,12 @@ def fetch_bom_cost_tree(): log("ERR", f"❌ 自动触发入库脚本失败: {db_err}") except Exception as e: - log("ERR", f"发生异常: {e}") + import traceback + err_msg = f"发生异常: {e}\n{traceback.format_exc()}" + with open("error.log", "w") as f: + f.write(err_msg) + print(err_msg, flush=True) + log("ERR", err_msg) if __name__ == "__main__": fetch_bom_cost_tree() \ No newline at end of file diff --git a/web_ui/app.py b/web_ui/app.py index 1434666..94838e7 100644 --- a/web_ui/app.py +++ b/web_ui/app.py @@ -155,6 +155,11 @@ def abnormal_report_page(): """渲染发料异常检查数据看板""" return render_template('abnormal_report.html') +@app.route('/reconciliation') +def reconciliation_page(): + """渲染绩效核查与BOM比对看板""" + return render_template('reconciliation.html') + @app.route('/api/receipts') def get_receipts(): """获取收货明细数据(支持分页和多条件搜索)""" @@ -299,6 +304,81 @@ def get_abnormal_report(): "rows": [dict(ix) for ix in orders] }) +@app.route('/api/analysis/match_work_orders', methods=['POST']) +def match_work_orders(): + """触发:第一步提取并匹配工单""" + global is_browser_busy + if is_browser_busy: + return jsonify({ + "success": False, + "message": f"系统忙碌:当前正在执行 '{current_task_name}',请稍后再试。" + }), 409 + + def run_match_task(): + set_browser_busy("自动清洗并匹配工单数据") + try: + sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + from analysis_service import MaterialReconciliationService + service = MaterialReconciliationService() + updated = service.step1_extract_and_match_work_orders() + print(f"✅ 工单匹配任务执行成功!共处理了 {updated} 条发料记录。") + except Exception as e: + print(f"❌ 自动清洗匹配失败: {e}") + finally: + release_browser() + + try: + threading.Thread(target=run_match_task, daemon=True).start() + return jsonify({"success": True, "message": "已触发后台自动清洗匹配工单任务!"}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/analysis/unmatched_materials') +def get_unmatched_materials(): + """获取:第二步没有匹配到工单的物料明细""" + try: + start_date = request.args.get('start') + end_date = request.args.get('end') + sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + from analysis_service import MaterialReconciliationService + service = MaterialReconciliationService() + data = service.step2_get_unmatched_materials(start_date, end_date) + return jsonify({"total": len(data), "rows": data}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/analysis/bom_reconciliation') +def get_bom_reconciliation(): + """获取:第三步比对发料与BOM差异""" + try: + start_date = request.args.get('start') + end_date = request.args.get('end') + sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + from analysis_service import MaterialReconciliationService + service = MaterialReconciliationService() + data = service.step3_bom_reconciliation(start_date, end_date) + + # 支持前端筛选 + status_filter = request.args.get('status', '') + if status_filter: + data = [r for r in data if r['status'] == status_filter] + + return jsonify({"total": len(data), "rows": data}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/analysis/summary') +def get_analysis_summary(): + """获取当前对账数据的周期等汇总信息""" + try: + sys.path.insert(0, str(BROWSER_LOGIN_DIR)) + from analysis_service import MaterialReconciliationService + service = MaterialReconciliationService() + period = service.get_data_period() + return jsonify(period) + except Exception as e: + return jsonify({"start": "-", "end": "-"}), 500 + @app.route('/api/task_status') def get_task_status(): """获取当前浏览器控制任务的状态""" @@ -451,7 +531,11 @@ def sync_bom(): from fetch_bom_cost_full_tree import fetch_bom_cost_tree fetch_bom_cost_tree() except Exception as e: - print(f"BOM 抓取失败: {e}") + import traceback + err_msg = f"BOM 抓取失败: {e}\n{traceback.format_exc()}" + print(err_msg) + with open("app_error.log", "w") as f: + f.write(err_msg) finally: release_browser() diff --git a/web_ui/templates/abnormal_report.html b/web_ui/templates/abnormal_report.html index 101fbe1..2a280f8 100644 --- a/web_ui/templates/abnormal_report.html +++ b/web_ui/templates/abnormal_report.html @@ -283,5 +283,6 @@ } }); +{% include "global_log.html" %} \ No newline at end of file diff --git a/web_ui/templates/bom_compare.html b/web_ui/templates/bom_compare.html index 2e3ad21..d30c3ae 100644 --- a/web_ui/templates/bom_compare.html +++ b/web_ui/templates/bom_compare.html @@ -621,5 +621,6 @@ } }) +{% include "global_log.html" %} \ No newline at end of file diff --git a/web_ui/templates/bom_tree.html b/web_ui/templates/bom_tree.html index 5562de2..7742db6 100644 --- a/web_ui/templates/bom_tree.html +++ b/web_ui/templates/bom_tree.html @@ -362,5 +362,6 @@ } }) +{% include "global_log.html" %} \ No newline at end of file diff --git a/web_ui/templates/global_log.html b/web_ui/templates/global_log.html new file mode 100644 index 0000000..40f43b3 --- /dev/null +++ b/web_ui/templates/global_log.html @@ -0,0 +1,80 @@ +
+ + + 后台运行日志 + + + + +
+
+ 暂无后台任务输出... +
+
+ {{ log }} +
+
+ + 关 闭 + +
+
+ + + + \ No newline at end of file diff --git a/web_ui/templates/home.html b/web_ui/templates/home.html index 2f4f65f..5bf28c9 100644 --- a/web_ui/templates/home.html +++ b/web_ui/templates/home.html @@ -95,21 +95,30 @@ } /* 特定卡片的颜色定制 */ - .card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; } + .card-receipt { border-top: 4px solid #67C23A; } .card-receipt i { color: #67C23A; } + .card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; } - .card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; } + .card-bom-tree { border-top: 4px solid #409EFF; } .card-bom-tree i { color: #409EFF; } + .card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; } - .card-bom-compare:hover { border-color: #E6A23C; background-color: #fdf6ec; } - .card-bom-compare i { color: #E6A23C; } + .card-bom-compare { border-top: 4px solid #9c27b0; } + .card-bom-compare i { color: #9c27b0; } + .card-bom-compare:hover { border-color: #9c27b0; background-color: #f3e5f5; } .card-work-order { border-top: 4px solid #E6A23C; } .card-work-order i { color: #E6A23C; } + .card-work-order:hover { border-color: #E6A23C; background-color: #fdf6ec; } .card-abnormal { border-top: 4px solid #F56C6C; } .card-abnormal i { color: #F56C6C; } + .card-abnormal:hover { border-color: #F56C6C; background-color: #fef0f0; } + .card-reconciliation { border-top: 4px solid #909399; } + .card-reconciliation i { color: #909399; } + .card-reconciliation:hover { border-color: #909399; background-color: #f4f4f5; } + .action-group { margin-top: 40px; padding-top: 30px; @@ -117,6 +126,37 @@ display: flex; justify-content: center; gap: 20px; + position: relative; + } + + .log-window { + margin-top: 30px; + background-color: #1e1e1e; + color: #a9b7c6; + border-radius: 8px; + padding: 15px; + height: 250px; + overflow-y: auto; + text-align: left; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + box-shadow: inset 0 0 10px rgba(0,0,0,0.5); + border: 1px solid #333; + } + + .log-window::-webkit-scrollbar { + width: 8px; + } + .log-window::-webkit-scrollbar-track { + background: #1e1e1e; + } + .log-window::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; + } + .log-window::-webkit-scrollbar-thumb:hover { + background: #777; } @@ -162,6 +202,13 @@

发料异常检查

排查生产工单的发料异常,对比理论出料与实际发放数量的差异。

+ + +
@@ -198,6 +245,8 @@
+ {% include 'global_log.html' %} + +{% include "global_log.html" %} \ No newline at end of file diff --git a/web_ui/templates/reconciliation.html b/web_ui/templates/reconciliation.html new file mode 100644 index 0000000..06408b9 --- /dev/null +++ b/web_ui/templates/reconciliation.html @@ -0,0 +1,405 @@ + + + + + + 绩效核查与 BOM 比对 + + + + + + + + + + + +
+ + + + +
+
+ + 执行自动对账前,请先点击"提取并匹配工单"进行数据清洗。 + + + +
+ 提取并匹配工单 +
+ + + + + + + 无工单发料明细 + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + BOM 发料对账 + +
+ + + + + + + + + + 导出 Excel +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ +
+
+
+ + +{% include "global_log.html" %} + + \ No newline at end of file diff --git a/web_ui/templates/work_orders.html b/web_ui/templates/work_orders.html index 5d12666..74df3c6 100644 --- a/web_ui/templates/work_orders.html +++ b/web_ui/templates/work_orders.html @@ -167,5 +167,6 @@ } }); +{% include "global_log.html" %} \ No newline at end of file