fix(staff-stats): 统计tab新客户/删除客户/红包昵称切 link——共享好友不再漏计
全项目同型审计结论的收尾项:统计分析 tab 按 owner 查全局单行档案表, 共享好友(档案挂别的员工名下)不计入—— - _new_customers(新客户):主体 Customer → CustomerAccountLink(add_time 窗+首条消息兜底 JOIN 平移到 link.owner);数字变大=对齐云客按员工口径 - _del_customers(删除客户):_flag_customers 参数化 model,del 走 link (delete_flag per-relation);blocked 保留档案口径(is_block 未镜像进 link,已知偏差=他人员工名下拉黑不计,注释标明) - _red_transfer 昵称:link 优先档案兜底(同 sessions._meta 模式) 审计同时确认无需修:群侧(chat_group 复合主键天然多对多)、搜索/长语音 (按 friend 全局直查)、analytics(全局口径)、WA(与微信共用同批修复, 12,578 行 link 已回填,1,967 共享客户在册;无昵称行=WA 联系人本身没存 名字,与云客一致显示号码)。 644 测试绿(新增共享好友新客户判别测试;存量测试种子补 link 行镜像 生产写路径)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut
|
||||
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount
|
||||
from app.db.models import (
|
||||
ChatGroup,
|
||||
Customer,
|
||||
CustomerAccountLink,
|
||||
Message,
|
||||
PhoneCall,
|
||||
WechatAccount,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["staff-stats"])
|
||||
|
||||
@@ -96,50 +103,57 @@ async def _new_customers(
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
|
||||
|
||||
读 ``customer_account_link``(2026-09-09):customer 档案全局单行、
|
||||
归属 last-wins,共享客户在其他员工名下会漏计;link 每账号一行,与
|
||||
云客按员工统计的口径一致(数字会比档案口径大)。
|
||||
|
||||
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
|
||||
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
|
||||
"""
|
||||
base = [Customer.channel == "wechat", Customer.owner_account_id == wxid]
|
||||
base = [
|
||||
CustomerAccountLink.channel == "wechat",
|
||||
CustomerAccountLink.owner_account_id == wxid,
|
||||
]
|
||||
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
||||
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
||||
fallback = (
|
||||
sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
CustomerAccountLink.friend_id,
|
||||
CustomerAccountLink.friend_nickname,
|
||||
CustomerAccountLink.friend_remark,
|
||||
CustomerAccountLink.head_image_url,
|
||||
sa.func.min(Message.timestamp).label("first_ts"),
|
||||
)
|
||||
.outerjoin(
|
||||
Message,
|
||||
sa.and_(
|
||||
Message.channel == "wechat",
|
||||
Message.owner_account_id == Customer.owner_account_id,
|
||||
Message.talker_id == Customer.friend_id,
|
||||
Message.owner_account_id == CustomerAccountLink.owner_account_id,
|
||||
Message.talker_id == CustomerAccountLink.friend_id,
|
||||
Message.timestamp.is_not(None),
|
||||
),
|
||||
)
|
||||
.where(*base, Customer.add_time.is_(None))
|
||||
# GROUP BY 显式列出全部裸选列:friend_id 是 Customer 唯一 PK,其余列
|
||||
# 函数依赖于它→分组结果不变;MySQL 8 FD 推断可容忍裸列,但显式列表
|
||||
# 在 ONLY_FULL_GROUP_BY 下部署安全(SQLite 同样兼容)。
|
||||
.where(*base, CustomerAccountLink.add_time.is_(None))
|
||||
# GROUP BY 显式列出全部裸选列:friend_id 是 link 复合 PK 的尾列,
|
||||
# (owner,friend) 已由 WHERE 钉死,其余列函数依赖于它→分组结果不变;
|
||||
# MySQL ONLY_FULL_GROUP_BY 部署安全(SQLite 同样兼容)。
|
||||
.group_by(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
CustomerAccountLink.friend_id,
|
||||
CustomerAccountLink.friend_nickname,
|
||||
CustomerAccountLink.friend_remark,
|
||||
CustomerAccountLink.head_image_url,
|
||||
)
|
||||
.having(sa.func.min(Message.timestamp) >= start)
|
||||
.having(sa.func.min(Message.timestamp) <= end)
|
||||
)
|
||||
# 直接口径:add_time 本身在窗内。
|
||||
direct = sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
Customer.add_time,
|
||||
).where(*base, Customer.add_time >= start, Customer.add_time <= end)
|
||||
CustomerAccountLink.friend_id,
|
||||
CustomerAccountLink.friend_nickname,
|
||||
CustomerAccountLink.friend_remark,
|
||||
CustomerAccountLink.head_image_url,
|
||||
CustomerAccountLink.add_time,
|
||||
).where(*base, CustomerAccountLink.add_time >= start, CustomerAccountLink.add_time <= end)
|
||||
|
||||
rows: list[dict[str, Any]] = [
|
||||
{
|
||||
@@ -237,19 +251,43 @@ async def _red_transfer(
|
||||
talker_ids = [m.talker_id for m in msgs if m.talker_id]
|
||||
nick_map: dict[str, str] = {}
|
||||
if talker_ids:
|
||||
# 昵称优先 link per-owner 快照(共享好友档案行可能挂别的员工名下,
|
||||
# 2026-09-09 同 sessions._meta),档案兜底
|
||||
nick_map = {
|
||||
fid: nick
|
||||
for fid, nick in (
|
||||
await session.execute(
|
||||
sa.select(Customer.friend_id, Customer.friend_nickname).where(
|
||||
Customer.channel == "wechat",
|
||||
Customer.owner_account_id == wxid,
|
||||
Customer.friend_id.in_(talker_ids),
|
||||
sa.select(
|
||||
CustomerAccountLink.friend_id,
|
||||
CustomerAccountLink.friend_nickname,
|
||||
).where(
|
||||
CustomerAccountLink.channel == "wechat",
|
||||
CustomerAccountLink.owner_account_id == wxid,
|
||||
CustomerAccountLink.friend_id.in_(talker_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if nick
|
||||
}
|
||||
missing = [t for t in talker_ids if t not in nick_map]
|
||||
if missing:
|
||||
nick_map.update(
|
||||
{
|
||||
fid: nick
|
||||
for fid, nick in (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
Customer.friend_id, Customer.friend_nickname
|
||||
).where(
|
||||
Customer.channel == "wechat",
|
||||
Customer.owner_account_id == wxid,
|
||||
Customer.friend_id.in_(missing),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if nick
|
||||
}
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for m in msgs:
|
||||
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
|
||||
@@ -273,15 +311,20 @@ async def _flag_customers(
|
||||
flag_cond: sa.ColumnElement,
|
||||
*,
|
||||
detail: bool,
|
||||
model: type,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
|
||||
|
||||
``model`` 由调用方决定(两者列名同形):del 走 ``CustomerAccountLink``
|
||||
(delete_flag per-relation,共享好友不漏),blocked 走 ``Customer``
|
||||
档案(is_block 未镜像进 link,2026-09-09)。
|
||||
|
||||
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
|
||||
"""
|
||||
conds = [Customer.channel == "wechat", Customer.owner_account_id == wxid, flag_cond]
|
||||
conds = [model.channel == "wechat", model.owner_account_id == wxid, flag_cond]
|
||||
count = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(Customer).where(*conds)
|
||||
sa.select(sa.func.count()).select_from(model).where(*conds)
|
||||
)
|
||||
).scalar_one()
|
||||
if not detail:
|
||||
@@ -289,13 +332,13 @@ async def _flag_customers(
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.updated_at,
|
||||
model.friend_id,
|
||||
model.friend_nickname,
|
||||
model.friend_remark,
|
||||
model.updated_at,
|
||||
)
|
||||
.where(*conds)
|
||||
.order_by(Customer.updated_at.desc(), Customer.friend_id.asc())
|
||||
.order_by(model.updated_at.desc(), model.friend_id.asc())
|
||||
)
|
||||
).all()
|
||||
return count, [
|
||||
@@ -311,18 +354,26 @@ async def _flag_customers(
|
||||
async def _del_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。"""
|
||||
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。
|
||||
|
||||
读 link(per-relation delete_flag,共享好友不漏计,2026-09-09)。
|
||||
"""
|
||||
return await _flag_customers(
|
||||
session, wxid, Customer.delete_flag == 1, detail=detail
|
||||
session, wxid, CustomerAccountLink.delete_flag == 1,
|
||||
detail=detail, model=CustomerAccountLink,
|
||||
)
|
||||
|
||||
|
||||
async def _blocked_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。"""
|
||||
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。
|
||||
|
||||
保持档案口径:is_block 未镜像进 link 表,切了会漏;已知偏差=共享好友
|
||||
在其他员工名下的拉黑不计入(待 link 补列后切换)。
|
||||
"""
|
||||
return await _flag_customers(
|
||||
session, wxid, Customer.is_block.is_(True), detail=detail
|
||||
session, wxid, Customer.is_block.is_(True), detail=detail, model=Customer
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user