Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/api/routers/breeze_buddy/merchants/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ async def create_merchant_handler(
merchant_data.is_active if merchant_data.is_active is not None else True
),
reseller_id=reseller_id,
pickup_rate_alert_enabled=merchant_data.pickup_rate_alert_enabled or False,
pickup_rate_alert_threshold=merchant_data.pickup_rate_alert_threshold,
)

if not merchant:
Expand Down Expand Up @@ -235,12 +237,22 @@ async def update_merchant_handler(
)
reseller_id = merchant_data.reseller_id

# Detect if pickup_rate_alert_threshold was explicitly set to null
# (vs simply not included in the request body).
clear_threshold = (
"pickup_rate_alert_threshold" in merchant_data.model_fields_set
and merchant_data.pickup_rate_alert_threshold is None
)

updated_merchant = await merchant_accessors.update_merchant(
merchant_id=merchant_id,
name=merchant_data.name,
description=merchant_data.description,
is_active=merchant_data.is_active,
reseller_id=reseller_id,
pickup_rate_alert_enabled=merchant_data.pickup_rate_alert_enabled,
pickup_rate_alert_threshold=merchant_data.pickup_rate_alert_threshold,
clear_pickup_rate_alert_threshold=clear_threshold,
)

if not updated_merchant:
Expand Down
24 changes: 24 additions & 0 deletions app/core/config/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ async def ENABLE_BACKGROUND_TASKS() -> bool:
return await get_config("ENABLE_BACKGROUND_TASKS", "false", bool)


# --- Pickup Rate Alert Configuration ---
async def ENABLE_PICKUP_RATE_ALERT() -> bool:
"""Returns ENABLE_PICKUP_RATE_ALERT from Redis"""
return await get_config("ENABLE_PICKUP_RATE_ALERT", "false", bool)


async def PICKUP_RATE_ALERT_INTERVAL_SECONDS() -> int:
"""Returns PICKUP_RATE_ALERT_INTERVAL_SECONDS from Redis (default: 86400 = 24 h).

Minimum enforced value is 60 seconds to prevent runaway task loops.
"""
value = await get_config("PICKUP_RATE_ALERT_INTERVAL_SECONDS", 86400, int)
return max(60, value)


async def PICKUP_RATE_ALERT_THRESHOLD() -> float:
"""Returns PICKUP_RATE_ALERT_THRESHOLD from Redis (default: 40.0 %).

Clamped to [0, 100] to guard against misconfiguration.
"""
value = await get_config("PICKUP_RATE_ALERT_THRESHOLD", 40.0, float)
return max(0.0, min(100.0, value))


async def DAILY_SUMMARY_HOUR() -> int:
"""Returns DAILY_SUMMARY_HOUR from Redis (24-hour format: 0-23)"""
return await get_config("DAILY_SUMMARY_HOUR", 21, int)
Expand Down
28 changes: 28 additions & 0 deletions app/database/accessor/breeze_buddy/lead_call_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
abort_lead_by_id_query,
acquire_lock_on_lead_by_id_query,
get_all_lead_call_trackers_query,
get_call_based_pickup_rate_query,
get_lead_based_analytics_query,
get_lead_by_call_id_query,
get_lead_by_id_query,
Expand Down Expand Up @@ -426,6 +427,7 @@ async def get_all_lead_call_trackers(
shop_name: Optional[str] = None,
page: Optional[int] = None,
page_size: Optional[int] = None,
merchant_id: Optional[str] = None,
) -> List[Tuple[LeadCallTracker, Optional[str]]]:
"""
Get all lead call trackers with optional filters and pagination.
Expand All @@ -444,6 +446,7 @@ async def get_all_lead_call_trackers(
shop_name=shop_name,
limit=limit,
offset=offset,
merchant_id=merchant_id,
)
result = await run_parameterized_query(query_text, values)
if result:
Expand Down Expand Up @@ -513,9 +516,33 @@ async def get_lead_call_trackers_count(
return 0


async def get_call_based_pickup_rate(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
merchant_id: Optional[str] = None,
) -> Tuple[int, int]:
"""Get call-based pickup rate as a single aggregated SQL result.

