675 lines
28 KiB
Python
675 lines
28 KiB
Python
from flask import Flask, render_template, request, jsonify, Response
|
|
import sqlite3, pandas as pd, os, io, csv, hashlib, json, re
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
from werkzeug.utils import secure_filename
|
|
|
|
app = Flask(__name__)
|
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
|
DB_PATH = 'queue_stats.db'
|
|
IRAN_TZ = ZoneInfo('Asia/Tehran')
|
|
SHORT_ABANDON = 5
|
|
|
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
|
|
# ─────────────────────── DB ───────────────────────
|
|
|
|
def init_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS queue_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp INTEGER, callid TEXT, queuename TEXT,
|
|
agent TEXT, event TEXT, data1 TEXT, data2 TEXT,
|
|
data3 TEXT, file_source TEXT, imported_at TEXT
|
|
)
|
|
''')
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS stats_cache (
|
|
cache_key TEXT PRIMARY KEY,
|
|
result TEXT,
|
|
created_at TEXT
|
|
)
|
|
''')
|
|
for idx, col in [('ts','timestamp'),('queue','queuename'),
|
|
('agent','agent'),('event','event'),('callid','callid')]:
|
|
conn.execute(f'CREATE INDEX IF NOT EXISTS idx_{idx} ON queue_events({col})')
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# ─────────────────────── Cache ───────────────────────
|
|
|
|
def get_cache(key):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
row = conn.execute("SELECT result FROM stats_cache WHERE cache_key=?", (key,)).fetchone()
|
|
conn.close()
|
|
return json.loads(row[0]) if row else None
|
|
|
|
def set_cache(key, data):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO stats_cache (cache_key, result, created_at) VALUES (?,?,?)",
|
|
(key, json.dumps(data, ensure_ascii=False), datetime.now().isoformat())
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def clear_cache():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute("DELETE FROM stats_cache")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def make_cache_key(*args):
|
|
return hashlib.md5('|'.join(str(a) for a in args).encode()).hexdigest()
|
|
|
|
# ─────────────────────── Phone ───────────────────────
|
|
|
|
def normalize_phone(number):
|
|
if not number or not isinstance(number, str):
|
|
return None
|
|
n = re.sub(r'\D', '', number.strip())
|
|
if not n or len(n) < 7:
|
|
return None
|
|
if n in ('0', '00'):
|
|
return None
|
|
if n.startswith('0098'):
|
|
n = n[4:]
|
|
elif n.startswith('98') and len(n) == 12:
|
|
n = n[2:]
|
|
elif n.startswith('0') and len(n) == 11:
|
|
n = n[1:]
|
|
return n if len(n) >= 7 else None
|
|
|
|
# ─────────────────────── Parse / Import ───────────────────────
|
|
|
|
def parse_line(line):
|
|
parts = line.strip().split('|')
|
|
if len(parts) < 5:
|
|
return None
|
|
try:
|
|
return {
|
|
'timestamp': int(parts[0]),
|
|
'callid' : parts[1],
|
|
'queuename': parts[2],
|
|
'agent' : parts[3],
|
|
'event' : parts[4],
|
|
'data1' : parts[5] if len(parts) > 5 else None,
|
|
'data2' : parts[6] if len(parts) > 6 else None,
|
|
'data3' : parts[7] if len(parts) > 7 else None,
|
|
}
|
|
except:
|
|
return None
|
|
|
|
def process_file(filepath, filename):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
existing = conn.execute(
|
|
"SELECT COUNT(*) FROM queue_events WHERE file_source=?", (filename,)
|
|
).fetchone()[0]
|
|
if existing > 0:
|
|
conn.close()
|
|
return -1
|
|
rows = []
|
|
with open(filepath, 'r', errors='ignore') as f:
|
|
for line in f:
|
|
r = parse_line(line)
|
|
if r:
|
|
rows.append((
|
|
r['timestamp'], r['callid'], r['queuename'],
|
|
r['agent'], r['event'], r['data1'],
|
|
r['data2'], r['data3'], filename,
|
|
datetime.now().isoformat()
|
|
))
|
|
conn.executemany('''
|
|
INSERT INTO queue_events
|
|
(timestamp,callid,queuename,agent,event,data1,data2,data3,file_source,imported_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
''', rows)
|
|
conn.commit()
|
|
conn.close()
|
|
return len(rows)
|
|
|
|
# ─────────────────────── Build Calls ───────────────────────
|
|
|
|
def ts_to_dt(t):
|
|
try:
|
|
return datetime.fromtimestamp(int(t), tz=IRAN_TZ)
|
|
except:
|
|
return None
|
|
|
|
def build_calls(df):
|
|
enter = df[df['event'] == 'ENTERQUEUE'].groupby('callid').first()
|
|
|
|
# ✅ آخرین CONNECT (در ringall چندتا داریم، آخری که جواب داده مهمه)
|
|
connect = df[df['event'] == 'CONNECT'].groupby('callid').last()
|
|
|
|
# ✅ COMPLETE = منبع اصلی — answered فقط با COMPLETE تعیین میشه
|
|
comp = df[df['event'].isin(['COMPLETEAGENT', 'COMPLETECALLER'])]\
|
|
.groupby('callid').first()
|
|
|
|
abandon = df[df['event'] == 'ABANDON'].groupby('callid').first()
|
|
timeout = df[df['event'] == 'EXITWITHTIMEOUT'].groupby('callid').first()
|
|
rna_cnt = df[df['event'] == 'RINGNOANSWER'].groupby('callid').size().rename('rna_count')
|
|
|
|
all_ids = pd.DataFrame(index=df['callid'].unique())
|
|
|
|
# ✅ outcome: answered فقط اگه COMPLETEAGENT یا COMPLETECALLER داشته باشه
|
|
# نه صرفاً CONNECT — چون در ringall چند CONNECT داریم
|
|
all_ids['outcome'] = 'other'
|
|
all_ids.loc[all_ids.index.isin(abandon.index), 'outcome'] = 'abandoned'
|
|
all_ids.loc[all_ids.index.isin(timeout.index), 'outcome'] = 'timeout'
|
|
all_ids.loc[all_ids.index.isin(comp.index), 'outcome'] = 'answered'
|
|
|
|
# زمانها: اول از COMPLETE (دقیقترین)، بعد از آخرین CONNECT
|
|
all_ids['wait_time'] = pd.to_numeric(comp['data1'], errors='coerce').reindex(all_ids.index)
|
|
all_ids['talk_time'] = pd.to_numeric(comp['data2'], errors='coerce').reindex(all_ids.index)
|
|
all_ids['position'] = pd.to_numeric(comp['data3'], errors='coerce').reindex(all_ids.index)
|
|
|
|
connect_wait = pd.to_numeric(connect['data1'], errors='coerce').reindex(all_ids.index)
|
|
all_ids['wait_time'] = all_ids['wait_time'].fillna(connect_wait)
|
|
|
|
enter_pos = pd.to_numeric(enter['data3'], errors='coerce').reindex(all_ids.index)
|
|
all_ids['position'] = all_ids['position'].fillna(enter_pos)
|
|
|
|
all_ids['abandon_wait'] = pd.to_numeric(abandon['data3'], errors='coerce').reindex(all_ids.index)
|
|
all_ids['timeout_wait'] = pd.to_numeric(timeout['data3'], errors='coerce').reindex(all_ids.index)
|
|
|
|
all_ids['is_short_abandon'] = (
|
|
(all_ids['outcome'] == 'abandoned') &
|
|
(all_ids['abandon_wait'].notna()) &
|
|
(all_ids['abandon_wait'] < SHORT_ABANDON)
|
|
)
|
|
|
|
all_ids['aht'] = all_ids['wait_time'] + all_ids['talk_time']
|
|
|
|
# agent: اول از COMPLETE، بعد از آخرین CONNECT
|
|
comp_agent = comp['agent'].reindex(all_ids.index)
|
|
connect_agent = connect['agent'].reindex(all_ids.index)
|
|
all_ids['agent'] = comp_agent.where(
|
|
comp_agent.notna() & ~comp_agent.isin(['NONE', 'none', '']),
|
|
connect_agent
|
|
)
|
|
all_ids['agent'] = all_ids['agent'].where(
|
|
all_ids['agent'].notna() & ~all_ids['agent'].isin(['NONE', 'none', '']),
|
|
None
|
|
)
|
|
|
|
raw_caller = enter['data2'].reindex(all_ids.index)
|
|
raw_caller = raw_caller.where(
|
|
raw_caller.notna() & ~raw_caller.isin(['NONE', 'none', '', '<unknown>', 'anonymous']),
|
|
None
|
|
)
|
|
all_ids['caller_id'] = raw_caller.apply(normalize_phone)
|
|
|
|
all_ids['queue'] = enter['queuename'].reindex(all_ids.index).fillna(
|
|
df.groupby('callid')['queuename'].first()
|
|
)
|
|
|
|
ts = df.groupby('callid')['timestamp'].min()
|
|
all_ids['timestamp'] = ts.reindex(all_ids.index)
|
|
|
|
def apply_dt(t, attr):
|
|
dt = ts_to_dt(t)
|
|
if dt is None:
|
|
return None
|
|
return {'hour': dt.hour, 'time': dt.strftime('%H:%M'), 'date': dt.strftime('%Y-%m-%d')}[attr]
|
|
|
|
all_ids['hour'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'hour'))
|
|
all_ids['time'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'time'))
|
|
all_ids['date'] = all_ids['timestamp'].apply(lambda t: apply_dt(t, 'date'))
|
|
|
|
comp_event = df[df['event'].isin(['COMPLETEAGENT', 'COMPLETECALLER'])]\
|
|
.groupby('callid')['event'].first()
|
|
all_ids['complete_type'] = comp_event.map(
|
|
{'COMPLETEAGENT': 'agent', 'COMPLETECALLER': 'caller'}
|
|
).reindex(all_ids.index)
|
|
|
|
all_ids['rna_count'] = rna_cnt.reindex(all_ids.index).fillna(0).astype(int)
|
|
|
|
all_ids.index.name = 'callid'
|
|
return all_ids.reset_index()
|
|
|
|
# ─────────────────────── Shared calls cache ───────────────────────
|
|
|
|
def get_calls_cached(queue, agent_filter, file_filter):
|
|
cache_key = make_cache_key('calls_df', queue, agent_filter, file_filter)
|
|
cached = get_cache(cache_key)
|
|
if cached is not None:
|
|
if not cached:
|
|
return pd.DataFrame()
|
|
df = pd.DataFrame(cached)
|
|
df['is_short_abandon'] = df['is_short_abandon'].astype(bool)
|
|
for col in ['wait_time', 'talk_time', 'aht', 'abandon_wait',
|
|
'timeout_wait', 'position', 'hour', 'rna_count']:
|
|
if col in df.columns:
|
|
df[col] = pd.to_numeric(df[col], errors='coerce')
|
|
return df
|
|
|
|
raw = _build_stats_df(queue, file_filter)
|
|
if raw.empty:
|
|
set_cache(cache_key, [])
|
|
return pd.DataFrame()
|
|
|
|
calls = build_calls(raw)
|
|
if calls.empty:
|
|
set_cache(cache_key, [])
|
|
return pd.DataFrame()
|
|
|
|
if agent_filter:
|
|
agent_callids = calls[
|
|
calls['agent'].notna() &
|
|
calls['agent'].str.contains(agent_filter, case=False, na=False)
|
|
]['callid']
|
|
calls = calls[calls['callid'].isin(agent_callids)]
|
|
|
|
calls_save = calls.copy()
|
|
calls_save['timestamp'] = calls_save['timestamp'].astype(str)
|
|
calls_save['is_short_abandon'] = calls_save['is_short_abandon'].astype(bool)
|
|
set_cache(cache_key, calls_save.where(pd.notnull(calls_save), None).to_dict('records'))
|
|
return calls
|
|
|
|
# ─────────────────────── Stats ───────────────────────
|
|
|
|
def _build_stats_df(queue='', file_filter=''):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
q = "SELECT * FROM queue_events WHERE 1=1"
|
|
params = []
|
|
if queue:
|
|
q += " AND queuename=?"
|
|
params.append(queue)
|
|
if file_filter:
|
|
q += " AND file_source=?"
|
|
params.append(file_filter)
|
|
df = pd.read_sql_query(q, conn, params=params)
|
|
conn.close()
|
|
return df
|
|
|
|
def _calculate_stats(calls):
|
|
answered = calls[calls['outcome'] == 'answered']
|
|
abandoned_all = calls[calls['outcome'] == 'abandoned']
|
|
short_abandons= abandoned_all[abandoned_all['is_short_abandon'] == True]
|
|
real_abandons = abandoned_all[abandoned_all['is_short_abandon'] == False]
|
|
timeouts = calls[calls['outcome'] == 'timeout']
|
|
|
|
total_calls = len(calls)
|
|
total_ans = len(answered)
|
|
total_abn_real = len(real_abandons)
|
|
total_abn_short = len(short_abandons)
|
|
total_to = len(timeouts)
|
|
|
|
wait_times = answered['wait_time'].dropna()
|
|
abn_waits = real_abandons['abandon_wait'].dropna()
|
|
to_waits = timeouts['timeout_wait'].dropna()
|
|
talk_times = answered['talk_time'].dropna()
|
|
aht_values = answered['aht'].dropna()
|
|
|
|
effective_total = total_calls - total_abn_short if total_calls else 0
|
|
answer_rate = round(total_ans / effective_total * 100, 1) if effective_total else 0
|
|
abandon_rate = round(total_abn_real / effective_total * 100, 1) if effective_total else 0
|
|
timeout_rate = round(total_to / effective_total * 100, 1) if effective_total else 0
|
|
|
|
pos_answered = answered['position'].dropna()
|
|
pos_abandoned = real_abandons['position'].dropna()
|
|
avg_pos_ans = round(float(pos_answered.mean()), 1) if len(pos_answered) > 0 else 0
|
|
avg_pos_abn = round(float(pos_abandoned.mean()), 1) if len(pos_abandoned) > 0 else 0
|
|
|
|
avg_to_wait = round(float(to_waits.mean()), 1) if len(to_waits) > 0 else 0
|
|
med_to_wait = round(float(to_waits.median()), 1) if len(to_waits) > 0 else 0
|
|
|
|
timeout_hourly = [
|
|
{'hour': h, 'count': int(len(timeouts[timeouts['hour'] == h]))}
|
|
for h in range(24)
|
|
]
|
|
|
|
fcr_data = calls[calls['caller_id'].notna() & (calls['caller_id'] != '')]
|
|
caller_counts = fcr_data.groupby('caller_id')['callid'].nunique() if not fcr_data.empty else pd.Series(dtype=int)
|
|
unique_callers = int(len(caller_counts))
|
|
repeat_callers = int((caller_counts > 1).sum()) if unique_callers > 0 else 0
|
|
repeat_rate = round(repeat_callers / unique_callers * 100, 1) if unique_callers > 0 else 0
|
|
repeat_calls = int(caller_counts[caller_counts > 1].sum()) - repeat_callers if unique_callers > 0 else 0
|
|
repeat_calls_rate = round(repeat_calls / total_calls * 100, 1) if total_calls > 0 else 0
|
|
|
|
hourly = []
|
|
for h in range(24):
|
|
hc = calls[calls['hour'] == h]
|
|
ha = hc[hc['outcome'] == 'answered']
|
|
habn = hc[(hc['outcome'] == 'abandoned') & (hc['is_short_abandon'] == False)]
|
|
hto = hc[hc['outcome'] == 'timeout']
|
|
hw = ha['wait_time'].dropna()
|
|
eff_h= len(hc) - int(hc['is_short_abandon'].sum())
|
|
hourly.append({
|
|
'hour' : h,
|
|
'total' : int(len(hc)),
|
|
'answered' : int(len(ha)),
|
|
'abandoned' : int(len(habn)),
|
|
'timeout' : int(len(hto)),
|
|
'abandon_rate': round(len(habn) / eff_h * 100, 1) if eff_h > 0 else 0,
|
|
'avg_wait' : round(float(hw.mean()), 1) if len(hw) > 0 else 0,
|
|
'avg_aht' : round(float(ha['aht'].dropna().mean()), 1) if len(ha) > 0 else 0,
|
|
})
|
|
|
|
team_avg_aht = round(float(aht_values.mean()), 1) if len(aht_values) > 0 else 0
|
|
team_med_wait = round(float(wait_times.median()), 1) if len(wait_times) > 0 else 0
|
|
|
|
agent_stats = []
|
|
agent_calls = answered[answered['agent'].notna() & (answered['agent'] != '')]
|
|
|
|
for ag, ag_calls in agent_calls.groupby('agent'):
|
|
ag_waits = ag_calls['wait_time'].dropna()
|
|
ag_talks = ag_calls['talk_time'].dropna()
|
|
ag_aht = ag_calls['aht'].dropna()
|
|
ag_ans = len(ag_calls)
|
|
|
|
share_of_team = round(ag_ans / total_ans * 100, 1) if total_ans > 0 else 0
|
|
comp_agent_cnt = int((ag_calls['complete_type'] == 'agent').sum())
|
|
comp_caller_cnt = int((ag_calls['complete_type'] == 'caller').sum())
|
|
|
|
ag_callers = ag_calls[ag_calls['caller_id'].notna()]
|
|
ag_caller_ids = set(ag_callers['caller_id'].values)
|
|
ag_repeat = sum(1 for cid in ag_caller_ids
|
|
if caller_counts.get(cid, 1) > 1) if unique_callers > 0 else 0
|
|
ag_repeat_rate= round(ag_repeat / len(ag_caller_ids) * 100, 1) if len(ag_caller_ids) > 0 else 0
|
|
|
|
ag_pos = ag_calls['position'].dropna()
|
|
avg_ag_pos = round(float(ag_pos.mean()), 1) if len(ag_pos) > 0 else 0
|
|
|
|
aht_vs_team = round(
|
|
(float(ag_aht.mean()) - team_avg_aht) / team_avg_aht * 100, 1
|
|
) if (len(ag_aht) > 0 and team_avg_aht > 0) else 0
|
|
|
|
agent_stats.append({
|
|
'agent' : ag,
|
|
'answered' : ag_ans,
|
|
'share_of_team' : share_of_team,
|
|
'complete_agent' : comp_agent_cnt,
|
|
'complete_caller': comp_caller_cnt,
|
|
'avg_wait' : round(float(ag_waits.mean()), 1) if len(ag_waits) > 0 else 0,
|
|
'median_wait' : round(float(ag_waits.median()), 1) if len(ag_waits) > 0 else 0,
|
|
'avg_talk' : round(float(ag_talks.mean()), 1) if len(ag_talks) > 0 else 0,
|
|
'median_talk' : round(float(ag_talks.median()), 1) if len(ag_talks) > 0 else 0,
|
|
'avg_aht' : round(float(ag_aht.mean()), 1) if len(ag_aht) > 0 else 0,
|
|
'aht_vs_team' : aht_vs_team,
|
|
'repeat_rate' : ag_repeat_rate,
|
|
'avg_position' : avg_ag_pos,
|
|
})
|
|
|
|
agent_stats.sort(key=lambda x: x['answered'], reverse=True)
|
|
|
|
histogram = {
|
|
'0-20s' : int((wait_times < 20).sum()),
|
|
'20-40s' : int(((wait_times >= 20) & (wait_times < 40)).sum()),
|
|
'40-60s' : int(((wait_times >= 40) & (wait_times < 60)).sum()),
|
|
'60-120s': int(((wait_times >= 60) & (wait_times < 120)).sum()),
|
|
'120s+' : int((wait_times >= 120).sum()),
|
|
}
|
|
|
|
return {
|
|
'total_calls' : int(total_calls),
|
|
'total_answered' : int(total_ans),
|
|
'total_abandoned' : int(total_abn_real),
|
|
'total_short_abandon': int(total_abn_short),
|
|
'total_timeout' : int(total_to),
|
|
'answer_rate' : answer_rate,
|
|
'abandon_rate' : abandon_rate,
|
|
'timeout_rate' : timeout_rate,
|
|
'avg_wait' : round(float(wait_times.mean()), 1) if len(wait_times) > 0 else 0,
|
|
'median_wait' : round(float(wait_times.median()), 1) if len(wait_times) > 0 else 0,
|
|
'avg_abandon_wait' : round(float(abn_waits.mean()), 1) if len(abn_waits) > 0 else 0,
|
|
'median_abandon_wait': round(float(abn_waits.median()), 1) if len(abn_waits) > 0 else 0,
|
|
'avg_timeout_wait' : avg_to_wait,
|
|
'median_timeout_wait': med_to_wait,
|
|
'avg_talk' : round(float(talk_times.mean()), 1) if len(talk_times) > 0 else 0,
|
|
'median_talk' : round(float(talk_times.median()), 1) if len(talk_times) > 0 else 0,
|
|
'avg_aht' : round(float(aht_values.mean()), 1) if len(aht_values) > 0 else 0,
|
|
'median_aht' : round(float(aht_values.median()), 1) if len(aht_values) > 0 else 0,
|
|
'team_avg_aht' : team_avg_aht,
|
|
'team_med_wait' : team_med_wait,
|
|
'avg_pos_answered' : avg_pos_ans,
|
|
'avg_pos_abandoned' : avg_pos_abn,
|
|
'unique_callers' : unique_callers,
|
|
'repeat_callers' : repeat_callers,
|
|
'repeat_rate' : repeat_rate,
|
|
'repeat_calls_rate' : repeat_calls_rate,
|
|
'timeout_hourly' : timeout_hourly,
|
|
'hourly' : hourly,
|
|
'histogram' : histogram,
|
|
'agents' : agent_stats,
|
|
}
|
|
|
|
# ─────────────────────── Routes ───────────────────────
|
|
|
|
@app.route('/queue/')
|
|
@app.route('/queue')
|
|
def index():
|
|
return render_template('index.html')
|
|
|
|
@app.route('/queue/upload', methods=['POST'])
|
|
def upload():
|
|
f = request.files.get('file')
|
|
if not f:
|
|
return jsonify({'error': 'فایل پیدا نشد'})
|
|
filename = secure_filename(f.filename)
|
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
f.save(filepath)
|
|
count = process_file(filepath, filename)
|
|
if count == -1:
|
|
return jsonify({'warning': True, 'message': f'فایل {filename} قبلاً وارد شده!'})
|
|
clear_cache()
|
|
return jsonify({'success': True, 'message': f'{count} رکورد از {filename} وارد شد'})
|
|
|
|
@app.route('/queue/files')
|
|
def get_files():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
files = pd.read_sql_query(
|
|
"SELECT file_source, COUNT(*) as count, MIN(imported_at) as imported "
|
|
"FROM queue_events GROUP BY file_source ORDER BY imported DESC", conn
|
|
)
|
|
conn.close()
|
|
return jsonify(files.to_dict('records'))
|
|
|
|
@app.route('/queue/queues')
|
|
def get_queues():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
queues = pd.read_sql_query(
|
|
"SELECT DISTINCT queuename FROM queue_events ORDER BY queuename", conn
|
|
)
|
|
conn.close()
|
|
return jsonify(queues['queuename'].tolist())
|
|
|
|
@app.route('/queue/agents')
|
|
def get_agents():
|
|
queue = request.args.get('queue', '')
|
|
conn = sqlite3.connect(DB_PATH)
|
|
q = "SELECT DISTINCT agent FROM queue_events WHERE agent NOT IN ('NONE','none','') AND agent IS NOT NULL"
|
|
params = []
|
|
if queue:
|
|
q += " AND queuename=?"
|
|
params.append(queue)
|
|
q += " ORDER BY agent"
|
|
agents = pd.read_sql_query(q, conn, params=params)
|
|
conn.close()
|
|
return jsonify(agents['agent'].tolist())
|
|
|
|
@app.route('/queue/delete_file', methods=['POST'])
|
|
def delete_file():
|
|
filename = request.json.get('filename')
|
|
if not filename:
|
|
return jsonify({'error': 'نام فایل وارد نشد'})
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute("DELETE FROM queue_events WHERE file_source=?", (filename,))
|
|
conn.commit()
|
|
conn.close()
|
|
fp = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
if os.path.exists(fp):
|
|
os.remove(fp)
|
|
clear_cache()
|
|
return jsonify({'success': True, 'message': f'فایل {filename} حذف شد'})
|
|
|
|
@app.route('/queue/clear_cache', methods=['POST'])
|
|
def api_clear_cache():
|
|
clear_cache()
|
|
return jsonify({'success': True, 'message': 'cache پاک شد'})
|
|
|
|
@app.route('/queue/stats')
|
|
def stats():
|
|
queue = request.args.get('queue', '')
|
|
agent_filter = request.args.get('agent', '')
|
|
file_filter = request.args.get('file_filter', '')
|
|
|
|
cache_key = make_cache_key('stats', queue, agent_filter, file_filter)
|
|
cached = get_cache(cache_key)
|
|
if cached:
|
|
cached['from_cache'] = True
|
|
return jsonify(cached)
|
|
|
|
calls = get_calls_cached(queue, agent_filter, file_filter)
|
|
if calls.empty:
|
|
return jsonify({'error': 'تماسی پیدا نشد'})
|
|
|
|
result = _calculate_stats(calls)
|
|
set_cache(cache_key, result)
|
|
result['from_cache'] = False
|
|
return jsonify(result)
|
|
|
|
@app.route('/queue/call_details')
|
|
def call_details():
|
|
queue = request.args.get('queue', '')
|
|
agent_filter = request.args.get('agent', '')
|
|
file_filter = request.args.get('file_filter', '')
|
|
page = int(request.args.get('page', 1))
|
|
per_page = int(request.args.get('per_page', 50))
|
|
search = request.args.get('search', '').strip()
|
|
|
|
cache_key = make_cache_key('details', queue, agent_filter, file_filter)
|
|
cached_calls = get_cache(cache_key)
|
|
|
|
if cached_calls is None:
|
|
calls = get_calls_cached(queue, agent_filter, file_filter)
|
|
if calls.empty:
|
|
return jsonify({'error': 'تماسی پیدا نشد', 'calls': [], 'total': 0})
|
|
|
|
calls = calls.sort_values('timestamp', ascending=False)
|
|
all_calls = []
|
|
for _, row in calls.iterrows():
|
|
all_calls.append({
|
|
'callid' : str(row.get('callid', '')),
|
|
'caller_id': str(row.get('caller_id', '') or '—'),
|
|
'agent' : str(row.get('agent', '') or '—'),
|
|
'queue' : str(row.get('queue', '')),
|
|
'outcome' : str(row.get('outcome', '')),
|
|
'wait_time': round(float(row['wait_time']), 1) if pd.notna(row.get('wait_time')) else '—',
|
|
'talk_time': round(float(row['talk_time']), 1) if pd.notna(row.get('talk_time')) else '—',
|
|
'aht' : round(float(row['aht']), 1) if pd.notna(row.get('aht')) else '—',
|
|
'date' : str(row.get('date', '')),
|
|
'time' : str(row.get('time', '') or '—'),
|
|
'position' : int(row['position']) if pd.notna(row.get('position')) else '—',
|
|
})
|
|
set_cache(cache_key, all_calls)
|
|
cached_calls = all_calls
|
|
|
|
filtered = [c for c in cached_calls if search in c.get('caller_id', '')] if search else cached_calls
|
|
total = len(filtered)
|
|
start = (page - 1) * per_page
|
|
page_calls = filtered[start: start + per_page]
|
|
|
|
return jsonify({
|
|
'calls' : page_calls,
|
|
'total' : total,
|
|
'page' : page,
|
|
'per_page': per_page,
|
|
'pages' : (total + per_page - 1) // per_page,
|
|
})
|
|
|
|
@app.route('/queue/repeat_callers')
|
|
def repeat_callers():
|
|
queue = request.args.get('queue', '')
|
|
agent_filter = request.args.get('agent', '')
|
|
file_filter = request.args.get('file_filter', '')
|
|
page = int(request.args.get('page', 1))
|
|
per_page = int(request.args.get('per_page', 50))
|
|
search = request.args.get('search', '').strip()
|
|
|
|
cache_key = make_cache_key('repeats', queue, agent_filter, file_filter)
|
|
cached_list = get_cache(cache_key)
|
|
|
|
if cached_list is None:
|
|
calls = get_calls_cached(queue, agent_filter, file_filter)
|
|
if calls.empty:
|
|
return jsonify({'callers': [], 'total': 0, 'pages': 0})
|
|
|
|
calls_with_id = calls[calls['caller_id'].notna() & (calls['caller_id'] != '')]
|
|
|
|
result = []
|
|
for cid, grp in calls_with_id.groupby('caller_id'):
|
|
if len(grp) < 2:
|
|
continue
|
|
answered = grp[grp['outcome'] == 'answered']
|
|
abandoned = grp[(grp['outcome'] == 'abandoned') & (grp['is_short_abandon'] == False)]
|
|
dates = sorted(grp['date'].dropna().unique().tolist())
|
|
agents_used = [a for a in grp['agent'].dropna().unique().tolist()
|
|
if a not in ('NONE', 'none', '')]
|
|
max_daily = int(grp.groupby('date')['callid'].count().max())
|
|
|
|
result.append({
|
|
'caller_id' : str(cid),
|
|
'call_count': len(grp),
|
|
'answered' : int(len(answered)),
|
|
'abandoned' : int(len(abandoned)),
|
|
'first_call': dates[0] if dates else '—',
|
|
'last_call' : dates[-1] if dates else '—',
|
|
'days_span' : len(dates),
|
|
'agents' : ', '.join(agents_used[:3]) + (
|
|
f' +{len(agents_used)-3}' if len(agents_used) > 3 else ''),
|
|
'avg_wait' : round(float(answered['wait_time'].dropna().mean()), 1)
|
|
if len(answered) > 0 and answered['wait_time'].notna().any() else '—',
|
|
'max_daily' : max_daily,
|
|
'is_shared' : max_daily >= 5,
|
|
})
|
|
|
|
result.sort(key=lambda x: x['call_count'], reverse=True)
|
|
set_cache(cache_key, result)
|
|
cached_list = result
|
|
|
|
filtered = [r for r in cached_list if search in r['caller_id']] if search else cached_list
|
|
total = len(filtered)
|
|
start = (page - 1) * per_page
|
|
paged = filtered[start: start + per_page]
|
|
|
|
return jsonify({
|
|
'callers' : paged,
|
|
'total' : total,
|
|
'page' : page,
|
|
'per_page': per_page,
|
|
'pages' : (total + per_page - 1) // per_page,
|
|
})
|
|
|
|
@app.route('/queue/export_csv')
|
|
def export_csv():
|
|
queue = request.args.get('queue', '')
|
|
file_filter = request.args.get('file_filter', '')
|
|
calls = get_calls_cached(queue, '', file_filter)
|
|
if calls.empty:
|
|
return jsonify({'error': 'دادهای پیدا نشد'})
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['callid', 'queue', 'agent', 'caller_id', 'outcome',
|
|
'wait_time', 'talk_time', 'aht', 'abandon_wait', 'position', 'date', 'time'])
|
|
for _, row in calls.iterrows():
|
|
writer.writerow([
|
|
row.get('callid', ''), row.get('queue', ''), row.get('agent', ''),
|
|
row.get('caller_id', ''), row.get('outcome', ''),
|
|
row.get('wait_time', ''), row.get('talk_time', ''), row.get('aht', ''),
|
|
row.get('abandon_wait', ''), row.get('position', ''),
|
|
row.get('date', ''), row.get('time', ''),
|
|
])
|
|
output.seek(0)
|
|
return Response(
|
|
'\ufeff' + output.getvalue(),
|
|
mimetype='text/csv; charset=utf-8',
|
|
headers={'Content-Disposition': 'attachment; filename=queue_report.csv'}
|
|
)
|
|
|
|
if __name__ == '__main__':
|
|
init_db()
|
|
app.run(host='0.0.0.0', port=5000)
|