"""Phase 6.2 — Enterprise Sales Process Engine tests.""" from __future__ import annotations from datetime import date from decimal import Decimal from uuid import uuid4 import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.database import engine from app.events.publisher import get_event_publisher from app.events.types import CrmEventType from app.models.sales_engine import ( OpportunityProduct, SalesForecast, SalesPlaybook, SalesStageHistory, ) from app.models.types import ( ContactRoleType, ForecastKind, ForecastPeriod, GoalMetric, GoalScope, PlaybookStepKind, TargetPeriod, WinLossOutcome, ) from app.schemas.sales_engine import ( ForecastComputeRequest, GoalCreate, OpportunityCompetitorCreate, OpportunityContactCreate, OpportunityCreate, OpportunityLoseRequest, OpportunityProductCreate, OpportunityStageChangeRequest, OpportunityUpdate, OpportunityWinRequest, PipelineCreate, PipelineStageCreate, PlaybookAssignRequest, PlaybookCompleteStepRequest, PlaybookCreate, PlaybookStepCreate, TargetCreate, WinLossCategoryCreate, WinLossReasonCreate, ) from app.services.opportunity_service import OpportunityService, PipelineService from app.services.sales_engine_services import ( ForecastService, GoalService, OpportunityExtrasService, PlaybookService, TargetService, WinLossService, ) from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers from app.validators.sales_engine import ( validate_lost_requires_reason, validate_stage_flags, ) from shared.exceptions import AppError def _session_factory(): return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async def _seed_pipeline(session, tenant_id): ps = PipelineService(session) pipe = await ps.create( tenant_id, PipelineCreate( name="Enterprise", code=f"ENT-{uuid4().hex[:6]}", is_default=True ), ) start = await ps.create_stage( tenant_id, PipelineStageCreate( pipeline_id=pipe.id, name="Qualify", code="START", position=0, win_probability=10, is_start_stage=True, color="#00AA00", ), ) mid = await ps.create_stage( tenant_id, PipelineStageCreate( pipeline_id=pipe.id, name="Negotiate", code="MID", position=1, win_probability=50, ), ) won = await ps.create_stage( tenant_id, PipelineStageCreate( pipeline_id=pipe.id, name="Closed Won", code="WON", position=2, win_probability=100, is_won_stage=True, is_closed_stage=True, ), ) lost = await ps.create_stage( tenant_id, PipelineStageCreate( pipeline_id=pipe.id, name="Closed Lost", code="LOST", position=3, win_probability=0, is_lost_stage=True, is_closed_stage=True, ), ) return pipe, start, mid, won, lost @pytest.mark.asyncio async def test_pipeline_stage_rules_and_opportunity_number(db_setup): Session = _session_factory() async with Session() as session: pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A) ps = PipelineService(session) with pytest.raises(AppError) as exc: await ps.create_stage( TENANT_A, PipelineStageCreate( pipeline_id=pipe.id, name="Another Start", code="START2", is_start_stage=True, ), ) assert exc.value.error_code == "duplicate_start_stage" opp = await OpportunityService(session).create( TENANT_A, OpportunityCreate( name="Deal A", pipeline_id=pipe.id, stage_id=start.id, amount=Decimal("1000"), expected_close_date=date(2026, 8, 1), ), ) assert opp.opportunity_number.startswith("OPP-") assert opp.title == "Deal A" assert opp.probability == 10 @pytest.mark.asyncio async def test_stage_change_history_and_immutable_won(db_setup): Session = _session_factory() async with Session() as session: pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A) svc = OpportunityService(session) opp = await svc.create( TENANT_A, OpportunityCreate( name="Deal B", pipeline_id=pipe.id, stage_id=start.id, amount=Decimal("5000"), ), ) moved = await svc.change_stage( TENANT_A, opp.id, OpportunityStageChangeRequest(stage_id=mid.id, reason="progress"), ) assert moved.stage_id == mid.id assert moved.probability == 50 result = await session.execute( select(SalesStageHistory).where(SalesStageHistory.opportunity_id == opp.id) ) assert len(list(result.scalars().all())) >= 2 await svc.mark_won( TENANT_A, opp.id, OpportunityWinRequest(custom_reason="best price") ) with pytest.raises(AppError) as exc: await svc.update( TENANT_A, opp.id, OpportunityUpdate(name="nope") ) assert exc.value.error_code == "opportunity_immutable" @pytest.mark.asyncio async def test_lost_requires_reason_and_win_loss(db_setup): Session = _session_factory() async with Session() as session: pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A) svc = OpportunityService(session) opp = await svc.create( TENANT_A, OpportunityCreate( name="Deal C", pipeline_id=pipe.id, stage_id=start.id, amount=Decimal("200"), ), ) wl = WinLossService(session) cat = await wl.create_category( TENANT_A, WinLossCategoryCreate(code="PRICE", name="Price") ) reason = await wl.create_reason( TENANT_A, WinLossReasonCreate( code="TOO_HIGH", name="Too expensive", outcome=WinLossOutcome.LOST, category_id=cat.id, ), ) with pytest.raises(AppError): validate_lost_requires_reason(None, None) lost_opp = await svc.mark_lost( TENANT_A, opp.id, OpportunityLoseRequest(reason_id=reason.id, price_factor=True), ) assert lost_opp.status.value == "lost" events = get_event_publisher().published assert any(e.event_type == CrmEventType.OPPORTUNITY_LOST.value for e in events) @pytest.mark.asyncio async def test_playbook_forecast_goals_products(db_setup): Session = _session_factory() async with Session() as session: pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A) opp = await OpportunityService(session).create( TENANT_A, OpportunityCreate( name="Deal D", pipeline_id=pipe.id, stage_id=mid.id, amount=Decimal("10000"), expected_revenue=Decimal("10000"), probability=80, expected_close_date=date(2026, 9, 15), ), ) pb = PlaybookService(session) book = await pb.create( TENANT_A, PlaybookCreate( name="Enterprise Playbook", code="ENT-PB", version=1, pipeline_id=pipe.id, ), ) step = await pb.add_step( TENANT_A, PlaybookStepCreate( playbook_id=book.id, title="Discovery", step_kind=PlaybookStepKind.REQUIRED_ACTIVITY, is_required=True, checklist_items=["agenda", "stakeholders"], ), ) assignment = await pb.assign( TENANT_A, PlaybookAssignRequest(playbook_id=book.id, opportunity_id=opp.id), ) done = await pb.complete_step( TENANT_A, assignment.id, PlaybookCompleteStepRequest(step_id=step.id), ) assert done.completion_percent == 100 assert done.status.value == "completed" assert any( e.event_type == CrmEventType.PLAYBOOK_COMPLETED.value for e in get_event_publisher().published ) extras = OpportunityExtrasService(session) product = await extras.add_product( TENANT_A, OpportunityProductCreate( opportunity_id=opp.id, product_ref_id="prod-sku-1", quantity=Decimal("2"), unit_price=Decimal("100"), discount_percent=Decimal("10"), ), ) assert product.expected_revenue == Decimal("180") await extras.add_contact( TENANT_A, OpportunityContactCreate( opportunity_id=opp.id, contact_id=uuid4(), role=ContactRoleType.DECISION_MAKER, is_primary=True, ), ) await extras.add_competitor( TENANT_A, OpportunityCompetitorCreate( opportunity_id=opp.id, competitor_name="RivalCo", strength="price", weakness="support", win_probability=30, ), ) forecast = await ForecastService(session).compute( TENANT_A, ForecastComputeRequest( name="Q3 Weighted", kind=ForecastKind.WEIGHTED, period=ForecastPeriod.QUARTERLY, period_start=date(2026, 7, 1), period_end=date(2026, 9, 30), pipeline_id=pipe.id, ), ) assert forecast.opportunity_count >= 1 assert forecast.weighted_amount > 0 assert forecast.committed_amount > 0 goal = await GoalService(session).create( TENANT_A, GoalCreate( name="Team Revenue", scope=GoalScope.TEAM, metric=GoalMetric.REVENUE, team_key="sales-ent", target_value=Decimal("100000"), period_start=date(2026, 1, 1), period_end=date(2026, 12, 31), ), ) target = await TargetService(session).create( TENANT_A, TargetCreate( name="Q3 Target", period=TargetPeriod.QUARTERLY, period_start=date(2026, 7, 1), period_end=date(2026, 9, 30), goal_id=goal.id, target_value=Decimal("25000"), actual_value=Decimal("12500"), ), ) assert target.achievement_percent == Decimal("50.0000") @pytest.mark.asyncio async def test_sales_engine_api_flow(client): pipeline = await client.post( "/api/v1/pipelines", json={"name": "Retail", "code": "RETAIL62", "sort_order": 1}, headers=tenant_headers(TENANT_A), ) assert pipeline.status_code == 201, pipeline.text pipeline_id = pipeline.json()["id"] stage = await client.post( "/api/v1/pipelines/stages", json={ "pipeline_id": pipeline_id, "name": "Open", "code": "OPEN", "is_start_stage": True, "win_probability": 20, }, headers=tenant_headers(TENANT_A), ) assert stage.status_code == 201 stage_id = stage.json()["id"] opp = await client.post( "/api/v1/opportunities", json={ "title": "Retail Deal", "pipeline_id": pipeline_id, "stage_id": stage_id, "amount": "8000", "expected_close_date": "2026-10-01", }, headers=tenant_headers(TENANT_A), ) assert opp.status_code == 201, opp.text assert opp.json()["opportunity_number"] opportunity_id = opp.json()["id"] stage2 = await client.post( "/api/v1/pipelines/stages", json={ "pipeline_id": pipeline_id, "name": "Proposal", "code": "PROP", "position": 1, "win_probability": 55, }, headers=tenant_headers(TENANT_A), ) changed = await client.post( f"/api/v1/opportunities/{opportunity_id}/stage", json={"stage_id": stage2.json()["id"], "reason": "ready"}, headers=tenant_headers(TENANT_A), ) assert changed.status_code == 200 hist = await client.get( f"/api/v1/opportunities/{opportunity_id}/stage-history", headers=tenant_headers(TENANT_A), ) assert hist.status_code == 200 assert len(hist.json()) >= 2 pb = await client.post( "/api/v1/playbooks", json={"name": "Retail PB", "code": "RET-PB", "pipeline_id": pipeline_id}, headers=tenant_headers(TENANT_A), ) assert pb.status_code == 201 await client.post( "/api/v1/playbooks/steps", json={ "playbook_id": pb.json()["id"], "title": "Call", "step_kind": "recommended_activity", }, headers=tenant_headers(TENANT_A), ) assign = await client.post( "/api/v1/playbooks/assignments", json={"playbook_id": pb.json()["id"], "opportunity_id": opportunity_id}, headers=tenant_headers(TENANT_A), ) assert assign.status_code == 201 prod = await client.post( "/api/v1/opportunity-products", json={ "opportunity_id": opportunity_id, "product_ref_id": "SKU-9", "quantity": "1", "unit_price": "8000", }, headers=tenant_headers(TENANT_A), ) assert prod.status_code == 201 fc = await client.post( "/api/v1/forecasts/compute", json={ "name": "Oct", "period": "monthly", "period_start": "2026-10-01", "period_end": "2026-10-31", "pipeline_id": pipeline_id, }, headers=tenant_headers(TENANT_A), ) assert fc.status_code == 201, fc.text goal = await client.post( "/api/v1/goals", json={ "name": "Indiv", "scope": "individual", "metric": "opportunity_count", "owner_user_id": "u1", "target_value": "10", "period_start": "2026-01-01", "period_end": "2026-12-31", }, headers=tenant_headers(TENANT_A), ) assert goal.status_code == 201 target = await client.post( "/api/v1/targets", json={ "name": "Annual", "period": "annual", "period_start": "2026-01-01", "period_end": "2026-12-31", "target_value": "100", "actual_value": "40", }, headers=tenant_headers(TENANT_A), ) assert target.status_code == 201 assert float(target.json()["achievement_percent"]) == 40.0 lost = await client.post( f"/api/v1/opportunities/{opportunity_id}/lose", json={"custom_reason": "budget cut", "timing_factor": True}, headers=tenant_headers(TENANT_A), ) assert lost.status_code == 200 assert lost.json()["status"] == "lost" wl = await client.get( f"/api/v1/win-loss/opportunities/{opportunity_id}", headers=tenant_headers(TENANT_A), ) assert wl.status_code == 200 assert wl.json()["is_locked"] is True other = await client.get( "/api/v1/playbooks", headers=tenant_headers(TENANT_B), ) assert other.status_code == 200 assert other.json() == [] def test_stage_flag_validator(): with pytest.raises(AppError): validate_stage_flags( is_start_stage=True, is_won_stage=True, is_lost_stage=False ) def test_sales_engine_models_have_tenant(): assert hasattr(SalesPlaybook, "tenant_id") assert hasattr(OpportunityProduct, "tenant_id") assert hasattr(SalesForecast, "tenant_id") assert hasattr(SalesStageHistory, "tenant_id")