feat: department + wechat account/staff scrapers

M2 organization scrapers for yunke-server:

- scrape_departments: POST /usercenter/dept/tree. Single request (no
  pagination). Defensively unwraps the envelope (data is node list OR an
  object wrapping nodes under children), flattens BOTH nested and flat
  shapes, and reconstructs the materialized path by walking the pId chain
  (ancestor names joined with /). Maps id/pId/name -> dept_id/parent_id/name.

- scrape_wechat_accounts: POST /wechatweb/wxUser/list. Page-number
  pagination via the existing paginate_by_page helper. Envelope differs
  from friend_list (no basicResult wrapper): data.data/data.total. Maps
  wechatId/nickName/alias/phone/userId/userName/contactsNum/groupNum/
  sendNum/receiveNum/isOnJob/dbSize/onlineStatus/lastUpdate/version to
  WechatAccount columns; carries embedded staff pair for the repo layer.

Repos:
- upsert_department: idempotent upsert into Department (PK dept_id).
- upsert_staff: primitive idempotent staff upsert (PK user_id, is_on_job
  coerced to bool).
- upsert_wechat_account: now ALSO upserts the embedded Staff row via
  upsert_staff_from_account (best-effort; skips when account has no user_id).
- upsert_staff_from_account: refactored to delegate to upsert_staff and
  forward phone/sale_phone/is_on_job when present.

Tests (TDD red->green): test_scrape_departments (nested + flat + wrapped-data
+ empty), test_scrape_wechat_accounts (2-page pagination + body advance +
empty first page), test_repos extended (dept idempotency/root parent, direct
staff upsert + bool coercion, account-also-upserts-staff, re-upsert updates
both, no-user_id skips staff). Cassettes: dept_tree_fixture (same 4 nodes in
both nested + flat shapes), wxUser_fixture (4 capture-shaped accounts incl.
departed + orphan).

