Files
datie-bom/browser_login/analysis_service.py
2026-06-22 16:48:54 +08:00

483 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (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:
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 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 = []
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,
order_date,
workshop,
material_code,
material_name,
total_demand_qty as bom_qty,
warehouse_issue_qty as act_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,
'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'],
'order_date': r['order_date'],
'workshop': r['workshop']
}
# 【新增】针对从发料明细中获取但没有 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': float(r['total_actual_issue']) if r['total_actual_issue'] else 0.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 = 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:
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})
""", product_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'],
'usage_qty': row['usage_qty'] or 1.0,
'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}",
'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': []
}
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])
# 为了将 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, {})
# 默认理论应发量 = 单台用量 * 理论产量
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['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 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外发料'
else:
node['status'] = '无发料任务'
processed_materials.add(m_code)
# 递归子节点,将当前节点的 bom_qty 作为子节点的乘数基数
node['children'] = fill_tree_qty(node['children'], sfc_id, node['bom_qty'])
# 如果当前节点既没有理论需求也没有实际发料,且也没有有意义的子节点,则修剪掉
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 = 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 bom_q > 0 and act_q == 0:
status = '未发料'
elif diff_q > 0 and bom_q > 0:
status = '超领发料'
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外发料'
else:
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': []
})
# 汇总根节点的发料数据
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)
# 根据工单号倒序排列
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.")