99773f69b9
全项目同型审计结论的收尾项:统计分析 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>
450 lines
21 KiB
Python
450 lines
21 KiB
Python
"""Tests for GET /api/staff-stats — 员工 7 类计数 + 分型明细(本地聚合,spec §4.5)。"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import (
|
|
ChatGroup,
|
|
Customer,
|
|
CustomerAccountLink,
|
|
Message,
|
|
PhoneCall,
|
|
WechatAccount,
|
|
)
|
|
|
|
OWNER = "wxid_o"
|
|
UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId!
|
|
WIN_START = 1_700_000_000_000
|
|
WIN_END = 1_900_000_000_000
|
|
IN = 1_800_000_000_000 # 窗内锚点
|
|
DAY = 86_400_000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 计数模式(无 kind)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _seed_counts(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
# 新客户: add_time 窗内 1 + 兜底首消息窗内 1 + 窗外 1(不计)
|
|
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER, add_time=IN))
|
|
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None))
|
|
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
|
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
|
timestamp=IN + 1))
|
|
# 红包+转账(窗内各 1)
|
|
s.add(Message(msg_id="m2", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
|
timestamp=IN + 2, type="red_packet"))
|
|
s.add(Message(msg_id="m3", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
|
timestamp=IN + 3, type="transfer"))
|
|
# 新群: 窗内 1 + 窗外 1(不计)
|
|
s.add(ChatGroup(room_id="r1@chatroom", channel="wechat", owner_account_id=OWNER, create_time=IN))
|
|
s.add(ChatGroup(room_id="r2@chatroom", channel="wechat", owner_account_id=OWNER, create_time=1_000))
|
|
# 删除/拉黑(现存量口径,忽略窗口)
|
|
s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1))
|
|
s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True))
|
|
# link 行(生产抓取经 upsert_customer 成对写;新客户/删除统计读 link)
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2"))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c9", delete_flag=1))
|
|
# 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径)
|
|
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
|
start_call_time=IN + 4, call_number="c1"))
|
|
s.add(PhoneCall(call_id="pc_x", channel="voice_call", user_id=OWNER,
|
|
start_call_time=IN + 5, call_number="c1"))
|
|
await s.commit()
|
|
|
|
|
|
async def test_counts_all_seven_kinds(make_client):
|
|
"""7 类计数一次返回;sensitive_words 恒 0;voice_calls 走 user_id 换算。"""
|
|
client = await make_client(_seed_counts)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert set(body) == {
|
|
"new_customers", "new_groups", "red_transfer",
|
|
"del_customers", "blocked_customers", "voice_calls", "sensitive_words",
|
|
}
|
|
assert body["new_customers"] == 2 # c1(add_time) + c2(首消息兜底); c3 窗外
|
|
assert body["new_groups"] == 1
|
|
assert body["red_transfer"] == 2
|
|
assert body["del_customers"] == 1 # 现存量
|
|
assert body["blocked_customers"] == 1 # 现存量
|
|
assert body["voice_calls"] == 1 # 直查 wxid 会得 0 —— join user_id
|
|
assert body["sensitive_words"] == 0 # 占位,spec 决策
|
|
|
|
|
|
async def test_counts_default_window_last_7_days(make_client):
|
|
"""缺省 start/end → 近 7 天:窗内计、8 天前不计。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
now = int(time.time() * 1000)
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now))
|
|
s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER,
|
|
add_time=now - 8 * DAY))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_now", add_time=now))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_old",
|
|
add_time=now - 8 * DAY))
|
|
s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID,
|
|
start_call_time=now, call_number="c_now"))
|
|
s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID,
|
|
start_call_time=now - 8 * DAY))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get("/api/staff-stats", params={"wechat_id": OWNER})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["new_customers"] == 1 # 只计窗内
|
|
assert body["voice_calls"] == 1
|
|
|
|
|
|
async def test_counts_unknown_staff_all_zero(make_client):
|
|
"""未知员工:全 0(不 404,前端弹窗兜底)。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": "wxid_ghost", "start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == {
|
|
"new_customers": 0, "new_groups": 0, "red_transfer": 0,
|
|
"del_customers": 0, "blocked_customers": 0, "voice_calls": 0,
|
|
"sensitive_words": 0,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 明细模式(?kind=) — 每 kind 字段各一条
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_detail_new_customers_fields(make_client):
|
|
"""new_customers 行 {talker_id, nickname, avatar, remark, time};兜底行 time=首消息 ms。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER, add_time=IN,
|
|
friend_nickname="牛牛", friend_remark="大客户",
|
|
head_image_url="http://a/1.png"))
|
|
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None,
|
|
friend_nickname="兜底客"))
|
|
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN,
|
|
friend_nickname="牛牛", friend_remark="大客户",
|
|
head_image_url="http://a/1.png"))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2",
|
|
friend_nickname="兜底客"))
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
|
|
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
|
timestamp=IN - 5))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "new_customers",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["kind"] == "new_customers"
|
|
by = {row["talker_id"]: row for row in body["rows"]}
|
|
assert set(by) == {"c1", "c2"} # c3 窗外
|
|
assert by["c1"]["nickname"] == "牛牛"
|
|
assert by["c1"]["remark"] == "大客户"
|
|
assert by["c1"]["avatar"] == "http://a/1.png"
|
|
assert by["c1"]["time"] == IN
|
|
assert by["c2"]["time"] == IN - 5 # add_time NULL → 首条消息兜底
|
|
# 排序:time desc(新→旧)
|
|
assert [row["talker_id"] for row in body["rows"]] == ["c1", "c2"]
|
|
|
|
|
|
async def test_detail_new_groups_fields(make_client):
|
|
"""new_groups 行 {room_id, room_name, time};只计本 owner 窗内。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(ChatGroup(room_id="r1@chatroom", channel="wechat", owner_account_id=OWNER,
|
|
room_name="老客户群", create_time=IN))
|
|
s.add(ChatGroup(room_id="r2@chatroom", channel="wechat", owner_account_id=OWNER,
|
|
room_name="窗外群", create_time=1_000))
|
|
s.add(ChatGroup(room_id="r3@chatroom", channel="wechat", owner_account_id="wxid_other",
|
|
room_name="别人的群", create_time=IN))
|
|
s.add(ChatGroup(room_id="r4@chatroom", channel="wechat", owner_account_id=OWNER,
|
|
room_name="无建群时间", create_time=None))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "new_groups",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
rows = r.json()["rows"]
|
|
assert rows == [{"room_id": "r1@chatroom", "room_name": "老客户群", "time": IN}]
|
|
|
|
|
|
async def test_detail_red_transfer_fields(make_client):
|
|
"""red_transfer 行 {op_type, talker_id, room_id, nickname, amount, time, msg_id};
|
|
金额双格式提取(红包 XML feedesc / 转账纯文本),失败 None;nickname 客户优先。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER,
|
|
friend_nickname="客户昵称"))
|
|
# 红包: XML <feedesc>¥88.00</feedesc>
|
|
s.add(Message(msg_id="m2", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
|
timestamp=IN, type="red_packet",
|
|
text="<msg><appmsg><type>2001</type>"
|
|
"<wcpayinfo><feedesc>¥88.00</feedesc></wcpayinfo>"
|
|
"</appmsg></msg>"))
|
|
# 转账: 云客纯文本「发送转账:¥100.00」;c2 无 customer 行 → raw_json.name 兜底
|
|
s.add(Message(msg_id="m3", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
|
timestamp=IN + 1, type="transfer", text="发送转账:¥100.00",
|
|
raw_json={"name": "裸数据客"}))
|
|
# 转账无金额可提 → amount None
|
|
s.add(Message(msg_id="m4", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
|
timestamp=IN + 2, type="transfer", text=None))
|
|
# 普通消息不计
|
|
s.add(Message(msg_id="m_text", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
|
timestamp=IN + 3, type="text", text="你好"))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "red_transfer",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
rows = r.json()["rows"]
|
|
assert [row["msg_id"] for row in rows] == ["m4", "m3", "m2"] # time desc
|
|
by = {row["msg_id"]: row for row in rows}
|
|
assert by["m2"]["op_type"] == "红包"
|
|
assert by["m2"]["amount"] == 88.0
|
|
assert by["m2"]["talker_id"] == "c1"
|
|
assert by["m2"]["room_id"] is None
|
|
assert by["m2"]["nickname"] == "客户昵称"
|
|
assert by["m2"]["time"] == IN
|
|
assert by["m3"]["op_type"] == "转账"
|
|
assert by["m3"]["amount"] == 100.0
|
|
assert by["m3"]["nickname"] == "裸数据客" # customer 无行 → raw_json.name
|
|
assert by["m4"]["amount"] is None
|
|
|
|
|
|
async def test_detail_del_and_blocked_fields(make_client):
|
|
"""del/blocked 行 {talker_id, nickname, time};time=updated_at 毫秒;只计本 owner。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Customer(friend_id="c_del", channel="wechat", owner_account_id=OWNER,
|
|
delete_flag=1, friend_nickname="已删客"))
|
|
s.add(Customer(friend_id="c_blk", channel="wechat", owner_account_id=OWNER,
|
|
is_block=True, friend_nickname=None, friend_remark="被拉黑"))
|
|
# 干扰项: 正常客户 / 别人的删除客户 / delete_flag=0
|
|
s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER))
|
|
s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other",
|
|
delete_flag=1))
|
|
# 删除读 link:c_del 补 link 行;is_block 保持档案(c_blk 不补)
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_del",
|
|
delete_flag=1, friend_nickname="已删客"))
|
|
s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER,
|
|
delete_flag=0, is_block=False))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r_del = await client.get("/api/staff-stats", params={"wechat_id": OWNER, "kind": "del_customers"})
|
|
r_blk = await client.get("/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "blocked_customers"})
|
|
assert r_del.status_code == r_blk.status_code == 200
|
|
rows_del = r_del.json()["rows"]
|
|
assert len(rows_del) == 1
|
|
d = rows_del[0]
|
|
assert d["talker_id"] == "c_del" and d["nickname"] == "已删客"
|
|
assert isinstance(d["time"], int) and d["time"] > WIN_START # updated_at ms
|
|
rows_blk = r_blk.json()["rows"]
|
|
assert len(rows_blk) == 1
|
|
b = rows_blk[0]
|
|
assert b["talker_id"] == "c_blk"
|
|
assert b["nickname"] == "被拉黑" # nickname 空时回退备注
|
|
assert isinstance(b["time"], int) and b["time"] > WIN_START
|
|
|
|
|
|
async def test_detail_voice_calls_fields(make_client):
|
|
"""voice_calls 行 {id, peer, incoming, connected, duration, start_time, media_url};
|
|
语义对齐 CallList.vue:connected=voice_call 口径 call_status==1,
|
|
media_url 本地 /api/media/{hash} 优先、OSS record_file 兜底。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
|
start_call_time=IN, call_number="c1", incoming_call=1,
|
|
call_status=1, call_duration=62, record_media_hash="abc123"))
|
|
s.add(PhoneCall(call_id="pc2", channel="voice_call", user_id=UID,
|
|
start_call_time=IN + 1, call_number="room@chatroom",
|
|
incoming_call=0, call_status=2, call_duration=0,
|
|
record_file="http://oss/x.mp3"))
|
|
# 干扰项: 他人语音 / 电话通话(同 uid 也不计)
|
|
s.add(PhoneCall(call_id="pc3", channel="voice_call", user_id="other_uid",
|
|
start_call_time=IN))
|
|
s.add(PhoneCall(call_id="pc4", channel="phone_call", user_id=UID,
|
|
start_call_time=IN))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "voice_calls",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
rows = r.json()["rows"]
|
|
assert [row["id"] for row in rows] == ["pc2", "pc1"] # start_time desc
|
|
by = {row["id"]: row for row in rows}
|
|
assert by["pc1"]["peer"] == "c1"
|
|
assert by["pc1"]["incoming"] == 1
|
|
assert by["pc1"]["connected"] is True # voice_call: call_status==1 接通
|
|
assert by["pc1"]["duration"] == 62
|
|
assert by["pc1"]["start_time"] == IN
|
|
assert by["pc1"]["media_url"] == "/api/media/abc123" # 本地优先
|
|
assert by["pc2"]["peer"] == "room@chatroom"
|
|
assert by["pc2"]["incoming"] == 0
|
|
assert by["pc2"]["connected"] is False # voice_call: call_status==2 未接通
|
|
assert by["pc2"]["media_url"] == "http://oss/x.mp3" # OSS 兜底
|
|
|
|
|
|
async def test_detail_unknown_kind_422(make_client):
|
|
"""非法 kind → 422(Literal 校验)。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get("/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "bogus"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 边缘口径
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_amount_thousands_separator(make_client):
|
|
"""金额千分位:``¥1,000.50`` → 1000.5(不能截成 1);feedesc 同口径。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Message(msg_id="m_plain", channel="wechat", owner_account_id=OWNER,
|
|
talker_id="c1", timestamp=IN, type="transfer",
|
|
text="发送转账:¥1,000.50"))
|
|
s.add(Message(msg_id="m_xml", channel="wechat", owner_account_id=OWNER,
|
|
talker_id="c1", timestamp=IN + 1, type="red_packet",
|
|
text="<wcpayinfo><feedesc>¥2,000.00</feedesc></wcpayinfo>"))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "red_transfer",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
by = {row["msg_id"]: row["amount"] for row in r.json()["rows"]}
|
|
assert by["m_plain"] == 1000.5
|
|
assert by["m_xml"] == 2000.0
|
|
|
|
|
|
async def test_voice_calls_account_without_user_id(make_client):
|
|
"""账号存在但 user_id 为 NULL(未同步):voice_calls 计 0、明细空,
|
|
而非把 user_id IS NULL 的通话误归属(防跨员工串数)。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=None))
|
|
s.add(PhoneCall(call_id="pc_null", channel="voice_call", user_id=None,
|
|
start_call_time=IN, call_number="c1"))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r_count = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "start": WIN_START, "end": WIN_END},
|
|
)
|
|
r_detail = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "voice_calls",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r_count.status_code == r_detail.status_code == 200
|
|
assert r_count.json()["voice_calls"] == 0
|
|
assert r_detail.json()["rows"] == []
|
|
|
|
|
|
async def test_new_customers_fallback_first_msg_before_window(make_client):
|
|
"""add_time NULL 兜底排除项:首条消息在窗前 → 不计(窗后/窗内才计)。"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
s.add(Customer(friend_id="c_early", channel="wechat", owner_account_id=OWNER,
|
|
add_time=None))
|
|
s.add(Message(msg_id="m_early", channel="wechat", owner_account_id=OWNER,
|
|
talker_id="c_early", timestamp=WIN_START - 1))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r_count = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "start": WIN_START, "end": WIN_END},
|
|
)
|
|
r_detail = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "new_customers",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r_count.status_code == r_detail.status_code == 200
|
|
assert r_count.json()["new_customers"] == 0
|
|
assert r_detail.json()["rows"] == []
|
|
|
|
|
|
async def test_new_customers_counts_shared_friend_via_link(make_client):
|
|
"""共享好友(档案挂别的员工名下)凭 link 行计入新客户(2026-09-09 口径修复)。
|
|
|
|
旧档案口径下 c_shared 对 OWNER 不可见(add_time 在窗内也不计);切 link
|
|
后按 per-owner 关系计数,与云客按员工统计一致。
|
|
"""
|
|
|
|
async def seed(s: AsyncSession) -> None:
|
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
|
# 档案行(last-wins)挂在别的员工名下
|
|
s.add(Customer(friend_id="c_shared", channel="wechat",
|
|
owner_account_id="wxid_other", add_time=IN))
|
|
# OWNER 名下的 link 关系
|
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER,
|
|
friend_id="c_shared", add_time=IN))
|
|
await s.commit()
|
|
|
|
client = await make_client(seed)
|
|
r = await client.get(
|
|
"/api/staff-stats",
|
|
params={"wechat_id": OWNER, "kind": "new_customers",
|
|
"start": WIN_START, "end": WIN_END},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert [row["talker_id"] for row in body["rows"]] == ["c_shared"]
|
|
assert len(body["rows"]) == 1
|