fix(api): staff-stats评审修复(GROUP BY显式列防ONLY_FULL_GROUP_BY/金额千分位/detail死参数/2边缘测试)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -61,9 +61,10 @@ KindParam = Literal[
|
||||
# 1) 红包 XML:``<wcpayinfo><feedesc>¥88.00</feedesc>``(feedesc 内货币符号非数字)
|
||||
# 2) 云客转账纯文本:``发送转账:¥100.00``
|
||||
# 3) 转账 XML ``<totalamount>100.00</totalamount>`` 兜底
|
||||
_AMOUNT_FEEDESC_RE = re.compile(r"<feedesc>[^0-9]*([\d.]+)")
|
||||
_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.]+)")
|
||||
_AMOUNT_TOTAL_RE = re.compile(r"<totalamount>\s*([\d.]+)")
|
||||
# 金额可带千分位逗号(``¥1,000.00``)→ 捕获组为 [\d.,]+,float() 前剔逗号。
|
||||
_AMOUNT_FEEDESC_RE = re.compile(r"<feedesc>[^0-9]*([\d.,]+)")
|
||||
_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.,]+)")
|
||||
_AMOUNT_TOTAL_RE = re.compile(r"<totalamount>\s*([\d.,]+)")
|
||||
|
||||
|
||||
def _extract_amount(text: str | None) -> float | None:
|
||||
@@ -78,8 +79,8 @@ def _extract_amount(text: str | None) -> float | None:
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except ValueError: # pragma: no cover — 正则已限定 [\d.]+,防御畸形串
|
||||
return float(m.group(1).replace(",", ""))
|
||||
except ValueError: # pragma: no cover — 正则已限定 [\d.,]+,防御畸形串
|
||||
return None
|
||||
|
||||
|
||||
@@ -91,9 +92,13 @@ def _dt_ms(value: Any) -> int | None:
|
||||
|
||||
|
||||
async def _new_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
session: AsyncSession, wxid: str, start: int, end: int
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。"""
|
||||
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
|
||||
|
||||
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
|
||||
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
|
||||
"""
|
||||
base = [Customer.channel == "wechat", Customer.owner_account_id == wxid]
|
||||
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
||||
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
||||
@@ -115,7 +120,15 @@ async def _new_customers(
|
||||
),
|
||||
)
|
||||
.where(*base, Customer.add_time.is_(None))
|
||||
.group_by(Customer.friend_id)
|
||||
# GROUP BY 显式列出全部裸选列:friend_id 是 Customer 唯一 PK,其余列
|
||||
# 函数依赖于它→分组结果不变;MySQL 8 FD 推断可容忍裸列,但显式列表
|
||||
# 在 ONLY_FULL_GROUP_BY 下部署安全(SQLite 同样兼容)。
|
||||
.group_by(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
)
|
||||
.having(sa.func.min(Message.timestamp) >= start)
|
||||
.having(sa.func.min(Message.timestamp) <= end)
|
||||
)
|
||||
@@ -399,9 +412,19 @@ async def get_staff_stats(
|
||||
if kind is None:
|
||||
counts: dict[str, int] = {}
|
||||
for kind_key, aggregate in _AGGREGATORS.items():
|
||||
counts[kind_key], _ = await aggregate(session, wechat_id, start, end, detail=False)
|
||||
if kind_key == "new_customers": # 无 detail 参数(见其 docstring)
|
||||
counts[kind_key], _ = await aggregate(session, wechat_id, start, end)
|
||||
else:
|
||||
counts[kind_key], _ = await aggregate(
|
||||
session, wechat_id, start, end, detail=False
|
||||
)
|
||||
counts["sensitive_words"] = 0 # 占位,spec 决策
|
||||
return StaffStatsCountsOut(**counts)
|
||||
|
||||
_, rows = await _AGGREGATORS[kind](session, wechat_id, start, end, detail=True)
|
||||
if kind == "new_customers": # 同上:无 detail 参数
|
||||
_, rows = await _AGGREGATORS[kind](session, wechat_id, start, end)
|
||||
else:
|
||||
_, rows = await _AGGREGATORS[kind](
|
||||
session, wechat_id, start, end, detail=True
|
||||
)
|
||||
return StaffStatsDetailOut(kind=kind, rows=rows)
|
||||
|
||||
@@ -313,3 +313,84 @@ async def test_detail_unknown_kind_422(make_client):
|
||||
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"] == []
|
||||
|
||||
Reference in New Issue
Block a user