2026-10-12 23:22:34 -04:00
|
|
|
|
"""人员管理接口集成测试(需要 MySQL 与 Redis)。
|
|
|
|
|
|
|
|
|
|
|
|
守的是人员档案的引用完整性 —— 岗位与直属经理是 M3 核算取公式模板与
|
|
|
|
|
|
经理聚合展开的锚点,悬空引用或环引用会让核算取不到公式、聚合死循环;
|
|
|
|
|
|
删除保护缺失会留下悬空的 manager_id 与客户归属,历史核算无法回溯到人;
|
|
|
|
|
|
辐射区域与拓展计划若把「全量替换」做成了追加,编辑一次就翻倍一份脏数据。
|
|
|
|
|
|
|
|
|
|
|
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
|
|
|
|
|
|
|
|
|
|
|
数据卫生:人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理;仅「删除」
|
|
|
|
|
|
语义的用例自验 DELETE 端点。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
ACTION_CREATE,
|
|
|
|
|
|
BUSINESS_ROLE_SALESPERSON,
|
|
|
|
|
|
MODULE_PERSONNEL,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import BusinessRole, OperationLog
|
|
|
|
|
|
|
|
|
|
|
|
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 _uname() -> 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()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def _manager(client, headers):
|
|
|
|
|
|
with get_session_factory()() as session:
|
|
|
|
|
|
role_id = session.scalar(select(BusinessRole.id).where(BusinessRole.calc_template == "TPL_MANAGER_SHARE", BusinessRole.status == 1))
|
|
|
|
|
|
return _create(client, headers, business_role_id=role_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 23:22:34 -04:00
|
|
|
|
def _payload(name: str, **overrides: object) -> dict:
|
|
|
|
|
|
body: dict = {
|
|
|
|
|
|
"personnel_name": name,
|
|
|
|
|
|
"business_role_id": _role_id(),
|
|
|
|
|
|
"salary_standard": "3500.50",
|
|
|
|
|
|
}
|
|
|
|
|
|
body.update(overrides)
|
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
|
|
|
|
|
|
resp = client.post(_PERSONNEL, json=_payload(_uname(), **overrides), headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _logs_of(username: str) -> list[OperationLog]:
|
|
|
|
|
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
return list(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(OperationLog)
|
|
|
|
|
|
.where(OperationLog.username == username)
|
|
|
|
|
|
.order_by(OperationLog.id.desc())
|
|
|
|
|
|
).scalars()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAuth:
|
|
|
|
|
|
"""鉴权:基础数据是业务操作不要求管理员,但仍是登录后接口。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_未登录访问人员列表_401(self, client: TestClient) -> None:
|
|
|
|
|
|
resp = client.get(_PERSONNEL)
|
|
|
|
|
|
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:
|
|
|
|
|
|
data = _create(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert data["status"] == 1, "新增人员应默认在职"
|
|
|
|
|
|
assert data["is_probation"] == 0
|
|
|
|
|
|
# 金额红线:str 进 str 出,不得经 float
|
|
|
|
|
|
assert data["salary_standard"] == "3500.50"
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert detail.status_code == 200, detail.text
|
|
|
|
|
|
body = detail.json()["data"]
|
|
|
|
|
|
assert body["salary_standard"] == "3500.50"
|
|
|
|
|
|
assert body["regions"] == []
|
|
|
|
|
|
assert body["plans"] == []
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
manager = _manager(client, headers)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
member = _create(client, headers, manager_id=manager["id"])
|
|
|
|
|
|
assert member["manager_id"] == manager["id"]
|
|
|
|
|
|
assert member["manager_name"] == manager["personnel_name"]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
assert member["business_role_id"] == _role_id()
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
listed = client.get(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert listed.status_code == 200, listed.text
|
|
|
|
|
|
rows = listed.json()["data"]["list"]
|
|
|
|
|
|
assert [r["id"] for r in rows] == [data["id"]]
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
updated = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}",
|
|
|
|
|
|
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers=writer["headers"],
|
2026-10-12 23:22:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert updated.status_code == 200, updated.text
|
|
|
|
|
|
body = updated.json()["data"]
|
|
|
|
|
|
assert body["phone"] == "13900001111"
|
|
|
|
|
|
assert body["is_probation"] == 1
|
|
|
|
|
|
|
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
|
|
|
|
data = _create(client, headers)
|
|
|
|
|
|
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
|
|
|
|
|
|
assert off.status_code == 200, off.text
|
|
|
|
|
|
assert off.json()["data"]["status"] == 0
|
|
|
|
|
|
on = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 1}, headers=headers)
|
|
|
|
|
|
assert on.status_code == 200, on.text
|
|
|
|
|
|
assert on.json()["data"]["status"] == 1
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create(client, writer["headers"])
|
|
|
|
|
|
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert resp.status_code == 200, resp.text
|
2026-11-01 20:42:53 -05:00
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert detail.status_code == 404
|
|
|
|
|
|
assert "人员不存在" in detail.json()["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRefRules:
|
|
|
|
|
|
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create(client, writer["headers"])
|
2026-10-12 23:22:34 -04:00
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}",
|
|
|
|
|
|
json=_payload(data["personnel_name"], manager_id=data["id"]),
|
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_直属经理不存在_400(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json=_payload(_uname(), manager_id=999999999),
|
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_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
manager = _manager(client, headers)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
quit_resp = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert quit_resp.status_code == 200, quit_resp.text
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json=_payload(_uname(), manager_id=manager["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
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json=_payload(_uname(), business_role_id=999999999),
|
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_岗位已停用_400(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
|
|
|
|
|
|
code = f"t_br_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
session.add(
|
|
|
|
|
|
BusinessRole(
|
|
|
|
|
|
role_code=code,
|
|
|
|
|
|
role_name="已停用测试岗位",
|
|
|
|
|
|
calc_template="TPL_SALESPERSON_DECAY",
|
|
|
|
|
|
status=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
role_id = int(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(BusinessRole.id).where(BusinessRole.role_code == code)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
)
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json=_payload(_uname(), business_role_id=role_id),
|
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-02 22:20:55 -05:00
|
|
|
|
class TestManagerEligibility:
|
|
|
|
|
|
def test_经理参照只列在职经理岗位且不泄露薪资(self, client, writer):
|
|
|
|
|
|
headers = writer["headers"]
|
|
|
|
|
|
manager = _manager(client, headers)
|
|
|
|
|
|
ordinary = _create(client, headers)
|
|
|
|
|
|
response = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers)
|
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
rows = response.json()["data"]
|
|
|
|
|
|
assert manager["id"] in {row["id"] for row in rows}
|
|
|
|
|
|
assert ordinary["id"] not in {row["id"] for row in rows}
|
|
|
|
|
|
assert all(set(row) == {"id", "personnel_name", "business_role_id", "status"} for row in rows)
|
|
|
|
|
|
|
|
|
|
|
|
def test_普通岗位即使在职也不能被新选为经理(self, client, writer):
|
|
|
|
|
|
headers = writer["headers"]
|
|
|
|
|
|
ordinary = _create(client, headers)
|
|
|
|
|
|
response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=ordinary["id"]))
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
assert "经理计提模板" in response.json()["message"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_经理转岗后历史参考保留但不能新增引用(self, client, writer):
|
|
|
|
|
|
headers = writer["headers"]
|
|
|
|
|
|
manager = _manager(client, headers)
|
|
|
|
|
|
member = _create(client, headers, manager_id=manager["id"])
|
|
|
|
|
|
response = client.put(f"{_PERSONNEL}/{manager['id']}", headers=headers, json=_payload(manager["personnel_name"]))
|
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
response = client.put(f"{_PERSONNEL}/{member['id']}", headers=headers, json=_payload(member["personnel_name"], manager_id=manager["id"]))
|
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=manager["id"]))
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
rows = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers).json()["data"]
|
|
|
|
|
|
assert manager["id"] not in {row["id"] for row in rows}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 23:22:34 -04:00
|
|
|
|
class TestDeleteProtection:
|
|
|
|
|
|
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
headers = writer["headers"]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
manager = _manager(client, headers)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
|
|
|
|
|
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
|
|
|
|
|
assert blocked.status_code == 400, blocked.text
|
2026-10-14 06:46:43 -04:00
|
|
|
|
assert (
|
|
|
|
|
|
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
|
|
|
|
|
|
)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
|
|
|
|
|
client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers)
|
|
|
|
|
|
freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
|
|
|
|
|
assert freed.status_code == 200, freed.text
|
|
|
|
|
|
|
|
|
|
|
|
def test_被客户归属引用禁删_离职不放宽_解除后可删(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-12 23:22:34 -04:00
|
|
|
|
) -> None:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
owner = _create(client, headers)
|
|
|
|
|
|
created = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"owner_name": "张老板",
|
|
|
|
|
|
"customer_type": "personal",
|
|
|
|
|
|
"owner_personnel_id": owner["id"],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert created.status_code == 200, created.text
|
|
|
|
|
|
customer = created.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
|
|
|
|
|
assert blocked.status_code == 400, blocked.text
|
2026-10-14 06:46:43 -04:00
|
|
|
|
assert (
|
|
|
|
|
|
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
|
|
|
|
|
|
)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
|
|
|
|
|
# 保护与在职状态无关:离职并不能让物理删除放行
|
|
|
|
|
|
client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers)
|
|
|
|
|
|
still = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
|
|
|
|
|
assert still.status_code == 400, still.text
|
|
|
|
|
|
|
|
|
|
|
|
# 删客户解除引用后方可删人
|
|
|
|
|
|
assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200
|
2026-10-13 05:06:46 -04:00
|
|
|
|
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
|
|
|
|
|
assert freed.status_code == 200, freed.text
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None:
|
2026-10-13 05:06:46 -04:00
|
|
|
|
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-13 05:06:46 -04:00
|
|
|
|
owner = _create(client, headers)
|
|
|
|
|
|
assistant = _create(client, headers)
|
|
|
|
|
|
resp_customer = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"owner_name": "张老板",
|
|
|
|
|
|
"customer_type": "personal",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp_customer.status_code == 200, resp_customer.text
|
|
|
|
|
|
customer_id = resp_customer.json()["data"]["id"]
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_BUSINESSES,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"customer_id": customer_id,
|
|
|
|
|
|
"owner_personnel_id": owner["id"],
|
|
|
|
|
|
"assistant_personnel_id": assistant["id"],
|
|
|
|
|
|
"items": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"product_name": "t_引用产品",
|
|
|
|
|
|
"product_category": "municipal_water",
|
|
|
|
|
|
"quantity": "1",
|
|
|
|
|
|
"sale_price": "1",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
for target, label in ((owner, "负责"), (assistant, "协助")):
|
|
|
|
|
|
blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers)
|
|
|
|
|
|
assert blocked.status_code == 400, blocked.text
|
|
|
|
|
|
assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截"
|
|
|
|
|
|
|
|
|
|
|
|
# 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户)
|
|
|
|
|
|
business_id = resp.json()["data"]["id"]
|
|
|
|
|
|
assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200
|
|
|
|
|
|
assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200
|
2026-10-12 23:22:34 -04:00
|
|
|
|
assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSubTableReplace:
|
|
|
|
|
|
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_辐射区域设置后详情回显一致_再次替换生效(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-12 23:22:34 -04:00
|
|
|
|
) -> None:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
data = _create(client, headers)
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/regions",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"items": [
|
|
|
|
|
|
{"region_name": "本溪市平山区", "remark": "核心区域"},
|
|
|
|
|
|
{"region_name": "本溪市溪湖区"},
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert [r["region_name"] for r in detail["regions"]] == ["本溪市平山区", "本溪市溪湖区"]
|
|
|
|
|
|
assert detail["regions"][0]["remark"] == "核心区域"
|
|
|
|
|
|
|
|
|
|
|
|
# 再替换为 1 条:是覆盖而非追加
|
|
|
|
|
|
resp2 = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/regions",
|
|
|
|
|
|
json={"items": [{"region_name": "沈阳市沈河区"}]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp2.status_code == 200, resp2.text
|
|
|
|
|
|
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
|
|
|
|
|
|
|
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
|
|
|
|
data = _create(client, headers)
|
|
|
|
|
|
client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/regions",
|
|
|
|
|
|
json={"items": [{"region_name": "本溪市平山区"}]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
cleared = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/regions", json={"items": []}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert cleared.status_code == 200, cleared.text
|
|
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert detail["regions"] == []
|
|
|
|
|
|
|
|
|
|
|
|
def test_拓展计划设置后详情回显一致_再次替换生效(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, client: TestClient, writer: dict
|
2026-10-12 23:22:34 -04:00
|
|
|
|
) -> None:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
headers = writer["headers"]
|
2026-10-12 23:22:34 -04:00
|
|
|
|
data = _create(client, headers)
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/plans",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"items": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"plan_period": "2026Q1",
|
|
|
|
|
|
"plan_content": "拓展本溪市政客户",
|
|
|
|
|
|
"target_desc": "新增3户",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"plan_period": "2026Q2", "plan_content": "维护存量客户"},
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert [p["plan_period"] for p in detail["plans"]] == ["2026Q1", "2026Q2"]
|
|
|
|
|
|
assert detail["plans"][0]["target_desc"] == "新增3户"
|
|
|
|
|
|
|
|
|
|
|
|
resp2 = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/plans",
|
|
|
|
|
|
json={"items": [{"plan_period": "2026Q3", "plan_content": "开拓轨交市场"}]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp2.status_code == 200, resp2.text
|
|
|
|
|
|
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
|
|
|
|
|
|
|
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
|
|
|
|
data = _create(client, headers)
|
|
|
|
|
|
client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/plans",
|
|
|
|
|
|
json={"items": [{"plan_period": "2026Q1", "plan_content": "拓展本溪市政客户"}]},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
cleared = client.put(
|
|
|
|
|
|
f"{_PERSONNEL}/{data['id']}/plans", json={"items": []}, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
assert cleared.status_code == 200, cleared.text
|
|
|
|
|
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
|
|
|
|
|
assert detail["plans"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAudit:
|
|
|
|
|
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
|
|
|
|
|
|
data = _create(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_PERSONNEL and log.action == ACTION_CREATE
|
|
|
|
|
|
]
|
|
|
|
|
|
assert logs, "新增人员未留操作日志"
|
|
|
|
|
|
latest = logs[0]
|
|
|
|
|
|
assert latest.success == 1
|
|
|
|
|
|
assert data["personnel_name"] in latest.summary
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None:
|
2026-10-12 23:22:34 -04:00
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_PERSONNEL,
|
|
|
|
|
|
json=_payload(_uname(), manager_id=999999999),
|
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
|
|
|
|
|
|
failed = [
|
|
|
|
|
|
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_PERSONNEL and log.success == 0
|
|
|
|
|
|
]
|
|
|
|
|
|
assert failed, "失败操作未留痕"
|
|
|
|
|
|
assert "直属经理不存在或已离职" in (failed[0].error_message or "")
|