sonuprasad23 commited on
Commit
96e57ad
·
1 Parent(s): 59861d6

Fixing this

Browse files
Files changed (2) hide show
  1. server.py +318 -117
  2. worker.py +277 -154
server.py CHANGED
@@ -1,23 +1,24 @@
1
  import eventlet
2
  eventlet.monkey_patch()
3
 
4
- import pandas as pd
5
- import io
6
- import threading
7
  import os
8
- import base64
9
- import json
10
  import re
 
 
 
11
  from datetime import datetime
12
- from flask import Flask, Response, request, jsonify
13
  from flask_socketio import SocketIO, emit
14
  from flask_cors import CORS
 
 
15
  from worker import QuantumBot
 
16
  from email.mime.multipart import MIMEMultipart
17
  from email.mime.text import MIMEText
18
  from email.mime.base import MIMEBase
19
  from email import encoders
20
- from dotenv import load_dotenv
21
 
22
  load_dotenv()
23
 
@@ -30,212 +31,412 @@ socketio = SocketIO(app, cors_allowed_origins=[FRONTEND_ORIGIN, "http://localhos
30
  bot_instance = None
31
  session_data = {}
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  class GmailApiService:
34
  def __init__(self):
35
- self.sender_email = os.getenv('EMAIL_SENDER'); self.service = None
 
36
  try:
37
- from google.oauth2 import service_account; from googleapiclient.discovery import build
 
38
  base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
39
- if not base64_creds: print("[Server Log] WARNING: GDRIVE_SA_KEY_BASE64 not found."); return
40
- creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
41
- credentials = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/gmail.send'])
42
- if self.sender_email: credentials = credentials.with_subject(self.sender_email)
 
 
 
 
 
 
 
43
  self.service = build('gmail', 'v1', credentials=credentials)
44
  print("[Server Log] Gmail API Service initialized successfully")
45
- except Exception as e: print(f"[Server Log] Gmail API Error: {e}")
46
-
47
- def create_professional_email_template(self, subject, status_text, stats, custom_name, process_type):
 
48
  status_color = "#28a745" if "completed" in status_text else "#ffc107" if "terminated" in status_text else "#dc3545"
49
  current_date = datetime.now().strftime("%B %d, %Y at %I:%M %p")
50
- html_template = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{subject}</title><style> * {{ margin: 0; padding: 0; box-sizing: border-box; }} body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #2c2c2c; background-color: #f8f8f8; }} .email-container {{ max-width: 700px; margin: 20px auto; background-color: #ffffff; box-shadow: 0 8px 32px rgba(139, 0, 0, 0.15); border-radius: 12px; overflow: hidden; }} .header {{ background: linear-gradient(135deg, #8A0303 0%, #4c00ff 100%); color: white; padding: 40px 30px; text-align: center; }} .header h1 {{ font-size: 32px; font-weight: 700; margin-bottom: 8px; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); }} .header p {{ font-size: 18px; opacity: 0.95; font-weight: 300; letter-spacing: 1px; }} .status-banner {{ background: {status_color}; color: white; text-align: center; padding: 18px; font-size: 16px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; }} .content {{ padding: 40px 30px; }} .report-info {{ background: #fdfdff; border-left: 6px solid #4c00ff; padding: 25px; margin-bottom: 35px; border-radius: 8px; box-shadow: 0 4px 12px rgba(76, 0, 255, 0.1); }} .info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 15px 25px; }} .info-item {{ display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }} .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; margin: 35px 0; }} .stat-card {{ background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 12px; padding: 25px 15px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); }} .stat-number {{ font-size: 36px; font-weight: 700; color: #4c00ff; margin-bottom: 10px; }} .attachments-section {{ background: #f8f5ff; border: 1px solid #e0e0e0; border-radius: 12px; padding: 25px; margin: 35px 0; }} .footer {{ background: #2c2c2c; color: white; padding: 35px 30px; text-align: center; }} h3 {{ color: #8A0303; margin-bottom: 20px; font-size: 20px; font-weight: 600; }} </style></head><body> <div class="email-container"> <div class="header"><h1>Hillside's Quantum Automation</h1><p>Patient Processing Report</p></div> <div class="status-banner">Process {status_text}</div> <div class="content"> <div class="report-info"> <h3>Report Summary</h3> <div class="info-grid"> <div class="info-item"><span><b>Report Name</b></span><span>{custom_name}</span></div> <div class="info-item"><span><b>Process Type</b></span><span>{process_type}</span></div> <div class="info-item"><span><b>Generated</b></span><span>{current_date}</span></div> <div class="info-item"><span><b>Status</b></span><span style="font-weight: bold; color: {status_color};">{status_text}</span></div> </div> </div> <h3>Processing Results</h3> <div class="stats-grid"> <div class="stat-card"><div class="stat-number">{stats['total']}</div><div>Total Patients</div></div> <div class="stat-card"><div class="stat-number">{stats['processed']}</div><div>Attempted</div></div> <div class="stat-card"><div class="stat-number">{stats['successful']}</div><div>Done</div></div> <div class="stat-card"><div class="stat-number">{stats['bad']}</div><div>Bad State</div></div> <div class="stat-card"><div class="stat-number">{stats['skipped']}</div><div>Skipped</div></div> </div> <div class="attachments-section"> <h3>Attached Reports</h3> <p style="margin-bottom: 1rem;">The following files are attached and have been uploaded to Google Drive:</p> <ul style="list-style-position: inside; padding-left: 0;"> <li><b>{custom_name}_Full.csv:</b> The complete report with the final status for every patient.</li> <li><b>{custom_name}_Bad.csv:</b> A filtered list of patients that resulted in a "Bad" state.</li> <li><b>{custom_name}_Skipped.csv:</b> A filtered list of patients that were skipped.</li> </ul> </div> </div> <div class="footer"><h4>Hillside Automation</h4><p>This is an automated report. Please do not reply.</p></div> </div> </body></html>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return html_template
52
 
53
  def send_report(self, recipients, subject, body, attachments=None):
54
- if not self.service or not recipients: return False
 
55
  try:
56
  from googleapiclient.errors import HttpError
57
- message = MIMEMultipart(); message['From'] = self.sender_email; message['To'] = ', '.join(recipients); message['Subject'] = subject
 
 
 
 
58
  message.attach(MIMEText(body, 'html'))
59
  if attachments:
60
  for filename, content in attachments.items():
61
- part = MIMEBase('application', 'octet-stream'); part.set_payload(content.encode('utf-8'))
62
- encoders.encode_base64(part); part.add_header('Content-Disposition', f'attachment; filename="{filename}"')
 
 
63
  message.attach(part)
 
64
  raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
65
  sent_message = self.service.users().messages().send(userId='me', body={'raw': raw_message}).execute()
66
- print(f"[Server Log] Email sent successfully! Message ID: {sent_message['id']}"); return True
67
- except Exception as e: print(f"[Server Log] Failed to send email: {e}"); return False
 
 
 
68
 
69
  class GoogleDriveService:
70
  def __init__(self):
71
- self.creds = None; self.service = None; self.folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
 
 
72
  try:
73
- from google.oauth2 import service_account; from googleapiclient.discovery import build
 
74
  base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
75
- if not base64_creds or not self.folder_id: raise ValueError("Google Drive secrets not found.")
76
- creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
77
- self.creds = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/drive'])
 
 
 
 
 
78
  self.service = build('drive', 'v3', credentials=self.creds)
79
  print("[Server Log] Google Drive Service initialized successfully.")
80
- except Exception as e: print(f"[Server Log] G-Drive ERROR: Could not initialize service: {e}")
81
-
 
82
  def upload_file(self, filename, file_content):
83
- if not self.service: return False
 
84
  try:
85
  from googleapiclient.http import MediaIoBaseUpload
86
  file_metadata = {'name': filename, 'parents': [self.folder_id]}
87
  media = MediaIoBaseUpload(io.BytesIO(file_content.encode('utf-8')), mimetype='text/csv', resumable=True)
88
  self.service.files().create(body=file_metadata, media_body=media, fields='id').execute()
89
- print(f"[Server Log] File '{filename}' uploaded to Google Drive."); return True
90
- except Exception as e: print(f"[Server Log] G-Drive ERROR: File upload failed: {e}"); return False
 
 
 
91
 
92
  email_service = GmailApiService()
93
  drive_service = GoogleDriveService()
94
 
95
- def get_email_list():
96
- try:
97
- with open('config/emails.conf', 'r') as f: return [line.strip() for line in f if line.strip()]
98
- except FileNotFoundError: return []
99
 
100
  def run_automation_process(session_id):
101
  global bot_instance
102
- results = []; is_terminated = False; is_crash = False
 
 
103
  try:
104
- data = session_data.get(session_id, {});
105
- patient_data = data.get('patient_data'); mode = data.get('mode')
106
- if not patient_data: raise ValueError("No patient data prepared for automation.")
 
 
 
 
 
 
107
  socketio.emit('initial_stats', {'total': len(patient_data)})
108
- results = bot_instance.start_processing(mode, patient_data, start_date=data.get('start_date'), end_date=data.get('end_date'))
 
 
 
 
 
109
  is_terminated = bot_instance.termination_event.is_set()
110
  except Exception as e:
111
- print(f"[Server Log] Fatal error in automation thread: {e}"); is_crash = True
 
112
  socketio.emit('error', {'message': f'A fatal error occurred: {e}'})
113
  finally:
114
  socketio.emit('micro_status_update', {'message': 'Generating final reports...'})
115
  generate_and_send_reports(session_id, results, is_crash_report=is_crash, is_terminated=is_terminated)
116
- if bot_instance: bot_instance.shutdown(); bot_instance = None
117
- if session_id in session_data: del session_data[session_id]
 
 
 
118
 
119
  def generate_and_send_reports(session_id, results, is_crash_report=False, is_terminated=False):
120
- data = session_data.get(session_id, {});
121
- if not data: print("[Server Log] Session data not found for reporting."); return
122
-
 
 
 
123
  full_df = pd.DataFrame(data.get('patient_data_for_report'))
 
124
  if results:
125
- result_df = pd.DataFrame(results).set_index('Name')
126
- full_df.set_index('Name', inplace=True)
127
- full_df.update(result_df); full_df.reset_index(inplace=True)
 
 
 
 
128
  full_df['Status'].fillna('Not Processed', inplace=True)
129
 
130
  final_report_df = full_df[['Name', 'PRN', 'Status']]
131
  bad_df = final_report_df[final_report_df['Status'] == 'Bad']
132
- skipped_df = final_report_df[final_report_df['Status'] == 'Skipped - No PRN']
 
 
 
 
 
 
133
 
134
- timestamp = datetime.now().strftime("%d_%b_%Y"); custom_name = data.get('filename') or timestamp
135
- full_report_name = f"{custom_name}_Full.csv"; bad_report_name = f"{custom_name}_Bad.csv"; skipped_report_name = f"{custom_name}_Skipped.csv"
136
  full_report_content = final_report_df.to_csv(index=False)
137
  drive_service.upload_file(full_report_name, full_report_content)
138
-
139
  attachments = {
140
  full_report_name: full_report_content,
141
  bad_report_name: bad_df.to_csv(index=False),
142
  skipped_report_name: skipped_df.to_csv(index=False)
143
  }
144
- status_text = "Terminated by User" if is_terminated else "Crashed" if is_crash_report else "Completed Successfully"
145
-
146
  stats = {
147
- 'total': len(full_df), 'processed': len(results),
148
- 'successful': len(full_df[full_df['Status'] == 'Done']),
149
- 'bad': len(bad_df), 'skipped': len(skipped_df)
 
 
150
  }
151
- process_type_str = data.get('mode', 'Unknown').title()
152
- subject = f"{process_type_str} Automation Report [{status_text.upper()}]: {custom_name}"
153
-
154
- professional_body = email_service.create_professional_email_template(subject, status_text, stats, custom_name, process_type_str)
155
-
156
  email_service.send_report(data.get('emails'), subject, professional_body, attachments)
157
  socketio.emit('process_complete', {'message': f'Process {status_text}. Report sent.'})
158
 
 
 
 
 
159
  @app.route('/')
160
  def status_page():
161
  APP_STATUS_HTML = """<!DOCTYPE html><html lang="en"><head><title>API Status</title><style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f0f2f5;}.status-box{text-align:center;padding:40px 60px;background:white;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.1);}h1{font-size:24px;color:#333;margin-bottom:10px;} .indicator{font-size:18px;font-weight:600;padding:8px 16px;border-radius:20px;}.active{color:#28a745;background-color:#e9f7ea;}</style></head><body><div class="status-box"><h1>Hillside Automation API</h1><div class="indicator active">● Active</div></div></body></html>"""
162
  return Response(APP_STATUS_HTML)
163
 
164
- def extract_patient_name(raw_name):
165
- if not isinstance(raw_name, str): return ""
166
- name_only = raw_name.split('DOB')[0].strip()
167
- return re.sub(r'[:\d\-\s]+$', '', name_only).strip()
 
 
 
168
 
169
- @app.route('/process_and_initialize', methods=['POST'])
170
- def handle_file_processing_and_init():
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  session_id = 'user_session'
172
  try:
173
- # Load session data from the custom header
174
- session_data_from_header = json.loads(request.headers.get('X-Session-Data'))
175
- session_data[session_id] = session_data_from_header
176
-
177
- if 'app_data' not in request.files or 'quantum_data' not in request.files:
178
- return jsonify({"error": "Both files are required."}), 400
179
-
180
- df_app = pd.read_excel(request.files['app_data']) if request.files['app_data'].filename.endswith('.xlsx') else pd.read_csv(request.files['app_data'])
181
- df_quantum = pd.read_excel(request.files['quantum_data']) if request.files['quantum_data'].filename.endswith('.xlsx') else pd.read_csv(request.files['quantum_data'])
182
-
183
- if 'Patient Name' not in df_app.columns or 'PRN' not in df_app.columns: raise ValueError("App Data must contain 'Patient Name' and 'PRN' columns.")
184
- if 'Name' not in df_quantum.columns: raise ValueError("Quantum Data must contain a 'Name' column.")
185
-
186
- df_app_filtered = df_app.dropna(subset=['PRN']); df_app_filtered = df_app_filtered[df_app_filtered['PRN'].astype(str).str.strip() != '']
187
- prn_lookup_dict = {extract_patient_name(row['Patient Name']): row['PRN'] for _, row in df_app_filtered.iterrows()}
188
-
189
- df_quantum['PRN'] = df_quantum['Name'].apply(lambda name: prn_lookup_dict.get(name, ""))
190
-
 
 
 
 
 
 
 
 
191
  master_df = df_quantum.copy()
 
 
192
  master_df['Status'] = ''
193
-
194
- session_data[session_id]['patient_data_for_report'] = master_df
195
- session_data[session_id]['patient_data'] = master_df.to_dict('records')
196
-
197
- socketio.emit('data_processed')
198
- return jsonify({"message": "Data processed successfully."})
199
- except Exception as e:
200
- print(f"[Server Log] ERROR during file processing: {e}")
201
- return jsonify({"error": str(e)}), 500
202
 
203
- @socketio.on('connect')
204
- def handle_connect():
205
- print(f'Frontend connected.')
206
- emit('email_list', {'emails': get_email_list()})
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
  @socketio.on('start_login')
209
  def handle_login(credentials):
210
  global bot_instance
211
- if bot_instance: bot_instance.shutdown()
212
- bot_instance = QuantumBot(socketio, app)
213
- is_success, error_message = bot_instance.initialize_driver()
214
- if is_success:
215
- is_login_success, login_error = bot_instance.login(credentials['username'], credentials['password'])
216
- if is_login_success: emit('otp_required')
217
- else: emit('error', {'message': f'Login failed: {login_error}'})
218
- else:
219
- emit('error', {'message': f'Failed to initialize bot: {error_message}'})
 
 
 
 
 
220
 
221
  @socketio.on('submit_otp')
222
  def handle_otp(data):
223
- if not bot_instance: return emit('error', {'message': 'Bot not initialized.'})
224
- is_success, error_message = bot_instance.submit_otp(data['otp'])
 
225
  if is_success:
226
  emit('login_successful')
227
  session_id = 'user_session'
228
  socketio.start_background_task(run_automation_process, session_id)
229
- else: emit('error', {'message': f'OTP failed: {error_message}'})
 
230
 
231
  @socketio.on('terminate_process')
232
  def handle_terminate():
233
- if bot_instance: print("Termination signal received."); bot_instance.stop()
 
 
 
 
 
 
234
 
235
  if __name__ == '__main__':
236
  print("====================================================================")
237
- print(" 🤗 Hillside Automation - Definitive Multi-Workflow Platform")
238
  print(f" Frontend URL: {FRONTEND_ORIGIN}")
239
  print(f" Port: {os.getenv('PORT', 7860)}")
240
  print("====================================================================")
241
- socketio.run(app, host='0.0.0.0', port=int(os.getenv('PORT', 7860)))
 
1
  import eventlet
2
  eventlet.monkey_patch()
3
 
 
 
 
4
  import os
5
+ import io
 
6
  import re
7
+ import json
8
+ import base64
9
+ import pandas as pd
10
  from datetime import datetime
11
+ from flask import Flask, Response, jsonify, request
12
  from flask_socketio import SocketIO, emit
13
  from flask_cors import CORS
14
+
15
+ from dotenv import load_dotenv
16
  from worker import QuantumBot
17
+
18
  from email.mime.multipart import MIMEMultipart
19
  from email.mime.text import MIMEText
20
  from email.mime.base import MIMEBase
21
  from email import encoders
 
22
 
23
  load_dotenv()
24
 
 
31
  bot_instance = None
32
  session_data = {}
33
 
34
+ # =========================
35
+ # Utilities
36
+ # =========================
37
+
38
+ def get_email_list():
39
+ try:
40
+ with open('config/emails.conf', 'r') as f:
41
+ return [line.strip() for line in f if line.strip()]
42
+ except FileNotFoundError:
43
+ return []
44
+
45
+ def extract_patient_name(raw_name):
46
+ if not isinstance(raw_name, str):
47
+ return ""
48
+ name_only = raw_name.split('DOB').strip()
49
+ return re.sub(r'[:\d\-\s]+$', '', name_only).strip()
50
+
51
+ def _read_tabular_from_b64(filename: str, b64_content: str) -> pd.DataFrame:
52
+ raw = base64.b64decode(b64_content)
53
+ ext = (filename or '').lower()
54
+ bio = io.BytesIO(raw)
55
+ if ext.endswith('.xlsx'):
56
+ return pd.read_excel(bio)
57
+ # default csv
58
+ return pd.read_csv(io.StringIO(raw.decode('utf-8', errors='ignore')))
59
+
60
+ # =========================
61
+ # Google APIs
62
+ # =========================
63
+
64
  class GmailApiService:
65
  def __init__(self):
66
+ self.sender_email = os.getenv('EMAIL_SENDER')
67
+ self.service = None
68
  try:
69
+ from google.oauth2 import service_account
70
+ from googleapiclient.discovery import build
71
  base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
72
+ if not base64_creds:
73
+ print("[Server Log] WARNING: GDRIVE_SA_KEY_BASE4 not found for Gmail.")
74
+ return
75
+ creds_json = base64.b64decode(base64_creds).decode('utf-8')
76
+ creds_dict = json.loads(creds_json)
77
+ credentials = service_account.Credentials.from_service_account_info(
78
+ creds_dict,
79
+ scopes=['https://www.googleapis.com/auth/gmail.send']
80
+ )
81
+ if self.sender_email:
82
+ credentials = credentials.with_subject(self.sender_email)
83
  self.service = build('gmail', 'v1', credentials=credentials)
84
  print("[Server Log] Gmail API Service initialized successfully")
85
+ except Exception as e:
86
+ print(f"[Server Log] Gmail API Error: {e}")
87
+
88
+ def create_professional_email_template(self, subject, status_text, stats, custom_name):
89
  status_color = "#28a745" if "completed" in status_text else "#ffc107" if "terminated" in status_text else "#dc3545"
90
  current_date = datetime.now().strftime("%B %d, %Y at %I:%M %p")
91
+
92
+ html_template = f"""
93
+ <!DOCTYPE html>
94
+ <html lang="en">
95
+ <head>
96
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{subject}</title>
97
+ <style>
98
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
99
+ body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #2c2c2c; background-color: #f8f8f8; }}
100
+ .email-container {{ max-width: 700px; margin: 20px auto; background-color: #ffffff; box-shadow: 0 8px 32px rgba(139, 0, 0, 0.15); border-radius: 12px; overflow: hidden; }}
101
+ .header {{ background: linear-gradient(135deg, #8A0303 0%, #4c00ff 100%); color: white; padding: 40px 30px; text-align: center; }}
102
+ .header h1 {{ font-size: 32px; font-weight: 700; margin-bottom: 8px; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); }}
103
+ .header p {{ font-size: 18px; opacity: 0.95; font-weight: 300; letter-spacing: 1px; }}
104
+ .status-banner {{ background: {status_color}; color: white; text-align: center; padding: 18px; font-size: 16px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; }}
105
+ .content {{ padding: 40px 30px; }}
106
+ .report-info {{ background: #fdfdff; border-left: 6px solid var(--violet); padding: 25px; margin-bottom: 35px; border-radius: 8px; box-shadow: 0 4px 12px rgba(76, 0, 255, 0.1); }}
107
+ .info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 15px 25px; }}
108
+ .info-item {{ display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }}
109
+ .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; margin: 35px 0; }}
110
+ .stat-card {{ background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 12px; padding: 25px 15px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); }}
111
+ .stat-number {{ font-size: 36px; font-weight: 700; color: var(--violet); margin-bottom: 10px; }}
112
+ .attachments-section {{ background: #f8f5ff; border: 1px solid #e0e0e0; border-radius: 12px; padding: 25px; margin: 35px 0; }}
113
+ .footer {{ background: #2c2c2c; color: white; padding: 35px 30px; text-align: center; }}
114
+ h3 {{ color: var(--blood-red); margin-bottom: 20px; font-size: 20px; font-weight: 600; }}
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <div class="email-container">
119
+ <div class="header"><h1>Hillside's Quantum Automation</h1><p>Patient Processing Report</p></div>
120
+ <div class="status-banner">Process {status_text}</div>
121
+ <div class="content">
122
+ <div class="report-info">
123
+ <h3>Report Summary</h3>
124
+ <div class="info-grid">
125
+ <div class="info-item"><span><b>Report Name</b></span><span>{custom_name}</span></div>
126
+ <div class="info-item"><span><b>Generated</b></span><span>{current_date}</span></div>
127
+ </div>
128
+ </div>
129
+ <h3>Processing Results</h3>
130
+ <div class="stats-grid">
131
+ <div class="stat-card"> <div class="stat-number">{stats['total']}</div> <div>Total in File</div> </div>
132
+ <div class="stat-card"> <div class="stat-number">{stats['processed']}</div> <div>Processed</div> </div>
133
+ <div class="stat-card"> <div class="stat-number">{stats['successful']}</div> <div>Done</div> </div>
134
+ <div class="stat-card"> <div class="stat-number">{stats['bad']}</div> <div>Bad State</div> </div>
135
+ <div class="stat-card"> <div class="stat-number">{stats['skipped']}</div> <div>Skipped (No PRN)</div> </div>
136
+ </div>
137
+ <div class="attachments-section">
138
+ <h3>Attached Reports</h3>
139
+ <p>The following files are attached to this email and have been uploaded to Google Drive:</p>
140
+ <ul>
141
+ <li><b>{custom_name}_Full.csv:</b> The complete report with the final status for every patient.</li>
142
+ <li><b>{custom_name}_Bad.csv:</b> A filtered list of patients that resulted in a "Bad" state.</li>
143
+ <li><b>{custom_name}_Skipped.csv:</b> A filtered list of patients that were skipped due to having no PRN.</li>
144
+ </ul>
145
+ </div>
146
+ </div>
147
+ <div class="footer"><h4>Hillside Automation</h4><p>This is an automated report. Please do not reply.</p></div>
148
+ </div>
149
+ </body>
150
+ </html>
151
+ """
152
  return html_template
153
 
154
  def send_report(self, recipients, subject, body, attachments=None):
155
+ if not self.service or not recipients:
156
+ return False
157
  try:
158
  from googleapiclient.errors import HttpError
159
+ message = MIMEMultipart()
160
+ message['From'] = self.sender_email
161
+ message['To'] = ', '.join(recipients)
162
+ message['Subject'] = subject
163
+
164
  message.attach(MIMEText(body, 'html'))
165
  if attachments:
166
  for filename, content in attachments.items():
167
+ part = MIMEBase('application', 'octet-stream')
168
+ part.set_payload(content.encode('utf-8'))
169
+ encoders.encode_base64(part)
170
+ part.add_header('Content-Disposition', f'attachment; filename="{filename}"')
171
  message.attach(part)
172
+
173
  raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
174
  sent_message = self.service.users().messages().send(userId='me', body={'raw': raw_message}).execute()
175
+ print(f"[Server Log] Email sent successfully! Message ID: {sent_message['id']}")
176
+ return True
177
+ except Exception as e:
178
+ print(f"[Server Log] Failed to send email: {e}")
179
+ return False
180
 
181
  class GoogleDriveService:
182
  def __init__(self):
183
+ self.creds = None
184
+ self.service = None
185
+ self.folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
186
  try:
187
+ from google.oauth2 import service_account
188
+ from googleapiclient.discovery import build
189
  base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
190
+ if not base64_creds or not self.folder_id:
191
+ raise ValueError("Google Drive secrets not found.")
192
+ creds_json = base64.b64decode(base64_creds).decode('utf-8')
193
+ creds_dict = json.loads(creds_json)
194
+ self.creds = service_account.Credentials.from_service_account_info(
195
+ creds_dict,
196
+ scopes=['https://www.googleapis.com/auth/drive']
197
+ )
198
  self.service = build('drive', 'v3', credentials=self.creds)
199
  print("[Server Log] Google Drive Service initialized successfully.")
200
+ except Exception as e:
201
+ print(f"[Server Log] G-Drive ERROR: Could not initialize service: {e}")
202
+
203
  def upload_file(self, filename, file_content):
204
+ if not self.service:
205
+ return False
206
  try:
207
  from googleapiclient.http import MediaIoBaseUpload
208
  file_metadata = {'name': filename, 'parents': [self.folder_id]}
209
  media = MediaIoBaseUpload(io.BytesIO(file_content.encode('utf-8')), mimetype='text/csv', resumable=True)
210
  self.service.files().create(body=file_metadata, media_body=media, fields='id').execute()
211
+ print(f"[Server Log] File '{filename}' uploaded to Google Drive.")
212
+ return True
213
+ except Exception as e:
214
+ print(f"[Server Log] G-Drive ERROR: File upload failed: {e}")
215
+ return False
216
 
217
  email_service = GmailApiService()
218
  drive_service = GoogleDriveService()
219
 
220
+ # =========================
221
+ # Core Automation
222
+ # =========================
 
223
 
224
  def run_automation_process(session_id):
225
  global bot_instance
226
+ results = []
227
+ is_terminated = False
228
+ is_crash = False
229
  try:
230
+ data = session_data.get(session_id, {})
231
+ patient_data = data.get('patient_data')
232
+ if not patient_data:
233
+ raise ValueError("No patient data prepared for automation.")
234
+
235
+ mode = data.get('mode', 'void')
236
+ start_date = data.get('start_date')
237
+ end_date = data.get('end_date')
238
+
239
  socketio.emit('initial_stats', {'total': len(patient_data)})
240
+ results = bot_instance.process_patient_list(
241
+ patient_data,
242
+ mode=mode,
243
+ start_date=start_date,
244
+ end_date=end_date
245
+ )
246
  is_terminated = bot_instance.termination_event.is_set()
247
  except Exception as e:
248
+ print(f"[Server Log] Fatal error in automation thread: {e}")
249
+ is_crash = True
250
  socketio.emit('error', {'message': f'A fatal error occurred: {e}'})
251
  finally:
252
  socketio.emit('micro_status_update', {'message': 'Generating final reports...'})
253
  generate_and_send_reports(session_id, results, is_crash_report=is_crash, is_terminated=is_terminated)
254
+ if bot_instance:
255
+ bot_instance.shutdown()
256
+ bot_instance = None
257
+ if session_id in session_data:
258
+ del session_data[session_id]
259
 
260
  def generate_and_send_reports(session_id, results, is_crash_report=False, is_terminated=False):
261
+ print("[Server Log] Preparing final reports...")
262
+ data = session_data.get(session_id, {})
263
+ if not data:
264
+ print("[Server Log] Session data not found, cannot generate report.")
265
+ return
266
+
267
  full_df = pd.DataFrame(data.get('patient_data_for_report'))
268
+
269
  if results:
270
+ result_df = pd.DataFrame(results)
271
+ if not result_df.empty:
272
+ result_df.set_index('Name', inplace=True)
273
+ full_df.set_index('Name', inplace=True)
274
+ full_df.update(result_df)
275
+ full_df.reset_index(inplace=True)
276
+
277
  full_df['Status'].fillna('Not Processed', inplace=True)
278
 
279
  final_report_df = full_df[['Name', 'PRN', 'Status']]
280
  bad_df = final_report_df[final_report_df['Status'] == 'Bad']
281
+ skipped_df = final_report_df[final_report_df['Status'].str.startswith('Skipped')]
282
+
283
+ timestamp = datetime.now().strftime("%d_%b_%Y")
284
+ custom_name = data.get('filename') or timestamp
285
+ full_report_name = f"{custom_name}_Full.csv"
286
+ bad_report_name = f"{custom_name}_Bad.csv"
287
+ skipped_report_name = f"{custom_name}_Skipped.csv"
288
 
 
 
289
  full_report_content = final_report_df.to_csv(index=False)
290
  drive_service.upload_file(full_report_name, full_report_content)
291
+
292
  attachments = {
293
  full_report_name: full_report_content,
294
  bad_report_name: bad_df.to_csv(index=False),
295
  skipped_report_name: skipped_df.to_csv(index=False)
296
  }
297
+ status_text = "terminated by user" if is_terminated else "crashed" if is_crash_report else "completed successfully"
298
+
299
  stats = {
300
+ 'total': len(full_df),
301
+ 'processed': len(results),
302
+ 'successful': len(final_report_df[final_report_df['Status'] == 'Done']),
303
+ 'bad': len(bad_df),
304
+ 'skipped': len(skipped_df)
305
  }
306
+
307
+ subject = f"Automation Report [{status_text.upper()}]: {custom_name}"
308
+ professional_body = email_service.create_professional_email_template(subject, status_text, stats, custom_name)
309
+
 
310
  email_service.send_report(data.get('emails'), subject, professional_body, attachments)
311
  socketio.emit('process_complete', {'message': f'Process {status_text}. Report sent.'})
312
 
313
+ # =========================
314
+ # Routes
315
+ # =========================
316
+
317
  @app.route('/')
318
  def status_page():
319
  APP_STATUS_HTML = """<!DOCTYPE html><html lang="en"><head><title>API Status</title><style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f0f2f5;}.status-box{text-align:center;padding:40px 60px;background:white;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.1);}h1{font-size:24px;color:#333;margin-bottom:10px;} .indicator{font-size:18px;font-weight:600;padding:8px 16px;border-radius:20px;}.active{color:#28a745;background-color:#e9f7ea;}</style></head><body><div class="status-box"><h1>Hillside Automation API</h1><div class="indicator active">● Active</div></div></body></html>"""
320
  return Response(APP_STATUS_HTML)
321
 
322
+ # =========================
323
+ # Socket Handlers (align with frontend)
324
+ # =========================
325
+
326
+ @socketio.on('connect')
327
+ def handle_connect():
328
+ print('Frontend connected.')
329
 
330
+ @socketio.on('get_email_list')
331
+ def handle_get_email_list():
332
+ emit('email_list', {'emails': get_email_list()})
333
+
334
+ @socketio.on('initialize_and_process_files')
335
+ def handle_initialize_and_process_files(payload):
336
+ """
337
+ Frontend sends:
338
+ - emails: list[str]
339
+ - filename: str (optional)
340
+ - mode: 'void' or 'refund'
341
+ - start_date, end_date (YYYY-MM-DD) for refund mode
342
+ - app_data_content (base64), app_data_filename
343
+ - quantum_data_content (base64), quantum_data_filename
344
+ """
345
  session_id = 'user_session'
346
  try:
347
+ required_keys = ['app_data_content', 'app_data_filename', 'quantum_data_content', 'quantum_data_filename', 'mode', 'emails']
348
+ for k in required_keys:
349
+ if k not in payload:
350
+ raise ValueError(f"Missing required field: {k}")
351
+
352
+ # Read files
353
+ df_app = _read_tabular_from_b64(payload['app_data_filename'], payload['app_data_content'])
354
+ df_quantum = _read_tabular_from_b64(payload['quantum_data_filename'], payload['quantum_data_content'])
355
+
356
+ if 'Patient Name' not in df_app.columns or 'PRN' not in df_app.columns:
357
+ raise ValueError("App Data must contain 'Patient Name' and 'PRN' columns.")
358
+ if 'Name' not in df_quantum.columns:
359
+ raise ValueError("Quantum Data must contain a 'Name' column.")
360
+
361
+ # Build PRN lookup from App Data
362
+ df_app_filtered = df_app.dropna(subset=['PRN'])
363
+ df_app_filtered = df_app_filtered[df_app_filtered['PRN'].astype(str).str.strip() != '']
364
+ prn_lookup = {
365
+ extract_patient_name(row['Patient Name']): row['PRN']
366
+ for _, row in df_app_filtered.iterrows()
367
+ }
368
+
369
+ # Assign PRN to Quantum by exact Name match (expected to be cleaned already)
370
+ df_quantum['PRN'] = df_quantum['Name'].apply(lambda name: prn_lookup.get(name, ""))
371
+
372
+ # Prepare master with Status
373
  master_df = df_quantum.copy()
374
+ if 'PRN' not in master_df.columns:
375
+ master_df['PRN'] = ''
376
  master_df['Status'] = ''
 
 
 
 
 
 
 
 
 
377
 
378
+ # Save to session
379
+ session_data[session_id] = {
380
+ 'emails': payload.get('emails', []),
381
+ 'filename': (payload.get('filename') or '').strip(),
382
+ 'mode': payload.get('mode', 'void'),
383
+ 'start_date': payload.get('start_date') or None,
384
+ 'end_date': payload.get('end_date') or None,
385
+ 'patient_data_for_report': master_df,
386
+ 'patient_data': master_df.to_dict('records')
387
+ }
388
+
389
+ # Signal UI to open login modal
390
+ emit('data_processed')
391
+ print(f"[Server Log] Data prepared. Total records: {len(master_df)}")
392
+ except Exception as e:
393
+ print(f"[Server Log] ERROR during initialize_and_process_files: {e}")
394
+ emit('error', {'message': str(e)})
395
 
396
  @socketio.on('start_login')
397
  def handle_login(credentials):
398
  global bot_instance
399
+ try:
400
+ if bot_instance:
401
+ bot_instance.shutdown()
402
+ bot_instance = QuantumBot(socketio, app)
403
+ is_success, error_message = bot_instance.initialize_driver()
404
+ if not is_success:
405
+ return emit('error', {'message': f'Failed to initialize bot: {error_message}'})
406
+ ok, login_error = bot_instance.login(credentials.get('username'), credentials.get('password'))
407
+ if ok:
408
+ emit('otp_required')
409
+ else:
410
+ emit('error', {'message': f'Login failed: {login_error}'})
411
+ except Exception as e:
412
+ emit('error', {'message': f'Login init error: {e}'})
413
 
414
  @socketio.on('submit_otp')
415
  def handle_otp(data):
416
+ if not bot_instance:
417
+ return emit('error', {'message': 'Bot not initialized.'})
418
+ is_success, error_message = bot_instance.submit_otp(data.get('otp'))
419
  if is_success:
420
  emit('login_successful')
421
  session_id = 'user_session'
422
  socketio.start_background_task(run_automation_process, session_id)
423
+ else:
424
+ emit('error', {'message': f'OTP failed: {error_message}'})
425
 
426
  @socketio.on('terminate_process')
427
  def handle_terminate():
428
+ if bot_instance:
429
+ print("Termination signal received.")
430
+ bot_instance.stop()
431
+
432
+ # =========================
433
+ # Entrypoint
434
+ # =========================
435
 
436
  if __name__ == '__main__':
437
  print("====================================================================")
438
+ print(" 🤗 Hillside Automation - Unified (Void + Refund)")
439
  print(f" Frontend URL: {FRONTEND_ORIGIN}")
440
  print(f" Port: {os.getenv('PORT', 7860)}")
441
  print("====================================================================")
442
+ socketio.run(app, host='0.0.0.0', port=int(os.getenv('PORT', 7860)))
worker.py CHANGED
@@ -1,9 +1,8 @@
 
1
  import time
2
  import threading
3
- import subprocess
4
- import pandas as pd
5
- import os
6
  from datetime import datetime
 
7
  from selenium import webdriver
8
  from selenium.webdriver.chrome.service import Service as ChromeService
9
  from selenium.webdriver.chrome.options import Options as ChromeOptions
@@ -15,48 +14,83 @@ from selenium.common.exceptions import TimeoutException
15
 
16
  class QuantumBot:
17
  def __init__(self, socketio, app):
18
- self.socketio = socketio; self.app = app; self.driver = None
19
- self.DEFAULT_TIMEOUT = 30; self.termination_event = threading.Event()
 
 
 
20
 
21
- def _kill_chrome_processes(self):
22
- try:
23
- subprocess.run(['pkill', '-f', 'chromium'], check=True, timeout=5)
24
- time.sleep(1)
25
- except Exception: pass
 
 
 
26
 
27
  def initialize_driver(self):
 
 
 
 
28
  try:
29
- self.micro_status("Initializing headless browser...")
30
- self._kill_chrome_processes()
31
- options = ChromeOptions(); options.binary_location = "/usr/bin/chromium"
32
- options.add_argument("--headless=new"); options.add_argument("--no-sandbox")
33
- options.add_argument("--disable-dev-shm-usage"); options.add_argument("--disable-gpu")
34
- options.add_argument("--window-size=1920,1080"); options.add_argument("--no-first-run")
35
- options.add_argument("--disable-extensions");
36
- user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
37
- options.add_argument(f"--user-agent={user_agent}")
38
- service = ChromeService(executable_path="/usr/bin/chromedriver")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  self.driver = webdriver.Chrome(service=service, options=options)
40
  self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
41
  'source': "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
42
  })
