sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,8 @@ This module handles all database operations for quotes, providing a clean
|
||||
separation between the API routes and data access layer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -50,15 +50,15 @@ def generate_access_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
|
||||
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal]:
|
||||
"""
|
||||
Calculate monthly, setup, and annual totals from quote items.
|
||||
Calculate monthly and setup totals from quote items.
|
||||
|
||||
Args:
|
||||
items: List of QuoteItem objects
|
||||
|
||||
Returns:
|
||||
tuple: (monthly_total, setup_total, annual_total)
|
||||
tuple: (monthly_total, setup_total)
|
||||
"""
|
||||
monthly_total = Decimal("0.00")
|
||||
setup_total = Decimal("0.00")
|
||||
@@ -70,29 +70,23 @@ def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]
|
||||
# Add to appropriate total based on billing frequency
|
||||
if item.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||
monthly_total += line_total
|
||||
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
|
||||
monthly_total += line_total / Decimal("3")
|
||||
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
|
||||
elif item.billing_frequency == BillingFrequency.YEARLY.value:
|
||||
monthly_total += line_total / Decimal("12")
|
||||
# one_time items don't add to monthly
|
||||
|
||||
# Setup fees are always one-time
|
||||
setup_total += item.setup_fee
|
||||
# Setup prices are always one-time
|
||||
setup_total += item.setup_price
|
||||
|
||||
# Annual total is monthly * 12 + setup
|
||||
annual_total = (monthly_total * Decimal("12")) + setup_total
|
||||
|
||||
return monthly_total, setup_total, annual_total
|
||||
return monthly_total, setup_total
|
||||
|
||||
|
||||
def log_activity(
|
||||
db: Session,
|
||||
quote_id: str,
|
||||
action: str,
|
||||
description: Optional[str] = None,
|
||||
actor: Optional[str] = None,
|
||||
details: Optional[str] = None,
|
||||
step_name: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> QuoteActivity:
|
||||
"""
|
||||
Log an activity for a quote.
|
||||
@@ -101,21 +95,23 @@ def log_activity(
|
||||
db: Database session
|
||||
quote_id: UUID of the quote
|
||||
action: Action being performed
|
||||
description: Detailed description
|
||||
actor: Who performed the action
|
||||
details: Additional details about the action (stored as JSON)
|
||||
step_name: Wizard step name associated with the action
|
||||
ip_address: IP address of the actor
|
||||
metadata: Additional metadata as dict
|
||||
|
||||
Returns:
|
||||
QuoteActivity: The created activity record
|
||||
"""
|
||||
import json
|
||||
# DB column has CHECK (json_valid(details)), so wrap in JSON
|
||||
details_json = json.dumps({"message": details}) if details else None
|
||||
|
||||
activity = QuoteActivity(
|
||||
quote_id=quote_id,
|
||||
action=action,
|
||||
description=description,
|
||||
actor=actor,
|
||||
step_name=step_name,
|
||||
details=details_json,
|
||||
ip_address=ip_address,
|
||||
metadata=json.dumps(metadata) if metadata else None
|
||||
)
|
||||
|
||||
db.add(activity)
|
||||
@@ -155,7 +151,6 @@ def create_quote(
|
||||
access_token=generate_access_token(),
|
||||
status=QuoteStatus.DRAFT.value,
|
||||
employee_count=quote_data.employee_count,
|
||||
notes=quote_data.notes,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
# Set expiration to 30 days from now
|
||||
@@ -170,34 +165,33 @@ def create_quote(
|
||||
for idx, item_data in enumerate(quote_data.items):
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
product_code=item_data.product_code,
|
||||
product_name=item_data.product_name,
|
||||
description=item_data.description,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||
unit_price=item_data.unit_price,
|
||||
setup_price=item_data.setup_price,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
tier=item_data.tier,
|
||||
is_recommended=item_data.is_recommended,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.flush()
|
||||
|
||||
# Calculate and update totals
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
monthly, setup = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="created",
|
||||
description="Quote draft created",
|
||||
details=f"Quote draft created, employee_count={quote_data.employee_count}",
|
||||
ip_address=ip_address,
|
||||
metadata={"employee_count": quote_data.employee_count}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
@@ -344,15 +338,16 @@ def update_quote(
|
||||
for idx, item_data in enumerate(quote_data.items):
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
product_code=item_data.product_code,
|
||||
product_name=item_data.product_name,
|
||||
description=item_data.description,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||
unit_price=item_data.unit_price,
|
||||
setup_price=item_data.setup_price,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
tier=item_data.tier,
|
||||
is_recommended=item_data.is_recommended,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
@@ -362,10 +357,9 @@ def update_quote(
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
monthly, setup = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
if changes:
|
||||
@@ -373,7 +367,7 @@ def update_quote(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="updated",
|
||||
description=f"Quote updated: {', '.join(changes)}",
|
||||
details=f"Quote updated: {', '.join(changes)}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
@@ -438,8 +432,6 @@ def submit_quote(
|
||||
quote.contact_name = submit_data.contact_name
|
||||
quote.contact_email = submit_data.contact_email
|
||||
quote.contact_phone = submit_data.contact_phone
|
||||
if submit_data.notes:
|
||||
quote.notes = submit_data.notes
|
||||
|
||||
# Update status and timestamp
|
||||
quote.status = QuoteStatus.SUBMITTED.value
|
||||
@@ -453,24 +445,17 @@ def submit_quote(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="submitted",
|
||||
description=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email})",
|
||||
actor=submit_data.contact_email,
|
||||
details=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email}), company={submit_data.company_name}, monthly=${quote.monthly_total}, setup=${quote.setup_total}",
|
||||
ip_address=ip_address,
|
||||
metadata={
|
||||
"company_name": submit_data.company_name,
|
||||
"contact_email": submit_data.contact_email,
|
||||
"monthly_total": str(quote.monthly_total),
|
||||
"setup_total": str(quote.setup_total)
|
||||
}
|
||||
)
|
||||
|
||||
# Create admin notification record (actual sending would be handled elsewhere)
|
||||
notification = QuoteNotification(
|
||||
quote_id=quote.id,
|
||||
notification_type="admin_alert",
|
||||
recipient="admin@example.com", # Would come from config in production
|
||||
notification_type="email",
|
||||
recipient=os.environ.get("ADMIN_NOTIFICATION_EMAIL", "mike@azcomputerguru.com"),
|
||||
subject=f"New Quote Submission: {submit_data.company_name}",
|
||||
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
|
||||
body=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
|
||||
status="pending"
|
||||
)
|
||||
db.add(notification)
|
||||
@@ -478,6 +463,10 @@ def submit_quote(
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
|
||||
# Syncro sync is handled via the admin endpoint POST /{quote_id}/sync-syncro
|
||||
# or can be triggered manually after submission. Not run inline to avoid
|
||||
# async/sync mixing and DB session lifecycle issues.
|
||||
|
||||
return quote
|
||||
|
||||
except HTTPException:
|
||||
@@ -556,11 +545,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_customer_found",
|
||||
description=f"Existing Syncro customer found: {customer_check.customer_name}",
|
||||
metadata={
|
||||
"syncro_customer_id": customer_check.customer_id,
|
||||
"match_type": customer_check.match_type
|
||||
}
|
||||
details=f"Existing Syncro customer found: {customer_check.customer_name} (ID: {customer_check.customer_id}, match: {customer_check.match_type})",
|
||||
)
|
||||
|
||||
# Create lead in Syncro
|
||||
@@ -577,11 +562,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_lead_created",
|
||||
description=f"Lead created in Syncro: {lead_result.lead_id}",
|
||||
metadata={
|
||||
"syncro_lead_id": lead_result.lead_id,
|
||||
"is_existing_customer": customer_check.exists
|
||||
}
|
||||
details=f"Lead created in Syncro: {lead_result.lead_id}, is_existing_customer={customer_check.exists}",
|
||||
)
|
||||
else:
|
||||
result["error"] = lead_result.error
|
||||
@@ -594,8 +575,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_sync_failed",
|
||||
description=f"Failed to sync to Syncro: {lead_result.error}",
|
||||
metadata={"error": lead_result.error}
|
||||
details=f"Failed to sync to Syncro: {lead_result.error}",
|
||||
)
|
||||
|
||||
# Commit the updates to quote
|
||||
@@ -616,8 +596,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="syncro_sync_error",
|
||||
description=f"Syncro sync error: {error_msg}",
|
||||
metadata={"error": error_msg}
|
||||
details=f"Syncro sync error: {error_msg}",
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
@@ -682,7 +661,7 @@ def update_quote_status(
|
||||
admin_user: str
|
||||
) -> Quote:
|
||||
"""
|
||||
Update quote status and admin notes (admin).
|
||||
Update quote status and expiration (admin).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -703,10 +682,6 @@ def update_quote_status(
|
||||
quote.status = update_data.status.value
|
||||
changes.append(f"status: {old_status} -> {update_data.status.value}")
|
||||
|
||||
if update_data.admin_notes is not None:
|
||||
quote.admin_notes = update_data.admin_notes
|
||||
changes.append("admin_notes updated")
|
||||
|
||||
if update_data.expires_at is not None:
|
||||
quote.expires_at = update_data.expires_at
|
||||
changes.append(f"expires_at: {update_data.expires_at}")
|
||||
@@ -717,8 +692,7 @@ def update_quote_status(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="admin_update",
|
||||
description=f"Admin update: {', '.join(changes)}",
|
||||
actor=admin_user
|
||||
details=f"Admin update by {admin_user}: {', '.join(changes)}",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
@@ -758,8 +732,9 @@ def get_quote_stats(db: Session) -> QuoteStatsResponse:
|
||||
# Total values for submitted quotes
|
||||
submitted_statuses = [
|
||||
QuoteStatus.SUBMITTED.value,
|
||||
QuoteStatus.REVIEWING.value,
|
||||
QuoteStatus.APPROVED.value
|
||||
QuoteStatus.VIEWED.value,
|
||||
QuoteStatus.FOLLOWED_UP.value,
|
||||
QuoteStatus.CONVERTED.value,
|
||||
]
|
||||
value_query = (
|
||||
db.query(
|
||||
@@ -849,41 +824,34 @@ def add_item_to_quote(
|
||||
)
|
||||
|
||||
try:
|
||||
# Get next sort order
|
||||
max_order = (
|
||||
db.query(func.max(QuoteItem.sort_order))
|
||||
.filter(QuoteItem.quote_id == quote.id)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
service_name=item_data.service_name,
|
||||
service_description=item_data.service_description,
|
||||
category=item_data.category.value,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
unit_price=item_data.unit_price,
|
||||
product_code=item_data.product_code,
|
||||
product_name=item_data.product_name,
|
||||
description=item_data.description,
|
||||
quantity=item_data.quantity,
|
||||
setup_fee=item_data.setup_fee,
|
||||
is_required=item_data.is_required,
|
||||
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
|
||||
unit_price=item_data.unit_price,
|
||||
setup_price=item_data.setup_price,
|
||||
billing_frequency=item_data.billing_frequency.value,
|
||||
tier=item_data.tier,
|
||||
is_recommended=item_data.is_recommended,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
monthly, setup = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="item_added",
|
||||
description=f"Added item: {item_data.service_name}",
|
||||
details=f"Added item: {item_data.product_name}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
@@ -942,30 +910,23 @@ def remove_item_from_quote(
|
||||
detail=f"Item with ID {item_id} not found in this quote"
|
||||
)
|
||||
|
||||
if item.is_required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove required items from the quote"
|
||||
)
|
||||
|
||||
try:
|
||||
item_name = item.service_name
|
||||
item_name = item.product_name
|
||||
db.delete(item)
|
||||
db.flush()
|
||||
|
||||
# Recalculate totals
|
||||
db.refresh(quote)
|
||||
monthly, setup, annual = calculate_totals(quote.items)
|
||||
monthly, setup = calculate_totals(quote.items)
|
||||
quote.monthly_total = monthly
|
||||
quote.setup_total = setup
|
||||
quote.annual_total = annual
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
db=db,
|
||||
quote_id=quote.id,
|
||||
action="item_removed",
|
||||
description=f"Removed item: {item_name}",
|
||||
details=f"Removed item: {item_name}",
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user