"""人员管理接口集成测试(需要 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 "")