diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index abbb11e..22abba8 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -1092,6 +1092,16 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.customer_id = %s + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') """ params = [customer_id] diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py index 3dc2be8..7ef41e4 100644 --- a/app/timetracking/backend/vtiger_sync.py +++ b/app/timetracking/backend/vtiger_sync.py @@ -670,17 +670,18 @@ class TimeTrackingVTigerService: - timelognumber: Unique ID (TL1234) - duration: Time in seconds - relatedto: Reference to Case/Account - - is_billable: '1' = yes, '0' = no + - isbillable: '1' = yes, '0' = no - cf_timelog_invoiced: '1' = has been invoiced + - billed_via_thehub_id: Hub order ID (in vTiger custom field) We only sync entries where: - relatedto is not empty (linked to a Case or Account) - Has valid duration > 0 - - NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger, - so we sync all timelogs and let the approval workflow decide what to bill. + - isbillable = '1' (only billable entries) + - cf_timelog_invoiced = '0' or NULL (not yet invoiced in vTiger) + - billed_via_thehub_id = '0' or NULL (not yet billed via Hub) """ - logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...") + logger.info(f"🔍 Syncing billable, uninvoiced timelogs from vTiger...") stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} @@ -716,12 +717,49 @@ class TimeTrackingVTigerService: logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger") - # We don't filter here - the existing code already filters by: - # 1. duration > 0 - # 2. relatedto not empty - # These filters happen in the processing loop below + # Filter timelogs based on requirements: + # 1. isbillable = '1' (only billable) + # 2. cf_timelog_invoiced = '0' or NULL (not invoiced in vTiger) + # 3. billed_via_thehub_id = '0' or NULL (not billed via Hub) + # 4. cf_timelog_rounduptimespent = '0' or NULL (not manually invoiced in vTiger) + filtered_timelogs = [] + filter_stats = {"not_billable": 0, "already_invoiced": 0, "billed_via_hub": 0, "manually_invoiced": 0} - timelogs = all_timelogs[:limit] # Trim to requested limit + for tl in all_timelogs: + isbillable = tl.get('isbillable', '0') + invoiced = tl.get('cf_timelog_invoiced', '0') or '0' + billed_via_hub = tl.get('billed_via_thehub_id', '0') or '0' + billed_via_hub_vtiger = tl.get('cf_timelog_billedviathehubid', '0') or '0' + roundup_spent = tl.get('cf_timelog_rounduptimespent', '0') or '0' + + # Debug log first 3 entries to see actual values + if len(all_timelogs) <= 3 or len(filtered_timelogs) == 0: + logger.info(f"🔍 DEBUG Timelog {tl.get('id')}: isbillable={isbillable}, cf_timelog_invoiced={invoiced}, billed_via_thehub_id={billed_via_hub}, billed_via_hub_vtiger={billed_via_hub_vtiger}, roundup_spent={roundup_spent}") + + # Track why we skip + if isbillable != '1': + filter_stats["not_billable"] += 1 + continue + if invoiced not in ('0', '', None): + filter_stats["already_invoiced"] += 1 + continue + if billed_via_hub not in ('0', '', None): + filter_stats["billed_via_hub"] += 1 + continue + if billed_via_hub_vtiger not in ('0', '', None): + filter_stats["billed_via_hub"] += 1 + continue + if roundup_spent not in ('0', '', None): + filter_stats["manually_invoiced"] += 1 + continue + + # Only include if billable AND not invoiced AND not billed via Hub AND not manually invoiced + filtered_timelogs.append(tl) + + logger.info(f"🔍 Filtered to {len(filtered_timelogs)} billable, uninvoiced timelogs") + logger.info(f" Skipped: {filter_stats['not_billable']} not billable, {filter_stats['already_invoiced']} already invoiced, {filter_stats['billed_via_hub']} billed via Hub, {filter_stats['manually_invoiced']} manually invoiced") + + timelogs = filtered_timelogs[:limit] # Trim to requested limit logger.info(f"📊 Processing {len(timelogs)} timelogs...") # NOTE: retrieve API is too slow for batch operations (1500+ individual calls) diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py index 00ec49d..82ac49e 100644 --- a/app/timetracking/backend/wizard.py +++ b/app/timetracking/backend/wizard.py @@ -172,6 +172,13 @@ class WizardService: detail=f"Time entry already {entry['status']}" ) + # Check if already billed + if entry.get('billed_via_thehub_id') is not None: + raise HTTPException( + status_code=400, + detail="Cannot approve time entry that has already been billed" + ) + # Update entry logger.info(f"🔄 Updating time entry {approval.time_id} in database") update_query = """ @@ -185,6 +192,7 @@ class WizardService: approved_at = CURRENT_TIMESTAMP, approved_by = %s WHERE id = %s + AND billed_via_thehub_id IS NULL """ execute_update( @@ -265,6 +273,13 @@ class WizardService: detail=f"Time entry already {entry['status']}" ) + # Check if already billed + if entry.get('billed_via_thehub_id') is not None: + raise HTTPException( + status_code=400, + detail="Cannot reject time entry that has already been billed" + ) + # Update to rejected update_query = """ UPDATE tmodule_times @@ -274,6 +289,7 @@ class WizardService: approved_at = CURRENT_TIMESTAMP, approved_by = %s WHERE id = %s + AND billed_via_thehub_id IS NULL """ execute_update(update_query, (reason, user_id, time_id)) @@ -341,6 +357,13 @@ class WizardService: detail="Cannot reset billed entries" ) + # Check if already billed via Hub order + if entry.get('billed_via_thehub_id') is not None: + raise HTTPException( + status_code=400, + detail="Cannot reset time entry that has been billed through Hub order" + ) + # Reset to pending - clear all approval data update_query = """ UPDATE tmodule_times @@ -352,6 +375,7 @@ class WizardService: approved_at = NULL, approved_by = NULL WHERE id = %s + AND billed_via_thehub_id IS NULL """ execute_update(update_query, (reason, time_id)) @@ -490,7 +514,9 @@ class WizardService: SELECT c.id, c.title FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id - WHERE t.customer_id = %s AND t.status = 'pending' + WHERE t.customer_id = %s + AND t.status = 'pending' + AND t.billed_via_thehub_id IS NULL ORDER BY t.worked_date LIMIT 1 """ @@ -558,6 +584,13 @@ class WizardService: AND t.status = 'pending' AND t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') AND cust.uses_time_card = false ORDER BY t.worked_date, t.id """ @@ -585,6 +618,13 @@ class WizardService: AND t.status = 'pending' AND t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') ORDER BY t.worked_date, t.id """ diff --git a/migrations/061_update_timetracking_views_billed_filter.sql b/migrations/061_update_timetracking_views_billed_filter.sql new file mode 100644 index 0000000..040fe29 --- /dev/null +++ b/migrations/061_update_timetracking_views_billed_filter.sql @@ -0,0 +1,41 @@ +-- Migration: Update timetracking views to exclude billed entries +-- Ensures that time entries with billed_via_thehub_id are not shown in pending counts + +-- Drop and recreate approval stats view to exclude billed entries +DROP VIEW IF EXISTS tmodule_approval_stats CASCADE; +CREATE VIEW tmodule_approval_stats AS +SELECT + c.id AS customer_id, + c.name AS customer_name, + c.vtiger_id AS customer_vtiger_id, + c.uses_time_card AS uses_time_card, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS total_entries, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'pending' AND t.billed_via_thehub_id IS NULL) AS pending_count, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.billed_via_thehub_id IS NULL) AS approved_count, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected') AS rejected_count, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed') AS billed_count, + SUM(t.original_hours) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS total_original_hours, + SUM(t.approved_hours) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.billed_via_thehub_id IS NULL) AS total_approved_hours, + MAX(t.worked_date) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS latest_work_date, + MAX(t.last_synced_at) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS last_sync +FROM tmodule_customers c +LEFT JOIN tmodule_times t ON c.id = t.customer_id +GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card; + +-- Drop and recreate next pending view to exclude billed entries +DROP VIEW IF EXISTS tmodule_next_pending CASCADE; +CREATE VIEW tmodule_next_pending AS +SELECT + t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate +FROM tmodule_times t +JOIN tmodule_cases c ON t.case_id = c.id +JOIN tmodule_customers cust ON t.customer_id = cust.id +WHERE t.status = 'pending' + AND t.billable = true + AND t.billed_via_thehub_id IS NULL +ORDER BY cust.name, c.title, t.worked_date; diff --git a/migrations/062_filter_invoiced_in_views.sql b/migrations/062_filter_invoiced_in_views.sql new file mode 100644 index 0000000..636907a --- /dev/null +++ b/migrations/062_filter_invoiced_in_views.sql @@ -0,0 +1,139 @@ +-- Migration: Filter out vTiger-invoiced entries from approval views +-- Ensures that time entries already invoiced in vTiger are not shown in pending/approval flows +-- Also filters entries with cf_timelog_rounduptimespent set (manually invoiced in vTiger) +-- Also filters entries with cf_timelog_billedviathehubid set (billed via Hub in vTiger) + +-- Drop and recreate approval stats view to exclude vTiger-invoiced entries +DROP VIEW IF EXISTS tmodule_approval_stats CASCADE; +CREATE VIEW tmodule_approval_stats AS +SELECT + c.id AS customer_id, + c.name AS customer_name, + c.vtiger_id AS customer_vtiger_id, + c.uses_time_card AS uses_time_card, + COUNT(t.id) FILTER (WHERE + t.billable = true + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS total_entries, + COUNT(t.id) FILTER (WHERE + t.billable = true + AND t.status = 'pending' + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS pending_count, + COUNT(t.id) FILTER (WHERE + t.billable = true + AND t.status = 'approved' + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS approved_count, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected') AS rejected_count, + COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed') AS billed_count, + SUM(t.original_hours) FILTER (WHERE + t.billable = true + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS total_original_hours, + SUM(t.approved_hours) FILTER (WHERE + t.billable = true + AND t.status = 'approved' + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS total_approved_hours, + MAX(t.worked_date) FILTER (WHERE + t.billable = true + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS latest_work_date, + MAX(t.last_synced_at) FILTER (WHERE + t.billable = true + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') + ) AS last_sync +FROM tmodule_customers c +LEFT JOIN tmodule_times t ON c.id = t.customer_id +GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card; + +-- Drop and recreate next pending view to exclude vTiger-invoiced entries +DROP VIEW IF EXISTS tmodule_next_pending CASCADE; +CREATE VIEW tmodule_next_pending AS +SELECT + t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate +FROM tmodule_times t +JOIN tmodule_cases c ON t.case_id = c.id +JOIN tmodule_customers cust ON t.customer_id = cust.id +WHERE t.status = 'pending' + AND t.billable = true + AND t.billed_via_thehub_id IS NULL + AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL + OR t.vtiger_data->>'cf_timelog_invoiced' = '0' + OR t.vtiger_data->>'cf_timelog_invoiced' = '') + AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' + OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') + AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' + OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') +ORDER BY cust.name, c.title, t.worked_date;