43
  return True, None
44
  except Exception as e:
45
- error_message = f"Message: {str(e)}"; print(f"CRITICAL ERROR in WebDriver Initialization: {error_message}")
 
46
  return False, error_message
47
 
48
- def micro_status(self, message):
49
- print(f"[Bot Log] {message}")
50
- with self.app.app_context():
51
- self.socketio.emit('micro_status_update', {'message': message})
52
-
53
- def stop(self):
54
- self.micro_status("Termination signal received..."); self.termination_event.set()
 
 
 
 
 
55
 
56
  def login(self, username, password):
57
  try:
58
- self.micro_status("Navigating to login page..."); self.driver.get("https://gateway.quantumepay.com/")
59
- time.sleep(2); self.micro_status("Entering credentials...")
 
60
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "Username"))).send_keys(username)
61
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "Password"))).send_keys(password)
62
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
@@ -64,111 +98,38 @@ class QuantumBot:
64
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "code1")))
65
  return True, None
66
  except Exception as e:
67
- error_message = f"Error during login: {str(e)}"; print(f"[Bot Log] ERROR during login: {error_message}")
 
68
  return False, error_message
69
 
70
  def submit_otp(self, otp):
71
  try:
72
- self.micro_status(f"Submitting OTP..."); otp_digits = list(otp)
73
- for i in range(6): self.driver.find_element(By.ID, f"code{i+1}").send_keys(otp_digits[i])
 
 
74
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
 
