2026-10-12 23:22:34 -04:00
|
|
|
|
"""客户管理接口集成测试(需要 MySQL 与 Redis)。
|
|
|
|
|
|
|
|
|
|
|
|
守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务
|
|
|
|
|
|
完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据;
|
|
|
|
|
|
编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的
|
|
|
|
|
|
append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。
|
|
|
|
|
|
|
|
|
|
|
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
|
|
|
|
|
|
|
|
|
|
|
数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
ACTION_CREATE,
|
|
|
|
|
|
BUSINESS_ROLE_SALESPERSON,
|
|
|
|
|
|
CUSTOMER_TYPE_ENTERPRISE,
|
|
|
|
|
|
CUSTOMER_TYPE_PERSONAL,
|
|
|
|
|
|
MODULE_CUSTOMER,
|
2026-10-13 05:06:46 -04:00
|
|
|
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import BusinessRole, OperationLog, OwnershipChangeLog
|
|
|
|
|
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
|
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
|
|
|
|
|
|
_PERSONNEL = "/api/v1/base/personnel"
|
|
|
|
|
|
_CUSTOMERS = "/api/v1/base/customers"
|
2026-10-13 05:06:46 -04:00
|
|
|
|
_BUSINESSES = "/api/v1/business/businesses"
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _company() -> str:
|
|
|
|
|
|
return f"t_公司_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _role_id() -> int:
|
|
|
|
|
|
"""种子岗位(营销员):归属人员的合法岗位引用。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
return int(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(BusinessRole.id).where(
|
|
|
|
|
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
|
|
|
|
|
BusinessRole.status == 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _personnel(client: TestClient, headers: dict) -> dict:
|
|
|
|
|
|
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"business_role_id": _role_id(),
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cpayload(company: str, **overrides: object) -> dict:
|
|
|
|
|
|
body: dict = {
|
|
|
|
|
|
"company_name": company,
|
|
|
|
|
|
"owner_name": "张老板",
|
|
|
|
|
|
"customer_type": CUSTOMER_TYPE_PERSONAL,
|
|
|
|
|
|
"address": "本溪市平山区",
|
|
|
|
|
|
}
|
|
|
|
|
|
body.update(overrides)
|
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_customer(client: TestClient, headers: dict, **overrides: object) -> dict:
|
|
|
|
|
|
resp = client.post(_CUSTOMERS, json=_cpayload(_company(), **overrides), headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _logs_of(username: str) -> list[OperationLog]:
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
return list(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(OperationLog)
|
|
|
|
|
|
.where(OperationLog.username == username)
|
|
|
|
|
|
.order_by(OperationLog.id.desc())
|
|
|
|
|
|
).scalars()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _latest_log(customer_id: int) -> OwnershipChangeLog | None:
|
|
|
|
|
|
"""取该客户最新一条归属留痕(按 id 倒序)。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
return (
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(OwnershipChangeLog)
|
|
|
|
|
|
.where(OwnershipChangeLog.customer_id == customer_id)
|
|
|
|
|
|
.order_by(OwnershipChangeLog.id.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
.scalars()
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAuth:
|
|
|
|
|
|
"""鉴权:登录后接口,裸访问一律 401。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_未登录访问客户列表_401(self, client: TestClient) -> None:
|
|
|
|
|
|
resp = client.get(_CUSTOMERS)
|
|
|
|
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCrudFlow:
|
|
|
|
|
|
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
owner = _personnel(client, writer["headers"])
|
|
|
|
|
|
data = _create_customer(client, writer["headers"], owner_personnel_id=owner["id"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert data["owner_personnel_id"] == owner["id"]
|
|
|
|
|
|
assert data["owner_personnel_name"] == owner["personnel_name"]
|
|
|
|
|
|
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_同公司名不同负责人创建成功(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
company = _company()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
first = _create_customer(client, writer["headers"], company_name=company)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
second = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json=_cpayload(company, owner_name="李老板"),
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers=writer["headers"],
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert second.status_code == 200, second.text
|
|
|
|
|
|
assert second.json()["data"]["id"] != first["id"]
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_重名同负责人_400(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
company = _company()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_create_customer(client, writer["headers"], company_name=company)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
dup = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json=_cpayload(company, owner_name="张老板"),
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers=writer["headers"],
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert dup.status_code == 400, dup.text
|
|
|
|
|
|
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
2026-10-13 05:06:46 -04:00
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, writer: dict) -> None:
|
2026-10-13 05:06:46 -04:00
|
|
|
|
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-13 05:06:46 -04:00
|
|
|
|
customer = _create_customer(client, headers)
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_BUSINESSES,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"customer_id": customer["id"],
|
|
|
|
|
|
"items": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"product_name": "t_被引用产品",
|
|
|
|
|
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
|
|
|
|
|
"quantity": "1",
|
|
|
|
|
|
"sale_price": "1",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
|
|
|
|
|
assert blocked.status_code == 400, blocked.text
|
|
|
|
|
|
assert "已存在关联业务" in blocked.json()["message"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_客户分类非法_400(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json=_cpayload(_company(), customer_type="not_a_type"),
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers=writer["headers"],
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
assert "客户分类不合法" in resp.json()["message"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_编辑其他字段不触归属_塞归属字段也被忽略(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-12 23:22:34 -04:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
owner = _personnel(client, headers)
|
|
|
|
|
|
stranger = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
|
|
|
|
|
|
|
|
|
|
|
updated = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"company_name": customer["company_name"],
|
|
|
|
|
|
"owner_name": customer["owner_name"],
|
|
|
|
|
|
"customer_type": CUSTOMER_TYPE_ENTERPRISE,
|
|
|
|
|
|
"address": "沈阳市沈河区",
|
|
|
|
|
|
# 试图经编辑端点改归属:schema 未声明该字段,必须被忽略
|
|
|
|
|
|
"ownerPersonnelId": stranger["id"],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert updated.status_code == 200, updated.text
|
|
|
|
|
|
body = updated.json()["data"]
|
|
|
|
|
|
assert body["customer_type"] == CUSTOMER_TYPE_ENTERPRISE
|
|
|
|
|
|
assert body["address"] == "沈阳市沈河区"
|
|
|
|
|
|
assert body["owner_personnel_id"] == owner["id"], "编辑端点不得改动归属"
|
|
|
|
|
|
assert body["owner_personnel_name"] == owner["personnel_name"]
|
|
|
|
|
|
|
|
|
|
|
|
detail = client.get(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
|
|
|
|
|
assert detail.json()["data"]["owner_personnel_id"] == owner["id"]
|
|
|
|
|
|
assert _latest_log(int(customer["id"])) is None, "编辑不应产生归属留痕"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOwnership:
|
|
|
|
|
|
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_归属变更_主表更新且留痕新增(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
old = _personnel(client, headers)
|
|
|
|
|
|
new = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.json()["data"]
|
|
|
|
|
|
assert body["owner_personnel_id"] == new["id"]
|
|
|
|
|
|
assert body["owner_personnel_name"] == new["personnel_name"]
|
|
|
|
|
|
|
|
|
|
|
|
log = _latest_log(int(customer["id"]))
|
|
|
|
|
|
assert log is not None, "归属变更未写留痕"
|
|
|
|
|
|
assert log.owner_type == OWNER_TYPE_CUSTOMER
|
|
|
|
|
|
assert log.customer_id == int(customer["id"])
|
|
|
|
|
|
assert log.old_owner_personnel_id == int(old["id"])
|
|
|
|
|
|
assert log.new_owner_personnel_id == int(new["id"])
|
|
|
|
|
|
assert log.change_reason == "区域调整"
|
2026-11-01 20:42:53 -05:00
|
|
|
|
assert log.operator_name == writer["username"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_变更后归属人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
target = _personnel(client, headers)
|
|
|
|
|
|
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
|
|
|
|
|
|
customer = _create_customer(client, headers)
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": target["id"]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
assert "归属人员不存在或已离职" in resp.json()["message"]
|
|
|
|
|
|
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_新旧归属相同_400(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
owner = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": owner["id"]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
assert "归属未变化" in resp.json()["message"]
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_双方均为空_400(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
|
2026-11-01 20:42:53 -05:00
|
|
|
|
customer = _create_customer(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": None},
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers=writer["headers"],
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
assert "归属未变化" in resp.json()["message"]
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
owner = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": None, "reason": "暂无负责人"},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.json()["data"]
|
|
|
|
|
|
assert body["owner_personnel_id"] is None
|
|
|
|
|
|
assert body["owner_personnel_name"] is None
|
|
|
|
|
|
|
|
|
|
|
|
log = _latest_log(int(customer["id"]))
|
|
|
|
|
|
assert log is not None
|
|
|
|
|
|
assert log.old_owner_personnel_id == int(owner["id"])
|
|
|
|
|
|
assert log.new_owner_personnel_id is None
|
|
|
|
|
|
assert log.change_reason == "暂无负责人"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOwnershipLogs:
|
|
|
|
|
|
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
first = _personnel(client, headers)
|
|
|
|
|
|
second = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=first["id"])
|
|
|
|
|
|
|
|
|
|
|
|
r1 = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": second["id"], "reason": "第一次调整"},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert r1.status_code == 200, r1.text
|
|
|
|
|
|
r2 = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": None, "reason": "清空归属"},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert r2.status_code == 200, r2.text
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(f"{_CUSTOMERS}/{customer['id']}/ownership-logs", headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.json()["data"]
|
|
|
|
|
|
assert body["total"] == 2
|
|
|
|
|
|
latest, earlier = body["list"][0], body["list"][1]
|
|
|
|
|
|
assert latest["change_reason"] == "清空归属"
|
|
|
|
|
|
assert latest["old_owner_personnel_id"] == int(second["id"])
|
|
|
|
|
|
assert latest["old_owner_name"] == second["personnel_name"]
|
|
|
|
|
|
assert latest["new_owner_personnel_id"] is None
|
|
|
|
|
|
assert latest["new_owner_name"] is None
|
|
|
|
|
|
assert earlier["old_owner_name"] == first["personnel_name"]
|
|
|
|
|
|
assert earlier["new_owner_name"] == second["personnel_name"]
|
|
|
|
|
|
assert earlier["new_owner_personnel_id"] == int(second["id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAudit:
|
|
|
|
|
|
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_新增客户留审计(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create_customer(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
logs = [
|
|
|
|
|
|
log
|
2026-11-01 20:42:53 -05:00
|
|
|
|
for log in _logs_of(writer["username"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
|
|
|
|
|
|
]
|
|
|
|
|
|
assert logs, "新增客户未留操作日志"
|
|
|
|
|
|
latest = logs[0]
|
|
|
|
|
|
assert latest.success == 1
|
|
|
|
|
|
assert data["company_name"] in latest.summary
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_归属变更审计摘要含客户名(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
old = _personnel(client, headers)
|
|
|
|
|
|
new = _personnel(client, headers)
|
|
|
|
|
|
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
|
|
|
|
|
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
logs = [
|
|
|
|
|
|
log
|
2026-11-01 20:42:53 -05:00
|
|
|
|
for log in _logs_of(writer["username"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
if log.module == MODULE_CUSTOMER and log.success == 1
|
|
|
|
|
|
]
|
|
|
|
|
|
assert logs, "归属变更未留操作日志"
|
|
|
|
|
|
latest = logs[0]
|
|
|
|
|
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
|
|
|
|
|
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
2026-10-22 22:59:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRating:
|
|
|
|
|
|
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
|
|
|
|
|
|
|
|
|
|
|
|
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
|
|
|
|
|
|
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
|
|
|
|
|
|
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-22 22:59:33 -04:00
|
|
|
|
) -> None:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-22 22:59:33 -04:00
|
|
|
|
customer = _create_customer(client, headers)
|
|
|
|
|
|
assert customer["rating_score"] is None, "新客户应无评分"
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.json()["data"]
|
|
|
|
|
|
assert body["rating_score"] == 88
|
|
|
|
|
|
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
|
|
|
|
|
|
|
|
|
|
|
|
# 覆盖式保存:新分数替换旧分数
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.json()["data"]["rating_score"] == 60
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_评分越界_400(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-10-22 22:59:33 -04:00
|
|
|
|
customer = _create_customer(client, headers)
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-22 22:59:33 -04:00
|
|
|
|
) -> None:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-22 22:59:33 -04:00
|
|
|
|
high = _create_customer(client, headers)
|
|
|
|
|
|
low = _create_customer(client, headers)
|
|
|
|
|
|
unrated = _create_customer(client, headers)
|
|
|
|
|
|
for target, score in ((high, 80), (low, 60)):
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
def _order_names(order: str) -> list[str]:
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
names = [row["company_name"] for row in resp.json()["data"]["list"]]
|
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
|
|
|
|
|
|
desc_names = _order_names("desc")
|
|
|
|
|
|
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
|
|
|
|
|
|
assert desc_names[-1] == unrated["company_name"]
|
|
|
|
|
|
asc_names = _order_names("asc")
|
|
|
|
|
|
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
|
|
|
|
|
|
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
|