""" Opportunities (Pipeline) Router Hub-local sales pipeline """ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Optional, List from datetime import date import logging from app.core.database import execute_query, execute_query_single, execute_update from app.services.opportunity_service import handle_stage_change logger = logging.getLogger(__name__) router = APIRouter() class PipelineStageBase(BaseModel): name: str description: Optional[str] = None sort_order: int = 0 default_probability: int = 0 color: Optional[str] = "#0f4c75" is_won: bool = False is_lost: bool = False is_active: bool = True class PipelineStageCreate(PipelineStageBase): pass class PipelineStageUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None sort_order: Optional[int] = None default_probability: Optional[int] = None color: Optional[str] = None is_won: Optional[bool] = None is_lost: Optional[bool] = None is_active: Optional[bool] = None class OpportunityBase(BaseModel): customer_id: int title: str description: Optional[str] = None amount: Optional[float] = 0 currency: Optional[str] = "DKK" expected_close_date: Optional[date] = None stage_id: Optional[int] = None owner_user_id: Optional[int] = None class OpportunityCreate(OpportunityBase): pass class OpportunityUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None amount: Optional[float] = None currency: Optional[str] = None expected_close_date: Optional[date] = None stage_id: Optional[int] = None owner_user_id: Optional[int] = None is_active: Optional[bool] = None class OpportunityStageUpdate(BaseModel): stage_id: int note: Optional[str] = None user_id: Optional[int] = None def _get_stage(stage_id: int): stage = execute_query_single( "SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE", (stage_id,) ) if not stage: raise HTTPException(status_code=404, detail="Stage not found") return stage def _get_default_stage(): stage = execute_query_single( "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC LIMIT 1" ) if not stage: raise HTTPException(status_code=400, detail="No active stages configured") return stage def _get_opportunity(opportunity_id: int): query = """ SELECT o.*, c.name AS customer_name, s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost FROM pipeline_opportunities o JOIN customers c ON c.id = o.customer_id JOIN pipeline_stages s ON s.id = o.stage_id WHERE o.id = %s """ opportunity = execute_query_single(query, (opportunity_id,)) if not opportunity: raise HTTPException(status_code=404, detail="Opportunity not found") return opportunity def _insert_stage_history(opportunity_id: int, from_stage_id: Optional[int], to_stage_id: int, user_id: Optional[int] = None, note: Optional[str] = None): execute_query( """ INSERT INTO pipeline_stage_history (opportunity_id, from_stage_id, to_stage_id, changed_by_user_id, note) VALUES (%s, %s, %s, %s, %s) """, (opportunity_id, from_stage_id, to_stage_id, user_id, note) ) # ============================ # Pipeline Stages # ============================ @router.get("/pipeline/stages", tags=["Pipeline Stages"]) async def list_stages(): query = "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC" return execute_query(query) or [] @router.post("/pipeline/stages", tags=["Pipeline Stages"]) async def create_stage(stage: PipelineStageCreate): query = """ INSERT INTO pipeline_stages (name, description, sort_order, default_probability, color, is_won, is_lost, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ result = execute_query(query, ( stage.name, stage.description, stage.sort_order, stage.default_probability, stage.color, stage.is_won, stage.is_lost, stage.is_active )) logger.info("✅ Created pipeline stage: %s", stage.name) return result[0] if result else None @router.put("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"]) async def update_stage(stage_id: int, stage: PipelineStageUpdate): updates = [] params = [] for field, value in stage.dict(exclude_unset=True).items(): updates.append(f"{field} = %s") params.append(value) if not updates: raise HTTPException(status_code=400, detail="No fields to update") params.append(stage_id) query = f"UPDATE pipeline_stages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING *" result = execute_query(query, tuple(params)) if not result: raise HTTPException(status_code=404, detail="Stage not found") logger.info("✅ Updated pipeline stage: %s", stage_id) return result[0] @router.delete("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"]) async def deactivate_stage(stage_id: int): affected = execute_update( "UPDATE pipeline_stages SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (stage_id,) ) if not affected: raise HTTPException(status_code=404, detail="Stage not found") logger.info("⚠️ Deactivated pipeline stage: %s", stage_id) return {"status": "success", "stage_id": stage_id} # ============================ # Opportunities # ============================ @router.get("/opportunities", tags=["Opportunities"]) async def list_opportunities(customer_id: Optional[int] = None, stage_id: Optional[int] = None): query = """ SELECT o.*, c.name AS customer_name, s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost FROM pipeline_opportunities o JOIN customers c ON c.id = o.customer_id JOIN pipeline_stages s ON s.id = o.stage_id WHERE o.is_active = TRUE """ params: List = [] if customer_id is not None: query += " AND o.customer_id = %s" params.append(customer_id) if stage_id is not None: query += " AND o.stage_id = %s" params.append(stage_id) query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC" if params: return execute_query(query, tuple(params)) or [] return execute_query(query) or [] @router.get("/opportunities/{opportunity_id}", tags=["Opportunities"]) async def get_opportunity(opportunity_id: int): return _get_opportunity(opportunity_id) @router.post("/opportunities", tags=["Opportunities"]) async def create_opportunity(opportunity: OpportunityCreate): stage = _get_stage(opportunity.stage_id) if opportunity.stage_id else _get_default_stage() probability = stage["default_probability"] query = """ INSERT INTO pipeline_opportunities (customer_id, title, description, amount, currency, expected_close_date, stage_id, probability, owner_user_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """ result = execute_query(query, ( opportunity.customer_id, opportunity.title, opportunity.description, opportunity.amount or 0, opportunity.currency or "DKK", opportunity.expected_close_date, stage["id"], probability, opportunity.owner_user_id )) if not result: raise HTTPException(status_code=500, detail="Failed to create opportunity") opportunity_id = result[0]["id"] _insert_stage_history(opportunity_id, None, stage["id"], opportunity.owner_user_id, "Oprettet") logger.info("✅ Created opportunity %s", opportunity_id) return _get_opportunity(opportunity_id) @router.put("/opportunities/{opportunity_id}", tags=["Opportunities"]) async def update_opportunity(opportunity_id: int, update: OpportunityUpdate): existing = _get_opportunity(opportunity_id) updates = [] params = [] update_dict = update.dict(exclude_unset=True) stage_changed = False new_stage = None if "stage_id" in update_dict: new_stage = _get_stage(update_dict["stage_id"]) update_dict["probability"] = new_stage["default_probability"] stage_changed = new_stage["id"] != existing["stage_id"] for field, value in update_dict.items(): updates.append(f"{field} = %s") params.append(value) if not updates: raise HTTPException(status_code=400, detail="No fields to update") params.append(opportunity_id) query = f"UPDATE pipeline_opportunities SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" execute_update(query, tuple(params)) if stage_changed and new_stage: _insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.owner_user_id, "Stage ændret") updated = _get_opportunity(opportunity_id) handle_stage_change(updated, new_stage) return _get_opportunity(opportunity_id) @router.patch("/opportunities/{opportunity_id}/stage", tags=["Opportunities"]) async def update_opportunity_stage(opportunity_id: int, update: OpportunityStageUpdate): existing = _get_opportunity(opportunity_id) new_stage = _get_stage(update.stage_id) execute_update( """ UPDATE pipeline_opportunities SET stage_id = %s, probability = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (new_stage["id"], new_stage["default_probability"], opportunity_id) ) _insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.user_id, update.note) updated = _get_opportunity(opportunity_id) handle_stage_change(updated, new_stage) return updated