BOM发料对比
This commit is contained in:
@@ -117,7 +117,7 @@ class MaterialReconciliationService:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cursor = conn.cursor()
|
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 = []
|
params = []
|
||||||
|
|
||||||
if start_date and end_date:
|
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"
|
where_actual = "WHERE production_order_no != '' AND production_order_no IS NOT NULL"
|
||||||
sfc_field = "production_order_no"
|
sfc_field = "production_order_no"
|
||||||
else:
|
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"
|
sfc_field = "inferred_production_order_no"
|
||||||
|
|
||||||
params_actual = []
|
params_actual = []
|
||||||
@@ -189,9 +189,12 @@ class MaterialReconciliationService:
|
|||||||
work_orders_number as sfc,
|
work_orders_number as sfc,
|
||||||
product_code,
|
product_code,
|
||||||
product_name,
|
product_name,
|
||||||
|
order_date,
|
||||||
|
workshop,
|
||||||
material_code,
|
material_code,
|
||||||
material_name,
|
material_name,
|
||||||
theoretical_issue_qty as bom_qty
|
total_demand_qty as bom_qty,
|
||||||
|
warehouse_issue_qty as act_qty
|
||||||
FROM abnormal_report
|
FROM abnormal_report
|
||||||
{where_bom}
|
{where_bom}
|
||||||
"""
|
"""
|
||||||
@@ -211,12 +214,29 @@ class MaterialReconciliationService:
|
|||||||
sfc_bom_dict[sfc] = {}
|
sfc_bom_dict[sfc] = {}
|
||||||
sfc_bom_dict[sfc][mat_code] = {
|
sfc_bom_dict[sfc][mat_code] = {
|
||||||
'bom_qty': r['bom_qty'] or 0,
|
'bom_qty': r['bom_qty'] or 0,
|
||||||
|
'act_qty': r['act_qty'] or 0,
|
||||||
'material_name': r['material_name']
|
'material_name': r['material_name']
|
||||||
}
|
}
|
||||||
if r['product_code']:
|
if r['product_code']:
|
||||||
sfc_to_product[sfc] = {
|
sfc_to_product[sfc] = {
|
||||||
'product_code': r['product_code'],
|
'product_code': r['product_code'],
|
||||||
'product_name': r['product_name']
|
'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}}}
|
sfc_actual_dict = {} # {sfc: {material_code: {qty, name}}}
|
||||||
@@ -226,7 +246,7 @@ class MaterialReconciliationService:
|
|||||||
if sfc not in sfc_actual_dict:
|
if sfc not in sfc_actual_dict:
|
||||||
sfc_actual_dict[sfc] = {}
|
sfc_actual_dict[sfc] = {}
|
||||||
sfc_actual_dict[sfc][mat_code] = {
|
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']
|
'material_name': r['material_name']
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +256,15 @@ class MaterialReconciliationService:
|
|||||||
all_sfcs = set(sfc_actual_dict.keys())
|
all_sfcs = set(sfc_actual_dict.keys())
|
||||||
|
|
||||||
# 预先获取所有的 product_code 的 bom_child 树结构
|
# 预先获取所有的 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}
|
bom_trees = {} # {product_code: list of root children}
|
||||||
if product_codes:
|
if product_codes:
|
||||||
unique_p_codes = list(set(product_codes))
|
placeholders = ','.join(['?'] * len(product_codes))
|
||||||
placeholders = ','.join(['?'] * len(unique_p_codes))
|
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
SELECT id, parent_material_code, node_material_code, node_material_name, bom_level, parent_node_id, usage_qty
|
SELECT id, parent_material_code, node_material_code, node_material_name, bom_level, parent_node_id, usage_qty
|
||||||
FROM bom_child
|
FROM bom_child
|
||||||
WHERE parent_material_code IN ({placeholders})
|
WHERE parent_material_code IN ({placeholders})
|
||||||
""", unique_p_codes)
|
""", product_codes)
|
||||||
all_bom_children = cursor.fetchall()
|
all_bom_children = cursor.fetchall()
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -262,6 +281,7 @@ class MaterialReconciliationService:
|
|||||||
'material_name': row['node_material_name'],
|
'material_name': row['node_material_name'],
|
||||||
'bom_level': row['bom_level'],
|
'bom_level': row['bom_level'],
|
||||||
'parent_node_id': row['parent_node_id'],
|
'parent_node_id': row['parent_node_id'],
|
||||||
|
'usage_qty': row['usage_qty'] or 1.0,
|
||||||
'bom_qty': 0,
|
'bom_qty': 0,
|
||||||
'actual_qty': 0,
|
'actual_qty': 0,
|
||||||
'diff_qty': 0,
|
'diff_qty': 0,
|
||||||
@@ -293,9 +313,11 @@ class MaterialReconciliationService:
|
|||||||
'sfc': sfc,
|
'sfc': sfc,
|
||||||
'material_code': p_code,
|
'material_code': p_code,
|
||||||
'material_name': f"【成品】{p_name}",
|
'material_name': f"【成品】{p_name}",
|
||||||
'bom_qty': '',
|
'order_date': prod_info.get('order_date', ''),
|
||||||
'actual_qty': '',
|
'workshop': prod_info.get('workshop', ''),
|
||||||
'diff_qty': '',
|
'bom_qty': 0.0,
|
||||||
|
'actual_qty': 0.0,
|
||||||
|
'diff_qty': 0.0,
|
||||||
'status': '',
|
'status': '',
|
||||||
'children': []
|
'children': []
|
||||||
}
|
}
|
||||||
@@ -308,62 +330,109 @@ class MaterialReconciliationService:
|
|||||||
if p_code in bom_trees:
|
if p_code in bom_trees:
|
||||||
tree_clone = copy.deepcopy(bom_trees[p_code])
|
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):
|
for idx, node in enumerate(nodes):
|
||||||
node['id'] = f"{sfc_id}_{node['id']}_{idx}" # 确保前端树节点的唯一性
|
node['id'] = f"{sfc_id}_{node['id']}_{idx}" # 确保前端树节点的唯一性
|
||||||
m_code = node['material_code']
|
m_code = node['material_code']
|
||||||
|
|
||||||
bom_info = boms_for_sfc.get(m_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)
|
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'] = '超领发料'
|
node['status'] = '超领发料'
|
||||||
elif node['diff_qty'] < 0:
|
elif node['diff_qty'] < 0 and node['bom_qty'] > 0:
|
||||||
node['status'] = '少领发料'
|
node['status'] = '少领发料'
|
||||||
elif node['diff_qty'] == 0 and node['bom_qty'] > 0:
|
elif node['diff_qty'] == 0 and node['bom_qty'] > 0:
|
||||||
node['status'] = '发料正常'
|
node['status'] = '发料正常'
|
||||||
elif node['bom_qty'] == 0 and node['actual_qty'] > 0:
|
elif node['bom_qty'] == 0 and node['actual_qty'] > 0:
|
||||||
node['status'] = 'BOM外发料'
|
node['status'] = 'BOM外发料'
|
||||||
elif node['bom_qty'] > 0 and node['actual_qty'] == 0:
|
|
||||||
node['status'] = '未发料'
|
|
||||||
else:
|
else:
|
||||||
node['status'] = '无发料任务'
|
node['status'] = '无发料任务'
|
||||||
|
|
||||||
processed_materials.add(m_code)
|
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)
|
sfc_node['children'].extend(tree_clone)
|
||||||
|
|
||||||
all_m_codes_for_sfc = set(boms_for_sfc.keys()).union(set(actuals_for_sfc.keys()))
|
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
|
extra_materials = all_m_codes_for_sfc - processed_materials
|
||||||
|
|
||||||
# 将多出的(或者没有树结构的)物料直接作为工单的子节点
|
# 将多出的(或者没有树结构的)物料直接作为工单的子节点
|
||||||
|
# 注意:只有在存在理论需求或实际发料时才加入
|
||||||
for i, m_code in enumerate(extra_materials):
|
for i, m_code in enumerate(extra_materials):
|
||||||
bom_info = boms_for_sfc.get(m_code, {})
|
bom_info = boms_for_sfc.get(m_code, {})
|
||||||
act_info = actuals_for_sfc.get(m_code, {})
|
act_info = actuals_for_sfc.get(m_code, {})
|
||||||
|
|
||||||
bom_q = bom_info.get('bom_qty', 0)
|
bom_q = float(bom_info.get('bom_qty', 0))
|
||||||
act_q = act_info.get('actual_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 '未知名称'
|
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)
|
diff_q = round(act_q - bom_q, 4)
|
||||||
|
|
||||||
status = ''
|
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 = '超领发料'
|
status = '超领发料'
|
||||||
elif diff_q < 0:
|
elif diff_q < 0 and bom_q > 0:
|
||||||
status = '少领发料'
|
status = '少领发料'
|
||||||
elif diff_q == 0 and bom_q > 0:
|
elif diff_q == 0 and bom_q > 0:
|
||||||
status = '发料正常'
|
status = '发料正常'
|
||||||
elif bom_q == 0 and act_q > 0:
|
elif bom_q == 0 and act_q > 0:
|
||||||
status = 'BOM外发料'
|
status = 'BOM外发料'
|
||||||
elif bom_q > 0 and act_q == 0:
|
else:
|
||||||
status = '未发料'
|
status = '无发料任务'
|
||||||
|
|
||||||
sfc_node['children'].append({
|
sfc_node['children'].append({
|
||||||
'id': f"extra_{sfc}_{m_code}_{i}",
|
'id': f"extra_{sfc}_{m_code}_{i}",
|
||||||
@@ -377,6 +446,22 @@ class MaterialReconciliationService:
|
|||||||
'children': []
|
'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.append(sfc_node)
|
||||||
|
|
||||||
# 根据工单号倒序排列
|
# 根据工单号倒序排列
|
||||||
|
|||||||
@@ -388,6 +388,25 @@ def import_issue_receipt_details(conn):
|
|||||||
if not wo_number or not line_no or not mat_code:
|
if not wo_number or not line_no or not mat_code:
|
||||||
continue
|
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:
|
try:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO issue_receipt_details (
|
INSERT INTO issue_receipt_details (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>绩效核查与 BOM 比对</title>
|
<title>绩效核查与 BOM 比对</title>
|
||||||
<!-- 引入 ElementUI 样式 -->
|
<!-- 引入 ElementUI 样式 -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
|
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
|
||||||
@@ -13,62 +12,104 @@
|
|||||||
<!-- 引入 axios -->
|
<!-- 引入 axios -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 20px; font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; background-color: #f0f2f5; }
|
body { margin: 0; padding: 0; background-color: #f0f2f5; font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; }
|
||||||
.box-card { margin-bottom: 20px; }
|
.header { background-color: #fff; padding: 15px 30px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
.header h2 { margin: 0; color: #303133; font-size: 20px; }
|
||||||
.page-header h2 { margin: 0; color: #303133; }
|
.main-container { padding: 0 30px; margin-bottom: 30px; }
|
||||||
.action-row { margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; background-color: #f8f9fa; padding: 15px; border-radius: 4px; border-left: 5px solid #409EFF;}
|
.card-panel { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
|
||||||
.pagination-container { margin-top: 20px; text-align: right; }
|
.filter-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: center; }
|
||||||
.filter-row { margin-bottom: 15px; display: flex; gap: 15px;}
|
.pagination-container { margin-top: 15px; display: flex; justify-content: flex-end; }
|
||||||
|
.el-table--small td, .el-table--small th { padding: 4px 0; }
|
||||||
|
.el-table__expand-icon { height: 16px !important; line-height: 16px !important; margin-right: 5px; }
|
||||||
|
.el-tag { font-weight: bold; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<el-card class="box-card">
|
<div class="header">
|
||||||
<div class="page-header">
|
<h2>🎯 绩效核查与 BOM 比对</h2>
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; gap: 10px;">
|
||||||
<h2 style="margin-right: 20px;"><i class="el-icon-data-analysis" style="margin-right: 10px; color: #409EFF;"></i>绩效核查与 BOM 比对</h2>
|
<el-button type="primary" :icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh'" @click="syncAbnormalReport" :disabled="isSystemBusy" size="small">
|
||||||
<el-tag type="success" effect="dark" style="font-size: 14px; padding: 0 15px; height: 32px; line-height: 30px;" v-if="dateRange.start !== '-'">
|
<span v-text="isSystemBusy ? '抓取中...' : '同步BOM发料台账'"></span>
|
||||||
<i class="el-icon-date"></i> <span v-text="'当前核对月份数据:' + dateRange.start + ' 至 ' + dateRange.end"></span>
|
</el-button>
|
||||||
</el-tag>
|
<el-button type="warning" :icon="isSystemBusy ? 'el-icon-loading' : 'el-icon-refresh'" @click="syncIssueReceipts" :disabled="isSystemBusy" size="small">
|
||||||
</div>
|
<span v-text="isSystemBusy ? '抓取中...' : '同步发料单明细'"></span>
|
||||||
<div>
|
</el-button>
|
||||||
<el-button type="info" plain icon="el-icon-back" @click="goBack" size="small">返回主控台</el-button>
|
<el-button type="info" plain icon="el-icon-back" @click="goBack" size="small">返回主控台</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作区域 -->
|
<div class="main-container">
|
||||||
<div class="action-row">
|
<div class="card-panel" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<div style="display: flex; align-items: center;">
|
<div>
|
||||||
<span style="font-size: 14px; color: #606266; margin-right: 15px;">
|
<span style="font-size: 14px; color: #606266; margin-right: 15px;"><i class="el-icon-date"></i> 对账月份:</span>
|
||||||
<i class="el-icon-info"></i> 执行自动对账前,请先点击"提取并匹配工单"进行数据清洗。
|
<el-date-picker v-model="dateRangeSelect" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" size="small" style="width: 260px; margin-right: 15px;" @change="handleDateChange"></el-date-picker>
|
||||||
</span>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="dateRangeSelect"
|
|
||||||
type="daterange"
|
|
||||||
range-separator="至"
|
|
||||||
start-placeholder="开始日期"
|
|
||||||
end-placeholder="结束日期"
|
|
||||||
value-format="yyyy-MM-dd"
|
|
||||||
size="small"
|
|
||||||
style="width: 260px; margin-right: 15px;"
|
|
||||||
@change="handleDateChange">
|
|
||||||
</el-date-picker>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" icon="el-icon-magic-stick" @click="triggerMatch" :loading="matching">提取并匹配工单</el-button>
|
<el-button type="primary" icon="el-icon-magic-stick" @click="triggerMatch" :loading="matching">提取并匹配工单</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签页 -->
|
<el-tabs v-model="activeTab" type="border-card">
|
||||||
<el-tabs v-model="activeTab" type="border-card" @tab-click="handleTabClick">
|
|
||||||
|
|
||||||
<!-- Tab 1: 无工单明细视图 -->
|
<!-- Tab 1: 工单发料明细 -->
|
||||||
|
<el-tab-pane label="工单发料明细" name="official">
|
||||||
|
<span slot="label"><i class="el-icon-document"></i> 工单发料明细</span>
|
||||||
|
<div class="filter-row">
|
||||||
|
<el-input v-model="officialSearch" placeholder="搜索工单号/物料名称/代码" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
||||||
|
<el-radio-group v-model="officialStatusFilter" size="small" style="margin-left: 15px;">
|
||||||
|
<el-radio-button label="">全部 (<span v-text="officialStatusCounts['全部']"></span>)</el-radio-button>
|
||||||
|
<el-radio-button label="发料正常">发料正常 (<span v-text="officialStatusCounts['发料正常']"></span>)</el-radio-button>
|
||||||
|
<el-radio-button label="超领发料">超领发料 (<span v-text="officialStatusCounts['超领发料']"></span>)</el-radio-button>
|
||||||
|
<el-radio-button label="少领发料">少领发料 (<span v-text="officialStatusCounts['少领发料']"></span>)</el-radio-button>
|
||||||
|
<el-radio-button label="未发料">未发料 (<span v-text="officialStatusCounts['未发料']"></span>)</el-radio-button>
|
||||||
|
<el-radio-button label="BOM外发料">BOM外发料 (<span v-text="officialStatusCounts['BOM外发料']"></span>)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button type="success" icon="el-icon-download" @click="exportOfficialData" size="small" style="margin-left: auto;">导出 Excel</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="pagedOfficialData" v-loading="loadingOfficial" row-key="id" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
|
||||||
|
<el-table-column prop="sfc" label="工单号 (SFC)" width="150" sortable></el-table-column>
|
||||||
|
<el-table-column prop="order_date" label="工单时间" width="160" sortable></el-table-column>
|
||||||
|
<el-table-column prop="workshop" label="生产车间" width="120" show-overflow-tooltip></el-table-column>
|
||||||
|
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
|
||||||
|
<el-table-column prop="material_name" label="物料名称" min-width="180" show-overflow-tooltip></el-table-column>
|
||||||
|
<el-table-column label="物料项数" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag v-if="scope.row.id && scope.row.id.startsWith('sfc_')" size="mini" type="info" effect="plain" v-text="getMaterialCount(scope.row) + ' 项'"></el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发料对比" align="center">
|
||||||
|
<el-table-column prop="bom_qty" label="BOM 应发量" width="110" align="right">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span v-if="scope.row.bom_qty !== ''" v-text="Number(scope.row.bom_qty).toFixed(4)"></span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="actual_qty" label="实际发料量" width="110" align="right">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span v-if="scope.row.actual_qty !== ''" style="font-weight: bold;" v-text="Number(scope.row.actual_qty).toFixed(4)"></span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="diff_qty" label="差异数量 (实-应)" width="130" align="right">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span v-if="scope.row.diff_qty !== ''" :style="{color: scope.row.diff_qty > 0 ? '#F56C6C' : (scope.row.diff_qty < 0 ? '#E6A23C' : '#67C23A'), fontWeight: 'bold'}" v-text="(scope.row.diff_qty > 0 ? '+' : '') + Number(scope.row.diff_qty).toFixed(4)"></span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="120" align="center" sortable>
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag v-if="scope.row.status && scope.row.status !== '-'" :type="getStatusType(scope.row.status)" effect="dark" size="small" v-text="scope.row.status"></el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-container">
|
||||||
|
<el-pagination @size-change="handleOfficialSizeChange" @current-change="handleOfficialCurrentChange" :current-page="officialPage" :page-sizes="[20, 50, 100, 500]" :page-size="officialPageSize" layout="total, sizes, prev, pager, next, jumper" :total="filteredOfficialData.length"></el-pagination>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 2: 无工单发料明细 -->
|
||||||
<el-tab-pane label="无工单发料明细" name="unmatched">
|
<el-tab-pane label="无工单发料明细" name="unmatched">
|
||||||
<span slot="label"><i class="el-icon-warning-outline"></i> 无工单发料明细 <el-badge v-if="filteredUnmatchedData.length > 0" :value="filteredUnmatchedData.length" class="mark" type="warning" /></span>
|
<span slot="label"><i class="el-icon-warning-outline"></i> 无工单发料明细 <el-badge v-if="filteredUnmatchedData.length > 0" :value="filteredUnmatchedData.length" class="mark" type="warning"></el-badge></span>
|
||||||
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<el-input v-model="unmatchedSearch" placeholder="搜索物料名称/代码/领料人" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
<el-input v-model="unmatchedSearch" placeholder="搜索物料名称/代码/领料人" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="pagedUnmatchedData" v-loading="loadingUnmatched" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
|
<el-table :data="pagedUnmatchedData" v-loading="loadingUnmatched" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
|
||||||
<el-table-column type="index" label="序号" width="60" align="center"></el-table-column>
|
<el-table-column type="index" label="序号" width="60" align="center"></el-table-column>
|
||||||
<el-table-column prop="execution_time" label="执行时间" width="160" sortable></el-table-column>
|
<el-table-column prop="execution_time" label="执行时间" width="160" sortable></el-table-column>
|
||||||
@@ -91,43 +132,37 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 前端分页 -->
|
|
||||||
<div class="pagination-container">
|
<div class="pagination-container">
|
||||||
<el-pagination
|
<el-pagination @size-change="handleUnmatchedSizeChange" @current-change="handleUnmatchedCurrentChange" :current-page="unmatchedPage" :page-sizes="[20, 50, 100, 500]" :page-size="unmatchedPageSize" layout="total, sizes, prev, pager, next, jumper" :total="filteredUnmatchedData.length"></el-pagination>
|
||||||
@size-change="val => { unmatchedPageSize = val; unmatchedPage = 1; }"
|
|
||||||
@current-change="val => { unmatchedPage = val; }"
|
|
||||||
:current-page="unmatchedPage"
|
|
||||||
:page-sizes="[20, 50, 100, 500]"
|
|
||||||
:page-size="unmatchedPageSize"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
:total="filteredUnmatchedData.length">
|
|
||||||
</el-pagination>
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Tab 2: BOM发料差异视图 -->
|
<!-- Tab 3: 从备注中提取的工单 -->
|
||||||
<el-tab-pane label="BOM 发料对账" name="reconciliation">
|
<el-tab-pane label="从备注中提取的工单" name="inferred">
|
||||||
<span slot="label"><i class="el-icon-finished"></i> BOM 发料对账</span>
|
<span slot="label"><i class="el-icon-magic-stick"></i> 从备注中提取的工单</span>
|
||||||
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<el-input v-model="reconSearch" placeholder="搜索工单号/物料名称/代码" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
<el-input v-model="inferredSearch" placeholder="搜索工单号/物料名称/代码" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
||||||
<el-select v-model="reconStatusFilter" placeholder="状态筛选" clearable style="width: 150px">
|
<el-radio-group v-model="inferredStatusFilter" size="small" style="margin-left: 15px;">
|
||||||
<el-option label="全部" value=""></el-option>
|
<el-radio-button label="">全部 (<span v-text="inferredStatusCounts['全部']"></span>)</el-radio-button>
|
||||||
<el-option label="发料正常" value="发料正常"></el-option>
|
<el-radio-button label="发料正常">发料正常 (<span v-text="inferredStatusCounts['发料正常']"></span>)</el-radio-button>
|
||||||
<el-option label="超领发料" value="超领发料"></el-option>
|
<el-radio-button label="超领发料">超领发料 (<span v-text="inferredStatusCounts['超领发料']"></span>)</el-radio-button>
|
||||||
<el-option label="少领发料" value="少领发料"></el-option>
|
<el-radio-button label="少领发料">少领发料 (<span v-text="inferredStatusCounts['少领发料']"></span>)</el-radio-button>
|
||||||
<el-option label="未发料" value="未发料"></el-option>
|
<el-radio-button label="未发料">未发料 (<span v-text="inferredStatusCounts['未发料']"></span>)</el-radio-button>
|
||||||
<el-option label="BOM外发料" value="BOM外发料"></el-option>
|
<el-radio-button label="BOM外发料">BOM外发料 (<span v-text="inferredStatusCounts['BOM外发料']"></span>)</el-radio-button>
|
||||||
</el-select>
|
</el-radio-group>
|
||||||
<el-button type="success" icon="el-icon-download" @click="exportReconData" size="small" style="margin-left: auto;">导出 Excel</el-button>
|
<el-button type="success" icon="el-icon-download" @click="exportInferredData" size="small" style="margin-left: auto;">导出 Excel</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<el-table :data="pagedInferredData" v-loading="loadingInferred" row-key="id" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
|
||||||
<el-table :data="pagedReconData" v-loading="loadingRecon" row-key="id" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
|
<el-table-column prop="sfc" label="提取的工单号" width="150" sortable></el-table-column>
|
||||||
<el-table-column prop="sfc" label="工单号 (SFC)" width="150" sortable></el-table-column>
|
<el-table-column prop="order_date" label="工单时间" width="160" sortable></el-table-column>
|
||||||
|
<el-table-column prop="workshop" label="生产车间" width="120" show-overflow-tooltip></el-table-column>
|
||||||
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
|
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
|
||||||
<el-table-column prop="material_name" label="物料名称" min-width="180" show-overflow-tooltip></el-table-column>
|
<el-table-column prop="material_name" label="物料名称" min-width="180" show-overflow-tooltip></el-table-column>
|
||||||
|
<el-table-column label="物料项数" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag v-if="scope.row.id && scope.row.id.startsWith('sfc_')" size="mini" type="info" effect="plain" v-text="getMaterialCount(scope.row) + ' 项'"></el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="发料对比" align="center">
|
<el-table-column label="发料对比" align="center">
|
||||||
<el-table-column prop="bom_qty" label="BOM 应发量" width="110" align="right">
|
<el-table-column prop="bom_qty" label="BOM 应发量" width="110" align="right">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
@@ -141,38 +176,23 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="diff_qty" label="差异数量 (实-应)" width="130" align="right">
|
<el-table-column prop="diff_qty" label="差异数量 (实-应)" width="130" align="right">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span v-if="scope.row.diff_qty !== ''" :style="{
|
<span v-if="scope.row.diff_qty !== ''" :style="{color: scope.row.diff_qty > 0 ? '#F56C6C' : (scope.row.diff_qty < 0 ? '#E6A23C' : '#67C23A'), fontWeight: 'bold'}" v-text="(scope.row.diff_qty > 0 ? '+' : '') + Number(scope.row.diff_qty).toFixed(4)"></span>
|
||||||
color: scope.row.diff_qty > 0 ? '#F56C6C' : (scope.row.diff_qty < 0 ? '#E6A23C' : '#67C23A'),
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}" v-text="(scope.row.diff_qty > 0 ? '+' : '') + Number(scope.row.diff_qty).toFixed(4)">
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="status" label="状态" width="120" align="center" sortable>
|
<el-table-column prop="status" label="状态" width="120" align="center" sortable>
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-tag v-if="scope.row.status && scope.row.status !== '-'" :type="getStatusType(scope.row.status)" effect="dark" size="small" v-text="scope.row.status"></el-tag>
|
<el-tag v-if="scope.row.status && scope.row.status !== '-'" :type="getStatusType(scope.row.status)" effect="dark" size="small" v-text="scope.row.status"></el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 前端分页 -->
|
|
||||||
<div class="pagination-container">
|
<div class="pagination-container">
|
||||||
<el-pagination
|
<el-pagination @size-change="handleInferredSizeChange" @current-change="handleInferredCurrentChange" :current-page="inferredPage" :page-sizes="[20, 50, 100, 500]" :page-size="inferredPageSize" layout="total, sizes, prev, pager, next, jumper" :total="filteredInferredData.length"></el-pagination>
|
||||||
@size-change="val => { reconPageSize = val; reconPage = 1; }"
|
|
||||||
@current-change="val => { reconPage = val; }"
|
|
||||||
:current-page="reconPage"
|
|
||||||
:page-sizes="[20, 50, 100, 500]"
|
|
||||||
:page-size="reconPageSize"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
:total="filteredReconData.length">
|
|
||||||
</el-pagination>
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -180,34 +200,78 @@
|
|||||||
el: '#app',
|
el: '#app',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isSystemBusy: false,
|
||||||
activeTab: 'official',
|
activeTab: 'official',
|
||||||
matching: false,
|
matching: false,
|
||||||
dateRange: { start: '-', end: '-' },
|
dateRange: { start: '-', end: '-' },
|
||||||
dateRangeSelect: [],
|
dateRangeSelect: [],
|
||||||
|
|
||||||
// Unmatched Tab Data
|
loadingOfficial: false,
|
||||||
|
officialData: [],
|
||||||
|
officialSearch: '',
|
||||||
|
officialStatusFilter: '',
|
||||||
|
officialPage: 1,
|
||||||
|
officialPageSize: 50,
|
||||||
|
|
||||||
loadingUnmatched: false,
|
loadingUnmatched: false,
|
||||||
unmatchedData: [],
|
unmatchedData: [],
|
||||||
unmatchedSearch: '',
|
unmatchedSearch: '',
|
||||||
unmatchedPage: 1,
|
unmatchedPage: 1,
|
||||||
unmatchedPageSize: 50,
|
unmatchedPageSize: 50,
|
||||||
|
|
||||||
// Reconciliation Tab Data
|
loadingInferred: false,
|
||||||
loadingRecon: false,
|
inferredData: [],
|
||||||
reconData: [],
|
inferredSearch: '',
|
||||||
reconSearch: '',
|
inferredStatusFilter: '',
|
||||||
reconStatusFilter: '',
|
inferredPage: 1,
|
||||||
reconPage: 1,
|
inferredPageSize: 50,
|
||||||
reconPageSize: 50
|
};
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// 前端过滤和分页计算属性
|
officialStatusCounts() {
|
||||||
|
const counts = { '全部': 0, '发料正常': 0, '超领发料': 0, '少领发料': 0, '未发料': 0, 'BOM外发料': 0 };
|
||||||
|
if (!this.officialData) return counts;
|
||||||
|
|
||||||
|
const checkStatus = (node, status) => {
|
||||||
|
if (node.status === status) return true;
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return node.children.some(child => checkStatus(child, status));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
counts['全部'] = this.officialData.length;
|
||||||
|
['发料正常', '超领发料', '少领发料', '未发料', 'BOM外发料'].forEach(status => {
|
||||||
|
counts[status] = this.officialData.filter(row => checkStatus(row, status)).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
},
|
||||||
|
inferredStatusCounts() {
|
||||||
|
const counts = { '全部': 0, '发料正常': 0, '超领发料': 0, '少领发料': 0, '未发料': 0, 'BOM外发料': 0 };
|
||||||
|
if (!this.inferredData) return counts;
|
||||||
|
|
||||||
|
const checkStatus = (node, status) => {
|
||||||
|
if (node.status === status) return true;
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return node.children.some(child => checkStatus(child, status));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
counts['全部'] = this.inferredData.length;
|
||||||
|
['发料正常', '超领发料', '少领发料', '未发料', 'BOM外发料'].forEach(status => {
|
||||||
|
counts[status] = this.inferredData.filter(row => checkStatus(row, status)).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
},
|
||||||
filteredUnmatchedData() {
|
filteredUnmatchedData() {
|
||||||
let data = this.unmatchedData;
|
let data = this.unmatchedData || [];
|
||||||
if (this.unmatchedSearch) {
|
if (this.unmatchedSearch) {
|
||||||
const keyword = this.unmatchedSearch.toLowerCase();
|
const keyword = this.unmatchedSearch.toLowerCase();
|
||||||
data = data.filter(item =>
|
data = data.filter(item =>
|
||||||
|
(item.issue_receipt_no && item.issue_receipt_no.toLowerCase().includes(keyword)) ||
|
||||||
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
|
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
|
||||||
(item.material_code && item.material_code.toLowerCase().includes(keyword)) ||
|
(item.material_code && item.material_code.toLowerCase().includes(keyword)) ||
|
||||||
(item.executor_user_name && item.executor_user_name.toLowerCase().includes(keyword))
|
(item.executor_user_name && item.executor_user_name.toLowerCase().includes(keyword))
|
||||||
@@ -220,15 +284,11 @@
|
|||||||
const end = start + this.unmatchedPageSize;
|
const end = start + this.unmatchedPageSize;
|
||||||
return this.filteredUnmatchedData.slice(start, end);
|
return this.filteredUnmatchedData.slice(start, end);
|
||||||
},
|
},
|
||||||
|
filteredOfficialData() {
|
||||||
filteredReconData() {
|
let result = this.officialData || [];
|
||||||
let data = this.reconData;
|
if (this.officialSearch || this.officialStatusFilter) {
|
||||||
|
const keyword = this.officialSearch ? this.officialSearch.toLowerCase() : '';
|
||||||
if (this.reconSearch || this.reconStatusFilter) {
|
const statusFilter = this.officialStatusFilter;
|
||||||
const keyword = this.reconSearch ? this.reconSearch.toLowerCase() : '';
|
|
||||||
const statusFilter = this.reconStatusFilter;
|
|
||||||
|
|
||||||
// 辅助函数:递归检查节点及其子节点是否匹配条件
|
|
||||||
const checkNode = (node) => {
|
const checkNode = (node) => {
|
||||||
let match = true;
|
let match = true;
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
@@ -238,129 +298,187 @@
|
|||||||
(node.material_name && node.material_name.toLowerCase().includes(keyword))
|
(node.material_name && node.material_name.toLowerCase().includes(keyword))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (statusFilter) {
|
if (statusFilter) { match = match && (node.status === statusFilter); }
|
||||||
match = match && (node.status === statusFilter);
|
|
||||||
}
|
|
||||||
if (match) return true;
|
if (match) return true;
|
||||||
|
|
||||||
// 如果当前节点不匹配,检查是否有子节点匹配
|
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.children && node.children.length > 0) {
|
||||||
return node.children.some(child => checkNode(child));
|
return node.children.some(child => checkNode(child));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
result = result.filter(row => checkNode(row));
|
||||||
data = data.filter(row => checkNode(row));
|
|
||||||
}
|
}
|
||||||
return data;
|
return result;
|
||||||
},
|
},
|
||||||
pagedReconData() {
|
pagedOfficialData() {
|
||||||
const start = (this.reconPage - 1) * this.reconPageSize;
|
const start = (this.officialPage - 1) * this.officialPageSize;
|
||||||
const end = start + this.reconPageSize;
|
const end = start + this.officialPageSize;
|
||||||
return this.filteredReconData.slice(start, end);
|
return this.filteredOfficialData.slice(start, end);
|
||||||
|
},
|
||||||
|
filteredInferredData() {
|
||||||
|
let result = this.inferredData || [];
|
||||||
|
if (this.inferredSearch || this.inferredStatusFilter) {
|
||||||
|
const keyword = this.inferredSearch ? this.inferredSearch.toLowerCase() : '';
|
||||||
|
const statusFilter = this.inferredStatusFilter;
|
||||||
|
const checkNode = (node) => {
|
||||||
|
let match = true;
|
||||||
|
if (keyword) {
|
||||||
|
match = match && (
|
||||||
|
(node.sfc && node.sfc.toLowerCase().includes(keyword)) ||
|
||||||
|
(node.material_code && node.material_code.toLowerCase().includes(keyword)) ||
|
||||||
|
(node.material_name && node.material_name.toLowerCase().includes(keyword))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (statusFilter) { match = match && (node.status === statusFilter); }
|
||||||
|
if (match) return true;
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return node.children.some(child => checkNode(child));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
result = result.filter(row => checkNode(row));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
pagedInferredData() {
|
||||||
|
const start = (this.inferredPage - 1) * this.inferredPageSize;
|
||||||
|
const end = start + this.inferredPageSize;
|
||||||
|
return this.filteredInferredData.slice(start, end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
dateRangeSelect(newVal) {
|
||||||
|
if (newVal && newVal.length === 2) {
|
||||||
|
this.loadUnmatchedData();
|
||||||
|
this.loadOfficialData();
|
||||||
|
this.loadInferredData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// 默认初始化为当月的第一天和最后一天
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
const m = now.getMonth() + 1;
|
|
||||||
const pad = (n) => n.toString().padStart(2, '0');
|
|
||||||
const lastDayDate = new Date(y, m, 0);
|
|
||||||
const firstDay = `${y}-${pad(m)}-01`;
|
|
||||||
const lastDay = `${y}-${pad(m)}-${pad(lastDayDate.getDate())}`;
|
|
||||||
this.dateRangeSelect = [firstDay, lastDay];
|
|
||||||
|
|
||||||
// 初始化加载数据
|
|
||||||
this.loadSummary();
|
this.loadSummary();
|
||||||
this.loadUnmatchedData();
|
this.loadUnmatchedData();
|
||||||
this.loadReconData();
|
this.loadOfficialData();
|
||||||
|
this.loadInferredData();
|
||||||
|
this.timer = setInterval(this.checkTaskStatus, 2000);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleDateChange(val) {
|
syncAbnormalReport() {
|
||||||
if (val && val.length === 2) {
|
this.$confirm('确定要抓取最新的 BOM 发料异常核对报表吗?该操作会在后台自动执行。', '提示', {
|
||||||
this.dateRange.start = val[0];
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
this.dateRange.end = val[1];
|
|
||||||
this.loadUnmatchedData();
|
|
||||||
this.loadReconData();
|
|
||||||
} else {
|
|
||||||
this.loadSummary();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loadSummary() {
|
|
||||||
axios.get('/api/analysis/summary')
|
|
||||||
.then(res => {
|
|
||||||
this.dateRange = res.data;
|
|
||||||
if (this.dateRange.start !== '-') {
|
|
||||||
this.dateRangeSelect = [this.dateRange.start, this.dateRange.end];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error(err));
|
|
||||||
},
|
|
||||||
goBack() {
|
|
||||||
window.location.href = '/';
|
|
||||||
},
|
|
||||||
handleTabClick(tab) {
|
|
||||||
// if (tab.name === 'unmatched') this.loadUnmatchedData();
|
|
||||||
// if (tab.name === 'reconciliation') this.loadReconData();
|
|
||||||
},
|
|
||||||
getStatusType(status) {
|
|
||||||
const map = {
|
|
||||||
'发料正常': 'success',
|
|
||||||
'超领发料': 'danger',
|
|
||||||
'少领发料': 'warning',
|
|
||||||
'未发料': 'info',
|
|
||||||
'BOM外发料': 'danger'
|
|
||||||
};
|
|
||||||
return map[status] || 'info';
|
|
||||||
},
|
|
||||||
triggerMatch() {
|
|
||||||
this.$confirm('此操作将基于最新抓取的发料单据和 BOM 表数据进行自动清洗匹配,确认执行?', '提示', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.matching = true;
|
this.isSystemBusy = true;
|
||||||
if (window.globalLogApp) {
|
axios.post('/api/sync_abnormal_report').then(res => {
|
||||||
window.globalLogApp.logDialogVisible = true;
|
|
||||||
}
|
|
||||||
axios.post('/api/analysis/match_work_orders')
|
|
||||||
.then(res => {
|
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
this.$message.success(res.data.message);
|
this.$message.success('已触发!' + res.data.message);
|
||||||
// 开启轮询等待后台任务完成,完成后再刷新数据
|
setTimeout(this.checkTaskStatus, 1000);
|
||||||
this.waitForMatchTask();
|
|
||||||
} else {
|
} else {
|
||||||
this.$message.error('执行失败: ' + res.data.message);
|
this.$message.error('触发失败:' + res.data.message);
|
||||||
this.matching = false;
|
|
||||||
}
|
}
|
||||||
})
|
}).catch(err => {
|
||||||
.catch(err => {
|
this.$message.error('请求失败');
|
||||||
if (err.response && err.response.status === 409) {
|
this.isSystemBusy = false;
|
||||||
this.$message.warning(err.response.data.message);
|
|
||||||
} else {
|
|
||||||
this.$message.error('请求出错,请检查后台日志');
|
|
||||||
}
|
|
||||||
this.matching = false;
|
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
waitForMatchTask() {
|
syncIssueReceipts() {
|
||||||
// 轮询检查任务状态,一旦结束就刷新列表
|
this.$confirm('确定要抓取最新的 发料单明细 吗?该操作会在后台自动执行。', '提示', {
|
||||||
let checkInterval = setInterval(() => {
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
axios.get('/api/task_status')
|
}).then(() => {
|
||||||
.then(res => {
|
this.isSystemBusy = true;
|
||||||
if (!res.data.is_busy) {
|
axios.post('/api/sync_issue_receipts').then(res => {
|
||||||
clearInterval(checkInterval);
|
if (res.data.success) {
|
||||||
this.matching = false;
|
this.$message.success('已触发!' + res.data.message);
|
||||||
this.$message.success('清洗匹配完成!');
|
setTimeout(this.checkTaskStatus, 1000);
|
||||||
|
} else {
|
||||||
|
this.$message.error('触发失败:' + res.data.message);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('请求失败');
|
||||||
|
this.isSystemBusy = false;
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
checkTaskStatus() {
|
||||||
|
axios.get('/api/task_status').then(res => {
|
||||||
|
if (res.data.is_busy) {
|
||||||
|
this.isSystemBusy = true;
|
||||||
|
setTimeout(this.checkTaskStatus, 3000);
|
||||||
|
} else {
|
||||||
|
if (this.isSystemBusy) {
|
||||||
|
this.$message.success('数据抓取任务已完成!正在重新加载数据...');
|
||||||
this.loadSummary();
|
this.loadSummary();
|
||||||
this.loadUnmatchedData();
|
this.loadUnmatchedData();
|
||||||
this.loadReconData();
|
this.loadOfficialData();
|
||||||
|
this.loadInferredData();
|
||||||
}
|
}
|
||||||
})
|
this.isSystemBusy = false;
|
||||||
.catch(err => {});
|
}
|
||||||
}, 1000);
|
});
|
||||||
|
},
|
||||||
|
getMaterialCount(row) {
|
||||||
|
if (!row.id || !row.id.startsWith('sfc_')) return '';
|
||||||
|
let count = 0;
|
||||||
|
const traverse = (nodes) => {
|
||||||
|
if (!nodes) return;
|
||||||
|
nodes.forEach(n => {
|
||||||
|
count++;
|
||||||
|
traverse(n.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(row.children);
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
goBack() { window.location.href = '/'; },
|
||||||
|
handleDateChange(val) {
|
||||||
|
if (!val) this.dateRangeSelect = [this.dateRange.start, this.dateRange.end];
|
||||||
|
},
|
||||||
|
handleOfficialSizeChange(val) { this.officialPageSize = val; this.officialPage = 1; },
|
||||||
|
handleOfficialCurrentChange(val) { this.officialPage = val; },
|
||||||
|
handleUnmatchedSizeChange(val) { this.unmatchedPageSize = val; this.unmatchedPage = 1; },
|
||||||
|
handleUnmatchedCurrentChange(val) { this.unmatchedPage = val; },
|
||||||
|
handleInferredSizeChange(val) { this.inferredPageSize = val; this.inferredPage = 1; },
|
||||||
|
handleInferredCurrentChange(val) { this.inferredPage = val; },
|
||||||
|
getStatusType(status) {
|
||||||
|
if (status === '发料正常') return 'success';
|
||||||
|
if (status === '超领发料') return 'danger';
|
||||||
|
if (status === '少领发料') return 'warning';
|
||||||
|
if (status === '未发料') return 'info';
|
||||||
|
if (status === 'BOM外发料') return 'primary';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
loadSummary() {
|
||||||
|
axios.get('/api/analysis/summary').then(res => {
|
||||||
|
this.dateRange = res.data;
|
||||||
|
if (!this.dateRangeSelect || this.dateRangeSelect.length === 0) {
|
||||||
|
this.dateRangeSelect = [this.dateRange.start, this.dateRange.end];
|
||||||
|
}
|
||||||
|
}).catch(err => console.error(err));
|
||||||
|
},
|
||||||
|
checkTaskStatus() {
|
||||||
|
axios.get('/api/task_status').then(res => {
|
||||||
|
if (res.data.is_busy && res.data.task_name.includes('自动清洗')) {
|
||||||
|
this.matching = true;
|
||||||
|
} else {
|
||||||
|
if (this.matching) {
|
||||||
|
this.matching = false;
|
||||||
|
this.loadUnmatchedData();
|
||||||
|
this.loadOfficialData();
|
||||||
|
this.loadInferredData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(err => console.error('获取任务状态失败', err));
|
||||||
|
},
|
||||||
|
triggerMatch() {
|
||||||
|
this.matching = true;
|
||||||
|
axios.post('/api/analysis/match_work_orders')
|
||||||
|
.then(res => this.$message.success(res.data.message))
|
||||||
|
.catch(err => {
|
||||||
|
this.matching = false;
|
||||||
|
this.$message.error(err.response?.data?.message || '触发失败');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
loadUnmatchedData() {
|
loadUnmatchedData() {
|
||||||
this.loadingUnmatched = true;
|
this.loadingUnmatched = true;
|
||||||
@@ -368,69 +486,74 @@
|
|||||||
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
||||||
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
||||||
}
|
}
|
||||||
axios.get(url)
|
axios.get(url).then(res => {
|
||||||
.then(res => {
|
this.unmatchedData = res.data.rows || [];
|
||||||
this.unmatchedData = res.data.rows;
|
|
||||||
this.unmatchedPage = 1;
|
this.unmatchedPage = 1;
|
||||||
})
|
}).catch(err => console.error(err)).finally(() => { this.loadingUnmatched = false; });
|
||||||
.catch(err => {
|
|
||||||
this.$message.error('加载无工单明细失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.loadingUnmatched = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
loadReconData() {
|
loadOfficialData() {
|
||||||
this.loadingRecon = true;
|
this.loadingOfficial = true;
|
||||||
let url = '/api/analysis/bom_reconciliation';
|
let url = '/api/analysis/bom_reconciliation?match_type=official';
|
||||||
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
||||||
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
url += `&start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
||||||
}
|
}
|
||||||
axios.get(url)
|
axios.get(url).then(res => {
|
||||||
.then(res => {
|
this.officialData = res.data.rows || [];
|
||||||
this.reconData = res.data.rows;
|
this.officialPage = 1;
|
||||||
this.reconPage = 1;
|
}).catch(err => console.error(err)).finally(() => { this.loadingOfficial = false; });
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
this.$message.error('加载 BOM 比对数据失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.loadingRecon = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
exportReconData() {
|
loadInferredData() {
|
||||||
// 简单的前端 CSV 导出(支持树形结构展开)
|
this.loadingInferred = true;
|
||||||
|
let url = '/api/analysis/bom_reconciliation?match_type=inferred';
|
||||||
|
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
||||||
|
url += `&start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
||||||
|
}
|
||||||
|
axios.get(url).then(res => {
|
||||||
|
this.inferredData = res.data.rows || [];
|
||||||
|
this.inferredPage = 1;
|
||||||
|
}).catch(err => console.error(err)).finally(() => { this.loadingInferred = false; });
|
||||||
|
},
|
||||||
|
exportOfficialData() {
|
||||||
const headers = ['层级', '工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
|
const headers = ['层级', '工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
|
||||||
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
|
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
|
||||||
|
|
||||||
const processRow = (node, level = 0) => {
|
const processRow = (node, level = 0) => {
|
||||||
const indent = " ".repeat(level);
|
const indent = " ".repeat(level);
|
||||||
const rowData = [
|
const rowData = [
|
||||||
level,
|
level, node.sfc || '', node.material_code || '', `"${indent}${node.material_name || ''}"`,
|
||||||
node.sfc || '',
|
node.bom_qty !== '' ? node.bom_qty : '', node.actual_qty !== '' ? node.actual_qty : '',
|
||||||
node.material_code || '',
|
node.diff_qty !== '' ? node.diff_qty : '', node.status || ''
|
||||||
`"${indent}${node.material_name || ''}"`,
|
|
||||||
node.bom_qty !== '' ? node.bom_qty : '',
|
|
||||||
node.actual_qty !== '' ? node.actual_qty : '',
|
|
||||||
node.diff_qty !== '' ? node.diff_qty : '',
|
|
||||||
node.status || ''
|
|
||||||
];
|
];
|
||||||
csvContent += rowData.join(",") + "\n";
|
csvContent += rowData.join(",") + "\n";
|
||||||
|
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.children && node.children.length > 0) {
|
||||||
node.children.forEach(child => processRow(child, level + 1));
|
node.children.forEach(child => processRow(child, level + 1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this.filteredOfficialData.forEach(row => processRow(row, 0));
|
||||||
this.filteredReconData.forEach(row => processRow(row, 0));
|
|
||||||
|
|
||||||
const encodedUri = encodeURI(csvContent);
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.setAttribute("href", encodedUri);
|
link.setAttribute("href", encodeURI(csvContent));
|
||||||
link.setAttribute("download", "BOM发料对账单.csv");
|
link.setAttribute("download", "工单发料对账单.csv");
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
||||||
link.click();
|
},
|
||||||
document.body.removeChild(link);
|
exportInferredData() {
|
||||||
|
const headers = ['层级', '工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
|
||||||
|
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
|
||||||
|
const processRow = (node, level = 0) => {
|
||||||
|
const indent = " ".repeat(level);
|
||||||
|
const rowData = [
|
||||||
|
level, node.sfc || '', node.material_code || '', `"${indent}${node.material_name || ''}"`,
|
||||||
|
node.bom_qty !== '' ? node.bom_qty : '', node.actual_qty !== '' ? node.actual_qty : '',
|
||||||
|
node.diff_qty !== '' ? node.diff_qty : '', node.status || ''
|
||||||
|
];
|
||||||
|
csvContent += rowData.join(",") + "\n";
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
node.children.forEach(child => processRow(child, level + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.filteredInferredData.forEach(row => processRow(row, 0));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", encodeURI(csvContent));
|
||||||
|
link.setAttribute("download", "备注提取工单对账单.csv");
|
||||||
|
document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user