Sep 16 - Optimize code, part 2

This commit is contained in:
2026-09-16 14:31:41 -04:00
parent 2c5627354e
commit 13b56fb1d1
9 changed files with 632 additions and 140 deletions
+161 -34
View File
@@ -6,8 +6,9 @@ Added functionality to detect and present duplicates for user review.
"""
import pandas as pd
import re
import uuid
from datetime import datetime, time
from datetime import datetime, date, time
from sqlalchemy.exc import SQLAlchemyError
from typing import Dict, List, Any, Optional, Tuple
import traceback
@@ -433,7 +434,7 @@ class TimeAttendanceImportService:
employee_name = self._get_employee_name(clean_id)
# Parse date and time
attendance_date = pd.to_datetime(row['Date']).date()
attendance_date = self._parse_date_field(row['Date'])
attendance_time = self._parse_time_field(row['Time'])
# Prepare record data
@@ -593,10 +594,10 @@ class TimeAttendanceImportService:
row_errors.append("Missing Date")
else:
try:
attendance_date = pd.to_datetime(row['Date']).date()
attendance_date = self._parse_date_field(row['Date'])
row_data['attendance_date'] = attendance_date
except Exception:
row_errors.append(f"Invalid date format: {row['Date']}")
except Exception as date_error:
row_errors.append(f"Invalid date format: {row['Date']} ({date_error})")
# Check and parse Time
if pd.isna(row['Time']):
@@ -832,7 +833,7 @@ class TimeAttendanceImportService:
# Validate and parse date
try:
attendance_date = pd.to_datetime(row['Date']).date()
attendance_date = self._parse_date_field(row['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)}")
@@ -936,17 +937,20 @@ class TimeAttendanceImportService:
)
except SQLAlchemyError as e:
self.db.session.rollback()
error_msg = f"Database error during import: {str(e)}"
import_results['errors'].append(error_msg)
# Rows commit in batches of 50 — drop the partial batch (all-or-nothing)
self._rollback_partial_batch(batch_id, import_results)
import_results['success'] = False
if self.logger:
self.logger.log_database_error('time_attendance_import', e)
except Exception as e:
self.db.session.rollback()
error_msg = f"Unexpected error during import: {str(e)}"
import_results['errors'].append(error_msg)
self._rollback_partial_batch(batch_id, import_results)
import_results['success'] = False
import_results['traceback'] = traceback.format_exc()
if self.logger:
@@ -955,6 +959,111 @@ class TimeAttendanceImportService:
return import_results
# Excel stores dates as days since 1899-12-30. A column formatted as General
# arrives as that number instead of a date, and pd.to_datetime() then read it
# as nanoseconds since 1970 — every such row imported as 1970-01-01.
_EXCEL_EPOCH = '1899-12-30'
_EXCEL_SERIAL_MIN = 20000 # 1954-10-03
_EXCEL_SERIAL_MAX = 60000 # 2064-04-05
def _parse_date_field(self, date_value) -> date:
"""
Parse the Date column into a date.
Handles:
- real date / datetime / pandas Timestamp cells
- Excel serial numbers (45123 -> 2023-07-04), the General-format case
- text dates, read **month-first** (US time clocks): "03/04/2026" is
4 March 2026. When the first number cannot be a month ("13/04/2026")
it is read day-first instead.
Raises ValueError with a readable message — the caller records the row as
failed instead of importing a wrong date.
"""
if date_value is None or (isinstance(date_value, float) and pd.isna(date_value)):
raise ValueError('Missing date')
# Already a date/datetime (openpyxl / pandas parsed the cell)
if isinstance(date_value, datetime):
return date_value.date()
if isinstance(date_value, date):
return date_value
to_pydatetime = getattr(date_value, 'to_pydatetime', None) # pandas Timestamp
if callable(to_pydatetime):
return to_pydatetime().date()
# Excel serial number, as a number or as text
serial = None
if isinstance(date_value, bool):
raise ValueError(f"'{date_value}' is not a date")
if isinstance(date_value, (int, float)):
serial = float(date_value)
else:
text_value = str(date_value).strip()
if re.fullmatch(r'\d{4,6}(\.\d+)?', text_value):
serial = float(text_value)
if serial is not None:
if not (self._EXCEL_SERIAL_MIN <= serial <= self._EXCEL_SERIAL_MAX):
raise ValueError(f"'{date_value}' is not a valid date (number out of range)")
return pd.to_datetime(serial, unit='D', origin=self._EXCEL_EPOCH).date()
text_value = str(date_value).strip()
if not text_value:
raise ValueError('Missing date')
# Month-first unless the first component cannot be a month
day_first = False
parts = re.match(r'^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})$', text_value)
if parts and int(parts.group(1)) > 12:
day_first = True
parsed = pd.to_datetime(text_value, dayfirst=day_first, errors='coerce')
if pd.isna(parsed):
raise ValueError(f"'{date_value}' is not a recognised date")
return parsed.date()
def _rollback_partial_batch(self, batch_id, import_results=None):
"""
Remove rows already committed for a failed import batch.
The row loop commits every 50 records to keep memory flat, so a failure
part-way through used to leave a partial batch in the table with nothing
to show it was incomplete. An import is all-or-nothing from the user's
point of view, so the batch is deleted and the failure reported.
"""
try:
from models.time_attendance import TimeAttendance
self.db.session.rollback()
removed = TimeAttendance.query.filter_by(import_batch_id=batch_id).delete(
synchronize_session=False)
self.db.session.commit()
if removed and self.logger:
self.logger.logger.warning(
f"Import batch {batch_id} failed — removed {removed} partially imported records"
)
if import_results is not None:
import_results['imported_records'] = 0
import_results['rolled_back_records'] = removed
import_results['errors'].append(
f"Import failed — {removed} partially imported records were removed. "
f"Nothing from this file was kept."
)
return removed
except Exception as cleanup_error:
if self.logger:
self.logger.logger.error(
f"Could not remove partial import batch {batch_id}: {cleanup_error}", exc_info=True
)
if import_results is not None:
import_results['errors'].append(
f"Import failed and the partial batch {batch_id} could not be removed "
f"automatically — delete it from the import history."
)
return 0
def _parse_time_field(self, time_value) -> time:
"""
Parse various time formats from Excel
@@ -1050,30 +1159,48 @@ class TimeAttendanceImportService:
def _clean_employee_id(self, employee_id) -> str:
"""
Clean employee ID to handle various formats
Args:
employee_id: Raw employee ID value
Returns:
Cleaned employee ID string
Normalise an employee ID from an Excel cell into the form the app stores.
1234 / "1234" ............. "1234" (unchanged)
1234.0 (Excel numeric) .... "1234"
"1759.PW", "1759 - PW" .... "1759PW" canonical work-type spelling
"1234.5" .................. "1234.5" NOT an ID — never truncated
"01234" ................... "01234" zero padding preserved
Work-type IDs are stored canonically so the calculator, the exports and
the report filters all read the same spelling (§13). Before this, an
imported "1759.PW" stayed as-is and the exports counted it as a separate
REGULAR employee called "1759.PW" instead of PW hours for employee 1759.
"""
try:
# Convert to string first
id_str = str(employee_id).strip()
# Handle float values like 1234.0 or '1234.0'
if '.' in id_str:
# Convert to float, then to int, then back to string
# This removes the decimal part: 1234.0 -> 1234
id_str = str(int(float(id_str)))
from working_hours_calculator import parse_employee_id_for_work_type
id_str = str(employee_id).strip()
if not id_str:
return id_str
except (ValueError, TypeError) as e:
# If conversion fails, return original string
if self.logger:
self.logger.logger.warning(f"Could not clean employee ID '{employee_id}': {e}")
return str(employee_id).strip()
# Excel hands whole numbers over as floats: 1234.0 -> "1234". A real
# fraction is not an employee ID, so keep it as typed and let validation
# flag it rather than silently importing hours for employee 1234.
if '.' in id_str:
try:
numeric = float(id_str)
except (TypeError, ValueError):
numeric = None
if numeric is not None:
if numeric.is_integer():
return str(int(numeric))
if self.logger:
self.logger.logger.warning(
f"Employee ID '{id_str}' is not a whole number — imported unchanged"
)
return id_str
# "1759.PW" / "1759 - PW" / "PW.1759" -> "1759PW"
base_id, work_type = parse_employee_id_for_work_type(id_str)
if work_type != 'regular' and base_id.isdigit():
return f"{base_id}{work_type}"
return id_str
def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
"""
@@ -1167,8 +1294,8 @@ class TimeAttendanceImportService:
for idx, date_val in df['Date'].items():
if pd.notna(date_val):
try:
pd.to_datetime(date_val)
except:
self._parse_date_field(date_val)
except Exception:
invalid_dates += 1
if invalid_dates > 0: