Updated import duplication review

This commit is contained in:
2025-10-08 14:57:39 -04:00
parent 3648c5ddda
commit 8fdb21d2ec
4 changed files with 955 additions and 75 deletions
+123 -9
View File
@@ -6544,7 +6544,7 @@ def time_attendance_dashboard():
@admin_required @admin_required
@log_database_operations('time_attendance_import') @log_database_operations('time_attendance_import')
def import_time_attendance(): def import_time_attendance():
"""Enhanced import time attendance data from Excel file""" """Enhanced import with duplicate review"""
if request.method == 'POST': if request.method == 'POST':
try: try:
# Check if file is uploaded # Check if file is uploaded
@@ -6567,19 +6567,37 @@ def import_time_attendance():
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'), temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
# Ensure upload directory exists
os.makedirs(os.path.dirname(temp_path), exist_ok=True) os.makedirs(os.path.dirname(temp_path), exist_ok=True)
file.save(temp_path) file.save(temp_path)
# Store file path in session for duplicate review
session['pending_import_file'] = temp_path
session['pending_import_filename'] = filename
try: try:
# Initialize enhanced import service
import_service = TimeAttendanceImportService(db, logger_handler) import_service = TimeAttendanceImportService(db, logger_handler)
# Get import options # Get import options
skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true' skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true'
validate_only = request.form.get('validate_only', 'false').lower() == 'true' validate_only = request.form.get('validate_only', 'false').lower() == 'true'
analyze_duplicates = request.form.get('analyze_duplicates', 'false').lower() == 'true'
# Validate file first # Check if this is coming from duplicate review
force_import_hashes = request.form.getlist('force_import_hashes[]')
# If analyzing for duplicates, show review page
if analyze_duplicates and not force_import_hashes:
duplicate_analysis = import_service.analyze_for_duplicates(temp_path)
if duplicate_analysis['duplicate_records'] > 0:
# Show duplicate review page
return render_template('time_attendance_duplicate_review.html',
analysis=duplicate_analysis,
filename=filename)
else:
flash('No duplicates found. Proceeding with import.', 'info')
# Validate file
validation_result = import_service.validate_excel_file(temp_path) validation_result = import_service.validate_excel_file(temp_path)
if not validation_result['valid']: if not validation_result['valid']:
@@ -6587,12 +6605,10 @@ def import_time_attendance():
return render_template('time_attendance_import.html', return render_template('time_attendance_import.html',
validation_result=validation_result) validation_result=validation_result)
# Show warnings if any
if validation_result['warnings']: if validation_result['warnings']:
for warning in validation_result['warnings']: for warning in validation_result['warnings']:
flash(warning, 'warning') flash(warning, 'warning')
# If validate only, return validation results
if validate_only: if validate_only:
flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success') flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success')
return render_template('time_attendance_import.html', return render_template('time_attendance_import.html',
@@ -6604,16 +6620,17 @@ def import_time_attendance():
temp_path, temp_path,
created_by=session['user_id'], created_by=session['user_id'],
import_source=import_source, import_source=import_source,
skip_duplicates=skip_duplicates skip_duplicates=skip_duplicates,
force_import_hashes=force_import_hashes
) )
if import_result['success']: if import_result['success']:
# Log successful import
logger_handler.logger.info( logger_handler.logger.info(
f"User {session['username']} successfully imported time attendance data - " f"User {session['username']} successfully imported time attendance data - "
f"Batch: {import_result['batch_id']}, " f"Batch: {import_result['batch_id']}, "
f"Records: {import_result['imported_records']}/{import_result['total_records']}, " f"Records: {import_result['imported_records']}/{import_result['total_records']}, "
f"Duplicates: {import_result['duplicate_records']}, " f"Duplicates: {import_result['duplicate_records']}, "
f"Forced: {import_result['forced_duplicates']}, "
f"Failed: {import_result['failed_records']}" f"Failed: {import_result['failed_records']}"
) )
@@ -6623,6 +6640,9 @@ def import_time_attendance():
if import_result['duplicate_records'] > 0: if import_result['duplicate_records'] > 0:
flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info') flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info')
if import_result['forced_duplicates'] > 0:
flash(f"Imported {import_result['forced_duplicates']} duplicate records as requested.", 'info')
if import_result['failed_records'] > 0: if import_result['failed_records'] > 0:
flash(f"Note: {import_result['failed_records']} records failed to import. " flash(f"Note: {import_result['failed_records']} records failed to import. "
f"Check the error details below.", 'warning') f"Check the error details below.", 'warning')
@@ -6637,10 +6657,15 @@ def import_time_attendance():
import_result=import_result) import_result=import_result)
finally: finally:
# Clean up temporary file # Clean up if import completed or failed (not if showing duplicate review)
if not analyze_duplicates or force_import_hashes:
if os.path.exists(temp_path): if os.path.exists(temp_path):
try: try:
os.remove(temp_path) os.remove(temp_path)
if 'pending_import_file' in session:
session.pop('pending_import_file')
if 'pending_import_filename' in session:
session.pop('pending_import_filename')
except Exception as cleanup_error: except Exception as cleanup_error:
logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}")
@@ -6651,6 +6676,95 @@ def import_time_attendance():
return render_template('time_attendance_import.html') return render_template('time_attendance_import.html')
@app.route('/time-attendance/import/analyze-duplicates', methods=['POST'])
@admin_required
def analyze_import_duplicates():
"""AJAX endpoint to analyze file for duplicates"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
if not file.filename.lower().endswith(('.xlsx', '.xls')):
return jsonify({'success': False, 'message': 'Invalid file format'}), 400
# Save temporarily
filename = secure_filename(file.filename)
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
f"analyze_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
file.save(temp_path)
# Store in session
session['pending_import_file'] = temp_path
session['pending_import_filename'] = filename
try:
import_service = TimeAttendanceImportService(db, logger_handler)
analysis = import_service.analyze_for_duplicates(temp_path)
# Convert datetime objects to strings for JSON
for duplicate in analysis.get('duplicates', []):
if 'new_record' in duplicate:
if duplicate['new_record'].get('attendance_date'):
duplicate['new_record']['attendance_date'] = str(duplicate['new_record']['attendance_date'])
if duplicate['new_record'].get('attendance_time'):
duplicate['new_record']['attendance_time'] = str(duplicate['new_record']['attendance_time'])
if 'existing_record' in duplicate:
if duplicate['existing_record'].get('attendance_date'):
duplicate['existing_record']['attendance_date'] = str(duplicate['existing_record']['attendance_date'])
if duplicate['existing_record'].get('attendance_time'):
duplicate['existing_record']['attendance_time'] = str(duplicate['existing_record']['attendance_time'])
if duplicate['existing_record'].get('import_date'):
duplicate['existing_record']['import_date'] = str(duplicate['existing_record']['import_date'])
return jsonify({
'success': True,
'analysis': analysis
})
except Exception as e:
# Cleanup on error
if os.path.exists(temp_path):
os.remove(temp_path)
raise e
except Exception as e:
logger_handler.logger.error(f"Duplicate analysis error: {e}")
return jsonify({
'success': False,
'message': f'Analysis failed: {str(e)}'
}), 500
@app.route('/time-attendance/import/cancel-pending')
@admin_required
def cancel_pending_import():
"""Cancel pending import and cleanup temp file"""
try:
if 'pending_import_file' in session:
temp_path = session['pending_import_file']
if os.path.exists(temp_path):
os.remove(temp_path)
session.pop('pending_import_file')
if 'pending_import_filename' in session:
session.pop('pending_import_filename')
flash('Import cancelled.', 'info')
except Exception as e:
logger_handler.logger.error(f"Error cancelling import: {e}")
return redirect(url_for('import_time_attendance'))
@app.route('/time-attendance/import/validate', methods=['POST']) @app.route('/time-attendance/import/validate', methods=['POST'])
@admin_required @admin_required
def validate_import_file(): def validate_import_file():
@@ -0,0 +1,641 @@
{% extends "base_authenticated.html" %}
{% block title %}Review Duplicate Records - {{ COMPANY_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
<style>
.duplicate-review-page {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.review-summary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.stat-box {
background: rgba(255,255,255,0.2);
padding: 1.5rem;
border-radius: 8px;
text-align: center;
backdrop-filter: blur(10px);
}
.stat-box .number {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.stat-box .label {
font-size: 0.875rem;
opacity: 0.9;
}
.duplicates-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.duplicate-item {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
border: 2px solid #e2e8f0;
transition: all 0.3s ease;
}
.duplicate-item.selected {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.duplicate-header {
background: #f7fafc;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
}
.duplicate-title {
display: flex;
align-items: center;
gap: 1rem;
}
.duplicate-title h3 {
margin: 0;
color: #2d3748;
font-size: 1.125rem;
}
.row-badge {
background: #ed8936;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.duplicate-actions {
display: flex;
gap: 0.5rem;
}
.duplicate-body {
padding: 1.5rem;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.record-card {
background: #f8fafc;
padding: 1.5rem;
border-radius: 8px;
border: 2px solid #e2e8f0;
}
.record-card h4 {
margin: 0 0 1rem 0;
color: #2d3748;
font-size: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.record-card.new-record h4 {
color: #3182ce;
}
.record-card.existing-record h4 {
color: #805ad5;
}
.record-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 0.5rem;
font-size: 0.875rem;
}
.detail-label {
color: #718096;
font-weight: 600;
}
.detail-value {
color: #2d3748;
word-break: break-word;
}
.detail-value.highlight {
background: #fef5e7;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 600;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #ebf8ff;
border-radius: 8px;
margin-top: 1rem;
}
.checkbox-container input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.checkbox-container label {
font-weight: 600;
color: #2c5282;
cursor: pointer;
margin: 0;
}
.action-buttons {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 2rem;
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
position: sticky;
bottom: 1rem;
z-index: 100;
}
.action-buttons .left-actions,
.action-buttons .right-actions {
display: flex;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
font-size: 1rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e2e8f0;
color: #2d3748;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.btn-danger {
background: #fc8181;
color: white;
}
.btn-danger:hover {
background: #f56565;
}
.selection-summary {
background: #f0fff4;
border: 2px solid #9ae6b4;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
display: none;
}
.selection-summary.visible {
display: block;
}
.selection-summary .count {
font-weight: 700;
color: #22543d;
font-size: 1.125rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.empty-state i {
font-size: 4rem;
color: #48bb78;
margin-bottom: 1rem;
}
.empty-state h3 {
color: #2d3748;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #718096;
}
@media (max-width: 768px) {
.comparison-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .left-actions,
.action-buttons .right-actions {
width: 100%;
}
}
</style>
{% endblock %}
{% block page_title %}Review Duplicate Records{% endblock %}
{% block content %}
<div class="duplicate-review-page">
<!-- Page Header -->
<div class="time-attendance-header">
<div class="header-navigation">
<a href="{{ url_for('import_time_attendance') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Import
</a>
</div>
<div class="header-content">
<h1>
<i class="fas fa-exclamation-triangle"></i>
Review Duplicate Records
</h1>
<p class="header-description">
The following records already exist in the system. Please review and select which ones you want to import anyway.
</p>
</div>
</div>
<!-- Summary Section -->
<div class="review-summary">
<h2 style="margin: 0 0 0.5rem 0;">
<i class="fas fa-file-excel"></i>
File: {{ filename }}
</h2>
<p style="margin: 0; opacity: 0.9;">Review duplicate records before importing</p>
<div class="summary-stats">
<div class="stat-box">
<div class="number">{{ analysis.total_records }}</div>
<div class="label">Total Records in File</div>
</div>
<div class="stat-box">
<div class="number">{{ analysis.new_records }}</div>
<div class="label">New Records</div>
</div>
<div class="stat-box">
<div class="number">{{ analysis.duplicate_records }}</div>
<div class="label">Duplicate Records</div>
</div>
</div>
</div>
<!-- Selection Summary -->
<div id="selectionSummary" class="selection-summary">
<i class="fas fa-check-circle"></i>
<span class="count">0</span> duplicate record(s) selected to import
</div>
<!-- Duplicates List -->
{% if analysis.duplicate_records > 0 %}
<form id="duplicateReviewForm" method="POST" action="{{ url_for('import_time_attendance') }}">
<input type="hidden" name="analyze_duplicates" value="false">
<input type="hidden" name="skip_duplicates" value="true">
<input type="hidden" name="import_source" value="Import with Duplicates - {{ filename }}">
<div class="duplicates-container">
{% for duplicate in analysis.duplicates %}
<div class="duplicate-item" data-hash="{{ duplicate.hash }}">
<div class="duplicate-header">
<div class="duplicate-title">
<span class="row-badge">Row {{ duplicate.row_number }}</span>
<h3>{{ duplicate.new_record.employee_name }} (ID: {{ duplicate.new_record.employee_id }})</h3>
</div>
</div>
<div class="duplicate-body">
<div class="comparison-grid">
<!-- New Record (from file) -->
<div class="record-card new-record">
<h4>
<i class="fas fa-file-import"></i>
New Record (From File)
</h4>
<div class="record-details">
<div class="detail-row">
<span class="detail-label">Employee ID:</span>
<span class="detail-value highlight">{{ duplicate.new_record.employee_id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Name:</span>
<span class="detail-value">{{ duplicate.new_record.employee_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Date:</span>
<span class="detail-value highlight">{{ duplicate.new_record.attendance_date }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Time:</span>
<span class="detail-value highlight">{{ duplicate.new_record.attendance_time }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Location:</span>
<span class="detail-value highlight">{{ duplicate.new_record.location_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Action:</span>
<span class="detail-value highlight">{{ duplicate.new_record.action_description }}</span>
</div>
{% if duplicate.new_record.platform %}
<div class="detail-row">
<span class="detail-label">Platform:</span>
<span class="detail-value">{{ duplicate.new_record.platform }}</span>
</div>
{% endif %}
{% if duplicate.new_record.event_description %}
<div class="detail-row">
<span class="detail-label">Event:</span>
<span class="detail-value">{{ duplicate.new_record.event_description }}</span>
</div>
{% endif %}
{% if duplicate.new_record.recorded_address %}
<div class="detail-row">
<span class="detail-label">Address:</span>
<span class="detail-value">{{ duplicate.new_record.recorded_address }}</span>
</div>
{% endif %}
</div>
</div>
<!-- Existing Record (in database) -->
<div class="record-card existing-record">
<h4>
<i class="fas fa-database"></i>
Existing Record (In System)
</h4>
<div class="record-details">
<div class="detail-row">
<span class="detail-label">Employee ID:</span>
<span class="detail-value highlight">{{ duplicate.existing_record.employee_id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Name:</span>
<span class="detail-value">{{ duplicate.existing_record.employee_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Date:</span>
<span class="detail-value highlight">{{ duplicate.existing_record.attendance_date }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Time:</span>
<span class="detail-value highlight">{{ duplicate.existing_record.attendance_time }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Location:</span>
<span class="detail-value highlight">{{ duplicate.existing_record.location_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Action:</span>
<span class="detail-value highlight">{{ duplicate.existing_record.action_description }}</span>
</div>
{% if duplicate.existing_record.platform %}
<div class="detail-row">
<span class="detail-label">Platform:</span>
<span class="detail-value">{{ duplicate.existing_record.platform }}</span>
</div>
{% endif %}
{% if duplicate.existing_record.event_description %}
<div class="detail-row">
<span class="detail-label">Event:</span>
<span class="detail-value">{{ duplicate.existing_record.event_description }}</span>
</div>
{% endif %}
{% if duplicate.existing_record.recorded_address %}
<div class="detail-row">
<span class="detail-label">Address:</span>
<span class="detail-value">{{ duplicate.existing_record.recorded_address }}</span>
</div>
{% endif %}
<div class="detail-row">
<span class="detail-label">Import Date:</span>
<span class="detail-value">
<i class="fas fa-clock"></i>
{{ duplicate.existing_record.import_date.strftime('%Y-%m-%d %H:%M') if duplicate.existing_record.import_date else 'Unknown' }}
</span>
</div>
{% if duplicate.existing_record.import_source %}
<div class="detail-row">
<span class="detail-label">Import Source:</span>
<span class="detail-value">{{ duplicate.existing_record.import_source }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Selection Checkbox -->
<div class="checkbox-container">
<input type="checkbox"
id="duplicate_{{ loop.index }}"
name="force_import_hashes[]"
value="{{ duplicate.hash }}"
class="duplicate-checkbox">
<label for="duplicate_{{ loop.index }}">
Import this duplicate record anyway
</label>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<div class="left-actions">
<a href="{{ url_for('cancel_pending_import') }}" class="btn btn-secondary">
<i class="fas fa-times"></i>
Cancel Import
</a>
</div>
<div class="right-actions">
<button type="button" id="selectAllBtn" class="btn btn-secondary">
<i class="fas fa-check-square"></i>
Select All
</button>
<button type="button" id="skipAllBtn" class="btn btn-secondary">
<i class="fas fa-forward"></i>
Skip All Duplicates
</button>
<button type="submit" class="btn btn-primary" id="proceedBtn">
<i class="fas fa-upload"></i>
Proceed with Import
</button>
</div>
</div>
</form>
{% else %}
<div class="empty-state">
<i class="fas fa-check-circle"></i>
<h3>No Duplicates Found</h3>
<p>All records in the file are unique. You can proceed with the import.</p>
<a href="{{ url_for('import_time_attendance') }}" class="btn btn-primary" style="margin-top: 1rem;">
<i class="fas fa-arrow-left"></i>
Back to Import
</a>
</div>
{% endif %}
</div>
<script>
// Track selected duplicates
const checkboxes = document.querySelectorAll('.duplicate-checkbox');
const selectionSummary = document.getElementById('selectionSummary');
const selectAllBtn = document.getElementById('selectAllBtn');
const skipAllBtn = document.getElementById('skipAllBtn');
const proceedBtn = document.getElementById('proceedBtn');
function updateSelectionSummary() {
const selectedCount = document.querySelectorAll('.duplicate-checkbox:checked').length;
const countSpan = selectionSummary.querySelector('.count');
countSpan.textContent = selectedCount;
if (selectedCount > 0) {
selectionSummary.classList.add('visible');
} else {
selectionSummary.classList.remove('visible');
}
// Update duplicate items visual state
document.querySelectorAll('.duplicate-item').forEach(item => {
const checkbox = item.querySelector('.duplicate-checkbox');
if (checkbox && checkbox.checked) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
}
// Listen to checkbox changes
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', updateSelectionSummary);
});
// Select all button
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = true);
updateSelectionSummary();
});
}
// Skip all button (uncheck all and submit)
if (skipAllBtn) {
skipAllBtn.addEventListener('click', () => {
if (confirm('This will skip all duplicate records and import only new records. Continue?')) {
checkboxes.forEach(cb => cb.checked = false);
document.getElementById('duplicateReviewForm').submit();
}
});
}
// Form submission confirmation
if (proceedBtn) {
proceedBtn.addEventListener('click', (e) => {
const selectedCount = document.querySelectorAll('.duplicate-checkbox:checked').length;
const totalDuplicates = checkboxes.length;
const newRecordsCount = {{ analysis.new_records }};
let message = '';
if (selectedCount > 0) {
message = `You are about to import:\n\n`;
message += `- ${newRecordsCount} new record(s)\n`;
message += `- ${selectedCount} duplicate record(s)\n`;
message += `- Skipping ${totalDuplicates - selectedCount} duplicate(s)\n\n`;
message += `Total: ${newRecordsCount + selectedCount} records will be imported.\n\n`;
message += `Continue with import?`;
} else {
message = `You are about to import ${newRecordsCount} new record(s) only.\n\n`;
message += `All ${totalDuplicates} duplicate record(s) will be skipped.\n\n`;
message += `Continue with import?`;
}
if (!confirm(message)) {
e.preventDefault();
}
});
}
// Initial update
updateSelectionSummary();
</script>
{% endblock %}
+8
View File
@@ -285,6 +285,14 @@
</label> </label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="analyze_duplicates" name="analyze_duplicates" value="true">
<label for="analyze_duplicates" class="checkbox-label">
<strong>Review Duplicates Before Import</strong>
<span>Show potential duplicate records for review and allow selective import</span>
</label>
</div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="validate_only" name="validate_only" value="true"> <input type="checkbox" id="validate_only" name="validate_only" value="true">
<label for="validate_only" class="checkbox-label"> <label for="validate_only" class="checkbox-label">
+177 -60
View File
@@ -1,9 +1,8 @@
""" """
Enhanced Time Attendance Import Service Enhanced Time Attendance Import Service with Duplicate Review
======================================== ============================================================
Improved service with duplicate detection, advanced validation, Added functionality to detect and present duplicates for user review.
and better error handling for Excel imports.
""" """
import pandas as pd import pandas as pd
@@ -15,23 +14,130 @@ import traceback
import hashlib import hashlib
class TimeAttendanceImportService: class TimeAttendanceImportService:
"""Enhanced service to handle time attendance data import from Excel files""" """Enhanced service with duplicate detection and review"""
def __init__(self, db, logger_handler=None): def __init__(self, db, logger_handler=None):
"""Initialize the import service with database and logger""" """Initialize the import service with database and logger"""
self.db = db self.db = db
self.logger = logger_handler self.logger = logger_handler
def import_from_excel(self, file_path: str, created_by: int = None, def analyze_for_duplicates(self, file_path: str) -> Dict[str, Any]:
import_source: str = None, skip_duplicates: bool = True) -> Dict[str, Any]:
""" """
Import time attendance data from Excel file with enhanced validation Analyze file for potential duplicates WITHOUT importing
Args:
file_path: Path to the Excel file
Returns:
Dictionary containing duplicate analysis
"""
analysis_result = {
'success': False,
'total_records': 0,
'new_records': 0,
'duplicate_records': 0,
'duplicates': [],
'errors': []
}
try:
# Read Excel file
excel_file = pd.ExcelFile(file_path)
sheet_name = excel_file.sheet_names[0]
df = pd.read_excel(file_path, sheet_name=sheet_name)
# Validate required columns
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
analysis_result['errors'].append(f"Missing columns: {', '.join(missing_columns)}")
return analysis_result
# Remove empty rows
df = df.dropna(how='all')
analysis_result['total_records'] = len(df)
# Get existing record hashes
existing_hashes = self._get_existing_record_hashes_with_data()
# Process each row
duplicates_list = []
new_records_count = 0
for index, row in df.iterrows():
try:
# Skip empty rows
if pd.isna(row['ID']) or pd.isna(row['Name']):
continue
# Parse date and time
attendance_date = pd.to_datetime(row['Date']).date()
attendance_time = self._parse_time_field(row['Time'])
# 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
record_hash = self._generate_record_hash(record_data)
if record_hash in existing_hashes:
# Found duplicate - get existing record details
existing_record = existing_hashes[record_hash]
duplicates_list.append({
'row_number': index + 2,
'new_record': record_data,
'existing_record': existing_record,
'hash': record_hash
})
else:
new_records_count += 1
except Exception as e:
analysis_result['errors'].append(f"Row {index + 2}: {str(e)}")
continue
analysis_result['success'] = True
analysis_result['new_records'] = new_records_count
analysis_result['duplicate_records'] = len(duplicates_list)
analysis_result['duplicates'] = duplicates_list
if self.logger:
self.logger.logger.info(
f"Duplicate analysis complete - Total: {analysis_result['total_records']}, "
f"New: {new_records_count}, Duplicates: {len(duplicates_list)}"
)
except Exception as e:
analysis_result['errors'].append(f"Analysis failed: {str(e)}")
if self.logger:
self.logger.logger.error(f"Duplicate analysis error: {e}")
return analysis_result
def import_from_excel(self, file_path: str, created_by: int = None,
import_source: str = None, skip_duplicates: bool = True,
force_import_hashes: List[str] = None) -> Dict[str, Any]:
"""
Import time attendance data from Excel file with enhanced duplicate handling
Args: Args:
file_path: Path to the Excel file file_path: Path to the Excel file
created_by: User ID who initiated the import created_by: User ID who initiated the import
import_source: Description of import source import_source: Description of import source
skip_duplicates: Whether to skip duplicate records skip_duplicates: Whether to skip duplicate records
force_import_hashes: List of hashes to force import (user confirmed duplicates)
Returns: Returns:
Dictionary containing import results Dictionary containing import results
@@ -44,21 +150,27 @@ class TimeAttendanceImportService:
'failed_records': 0, 'failed_records': 0,
'duplicate_records': 0, 'duplicate_records': 0,
'skipped_records': 0, 'skipped_records': 0,
'forced_duplicates': 0,
'errors': [], 'errors': [],
'warnings': [], 'warnings': [],
'success': False, 'success': False,
'import_date': datetime.utcnow() 'import_date': datetime.utcnow()
} }
force_import_hashes = force_import_hashes or []
try: try:
# Log import start # Log import start
if self.logger: if self.logger:
self.logger.logger.info(f"Starting enhanced 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} "
f"(skip_duplicates={skip_duplicates}, force_import={len(force_import_hashes)})"
)
# Read Excel file with multiple sheet support # Read Excel file
try: try:
excel_file = pd.ExcelFile(file_path) excel_file = pd.ExcelFile(file_path)
sheet_name = excel_file.sheet_names[0] # Use first sheet sheet_name = excel_file.sheet_names[0]
df = pd.read_excel(file_path, sheet_name=sheet_name) df = pd.read_excel(file_path, sheet_name=sheet_name)
if self.logger: if self.logger:
@@ -112,7 +224,7 @@ class TimeAttendanceImportService:
import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}") import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}")
continue continue
# Validate and parse time with multiple format support # Validate and parse time
try: try:
attendance_time = self._parse_time_field(row['Time']) attendance_time = self._parse_time_field(row['Time'])
except Exception as time_error: except Exception as time_error:
@@ -136,13 +248,20 @@ class TimeAttendanceImportService:
# Check for duplicates # Check for duplicates
if skip_duplicates: if skip_duplicates:
record_hash = self._generate_record_hash(record_data) record_hash = self._generate_record_hash(record_data)
if record_hash in duplicate_hashes:
# If duplicate and NOT in force import list, skip it
if record_hash in duplicate_hashes and record_hash not in force_import_hashes:
import_results['duplicate_records'] += 1 import_results['duplicate_records'] += 1
import_results['warnings'].append( import_results['warnings'].append(
f"Row {index + 2}: Duplicate record for {record_data['employee_name']} " f"Row {index + 2}: Duplicate record for {record_data['employee_name']} "
f"on {attendance_date} at {attendance_time} - Skipped" f"on {attendance_date} at {attendance_time} - Skipped"
) )
continue continue
# If in force import list, track it
if record_hash in force_import_hashes:
import_results['forced_duplicates'] += 1
duplicate_hashes.add(record_hash) duplicate_hashes.add(record_hash)
# Create TimeAttendance record # Create TimeAttendance record
@@ -184,6 +303,7 @@ class TimeAttendanceImportService:
f"Imported: {import_results['imported_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"Duplicates: {import_results['duplicate_records']}, "
f"Forced: {import_results['forced_duplicates']}, "
f"Skipped: {import_results['skipped_records']}" f"Skipped: {import_results['skipped_records']}"
) )
@@ -208,26 +328,15 @@ class TimeAttendanceImportService:
return import_results return import_results
def _parse_time_field(self, time_value) -> time: def _parse_time_field(self, time_value) -> time:
""" """Parse time field with multiple format support"""
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): if pd.isna(time_value):
raise ValueError("Time value is empty") raise ValueError("Time value is empty")
# If already a time object
if isinstance(time_value, time): if isinstance(time_value, time):
return time_value return time_value
# Convert to string and try parsing
time_str = str(time_value).strip() time_str = str(time_value).strip()
# Try common time formats
time_formats = [ time_formats = [
'%H:%M:%S', '%H:%M:%S',
'%H:%M', '%H:%M',
@@ -241,7 +350,6 @@ class TimeAttendanceImportService:
except ValueError: except ValueError:
continue continue
# Try pandas datetime parsing as fallback
try: try:
return pd.to_datetime(time_value).time() return pd.to_datetime(time_value).time()
except: except:
@@ -250,15 +358,7 @@ class TimeAttendanceImportService:
raise ValueError(f"Unable to parse time value: {time_value}") raise ValueError(f"Unable to parse time value: {time_value}")
def _generate_record_hash(self, record_data: Dict) -> str: def _generate_record_hash(self, record_data: Dict) -> str:
""" """Generate unique hash for a record to detect duplicates"""
Generate unique hash for a record to detect duplicates
Args:
record_data: Dictionary containing record information
Returns:
Hash string
"""
hash_string = ( hash_string = (
f"{record_data['employee_id']}_" f"{record_data['employee_id']}_"
f"{record_data['attendance_date']}_" f"{record_data['attendance_date']}_"
@@ -269,12 +369,7 @@ class TimeAttendanceImportService:
return hashlib.md5(hash_string.encode()).hexdigest() return hashlib.md5(hash_string.encode()).hexdigest()
def _get_existing_record_hashes(self) -> set: def _get_existing_record_hashes(self) -> set:
""" """Get hashes of existing records (hash only)"""
Get hashes of existing records to detect duplicates
Returns:
Set of record hashes
"""
try: try:
from models.time_attendance import TimeAttendance from models.time_attendance import TimeAttendance
@@ -297,16 +392,48 @@ class TimeAttendanceImportService:
self.logger.logger.warning(f"Failed to get existing record hashes: {e}") self.logger.logger.warning(f"Failed to get existing record hashes: {e}")
return set() return set()
def _get_existing_record_hashes_with_data(self) -> Dict[str, Dict]:
"""Get hashes with full existing record data for comparison"""
try:
from models.time_attendance import TimeAttendance
existing_records = TimeAttendance.query.all()
hash_map = {}
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
}
record_hash = self._generate_record_hash(record_data)
hash_map[record_hash] = {
'id': record.id,
'employee_id': record.employee_id,
'employee_name': record.employee_name,
'platform': record.platform,
'attendance_date': record.attendance_date,
'attendance_time': record.attendance_time,
'location_name': record.location_name,
'action_description': record.action_description,
'event_description': record.event_description,
'recorded_address': record.recorded_address,
'import_batch_id': record.import_batch_id,
'import_date': record.import_date,
'import_source': record.import_source
}
return hash_map
except Exception as e:
if self.logger:
self.logger.logger.warning(f"Failed to get existing records with data: {e}")
return {}
def validate_excel_file(self, file_path: str) -> Dict[str, Any]: def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
""" """Enhanced Excel file validation with detailed analysis"""
Enhanced Excel file validation with detailed analysis
Args:
file_path: Path to the Excel file
Returns:
Dictionary containing validation results
"""
validation_results = { validation_results = {
'valid': False, 'valid': False,
'total_rows': 0, 'total_rows': 0,
@@ -320,7 +447,6 @@ class TimeAttendanceImportService:
} }
try: try:
# Get file information
import os import os
file_stats = os.stat(file_path) file_stats = os.stat(file_path)
validation_results['file_info'] = { validation_results['file_info'] = {
@@ -328,12 +454,10 @@ class TimeAttendanceImportService:
'size_mb': round(file_stats.st_size / (1024 * 1024), 2) 'size_mb': round(file_stats.st_size / (1024 * 1024), 2)
} }
# Read Excel file
excel_file = pd.ExcelFile(file_path) excel_file = pd.ExcelFile(file_path)
sheet_name = excel_file.sheet_names[0] sheet_name = excel_file.sheet_names[0]
df = pd.read_excel(file_path, sheet_name=sheet_name) df = pd.read_excel(file_path, sheet_name=sheet_name)
# Remove empty rows
original_row_count = len(df) original_row_count = len(df)
df = df.dropna(how='all') df = df.dropna(how='all')
@@ -345,11 +469,9 @@ class TimeAttendanceImportService:
f"Removed {original_row_count - len(df)} completely empty rows" f"Removed {original_row_count - len(df)} completely empty rows"
) )
# Get sample data (first 5 rows)
sample_rows = df.head(5).to_dict('records') sample_rows = df.head(5).to_dict('records')
validation_results['sample_data'] = sample_rows validation_results['sample_data'] = sample_rows
# Validate required columns
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description'] required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
missing_columns = [col for col in required_columns if col not in df.columns] missing_columns = [col for col in required_columns if col not in df.columns]
@@ -358,7 +480,6 @@ class TimeAttendanceImportService:
f"Missing required columns: {', '.join(missing_columns)}" f"Missing required columns: {', '.join(missing_columns)}"
) )
# Check for empty required fields
valid_row_count = 0 valid_row_count = 0
for index, row in df.iterrows(): for index, row in df.iterrows():
is_valid = True is_valid = True
@@ -378,7 +499,6 @@ class TimeAttendanceImportService:
f"{validation_results['invalid_rows']} rows have missing required data" f"{validation_results['invalid_rows']} rows have missing required data"
) )
# Validate date format
if 'Date' in df.columns: if 'Date' in df.columns:
invalid_dates = 0 invalid_dates = 0
for idx, date_val in df['Date'].items(): for idx, date_val in df['Date'].items():
@@ -393,7 +513,6 @@ class TimeAttendanceImportService:
f"{invalid_dates} rows have invalid date format" f"{invalid_dates} rows have invalid date format"
) )
# Validate time format
if 'Time' in df.columns: if 'Time' in df.columns:
invalid_times = 0 invalid_times = 0
for idx, time_val in df['Time'].items(): for idx, time_val in df['Time'].items():
@@ -408,7 +527,6 @@ class TimeAttendanceImportService:
f"{invalid_times} rows have invalid time format" 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']): if all(col in df.columns for col in ['ID', 'Date', 'Time', 'Location Name']):
duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated() duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated()
duplicate_count = duplicate_check.sum() duplicate_count = duplicate_check.sum()
@@ -418,7 +536,6 @@ class TimeAttendanceImportService:
f"{duplicate_count} potential duplicate records detected" f"{duplicate_count} potential duplicate records detected"
) )
# Set valid flag
validation_results['valid'] = ( validation_results['valid'] = (
len(validation_results['errors']) == 0 and len(validation_results['errors']) == 0 and
validation_results['valid_rows'] > 0 validation_results['valid_rows'] > 0