75
  self.micro_status("Verifying login success...")
76
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//span[text()='Payments']")))
77
  return True, None
78
  except Exception as e:
79
- error_message = f"Error during OTP submission: {str(e)}"; print(f"[Bot Log] ERROR during OTP submission: {error_message}")
 
80
  return False, error_message
81
 
82
- def start_processing(self, process_type, patient_data, **kwargs):
83
- if process_type == 'void':
84
- return self.process_void_list(patient_data)
85
- elif process_type == 'refund':
86
- return self.process_refund_list(patient_data, kwargs.get('start_date'), kwargs.get('end_date'))
87
- return []
88
-
89
- def process_void_list(self, patient_data):
90
- results = []
91
- for index, record in enumerate(patient_data):
92
- if self.termination_event.is_set(): print("[Bot Log] Termination detected."); break
93
- patient_name = record['Name']; patient_prn = record.get('PRN', '')
94
- status = 'Skipped - No PRN'
95
- if patient_prn and str(patient_prn).strip():
96
- self.micro_status(f"Processing VOID for '{patient_name}' ({index + 1}/{len(patient_data)})...")
97
- status = self._process_single_void(patient_name, patient_prn)
98
- else:
99
- self.micro_status(f"Skipping '{patient_name}' (No PRN)."); time.sleep(0.5)
100
- results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
101
- with self.app.app_context():
102
- self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
103
- self.socketio.emit('stats_update', {'processed': index + 1, 'remaining': len(patient_data) - (index + 1), 'status': status})
104
- return results
105
-
106
- def process_refund_list(self, patient_data, start_date_str, end_date_str):
107
- results = []
108
- for index, record in enumerate(patient_data):
109
- if self.termination_event.is_set(): print("[Bot Log] Termination detected."); break
110
- patient_name = record['Name']; patient_prn = record.get('PRN', '')
111
- status = 'Skipped - No PRN'
112
- if patient_prn and str(patient_prn).strip():
113
- self.micro_status(f"Processing REFUND for '{patient_name}' ({index + 1}/{len(patient_data)})...")
114
- status = self._process_single_refund(patient_name, patient_prn, start_date_str, end_date_str)
115
- else:
116
- self.micro_status(f"Skipping '{patient_name}' (No PRN)."); time.sleep(0.5)
117
- results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
118
- with self.app.app_context():
119
- self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
120
- self.socketio.emit('stats_update', {'processed': index + 1, 'remaining': len(patient_data) - (index + 1), 'status': status})
121
- return results
122
-
123
- def _process_single_void(self, patient_name, patient_prn):
124
- try:
125
- self.micro_status(f"Navigating to Void page for '{patient_name}'")
126
- self.driver.get("https://gateway.quantumepay.com/credit-card/void")
127
- search_successful = False
128
- for attempt in range(15):
129
- try:
130
- self.micro_status(f"Searching... (Attempt {attempt + 1})")
131
- WebDriverWait(self.driver, 2).until(EC.presence_of_element_located((By.XPATH, "//div[contains(@class, 'table-wrapper')]")))
132
- search_box = WebDriverWait(self.driver, 2).until(EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search']")))
133
- search_box.click(); time.sleep(0.5); search_box.clear(); time.sleep(0.5)
134
- search_box.send_keys(patient_name); search_successful = True; break
135
- except Exception: time.sleep(1)
136
- if not search_successful: raise Exception("Failed to search for patient.")
137
- time.sleep(3)
138
- self.micro_status("Opening transaction details...")
139
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, f"//tr[contains(., \"{patient_name}\")]//button[@data-v-b6b33fa0]"))).click()
140
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.LINK_TEXT, "Transaction Detail"))).click()
141
- self.micro_status("Adding to Vault...")
142
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Add to Vault']"))).click()
143
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//div[@class='modal-footer']//button/span[normalize-space()='Confirm']"))).click()
144
- try:
145
- self.micro_status("Verifying success and saving...")
146
- company_input = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.NAME, "company_name")))
147
- company_input.clear(); company_input.send_keys(patient_name)
148
- contact_input = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((By.NAME, "company_contact")))
149
- contact_input.click(); contact_input.send_keys(Keys.CONTROL + "a"); contact_input.send_keys(Keys.BACK_SPACE)
150
- self.micro_status(f"Entering PRN: {patient_prn}...")
151
- contact_input.send_keys(str(patient_prn))
152
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Save Changes']"))).click()
153
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Confirm']]"))).click()
154
- time.sleep(5); return 'Done'
155
- except TimeoutException:
156
- self.micro_status(f"'{patient_name}' is in a bad state, cancelling.")
157
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Cancel']]"))).click()
158
- return 'Bad'
159
- except Exception as e:
160
- print(f"An error occurred during VOID for {patient_name}: {e}"); return 'Error'
161
-
162
  def _get_calendar_months(self):
