Updated Time Attendance functionality
This commit is contained in:
@@ -1,20 +1,21 @@
|
||||
"""
|
||||
Time Attendance Import Service
|
||||
=============================
|
||||
Enhanced Time Attendance Import Service
|
||||
========================================
|
||||
|
||||
Service to handle importing time attendance data from Excel files.
|
||||
Provides functionality to parse Excel files and import data into the time_attendance table.
|
||||
Improved service with duplicate detection, advanced validation,
|
||||
and better error handling for Excel imports.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import traceback
|
||||
import hashlib
|
||||
|
||||
class TimeAttendanceImportService:
|
||||
"""Service to handle time attendance data import from Excel files"""
|
||||
"""Enhanced service to handle time attendance data import from Excel files"""
|
||||
|
||||
def __init__(self, db, logger_handler=None):
|
||||
"""Initialize the import service with database and logger"""
|
||||
@@ -22,14 +23,15 @@ class TimeAttendanceImportService:
|
||||
self.logger = logger_handler
|
||||
|
||||
def import_from_excel(self, file_path: str, created_by: int = None,
|
||||
import_source: str = None) -> Dict[str, Any]:
|
||||
import_source: str = None, skip_duplicates: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Import time attendance data from Excel file
|
||||
Import time attendance data from Excel file with enhanced validation
|
||||
|
||||
Args:
|
||||
file_path: Path to the Excel file
|
||||
created_by: User ID who initiated the import
|
||||
import_source: Description of import source
|
||||
skip_duplicates: Whether to skip duplicate records
|
||||
|
||||
Returns:
|
||||
Dictionary containing import results
|
||||
@@ -40,7 +42,10 @@ class TimeAttendanceImportService:
|
||||
'total_records': 0,
|
||||
'imported_records': 0,
|
||||
'failed_records': 0,
|
||||
'duplicate_records': 0,
|
||||
'skipped_records': 0,
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'success': False,
|
||||
'import_date': datetime.utcnow()
|
||||
}
|
||||
@@ -48,10 +53,22 @@ class TimeAttendanceImportService:
|
||||
try:
|
||||
# Log import start
|
||||
if self.logger:
|
||||
self.logger.logger.info(f"Starting time attendance import from {file_path} by user {created_by}")
|
||||
self.logger.logger.info(f"Starting enhanced time attendance import from {file_path} by user {created_by}")
|
||||
|
||||
# Read Excel file
|
||||
df = pd.read_excel(file_path, sheet_name=0) # Read first sheet
|
||||
# Read Excel file with multiple sheet support
|
||||
try:
|
||||
excel_file = pd.ExcelFile(file_path)
|
||||
sheet_name = excel_file.sheet_names[0] # Use first sheet
|
||||
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.info(f"Reading sheet: {sheet_name} with {len(df)} rows")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to read Excel file: {str(e)}"
|
||||
import_results['errors'].append(error_msg)
|
||||
if self.logger:
|
||||
self.logger.logger.error(error_msg)
|
||||
return import_results
|
||||
|
||||
# Validate required columns
|
||||
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
|
||||
@@ -61,38 +78,78 @@ class TimeAttendanceImportService:
|
||||
error_msg = f"Missing required columns: {', '.join(missing_columns)}"
|
||||
import_results['errors'].append(error_msg)
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Import failed - {error_msg}")
|
||||
self.logger.logger.error(error_msg)
|
||||
return import_results
|
||||
|
||||
# Remove completely empty rows
|
||||
df = df.dropna(how='all')
|
||||
import_results['total_records'] = len(df)
|
||||
|
||||
# Process each row
|
||||
if import_results['total_records'] == 0:
|
||||
error_msg = "No valid data rows found in Excel file"
|
||||
import_results['errors'].append(error_msg)
|
||||
return import_results
|
||||
|
||||
# Track duplicates using hash
|
||||
duplicate_hashes = set()
|
||||
if skip_duplicates:
|
||||
duplicate_hashes = self._get_existing_record_hashes()
|
||||
|
||||
# Process each row with enhanced validation
|
||||
for index, row in df.iterrows():
|
||||
try:
|
||||
# Parse date and time
|
||||
attendance_date = pd.to_datetime(row['Date']).date()
|
||||
# Skip empty rows
|
||||
if pd.isna(row['ID']) or pd.isna(row['Name']):
|
||||
import_results['skipped_records'] += 1
|
||||
import_results['warnings'].append(f"Row {index + 2}: Skipped due to missing ID or Name")
|
||||
continue
|
||||
|
||||
# Handle time parsing - could be string or time object
|
||||
time_str = str(row['Time'])
|
||||
if ':' in time_str:
|
||||
attendance_time = datetime.strptime(time_str, '%H:%M:%S').time()
|
||||
else:
|
||||
# Handle Excel time format
|
||||
attendance_time = pd.to_datetime(row['Time']).time()
|
||||
# Validate and parse date
|
||||
try:
|
||||
attendance_date = pd.to_datetime(row['Date']).date()
|
||||
except Exception as date_error:
|
||||
import_results['failed_records'] += 1
|
||||
import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}")
|
||||
continue
|
||||
|
||||
# Validate and parse time with multiple format support
|
||||
try:
|
||||
attendance_time = self._parse_time_field(row['Time'])
|
||||
except Exception as time_error:
|
||||
import_results['failed_records'] += 1
|
||||
import_results['errors'].append(f"Row {index + 2}: Invalid time format - {str(time_error)}")
|
||||
continue
|
||||
|
||||
# Prepare record data
|
||||
record_data = {
|
||||
'employee_id': str(row['ID']).strip(),
|
||||
'employee_name': str(row['Name']).strip(),
|
||||
'platform': str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
|
||||
'attendance_date': attendance_date,
|
||||
'attendance_time': attendance_time,
|
||||
'location_name': str(row['Location Name']).strip(),
|
||||
'action_description': str(row['Action Description']).strip(),
|
||||
'event_description': str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
|
||||
'recorded_address': str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
|
||||
}
|
||||
|
||||
# Check for duplicates
|
||||
if skip_duplicates:
|
||||
record_hash = self._generate_record_hash(record_data)
|
||||
if record_hash in duplicate_hashes:
|
||||
import_results['duplicate_records'] += 1
|
||||
import_results['warnings'].append(
|
||||
f"Row {index + 2}: Duplicate record for {record_data['employee_name']} "
|
||||
f"on {attendance_date} at {attendance_time} - Skipped"
|
||||
)
|
||||
continue
|
||||
duplicate_hashes.add(record_hash)
|
||||
|
||||
# Create TimeAttendance record
|
||||
from models.time_attendance import TimeAttendance
|
||||
|
||||
time_attendance_record = TimeAttendance(
|
||||
employee_id=str(row['ID']).strip(),
|
||||
employee_name=str(row['Name']).strip(),
|
||||
platform=str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
|
||||
attendance_date=attendance_date,
|
||||
attendance_time=attendance_time,
|
||||
location_name=str(row['Location Name']).strip(),
|
||||
action_description=str(row['Action Description']).strip(),
|
||||
event_description=str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
|
||||
recorded_address=str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
|
||||
**record_data,
|
||||
import_batch_id=batch_id,
|
||||
import_source=import_source or f"Excel Import - {file_path}",
|
||||
created_by=created_by
|
||||
@@ -101,6 +158,10 @@ class TimeAttendanceImportService:
|
||||
self.db.session.add(time_attendance_record)
|
||||
import_results['imported_records'] += 1
|
||||
|
||||
# Commit in batches for better performance
|
||||
if import_results['imported_records'] % 100 == 0:
|
||||
self.db.session.flush()
|
||||
|
||||
except Exception as e:
|
||||
import_results['failed_records'] += 1
|
||||
error_msg = f"Row {index + 2}: {str(e)}"
|
||||
@@ -111,9 +172,9 @@ class TimeAttendanceImportService:
|
||||
|
||||
continue
|
||||
|
||||
# Commit all records
|
||||
# Final commit
|
||||
self.db.session.commit()
|
||||
import_results['success'] = True
|
||||
import_results['success'] = import_results['imported_records'] > 0
|
||||
|
||||
# Log successful import
|
||||
if self.logger:
|
||||
@@ -121,7 +182,9 @@ class TimeAttendanceImportService:
|
||||
f"Time attendance import completed - Batch: {batch_id}, "
|
||||
f"Total: {import_results['total_records']}, "
|
||||
f"Imported: {import_results['imported_records']}, "
|
||||
f"Failed: {import_results['failed_records']}"
|
||||
f"Failed: {import_results['failed_records']}, "
|
||||
f"Duplicates: {import_results['duplicate_records']}, "
|
||||
f"Skipped: {import_results['skipped_records']}"
|
||||
)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
@@ -144,9 +207,99 @@ class TimeAttendanceImportService:
|
||||
|
||||
return import_results
|
||||
|
||||
def _parse_time_field(self, time_value) -> time:
|
||||
"""
|
||||
Parse time field with multiple format support
|
||||
|
||||
Args:
|
||||
time_value: Time value from Excel (string, datetime, or time object)
|
||||
|
||||
Returns:
|
||||
time object
|
||||
"""
|
||||
if pd.isna(time_value):
|
||||
raise ValueError("Time value is empty")
|
||||
|
||||
# If already a time object
|
||||
if isinstance(time_value, time):
|
||||
return time_value
|
||||
|
||||
# Convert to string and try parsing
|
||||
time_str = str(time_value).strip()
|
||||
|
||||
# Try common time formats
|
||||
time_formats = [
|
||||
'%H:%M:%S',
|
||||
'%H:%M',
|
||||
'%I:%M:%S %p',
|
||||
'%I:%M %p',
|
||||
]
|
||||
|
||||
for fmt in time_formats:
|
||||
try:
|
||||
return datetime.strptime(time_str, fmt).time()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try pandas datetime parsing as fallback
|
||||
try:
|
||||
return pd.to_datetime(time_value).time()
|
||||
except:
|
||||
pass
|
||||
|
||||
raise ValueError(f"Unable to parse time value: {time_value}")
|
||||
|
||||
def _generate_record_hash(self, record_data: Dict) -> str:
|
||||
"""
|
||||
Generate unique hash for a record to detect duplicates
|
||||
|
||||
Args:
|
||||
record_data: Dictionary containing record information
|
||||
|
||||
Returns:
|
||||
Hash string
|
||||
"""
|
||||
hash_string = (
|
||||
f"{record_data['employee_id']}_"
|
||||
f"{record_data['attendance_date']}_"
|
||||
f"{record_data['attendance_time']}_"
|
||||
f"{record_data['location_name']}_"
|
||||
f"{record_data['action_description']}"
|
||||
)
|
||||
return hashlib.md5(hash_string.encode()).hexdigest()
|
||||
|
||||
def _get_existing_record_hashes(self) -> set:
|
||||
"""
|
||||
Get hashes of existing records to detect duplicates
|
||||
|
||||
Returns:
|
||||
Set of record hashes
|
||||
"""
|
||||
try:
|
||||
from models.time_attendance import TimeAttendance
|
||||
|
||||
existing_records = TimeAttendance.query.all()
|
||||
hashes = set()
|
||||
|
||||
for record in existing_records:
|
||||
record_data = {
|
||||
'employee_id': record.employee_id,
|
||||
'attendance_date': record.attendance_date,
|
||||
'attendance_time': record.attendance_time,
|
||||
'location_name': record.location_name,
|
||||
'action_description': record.action_description
|
||||
}
|
||||
hashes.add(self._generate_record_hash(record_data))
|
||||
|
||||
return hashes
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.logger.warning(f"Failed to get existing record hashes: {e}")
|
||||
return set()
|
||||
|
||||
def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate Excel file structure before import
|
||||
Enhanced Excel file validation with detailed analysis
|
||||
|
||||
Args:
|
||||
file_path: Path to the Excel file
|
||||
@@ -157,19 +310,41 @@ class TimeAttendanceImportService:
|
||||
validation_results = {
|
||||
'valid': False,
|
||||
'total_rows': 0,
|
||||
'valid_rows': 0,
|
||||
'invalid_rows': 0,
|
||||
'columns': [],
|
||||
'sample_data': [],
|
||||
'errors': [],
|
||||
'warnings': []
|
||||
'warnings': [],
|
||||
'file_info': {}
|
||||
}
|
||||
|
||||
try:
|
||||
# Get file information
|
||||
import os
|
||||
file_stats = os.stat(file_path)
|
||||
validation_results['file_info'] = {
|
||||
'size': file_stats.st_size,
|
||||
'size_mb': round(file_stats.st_size / (1024 * 1024), 2)
|
||||
}
|
||||
|
||||
# Read Excel file
|
||||
df = pd.read_excel(file_path, sheet_name=0)
|
||||
excel_file = pd.ExcelFile(file_path)
|
||||
sheet_name = excel_file.sheet_names[0]
|
||||
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||
|
||||
# Remove empty rows
|
||||
original_row_count = len(df)
|
||||
df = df.dropna(how='all')
|
||||
|
||||
validation_results['total_rows'] = len(df)
|
||||
validation_results['columns'] = df.columns.tolist()
|
||||
|
||||
if original_row_count > len(df):
|
||||
validation_results['warnings'].append(
|
||||
f"Removed {original_row_count - len(df)} completely empty rows"
|
||||
)
|
||||
|
||||
# Get sample data (first 5 rows)
|
||||
sample_rows = df.head(5).to_dict('records')
|
||||
validation_results['sample_data'] = sample_rows
|
||||
@@ -179,35 +354,86 @@ class TimeAttendanceImportService:
|
||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||
|
||||
if missing_columns:
|
||||
validation_results['errors'].append(f"Missing required columns: {', '.join(missing_columns)}")
|
||||
validation_results['errors'].append(
|
||||
f"Missing required columns: {', '.join(missing_columns)}"
|
||||
)
|
||||
|
||||
# Check for empty required fields
|
||||
for col in required_columns:
|
||||
if col in df.columns:
|
||||
empty_count = df[col].isna().sum()
|
||||
if empty_count > 0:
|
||||
validation_results['warnings'].append(
|
||||
f"Column '{col}' has {empty_count} empty values"
|
||||
)
|
||||
valid_row_count = 0
|
||||
for index, row in df.iterrows():
|
||||
is_valid = True
|
||||
for col in required_columns:
|
||||
if col in df.columns and pd.isna(row[col]):
|
||||
is_valid = False
|
||||
break
|
||||
|
||||
if is_valid:
|
||||
valid_row_count += 1
|
||||
|
||||
validation_results['valid_rows'] = valid_row_count
|
||||
validation_results['invalid_rows'] = len(df) - valid_row_count
|
||||
|
||||
if validation_results['invalid_rows'] > 0:
|
||||
validation_results['warnings'].append(
|
||||
f"{validation_results['invalid_rows']} rows have missing required data"
|
||||
)
|
||||
|
||||
# Validate date format
|
||||
if 'Date' in df.columns:
|
||||
try:
|
||||
pd.to_datetime(df['Date'], errors='coerce')
|
||||
except:
|
||||
validation_results['errors'].append("Invalid date format in 'Date' column")
|
||||
invalid_dates = 0
|
||||
for idx, date_val in df['Date'].items():
|
||||
if pd.notna(date_val):
|
||||
try:
|
||||
pd.to_datetime(date_val)
|
||||
except:
|
||||
invalid_dates += 1
|
||||
|
||||
if invalid_dates > 0:
|
||||
validation_results['warnings'].append(
|
||||
f"{invalid_dates} rows have invalid date format"
|
||||
)
|
||||
|
||||
# Validate time format
|
||||
if 'Time' in df.columns:
|
||||
invalid_times = 0
|
||||
for idx, time_val in df['Time'].items():
|
||||
if pd.notna(time_val):
|
||||
try:
|
||||
self._parse_time_field(time_val)
|
||||
except:
|
||||
invalid_times += 1
|
||||
|
||||
if invalid_times > 0:
|
||||
validation_results['warnings'].append(
|
||||
f"{invalid_times} rows have invalid time format"
|
||||
)
|
||||
|
||||
# Check for potential duplicates
|
||||
if all(col in df.columns for col in ['ID', 'Date', 'Time', 'Location Name']):
|
||||
duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated()
|
||||
duplicate_count = duplicate_check.sum()
|
||||
|
||||
if duplicate_count > 0:
|
||||
validation_results['warnings'].append(
|
||||
f"{duplicate_count} potential duplicate records detected"
|
||||
)
|
||||
|
||||
# Set valid flag
|
||||
validation_results['valid'] = len(validation_results['errors']) == 0
|
||||
validation_results['valid'] = (
|
||||
len(validation_results['errors']) == 0 and
|
||||
validation_results['valid_rows'] > 0
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
validation_results['errors'].append(f"Failed to read Excel file: {str(e)}")
|
||||
validation_results['errors'].append(f"Failed to validate Excel file: {str(e)}")
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Validation error: {e}")
|
||||
|
||||
return validation_results
|
||||
|
||||
def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get summary of imported data by batch ID
|
||||
Get detailed summary of imported data by batch ID
|
||||
|
||||
Args:
|
||||
batch_id: Import batch identifier
|
||||
@@ -238,6 +464,17 @@ class TimeAttendanceImportService:
|
||||
action = record.action_description
|
||||
actions[action] = actions.get(action, 0) + 1
|
||||
|
||||
# Group by employee
|
||||
employee_summary = {}
|
||||
for record in records:
|
||||
emp_id = record.employee_id
|
||||
if emp_id not in employee_summary:
|
||||
employee_summary[emp_id] = {
|
||||
'name': record.employee_name,
|
||||
'count': 0
|
||||
}
|
||||
employee_summary[emp_id]['count'] += 1
|
||||
|
||||
return {
|
||||
'batch_id': batch_id,
|
||||
'total_records': total_records,
|
||||
@@ -245,6 +482,7 @@ class TimeAttendanceImportService:
|
||||
'unique_locations': unique_locations,
|
||||
'date_range': date_range,
|
||||
'actions': actions,
|
||||
'employee_summary': employee_summary,
|
||||
'import_date': records[0].import_date if records else None,
|
||||
'import_source': records[0].import_source if records else None
|
||||
}
|
||||
@@ -252,4 +490,58 @@ class TimeAttendanceImportService:
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def delete_import_batch(self, batch_id: str, deleted_by: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete all records from a specific import batch
|
||||
|
||||
Args:
|
||||
batch_id: Import batch identifier
|
||||
deleted_by: User ID who initiated the deletion
|
||||
|
||||
Returns:
|
||||
Dictionary containing deletion results
|
||||
"""
|
||||
result = {
|
||||
'success': False,
|
||||
'deleted_count': 0,
|
||||
'message': ''
|
||||
}
|
||||
|
||||
try:
|
||||
from models.time_attendance import TimeAttendance
|
||||
|
||||
records = TimeAttendance.query.filter_by(import_batch_id=batch_id).all()
|
||||
deleted_count = len(records)
|
||||
|
||||
if deleted_count == 0:
|
||||
result['message'] = 'No records found for this batch'
|
||||
return result
|
||||
|
||||
# Delete records
|
||||
for record in records:
|
||||
self.db.session.delete(record)
|
||||
|
||||
self.db.session.commit()
|
||||
|
||||
# Log deletion
|
||||
if self.logger:
|
||||
self.logger.logger.info(
|
||||
f"User {deleted_by} deleted import batch {batch_id} - "
|
||||
f"Removed {deleted_count} records"
|
||||
)
|
||||
|
||||
result['success'] = True
|
||||
result['deleted_count'] = deleted_count
|
||||
result['message'] = f'Successfully deleted {deleted_count} records'
|
||||
|
||||
except Exception as e:
|
||||
self.db.session.rollback()
|
||||
result['message'] = f'Error deleting batch: {str(e)}'
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Failed to delete batch {batch_id}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user