From 4baa8639ba8beea6c86763a9fbdffe6512d4577b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 25 Aug 2026 16:36:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20staff-stats=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D(GROUP=20BY=E6=98=BE=E5=BC=8F=E5=88=97?= =?UTF-8?q?=E9=98=B2ONLY=5FFULL=5FGROUP=5FBY/=E9=87=91=E9=A2=9D=E5=8D=83?= =?UTF-8?q?=E5=88=86=E4=BD=8D/detail=E6=AD=BB=E5=8F=82=E6=95=B0/2=E8=BE=B9?= =?UTF-8?q?=E7=BC=98=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- yunke-server/app/api/routers/staff_stats.py | 43 ++++++++--- yunke-server/tests/test_api_staff_stats.py | 81 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/yunke-server/app/api/routers/staff_stats.py b/yunke-server/app/api/routers/staff_stats.py index 940019d..afd2dc8 100644 --- a/yunke-server/app/api/routers/staff_stats.py +++ b/yunke-server/app/api/routers/staff_stats.py @@ -61,9 +61,10 @@ KindParam = Literal[ # 1) 红包 XML:``¥88.00``(feedesc 内货币符号非数字) # 2) 云客转账纯文本:``发送转账:¥100.00`` # 3) 转账 XML ``100.00`` 兜底 -_AMOUNT_FEEDESC_RE = re.compile(r"[^0-9]*([\d.]+)") -_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.]+)") -_AMOUNT_TOTAL_RE = re.compile(r"\s*([\d.]+)") +# 金额可带千分位逗号(``¥1,000.00``)→ 捕获组为 [\d.,]+,float() 前剔逗号。 +_AMOUNT_FEEDESC_RE = re.compile(r"[^0-9]*([\d.,]+)") +_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.,]+)") +_AMOUNT_TOTAL_RE = re.compile(r"\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) diff --git a/yunke-server/tests/test_api_staff_stats.py b/yunke-server/tests/test_api_staff_stats.py index 556c249..32d4207 100644 --- a/yunke-server/tests/test_api_staff_stats.py +++ b/yunke-server/tests/test_api_staff_stats.py @@ -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="¥2,000.00")) + 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"] == []