163
  try:
164
  titles = self.driver.find_elements(By.XPATH, "//div[contains(@class, 'vc-title')]")
165
  return [datetime.strptime(title.text, "%B %Y") for title in titles]
166
- except Exception: return []
 
167
 
168
  def _select_date_in_calendar(self, target_date):
169
  target_month_str = target_date.strftime("%B %Y")
170
  self.micro_status(f"Navigating calendar to {target_month_str}...")
171
- for _ in range(24):
172
  visible_months = self._get_calendar_months()
173
  if any(d.strftime("%B %Y") == target_month_str for d in visible_months):
174
  self.micro_status(f"Found month. Selecting day {target_date.day}.")
@@ -176,69 +137,231 @@ class QuantumBot:
176
  expected_aria_label = target_date.strftime(f"%A, %B {day_format}, %Y")
177
  day_xpath = f"//span[@aria-label='{expected_aria_label}']"
178
  day_element = WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.XPATH, day_xpath)))
179
- self.driver.execute_script("arguments[0].click();", day_element)
180
  return
181
- if target_date < visible_months[0]:
182
  self.driver.find_element(By.XPATH, "//div[contains(@class, 'vc-arrow') and contains(@class, 'is-left')]").click()
183
  else:
184
  self.driver.find_element(By.XPATH, "//div[contains(@class, 'vc-arrow') and contains(@class, 'is-right')]").click()