Returns:
Tuple of (calls_attempted, calls_no_answer) — both ints.
Raises on DB error so the caller can handle failure explicitly.
"""
query_text, values = get_call_based_pickup_rate_query(
start_date=start_date,
end_date=end_date,
merchant_id=merchant_id,
)
result = await run_parameterized_query(query_text, values)
if result:
row = result[0]
return int(row["calls_attempted"]), int(row["calls_no_answer"])
return 0, 0


async def get_lead_based_analytics(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
merchant_id: Optional[str] = None,
) -> List[asyncpg.Record]:
"""
Get per-lead call data for analytics.
Expand All @@ -527,6 +554,7 @@ async def get_lead_based_analytics(
query_text, values = get_lead_based_analytics_query(
start_date=start_date,
end_date=end_date,
merchant_id=merchant_id,
)
result = await run_parameterized_query(query_text, values)
return result if result else []
Expand Down
35 changes: 35 additions & 0 deletions app/database/accessor/breeze_buddy/merchants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_merchant_by_merchant_identifier_query,
get_merchants_by_ids_query,
get_merchants_by_reseller_query,
get_merchants_with_pickup_rate_alert_enabled_query,
update_merchant_query,
)
from app.schemas.breeze_buddy.merchants import MerchantResponse
Expand Down Expand Up @@ -49,6 +50,8 @@ async def create_merchant(
name: Optional[str] = None,
description: Optional[str] = None,
is_active: bool = True,
pickup_rate_alert_enabled: bool = False,
pickup_rate_alert_threshold: Optional[float] = None,
) -> Optional[MerchantResponse]:
"""Create a new merchant entity (business entity).

Expand All @@ -58,6 +61,8 @@ async def create_merchant(
name: Optional display name
description: Optional description
is_active: Active status (default: true)
pickup_rate_alert_enabled: Enable per-merchant pickup rate alerting
pickup_rate_alert_threshold: Alert threshold %; None falls back to global config

Returns:
MerchantResponse if successful, None otherwise
Expand All @@ -68,6 +73,8 @@ async def create_merchant(
name=name,
description=description,
is_active=is_active,
pickup_rate_alert_enabled=pickup_rate_alert_enabled,
pickup_rate_alert_threshold=pickup_rate_alert_threshold,
)

try:
Expand Down Expand Up @@ -258,6 +265,9 @@ async def update_merchant(
description: Optional[str] = None,
is_active: Optional[bool] = None,
reseller_id: Optional[str] = None,
pickup_rate_alert_enabled: Optional[bool] = None,
pickup_rate_alert_threshold: Optional[float] = None,
clear_pickup_rate_alert_threshold: bool = False,
) -> Optional[MerchantResponse]:
"""Update merchant entity (merchant_id cannot be changed).

