1316 lines
41 KiB
Python
1316 lines
41 KiB
Python
"""CRM 商机 CRUD(主表 + 阶段/赢输单/统计/看板/漏斗)"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from sqlalchemy import case, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.delete_guard import assert_row_deletable_by_creator
|
|
from app.core.utils import generate_uuid
|
|
from app.models.crm_models import (
|
|
CrmContact,
|
|
CrmCustomer,
|
|
CrmOpportunity,
|
|
CrmOpportunityStageLog,
|
|
)
|
|
from app.models.models import SysUser
|
|
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope
|
|
|
|
_FMT = "%Y-%m-%d %H:%M:%S"
|
|
_DATE_FMT = "%Y-%m-%d"
|
|
|
|
_STAGE_ORDER = (
|
|
"initial",
|
|
"requirement",
|
|
"solution",
|
|
"quote",
|
|
"negotiate",
|
|
"contract",
|
|
"won",
|
|
"lost",
|
|
)
|
|
_STAGE_DEFAULT_PERCENT = {
|
|
"initial": 10,
|
|
"requirement": 20,
|
|
"solution": 40,
|
|
"quote": 60,
|
|
"negotiate": 80,
|
|
"contract": 90,
|
|
"won": 100,
|
|
"lost": 0,
|
|
}
|
|
_KANBAN_STAGES = ("initial", "requirement", "solution", "quote", "negotiate", "contract")
|
|
_FUNNEL_STAGES = _KANBAN_STAGES + ("won",)
|
|
_FUNNEL_STAGE_NAMES = (
|
|
"初步接触",
|
|
"需求分析",
|
|
"方案制定",
|
|
"报价",
|
|
"谈判审核",
|
|
"合同签订",
|
|
"赢单",
|
|
)
|
|
|
|
|
|
def _ensure_opportunity_in_scope(db: Session, row: CrmOpportunity, user_info, tenant_no: str) -> None:
|
|
ensure_crm_owner_row_in_scope(db, row, user_info, tenant_no, message="无权访问该商机")
|
|
|
|
|
|
def _scoped_opportunity_query(
|
|
db: Session,
|
|
tenant_no: str,
|
|
user_info=None,
|
|
owner_user_id: str = None,
|
|
):
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.tenant_no == tenant_no,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if owner_user_id:
|
|
q = q.filter(CrmOpportunity.owner_user_id == owner_user_id)
|
|
if user_info:
|
|
q = apply_crm_owner_scope(q, CrmOpportunity, user_info, db, tenant_no)
|
|
return q
|
|
|
|
|
|
def total_pages(total: int, page_size: int) -> int:
|
|
if page_size <= 0:
|
|
return 0
|
|
return math.ceil(total / page_size)
|
|
|
|
|
|
def _parse_optional_date(value: Any) -> Optional[date]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, date) and not isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
for fmt in (_DATE_FMT, _FMT):
|
|
try:
|
|
return datetime.strptime(text, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f"日期格式无效: {text}")
|
|
|
|
|
|
def _parse_optional_datetime(value: Any) -> Optional[datetime]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
for fmt in (_FMT, _DATE_FMT):
|
|
try:
|
|
return datetime.strptime(text, fmt)
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f"日期时间格式无效: {text}")
|
|
|
|
|
|
def _decimal_value(value: Any, default: Decimal = Decimal("0")) -> Decimal:
|
|
if value is None or value == "":
|
|
return default
|
|
return Decimal(str(value))
|
|
|
|
|
|
def _stage_default_percent(stage: Optional[str]) -> int:
|
|
return _STAGE_DEFAULT_PERCENT.get((stage or "").strip(), 10)
|
|
|
|
|
|
def _resolve_user_display_name(db: Session, user_id: Optional[str]) -> str:
|
|
uid = (user_id or "").strip()
|
|
if not uid:
|
|
return ""
|
|
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
|
if not u:
|
|
return ""
|
|
return (u.nick_name or u.user_no or "").strip()
|
|
|
|
|
|
def _enrich_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
|
|
field_pairs = (
|
|
("ownerUserId", "ownerUserName"),
|
|
("createBy", "createByName"),
|
|
)
|
|
need_ids: set[str] = set()
|
|
for d in rows:
|
|
for id_field, name_field in field_pairs:
|
|
uid = str(d.get(id_field) or "").strip()
|
|
if uid and not (d.get(name_field) or "").strip():
|
|
need_ids.add(uid)
|
|
if not need_ids:
|
|
return
|
|
users = (
|
|
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
|
.filter(SysUser.id.in_(need_ids), SysUser.del_flag == 0)
|
|
.all()
|
|
)
|
|
name_map = {u.id: (u.nick_name or u.user_no or "").strip() for u in users}
|
|
for d in rows:
|
|
for id_field, name_field in field_pairs:
|
|
uid = str(d.get(id_field) or "").strip()
|
|
if uid and not (d.get(name_field) or "").strip():
|
|
d[name_field] = name_map.get(uid, "")
|
|
|
|
|
|
def _guard_terminal_status(row: CrmOpportunity, action: str = "操作") -> None:
|
|
if row.status in ("won", "lost"):
|
|
raise ValueError(f"已赢单或已输单的商机不允许{action}")
|
|
|
|
|
|
def _row_to_dict(row: CrmOpportunity) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"opportunityNo": row.opportunity_no or "",
|
|
"opportunityName": row.opportunity_name or "",
|
|
"customerId": row.customer_id or "",
|
|
"customerName": row.customer_name or "",
|
|
"contactId": row.contact_id or "",
|
|
"contactName": row.contact_name or "",
|
|
"stage": row.stage or "",
|
|
"stagePercent": row.stage_percent if row.stage_percent is not None else 0,
|
|
"status": row.status or "",
|
|
"winRate": float(row.win_rate) if row.win_rate is not None else 0,
|
|
"amount": float(row.amount) if row.amount is not None else 0.0,
|
|
"expectDealDate": row.expect_deal_date.strftime(_DATE_FMT) if row.expect_deal_date else "",
|
|
"actualDealDate": row.actual_deal_date.strftime(_DATE_FMT) if row.actual_deal_date else "",
|
|
"lostReason": row.lost_reason or "",
|
|
"ownerUserId": row.owner_user_id or "",
|
|
"ownerUserName": row.owner_user_name or "",
|
|
"productCount": row.product_count if row.product_count is not None else 0,
|
|
"lastTime": row.last_time.strftime(_FMT) if row.last_time else "",
|
|
"nextTime": row.next_time.strftime(_FMT) if row.next_time else "",
|
|
"followCount": row.follow_count if row.follow_count is not None else 0,
|
|
"contractId": row.contract_id or "",
|
|
"source": row.source or "",
|
|
"type": row.type or "",
|
|
"description": row.description or "",
|
|
"competitors": row.competitors or "",
|
|
"keyFactors": row.key_factors or "",
|
|
"orderNum": row.order_num if row.order_num is not None else 0,
|
|
"remark": row.remark or "",
|
|
"createBy": row.create_by or "",
|
|
"createTime": row.create_time.strftime(_FMT) if row.create_time else "",
|
|
"updateBy": row.update_by or "",
|
|
"updateTime": row.update_time.strftime(_FMT) if row.update_time else "",
|
|
}
|
|
|
|
|
|
def _stage_log_to_dict(row: CrmOpportunityStageLog) -> Dict[str, Any]:
|
|
change_time = row.create_time.strftime(_FMT) if row.create_time else ""
|
|
d = {
|
|
"id": row.id,
|
|
"opportunityId": row.opportunity_id or "",
|
|
"fromStage": row.from_stage or "",
|
|
"fromStageName": row.from_stage_name or "",
|
|
"fromPercent": row.from_percent if row.from_percent is not None else 0,
|
|
"toStage": row.to_stage or "",
|
|
"toStageName": row.to_stage_name or "",
|
|
"toPercent": row.to_percent if row.to_percent is not None else 0,
|
|
"oldStage": row.from_stage or "",
|
|
"newStage": row.to_stage or "",
|
|
"stagePercent": row.to_percent if row.to_percent is not None else 0,
|
|
"changeType": row.change_type or "",
|
|
"changeReason": row.change_reason or "",
|
|
"changeAmount": float(row.change_amount) if row.change_amount is not None else 0,
|
|
"lostDetail": row.lost_detail or "",
|
|
"competitorInfo": row.competitor_info or "",
|
|
"improvement": row.improvement or "",
|
|
"wonRemark": row.won_remark or "",
|
|
"remark": row.remark or "",
|
|
"changeBy": row.create_by or "",
|
|
"changeTime": change_time,
|
|
"createBy": row.create_by or "",
|
|
"createTime": change_time,
|
|
}
|
|
return d
|
|
|
|
|
|
def _apply_list_filters(
|
|
q,
|
|
*,
|
|
tenant_no: str,
|
|
keyword: str = None,
|
|
opportunity_no: str = None,
|
|
opportunity_name: str = None,
|
|
customer_name: str = None,
|
|
customer_id: str = None,
|
|
stage: str = None,
|
|
status: str = None,
|
|
source: str = None,
|
|
owner_user_id: str = None,
|
|
create_time_start: Any = None,
|
|
create_time_end: Any = None,
|
|
amount_min: Any = None,
|
|
amount_max: Any = None,
|
|
):
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no, CrmOpportunity.del_flag == 0)
|
|
|
|
kw = (keyword or "").strip()
|
|
if kw:
|
|
like = f"%{kw}%"
|
|
q = q.filter(
|
|
or_(
|
|
CrmOpportunity.opportunity_name.like(like),
|
|
CrmOpportunity.customer_name.like(like),
|
|
)
|
|
)
|
|
elif opportunity_name and customer_name and opportunity_name == customer_name:
|
|
like = f"%{opportunity_name}%"
|
|
q = q.filter(
|
|
or_(
|
|
CrmOpportunity.opportunity_name.like(like),
|
|
CrmOpportunity.customer_name.like(like),
|
|
)
|
|
)
|
|
else:
|
|
if opportunity_name:
|
|
q = q.filter(CrmOpportunity.opportunity_name.like(f"%{opportunity_name}%"))
|
|
if customer_name:
|
|
q = q.filter(CrmOpportunity.customer_name.like(f"%{customer_name}%"))
|
|
|
|
if opportunity_no:
|
|
q = q.filter(CrmOpportunity.opportunity_no.like(f"%{opportunity_no}%"))
|
|
if customer_id:
|
|
q = q.filter(CrmOpportunity.customer_id == customer_id)
|
|
if stage:
|
|
q = q.filter(CrmOpportunity.stage == stage)
|
|
if status:
|
|
q = q.filter(CrmOpportunity.status == status)
|
|
if source:
|
|
q = q.filter(CrmOpportunity.source == source)
|
|
if owner_user_id:
|
|
q = q.filter(CrmOpportunity.owner_user_id == owner_user_id)
|
|
if create_time_start:
|
|
q = q.filter(CrmOpportunity.create_time >= create_time_start)
|
|
if create_time_end:
|
|
q = q.filter(CrmOpportunity.create_time <= create_time_end)
|
|
if amount_min is not None:
|
|
q = q.filter(CrmOpportunity.amount >= _decimal_value(amount_min))
|
|
if amount_max is not None:
|
|
q = q.filter(CrmOpportunity.amount <= _decimal_value(amount_max))
|
|
return q
|
|
|
|
|
|
def list_opportunities(
|
|
db: Session,
|
|
tenant_no: str,
|
|
user_id: str = None,
|
|
*,
|
|
page: int = 1,
|
|
page_size: int = 10,
|
|
view_type: str = None,
|
|
keyword: str = None,
|
|
opportunity_no: str = None,
|
|
opportunity_name: str = None,
|
|
customer_name: str = None,
|
|
customer_id: str = None,
|
|
stage: str = None,
|
|
status: str = None,
|
|
source: str = None,
|
|
owner_user_id: str = None,
|
|
create_time_start: str = None,
|
|
create_time_end: str = None,
|
|
amount_min: Any = None,
|
|
amount_max: Any = None,
|
|
user_info=None,
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
q = db.query(CrmOpportunity)
|
|
effective_owner = owner_user_id
|
|
if view_type == "mine" and user_id:
|
|
effective_owner = user_id
|
|
|
|
q = _apply_list_filters(
|
|
q,
|
|
tenant_no=tenant_no,
|
|
keyword=keyword,
|
|
opportunity_no=opportunity_no,
|
|
opportunity_name=opportunity_name,
|
|
customer_name=customer_name,
|
|
customer_id=customer_id,
|
|
stage=stage,
|
|
status=status,
|
|
source=source,
|
|
owner_user_id=effective_owner,
|
|
create_time_start=create_time_start,
|
|
create_time_end=create_time_end,
|
|
amount_min=amount_min,
|
|
amount_max=amount_max,
|
|
)
|
|
|
|
if user_info:
|
|
q = apply_crm_owner_scope(q, CrmOpportunity, user_info, db, tenant_no)
|
|
|
|
total = q.order_by(None).with_entities(func.count(CrmOpportunity.id)).scalar() or 0
|
|
page = max(1, page)
|
|
page_size = min(max(1, page_size), 500)
|
|
rows = (
|
|
q.order_by(CrmOpportunity.create_time.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
result = [_row_to_dict(r) for r in rows]
|
|
_enrich_user_names(db, result)
|
|
return result, int(total)
|
|
|
|
|
|
def get_opportunity(
|
|
db: Session, opportunity_id: str, tenant_no: str = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
row = q.first()
|
|
if not row:
|
|
return None
|
|
|
|
detail = _row_to_dict(row)
|
|
_enrich_user_names(db, [detail])
|
|
|
|
from app.crud.crm.opportunity_product import list_products_by_opportunity
|
|
|
|
products = list_products_by_opportunity(db, opportunity_id, tenant_no=tenant_no)
|
|
detail["productList"] = products
|
|
detail["productCount"] = len(products)
|
|
|
|
last_log = (
|
|
db.query(CrmOpportunityStageLog)
|
|
.filter(
|
|
CrmOpportunityStageLog.opportunity_id == opportunity_id,
|
|
CrmOpportunityStageLog.del_flag == 0,
|
|
)
|
|
.order_by(CrmOpportunityStageLog.create_time.desc())
|
|
.first()
|
|
)
|
|
if last_log and last_log.create_time:
|
|
detail["stageChangeTime"] = last_log.create_time.strftime(_FMT)
|
|
|
|
if not detail.get("createByName") and detail.get("createBy"):
|
|
detail["createByName"] = _resolve_user_display_name(db, detail["createBy"])
|
|
|
|
return detail
|
|
|
|
|
|
def generate_opportunity_no(db: Session, tenant_no: str = None) -> str:
|
|
date_str = datetime.now().strftime("%Y%m%d")
|
|
prefix = f"OPP{date_str}"
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.opportunity_no.like(f"{prefix}%"),
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
last = q.order_by(CrmOpportunity.opportunity_no.desc()).first()
|
|
sequence = 1
|
|
if last and last.opportunity_no and len(last.opportunity_no) >= len(prefix) + 6:
|
|
try:
|
|
sequence = int(last.opportunity_no[len(prefix) :]) + 1
|
|
except ValueError:
|
|
sequence = 1
|
|
for retry in range(10):
|
|
candidate = prefix + f"{sequence + retry:06d}"
|
|
exists = (
|
|
db.query(CrmOpportunity.id)
|
|
.filter(CrmOpportunity.opportunity_no == candidate, CrmOpportunity.del_flag == 0)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
return candidate
|
|
raise ValueError("生成商机编号失败,请稍后重试")
|
|
|
|
|
|
def _fill_customer_contact(
|
|
db: Session, entity: CrmOpportunity, customer_id: str = None, contact_id: str = None
|
|
) -> None:
|
|
cid = (customer_id or entity.customer_id or "").strip()
|
|
if cid:
|
|
customer = (
|
|
db.query(CrmCustomer)
|
|
.filter(CrmCustomer.id == cid, CrmCustomer.del_flag == 0)
|
|
.first()
|
|
)
|
|
if not customer:
|
|
raise ValueError("客户不存在")
|
|
entity.customer_id = cid
|
|
entity.customer_name = customer.customer_name or ""
|
|
|
|
ctid = (contact_id or entity.contact_id or "").strip() or None
|
|
if ctid:
|
|
contact = (
|
|
db.query(CrmContact)
|
|
.filter(CrmContact.id == ctid, CrmContact.del_flag == 0)
|
|
.first()
|
|
)
|
|
if contact:
|
|
entity.contact_id = ctid
|
|
entity.contact_name = contact.contact_name or ""
|
|
|
|
|
|
def record_stage_log(
|
|
db: Session,
|
|
tenant_no: str,
|
|
opportunity_id: str,
|
|
*,
|
|
from_stage: str = None,
|
|
to_stage: str,
|
|
to_percent: int = None,
|
|
change_type: str = "forward",
|
|
change_reason: str = None,
|
|
remark: str = None,
|
|
change_amount: Decimal = None,
|
|
lost_detail: str = None,
|
|
competitor_info: str = None,
|
|
improvement: str = None,
|
|
won_remark: str = None,
|
|
user_id: str = None,
|
|
) -> CrmOpportunityStageLog:
|
|
now = datetime.now()
|
|
log = CrmOpportunityStageLog(
|
|
id=generate_uuid(),
|
|
opportunity_id=opportunity_id,
|
|
from_stage=from_stage,
|
|
to_stage=to_stage,
|
|
to_percent=to_percent if to_percent is not None else _stage_default_percent(to_stage),
|
|
change_type=change_type,
|
|
change_reason=change_reason,
|
|
change_amount=change_amount,
|
|
lost_detail=lost_detail,
|
|
competitor_info=competitor_info,
|
|
improvement=improvement,
|
|
won_remark=won_remark,
|
|
remark=remark,
|
|
tenant_no=tenant_no,
|
|
order_num=0,
|
|
create_by=user_id,
|
|
create_dept_id="",
|
|
create_time=now,
|
|
update_by=user_id,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
db.add(log)
|
|
return log
|
|
|
|
|
|
def create_opportunity(
|
|
db: Session,
|
|
tenant_no: str,
|
|
data: dict,
|
|
user_id: str = None,
|
|
) -> Dict[str, Any]:
|
|
now = datetime.now()
|
|
stage = (data.get("stage") or "initial").strip() or "initial"
|
|
stage_percent = data.get("stage_percent")
|
|
if stage_percent is None:
|
|
stage_percent = _stage_default_percent(stage)
|
|
win_rate = data.get("win_rate")
|
|
if win_rate is None:
|
|
win_rate = Decimal(str(stage_percent))
|
|
|
|
owner_id = (data.get("owner_user_id") or user_id or "").strip() or None
|
|
obj = CrmOpportunity(
|
|
id=generate_uuid(),
|
|
opportunity_no=data.get("opportunity_no") or generate_opportunity_no(db, tenant_no),
|
|
opportunity_name=data.get("opportunity_name"),
|
|
customer_id=data.get("customer_id"),
|
|
stage=stage,
|
|
stage_percent=int(stage_percent),
|
|
status="following",
|
|
win_rate=_decimal_value(win_rate, Decimal(str(stage_percent))),
|
|
amount=_decimal_value(data.get("amount"), Decimal("0")),
|
|
expect_deal_date=_parse_optional_date(data.get("expect_deal_date")),
|
|
owner_user_id=owner_id,
|
|
owner_user_name=_resolve_user_display_name(db, owner_id) if owner_id else "",
|
|
follow_count=0,
|
|
product_count=0,
|
|
source=data.get("source"),
|
|
type=data.get("type"),
|
|
description=data.get("description"),
|
|
competitors=data.get("competitors"),
|
|
key_factors=data.get("key_factors"),
|
|
tenant_no=tenant_no,
|
|
order_num=data.get("order_num"),
|
|
remark=data.get("remark"),
|
|
create_by=user_id,
|
|
create_dept_id=data.get("create_dept_id", ""),
|
|
create_time=now,
|
|
update_by=user_id,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
_fill_customer_contact(
|
|
db, obj, data.get("customer_id"), data.get("contact_id")
|
|
)
|
|
db.add(obj)
|
|
db.flush()
|
|
record_stage_log(
|
|
db,
|
|
tenant_no,
|
|
obj.id,
|
|
from_stage=None,
|
|
to_stage=obj.stage,
|
|
to_percent=obj.stage_percent,
|
|
change_type="forward",
|
|
change_reason="新建商机",
|
|
remark="系统创建",
|
|
change_amount=obj.amount,
|
|
user_id=user_id,
|
|
)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return _row_to_dict(obj)
|
|
|
|
|
|
def update_opportunity(
|
|
db: Session,
|
|
opportunity_id: str,
|
|
data: dict,
|
|
user_id: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
obj = q.first()
|
|
if not obj:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, obj, user_info, obj.tenant_no)
|
|
_guard_terminal_status(obj, "修改")
|
|
|
|
_updatable = (
|
|
"opportunity_name",
|
|
"customer_id",
|
|
"contact_id",
|
|
"stage",
|
|
"stage_percent",
|
|
"win_rate",
|
|
"amount",
|
|
"expect_deal_date",
|
|
"source",
|
|
"type",
|
|
"description",
|
|
"competitors",
|
|
"key_factors",
|
|
"order_num",
|
|
"remark",
|
|
)
|
|
for field in _updatable:
|
|
if field not in data:
|
|
continue
|
|
val = data[field]
|
|
if field == "expect_deal_date":
|
|
setattr(obj, field, _parse_optional_date(val))
|
|
elif field in ("stage_percent",):
|
|
setattr(obj, field, int(val) if val is not None else None)
|
|
elif field in ("win_rate", "amount"):
|
|
setattr(obj, field, _decimal_value(val) if val is not None else None)
|
|
elif val is not None:
|
|
setattr(obj, field, val)
|
|
|
|
if "customer_id" in data or "contact_id" in data:
|
|
_fill_customer_contact(
|
|
db,
|
|
obj,
|
|
data.get("customer_id"),
|
|
data.get("contact_id"),
|
|
)
|
|
if "owner_user_id" in data:
|
|
owner_id = (data.get("owner_user_id") or "").strip() or None
|
|
obj.owner_user_id = owner_id
|
|
obj.owner_user_name = _resolve_user_display_name(db, owner_id) if owner_id else ""
|
|
|
|
obj.update_by = user_id
|
|
obj.update_time = datetime.now()
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return _row_to_dict(obj)
|
|
|
|
|
|
def soft_delete_opportunities(
|
|
db: Session,
|
|
ids: List[str],
|
|
user_id: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> int:
|
|
q = db.query(CrmOpportunity).filter(CrmOpportunity.id.in_(ids), CrmOpportunity.del_flag == 0)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
rows = q.all()
|
|
if len(rows) != len(ids):
|
|
raise ValueError("部分商机不存在或无权操作")
|
|
if any(r.status == "won" for r in rows):
|
|
raise ValueError("已赢单的商机不允许删除")
|
|
|
|
now = datetime.now()
|
|
for row in rows:
|
|
_ensure_opportunity_in_scope(db, row, user_info, row.tenant_no)
|
|
_guard_terminal_status(row, "删除")
|
|
assert_row_deletable_by_creator(row, user_info, user_id=user_id)
|
|
row.del_flag = 1
|
|
row.update_by = user_id
|
|
row.update_time = now
|
|
db.commit()
|
|
return len(rows)
|
|
|
|
|
|
def assign_opportunities(
|
|
db: Session,
|
|
ids: List[str],
|
|
owner_user_id: str,
|
|
user_id: str = None,
|
|
remark: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> int:
|
|
owner_name = _resolve_user_display_name(db, owner_user_id)
|
|
q = db.query(CrmOpportunity).filter(CrmOpportunity.id.in_(ids), CrmOpportunity.del_flag == 0)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
rows = q.all()
|
|
now = datetime.now()
|
|
count = 0
|
|
for row in rows:
|
|
_ensure_opportunity_in_scope(db, row, user_info, row.tenant_no)
|
|
if row.status in ("won", "lost"):
|
|
continue
|
|
row.owner_user_id = owner_user_id
|
|
row.owner_user_name = owner_name
|
|
row.update_by = user_id
|
|
row.update_time = now
|
|
if remark:
|
|
old = (row.remark or "").strip()
|
|
row.remark = f"{old}\n分配说明:{remark}".strip() if old else f"分配说明:{remark}"
|
|
count += 1
|
|
db.commit()
|
|
return count
|
|
|
|
|
|
def change_stage(
|
|
db: Session,
|
|
opportunity_id: str,
|
|
stage: str,
|
|
stage_percent: int,
|
|
win_rate: Any,
|
|
change_reason: str = None,
|
|
remark: str = None,
|
|
user_id: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
obj = q.first()
|
|
if not obj:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, obj, user_info, obj.tenant_no)
|
|
_guard_terminal_status(obj, "变更阶段")
|
|
|
|
old_stage = obj.stage
|
|
obj.stage = stage
|
|
obj.stage_percent = int(stage_percent)
|
|
obj.win_rate = _decimal_value(win_rate, Decimal(str(stage_percent)))
|
|
obj.update_by = user_id
|
|
obj.update_time = datetime.now()
|
|
|
|
record_stage_log(
|
|
db,
|
|
obj.tenant_no,
|
|
obj.id,
|
|
from_stage=old_stage,
|
|
to_stage=stage,
|
|
to_percent=obj.stage_percent,
|
|
change_type="forward",
|
|
change_reason=change_reason,
|
|
remark=remark,
|
|
change_amount=obj.amount,
|
|
user_id=user_id,
|
|
)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return _row_to_dict(obj)
|
|
|
|
|
|
def _update_customer_deal_status(db: Session, customer_id: str) -> None:
|
|
won_count = (
|
|
db.query(func.count(CrmOpportunity.id))
|
|
.filter(
|
|
CrmOpportunity.customer_id == customer_id,
|
|
CrmOpportunity.status == "won",
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
if won_count <= 0:
|
|
return
|
|
customer = (
|
|
db.query(CrmCustomer)
|
|
.filter(CrmCustomer.id == customer_id, CrmCustomer.del_flag == 0)
|
|
.first()
|
|
)
|
|
if customer and customer.deal_status != "dealed":
|
|
customer.deal_status = "dealed"
|
|
customer.deal_time = datetime.now()
|
|
customer.update_time = datetime.now()
|
|
|
|
|
|
def mark_won(
|
|
db: Session,
|
|
opportunity_id: str,
|
|
actual_deal_date: Any = None,
|
|
actual_amount: Any = None,
|
|
remark: str = None,
|
|
user_id: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
obj = q.first()
|
|
if not obj:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, obj, user_info, obj.tenant_no)
|
|
if obj.status == "won":
|
|
raise ValueError("商机已是赢单状态")
|
|
|
|
old_stage = obj.stage
|
|
obj.status = "won"
|
|
obj.stage = "won"
|
|
obj.stage_percent = 100
|
|
obj.win_rate = Decimal("100")
|
|
obj.actual_deal_date = _parse_optional_date(actual_deal_date) or date.today()
|
|
if actual_amount is not None:
|
|
obj.amount = _decimal_value(actual_amount)
|
|
obj.update_by = user_id
|
|
obj.update_time = datetime.now()
|
|
|
|
record_stage_log(
|
|
db,
|
|
obj.tenant_no,
|
|
obj.id,
|
|
from_stage=old_stage,
|
|
to_stage="won",
|
|
to_percent=100,
|
|
change_type="won",
|
|
change_reason="标记赢单",
|
|
won_remark=remark,
|
|
change_amount=obj.amount,
|
|
user_id=user_id,
|
|
)
|
|
if obj.customer_id:
|
|
_update_customer_deal_status(db, obj.customer_id)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return _row_to_dict(obj)
|
|
|
|
|
|
def mark_lost(
|
|
db: Session,
|
|
opportunity_id: str,
|
|
lost_reason: str,
|
|
remark: str = None,
|
|
competitor_info: str = None,
|
|
improvement: str = None,
|
|
user_id: str = None,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
obj = q.first()
|
|
if not obj:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, obj, user_info, obj.tenant_no)
|
|
if obj.status == "lost":
|
|
raise ValueError("商机已是输单状态")
|
|
|
|
old_stage = obj.stage
|
|
obj.status = "lost"
|
|
obj.stage = "lost"
|
|
obj.stage_percent = 0
|
|
obj.win_rate = Decimal("0")
|
|
obj.lost_reason = lost_reason
|
|
obj.update_by = user_id
|
|
obj.update_time = datetime.now()
|
|
|
|
record_stage_log(
|
|
db,
|
|
obj.tenant_no,
|
|
obj.id,
|
|
from_stage=old_stage,
|
|
to_stage="lost",
|
|
to_percent=0,
|
|
change_type="lost",
|
|
change_reason=lost_reason,
|
|
remark=remark,
|
|
lost_detail=remark,
|
|
competitor_info=competitor_info,
|
|
improvement=improvement,
|
|
change_amount=obj.amount,
|
|
user_id=user_id,
|
|
)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return _row_to_dict(obj)
|
|
|
|
|
|
def batch_update_stage(
|
|
db: Session,
|
|
ids: List[str],
|
|
stage: str,
|
|
stage_percent: int,
|
|
win_rate: Any,
|
|
change_reason: str,
|
|
tenant_no: str = None,
|
|
user_id: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
success_count = 0
|
|
fail_count = 0
|
|
fail_reasons: List[Dict[str, str]] = []
|
|
for oid in ids:
|
|
try:
|
|
change_stage(
|
|
db,
|
|
oid,
|
|
stage,
|
|
stage_percent,
|
|
win_rate,
|
|
change_reason=change_reason,
|
|
user_id=user_id,
|
|
tenant_no=tenant_no,
|
|
user_info=user_info,
|
|
)
|
|
success_count += 1
|
|
except Exception as e:
|
|
fail_count += 1
|
|
fail_reasons.append({"id": oid, "reason": str(e)})
|
|
return {
|
|
"successCount": success_count,
|
|
"failCount": fail_count,
|
|
"failReasons": fail_reasons,
|
|
}
|
|
|
|
|
|
def batch_update_expect_date(
|
|
db: Session,
|
|
ids: List[str],
|
|
update_type: str,
|
|
expect_deal_date: str = None,
|
|
days: int = None,
|
|
tenant_no: str = None,
|
|
user_id: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
success_count = 0
|
|
fail_count = 0
|
|
fail_reasons: List[Dict[str, str]] = []
|
|
|
|
for oid in ids:
|
|
try:
|
|
q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == oid,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
obj = q.first()
|
|
if not obj:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, obj, user_info, obj.tenant_no)
|
|
_guard_terminal_status(obj, "修改预计成交日期")
|
|
|
|
new_date: Optional[date] = None
|
|
if update_type == "set_date":
|
|
if not expect_deal_date:
|
|
raise ValueError("新日期不能为空")
|
|
new_date = _parse_optional_date(expect_deal_date)
|
|
elif update_type == "delay_days":
|
|
if not days or days <= 0:
|
|
raise ValueError("延后天数必须大于0")
|
|
if not obj.expect_deal_date:
|
|
raise ValueError(
|
|
f"商机【{obj.opportunity_name}】原预计成交日期为空,无法延后"
|
|
)
|
|
new_date = obj.expect_deal_date + timedelta(days=int(days))
|
|
elif update_type == "advance_days":
|
|
if not days or days <= 0:
|
|
raise ValueError("提前天数必须大于0")
|
|
if not obj.expect_deal_date:
|
|
raise ValueError(
|
|
f"商机【{obj.opportunity_name}】原预计成交日期为空,无法提前"
|
|
)
|
|
new_date = obj.expect_deal_date - timedelta(days=int(days))
|
|
else:
|
|
raise ValueError("不支持的更新类型")
|
|
|
|
obj.expect_deal_date = new_date
|
|
obj.update_by = user_id
|
|
obj.update_time = datetime.now()
|
|
db.commit()
|
|
success_count += 1
|
|
except Exception as e:
|
|
db.rollback()
|
|
fail_count += 1
|
|
fail_reasons.append({"id": oid, "reason": str(e)})
|
|
|
|
return {
|
|
"successCount": success_count,
|
|
"failCount": fail_count,
|
|
"failReasons": fail_reasons,
|
|
}
|
|
|
|
|
|
def get_stage_log_list(
|
|
db: Session,
|
|
opportunity_id: str,
|
|
tenant_no: str = None,
|
|
user_info=None,
|
|
) -> List[Dict[str, Any]]:
|
|
opp_q = db.query(CrmOpportunity).filter(
|
|
CrmOpportunity.id == opportunity_id,
|
|
CrmOpportunity.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
opp_q = opp_q.filter(CrmOpportunity.tenant_no == tenant_no)
|
|
opp = opp_q.first()
|
|
if not opp:
|
|
raise ValueError("商机不存在")
|
|
_ensure_opportunity_in_scope(db, opp, user_info, opp.tenant_no)
|
|
|
|
q = db.query(CrmOpportunityStageLog).filter(
|
|
CrmOpportunityStageLog.opportunity_id == opportunity_id,
|
|
CrmOpportunityStageLog.del_flag == 0,
|
|
)
|
|
if tenant_no:
|
|
q = q.filter(CrmOpportunityStageLog.tenant_no == tenant_no)
|
|
rows = q.order_by(CrmOpportunityStageLog.create_time.desc()).all()
|
|
result = [_stage_log_to_dict(r) for r in rows]
|
|
for d in result:
|
|
if d.get("changeBy") and not d.get("changeByName"):
|
|
d["changeByName"] = _resolve_user_display_name(db, d["changeBy"])
|
|
d["createByName"] = d["changeByName"]
|
|
return result
|
|
|
|
|
|
def get_statistics_overview(
|
|
db: Session,
|
|
tenant_no: str,
|
|
owner_user_id: str = None,
|
|
begin_time: str = None,
|
|
end_time: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
q = _scoped_opportunity_query(db, tenant_no, user_info, owner_user_id)
|
|
if begin_time:
|
|
q = q.filter(CrmOpportunity.create_time >= begin_time)
|
|
if end_time:
|
|
q = q.filter(CrmOpportunity.create_time <= end_time)
|
|
|
|
total_count = q.count()
|
|
following_count = q.filter(CrmOpportunity.status == "following").count()
|
|
won_count = q.filter(CrmOpportunity.status == "won").count()
|
|
lost_count = q.filter(CrmOpportunity.status == "lost").count()
|
|
win_rate = Decimal("0")
|
|
if won_count + lost_count > 0:
|
|
win_rate = (
|
|
Decimal(won_count)
|
|
/ Decimal(won_count + lost_count)
|
|
* Decimal("100")
|
|
).quantize(Decimal("0.01"))
|
|
|
|
total_amount = (
|
|
q.with_entities(func.coalesce(func.sum(CrmOpportunity.amount), 0))
|
|
.filter(CrmOpportunity.status.in_(("following", "won")))
|
|
.scalar()
|
|
)
|
|
|
|
return {
|
|
"totalCount": int(total_count),
|
|
"inProgressCount": int(following_count),
|
|
"followingCount": int(following_count),
|
|
"wonCount": int(won_count),
|
|
"lostCount": int(lost_count),
|
|
"winRate": float(win_rate),
|
|
"totalAmount": float(total_amount or 0),
|
|
}
|
|
|
|
|
|
def get_statistics_by_stage(
|
|
db: Session, tenant_no: str, user_info=None
|
|
) -> List[Dict[str, Any]]:
|
|
rows = (
|
|
_scoped_opportunity_query(db, tenant_no, user_info)
|
|
.filter(CrmOpportunity.status == "following")
|
|
.with_entities(
|
|
CrmOpportunity.stage,
|
|
func.count(CrmOpportunity.id).label("count"),
|
|
func.coalesce(func.sum(CrmOpportunity.amount), 0).label("totalAmount"),
|
|
func.coalesce(func.avg(CrmOpportunity.win_rate), 0).label("avgWinRate"),
|
|
)
|
|
.group_by(CrmOpportunity.stage)
|
|
.all()
|
|
)
|
|
order_map = {s: i for i, s in enumerate(_STAGE_ORDER)}
|
|
result = [
|
|
{
|
|
"stage": r.stage,
|
|
"count": int(r.count),
|
|
"totalAmount": float(r.totalAmount or 0),
|
|
"avgWinRate": float(r.avgWinRate or 0),
|
|
}
|
|
for r in rows
|
|
]
|
|
result.sort(key=lambda x: order_map.get(x["stage"], 99))
|
|
return result
|
|
|
|
|
|
def get_win_rate_statistics(
|
|
db: Session,
|
|
tenant_no: str,
|
|
stat_type: str = "month",
|
|
begin_time: str = None,
|
|
end_time: str = None,
|
|
user_info=None,
|
|
) -> List[Dict[str, Any]]:
|
|
if stat_type == "day":
|
|
fmt = "%Y-%m-%d"
|
|
elif stat_type == "week":
|
|
fmt = "%Y-%u"
|
|
else:
|
|
fmt = "%Y-%m"
|
|
|
|
date_expr = func.date_format(CrmOpportunity.create_time, fmt).label("date")
|
|
q = _scoped_opportunity_query(db, tenant_no, user_info).filter(
|
|
CrmOpportunity.status.in_(("won", "lost"))
|
|
)
|
|
if begin_time:
|
|
q = q.filter(CrmOpportunity.create_time >= begin_time)
|
|
if end_time:
|
|
q = q.filter(CrmOpportunity.create_time <= end_time)
|
|
|
|
rows = (
|
|
q.with_entities(
|
|
date_expr,
|
|
func.count(CrmOpportunity.id).label("totalCount"),
|
|
func.sum(case((CrmOpportunity.status == "won", 1), else_=0)).label("wonCount"),
|
|
func.sum(case((CrmOpportunity.status == "lost", 1), else_=0)).label("lostCount"),
|
|
)
|
|
.group_by(date_expr)
|
|
.order_by(date_expr)
|
|
.all()
|
|
)
|
|
result = []
|
|
for r in rows:
|
|
total = int(r.totalCount or 0)
|
|
won = int(r.wonCount or 0)
|
|
win_rate = round(won * 100.0 / total, 2) if total else 0
|
|
result.append(
|
|
{
|
|
"date": r.date,
|
|
"totalCount": total,
|
|
"wonCount": won,
|
|
"lostCount": int(r.lostCount or 0),
|
|
"winRate": win_rate,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def get_statistics_by_owner(
|
|
db: Session,
|
|
tenant_no: str,
|
|
begin_time: str = None,
|
|
end_time: str = None,
|
|
user_info=None,
|
|
) -> List[Dict[str, Any]]:
|
|
q = _scoped_opportunity_query(db, tenant_no, user_info)
|
|
if begin_time:
|
|
q = q.filter(CrmOpportunity.create_time >= begin_time)
|
|
if end_time:
|
|
q = q.filter(CrmOpportunity.create_time <= end_time)
|
|
|
|
rows = (
|
|
q.with_entities(
|
|
CrmOpportunity.owner_user_id.label("ownerUserId"),
|
|
CrmOpportunity.owner_user_name.label("ownerUserName"),
|
|
func.count(CrmOpportunity.id).label("totalCount"),
|
|
func.sum(case((CrmOpportunity.status == "following", 1), else_=0)).label(
|
|
"followingCount"
|
|
),
|
|
func.sum(case((CrmOpportunity.status == "won", 1), else_=0)).label("wonCount"),
|
|
func.sum(case((CrmOpportunity.status == "lost", 1), else_=0)).label("lostCount"),
|
|
func.coalesce(
|
|
func.sum(case((CrmOpportunity.status == "won", CrmOpportunity.amount), else_=0)),
|
|
0,
|
|
).label("wonAmount"),
|
|
func.coalesce(
|
|
func.sum(
|
|
case((CrmOpportunity.status == "following", CrmOpportunity.amount), else_=0)
|
|
),
|
|
0,
|
|
).label("followingAmount"),
|
|
)
|
|
.group_by(CrmOpportunity.owner_user_id, CrmOpportunity.owner_user_name)
|
|
.order_by(
|
|
func.sum(case((CrmOpportunity.status == "won", 1), else_=0)).desc(),
|
|
func.coalesce(
|
|
func.sum(case((CrmOpportunity.status == "won", CrmOpportunity.amount), else_=0)),
|
|
0,
|
|
).desc(),
|
|
)
|
|
.all()
|
|
)
|
|
result = []
|
|
for r in rows:
|
|
won = int(r.wonCount or 0)
|
|
lost = int(r.lostCount or 0)
|
|
closed = won + lost
|
|
win_rate = round(won * 100.0 / closed, 2) if closed else 0
|
|
result.append(
|
|
{
|
|
"ownerUserId": r.ownerUserId or "",
|
|
"ownerUserName": r.ownerUserName or "",
|
|
"totalCount": int(r.totalCount or 0),
|
|
"followingCount": int(r.followingCount or 0),
|
|
"wonCount": won,
|
|
"lostCount": lost,
|
|
"winRate": win_rate,
|
|
"wonAmount": float(r.wonAmount or 0),
|
|
"followingAmount": float(r.followingAmount or 0),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def get_kanban_data(
|
|
db: Session,
|
|
tenant_no: str,
|
|
filters: dict = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
filters = filters or {}
|
|
result: Dict[str, Any] = {}
|
|
for stage in _KANBAN_STAGES:
|
|
q = _scoped_opportunity_query(db, tenant_no, user_info).filter(
|
|
CrmOpportunity.stage == stage,
|
|
CrmOpportunity.status == "following",
|
|
)
|
|
if filters.get("customer_id"):
|
|
q = q.filter(CrmOpportunity.customer_id == filters["customer_id"])
|
|
if filters.get("source"):
|
|
q = q.filter(CrmOpportunity.source == filters["source"])
|
|
if filters.get("owner_user_id"):
|
|
q = q.filter(CrmOpportunity.owner_user_id == filters["owner_user_id"])
|
|
if filters.get("create_time_start"):
|
|
q = q.filter(CrmOpportunity.create_time >= filters["create_time_start"])
|
|
if filters.get("create_time_end"):
|
|
q = q.filter(CrmOpportunity.create_time <= filters["create_time_end"])
|
|
|
|
rows = (
|
|
q.order_by(
|
|
CrmOpportunity.expect_deal_date.asc(),
|
|
CrmOpportunity.create_time.desc(),
|
|
)
|
|
.all()
|
|
)
|
|
opps = [_row_to_dict(r) for r in rows]
|
|
_enrich_user_names(db, opps)
|
|
total_amount = sum(float(o.get("amount") or 0) for o in opps)
|
|
result[stage] = {
|
|
"stage": stage,
|
|
"count": len(opps),
|
|
"totalAmount": total_amount,
|
|
"opportunities": opps,
|
|
}
|
|
return result
|
|
|
|
|
|
def get_sales_funnel(
|
|
db: Session,
|
|
tenant_no: str,
|
|
owner_user_id: str = None,
|
|
begin_time: str = None,
|
|
end_time: str = None,
|
|
user_info=None,
|
|
) -> Dict[str, Any]:
|
|
funnel_data: List[Dict[str, Any]] = []
|
|
for i, stage in enumerate(_FUNNEL_STAGES):
|
|
q = _scoped_opportunity_query(db, tenant_no, user_info, owner_user_id)
|
|
if begin_time:
|
|
q = q.filter(CrmOpportunity.create_time >= begin_time)
|
|
if end_time:
|
|
q = q.filter(CrmOpportunity.create_time <= end_time)
|
|
|
|
if stage == "won":
|
|
q = q.filter(CrmOpportunity.status == "won")
|
|
else:
|
|
q = q.filter(CrmOpportunity.stage == stage, CrmOpportunity.status == "following")
|
|
|
|
rows = q.all()
|
|
count = len(rows)
|
|
total_amount = sum(float(r.amount or 0) for r in rows)
|
|
avg_win_rate = Decimal("0")
|
|
rates = [r.win_rate for r in rows if r.win_rate is not None]
|
|
if rates:
|
|
avg_win_rate = sum(rates) / len(rates)
|
|
|
|
stage_data: Dict[str, Any] = {
|
|
"stage": stage,
|
|
"stageName": _FUNNEL_STAGE_NAMES[i],
|
|
"count": count,
|
|
"totalAmount": float(total_amount),
|
|
"avgWinRate": float(avg_win_rate.quantize(Decimal("0.01")) if rates else 0),
|
|
}
|
|
if i > 0 and funnel_data:
|
|
prev_count = funnel_data[i - 1]["count"]
|
|
conversion = (
|
|
round(count * 100.0 / prev_count, 2) if prev_count else 0
|
|
)
|
|
stage_data["conversionRate"] = conversion
|
|
else:
|
|
stage_data["conversionRate"] = 100
|
|
funnel_data.append(stage_data)
|
|
|
|
total_opp = funnel_data[0]["count"] if funnel_data else 0
|
|
won_opp = funnel_data[-1]["count"] if funnel_data else 0
|
|
overall = round(won_opp * 100.0 / total_opp, 2) if total_opp else 0
|
|
summary = {
|
|
"totalOpportunities": total_opp,
|
|
"wonOpportunities": won_opp,
|
|
"overallConversionRate": overall,
|
|
"totalAmount": sum(d.get("totalAmount", 0) for d in funnel_data),
|
|
}
|
|
return {"funnelData": funnel_data, "summary": summary}
|