185
- time.sleep(0.5)
186
  raise Exception(f"Could not navigate to date {target_date.strftime('%Y-%m-%d')}")
187
 
188
  def _set_date_range_on_page(self, start_date_str, end_date_str):
189
  self.micro_status("Opening calendar...")
190
  date_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[contains(text(), '-')]]")))
191
- time.sleep(1); date_button.click()
192
- start_date = datetime.strptime(start_date_str, "%Y-%m-%d"); end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
 
 
 
193
  self._select_date_in_calendar(start_date)
194
- time.sleep(1)
195
  self._select_date_in_calendar(end_date)
196
- time.sleep(1)
197
  self.driver.find_element(By.TAG_NAME, "body").click()
198
- self.micro_status("Date range set. Waiting for data to filter...")
199
- time.sleep(3)
200
 
201
  def _navigate_and_verify(self, url, verification_xpath):
202
- self.micro_status(f"Navigating and verifying page...")
203
  self.driver.get(url)
204
  time.sleep(3)
 
205
  try:
206
- WebDriverWait(self.driver, 15).until(EC.presence_of_element_located((By.XPATH, verification_xpath)))
 
207
  except TimeoutException:
208
- self.micro_status("Page did not load correctly. Refreshing...")
209
- self.driver.refresh(); time.sleep(3)
210
- WebDriverWait(self.driver, 15).until(EC.presence_of_element_located((By.XPATH, verification_xpath)))
211
- self.micro_status("Page loaded.")
 
 
212
 