Expand All @@ -267,6 +277,11 @@ async def update_merchant(
description: New description
is_active: New active status
reseller_id: New reseller owner ID
pickup_rate_alert_enabled: Toggle per-merchant pickup rate alerting
pickup_rate_alert_threshold: New alert threshold %; None leaves unchanged
clear_pickup_rate_alert_threshold: Set to True to explicitly clear the
threshold to NULL (fall back to global config). Takes precedence over
pickup_rate_alert_threshold.

Returns:
MerchantResponse if successful, None if not found
Expand All @@ -277,6 +292,9 @@ async def update_merchant(
description=description,
is_active=is_active,
reseller_id=reseller_id,
pickup_rate_alert_enabled=pickup_rate_alert_enabled,
pickup_rate_alert_threshold=pickup_rate_alert_threshold,
clear_pickup_rate_alert_threshold=clear_pickup_rate_alert_threshold,
)

if not values:
Expand Down Expand Up @@ -338,3 +356,20 @@ async def delete_merchant(merchant_id: str) -> bool:
except Exception as e:
logger.error(f"Error deleting merchant entity {merchant_id}: {e}")
raise


async def get_merchants_with_pickup_rate_alert_enabled() -> List[MerchantResponse]:
"""Get all active merchants that have pickup rate alerting enabled.

Returns:
List of MerchantResponse for merchants where pickup_rate_alert_enabled = true
and is_active = true.
"""
query, values = get_merchants_with_pickup_rate_alert_enabled_query()

try:
rows = await run_parameterized_query(query, values)
return [decode_merchant(row) for row in rows] if rows else []
except Exception as e:
logger.error(f"Error fetching merchants with pickup rate alert enabled: {e}")
raise
2 changes: 2 additions & 0 deletions app/database/decoder/breeze_buddy/merchants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def decode_merchant(row) -> MerchantResponse:
description=row.get("description"),
is_active=row.get("is_active", True),
reseller_id=str(row["reseller_id"]) if row.get("reseller_id") else None,
pickup_rate_alert_enabled=row.get("pickup_rate_alert_enabled", False),
pickup_rate_alert_threshold=row.get("pickup_rate_alert_threshold"),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
14 changes: 14 additions & 0 deletions app/database/migrations/025_add_pickup_rate_alert_to_merchants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Migration 025: Add per-merchant pickup rate alert configuration
-- Extends merchants table with opt-in alerting columns.
-- NULL threshold means "use global PICKUP_RATE_ALERT_THRESHOLD dynamic config".

BEGIN;

ALTER TABLE merchants
ADD COLUMN IF NOT EXISTS pickup_rate_alert_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS pickup_rate_alert_threshold FLOAT DEFAULT NULL;

CREATE INDEX IF NOT EXISTS idx_merchants_pickup_rate_alert_enabled
ON merchants(pickup_rate_alert_enabled);

COMMIT;
50 changes: 50 additions & 0 deletions app/database/queries/breeze_buddy/lead_call_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def get_all_lead_call_trackers_query(
shop_name: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
merchant_id: Optional[str] = None,
) -> Tuple[str, List[Any]]:
"""
Generate query to get all lead call trackers within a date range with optional filters and pagination.
Expand Down Expand Up @@ -374,6 +375,10 @@ def get_all_lead_call_trackers_query(
values.append(f"%{shop_name}%")
conditions.append(f"payload->>'shop_name' LIKE ${len(values)}")

if merchant_id:
values.append(merchant_id)
conditions.append(f'lct."merchant_id" = ${len(values)}')

if conditions:
text += " WHERE " + " AND ".join(conditions)

Expand Down Expand Up @@ -451,9 +456,50 @@ def get_lead_call_trackers_count_query(
return text, values


def get_call_based_pickup_rate_query(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
merchant_id: Optional[str] = None,
) -> Tuple[str, List[Any]]:
"""
Generate query to aggregate call-based pickup rate metrics in a single SQL pass.

Returns one row with:
calls_attempted - COUNT of FINISHED calls in window
calls_no_answer - COUNT of FINISHED calls with NO_ANSWER outcome
"""
values: List[Any] = []
conditions = []

if start_date:
values.append(start_date)
conditions.append(f'"call_initiated_time" >= ${len(values)}')

if end_date:
values.append(end_date)
conditions.append(f'"call_initiated_time" < ${len(values)}')

if merchant_id:
values.append(merchant_id)
conditions.append(f'"merchant_id" = ${len(values)}')

where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""

text = f"""
SELECT
COUNT(*) FILTER (WHERE status = 'FINISHED') AS calls_attempted,
COUNT(*) FILTER (WHERE status = 'FINISHED' AND outcome = 'NO_ANSWER')
AS calls_no_answer
FROM "{LEAD_CALL_TRACKER_TABLE}"
{where_clause};
"""
return text, values


def get_lead_based_analytics_query(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
merchant_id: Optional[str] = None,
) -> Tuple[str, List[Any]]:
"""
Generate query to get per-lead call data.
Expand All @@ -470,6 +516,10 @@ def get_lead_based_analytics_query(
values.append(end_date)
conditions.append(f'"call_initiated_time" < ${len(values)}')

if merchant_id:
values.append(merchant_id)
conditions.append(f'"merchant_id" = ${len(values)}')

where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
Comment on lines +519 to 523
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In get_lead_based_analytics_query, the outcome aggregates (e.g. no_answer_calls) are currently counted without restricting to status = 'FINISHED', while finished_calls is restricted. This makes the downstream lead pickup evaluation (finished_calls > no_answer_calls) inconsistent with the ScoreMonitor logic (which only counts outcomes for FINISHED calls) and can misreport pickup rates if non-finished rows have outcomes set. Consider adding status = 'FINISHED' to the FILTER conditions for outcome counts (at least NO_ANSWER, ideally all outcomes) so they’re comparable to finished_calls.

Copilot uses AI. Check for mistakes.

text = f"""
Expand Down
Loading
Loading