docs(plan): 微信员工 5 数字 modal Phase A 实现计划(全本地 message 聚合)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
# 微信员工 沟通数/及时回复 5 数字 modal(Phase A)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 微信员工列表(StaffList)每行「沟通数」「及时回复」复合列的 5 个数字可点 → 弹 modal 展示对应客户明细,数据全本地从 `message` 表实时聚合。
|
||||
|
||||
**Architecture:** 新增后端聚合端点 `GET /api/staff-chat-stats`(per 员工 wxid + ms-epoch 日期窗 → 5 汇总数 + 客户明细行;SQL `GROUP BY talker_id` + 跨库日期提取 + ROW_NUMBER 窗口子查询取每客户最后一条消息方向;customer 表 left-join 取昵称;5 汇总数在 Python 侧从 customers 派生)。前端复用 `YkStaffDetailModal`(**不改组件**),StaffList 持日期选择器 + 每行缓存 stats;5 数字改读本地 summary,点击时按 metric 过滤 + 合成分页。只算 1:1 私聊(`room_id` 过滤),无新表、无 Alembic。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy 2.0 async(`yunke-server/`);Vue3 + TS + Element Plus(`yunkes/`)。pytest(async, `make_client` factory);`vue-tsc -b` 类型门禁。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-07-yunke-staff-chat-stats-design.md`(已过审;本计划严格在其范围内,不重开已定决策)。
|
||||
|
||||
---
|
||||
|
||||
## 关键事实(plan 核实,来自 codebase)
|
||||
|
||||
- StaffList 5 数字 cell 与点击 handler **已存在**(`yunkes/src/views/StaffList.vue:519-552`):沟通人数→`chatCount`、有效沟通数→`contactCount`、跟进客户数→`followCount`、员工最后回复数→`saleSendLastNum`、未回复客户数→`customerSendLastNum`,全部调 `openWechatCommModal(row, title, withMsgCols)`(`StaffList.vue:274-300`)。Phase A 是**换数据源**,不是加接线。
|
||||
- `row.account_id` = 员工微信号(wxid)✓;`row.user_name` = 显示名(标题用)。**仅 wechat tab 行做本地 stats;WA tab 与 `员工最后回复率(saleSendLastNumRate)` 保持不动。**
|
||||
- `YkStaffDetailModal` fetcher 契约:`(page, page_size) => Promise<Page<any>>`,`Page = {items, total, page, page_size}`(`yunkes/src/components/YkStaffDetailModal.vue:26`)。组件按 `cols` 渲染、`watch(modelValue)` 开启时 `load()`、翻页回调 `load()`。
|
||||
- 后端路由风格:每个 router 自带 `prefix="/api"`,`yunke-server/app/api/main.py` 用 `app.include_router(x.router)` 注册(无额外 prefix/tags)。鉴权全局 `Depends(require_auth)`,`Settings.api_token` 为空时 no-op。
|
||||
- `Message` 模型(`yunke-server/app/db/models.py:327-394`):`is_send: bool|None`(Boolean)、`timestamp: int|None`(BigInteger ms-epoch)、`room_id: str|None`、`talker_id: str|None`、`owner_account_id: str|None`、`channel: str`。唯一非主键索引 `ix_message_channel_owner_talker_time(channel, owner_account_id, talker_id, timestamp)`。
|
||||
- `Customer` 模型(`models.py:206-256`):`friend_id`(PK)、`channel`、`owner_account_id`、`friend_nickname`/`friend_remark`/`real_name`。与 Message **无 FK、无 ORM 关系**,逻辑 join 在 `(owner_account_id, talker_id ↔ friend_id)`;索引 `ix_customer_owner_friend`。
|
||||
- **无现成跨库日期提取**(仅 `app/storage/repos.py` 有 `session.bind.dialect.name` 方言分支的先例)。本计划引入该 codebase 第一个 SQL 日期提取,**镜像 repos.py 的方言检测写法**。
|
||||
- 测试模式(`tests/test_api_messages.py` + `tests/conftest.py:53-114` 的 `make_client` fixture):直接 ORM `session.add_all([...])` 播种;async mode(无需 `@pytest.mark.asyncio`);`client = await make_client(_seed)`。
|
||||
|
||||
## 实现决策(spec §5 允许范围内的选择)
|
||||
|
||||
1. **3 条查询而非 1 条**:`COUNT(DISTINCT 日期)` 必须用 `GROUP BY`,而「每客户最后一条消息方向」用 `ROW_NUMBER() OVER` 窗口子查询取 `rn=1`——两者无法在同一 SELECT 层共存(窗口聚合与 GROUP BY 不能混用,MySQL 也不支持 `COUNT(DISTINCT) OVER`)。故拆为:Query A(GROUP BY 聚合)、Query B(窗口取 last_is_send)、Query C(customer 昵称 left-join)。三者皆索引覆盖、可移植、易测。
|
||||
2. **last_is_send 用 ROW_NUMBER 窗口**(不用相关子查询):干净、无 tie 歧义、SQLAlchemy `.over()` 表达自然;SQLite 3.25+ / MySQL 8.0+ 均支持。tie-break 加 `msg_id.desc()` 保证确定性。
|
||||
3. **`is_send IS NULL` 语义**:聚合 base set 只排除 `timestamp IS NULL`(spec §3),保留方向未知的行(算入 chat_count / interaction_days / last_time);send/receive 用 `CASE WHEN is_send IS TRUE/FALSE` 天然跳过 NULL;`last_is_send` 为 NULL 时该客户既不算「最后回复」也不算「未回复」。
|
||||
4. **5 汇总数全部 Python 侧从 customers 派生**(spec §5 明示允许)。
|
||||
5. **昵称优先级**:`friend_remark` > `friend_nickname` > `real_name` > `talker_id`(fallback)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
**后端**(`yunke-server/`):
|
||||
- 改 `app/api/schemas.py`:新增 `StaffChatCustomerOut` / `StaffChatStatsSummary` / `StaffChatStatsOut`。
|
||||
- 新 `app/api/routers/staff_chat_stats.py`:`GET /api/staff-chat-stats`(聚合查询,含 room_id 私聊过滤 + 跨库日期)。
|
||||
- 改 `app/api/main.py`:注册 router。
|
||||
- 新 `tests/test_api_staff_chat_stats.py`:私聊 + 群过滤、5 数字、日期窗、跨 owner、缺失昵称 fallback、空集。
|
||||
|
||||
**前端**(`yunkes/`):
|
||||
- 改 `src/api/types.ts`:新增 `StaffChatCustomer` / `StaffChatStatsSummary` / `StaffChatStats`。
|
||||
- 改 `src/api/index.ts`:新增 `getStaffChatStats` + `StaffChatStatsQuery`;re-export 3 类型。
|
||||
- 改 `src/views/StaffList.vue`:日期选择器 + 每行本地 summary 缓存 + 5 数字换源 + `openWechatCommModal` 换源(metric 过滤 + 合成分页 + 列/标题);WA 与回复率不动。
|
||||
- `YkStaffDetailModal.vue` **不改**。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端聚合端点 `GET /api/staff-chat-stats`(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `yunke-server/app/api/routers/staff_chat_stats.py`
|
||||
- Modify: `yunke-server/app/api/schemas.py`(追加 3 个 schema)
|
||||
- Modify: `yunke-server/app/api/main.py`(注册 router)
|
||||
- Test: `yunke-server/tests/test_api_staff_chat_stats.py`
|
||||
|
||||
镜像参考:`app/api/routers/messages.py`、`tests/test_api_messages.py`、`tests/conftest.py` 的 `make_client`、`app/storage/repos.py` 的方言检测。
|
||||
|
||||
- [ ] **Step 1: 写 schema(追加到 `app/api/schemas.py` 末尾)**
|
||||
|
||||
```python
|
||||
class StaffChatCustomerOut(BaseModel):
|
||||
"""单个客户在日期窗内的私聊聚合行(本地 message 表派生)。"""
|
||||
|
||||
talker_id: str
|
||||
friend_name: str # remark > nickname > real_name > talker_id
|
||||
send_count: int # 员工发出(is_send True)
|
||||
receive_count: int # 客户发出(is_send False)
|
||||
interaction_days: int # 窗口内不同日期数
|
||||
last_is_send: int | None # 1=员工最后发, 0=客户最后发, None=最后一条方向未知(不计入两率)
|
||||
last_time: int | None = None # ms-epoch,最后一条时间
|
||||
|
||||
|
||||
class StaffChatStatsSummary(BaseModel):
|
||||
"""5 个汇总数字(从 customers 派生)。"""
|
||||
|
||||
chat_count: int # 沟通人数:窗口内 ≥1 条消息的去重客户数
|
||||
effective_count: int # 有效沟通数:send≥1 且 receive≥1 的客户数
|
||||
follow_count: int # 跟进客户数:interaction_days≥2 的客户数(近似口径)
|
||||
last_reply_count: int # 员工最后回复数:last_is_send=1 的客户数
|
||||
no_reply_count: int # 未回复客户数:last_is_send=0 的客户数
|
||||
|
||||
|
||||
class StaffChatStatsOut(BaseModel):
|
||||
owner_account_id: str
|
||||
start: int # ms-epoch
|
||||
end: int # ms-epoch
|
||||
summary: StaffChatStatsSummary
|
||||
customers: list[StaffChatCustomerOut]
|
||||
```
|
||||
|
||||
> schema 由聚合数据构造(非 ORM 行),故不需 `from_attributes`。
|
||||
|
||||
- [ ] **Step 2: 写失败测试 `tests/test_api_staff_chat_stats.py`(完整测试套件)**
|
||||
|
||||
```python
|
||||
"""Tests for GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
OWNER = "wx_owner_1"
|
||||
WIN_START = 1_700_000_000_000 # 固定 ms-epoch,确定性
|
||||
WIN_END = WIN_START + 3 * 86_400_000
|
||||
DAY = 86_400_000
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
session.add_all(
|
||||
[
|
||||
# 客户 A:跨 2 天双向 → effective + follow,员工最后发
|
||||
Message(msg_id="a1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START, type="text", type_raw=1, text="x"),
|
||||
Message(msg_id="a2", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=False, timestamp=WIN_START + DAY, type="text", type_raw=1, text="y"),
|
||||
Message(msg_id="a3", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + DAY + 1000, type="text", type_raw=1, text="z"),
|
||||
# 客户 B:仅接收 → 非 effective,客户最后发(未回复)
|
||||
Message(msg_id="b1", channel="wechat", owner_account_id=OWNER, talker_id="t_b",
|
||||
room_id=None, is_send=False, timestamp=WIN_START + 2000, type="text", type_raw=1, text="hi"),
|
||||
# 客户 C:单条员工发 → 员工最后发,1 天
|
||||
Message(msg_id="c1", channel="wechat", owner_account_id=OWNER, talker_id="t_c",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + 3000, type="text", type_raw=1, text="c"),
|
||||
# 群消息(room_id 非空)——必须被全部排除
|
||||
Message(msg_id="g1", channel="wechat", owner_account_id=OWNER, talker_id="t_grp_member",
|
||||
room_id="room123@chatroom", is_send=True, timestamp=WIN_START + 4000, type="text", type_raw=1, text="g"),
|
||||
# 窗口外私聊——必须排除(不污染 t_a 统计)
|
||||
Message(msg_id="o1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_END + 1000, type="text", type_raw=1, text="future"),
|
||||
# 其他 owner——必须排除
|
||||
Message(msg_id="x1", channel="wechat", owner_account_id="wx_other", talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + 5000, type="text", type_raw=1, text="other"),
|
||||
Customer(friend_id="t_a", channel="wechat", owner_account_id=OWNER,
|
||||
friend_nickname="Alice", friend_remark="Alice备注", real_name=None),
|
||||
Customer(friend_id="t_b", channel="wechat", owner_account_id=OWNER,
|
||||
friend_nickname="Bob", friend_remark=None, real_name=None),
|
||||
# t_c 无 customer 行 → 昵称 fallback 为 talker_id "t_c"
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_summary_five_numbers(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
s = r.json()["summary"]
|
||||
assert s == {
|
||||
"chat_count": 3, # t_a, t_b, t_c(群/窗外/他owner 全排除)
|
||||
"effective_count": 1, # 仅 t_a 双向
|
||||
"follow_count": 1, # 仅 t_a 跨 2 天
|
||||
"last_reply_count": 2, # t_a, t_c 员工最后发
|
||||
"no_reply_count": 1, # t_b 客户最后发
|
||||
}
|
||||
|
||||
|
||||
async def test_customers_fields_and_names(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
|
||||
assert set(by_id) == {"t_a", "t_b", "t_c"}
|
||||
# t_a: send=2 recv=1 days=2 last_is_send=1 last_time=a3
|
||||
assert by_id["t_a"]["friend_name"] == "Alice备注" # remark 优先
|
||||
assert by_id["t_a"]["send_count"] == 2
|
||||
assert by_id["t_a"]["receive_count"] == 1
|
||||
assert by_id["t_a"]["interaction_days"] == 2
|
||||
assert by_id["t_a"]["last_is_send"] == 1
|
||||
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000
|
||||
# t_b
|
||||
assert by_id["t_b"]["friend_name"] == "Bob"
|
||||
assert by_id["t_b"]["send_count"] == 0
|
||||
assert by_id["t_b"]["receive_count"] == 1
|
||||
assert by_id["t_b"]["interaction_days"] == 1
|
||||
assert by_id["t_b"]["last_is_send"] == 0
|
||||
# t_c 无 customer 行 → fallback
|
||||
assert by_id["t_c"]["friend_name"] == "t_c"
|
||||
assert by_id["t_c"]["last_is_send"] == 1
|
||||
|
||||
|
||||
async def test_group_messages_excluded(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
ids = {c["talker_id"] for c in r.json()["customers"]}
|
||||
assert "t_grp_member" not in ids # 群消息被 room_id 过滤
|
||||
|
||||
|
||||
async def test_date_window_and_owner_filter(make_client):
|
||||
"""窗外消息与他 owner 消息不污染统计(t_a 未被 o1/x1 抬高)。"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
|
||||
assert by_id["t_a"]["send_count"] == 2 # 非 4(a1+a3,排除 o1+x1)
|
||||
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000 # 非 o1
|
||||
|
||||
|
||||
async def test_empty_when_no_messages(make_client):
|
||||
async def _empty(session: AsyncSession) -> None:
|
||||
await session.commit()
|
||||
|
||||
client = await make_client(_empty)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["customers"] == []
|
||||
assert body["summary"] == {
|
||||
"chat_count": 0, "effective_count": 0, "follow_count": 0,
|
||||
"last_reply_count": 0, "no_reply_count": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_default_window_returns_ok(make_client):
|
||||
"""省略 start/end 走默认近 3 天;2023 固定种子在默认窗外 → 空集,仅验结构。"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get("/api/staff-chat-stats", params={"owner_account_id": OWNER})
|
||||
assert r.status_code == 200
|
||||
assert "summary" in r.json() and "customers" in r.json()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败(endpoint 不存在 → 404)**
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_staff_chat_stats.py -v
|
||||
```
|
||||
Expected: FAIL(404 / 路由未注册)。
|
||||
|
||||
- [ ] **Step 4: 写 router `app/api/routers/staff_chat_stats.py`**
|
||||
|
||||
```python
|
||||
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 表实时聚合)。
|
||||
|
||||
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
|
||||
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
|
||||
跨库日期提取(codebase 首例)镜像 app/storage/repos.py 的方言检测写法。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_MS_PER_DAY = 86_400_000
|
||||
|
||||
|
||||
def _private_window(msg, owner: str, start: int, end: int):
|
||||
"""1:1 私聊 + 窗口过滤条件(channel 固定 wechat),排除 timestamp IS NULL。"""
|
||||
return [
|
||||
msg.channel == "wechat",
|
||||
msg.owner_account_id == owner,
|
||||
sa.or_(msg.room_id.is_(None), msg.room_id == ""),
|
||||
msg.timestamp.is_not(None),
|
||||
msg.timestamp >= start,
|
||||
msg.timestamp <= end,
|
||||
]
|
||||
|
||||
|
||||
async def _aggregate(session: AsyncSession, owner: str, start: int, end: int):
|
||||
"""返回 [(talker_id, send, recv, interaction_days, last_time)] + last_is_send map。"""
|
||||
# 跨库日期提取:从 ms-epoch 取日期。镜像 repos.py 方言检测。
|
||||
dialect = session.bind.name # 镜像 app/storage/repos.py(.name 等价 .dialect.name)
|
||||
if dialect == "sqlite":
|
||||
day_expr = sa.func.date(Message.timestamp / 1000, "unixepoch")
|
||||
else: # mysql(生产)
|
||||
day_expr = sa.func.date(sa.func.from_unixtime(Message.timestamp / 1000))
|
||||
|
||||
base = _private_window(Message, owner, start, end)
|
||||
agg_stmt = (
|
||||
sa.select(
|
||||
Message.talker_id,
|
||||
sa.func.sum(sa.case((Message.is_send.is_(True), 1), else_=0)).label("send_count"),
|
||||
sa.func.sum(sa.case((Message.is_send.is_(False), 1), else_=0)).label("receive_count"),
|
||||
sa.func.count(sa.func.distinct(day_expr)).label("interaction_days"),
|
||||
sa.func.max(Message.timestamp).label("last_time"),
|
||||
)
|
||||
.where(*base)
|
||||
.group_by(Message.talker_id)
|
||||
)
|
||||
agg_rows = (await session.execute(agg_stmt)).all()
|
||||
|
||||
# 每客户最后一条消息方向:ROW_NUMBER 窗口取 rn=1(tie-break msg_id 保证确定性)
|
||||
inner = (
|
||||
sa.select(
|
||||
Message.talker_id,
|
||||
Message.is_send,
|
||||
sa.func.row_number()
|
||||
.over(
|
||||
partition_by=Message.talker_id,
|
||||
order_by=(Message.timestamp.desc(), Message.msg_id.desc()),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(*base)
|
||||
)
|
||||
sub = inner.subquery("last_msg")
|
||||
last_stmt = sa.select(sub.c.talker_id, sub.c.is_send).where(sub.c.rn == 1)
|
||||
last_map = {tid: is_send for tid, is_send in (await session.execute(last_stmt)).all()}
|
||||
|
||||
return agg_rows, last_map
|
||||
|
||||
|
||||
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, str]:
|
||||
if not talker_ids:
|
||||
return {}
|
||||
stmt = sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_remark,
|
||||
Customer.friend_nickname,
|
||||
Customer.real_name,
|
||||
).where(
|
||||
Customer.channel == "wechat",
|
||||
Customer.owner_account_id == owner,
|
||||
Customer.friend_id.in_(talker_ids),
|
||||
)
|
||||
names: dict[str, str] = {}
|
||||
for fid, remark, nick, real in (await session.execute(stmt)).all():
|
||||
names[fid] = remark or nick or real or fid
|
||||
return names
|
||||
|
||||
|
||||
@router.get("/staff-chat-stats")
|
||||
async def get_staff_chat_stats(
|
||||
session: SessionDep,
|
||||
owner_account_id: Annotated[str, Query(min_length=1)],
|
||||
start: Annotated[int | None, Query(ge=0)] = None,
|
||||
end: Annotated[int | None, Query(ge=0)] = None,
|
||||
) -> StaffChatStatsOut:
|
||||
"""微信员工沟通统计:5 数字 + 客户明细(全本地 message 聚合,1:1 私聊)。"""
|
||||
if end is None:
|
||||
end = int(time.time() * 1000)
|
||||
if start is None:
|
||||
start = end - 3 * _MS_PER_DAY # 默认近 3 天
|
||||
|
||||
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
|
||||
talker_ids = [r.talker_id for r in agg_rows]
|
||||
names = await _customer_names(session, owner_account_id, talker_ids)
|
||||
|
||||
customers: list[StaffChatCustomerOut] = []
|
||||
for r in agg_rows:
|
||||
tid = r.talker_id
|
||||
last_is_send = last_map.get(tid) # bool | None(None=最后一条方向未知)
|
||||
customers.append(
|
||||
StaffChatCustomerOut(
|
||||
talker_id=tid,
|
||||
friend_name=names.get(tid, tid),
|
||||
send_count=int(r.send_count or 0),
|
||||
receive_count=int(r.receive_count or 0),
|
||||
interaction_days=int(r.interaction_days or 0),
|
||||
# True→1, False→0, None→None(方向未知不计入两率;summary 用 ==1/==0 自动排除 None)
|
||||
last_is_send=int(last_is_send) if last_is_send is not None else None,
|
||||
last_time=r.last_time,
|
||||
)
|
||||
)
|
||||
|
||||
summary = StaffChatStatsSummary(
|
||||
chat_count=len(customers),
|
||||
effective_count=sum(1 for c in customers if c.send_count >= 1 and c.receive_count >= 1),
|
||||
follow_count=sum(1 for c in customers if c.interaction_days >= 2),
|
||||
last_reply_count=sum(1 for c in customers if c.last_is_send == 1),
|
||||
no_reply_count=sum(1 for c in customers if c.last_is_send == 0),
|
||||
)
|
||||
return StaffChatStatsOut(
|
||||
owner_account_id=owner_account_id,
|
||||
start=start,
|
||||
end=end,
|
||||
summary=summary,
|
||||
customers=customers,
|
||||
)
|
||||
```
|
||||
|
||||
> 注意:方言检测用 `session.bind.name`(镜像 `app/storage/repos.py` 现有可用写法;`.name` 等价 `.dialect.name`,在 async session 下已验证可用)。不要新发明访问方式。
|
||||
|
||||
- [ ] **Step 5: 在 `app/api/main.py` 注册 router**
|
||||
|
||||
在 `from app.api.routers import (...)` 的 import 列表加 `staff_chat_stats`(按字母序插在 `sms,` 与 `sync,` 之间),并在 `app.include_router(...)` 块加 `app.include_router(staff_chat_stats.router)`。
|
||||
|
||||
- [ ] **Step 6: 运行新测试确认全绿**
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_staff_chat_stats.py -v
|
||||
```
|
||||
Expected: 6 passed。若日期/窗口/last_is_send 计算不符,按 failing assertion 修正(用 systematic-debugging:先看实际值 vs 期望)。
|
||||
|
||||
- [ ] **Step 7: 全量测试回归**
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest -q
|
||||
```
|
||||
Expected: 全绿(原计数 + 6 新增)。无 Alembic、无 schema 迁移。
|
||||
|
||||
- [ ] **Step 8: EXPLAIN 验证索引覆盖(spec §8/§10,可选但推荐)**
|
||||
|
||||
确认聚合查询走 `ix_message_channel_owner_talker_time` 而非全表扫描。直接用 sqlite3 对 smoke.db 跑 EXPLAIN(绕开 async session 导入):
|
||||
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -c "
|
||||
import sqlite3
|
||||
con = sqlite3.connect('smoke.db')
|
||||
print(con.execute(\"EXPLAIN QUERY PLAN SELECT talker_id FROM message WHERE channel='wechat' AND owner_account_id='wxid_x' AND (room_id IS NULL OR room_id='') AND timestamp IS NOT NULL GROUP BY talker_id\").fetchall())
|
||||
"
|
||||
```
|
||||
Expected: 输出含 `ix_message_channel_owner_talker_time`、无 `SCAN TABLE message`(全表扫描)。此步为信心校验(不阻塞合并);WHERE 列序 (channel, owner_account_id, talker_id, timestamp) 与该索引前缀一致 → 应命中。**不加新索引、无 Alembic。**
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add yunke-server/app/api/schemas.py yunke-server/app/api/routers/staff_chat_stats.py yunke-server/app/api/main.py yunke-server/tests/test_api_staff_chat_stats.py
|
||||
git commit -m "feat(api): staff-chat-stats 聚合端点(微信员工 5 数字 + 客户明细,全本地 message)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 前端数据层 + StaffList 5 数字换源(仅 wechat)
|
||||
|
||||
**Files:**
|
||||
- Modify: `yunkes/src/api/types.ts`(追加 3 类型)
|
||||
- Modify: `yunkes/src/api/index.ts`(追加 `getStaffChatStats` + re-export)
|
||||
- Modify: `yunkes/src/views/StaffList.vue`(日期选择器 + stats 缓存 + 5 数字换源;WA 与回复率不动)
|
||||
|
||||
镜像参考:`api/index.ts` 的 `getMessages`/`clean` 模式;`types.ts` 的 `Message`/`Page`。
|
||||
|
||||
- [ ] **Step 1: `types.ts` 追加类型**
|
||||
|
||||
```typescript
|
||||
/** 微信员工沟通统计:单个客户聚合行(镜像 backend StaffChatCustomerOut)。 */
|
||||
export interface StaffChatCustomer {
|
||||
talker_id: string
|
||||
friend_name: string
|
||||
send_count: number
|
||||
receive_count: number
|
||||
interaction_days: number
|
||||
/** 1 = 员工最后发,0 = 客户最后发,null = 最后一条方向未知(不计入两率) */
|
||||
last_is_send: number | null
|
||||
/** ms-epoch,最后一条时间 */
|
||||
last_time: number | null
|
||||
}
|
||||
|
||||
/** 5 个汇总数字(镜像 backend StaffChatStatsSummary)。 */
|
||||
export interface StaffChatStatsSummary {
|
||||
chat_count: number
|
||||
effective_count: number
|
||||
follow_count: number
|
||||
last_reply_count: number
|
||||
no_reply_count: number
|
||||
}
|
||||
|
||||
/** GET /api/staff-chat-stats 响应(镜像 backend StaffChatStatsOut)。 */
|
||||
export interface StaffChatStats {
|
||||
owner_account_id: string
|
||||
start: number
|
||||
end: number
|
||||
summary: StaffChatStatsSummary
|
||||
customers: StaffChatCustomer[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `index.ts` 追加 client + re-export**
|
||||
|
||||
import 块加 `StaffChatStats`(按字母序插在 `Staff,` 与 `SyncRun,` 之间)。新增:
|
||||
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// Staff chat stats (微信员工 5 数字 + 客户明细,全本地 message 聚合)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StaffChatStatsQuery {
|
||||
owner_account_id: string
|
||||
/** ms-epoch inclusive 窗口(默认近 3 天,由后端兜底)。 */
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
|
||||
export function getStaffChatStats(query: StaffChatStatsQuery): Promise<StaffChatStats> {
|
||||
return getJson<StaffChatStats>(
|
||||
'/staff-chat-stats',
|
||||
clean({
|
||||
owner_account_id: query.owner_account_id,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
}),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
文件末尾 re-export 块加 `StaffChatStats,`(按字母序插在 `Staff,` 与 `SyncRun,` 之间)。
|
||||
|
||||
- [ ] **Step 3: StaffList.vue —— import + 日期状态 + stats 缓存**
|
||||
|
||||
import 行(第 5 行)改为:
|
||||
```typescript
|
||||
import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'
|
||||
```
|
||||
import type 行(第 6 行)加 `StaffChatStats`:
|
||||
```typescript
|
||||
import type { Account, Channel, StaffChatStats } from '@/api/types'
|
||||
```
|
||||
|
||||
在 `<script setup>` 顶部状态区(drawerOpen 等附近)追加:
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// 微信员工沟通统计(全本地 message 聚合)——仅 wechat tab。
|
||||
// 日期窗默认近 3 天;每行(按 account_id=wxid)缓存 stats 供 5 数字 + modal 复用。
|
||||
// WA tab 与「员工最后回复率」保持原 raw_json 数据源不动。
|
||||
// ---------------------------------------------------------------------------
|
||||
const THREE_DAYS_MS = 3 * 86_400_000
|
||||
function defaultDateRange(): [Date, Date] {
|
||||
const end = new Date()
|
||||
end.setHours(23, 59, 59, 999)
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - 2)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [start, end]
|
||||
}
|
||||
const dateRange = ref<[Date, Date]>(defaultDateRange())
|
||||
const statsStart = computed(() => dateRange.value[0].getTime())
|
||||
const statsEnd = computed(() => dateRange.value[1].getTime())
|
||||
/** account_id → stats 缓存(wechat 行)。undefined 表示尚未加载,cell 显示 '--'。 */
|
||||
const staffStats = ref<Record<string, StaffChatStats | undefined>>({})
|
||||
|
||||
async function loadWechatStats(rows: Account[]): Promise<void> {
|
||||
const ids = rows.map((r) => r.account_id).filter(Boolean)
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
staffStats.value[id] = await getStaffChatStats({
|
||||
owner_account_id: id,
|
||||
start: statsStart.value,
|
||||
end: statsEnd.value,
|
||||
})
|
||||
} catch {
|
||||
staffStats.value[id] = undefined // 失败留空,cell 显示 '--'
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 5 数字 cell:读本地 summary;未加载显示 '--'。 */
|
||||
function statNum(
|
||||
row: Account,
|
||||
key: 'chat_count' | 'effective_count' | 'follow_count' | 'last_reply_count' | 'no_reply_count',
|
||||
): string {
|
||||
const s = staffStats.value[row.account_id]?.summary
|
||||
return s ? String(s[key]) : '--'
|
||||
}
|
||||
|
||||
function onStatsDateChange(): void {
|
||||
// 改日期 → 重查当前可见 wechat 行(覆盖其缓存条目)。
|
||||
loadWechatStats(tabState.wechat.items)
|
||||
}
|
||||
```
|
||||
|
||||
> `computed` 需在 `import { computed, onMounted, reactive, ref } from 'vue'`(已存在,第 2 行)。
|
||||
|
||||
- [ ] **Step 4: loadTab 末尾触发 wechat stats 加载**
|
||||
|
||||
在 `loadTab`(约 306 行)的 `st.loaded = true` 之后、`try` 块内追加:
|
||||
```typescript
|
||||
if (ch === 'wechat') {
|
||||
// 5 数字改读本地 summary:员工列表到位后并行拉每行 stats。
|
||||
void loadWechatStats(st.items)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 模板——wechat tab 加日期选择器**
|
||||
|
||||
在 `<el-tab-pane label="微信员工" name="wechat">` 内、`<el-alert>` 之前插入:
|
||||
```html
|
||||
<div class="yk-staff__stats-bar">
|
||||
<span class="yk-staff__stats-label">沟通统计时段</span>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
size="small"
|
||||
:clearable="false"
|
||||
@change="onStatsDateChange"
|
||||
/>
|
||||
<span class="yk-staff__stats-hint">数字为本地消息表实时聚合(口径近似云客)</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
> 不加 `value-format`:daterange + value-format 会把 v-model 变成 `[string,string]`,而 `statsStart/End` 需对 Date 调 `.getTime()`。保持原生 Date 对象即可。
|
||||
|
||||
样式追加到 `<style scoped>`:
|
||||
```css
|
||||
.yk-staff__stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.yk-staff__stats-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.yk-staff__stats-hint {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 模板——5 数字 cell 换源(5 处)+ handler 改传 metric**
|
||||
|
||||
「沟通数」列(约 519-534 行)与「及时回复」列(约 537-552 行)的 5 个 `el-button`:
|
||||
|
||||
| 原 | 改为 |
|
||||
|---|---|
|
||||
| `cellNum(row, 'chatCount')` | `statNum(row, 'chat_count')` |
|
||||
| `cellNum(row, 'contactCount')` | `statNum(row, 'effective_count')` |
|
||||
| `cellNum(row, 'followCount')` | `statNum(row, 'follow_count')` |
|
||||
| `cellNum(row, 'saleSendLastNum')` | `statNum(row, 'last_reply_count')` |
|
||||
| `cellNum(row, 'customerSendLastNum')` | `statNum(row, 'no_reply_count')` |
|
||||
|
||||
5 个 `@click.stop="openWechatCommModal(row, '<title>', <bool>)"` 改为传 metric:
|
||||
- 沟通人数 → `openWechatCommModal(row, 'chat')`
|
||||
- 有效沟通数 → `openWechatCommModal(row, 'effective')`
|
||||
- 跟进客户数 → `openWechatCommModal(row, 'follow')`
|
||||
- 员工最后回复数 → `openWechatCommModal(row, 'last_reply')`
|
||||
- 未回复客户数 → `openWechatCommModal(row, 'no_reply')`
|
||||
|
||||
`员工最后回复率`(`cellText(row, 'saleSendLastNumRate')`,约 545 行)**保持不动**。WA tab 全部不动。
|
||||
|
||||
> Task 2 此刻 `openWechatCommModal` 签名变了会暂时类型不通过——本 step 仅改模板调用,handler 实现在 Task 3 重写。**为保持本 task 自洽可编译**,本 step 同时在 script 里把 `openWechatCommModal` 临时改为接收 `(row, metric)` 并先按 metric 返回标题占位(modal 真实数据在 Task 3 接)。临时实现:
|
||||
> ```typescript
|
||||
> type ChatMetric = 'chat' | 'effective' | 'follow' | 'last_reply' | 'no_reply'
|
||||
> const METRIC_TITLES: Record<ChatMetric, string> = {
|
||||
> chat: '沟通人数(已去重)',
|
||||
> effective: '有效沟通数(已去重)',
|
||||
> follow: '跟进客户数(口径:窗口内≥2个沟通日,近似)',
|
||||
> last_reply: '员工最后回复数',
|
||||
> no_reply: '未回复客户数',
|
||||
> }
|
||||
> // 对齐云客 spec §6:所有 modal 显示「客户微信」;仅「有效沟通数」modal 额外显示发送/接收数。
|
||||
> // 序号需改 YkStaffDetailModal(spec §1 不改组件,略);员工信息/员工微信由 modal 标题承载,行内冗余故略。
|
||||
> function commModalColsFor(metric: ChatMetric): any[] {
|
||||
> const cols: any[] = [{ prop: 'friend_name', label: '客户微信', minWidth: 180 }]
|
||||
> if (metric === 'effective') {
|
||||
> cols.push(
|
||||
> { prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
|
||||
> { prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
|
||||
> )
|
||||
> }
|
||||
> return cols
|
||||
> }
|
||||
> function openWechatCommModal(row: Account, metric: ChatMetric): void {
|
||||
> // Task 3 接真实数据 + metric 过滤 + 合成分页。此处先立标题与列骨架。
|
||||
> detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
|
||||
> detailCols.value = commModalColsFor(metric)
|
||||
> detailFetcher.value = async () => ({ items: [], total: 0, page: 1, page_size: 10 })
|
||||
> detailOpen.value = true
|
||||
> }
|
||||
> ```
|
||||
> 删除旧 `openWechatCommModal(row, title, withMsgCols)` 实现(含 `goChat`——Phase A 不做 modal 二级可点,spec §1/§6)。**同步从 import 移除 `Customer`**(其唯一用处是 `goChat` 的 `(r: Customer)` 参数;删 goChat 后无引用,否则 `noUnusedLocals` 报错)。
|
||||
|
||||
- [ ] **Step 7: 类型门禁**
|
||||
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
Expected: exit 0(无类型错误)。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add yunkes/src/api/types.ts yunkes/src/api/index.ts yunkes/src/views/StaffList.vue
|
||||
git commit -m "feat(staff): StaffList 微信 5 数字改读本地 summary + 日期选择器(Phase A 接线)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: StaffList modal 换源(metric 过滤 + 合成分页)
|
||||
|
||||
**Files:**
|
||||
- Modify: `yunkes/src/views/StaffList.vue`(`openWechatCommModal` 真实数据源 + metric 过滤 + 合成分页)
|
||||
|
||||
> 依赖 Task 2 的 `staffStats` 缓存 / `statsStart`/`statsEnd` / `METRIC_TITLES` / `ChatMetric` / 列骨架。`YkStaffDetailModal` 不改。
|
||||
|
||||
- [ ] **Step 1: 实现 metric 过滤 + 合成分页 fetcher**
|
||||
|
||||
把 Task 2 的临时 `openWechatCommModal` 实现替换为真实数据源:
|
||||
|
||||
```typescript
|
||||
import type { Page } from '@/api/types' // 若未 import 则补(Page 用于合成分页返回类型)
|
||||
|
||||
function metricFilter(c: StaffChatCustomer, m: ChatMetric): boolean {
|
||||
switch (m) {
|
||||
case 'chat':
|
||||
return true
|
||||
case 'effective':
|
||||
return c.send_count >= 1 && c.receive_count >= 1
|
||||
case 'follow':
|
||||
return c.interaction_days >= 2
|
||||
case 'last_reply':
|
||||
return c.last_is_send === 1
|
||||
case 'no_reply':
|
||||
return c.last_is_send === 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信员工 5 数字 → 明细 modal(数据源:本地 staff-chat-stats,按 metric 过滤 + 合成分页)。 */
|
||||
function openWechatCommModal(row: Account, metric: ChatMetric): void {
|
||||
detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
|
||||
detailCols.value = commModalColsFor(metric) // Task 2 已定义(metric 决定是否含发送/接收列)
|
||||
detailFetcher.value = async (page: number, page_size: number): Promise<Page<any>> => {
|
||||
// 复用已缓存的 stats;点击早于列表加载完成则按需补拉。
|
||||
let stats = staffStats.value[row.account_id]
|
||||
if (!stats) {
|
||||
try {
|
||||
stats = await getStaffChatStats({
|
||||
owner_account_id: row.account_id,
|
||||
start: statsStart.value,
|
||||
end: statsEnd.value,
|
||||
})
|
||||
staffStats.value[row.account_id] = stats
|
||||
} catch {
|
||||
return { items: [], total: 0, page, page_size }
|
||||
}
|
||||
}
|
||||
const filtered = stats.customers.filter((c) => metricFilter(c, metric))
|
||||
const startIdx = (page - 1) * page_size
|
||||
return {
|
||||
items: filtered.slice(startIdx, startIdx + page_size),
|
||||
total: filtered.length,
|
||||
page,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
detailOpen.value = true
|
||||
}
|
||||
```
|
||||
|
||||
> `StaffChatCustomer` 类型需 import(Task 2 已 import `StaffChatStats`;补 `StaffChatCustomer`)。`Page` 类型从 `@/api/types` import(StaffList 当前未直接 import `Page`——补上)。
|
||||
|
||||
- [ ] **Step 2: 清理遗留**
|
||||
|
||||
- 删除 Task 2 临时实现里已被替换的占位 fetcher。
|
||||
- 确认旧 `goChat` / `withMsgCols` 参数已无引用(Task 2 已删;本 step 二次确认无残留)。
|
||||
- `baseCustomerCols` 仍被 `openAddedModal`/`openTalkedModal`(WA tab)使用——**保留**。
|
||||
|
||||
- [ ] **Step 3: 类型门禁**
|
||||
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
Expected: exit 0。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add yunkes/src/views/StaffList.vue
|
||||
git commit -m "feat(staff): 微信 5 数字 modal 换本地数据源(metric 过滤 + 合成分页)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收(spec §10)
|
||||
|
||||
- [ ] 后端 `pytest -q` 全绿(含 `test_api_staff_chat_stats.py` 6 用例:群过滤、窗外/他 owner 排除、5 数字、客户字段、缺失昵称 fallback、空集、默认窗)。
|
||||
- [ ] `cd yunkes && npx vue-tsc -b` exit 0。
|
||||
- [ ] **浏览器实测**(运行后台本地,绝不访问云客):
|
||||
- 启动后端 `cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` + 前端 `cd yunkes && npm run dev`。
|
||||
- 微信员工 tab:5 数字显示(非 '--',若 smoke.db 有 wechat 消息);点各数字 → modal 弹对应客户子集;改日期窗 → 数字与 modal 重查。
|
||||
- Network 面板:仅打 `/api/staff-chat-stats`(**无任何 crm2.yunkecn.com 请求**)。
|
||||
- 列对齐云客:客户微信 / 发送消息数 / 接收消息数;标题含「(已去重)」、跟进标题含近似口径说明。
|
||||
- WA tab 数字不受影响(仍 raw_json);员工最后回复率仍展示(不可点)。
|
||||
|
||||
## 风险与边界
|
||||
|
||||
- **跨库日期提取首例**:镜像 `repos.py` 方言检测;SQLite 测试覆盖。若 MySQL 生产异常,TDD + 浏览器实测会暴露。
|
||||
- **ROW_NUMBER 窗口**:SQLite 3.25+ / MySQL 8.0+ 均支持;tie 用 `msg_id.desc()` 确定。
|
||||
- **smoke.db 数据稀疏**(spec §8):数字小但机制成立;真实部署数据全后才有意义。
|
||||
- **每行一次聚合**(20 行/页 × 并发):现有复合索引覆盖;spec §8 已接受该 per-row 成本。
|
||||
- **Phase A 不做**(spec §1):员工姓名→drawer、主 badge→聊天、modal 内二级可点 → Phase B;WA 员工表数字 modal 待复核。
|
||||
Reference in New Issue
Block a user