213
- def _process_single_refund(self, patient_name, patient_prn, start_date_str, end_date_str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  try:
215
- self.micro_status(f"Resetting state for '{patient_name}'...")
216
- self._navigate_and_verify("https://gateway.quantumepay.com/credit-card/refund", "//button[.//span[contains(text(), '-')]]")
217
- self._set_date_range_on_page(start_date_str, end_date_str)
218
-
219
- self.micro_status(f"Searching for '{patient_name}' in date range...")
220
- search_box = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search']")))
221
- time.sleep(1); search_box.click(); search_box.clear(); time.sleep(0.5); search_box.send_keys(patient_name)
222
- time.sleep(3)
223
-
 
 
 
 
 
 
 
224
  self.micro_status("Opening transaction details...")
225
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, f"//tr[contains(., \"{patient_name}\")]//button[@data-v-b6b33fa0]"))).click()
 
 
 
226
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.LINK_TEXT, "Transaction Detail"))).click()
227
-
228
- self.micro_status("Refunding...")
229
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Refund']"))).click()
230
- # This is the corrected, more complete refund logic
231
- WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.XPATH, "//input[@name='amount']")))
232
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//div[@class='modal-footer']//button/span[normalize-space()='Confirm']"))).click()
233
- time.sleep(5)
234
- return 'Done'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  except Exception as e:
236
- print(f"An error occurred during REFUND for {patient_name}: {e}")
237
  return 'Error'
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  def shutdown(self):
240
  try:
