feat: Enhance time tracking by excluding billed entries from views and approval processes

This commit is contained in:
Christian 2026-01-09 08:01:28 +01:00
parent ccb7714779
commit 19827d03a8
5 changed files with 279 additions and 11 deletions

View File

@ -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_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.customer_id = %s 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] params = [customer_id]

View File

@ -670,17 +670,18 @@ class TimeTrackingVTigerService:
- timelognumber: Unique ID (TL1234) - timelognumber: Unique ID (TL1234)
- duration: Time in seconds - duration: Time in seconds
- relatedto: Reference to Case/Account - relatedto: Reference to Case/Account
- is_billable: '1' = yes, '0' = no - isbillable: '1' = yes, '0' = no
- cf_timelog_invoiced: '1' = has been invoiced - cf_timelog_invoiced: '1' = has been invoiced
- billed_via_thehub_id: Hub order ID (in vTiger custom field)
We only sync entries where: We only sync entries where:
- relatedto is not empty (linked to a Case or Account) - relatedto is not empty (linked to a Case or Account)
- Has valid duration > 0 - Has valid duration > 0
- isbillable = '1' (only billable entries)
NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger, - cf_timelog_invoiced = '0' or NULL (not yet invoiced in vTiger)
so we sync all timelogs and let the approval workflow decide what to bill. - 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} 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") logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger")
# We don't filter here - the existing code already filters by: # Filter timelogs based on requirements:
# 1. duration > 0 # 1. isbillable = '1' (only billable)
# 2. relatedto not empty # 2. cf_timelog_invoiced = '0' or NULL (not invoiced in vTiger)
# These filters happen in the processing loop below # 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...") logger.info(f"📊 Processing {len(timelogs)} timelogs...")
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls) # NOTE: retrieve API is too slow for batch operations (1500+ individual calls)

View File

@ -172,6 +172,13 @@ class WizardService:
detail=f"Time entry already {entry['status']}" 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 # Update entry
logger.info(f"🔄 Updating time entry {approval.time_id} in database") logger.info(f"🔄 Updating time entry {approval.time_id} in database")
update_query = """ update_query = """
@ -185,6 +192,7 @@ class WizardService:
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
approved_by = %s approved_by = %s
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update( execute_update(
@ -265,6 +273,13 @@ class WizardService:
detail=f"Time entry already {entry['status']}" 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 to rejected
update_query = """ update_query = """
UPDATE tmodule_times UPDATE tmodule_times
@ -274,6 +289,7 @@ class WizardService:
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
approved_by = %s approved_by = %s
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update(update_query, (reason, user_id, time_id)) execute_update(update_query, (reason, user_id, time_id))
@ -341,6 +357,13 @@ class WizardService:
detail="Cannot reset billed entries" 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 # Reset to pending - clear all approval data
update_query = """ update_query = """
UPDATE tmodule_times UPDATE tmodule_times
@ -352,6 +375,7 @@ class WizardService:
approved_at = NULL, approved_at = NULL,
approved_by = NULL approved_by = NULL
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update(update_query, (reason, time_id)) execute_update(update_query, (reason, time_id))
@ -490,7 +514,9 @@ class WizardService:
SELECT c.id, c.title SELECT c.id, c.title
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id 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 ORDER BY t.worked_date
LIMIT 1 LIMIT 1
""" """
@ -558,6 +584,13 @@ class WizardService:
AND t.status = 'pending' AND t.status = 'pending'
AND t.billable = true AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' 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 AND cust.uses_time_card = false
ORDER BY t.worked_date, t.id ORDER BY t.worked_date, t.id
""" """
@ -585,6 +618,13 @@ class WizardService:
AND t.status = 'pending' AND t.status = 'pending'
AND t.billable = true AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' 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 ORDER BY t.worked_date, t.id
""" """

View File

@ -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;

View File

@ -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;