PayrollManagementSystem/backend/tests/integration/test_personnel.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

492 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""人员管理接口集成测试(需要 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"
_BUSINESSES = "/api/v1/business/businesses"
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()
)
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)
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:
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
assert data["status"] == 1, "新增人员应默认在职"
assert data["is_probation"] == 0
# 金额红线:str 进 str 出,不得经 float
assert data["salary_standard"] == "3500.50"
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
assert detail.status_code == 200, detail.text
body = detail.json()["data"]
assert body["salary_standard"] == "3500.50"
assert body["regions"] == []
assert body["plans"] == []
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _manager(client, headers)
member = _create(client, headers, manager_id=manager["id"])
assert member["manager_id"] == manager["id"]
assert member["manager_name"] == manager["personnel_name"]
assert member["business_role_id"] == _role_id()
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
listed = client.get(
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=writer["headers"]
)
assert listed.status_code == 200, listed.text
rows = listed.json()["data"]["list"]
assert [r["id"] for r in rows] == [data["id"]]
def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
updated = client.put(
f"{_PERSONNEL}/{data['id']}",
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
headers=writer["headers"],
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["phone"] == "13900001111"
assert body["is_probation"] == 1
def test_在职离职双向切换(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
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
def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
assert detail.status_code == 404
assert "人员不存在" in detail.json()["message"]
class TestRefRules:
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
resp = client.put(
f"{_PERSONNEL}/{data['id']}",
json=_payload(data["personnel_name"], manager_id=data["id"]),
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不能是自己" in resp.json()["message"]
def test_直属经理不存在_400(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"]
def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _manager(client, headers)
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"]
def test_岗位不存在_400(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), business_role_id=999999999),
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
def test_岗位已停用_400(self, client: TestClient, writer: dict) -> None:
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
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),
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
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}
class TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _manager(client, headers)
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert (
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
)
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_被客户归属引用禁删_离职不放宽_解除后可删(
self, client: TestClient, writer: dict
) -> None:
headers = writer["headers"]
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
assert (
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
)
# 保护与在职状态无关:离职并不能让物理删除放行
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
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert freed.status_code == 200, freed.text
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None:
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
headers = writer["headers"]
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
assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200
class TestSubTableReplace:
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
def test_辐射区域设置后详情回显一致_再次替换生效(
self, client: TestClient, writer: dict
) -> None:
headers = writer["headers"]
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"]] == ["沈阳市沈河区"]
def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
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_拓展计划设置后详情回显一致_再次替换生效(
self, client: TestClient, writer: dict
) -> None:
headers = writer["headers"]
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"]
def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
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 留痕 —— 审计是验收项。"""
def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
logs = [
log
for log in _logs_of(writer["username"])
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
def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(writer["username"])
if log.module == MODULE_PERSONNEL and log.success == 0
]
assert failed, "失败操作未留痕"
assert "直属经理不存在或已离职" in (failed[0].error_message or "")