241
- if self.driver: self.driver.quit()
242
- self._kill_chrome_processes()
243
- print("[Bot Log] Chrome session closed and cleaned up.")
244
- except Exception as e: print(f"[Bot Log] Error during shutdown: {e}")
 
 
1
+ import os
2
  import time
3
  import threading
 
 
 
4
  from datetime import datetime
5
+
6
  from selenium import webdriver
7
  from selenium.webdriver.chrome.service import Service as ChromeService
8
  from selenium.webdriver.chrome.options import Options as ChromeOptions
 
14
 
15
  class QuantumBot:
16
  def __init__(self, socketio, app):
17
+ self.socketio = socketio
18
+ self.app = app
19
+ self.driver = None
20
+ self.DEFAULT_TIMEOUT = 30
21
+ self.termination_event = threading.Event()
22
 
23
+ def micro_status(self, message):
24
+ print(f"[Bot] {message}")
25
+ with self.app.app_context():
26
+ self.socketio.emit('micro_status_update', {'message': message})
27
+
28
+ def stop(self):
29
+ self.micro_status("Termination signal received...")
30
+ self.termination_event.set()
31
 
32
  def initialize_driver(self):
33
+ """
34
+ Headless on Linux/HF (HEADLESS=1 default), desktop Chrome when HEADLESS=0.
35
+ Optionally define CHROME_BINARY and CHROMEDRIVER for custom paths.
36
+ """
37
  try:
38
+ self.micro_status("Initializing browser...")
39
+ options = ChromeOptions()
40
+ headless = os.getenv("HEADLESS", "1") == "1"
41
+
42
+ if headless:
43
+ # Headless / CI / HF runner
44
+ chrome_binary = os.getenv("CHROME_BINARY", "/usr/bin/chromium")
45
+ chromedriver_path = os.getenv("CHROMEDRIVER", "/usr/bin/chromedriver")
46
+ options.binary_location = chrome_binary
47
+ options.add_argument("--headless=new")
48
+ options.add_argument("--no-sandbox")
49
+ options.add_argument("--disable-dev-shm-usage")
50
+ options.add_argument("--disable-gpu")
51
+ options.add_argument("--window-size=1920,1080")
52
+ ua = os.getenv("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
53
+ options.add_argument(f"--user-agent={ua}")
54
+ service = ChromeService(executable_path=chromedriver_path)
55
+ else:
56
+ # Local desktop with installed Chrome
57
+ from webdriver_manager.chrome import ChromeDriverManager
58
+ options.add_argument("--start-maximized")
59
+ service = ChromeService(ChromeDriverManager().install())
60
+
61
+ # Anti-automation flags
62
+ options.add_argument('--disable-blink-features=AutomationControlled')
63
+ options.add_experimental_option("excludeSwitches", ["enable-automation"])
64
+ options.add_experimental_option('useAutomationExtension', False)
65
+
66
  self.driver = webdriver.Chrome(service=service, options=options)
67
  self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
68
  'source': "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
69
  })
70
  return True, None
71
  except Exception as e:
72
+ error_message = f"Message: {str(e)}"
73
+ print(f"CRITICAL ERROR in WebDriver Initialization: {error_message}")
74
  return False, error_message
75
 
76
+ def _wait_for_page_load(self, timeout=None):
77
+ if timeout is None:
78
+ timeout = self.DEFAULT_TIMEOUT
79
+ self.micro_status("Waiting for page activity to cease...")
80
+ loading_overlay_xpath = "//div[contains(@class, 'vld-background')]"
81
+ try:
82
+ WebDriverWait(self.driver, timeout).until(
83
+ EC.invisibility_of_element_located((By.XPATH, loading_overlay_xpath))
84
+ )
85
+ self.micro_status("...Page is ready.")
86
+ except TimeoutException:
87
+ self.micro_status("Warning: Loading overlay did not disappear in time.")
88
 
89
  def login(self, username, password):
90
  try:
91
+ self.micro_status("Navigating to login page...")
92
+ self.driver.get("https://gateway.quantumepay.com/")
93
+ self._wait_for_page_load()
94
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "Username"))).send_keys(username)
95
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "Password"))).send_keys(password)
96
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
 
98
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "code1")))
99
  return True, None
100
  except Exception as e:
101
+ error_message = f"Error during login: {str(e)}"
102
+ print(f"[Bot] ERROR: {error_message}")
103
  return False, error_message
104
 
105
  def submit_otp(self, otp):
106
  try:
107
+ self.micro_status("Submitting OTP...")
108
+ otp_digits = list(otp)
109
+ for i in range(6):
110
+ self.driver.find_element(By.ID, f"code{i+1}").send_keys(otp_digits[i])
111
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
112
+ self._wait_for_page_load()
113
  self.micro_status("Verifying login success...")
114
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//span[text()='Payments']")))
115
  return True, None
116
  except Exception as e:
117
+ error_message = f"Error during OTP submission: {str(e)}"
118
+ print(f"[Bot] ERROR: {error_message}")
119
  return False, error_message
120
 
121
+ # ===== Refund helpers (calendar) =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def _get_calendar_months(self):
123
  try:
124
  titles = self.driver.find_elements(By.XPATH, "//div[contains(@class, 'vc-title')]")
125
  return [datetime.strptime(title.text, "%B %Y") for title in titles]
126
+ except Exception:
127
+ return []
128
 
129
  def _select_date_in_calendar(self, target_date):
130
  target_month_str = target_date.strftime("%B %Y")
131
  self.micro_status(f"Navigating calendar to {target_month_str}...")
132
+ for _ in range(24):
133
  visible_months = self._get_calendar_months()
134
  if any(d.strftime("%B %Y") == target_month_str for d in visible_months):
135
  self.micro_status(f"Found month. Selecting day {target_date.day}.")
 
137
  expected_aria_label = target_date.strftime(f"%A, %B {day_format}, %Y")
138
  day_xpath = f"//span[@aria-label='{expected_aria_label}']"
139
  day_element = WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.XPATH, day_xpath)))
140
+ self.driver.execute_script("arguments.click();", day_element)
141
  return
142
+ if visible_months and target_date < visible_months:
143
  self.driver.find_element(By.XPATH, "//div[contains(@class, 'vc-arrow') and contains(@class, 'is-left')]").click()
144
  else:
145
  self.driver.find_element(By.XPATH, "//div[contains(@class, 'vc-arrow') and contains(@class, 'is-right')]").click()
146
+ self._wait_for_page_load(timeout=5)
147
  raise Exception(f"Could not navigate to date {target_date.strftime('%Y-%m-%d')}")
148
 
149
  def _set_date_range_on_page(self, start_date_str, end_date_str):
150
  self.micro_status("Opening calendar...")
151
  date_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[contains(text(), '-')]]")))
152
+ time.sleep(2)
153
+ date_button.click()
154
+ self._wait_for_page_load(timeout=10)
155
+ start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
156
+ end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
157
  self._select_date_in_calendar(start_date)
158
+ self._wait_for_page_load(timeout=10)
159
  self._select_date_in_calendar(end_date)
160
+ time.sleep(2)
161
  self.driver.find_element(By.TAG_NAME, "body").click()
162
+ self._wait_for_page_load(timeout=10)
163
+ self._wait_for_page_load()
164
 
165
  def _navigate_and_verify(self, url, verification_xpath):
166
+ self.micro_status("Navigating to page and verifying...")
167
  self.driver.get(url)
168
  time.sleep(3)
169
+ self._wait_for_page_load()
170
  try:
171
+ WebDriverWait(self.driver, 15).until(EC.element_to_be_clickable((By.XPATH, verification_xpath)))
172
+ self.micro_status("Page loaded successfully.")
173
  except TimeoutException:
174
+ self.micro_status("Page did not load correctly. Attempting a hard refresh...")
175
+ self.driver.refresh()
176
+ time.sleep(3)
177
+ self._wait_for_page_load()
178
+ WebDriverWait(self.driver, 15).until(EC.element_to_be_clickable((By.XPATH, verification_xpath)))
179
+ self.micro_status("Page loaded successfully after refresh.")
180
 
