抓取生产工单,抓取发料异常
This commit is contained in:
249
browser_login/analysis_service.py
Normal file
249
browser_login/analysis_service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
import sqlite3
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# 把当前目录加入 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from config import OUTPUT_DIR
|
||||
|
||||
DB_PATH = OUTPUT_DIR / 'erp_data.db'
|
||||
|
||||
class MaterialReconciliationService:
|
||||
def __init__(self):
|
||||
self.db_path = DB_PATH
|
||||
|
||||
def get_conn(self):
|
||||
return sqlite3.connect(self.db_path)
|
||||
|
||||
def get_data_period(self):
|
||||
"""获取当前数据库中发料记录的时间周期,用于前端展示对账月份"""
|
||||
import datetime
|
||||
import calendar
|
||||
|
||||
now = datetime.datetime.now()
|
||||
first_day = datetime.date(now.year, now.month, 1).strftime('%Y-%m-%d')
|
||||
last_day = datetime.date(now.year, now.month, calendar.monthrange(now.year, now.month)[1]).strftime('%Y-%m-%d')
|
||||
|
||||
return {"start": first_day, "end": last_day}
|
||||
|
||||
def step1_extract_and_match_work_orders(self):
|
||||
"""
|
||||
第一步:匹配生产工单。
|
||||
对于有生产工单的,直接赋值。
|
||||
对于没有生产工单的,从备注中通过正则提取潜在的工单号(如 SFC... 或 连续数字)。
|
||||
并将其更新到 inferred_production_order_no 字段中。
|
||||
"""
|
||||
print("[开始] 正在连接数据库并准备清洗工单数据...")
|
||||
conn = self.get_conn()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 确保表有 inferred_production_order_no 字段
|
||||
try:
|
||||
cursor.execute("ALTER TABLE issue_receipt_details ADD COLUMN inferred_production_order_no TEXT;")
|
||||
print("[信息] 已在数据库中新建 inferred_production_order_no 字段。")
|
||||
except sqlite3.OperationalError:
|
||||
pass # 已经存在
|
||||
|
||||
print("[执行] 正在读取所有发料单明细...")
|
||||
# 获取所有发料单
|
||||
cursor.execute("SELECT id, production_order_no, work_orders_remark, detailed_remark, production_order_remark FROM issue_receipt_details")
|
||||
rows = cursor.fetchall()
|
||||
print(f"[执行] 共读取到 {len(rows)} 条发料记录,开始进行正则比对...")
|
||||
|
||||
# 预先加载 abnormal_report 中的有效工单号作为验证库
|
||||
cursor.execute("SELECT DISTINCT work_orders_number FROM abnormal_report")
|
||||
valid_sfcs = {r[0].upper() for r in cursor.fetchall() if r[0]}
|
||||
|
||||
updates = []
|
||||
matched_count = 0
|
||||
|
||||
for row in rows:
|
||||
row_id = row[0]
|
||||
prod_no = row[1]
|
||||
work_remark = row[2] or ''
|
||||
detail_remark = row[3] or ''
|
||||
prod_remark = row[4] or ''
|
||||
|
||||
inferred_sfc = ''
|
||||
|
||||
# 如果系统本身已经有工单号
|
||||
if prod_no and prod_no.strip():
|
||||
inferred_sfc = prod_no.strip()
|
||||
else:
|
||||
# 合并所有备注信息
|
||||
combined_remarks = f"{work_remark} {detail_remark} {prod_remark}"
|
||||
if combined_remarks.strip():
|
||||
# 优先匹配完整的 SFC 格式 (例如 SFC018845)
|
||||
sfc_match = re.search(r'(SFC\d+)', combined_remarks, re.IGNORECASE)
|
||||
if sfc_match:
|
||||
inferred_sfc = sfc_match.group(1).upper()
|
||||
else:
|
||||
# 尝试匹配纯数字格式 (长度大于等于4位的连续数字)
|
||||
num_match = re.search(r'(\d{4,})', combined_remarks)
|
||||
if num_match:
|
||||
candidate_num = num_match.group(1)
|
||||
# 尝试拼凑 SFC 前缀并校验是否存在于有效库中
|
||||
candidate_sfc = f"SFC0{candidate_num}"
|
||||
if candidate_sfc in valid_sfcs:
|
||||
inferred_sfc = candidate_sfc
|
||||
else:
|
||||
candidate_sfc2 = f"SFC{candidate_num}"
|
||||
if candidate_sfc2 in valid_sfcs:
|
||||
inferred_sfc = candidate_sfc2
|
||||
else:
|
||||
inferred_sfc = candidate_sfc # 哪怕没在库里也提取出来,作为参考
|
||||
|
||||
if inferred_sfc:
|
||||
matched_count += 1
|
||||
updates.append((inferred_sfc, row_id))
|
||||
|
||||
print(f"[信息] 匹配完成。有 {matched_count} 条数据成功匹配到工单号。正在将结果写回数据库...")
|
||||
# 批量更新数据库
|
||||
cursor.executemany(
|
||||
"UPDATE issue_receipt_details SET inferred_production_order_no = ? WHERE id = ?",
|
||||
updates
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[完成] 数据库更新完毕!本次清洗共处理了 {len(updates)} 条数据。")
|
||||
return len(updates)
|
||||
|
||||
def step2_get_unmatched_materials(self, start_date=None, end_date=None):
|
||||
"""
|
||||
第二步:整理出没有生产工单的物料明细表
|
||||
"""
|
||||
conn = self.get_conn()
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
where_clause = "WHERE (inferred_production_order_no = '' OR inferred_production_order_no IS NULL)"
|
||||
params = []
|
||||
|
||||
if start_date and end_date:
|
||||
where_clause += " AND execution_time >= ? AND execution_time <= ?"
|
||||
params.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"])
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
work_orders_number as issue_receipt_no,
|
||||
material_code,
|
||||
material_name,
|
||||
material_specification,
|
||||
issue_number,
|
||||
work_orders_remark,
|
||||
detailed_remark,
|
||||
production_order_remark,
|
||||
execution_time,
|
||||
executor_user_name,
|
||||
warehouse_name
|
||||
FROM issue_receipt_details
|
||||
{where_clause}
|
||||
ORDER BY execution_time DESC
|
||||
"""
|
||||
cursor.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def step3_bom_reconciliation(self, start_date=None, end_date=None):
|
||||
"""
|
||||
第三步:BOM 校准物料,比对发料与BOM差异
|
||||
返回按工单分组的差异列表。
|
||||
"""
|
||||
conn = self.get_conn()
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
where_actual = "WHERE inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL"
|
||||
params_actual = []
|
||||
|
||||
if start_date and end_date:
|
||||
where_actual += " AND execution_time >= ? AND execution_time <= ?"
|
||||
params_actual.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"])
|
||||
|
||||
sql_actual = f"""
|
||||
SELECT
|
||||
inferred_production_order_no as sfc,
|
||||
material_code,
|
||||
material_name,
|
||||
SUM(issue_number) as total_actual_issue
|
||||
FROM issue_receipt_details
|
||||
{where_actual}
|
||||
GROUP BY inferred_production_order_no, material_code
|
||||
"""
|
||||
|
||||
where_bom = ""
|
||||
params_bom = []
|
||||
if start_date and end_date:
|
||||
where_bom = "WHERE order_date >= ? AND order_date <= ?"
|
||||
params_bom.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"])
|
||||
|
||||
sql_bom = f"""
|
||||
SELECT
|
||||
work_orders_number as sfc,
|
||||
material_code,
|
||||
material_name,
|
||||
theoretical_issue_qty as bom_qty
|
||||
FROM abnormal_report
|
||||
{where_bom}
|
||||
"""
|
||||
|
||||
cursor.execute(sql_actual, params_actual)
|
||||
actuals = cursor.fetchall()
|
||||
|
||||
cursor.execute(sql_bom, params_bom)
|
||||
boms = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
reconciliation = {}
|
||||
|
||||
for r in boms:
|
||||
key = (r['sfc'], r['material_code'])
|
||||
reconciliation[key] = {
|
||||
'sfc': r['sfc'],
|
||||
'material_code': r['material_code'],
|
||||
'material_name': r['material_name'],
|
||||
'bom_qty': r['bom_qty'] or 0,
|
||||
'actual_qty': 0,
|
||||
'diff_qty': 0 - (r['bom_qty'] or 0),
|
||||
'status': '未发料'
|
||||
}
|
||||
|
||||
for r in actuals:
|
||||
key = (r['sfc'], r['material_code'])
|
||||
if key not in reconciliation:
|
||||
reconciliation[key] = {
|
||||
'sfc': r['sfc'],
|
||||
'material_code': r['material_code'],
|
||||
'material_name': r['material_name'],
|
||||
'bom_qty': 0,
|
||||
'actual_qty': 0,
|
||||
'diff_qty': 0,
|
||||
'status': 'BOM外发料'
|
||||
}
|
||||
reconciliation[key]['actual_qty'] += r['total_actual_issue']
|
||||
reconciliation[key]['diff_qty'] = round(reconciliation[key]['actual_qty'] - reconciliation[key]['bom_qty'], 4)
|
||||
|
||||
if reconciliation[key]['diff_qty'] > 0 and reconciliation[key]['bom_qty'] > 0:
|
||||
reconciliation[key]['status'] = '超领发料'
|
||||
elif reconciliation[key]['diff_qty'] < 0:
|
||||
reconciliation[key]['status'] = '少领发料'
|
||||
elif reconciliation[key]['diff_qty'] == 0 and reconciliation[key]['bom_qty'] > 0:
|
||||
reconciliation[key]['status'] = '发料正常'
|
||||
|
||||
return list(reconciliation.values())
|
||||
|
||||
if __name__ == '__main__':
|
||||
service = MaterialReconciliationService()
|
||||
print("Executing Step 1: Matching work orders...")
|
||||
updated = service.step1_extract_and_match_work_orders()
|
||||
print(f"Updated {updated} issue receipts with inferred production orders.")
|
||||
|
||||
unmatched = service.step2_get_unmatched_materials()
|
||||
print(f"Found {len(unmatched)} unmatched materials.")
|
||||
|
||||
recon = service.step3_bom_reconciliation()
|
||||
abnormal = [r for r in recon if r['status'] in ['超领发料', 'BOM外发料']]
|
||||
print(f"Found {len(abnormal)} abnormal BOM issues.")
|
||||
@@ -83,20 +83,39 @@ def fetch_report_data(page):
|
||||
var doc = iframes[j].contentDocument || iframes[j].contentWindow.document;
|
||||
var win = iframes[j].contentWindow;
|
||||
|
||||
// 1. 设置开始日期
|
||||
var startInputs = doc.querySelectorAll('.input_StartValue.datebox-f');
|
||||
if (startInputs.length > 0) {{
|
||||
win.$(startInputs[0]).datebox('setValue', '{first_day}');
|
||||
// 1. 自动设置下单日期为当月
|
||||
// (暴力匹配所有的 datebox,前两个一般就是开始和结束)
|
||||
var dates = doc.querySelectorAll('.datebox-f, .datetimebox-f');
|
||||
if (dates.length >= 2) {{
|
||||
try {{ win.$(dates[0]).datetimebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}}
|
||||
try {{ win.$(dates[0]).datebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}}
|
||||
|
||||
try {{ win.$(dates[1]).datetimebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}}
|
||||
try {{ win.$(dates[1]).datebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}}
|
||||
}} else {{
|
||||
// 备用方案:寻找附近元素
|
||||
var allSpans = doc.querySelectorAll('span, td, th');
|
||||
for(var k=0; k<allSpans.length; k++) {{
|
||||
var txt = allSpans[k].innerText || '';
|
||||
if (txt.indexOf('下单日期(开始)') !== -1) {{
|
||||
var input = allSpans[k].parentNode.parentNode.querySelector('.textbox-f');
|
||||
if(input) {{
|
||||
try {{ win.$(input).datetimebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}}
|
||||
try {{ win.$(input).datebox('setValue', '{first_day} 00:00:00'); }} catch(e) {{}}
|
||||
}}
|
||||
}}
|
||||
if (txt.indexOf('下单日期(结束)') !== -1) {{
|
||||
var input = allSpans[k].parentNode.parentNode.querySelector('.textbox-f');
|
||||
if(input) {{
|
||||
try {{ win.$(input).datetimebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}}
|
||||
try {{ win.$(input).datebox('setValue', '{last_day} 23:59:59'); }} catch(e) {{}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
// 2. 设置结束日期
|
||||
var endInputs = doc.querySelectorAll('.input_EndValue.datebox-f');
|
||||
if (endInputs.length > 0) {{
|
||||
win.$(endInputs[0]).datebox('setValue', '{last_day}');
|
||||
}}
|
||||
|
||||
// 3. 清理所有下拉框(包括发料情况)
|
||||
var combos = doc.querySelectorAll('.combobox-f, .textbox-f');
|
||||
// 3. 清理发料情况下拉框 (千万不能选 textbox-f,否则会把刚才填的日期清空!)
|
||||
var combos = doc.querySelectorAll('.combobox-f');
|
||||
for(var i=0; i<combos.length; i++) {{
|
||||
try {{ win.$(combos[i]).combobox('clear'); }} catch(e) {{}}
|
||||
}}
|
||||
@@ -152,8 +171,8 @@ def fetch_report_data(page):
|
||||
|
||||
print("开始监听网络请求,寻找 API 数据包...")
|
||||
while True:
|
||||
packets = target_tab.listen.steps()
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 收集到 {len(packets)} 个网络数据包,正在解析...")
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 正在收集并解析网络数据包...")
|
||||
packets = target_tab.listen.steps(timeout=5)
|
||||
|
||||
found_data = False
|
||||
total_pages = 1
|
||||
@@ -172,13 +191,15 @@ def fetch_report_data(page):
|
||||
total_count = res.get('totalCount', 0)
|
||||
items = res.get('items', [])
|
||||
|
||||
# 动态计算真实的总页数 (防止前端强改500条失败)
|
||||
actual_page_size = len(items) if len(items) > 0 else 50
|
||||
total_pages = (total_count + actual_page_size - 1) // actual_page_size if total_count > 0 else 1
|
||||
|
||||
print("===================================")
|
||||
print(f"✅ 成功拦截到报表数据API (第 {current_page} 页)")
|
||||
print(f"✅ 成功拦截到报表数据API (第 {current_page}/{total_pages} 页)")
|
||||
print(f"✅ 数据总条数: {total_count}, 当前页条数: {len(items)}")
|
||||
print("===================================")
|
||||
|
||||
total_pages = (total_count + 499) // 500 if total_count > 0 else 1
|
||||
|
||||
# Import and save to database
|
||||
try:
|
||||
import import_to_sqlite
|
||||
@@ -197,20 +218,13 @@ def fetch_report_data(page):
|
||||
pass
|
||||
|
||||
if not found_data:
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 第 {current_page} 页等待了3秒,没有拦截到匹配的报表数据...")
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 第 {current_page} 页等待了超时,没有拦截到匹配的报表数据...")
|
||||
|
||||
# 再给一次机会等3秒
|
||||
print("再等待3秒重试...")
|
||||
time.sleep(3)
|
||||
retry_packets = target_tab.listen.steps()
|
||||
print(f"重试收集到 {len(retry_packets)} 个数据包。")
|
||||
if not retry_packets:
|
||||
print(f"彻底没有数据,停止抓取。")
|
||||
break
|
||||
else:
|
||||
packets.extend(retry_packets)
|
||||
# 重新让上面解析
|
||||
continue
|
||||
# 重新让上面解析
|
||||
continue
|
||||
|
||||
if current_page >= total_pages:
|
||||
print(f"已到达最后一页 (共 {total_pages} 页),抓取完成!")
|
||||
|
||||
@@ -100,10 +100,13 @@ def fetch_bom_cost_tree():
|
||||
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
||||
|
||||
if isinstance(data, dict) and "result" in data:
|
||||
items = data["result"].get("items", [])
|
||||
total_records = data["result"].get("totalCount", 0)
|
||||
result_data = data.get("result") or {}
|
||||
items = result_data.get("items", [])
|
||||
total_records = result_data.get("totalCount", 0)
|
||||
|
||||
for item in items:
|
||||
if not item:
|
||||
continue
|
||||
# 注意:我们要拿的是 parentMaterialId,因为这是传给 BOM 成本 API 的关键参数 materialId
|
||||
clean_parent = {
|
||||
"_id": item.get("id"), # 这个是 partBomCostAccountingId
|
||||
@@ -237,7 +240,12 @@ def fetch_bom_cost_tree():
|
||||
log("ERR", f"❌ 自动触发入库脚本失败: {db_err}")
|
||||
|
||||
except Exception as e:
|
||||
log("ERR", f"发生异常: {e}")
|
||||
import traceback
|
||||
err_msg = f"发生异常: {e}\n{traceback.format_exc()}"
|
||||
with open("error.log", "w") as f:
|
||||
f.write(err_msg)
|
||||
print(err_msg, flush=True)
|
||||
log("ERR", err_msg)
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_bom_cost_tree()
|
||||
@@ -155,6 +155,11 @@ def abnormal_report_page():
|
||||
"""渲染发料异常检查数据看板"""
|
||||
return render_template('abnormal_report.html')
|
||||
|
||||
@app.route('/reconciliation')
|
||||
def reconciliation_page():
|
||||
"""渲染绩效核查与BOM比对看板"""
|
||||
return render_template('reconciliation.html')
|
||||
|
||||
@app.route('/api/receipts')
|
||||
def get_receipts():
|
||||
"""获取收货明细数据(支持分页和多条件搜索)"""
|
||||
@@ -299,6 +304,81 @@ def get_abnormal_report():
|
||||
"rows": [dict(ix) for ix in orders]
|
||||
})
|
||||
|
||||
@app.route('/api/analysis/match_work_orders', methods=['POST'])
|
||||
def match_work_orders():
|
||||
"""触发:第一步提取并匹配工单"""
|
||||
global is_browser_busy
|
||||
if is_browser_busy:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"系统忙碌:当前正在执行 '{current_task_name}',请稍后再试。"
|
||||
}), 409
|
||||
|
||||
def run_match_task():
|
||||
set_browser_busy("自动清洗并匹配工单数据")
|
||||
try:
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
from analysis_service import MaterialReconciliationService
|
||||
service = MaterialReconciliationService()
|
||||
updated = service.step1_extract_and_match_work_orders()
|
||||
print(f"✅ 工单匹配任务执行成功!共处理了 {updated} 条发料记录。")
|
||||
except Exception as e:
|
||||
print(f"❌ 自动清洗匹配失败: {e}")
|
||||
finally:
|
||||
release_browser()
|
||||
|
||||
try:
|
||||
threading.Thread(target=run_match_task, daemon=True).start()
|
||||
return jsonify({"success": True, "message": "已触发后台自动清洗匹配工单任务!"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@app.route('/api/analysis/unmatched_materials')
|
||||
def get_unmatched_materials():
|
||||
"""获取:第二步没有匹配到工单的物料明细"""
|
||||
try:
|
||||
start_date = request.args.get('start')
|
||||
end_date = request.args.get('end')
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
from analysis_service import MaterialReconciliationService
|
||||
service = MaterialReconciliationService()
|
||||
data = service.step2_get_unmatched_materials(start_date, end_date)
|
||||
return jsonify({"total": len(data), "rows": data})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@app.route('/api/analysis/bom_reconciliation')
|
||||
def get_bom_reconciliation():
|
||||
"""获取:第三步比对发料与BOM差异"""
|
||||
try:
|
||||
start_date = request.args.get('start')
|
||||
end_date = request.args.get('end')
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
from analysis_service import MaterialReconciliationService
|
||||
service = MaterialReconciliationService()
|
||||
data = service.step3_bom_reconciliation(start_date, end_date)
|
||||
|
||||
# 支持前端筛选
|
||||
status_filter = request.args.get('status', '')
|
||||
if status_filter:
|
||||
data = [r for r in data if r['status'] == status_filter]
|
||||
|
||||
return jsonify({"total": len(data), "rows": data})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@app.route('/api/analysis/summary')
|
||||
def get_analysis_summary():
|
||||
"""获取当前对账数据的周期等汇总信息"""
|
||||
try:
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
from analysis_service import MaterialReconciliationService
|
||||
service = MaterialReconciliationService()
|
||||
period = service.get_data_period()
|
||||
return jsonify(period)
|
||||
except Exception as e:
|
||||
return jsonify({"start": "-", "end": "-"}), 500
|
||||
|
||||
@app.route('/api/task_status')
|
||||
def get_task_status():
|
||||
"""获取当前浏览器控制任务的状态"""
|
||||
@@ -451,7 +531,11 @@ def sync_bom():
|
||||
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
|
||||
fetch_bom_cost_tree()
|
||||
except Exception as e:
|
||||
print(f"BOM 抓取失败: {e}")
|
||||
import traceback
|
||||
err_msg = f"BOM 抓取失败: {e}\n{traceback.format_exc()}"
|
||||
print(err_msg)
|
||||
with open("app_error.log", "w") as f:
|
||||
f.write(err_msg)
|
||||
finally:
|
||||
release_browser()
|
||||
|
||||
|
||||
@@ -283,5 +283,6 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -621,5 +621,6 @@
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -362,5 +362,6 @@
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
80
web_ui/templates/global_log.html
Normal file
80
web_ui/templates/global_log.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<div id="global-log-app">
|
||||
<!-- 悬浮日志按钮 -->
|
||||
<el-button
|
||||
type="info"
|
||||
icon="el-icon-tickets"
|
||||
@click="logDialogVisible = true"
|
||||
class="floating-log-btn"
|
||||
round>
|
||||
后台运行日志
|
||||
</el-button>
|
||||
|
||||
<!-- 弹窗式日志窗口 -->
|
||||
<el-dialog title="后台运行日志" :visible.sync="logDialogVisible" width="60%" center append-to-body>
|
||||
<div class="log-window" id="globalLogContainer" style="margin-top: 0; height: 400px; background-color: #1e1e1e; color: #a9b7c6; border-radius: 8px; padding: 15px; overflow-y: auto; text-align: left; font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; line-height: 1.5; box-shadow: inset 0 0 10px rgba(0,0,0,0.5); border: 1px solid #333;">
|
||||
<div v-if="logs.length === 0" style="color: #666; text-align: center; margin-top: 150px;">
|
||||
暂无后台任务输出...
|
||||
</div>
|
||||
<div v-for="(log, index) in logs" :key="index" style="margin-bottom: 2px;">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="logDialogVisible = false">关 闭</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.floating-log-btn {
|
||||
position: fixed;
|
||||
right: 40px;
|
||||
bottom: 40px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.floating-log-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 确保 Vue 和 axios 已经加载
|
||||
if (typeof Vue !== 'undefined' && typeof axios !== 'undefined') {
|
||||
window.globalLogApp = new Vue({
|
||||
el: '#global-log-app',
|
||||
data() {
|
||||
return {
|
||||
logs: [],
|
||||
logTimer: null,
|
||||
logDialogVisible: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchLogs();
|
||||
this.logTimer = setInterval(this.fetchLogs, 1500);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.logTimer) {
|
||||
clearInterval(this.logTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchLogs() {
|
||||
// 如果弹窗没打开,也可以不刷新日志以节省性能,或者一直拉取保持最新
|
||||
if (!this.logDialogVisible) return;
|
||||
|
||||
axios.get('/api/task_logs')
|
||||
.then(res => {
|
||||
if (res.data.logs && res.data.logs.length > 0) {
|
||||
this.logs = res.data.logs;
|
||||
}
|
||||
})
|
||||
.catch(err => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -95,21 +95,30 @@
|
||||
}
|
||||
|
||||
/* 特定卡片的颜色定制 */
|
||||
.card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; }
|
||||
.card-receipt { border-top: 4px solid #67C23A; }
|
||||
.card-receipt i { color: #67C23A; }
|
||||
.card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; }
|
||||
|
||||
.card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; }
|
||||
.card-bom-tree { border-top: 4px solid #409EFF; }
|
||||
.card-bom-tree i { color: #409EFF; }
|
||||
.card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; }
|
||||
|
||||
.card-bom-compare:hover { border-color: #E6A23C; background-color: #fdf6ec; }
|
||||
.card-bom-compare i { color: #E6A23C; }
|
||||
.card-bom-compare { border-top: 4px solid #9c27b0; }
|
||||
.card-bom-compare i { color: #9c27b0; }
|
||||
.card-bom-compare:hover { border-color: #9c27b0; background-color: #f3e5f5; }
|
||||
|
||||
.card-work-order { border-top: 4px solid #E6A23C; }
|
||||
.card-work-order i { color: #E6A23C; }
|
||||
.card-work-order:hover { border-color: #E6A23C; background-color: #fdf6ec; }
|
||||
|
||||
.card-abnormal { border-top: 4px solid #F56C6C; }
|
||||
.card-abnormal i { color: #F56C6C; }
|
||||
.card-abnormal:hover { border-color: #F56C6C; background-color: #fef0f0; }
|
||||
|
||||
.card-reconciliation { border-top: 4px solid #909399; }
|
||||
.card-reconciliation i { color: #909399; }
|
||||
.card-reconciliation:hover { border-color: #909399; background-color: #f4f4f5; }
|
||||
|
||||
.action-group {
|
||||
margin-top: 40px;
|
||||
padding-top: 30px;
|
||||
@@ -117,6 +126,37 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.log-window {
|
||||
margin-top: 30px;
|
||||
background-color: #1e1e1e;
|
||||
color: #a9b7c6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
height: 250px;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.log-window::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.log-window::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.log-window::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-window::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -162,6 +202,13 @@
|
||||
<h3>发料异常检查</h3>
|
||||
<p>排查生产工单的发料异常,对比理论出料与实际发放数量的差异。</p>
|
||||
</div>
|
||||
|
||||
<!-- 卡片 6: 绩效核查与BOM比对 -->
|
||||
<div class="nav-card card-reconciliation" onclick="window.location.href='/reconciliation'">
|
||||
<i class="el-icon-data-analysis"></i>
|
||||
<h3>绩效核查与BOM比对</h3>
|
||||
<p>数据清洗、匹配工单号,智能比对 BOM 理论发料量与实际发料量差异。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
@@ -198,6 +245,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'global_log.html' %}
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
@@ -235,7 +284,9 @@
|
||||
},
|
||||
syncReceipts() {
|
||||
this.syncing = true;
|
||||
|
||||
if (window.globalLogApp) {
|
||||
window.globalLogApp.logDialogVisible = true;
|
||||
}
|
||||
axios.post('/api/sync_receipts')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
@@ -258,31 +309,42 @@
|
||||
});
|
||||
},
|
||||
syncBom() {
|
||||
this.syncingBom = true;
|
||||
|
||||
axios.post('/api/sync_bom')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
this.$message.success('已触发!' + res.data.message);
|
||||
setTimeout(this.checkTaskStatus, 500);
|
||||
} else {
|
||||
this.$message.error('触发失败:' + res.data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.response && err.response.status === 409) {
|
||||
this.$message.warning(err.response.data.message);
|
||||
} else {
|
||||
this.$message.error('请求发生异常,请检查后端日志。');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.syncingBom = false;
|
||||
});
|
||||
this.$confirm('此操作将启动后台浏览器,耗时较长(约10-20分钟),期间请勿关闭服务器终端。确认执行?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.syncingBom = true;
|
||||
// 如果存在全局日志组件,自动打开日志面板
|
||||
if (window.globalLogApp) {
|
||||
window.globalLogApp.logDialogVisible = true;
|
||||
}
|
||||
axios.post('/api/sync_bom')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
this.$message.success('已触发!' + res.data.message);
|
||||
setTimeout(this.checkTaskStatus, 500);
|
||||
} else {
|
||||
this.$message.error('触发失败:' + res.data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.response && err.response.status === 409) {
|
||||
this.$message.warning(err.response.data.message);
|
||||
} else {
|
||||
this.$message.error('请求发生异常,请检查后端日志。');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.syncingBom = false;
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
syncWorkOrders() {
|
||||
this.syncingWorkOrders = true;
|
||||
|
||||
if (window.globalLogApp) {
|
||||
window.globalLogApp.logDialogVisible = true;
|
||||
}
|
||||
axios.post('/api/sync_work_orders')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
|
||||
@@ -182,5 +182,6 @@
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
405
web_ui/templates/reconciliation.html
Normal file
405
web_ui/templates/reconciliation.html
Normal file
@@ -0,0 +1,405 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>绩效核查与 BOM 比对</title>
|
||||
<!-- 引入 ElementUI 样式 -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
|
||||
<!-- 引入 Vue.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
|
||||
<!-- 引入 ElementUI 组件库 -->
|
||||
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
|
||||
<!-- 引入 axios -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; background-color: #f0f2f5; }
|
||||
.box-card { margin-bottom: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.page-header h2 { margin: 0; color: #303133; }
|
||||
.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;}
|
||||
.pagination-container { margin-top: 20px; text-align: right; }
|
||||
.filter-row { margin-bottom: 15px; display: flex; gap: 15px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<h2 style="margin-right: 20px;"><i class="el-icon-data-analysis" style="margin-right: 10px; color: #409EFF;"></i>绩效核查与 BOM 比对</h2>
|
||||
<el-tag type="success" effect="dark" style="font-size: 14px; padding: 0 15px; height: 32px; line-height: 30px;" v-if="dateRange.start !== '-'">
|
||||
<i class="el-icon-date"></i> <span v-text="'当前核对月份数据:' + dateRange.start + ' 至 ' + dateRange.end"></span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="info" plain icon="el-icon-back" @click="goBack" size="small">返回主控台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作区域 -->
|
||||
<div class="action-row">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="font-size: 14px; color: #606266; margin-right: 15px;">
|
||||
<i class="el-icon-info"></i> 执行自动对账前,请先点击"提取并匹配工单"进行数据清洗。
|
||||
</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>
|
||||
<el-button type="primary" icon="el-icon-magic-stick" @click="triggerMatch" :loading="matching">提取并匹配工单</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<el-tabs v-model="activeTab" type="border-card" @tab-click="handleTabClick">
|
||||
|
||||
<!-- Tab 1: 无工单明细视图 -->
|
||||
<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>
|
||||
|
||||
<div class="filter-row">
|
||||
<el-input v-model="unmatchedSearch" placeholder="搜索物料名称/代码/领料人" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
||||
</div>
|
||||
|
||||
<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 prop="execution_time" label="执行时间" width="160" sortable></el-table-column>
|
||||
<el-table-column prop="issue_receipt_no" label="发料单号" width="150"></el-table-column>
|
||||
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="150" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="material_specification" label="规格" min-width="150" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="issue_number" label="发料数量" width="90" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span style="color: #F56C6C; font-weight: bold;" v-text="scope.row.issue_number"></span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="executor_user_name" label="发料人" width="100" align="center"></el-table-column>
|
||||
<el-table-column prop="warehouse_name" label="仓库" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="单据备注信息" min-width="200" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.work_orders_remark" v-text="'【工单备注】' + scope.row.work_orders_remark + ' '"></span>
|
||||
<span v-if="scope.row.detailed_remark" v-text="'【明细备注】' + scope.row.detailed_remark + ' '"></span>
|
||||
<span v-if="scope.row.production_order_remark" v-text="'【生产单备注】' + scope.row.production_order_remark"></span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 前端分页 -->
|
||||
<div class="pagination-container">
|
||||
<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>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: BOM发料差异视图 -->
|
||||
<el-tab-pane label="BOM 发料对账" name="reconciliation">
|
||||
<span slot="label"><i class="el-icon-finished"></i> BOM 发料对账</span>
|
||||
|
||||
<div class="filter-row">
|
||||
<el-input v-model="reconSearch" placeholder="搜索工单号/物料名称/代码" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
|
||||
<el-select v-model="reconStatusFilter" placeholder="状态筛选" clearable style="width: 150px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="发料正常" value="发料正常"></el-option>
|
||||
<el-option label="超领发料" value="超领发料"></el-option>
|
||||
<el-option label="少领发料" value="少领发料"></el-option>
|
||||
<el-option label="未发料" value="未发料"></el-option>
|
||||
<el-option label="BOM外发料" value="BOM外发料"></el-option>
|
||||
</el-select>
|
||||
<el-button type="success" icon="el-icon-download" @click="exportReconData" size="small" style="margin-left: auto;">导出 Excel</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="pagedReconData" v-loading="loadingRecon" 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 prop="sfc" label="工单号 (SFC)" width="150" sortable></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="发料对比" align="center">
|
||||
<el-table-column prop="bom_qty" label="BOM 应发量" width="110" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span 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 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 :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 :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="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>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'unmatched',
|
||||
matching: false,
|
||||
dateRange: { start: '-', end: '-' },
|
||||
dateRangeSelect: [],
|
||||
|
||||
// Unmatched Tab Data
|
||||
loadingUnmatched: false,
|
||||
unmatchedData: [],
|
||||
unmatchedSearch: '',
|
||||
unmatchedPage: 1,
|
||||
unmatchedPageSize: 50,
|
||||
|
||||
// Reconciliation Tab Data
|
||||
loadingRecon: false,
|
||||
reconData: [],
|
||||
reconSearch: '',
|
||||
reconStatusFilter: '',
|
||||
reconPage: 1,
|
||||
reconPageSize: 50
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 前端过滤和分页计算属性
|
||||
filteredUnmatchedData() {
|
||||
let data = this.unmatchedData;
|
||||
if (this.unmatchedSearch) {
|
||||
const keyword = this.unmatchedSearch.toLowerCase();
|
||||
data = data.filter(item =>
|
||||
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
|
||||
(item.material_code && item.material_code.toLowerCase().includes(keyword)) ||
|
||||
(item.executor_user_name && item.executor_user_name.toLowerCase().includes(keyword))
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
pagedUnmatchedData() {
|
||||
const start = (this.unmatchedPage - 1) * this.unmatchedPageSize;
|
||||
const end = start + this.unmatchedPageSize;
|
||||
return this.filteredUnmatchedData.slice(start, end);
|
||||
},
|
||||
|
||||
filteredReconData() {
|
||||
let data = this.reconData;
|
||||
if (this.reconStatusFilter) {
|
||||
data = data.filter(item => item.status === this.reconStatusFilter);
|
||||
}
|
||||
if (this.reconSearch) {
|
||||
const keyword = this.reconSearch.toLowerCase();
|
||||
data = data.filter(item =>
|
||||
(item.sfc && item.sfc.toLowerCase().includes(keyword)) ||
|
||||
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
|
||||
(item.material_code && item.material_code.toLowerCase().includes(keyword))
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
pagedReconData() {
|
||||
const start = (this.reconPage - 1) * this.reconPageSize;
|
||||
const end = start + this.reconPageSize;
|
||||
return this.filteredReconData.slice(start, end);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 初始化加载数据
|
||||
this.loadSummary();
|
||||
this.loadUnmatchedData();
|
||||
this.loadReconData();
|
||||
},
|
||||
methods: {
|
||||
handleDateChange(val) {
|
||||
if (val && val.length === 2) {
|
||||
this.dateRange.start = val[0];
|
||||
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(() => {
|
||||
this.matching = true;
|
||||
if (window.globalLogApp) {
|
||||
window.globalLogApp.logDialogVisible = true;
|
||||
}
|
||||
axios.post('/api/analysis/match_work_orders')
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
this.$message.success(res.data.message);
|
||||
// 开启轮询等待后台任务完成,完成后再刷新数据
|
||||
this.waitForMatchTask();
|
||||
} else {
|
||||
this.$message.error('执行失败: ' + res.data.message);
|
||||
this.matching = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.response && err.response.status === 409) {
|
||||
this.$message.warning(err.response.data.message);
|
||||
} else {
|
||||
this.$message.error('请求出错,请检查后台日志');
|
||||
}
|
||||
this.matching = false;
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
waitForMatchTask() {
|
||||
// 轮询检查任务状态,一旦结束就刷新列表
|
||||
let checkInterval = setInterval(() => {
|
||||
axios.get('/api/task_status')
|
||||
.then(res => {
|
||||
if (!res.data.is_busy) {
|
||||
clearInterval(checkInterval);
|
||||
this.matching = false;
|
||||
this.$message.success('清洗匹配完成!');
|
||||
this.loadSummary();
|
||||
this.loadUnmatchedData();
|
||||
this.loadReconData();
|
||||
}
|
||||
})
|
||||
.catch(err => {});
|
||||
}, 1000);
|
||||
},
|
||||
loadUnmatchedData() {
|
||||
this.loadingUnmatched = true;
|
||||
let url = '/api/analysis/unmatched_materials';
|
||||
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
||||
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
||||
}
|
||||
axios.get(url)
|
||||
.then(res => {
|
||||
this.unmatchedData = res.data.rows;
|
||||
this.unmatchedPage = 1;
|
||||
})
|
||||
.catch(err => {
|
||||
this.$message.error('加载无工单明细失败');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingUnmatched = false;
|
||||
});
|
||||
},
|
||||
loadReconData() {
|
||||
this.loadingRecon = true;
|
||||
let url = '/api/analysis/bom_reconciliation';
|
||||
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
|
||||
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
|
||||
}
|
||||
axios.get(url)
|
||||
.then(res => {
|
||||
this.reconData = res.data.rows;
|
||||
this.reconPage = 1;
|
||||
})
|
||||
.catch(err => {
|
||||
this.$message.error('加载 BOM 比对数据失败');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingRecon = false;
|
||||
});
|
||||
},
|
||||
exportReconData() {
|
||||
// 简单的前端 CSV 导出
|
||||
const headers = ['工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
|
||||
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
|
||||
|
||||
this.filteredReconData.forEach(row => {
|
||||
const rowData = [
|
||||
row.sfc,
|
||||
row.material_code,
|
||||
`"${row.material_name || ''}"`,
|
||||
row.bom_qty,
|
||||
row.actual_qty,
|
||||
row.diff_qty,
|
||||
row.status
|
||||
];
|
||||
csvContent += rowData.join(",") + "\n";
|
||||
});
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "BOM发料对账单.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -167,5 +167,6 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include "global_log.html" %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user