feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

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

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

No files matched your search

+48 -48
View File
@@ -90,51 +90,51 @@ class TestAuth:
class TestCrudFlow:
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
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=normal["headers"])
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, normal: dict) -> None:
headers = normal["headers"]
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, normal: dict) -> None:
data = _create(client, normal["headers"])
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
listed = client.get(
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"]
_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, normal: dict) -> None:
data = _create(client, normal["headers"])
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=normal["headers"],
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, normal: dict) -> None:
headers = normal["headers"]
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
@@ -143,11 +143,11 @@ class TestCrudFlow:
assert on.status_code == 200, on.text
assert on.json()["data"]["status"] == 1
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
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=normal["headers"])
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
assert detail.status_code == 404
assert "人员不存在" in detail.json()["message"]
@@ -155,27 +155,27 @@ class TestCrudFlow:
class TestRefRules:
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
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=normal["headers"],
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不能是自己" in resp.json()["message"]
def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None:
def test_直属经理不存在_400(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"],
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"]
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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
@@ -189,16 +189,16 @@ class TestRefRules:
assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"]
def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None:
def test_岗位不存在_400(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), business_role_id=999999999),
headers=normal["headers"],
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None:
def test_岗位已停用_400(self, client: TestClient, writer: dict) -> None:
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
code = f"t_br_{uuid.uuid4().hex[:8]}"
factory = get_session_factory()
@@ -220,7 +220,7 @@ class TestRefRules:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), business_role_id=role_id),
headers=normal["headers"],
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
@@ -229,8 +229,8 @@ class TestRefRules:
class TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
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)
@@ -244,9 +244,9 @@ class TestDeleteProtection:
assert freed.status_code == 200, freed.text
def test_被客户归属引用禁删_离职不放宽_解除后可删(
self, client: TestClient, normal: dict
self, client: TestClient, writer: dict
) -> None:
headers = normal["headers"]
headers = writer["headers"]
owner = _create(client, headers)
created = client.post(
_CUSTOMERS,
@@ -277,9 +277,9 @@ class TestDeleteProtection:
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert freed.status_code == 200, freed.text
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None:
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
headers = normal["headers"]
headers = writer["headers"]
owner = _create(client, headers)
assistant = _create(client, headers)
resp_customer = client.post(
@@ -328,9 +328,9 @@ class TestSubTableReplace:
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
def test_辐射区域设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict
self, client: TestClient, writer: dict
) -> None:
headers = normal["headers"]
headers = writer["headers"]
data = _create(client, headers)
resp = client.put(
f"{_PERSONNEL}/{data['id']}/regions",
@@ -357,8 +357,8 @@ class TestSubTableReplace:
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, normal: dict) -> None:
headers = normal["headers"]
def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
data = _create(client, headers)
client.put(
f"{_PERSONNEL}/{data['id']}/regions",
@@ -373,9 +373,9 @@ class TestSubTableReplace:
assert detail["regions"] == []
def test_拓展计划设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict
self, client: TestClient, writer: dict
) -> None:
headers = normal["headers"]
headers = writer["headers"]
data = _create(client, headers)
resp = client.put(
f"{_PERSONNEL}/{data['id']}/plans",
@@ -405,8 +405,8 @@ class TestSubTableReplace:
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, normal: dict) -> None:
headers = normal["headers"]
def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
data = _create(client, headers)
client.put(
f"{_PERSONNEL}/{data['id']}/plans",
@@ -424,11 +424,11 @@ class TestSubTableReplace:
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
logs = [
log
for log in _logs_of(normal["username"])
for log in _logs_of(writer["username"])
if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
]
assert logs, "新增人员未留操作日志"
@@ -436,16 +436,16 @@ class TestAudit:
assert latest.success == 1
assert data["personnel_name"] in latest.summary
def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None:
def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"],
headers=writer["headers"],
)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
for log in _logs_of(writer["username"])
if log.module == MODULE_PERSONNEL and log.success == 0
]
assert failed, "失败操作未留痕"