Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script. Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
17 KiB
Python
541 lines
17 KiB
Python
"""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")
|