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, match_type='official'): """ 第三步:BOM 校准物料,比对发料与BOM差异 返回按工单分组的树形结构。 """ conn = self.get_conn() conn.row_factory = sqlite3.Row cursor = conn.cursor() if match_type == 'official': where_actual = "WHERE production_order_no != '' AND production_order_no IS NOT NULL" sfc_field = "production_order_no" else: where_actual = "WHERE (production_order_no = '' OR production_order_no IS NULL) AND inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL" sfc_field = "inferred_production_order_no" 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 {sfc_field} as sfc, material_code, material_name, SUM(issue_number) as total_actual_issue FROM issue_receipt_details {where_actual} GROUP BY {sfc_field}, 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, product_code, product_name, 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() sfc_to_product = {} sfc_bom_dict = {} # {sfc: {material_code: {qty, name}}} for r in boms: sfc = r['sfc'] mat_code = r['material_code'] if sfc not in sfc_bom_dict: sfc_bom_dict[sfc] = {} sfc_bom_dict[sfc][mat_code] = { 'bom_qty': r['bom_qty'] or 0, 'material_name': r['material_name'] } if r['product_code']: sfc_to_product[sfc] = { 'product_code': r['product_code'], 'product_name': r['product_name'] } sfc_actual_dict = {} # {sfc: {material_code: {qty, name}}} for r in actuals: sfc = r['sfc'] mat_code = r['material_code'] if sfc not in sfc_actual_dict: sfc_actual_dict[sfc] = {} sfc_actual_dict[sfc][mat_code] = { 'actual_qty': r['total_actual_issue'] or 0, 'material_name': r['material_name'] } if match_type == 'official': all_sfcs = set(sfc_bom_dict.keys()).union(set(sfc_actual_dict.keys())) else: all_sfcs = set(sfc_actual_dict.keys()) # 预先获取所有的 product_code 的 bom_child 树结构 product_codes = [p['product_code'] for p in sfc_to_product.values() if p['product_code']] bom_trees = {} # {product_code: list of root children} if product_codes: unique_p_codes = list(set(product_codes)) placeholders = ','.join(['?'] * len(unique_p_codes)) cursor.execute(f""" SELECT id, parent_material_code, node_material_code, node_material_name, bom_level, parent_node_id, usage_qty FROM bom_child WHERE parent_material_code IN ({placeholders}) """, unique_p_codes) all_bom_children = cursor.fetchall() from collections import defaultdict child_by_product = defaultdict(list) for row in all_bom_children: child_by_product[row['parent_material_code']].append(dict(row)) for p_code, children in child_by_product.items(): nodes_map = {} for row in children: nodes_map[row['id']] = { 'id': f"node_{row['id']}", 'material_code': row['node_material_code'], 'material_name': row['node_material_name'], 'bom_level': row['bom_level'], 'parent_node_id': row['parent_node_id'], 'bom_qty': 0, 'actual_qty': 0, 'diff_qty': 0, 'status': '', 'children': [] } root_children = [] for row in children: node_id = row['id'] parent_id = row['parent_node_id'] if parent_id is None: root_children.append(nodes_map[node_id]) else: if parent_id in nodes_map: nodes_map[parent_id]['children'].append(nodes_map[node_id]) bom_trees[p_code] = root_children conn.close() result = [] for sfc in all_sfcs: prod_info = sfc_to_product.get(sfc, {}) p_code = prod_info.get('product_code', '') p_name = prod_info.get('product_name', '无工单关联产品') sfc_node = { 'id': f"sfc_{sfc}", 'sfc': sfc, 'material_code': p_code, 'material_name': f"【成品】{p_name}", 'bom_qty': '', 'actual_qty': '', 'diff_qty': '', 'status': '', 'children': [] } boms_for_sfc = sfc_bom_dict.get(sfc, {}) actuals_for_sfc = sfc_actual_dict.get(sfc, {}) processed_materials = set() import copy if p_code in bom_trees: tree_clone = copy.deepcopy(bom_trees[p_code]) def fill_tree_qty(nodes, sfc_id): for idx, node in enumerate(nodes): node['id'] = f"{sfc_id}_{node['id']}_{idx}" # 确保前端树节点的唯一性 m_code = node['material_code'] bom_info = boms_for_sfc.get(m_code, {}) act_info = actuals_for_sfc.get(m_code, {}) node['bom_qty'] = bom_info.get('bom_qty', 0) node['actual_qty'] = act_info.get('actual_qty', 0) node['diff_qty'] = round(node['actual_qty'] - node['bom_qty'], 4) if node['diff_qty'] > 0 and node['bom_qty'] > 0: node['status'] = '超领发料' elif node['diff_qty'] < 0: node['status'] = '少领发料' elif node['diff_qty'] == 0 and node['bom_qty'] > 0: node['status'] = '发料正常' elif node['bom_qty'] == 0 and node['actual_qty'] > 0: node['status'] = 'BOM外发料' elif node['bom_qty'] > 0 and node['actual_qty'] == 0: node['status'] = '未发料' else: node['status'] = '无发料任务' processed_materials.add(m_code) fill_tree_qty(node['children'], sfc_id) fill_tree_qty(tree_clone, sfc) sfc_node['children'].extend(tree_clone) all_m_codes_for_sfc = set(boms_for_sfc.keys()).union(set(actuals_for_sfc.keys())) extra_materials = all_m_codes_for_sfc - processed_materials # 将多出的(或者没有树结构的)物料直接作为工单的子节点 for i, m_code in enumerate(extra_materials): bom_info = boms_for_sfc.get(m_code, {}) act_info = actuals_for_sfc.get(m_code, {}) bom_q = bom_info.get('bom_qty', 0) act_q = act_info.get('actual_qty', 0) m_name = bom_info.get('material_name') or act_info.get('material_name') or '未知名称' diff_q = round(act_q - bom_q, 4) status = '' if diff_q > 0 and bom_q > 0: status = '超领发料' elif diff_q < 0: status = '少领发料' elif diff_q == 0 and bom_q > 0: status = '发料正常' elif bom_q == 0 and act_q > 0: status = 'BOM外发料' elif bom_q > 0 and act_q == 0: status = '未发料' sfc_node['children'].append({ 'id': f"extra_{sfc}_{m_code}_{i}", 'sfc': '', 'material_code': m_code, 'material_name': m_name, 'bom_qty': bom_q, 'actual_qty': act_q, 'diff_qty': diff_q, 'status': status, 'children': [] }) result.append(sfc_node) # 根据工单号倒序排列 result.sort(key=lambda x: x['sfc'], reverse=True) return result 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.")