49f2ffe8ea
Co-Authored-By: Claude Code <noreply@anthropic.com>
292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
"""A1/A3/B2 指标函数——纯查询,口径速查表见 spec §8。
|
|
|
|
时区:day 键一律 Asia/Shanghai 自然日(Python 侧算,禁 SQL 日期函数)。
|
|
周起始=周一(scope=week)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from collections.abc import Iterable
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import MsgDailyStats, PhoneCall, StatDailyStaff
|
|
from app.storage.staff_summary import account_map
|
|
from app.storage.staff_summary import has_rows as _staff_rows
|
|
from app.storage.summary import day_bounds_ms
|
|
|
|
_SH = ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
def _date_key(d: dt.date) -> int:
|
|
return d.year * 10000 + d.month * 100 + d.day
|
|
|
|
|
|
def today_key(now: dt.datetime | None = None) -> int:
|
|
"""上海自然日 yyyymmdd(测试 monkeypatch 点)。"""
|
|
return _date_key((now or dt.datetime.now(_SH)).date())
|
|
|
|
|
|
def _day_date(day: int) -> dt.date:
|
|
return dt.date(day // 10000, (day // 100) % 100, day % 100)
|
|
|
|
|
|
def scope_day_keys(scope: str, today: int) -> list[int]:
|
|
"""today={今};week=本周一..今天;month=本月1号..今天(含端点)。"""
|
|
end = _day_date(today)
|
|
if scope == "today":
|
|
start = end
|
|
elif scope == "week":
|
|
start = end - dt.timedelta(days=end.weekday()) # 周一=0
|
|
elif scope == "month":
|
|
start = end.replace(day=1)
|
|
else:
|
|
raise ValueError(f"bad scope: {scope!r}")
|
|
out, cur = [], start
|
|
while cur <= end:
|
|
out.append(_date_key(cur))
|
|
cur += dt.timedelta(days=1)
|
|
return out
|
|
|
|
|
|
def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
|
|
"""紧邻的前一个等长窗口(环比;今日 scope 不调用)。"""
|
|
n = len(days)
|
|
last = _day_date(days[0]) - dt.timedelta(days=1)
|
|
return [
|
|
_date_key(last - dt.timedelta(days=i))
|
|
for i in range(n - 1, -1, -1)
|
|
]
|
|
|
|
|
|
async def _day_rows(
|
|
session: AsyncSession, channel: str, days: Iterable[int]
|
|
) -> list[Any]:
|
|
return (
|
|
await session.execute(
|
|
sa.select(
|
|
MsgDailyStats.day,
|
|
MsgDailyStats.peer_kind,
|
|
MsgDailyStats.peer_id,
|
|
MsgDailyStats.send_cnt,
|
|
MsgDailyStats.recv_cnt,
|
|
).where(
|
|
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(list(days))
|
|
)
|
|
)
|
|
).all()
|
|
|
|
|
|
async def _call_count(session: AsyncSession, channel_scope_days: list[int]) -> int:
|
|
"""phone_call 落窗计数(3k 行小表;start_call_time ms 与 day 键边界换算)。"""
|
|
if not channel_scope_days:
|
|
return 0
|
|
lo, _hi = day_bounds_ms(channel_scope_days[0])
|
|
hi_day_start, _ = day_bounds_ms(channel_scope_days[-1])
|
|
hi = hi_day_start + 86_400_000 # 含末日全天
|
|
val = (
|
|
await session.execute(
|
|
sa.select(sa.func.count()).select_from(PhoneCall).where(
|
|
PhoneCall.start_call_time.is_not(None),
|
|
PhoneCall.start_call_time >= lo,
|
|
PhoneCall.start_call_time < hi,
|
|
)
|
|
)
|
|
).scalar_one()
|
|
return int(val)
|
|
|
|
|
|
async def _first_contact(
|
|
session: AsyncSession, channel: str, days: list[int]
|
|
) -> int:
|
|
"""首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。"""
|
|
rows = (
|
|
await session.execute(
|
|
sa.select(MsgDailyStats.peer_id, sa.func.min(MsgDailyStats.day))
|
|
.where(
|
|
MsgDailyStats.channel == channel,
|
|
MsgDailyStats.peer_kind == "friend",
|
|
)
|
|
.group_by(MsgDailyStats.peer_id)
|
|
)
|
|
).all()
|
|
dayset = set(days)
|
|
return sum(1 for _peer, first in rows if first in dayset)
|
|
|
|
|
|
async def _active_staff(
|
|
session: AsyncSession, channel: str, days: list[int]
|
|
) -> int:
|
|
"""活跃员工 distinct user_id;stat_daily_staff 空表回退 msg_daily_stats+映射。"""
|
|
if await _staff_rows(session):
|
|
val = (
|
|
await session.execute(
|
|
sa.select(sa.func.count(sa.func.distinct(StatDailyStaff.user_id))).where(
|
|
StatDailyStaff.channel == channel,
|
|
StatDailyStaff.day.in_(days),
|
|
)
|
|
)
|
|
).scalar_one()
|
|
return int(val)
|
|
amap = await account_map(session, channel)
|
|
owners = {amap.get(o, o) for (o,) in (
|
|
await session.execute(
|
|
sa.select(MsgDailyStats.owner_account_id).where(
|
|
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
|
|
)
|
|
)
|
|
).all()}
|
|
return len(owners)
|
|
|
|
|
|
def anomaly_threshold() -> float:
|
|
return 0.5 # 昨日 vs 前 7 日均值偏离阈值,v1 固定
|
|
|
|
|
|
async def anomaly(session: AsyncSession, channel: str) -> dict | None:
|
|
"""E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。"""
|
|
today = today_key()
|
|
yesterday = _day_date(today) - dt.timedelta(days=1)
|
|
|
|
yday = _date_key(yesterday)
|
|
prev7 = [_date_key(yesterday - dt.timedelta(days=i)) for i in range(1, 8)]
|
|
|
|
use_rollup = await _staff_rows(session)
|
|
if use_rollup:
|
|
rows = (
|
|
await session.execute(
|
|
sa.select(
|
|
StatDailyStaff.day, StatDailyStaff.user_id,
|
|
sa.func.coalesce(StatDailyStaff.msg_sent + StatDailyStaff.msg_recv, 0),
|
|
).where(
|
|
StatDailyStaff.channel == channel,
|
|
StatDailyStaff.day.in_([yday] + prev7),
|
|
)
|
|
)
|
|
).all()
|
|
else:
|
|
amap = await account_map(session, channel)
|
|
raw = (
|
|
await session.execute(
|
|
sa.select(
|
|
MsgDailyStats.day, MsgDailyStats.owner_account_id,
|
|
sa.func.coalesce(
|
|
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
|
|
),
|
|
)
|
|
.where(
|
|
MsgDailyStats.channel == channel,
|
|
MsgDailyStats.day.in_([yday] + prev7),
|
|
)
|
|
.group_by(MsgDailyStats.day, MsgDailyStats.owner_account_id)
|
|
)
|
|
).all()
|
|
rows = [(d, amap.get(o, o), v) for d, o, v in raw]
|
|
|
|
# 逐 user 显式算(yesterday 量 + prev7 均值),不猜 rows 顺序
|
|
day_rows: dict[str, dict[int, int]] = {}
|
|
for day, uid, v in rows:
|
|
day_rows.setdefault(uid, {})[day] = int(v)
|
|
|
|
staff_out = []
|
|
totals_y, totals_p = 0, 0.0
|
|
for uid, by_day in day_rows.items():
|
|
y = by_day.get(yday, 0)
|
|
pvals = [by_day.get(d, 0) for d in prev7]
|
|
pmean = sum(pvals) / len(pvals) if pvals else 0.0
|
|
totals_y += y
|
|
totals_p += pmean
|
|
if pmean > 0 and abs(y - pmean) / pmean > anomaly_threshold():
|
|
staff_out.append({
|
|
"user_id": uid,
|
|
"yesterday": y,
|
|
"prev7_mean": round(pmean, 1),
|
|
})
|
|
if totals_p <= 0:
|
|
return None
|
|
delta = (totals_y - totals_p) / totals_p
|
|
if abs(delta) > anomaly_threshold():
|
|
return {
|
|
"total_delta_pct": round(delta, 3),
|
|
"yesterday_total": totals_y,
|
|
"prev7_mean": round(totals_p, 1),
|
|
"staff": sorted(staff_out, key=lambda s: s["yesterday"]),
|
|
}
|
|
return None
|
|
|
|
|
|
async def overview(
|
|
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
|
|
) -> dict[str, Any]:
|
|
"""A1:卡片 + 环比(仅周/月) + 近 N 天趋势 + 异常警示。"""
|
|
today = today_key()
|
|
days = scope_day_keys(scope, today)
|
|
rows = await _day_rows(session, channel, days)
|
|
msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows)
|
|
active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"})
|
|
|
|
cards: dict[str, Any] = {
|
|
"msg_total": msg_total,
|
|
"active_customers": active_customers,
|
|
"active_staff": await _active_staff(session, channel, days),
|
|
"call_cnt": await _call_count(session, days),
|
|
"first_contact": await _first_contact(session, channel, days),
|
|
}
|
|
|
|
# 环比仅周/月(今日 vs 昨日全天会误导,spec §8)
|
|
if scope in ("week", "month"):
|
|
prev_days = _prev_scope_days(scope, days)
|
|
prev_rows = await _day_rows(session, channel, prev_days)
|
|
prev_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in prev_rows)
|
|
cards["prev_msg_total"] = prev_total
|
|
cards["msg_total_delta"] = (
|
|
round((msg_total - prev_total) / prev_total, 3) if prev_total else None
|
|
)
|
|
|
|
# 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量
|
|
end = _day_date(today)
|
|
t_days = []
|
|
cur = end - dt.timedelta(days=trend_days - 1)
|
|
while cur <= end:
|
|
t_days.append(_date_key(cur))
|
|
cur += dt.timedelta(days=1)
|
|
t_rows = await _day_rows(session, channel, t_days)
|
|
by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days}
|
|
for d, kind, p, s, r in t_rows:
|
|
by_day[d]["msg"] += int(s or 0) + int(r or 0)
|
|
if kind == "friend":
|
|
by_day[d]["cust"].add(p)
|
|
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
|
|
lo, _ = day_bounds_ms(t_days[0])
|
|
hi_day, _ = day_bounds_ms(t_days[-1])
|
|
calls = (
|
|
await session.execute(
|
|
sa.select(PhoneCall.start_call_time).where(
|
|
PhoneCall.start_call_time.is_not(None),
|
|
PhoneCall.start_call_time >= lo,
|
|
PhoneCall.start_call_time < hi_day + 86_400_000,
|
|
)
|
|
)
|
|
).scalars().all()
|
|
call_by_day: dict[int, int] = {}
|
|
for ts in calls:
|
|
k = _date_key(dt.datetime.fromtimestamp(ts / 1000, _SH).date())
|
|
call_by_day[k] = call_by_day.get(k, 0) + 1
|
|
trend = [
|
|
{"day": d, "msg": v["msg"], "active_customers": len(v["cust"]),
|
|
"call_cnt": call_by_day.get(d, 0)}
|
|
for d, v in by_day.items()
|
|
]
|
|
|
|
return {
|
|
"scope": scope,
|
|
"channel": channel,
|
|
"days": days,
|
|
"cards": cards,
|
|
"trend": trend,
|
|
"anomaly": await anomaly(session, channel),
|
|
}
|