181
+ # ===== Main dispatcher =====
182
+ def process_patient_list(self, patient_data, mode='void', start_date=None, end_date=None):
183
+ """
184
+ mode: 'void' or 'refund'
185
+ For 'refund', start_date and end_date must be provided (YYYY-MM-DD).
186
+ """
187
+ results = []
188
+ idx = 0
189
+ total = len(patient_data)
190
+ while idx < total:
191
+ if self.termination_event.is_set():
192
+ print("[Bot] Termination detected.")
193
+ break
194
+
195
+ record = patient_data[idx]
196
+ patient_name = record.get('Name', '')
197
+ patient_prn = record.get('PRN', '')
198
+
199
+ with self.app.app_context():
200
+ self.socketio.emit('stats_update', {
201
+ 'processed': len(results),
202
+ 'remaining': total - len(results),
203
+ 'status': 'Starting'
204
+ })
205
+
206
+ if not patient_prn or not str(patient_prn).strip():
207
+ status = 'Skipped - No PRN'
208
+ self.micro_status(f"Skipping '{patient_name}' (No PRN).")
209
+ time.sleep(0.4)
210
+ results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
211
+ with self.app.app_context():
212
+ self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
213
+ idx += 1
214
+ continue
215
+
216
+ self.micro_status(f"Processing '{patient_name}' ({len(results) + 1}/{total})...")
217
+
218
+ if mode == 'refund':
219
+ status = self._process_single_patient_refund(patient_name, patient_prn, start_date, end_date)
220
+ else:
221
+ status = self._process_single_patient_void(patient_name, patient_prn)
222
+
223
+ if status != "RETRY":
224
+ results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
225
+ with self.app.app_context():
226
+ self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
227
+ idx += 1
228
+
229
+ return results
230
+
231
+ # ===== Void workflow =====
232
+ def _process_single_patient_void(self, patient_name, patient_prn):
233
  try:
234
+ self.micro_status(f"Navigating to Void page for '{patient_name}'")
235
+ self.driver.get("https://gateway.quantumepay.com/credit-card/void")
236
+
237
+ # Search with retries for stability
238
+ for attempt in range(15):
239
+ try:
240
+ self.micro_status(f"Searching... (Attempt {attempt + 1})")
241
+ WebDriverWait(self.driver, 2).until(EC.presence_of_element_located((By.XPATH, "//div[contains(@class, 'table-wrapper')]")))
242
+ search_box = WebDriverWait(self.driver, 2).until(EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search']")))
243
+ search_box.click(); time.sleep(0.3); search_box.clear(); time.sleep(0.2)
244
+ search_box.send_keys(patient_name)
245
+ break
246
+ except Exception:
247
+ time.sleep(0.6)
248
+
249
+ time.sleep(2)
250
  self.micro_status("Opening transaction details...")
251
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(
252
+ EC.element_to_be_clickable((By.XPATH, f"//tr[contains(., \"{patient_name}\")]//button[@data-v-b6b33fa0]"))
253
+ ).click()
254
+
255
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.LINK_TEXT, "Transaction Detail"))).click()
256
+
257
+ self.micro_status("Adding to Vault...")
258
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Add to Vault']"))).click()
 
 
259
  WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//div[@class='modal-footer']//button/span[normalize-space()='Confirm']"))).click()
260
+
261
+ try:
262
+ self.micro_status("Verifying success and saving...")
263
+ company_input = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.NAME, "company_name")))
264
+ company_input.clear(); company_input.send_keys(patient_name)
265
+
266
+ contact_input = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((By.NAME, "company_contact")))
267
+ # Clear reliably then fill PRN
268
+ contact_input.click(); contact_input.send_keys(Keys.CONTROL + "a"); contact_input.send_keys(Keys.BACK_SPACE)
269
+ contact_input.send_keys(str(patient_prn))
270
+
271
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Save Changes']"))).click()
272
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Confirm']]"))).click()
273
+ time.sleep(2)
274
+ return 'Done'
275
+ except TimeoutException:
276
+ self.micro_status(f"'{patient_name}' is in a bad state, cancelling.")
277
+ try:
278
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Cancel']]"))).click()
279
+ except Exception:
280
+ pass
281
+ return 'Bad'
282
  except Exception as e:
283
+ print(f"An error occurred while processing {patient_name}: {e}")
284
  return 'Error'
285
 
286
+ # ===== Refund workflow =====
287
+ def _process_single_patient_refund(self, patient_name, patient_prn, start_date_str, end_date_str):
288
+ try:
289
+ refund_page_url = "https://gateway.quantumepay.com/credit-card/refund"
290
+ date_button_xpath = "//button[.//span[contains(text(), '-')]]"
291
+ self._navigate_and_verify(refund_page_url, date_button_xpath)
292
+ self._set_date_range_on_page(start_date_str, end_date_str)
293
+
294
+ self.micro_status(f"Searching for '{patient_name}'...")
295
+ search_box = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(
296
+ EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search']"))
297
+ )
298
+ time.sleep(0.5)
299
+ search_box.click(); search_box.clear(); time.sleep(0.2); search_box.send_keys(patient_name)
300
+ self._wait_for_page_load()
301
+
302
+ row_xpath = f"//tr[contains(., \"{patient_name}\")]"
303
+ WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.XPATH, row_xpath)))
304
+
305
+ self.micro_status("Opening transaction details...")
306
+ details_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(
307
+ EC.element_to_be_clickable((By.XPATH, f"{row_xpath}//button[@data-v-b6b33fa0]"))
308
+ )
309
+ time.sleep(0.6); details_button.click()
310
+ self._wait_for_page_load(timeout=5)
311
+
312
+ transaction_link = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.LINK_TEXT, "Transaction Detail")))
313
+ time.sleep(0.6); transaction_link.click()
314
+ self._wait_for_page_load()
315
+
316
+ self.micro_status("Adding to Vault...")
317
+ vault_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Add to Vault']")))
318
+ time.sleep(0.6); vault_button.click()
319
+ self._wait_for_page_load()
320
+
321
+ confirm_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//div[@class='modal-footer']//button/span[normalize-space()='Confirm']")))
322
+ time.sleep(0.6); confirm_button.click()
323
+ self._wait_for_page_load()
324
+
325
+ try:
326
+ self.micro_status("Verifying success and saving...")
327
+ company_input = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.NAME, "company_name")))
328
+ time.sleep(0.5); company_input.clear(); company_input.send_keys(patient_name)
329
+
330
+ if patient_prn and str(patient_prn).strip() and str(patient_prn).lower() != 'nan':
331
+ contact_input = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((By.NAME, "company_contact")))
332
+ time.sleep(0.5)
333
+ if contact_input.get_attribute('value') == 'N/A':
334
+ contact_input.clear()
335
+ contact_input.click(); contact_input.send_keys(Keys.CONTROL + "a"); contact_input.send_keys(Keys.BACK_SPACE)
336
+ contact_input.send_keys(str(patient_prn))
337
+
338
+ save_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Save Changes']")))
339
+ time.sleep(0.6); save_button.click()
340
+ self._wait_for_page_load()
341
+
342
+ final_confirm = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Confirm']]")))
343
+ time.sleep(0.6); final_confirm.click()
344
+ time.sleep(1.2)
345
+ self._wait_for_page_load()
346
+ return 'Done'
347
+ except TimeoutException:
348
+ self.micro_status(f"'{patient_name}' is in a bad state, cancelling.")
349
+ try:
350
+ cancel_button = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Cancel']]")))
351
+ time.sleep(0.5); cancel_button.click()
352
+ except Exception:
353
+ pass
354
+ self._wait_for_page_load()
355
+ return 'Bad'
356
+ except Exception as e:
357
+ print(f"An error occurred while processing {patient_name}: {e}")
358
+ self.micro_status(f"An error occurred for '{patient_name}'. Retrying this patient...")
359
+ return 'RETRY'
360
+
361
  def shutdown(self):
362
  try:
363
+ if self.driver:
364
+ self.driver.quit()
365
+ print("[Bot] Chrome session closed.")
366
+ except Exception as e:
367
+ print(f"[Bot] Error during shutdown: {e}")