Files
shanxia2.0/backend/app/crud/crm/opportunity.py
T
2026-08-21 16:50:47 +08:00

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}