PayrollManagementSystem/backend/tests/integration/test_personnel.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

453 lines
19 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 _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 = _create(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["role_name"] == manager["role_name"]
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 = _create(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 TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _create(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 "")