feat(management): 员工指标宽表——1:1口径/串类与fact同源/生命周期列/幽灵过滤/纯生命周期日出行
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,14 +11,18 @@ from zoneinfo import ZoneInfo
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.db.models import (
|
||||
Customer,
|
||||
EmployeeCallDaily,
|
||||
EmployeeMetricDaily,
|
||||
FirstReplyFact,
|
||||
Message,
|
||||
MsgDailyStats,
|
||||
PhoneCall,
|
||||
Staff,
|
||||
WechatAccount,
|
||||
WhatsappAccount,
|
||||
)
|
||||
from app.storage.employee_metrics import recompute_call_days
|
||||
from app.storage.employee_metrics import recompute_call_days, recompute_metric_days
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
@@ -93,3 +97,110 @@ async def test_call_daily_ignores_unknown_staff(make_session):
|
||||
await s.commit()
|
||||
assert (await s.execute(
|
||||
sa.select(sa.func.count()).select_from(EmployeeCallDaily))).scalar_one() == 0
|
||||
|
||||
|
||||
async def _seed_msg_rows(s, *, day=20260901, channel="wechat", owner="wx1",
|
||||
kind="friend", peer="p1", send=3, recv=2):
|
||||
s.add(MsgDailyStats(channel=channel, owner_account_id=owner, peer_kind=kind,
|
||||
peer_id=peer, day=day, send_cnt=send, recv_cnt=recv))
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def test_metric_daily_friend_only_caliber(make_session):
|
||||
"""口径判别(v2 §1.1 #2 防线):room 行不进消息计数/串列,只进 active_rooms。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
await _seed_msg_rows(s, send=3, recv=2, kind="friend", peer="p1")
|
||||
await _seed_msg_rows(s, send=10, recv=0, kind="room", peer="r1")
|
||||
# friend 会话给一条消息供串扫描(无消息则无串)
|
||||
s.add(Message(msg_id="p1-0", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="p1", timestamp=_ts(2026, 9, 1, 10, 0),
|
||||
is_send=False, type="text", text="q"))
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20260901])
|
||||
await s.commit()
|
||||
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||
assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进
|
||||
assert row.active_rooms == 1
|
||||
assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串
|
||||
|
||||
|
||||
async def test_metric_daily_ghost_filtered(make_session):
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
await _seed_msg_rows(s, owner="wx-ghost", peer="pg")
|
||||
await recompute_metric_days(s, [20260901])
|
||||
await s.commit()
|
||||
assert (await s.execute(
|
||||
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one() == 0
|
||||
|
||||
|
||||
async def test_metric_daily_same_source_as_fact(make_session):
|
||||
"""同源判别(v2 §1.1 #5 防线):
|
||||
1) threads_answered ≡ fact 行数(同归属日);
|
||||
2) 多日未回串宽表 threads < 日切 cust_threads 之和(分母不再膨胀);
|
||||
3) 幂等。"""
|
||||
from app.storage.first_reply_facts import recompute_pairs
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
# 客户 D1 10:00、D1 20:00、D2 09:00 三条消息,始终未回 → 1 个跨日串
|
||||
rows = [(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False),
|
||||
(_ts(2026, 9, 2, 9, 0), False)]
|
||||
for i, (ts, is_send) in enumerate(rows):
|
||||
s.add(Message(msg_id=f"g{i}", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pg", timestamp=ts, is_send=is_send,
|
||||
type="text", text="x"))
|
||||
# 日切口径的 msg_daily_stats:D1、D2 各计一个未回串(供对照)
|
||||
await _seed_msg_rows(s, day=20260901, peer="pg", send=0, recv=2)
|
||||
await _seed_msg_rows(s, day=20260902, peer="pg", send=0, recv=1)
|
||||
await s.commit()
|
||||
await recompute_pairs(s, [("wechat", "wx1", "pg")])
|
||||
await s.commit()
|
||||
await recompute_metric_days(s, [20260901, 20260902])
|
||||
await s.commit()
|
||||
got = {r.day: r for r in (
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
# 1) fact 行数=0(未回),宽表 threads_answered 也=0 且同源
|
||||
assert (await s.execute(
|
||||
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one() == 0
|
||||
# 2) 跨日串只归属 D2;日切 D1+D2=2,宽表合计=1
|
||||
assert got[20260901].cust_threads == 0
|
||||
assert got[20260902].cust_threads == 1
|
||||
# 3) 幂等
|
||||
await recompute_metric_days(s, [20260901, 20260902])
|
||||
await s.commit()
|
||||
assert len((await s.execute(
|
||||
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
|
||||
|
||||
|
||||
async def test_metric_daily_lifecycle_counts(make_session):
|
||||
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
# 客户 pc:D1 有互动后静默(历史≥5 条) → as_of=10-10 为 dormant;
|
||||
# 10-11 回流 → 该日 reactivated
|
||||
s.add(Customer(friend_id="pc", channel="wechat", owner_account_id="wx1",
|
||||
delete_flag=0))
|
||||
hist = []
|
||||
for h in range(9, 17): # D1 8 条消息(≥5)
|
||||
hist.append((_ts(2026, 9, 1, h, 0), h % 2 == 0))
|
||||
for i, (ts, is_send) in enumerate(hist):
|
||||
s.add(Message(msg_id=f"lc{i}", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pc", timestamp=ts, is_send=is_send,
|
||||
type="text", text="x"))
|
||||
await _seed_msg_rows(s, day=20260901, peer="pc", send=4, recv=4)
|
||||
await s.commit()
|
||||
# D41(10-11)客户回流一条
|
||||
s.add(Message(msg_id="lc-b", channel="wechat", owner_account_id="wx1",
|
||||
talker_id="pc", timestamp=_ts(2026, 10, 11, 9, 0),
|
||||
is_send=False, type="text", text="back"))
|
||||
await _seed_msg_rows(s, day=20261011, peer="pc", send=0, recv=1)
|
||||
await s.commit()
|
||||
from app.storage.first_reply_facts import recompute_pairs
|
||||
await recompute_pairs(s, [("wechat", "wx1", "pc")])
|
||||
await recompute_metric_days(s, [20261010, 20261011])
|
||||
await s.commit()
|
||||
got = {r.day: r for r in (
|
||||
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||
assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条
|
||||
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
|
||||
|
||||
Reference in New Issue
Block a user