PayrollManagementSystem/backend/tests/integration/test_customers.py

481 lines
20 KiB
Python
Raw Normal View History

"""客户管理接口集成测试(需要 MySQL 与 Redis)。
守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务
完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据;
编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的
append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。
"""
import uuid
from decimal import Decimal
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,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
)
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"
_BUSINESSES = "/api/v1/business/businesses"
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:
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None:
owner = _personnel(client, normal["headers"])
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"])
assert data["owner_personnel_id"] == owner["id"]
assert data["owner_personnel_name"] == owner["personnel_name"]
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None:
company = _company()
first = _create_customer(client, normal["headers"], company_name=company)
second = client.post(
_CUSTOMERS,
json=_cpayload(company, owner_name="李老板"),
headers=normal["headers"],
)
assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"]
def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None:
company = _company()
_create_customer(client, normal["headers"], company_name=company)
dup = client.post(
_CUSTOMERS,
json=_cpayload(company, owner_name="张老板"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
headers = normal["headers"]
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"]
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_CUSTOMERS,
json=_cpayload(_company(), customer_type="not_a_type"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "客户分类不合法" in resp.json()["message"]
def test_编辑其他字段不触归属_塞归属字段也被忽略(
self, client: TestClient, normal: dict
) -> None:
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
headers = normal["headers"]
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:
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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 == "区域调整"
assert log.operator_name == normal["username"]
def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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, "失败变更不应留痕"
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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"]
def test_双方均为空_400(self, client: TestClient, normal: dict) -> None:
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
customer = _create_customer(client, normal["headers"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": None},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"]
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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:
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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:
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None:
data = _create_customer(client, normal["headers"])
logs = [
log
for log in _logs_of(normal["username"])
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
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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
for log in _logs_of(normal["username"])
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, "摘要应体现新旧归属走向"
# ==================== 客户评级列(V7) ====================
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
"""保存客户评级(五项 0-20),返回响应 data。"""
resp = client.put(
f"{_CUSTOMERS}/{customer_id}/rating",
json=dict(zip(_RATING_KEYS, scores, strict=True)),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
resp = client.get(_CUSTOMERS, params=query, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]["list"]
class TestRatingColumn:
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
未评级行用布尔键模拟,正序/倒序都在末尾。
"""
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
token = uuid.uuid4().hex[:8]
keyword = f"t_公司_{token}"
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
_rate(client, headers, high["id"], ["18"] * 5)
_rate(client, headers, low["id"], ["0"] * 5)
return keyword, high, low, none
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
rows = _list_by_keyword(client, headers, keyword)
by_id = {row["id"]: row for row in rows}
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
assert by_id[high["id"]]["rating_grade_name"] == "A"
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
assert by_id[low["id"]]["rating_grade_name"] == "D"
assert by_id[none["id"]]["rating_total"] is None
assert by_id[none["id"]]["rating_grade_name"] is None
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
resp = client.get(
_CUSTOMERS,
params={"order_field": "company_name", "order_type": "asc"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
resp = client.get(
_CUSTOMERS,
params={"order_field": "rating", "order_type": "sideways"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
_rate(client, headers, customer["id"], ["20"] * 5)
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert resp.status_code == 200, resp.text
assert (
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
)