From 564118895248606304cacd477ad8188b5c22217a Mon Sep 17 00:00:00 2001 From: hjq <770690987@qq.com> Date: Mon, 22 Jun 2026 16:48:54 +0800 Subject: [PATCH] =?UTF-8?q?BOM=E5=8F=91=E6=96=99=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- browser_login/analysis_service.py | 143 ++++-- browser_login/import_to_sqlite.py | 19 + web_ui/templates/reconciliation.html | 645 ++++++++++++++++----------- 3 files changed, 517 insertions(+), 290 deletions(-) diff --git a/browser_login/analysis_service.py b/browser_login/analysis_service.py index aaaa455..48ec15b 100644 --- a/browser_login/analysis_service.py +++ b/browser_login/analysis_service.py @@ -117,7 +117,7 @@ class MaterialReconciliationService: conn.row_factory = sqlite3.Row cursor = conn.cursor() - where_clause = "WHERE (inferred_production_order_no = '' OR inferred_production_order_no IS NULL)" + where_clause = "WHERE (production_order_no = '' OR production_order_no IS NULL) AND (inferred_production_order_no = '' OR inferred_production_order_no IS NULL)" params = [] if start_date and end_date: @@ -159,7 +159,7 @@ class MaterialReconciliationService: 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" + where_actual = "WHERE inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL AND (production_order_no = '' OR production_order_no IS NULL)" sfc_field = "inferred_production_order_no" params_actual = [] @@ -189,9 +189,12 @@ class MaterialReconciliationService: work_orders_number as sfc, product_code, product_name, + order_date, + workshop, material_code, material_name, - theoretical_issue_qty as bom_qty + total_demand_qty as bom_qty, + warehouse_issue_qty as act_qty FROM abnormal_report {where_bom} """ @@ -211,22 +214,39 @@ class MaterialReconciliationService: sfc_bom_dict[sfc] = {} sfc_bom_dict[sfc][mat_code] = { 'bom_qty': r['bom_qty'] or 0, + 'act_qty': r['act_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'] + 'product_name': r['product_name'], + 'order_date': r['order_date'], + 'workshop': r['workshop'] } - sfc_actual_dict = {} # {sfc: {material_code: {qty, name}}} + # 【新增】针对从发料明细中获取但没有 BOM 基础数据的 SFC,尝试从其发料明细中提取产品代码和产品名称 + for r in actuals: + sfc = r['sfc'] + if sfc not in sfc_to_product: + cursor.execute(f"SELECT product_material_code, product_material_name, execution_time, warehouse_name FROM issue_receipt_details WHERE {sfc_field} = ? LIMIT 1", (sfc,)) + prod_row = cursor.fetchone() + if prod_row and prod_row['product_material_code']: + sfc_to_product[sfc] = { + 'product_code': prod_row['product_material_code'], + 'product_name': prod_row['product_material_name'], + 'order_date': prod_row['execution_time'], + 'workshop': prod_row['warehouse_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, + 'actual_qty': float(r['total_actual_issue']) if r['total_actual_issue'] else 0.0, 'material_name': r['material_name'] } @@ -236,16 +256,15 @@ class MaterialReconciliationService: 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']] + product_codes = list(set([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)) + placeholders = ','.join(['?'] * len(product_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) + """, product_codes) all_bom_children = cursor.fetchall() from collections import defaultdict @@ -262,6 +281,7 @@ class MaterialReconciliationService: 'material_name': row['node_material_name'], 'bom_level': row['bom_level'], 'parent_node_id': row['parent_node_id'], + 'usage_qty': row['usage_qty'] or 1.0, 'bom_qty': 0, 'actual_qty': 0, 'diff_qty': 0, @@ -293,9 +313,11 @@ class MaterialReconciliationService: 'sfc': sfc, 'material_code': p_code, 'material_name': f"【成品】{p_name}", - 'bom_qty': '', - 'actual_qty': '', - 'diff_qty': '', + 'order_date': prod_info.get('order_date', ''), + 'workshop': prod_info.get('workshop', ''), + 'bom_qty': 0.0, + 'actual_qty': 0.0, + 'diff_qty': 0.0, 'status': '', 'children': [] } @@ -308,62 +330,109 @@ class MaterialReconciliationService: if p_code in bom_trees: tree_clone = copy.deepcopy(bom_trees[p_code]) - def fill_tree_qty(nodes, sfc_id): + # 为了将 actuals_for_sfc 里的实际发料数量精确地匹配到树节点上,我们需要处理多层级物料 + # 这里先把树结构展平,便于我们累加发料量 + flat_nodes = {} # material_code -> list of nodes + def build_flat_nodes(nodes): + for node in nodes: + m_code = node['material_code'] + if m_code not in flat_nodes: + flat_nodes[m_code] = [] + flat_nodes[m_code].append(node) + build_flat_nodes(node['children']) + build_flat_nodes(tree_clone) + + def fill_tree_qty(nodes, sfc_id, multiplier=1.0): + # 临时保存需要保留的节点 + valid_nodes = [] 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) + # 默认理论应发量 = 单台用量 * 理论产量 + current_bom_qty = round(float(node.get('usage_qty', 1.0)) * multiplier, 4) + node['bom_qty'] = current_bom_qty + + # 【核心修正】:如果 BOM 台账(abnormal_report)中给出了这个物料在该工单下的明确应发量,则直接覆盖! + if 'bom_qty' in bom_info and float(bom_info['bom_qty']) > 0: + node['bom_qty'] = float(bom_info['bom_qty']) + elif node['bom_qty'] > 0 and 'bom_qty' not in bom_info and m_code not in actuals_for_sfc: + # 这是一个纯理论计算出的子节点,但实际上并没有在这张工单的需求或者发料里出现 + node['bom_qty'] = 0 + + # 由于这棵树是把所有的可能的 BOM 都列出来了,但是我们只查有实际领料记录或者在理论需求里的物料 + node['actual_qty'] = 0.0 + + # 【核心修正】:实际发料量按照“仓库发放数量(act_qty)”来展示。如果BOM台账中有,则优先用BOM台账的。 + # 如果是完全的BOM外发料,则回退使用发料单明细(actuals_for_sfc)中的数量。 + if 'act_qty' in bom_info and float(bom_info['act_qty']) > 0: + if flat_nodes[m_code][0]['id'] == node['id']: + node['actual_qty'] = float(bom_info['act_qty']) + elif m_code in actuals_for_sfc and float(actuals_for_sfc[m_code].get('actual_qty', 0)) > 0: + if flat_nodes[m_code][0]['id'] == node['id']: + node['actual_qty'] = float(actuals_for_sfc[m_code]['actual_qty']) node['diff_qty'] = round(node['actual_qty'] - node['bom_qty'], 4) - if node['diff_qty'] > 0 and node['bom_qty'] > 0: + if node['bom_qty'] > 0 and node['actual_qty'] == 0: + node['status'] = '未发料' + elif node['diff_qty'] > 0 and node['bom_qty'] > 0: node['status'] = '超领发料' - elif node['diff_qty'] < 0: + elif node['diff_qty'] < 0 and node['bom_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) + # 递归子节点,将当前节点的 bom_qty 作为子节点的乘数基数 + node['children'] = fill_tree_qty(node['children'], sfc_id, node['bom_qty']) - fill_tree_qty(tree_clone, sfc) + # 如果当前节点既没有理论需求也没有实际发料,且也没有有意义的子节点,则修剪掉 + if node['bom_qty'] == 0 and node['actual_qty'] == 0 and len(node['children']) == 0: + continue + + valid_nodes.append(node) + return valid_nodes + + tree_clone = 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) + bom_q = float(bom_info.get('bom_qty', 0)) + act_q = float(act_info.get('actual_qty', 0)) m_name = bom_info.get('material_name') or act_info.get('material_name') or '未知名称' + if bom_q == 0 and act_q == 0: + continue + diff_q = round(act_q - bom_q, 4) status = '' - if diff_q > 0 and bom_q > 0: + if bom_q > 0 and act_q == 0: + status = '未发料' + elif diff_q > 0 and bom_q > 0: status = '超领发料' - elif diff_q < 0: + elif diff_q < 0 and bom_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 = '未发料' + else: + status = '无发料任务' sfc_node['children'].append({ 'id': f"extra_{sfc}_{m_code}_{i}", @@ -377,6 +446,22 @@ class MaterialReconciliationService: 'children': [] }) + # 汇总根节点的发料数据 + total_bom = 0.0 + total_actual = 0.0 + + def sum_qty(nodes): + nonlocal total_bom, total_actual + for n in nodes: + total_bom += n.get('bom_qty', 0) + total_actual += n.get('actual_qty', 0) + sum_qty(n.get('children', [])) + + sum_qty(sfc_node['children']) + sfc_node['bom_qty'] = round(total_bom, 4) + sfc_node['actual_qty'] = round(total_actual, 4) + sfc_node['diff_qty'] = round(total_actual - total_bom, 4) + result.append(sfc_node) # 根据工单号倒序排列 diff --git a/browser_login/import_to_sqlite.py b/browser_login/import_to_sqlite.py index 988c4b1..ea77d40 100644 --- a/browser_login/import_to_sqlite.py +++ b/browser_login/import_to_sqlite.py @@ -388,6 +388,25 @@ def import_issue_receipt_details(conn): if not wo_number or not line_no or not mat_code: continue + remark = item.get("明细备注") or "" + p_remark = item.get("生产订单备注") or "" + inferred_sfc = "" + + # 尝试从备注中提取 SFC 编号 + if '【' in remark and '】' in remark: + start = remark.find('【') + 1 + end = remark.find('】') + sfc_candidate = remark[start:end].strip() + if sfc_candidate.startswith('SFC'): + inferred_sfc = sfc_candidate + + if '【' in p_remark and '】' in p_remark: + start = p_remark.find('【') + 1 + end = p_remark.find('】') + sfc_candidate = p_remark[start:end].strip() + if sfc_candidate.startswith('SFC'): + inferred_sfc = sfc_candidate + try: cursor.execute(''' INSERT INTO issue_receipt_details ( diff --git a/web_ui/templates/reconciliation.html b/web_ui/templates/reconciliation.html index fc09686..16db9d1 100644 --- a/web_ui/templates/reconciliation.html +++ b/web_ui/templates/reconciliation.html @@ -2,7 +2,6 @@
-