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:
2026-03-10 19:59:08 -07:00
parent a1a19f8c00
commit fa15b03180
169 changed files with 879909 additions and 1243 deletions

View File

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