Suite: 97 -> 111 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 13:46:23 +08:00
parent eebff9f786
commit 2b79e7a232
7 changed files with 1267 additions and 22 deletions
+276 -1
View File
@@ -22,13 +22,15 @@ import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import Customer, Message, Staff, WechatAccount
from app.db.models import Customer, Department, Message, Staff, WechatAccount
from app.storage.repos import (
_coerce_bool,
_coerce_int,
upsert_customer,
upsert_customers,
upsert_department,
upsert_messages,
upsert_staff,
upsert_staff_from_account,
upsert_wechat_account,
)
@@ -426,3 +428,276 @@ async def test_upsert_staff_from_account_skips_when_no_user_id():
assert rows == []
finally:
await engine.dispose()
# ---------------------------------------------------------------------------
# Department upsert (Task 2.0)
# ---------------------------------------------------------------------------
async def test_upsert_department_is_idempotent_and_updates_path():
"""Same dept_id twice with different path -> 1 row, path refreshed."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec1 = {
"dept_id": "1.1",
"parent_id": "1",
"name": "East",
"path": "Sales/East",
}
rec2 = {
"dept_id": "1.1",
"parent_id": "1",
"name": "East",
# Re-scrape reconstructs the same path; verify it is overwritten
# rather than producing a duplicate row.
"path": "Sales/East",
"raw_json": {"id": "1.1", "pId": "1", "name": "East"},
}
await upsert_department(session, rec1)
await session.commit()
await upsert_department(session, rec2)
await session.commit()
rows = (
await session.execute(
sa.select(Department)
.where(Department.dept_id == "1.1")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(rows) == 1
assert rows[0].dept_id == "1.1"
assert rows[0].parent_id == "1"
assert rows[0].name == "East"
assert rows[0].path == "Sales/East"
# raw_json from the second upsert wins (last write).
assert rows[0].raw_json == rec2["raw_json"]
finally:
await engine.dispose()
async def test_upsert_department_preserves_none_parent_for_root():
"""A root dept (parent_id None) is stored as-is; no spurious coercion."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"dept_id": "1",
"parent_id": None,
"name": "Sales",
"path": "Sales",
}
await upsert_department(session, rec)
await session.commit()
fetched = (
await session.execute(
sa.select(Department).where(Department.dept_id == "1")
)
).scalar_one()
assert fetched.parent_id is None
assert fetched.name == "Sales"
assert fetched.path == "Sales"
finally:
await engine.dispose()
# ---------------------------------------------------------------------------
# upsert_staff — direct staff-shaped dict upsert (Task 2.1)
# ---------------------------------------------------------------------------
async def test_upsert_staff_is_idempotent_and_updates_name():
"""Same user_id twice with different name -> 1 row, name refreshed."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec1 = {"user_id": "u_new", "name": "Old Name"}
rec2 = {"user_id": "u_new", "name": "New Name"}
await upsert_staff(session, rec1)
await session.commit()
await upsert_staff(session, rec2)
await session.commit()
rows = (
await session.execute(
sa.select(Staff)
.where(Staff.user_id == "u_new")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(rows) == 1
assert rows[0].name == "New Name"
finally:
await engine.dispose()
async def test_upsert_staff_coerces_is_on_job():
"""is_on_job '1' -> True; '0' -> False; None preserved."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
await upsert_staff(
session, {"user_id": "u_b1", "is_on_job": "1"}
)
await upsert_staff(
session, {"user_id": "u_b0", "is_on_job": "0"}
)
await upsert_staff(
session, {"user_id": "u_bn", "is_on_job": None}
)
await session.commit()
on = (
await session.execute(
sa.select(Staff).where(Staff.user_id == "u_b1")
)
).scalar_one()
off = (
await session.execute(
sa.select(Staff).where(Staff.user_id == "u_b0")
)
).scalar_one()
none_ = (
await session.execute(
sa.select(Staff).where(Staff.user_id == "u_bn")
)
).scalar_one()
assert on.is_on_job is True
assert off.is_on_job is False
assert none_.is_on_job is None
finally:
await engine.dispose()
# ---------------------------------------------------------------------------
# upsert_wechat_account now ALSO upserts the embedded Staff (Task 2.1)
# ---------------------------------------------------------------------------
async def test_upsert_wechat_account_also_upserts_embedded_staff():
"""Account upsert creates BOTH the WechatAccount row and the Staff row."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"wx_id": "wxid_dup",
"nick_name": "Dup WeChat",
"user_id": "u_dup",
"user_name": "Dup Real Name",
"is_on_job": 1,
"phone": "13800000099",
}
await upsert_wechat_account(session, rec)
await session.commit()
acct = (
await session.execute(
sa.select(WechatAccount).where(WechatAccount.wx_id == "wxid_dup")
)
).scalar_one()
assert acct.user_id == "u_dup"
assert acct.user_name == "Dup Real Name"
staff = (
await session.execute(
sa.select(Staff).where(Staff.user_id == "u_dup")
)
).scalar_one()
assert staff.name == "Dup Real Name"
assert staff.is_on_job is True
finally:
await engine.dispose()
async def test_upsert_wechat_account_reupsert_updates_embedded_staff():
"""Re-upserting the account refreshes BOTH account + embedded staff rows."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec1 = {
"wx_id": "wxid_re",
"nick_name": "Old Nick",
"user_id": "u_re",
"user_name": "Old Name",
"is_on_job": 1,
}
rec2 = {
"wx_id": "wxid_re",
"nick_name": "New Nick",
"user_id": "u_re",
"user_name": "New Name",
"is_on_job": 0, # staff departed since last scrape
}
await upsert_wechat_account(session, rec1)
await session.commit()
await upsert_wechat_account(session, rec2)
await session.commit()
# One account row, refreshed.
acct_rows = (
await session.execute(
sa.select(WechatAccount)
.where(WechatAccount.wx_id == "wxid_re")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(acct_rows) == 1
assert acct_rows[0].nick_name == "New Nick"
# One staff row, refreshed (not duplicated).
staff_rows = (
await session.execute(
sa.select(Staff)
.where(Staff.user_id == "u_re")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(staff_rows) == 1
assert staff_rows[0].name == "New Name"
assert staff_rows[0].is_on_job is False
finally:
await engine.dispose()
async def test_upsert_wechat_account_without_user_id_skips_staff():
"""Account with no user_id is written; embedded-staff upsert is skipped."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"wx_id": "wxid_no_staff",
"nick_name": "Orphan WeChat",
# no user_id / user_name
}
await upsert_wechat_account(session, rec)
await session.commit()
# Account row written.
acct = (
await session.execute(
sa.select(WechatAccount).where(
WechatAccount.wx_id == "wxid_no_staff"
)
)
).scalar_one()
assert acct.nick_name == "Orphan WeChat"
# No staff rows at all.
staff_rows = (
await session.execute(sa.select(Staff))
).scalars().all()
assert staff_rows == []
finally:
await engine.dispose()