feat(T+): 完成数据贯通与自动接入闭环

打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
liuwenxin committed 2026-11-05 18:47:13 +08:00
1 parent 3efe72c344
commit 1e53792ad6
151 files changed
+8842 -401

No files matched your search

@@ -98,6 +98,21 @@ def test_角色配置后分配给用户并在原会话即时生效(client, admin
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
def test_来源销售关系核对需要业务更新和核算准备双权限(client, admin, normal):
business_only = role(client, admin, ["business:businesses:view", "business:businesses:update"])
payroll_only = role(client, admin, ["payroll:view", "payroll:prepare"])
payload = {"item_id": 1, "expected_version": 1, "evidence": "权限回归"}
url = "/api/v1/business/businesses/1/sales-relation/release"
for item in (business_only, payroll_only):
assert save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
assert client.put(url, headers=normal["headers"], json=payload).status_code == 403
assert save(client, admin, normal["id"], role_codes=["user", business_only["role_code"], payroll_only["role_code"]]).status_code == 200
response = client.put(url, headers=normal["headers"], json=payload)
assert response.status_code == 400
assert "来源业务" in response.json()["message"] or "核对结论" in response.json()["message"]
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
def test_旧个人覆盖写入拒绝且不影响既有角色(client, admin, normal):
response = save(
client,
@@ -163,6 +178,57 @@ def test_角色停用撤销旧会话授权且恢复后重新生效(client, admin
) == bool(status)
def test_编辑角色拒绝停用权限且保留原授权(client, admin):
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import Permission
item = role(client, admin, ["base:personnel:view"])
url = f"{BASE}/roles/{item['id']}/permissions"
before = client.get(url, headers=admin["headers"]).json()["data"]
with get_session_factory()() as session:
permission = session.scalar(select(Permission).where(
Permission.perm_code == "base:personnel:regions"
))
old_status = permission.status
permission.status = 0
session.commit()
try:
response = client.put(url, headers=admin["headers"], json={
"permission_codes": ["base:personnel:view", "base:personnel:regions"],
"expected_revision": before["revision"], "reason": "验证停用权限",
})
assert response.status_code == 400
after = client.get(url, headers=admin["headers"]).json()["data"]
assert after["permission_codes"] == before["permission_codes"]
assert after["revision"] == before["revision"]
finally:
with get_session_factory()() as session:
permission = session.scalar(select(Permission).where(
Permission.perm_code == "base:personnel:regions"
))
permission.status = old_status
session.commit()
def test_编辑角色拒绝缺失权限且保留原授权(client, admin, monkeypatch):
from app.services.permission_catalog import CATALOG
item = role(client, admin, ["base:personnel:view"])
url = f"{BASE}/roles/{item['id']}/permissions"
before = client.get(url, headers=admin["headers"]).json()["data"]
code = "test:missing:view"
monkeypatch.setitem(CATALOG, code, {"admin_only": False, "view_code": code})
response = client.put(url, headers=admin["headers"], json={
"permission_codes": [code], "expected_revision": before["revision"], "reason": "验证缺失权限",
})
assert response.status_code == 400
after = client.get(url, headers=admin["headers"]).json()["data"]
assert after["permission_codes"] == before["permission_codes"]
assert after["revision"] == before["revision"]
def test_角色权限编辑自动影响全部绑定用户(client, admin, normal, make_user):
other = make_user(["user"])
item = role(client, admin, ["base:personnel:view"])
@@ -0,0 +1,140 @@
"""隔离MySQL/API验证独立确认、默认门槛、凭证及内容变更,不写真实工资。"""
import copy
from decimal import Decimal
import pytest
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt
from tests.integration.test_formal_payroll import (
post,
unconfirmed_formal_chain as unconfirmed_formal_chain,
)
pytestmark = pytest.mark.integration
ROOT = "/api/v1/business/receipts"
def confirm(client, headers, receipt):
return client.post(f"{ROOT}/{receipt['id']}/confirm", headers=headers,
json={"expected_revision": receipt["confirmation_revision"]})
def calculate(client, headers, business):
return client.post(f"/api/v1/payroll/businesses/{business['id']}/calculate", headers=headers,
json={"month": "2026-03"})
def switch_off(client, headers):
post(client, "/api/v1/system/params/changes", headers, {
"change_type": 1, "remark": "t_独立确认关闭场景",
"items": [{"param_code": "receipt.confirmation.enabled", "dimension_value": "", "value": "0"}],
})
def test_默认开启_保存资料和凭证资格不能代替人工确认(client, admin, unconfirmed_formal_chain):
business, receipt, *_ = unconfirmed_formal_chain
headers = admin["headers"]
assert client.get(ROOT + "/confirmation-policy", headers=headers).json()["data"]["enabled"] is True
assert receipt["confirmation_status"] == 0
response = calculate(client, headers, business)
assert response.status_code == 400 and "人工确认" in response.text
with get_session_factory()() as s:
assert s.get(Receipt, receipt["id"]).confirmation_status == 0
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
def test_独立确认后核算_重复确认幂等及审计(client, admin, unconfirmed_formal_chain):
business, receipt, *_ = unconfirmed_formal_chain
headers = admin["headers"]
response = confirm(client, headers, receipt)
assert response.status_code == 200, response.text
confirmed = response.json()["data"]
assert confirmed["confirmation_status"] == 1 and confirmed["confirmed_by"]
again = confirm(client, headers, receipt).json()["data"]
assert again["confirmed_at"] == confirmed["confirmed_at"]
result = calculate(client, headers, business)
assert result.status_code == 200, result.text
assert result.json()["data"]["performance"] == "0.20"
with get_session_factory()() as s:
assert s.get(Receipt, receipt["id"]).settlement_status == 1
assert s.scalar(select(func.count()).select_from(OperationLog).where(OperationLog.action == "人工确认")) >= 1
def test_关闭仅跳过人工确认_不产生确认状态(client, admin, unconfirmed_formal_chain):
business, receipt, *_ = unconfirmed_formal_chain
switch_off(client, admin["headers"])
result = calculate(client, admin["headers"], business)
assert result.status_code == 200, result.text
with get_session_factory()() as s:
assert s.get(Receipt, receipt["id"]).confirmation_status == 0
@pytest.mark.parametrize("manually_confirmed", [False, True])
def test_关闭开关或人工确认都不能豁免无效凭证(client, admin, unconfirmed_formal_chain, manually_confirmed):
business, receipt, *_ = unconfirmed_formal_chain
if manually_confirmed:
assert confirm(client, admin["headers"], receipt).status_code == 200
else:
switch_off(client, admin["headers"])
with get_session_factory()() as s:
record = s.scalar(select(PayrollInput).where(PayrollInput.business_id == business["id"]))
data = copy.deepcopy(record.data_json)
data["receipts"][0]["voucher_confirmed"] = False
record.data_json = data
s.commit()
result = calculate(client, admin["headers"], business)
assert result.status_code == 400 and "VOUCHER_STATE_PENDING" in result.text
def test_修改关键内容重置确认_旧页面不能误确认(client, admin, unconfirmed_formal_chain):
_, receipt, *_ = unconfirmed_formal_chain
headers = admin["headers"]
assert confirm(client, headers, receipt).status_code == 200
changed = client.put(f"{ROOT}/{receipt['id']}", headers=headers,
json={"receipt_date": receipt["receipt_date"], "amount": "101"})
assert changed.status_code == 200, changed.text
data = changed.json()["data"]
assert data["confirmation_status"] == 0 and data["confirmed_at"] is None
assert confirm(client, headers, receipt).status_code == 409
assert confirm(client, headers, data).status_code == 200
def test_只读拒绝确认及切换开关(client, admin, normal, unconfirmed_formal_chain):
_, receipt, *_ = unconfirmed_formal_chain
assert confirm(client, normal["headers"], receipt).status_code == 403
response = client.post("/api/v1/system/params/changes", headers=normal["headers"], json={
"change_type": 1, "items": [{"param_code": "receipt.confirmation.enabled", "value": "0"}],
})
assert response.status_code == 403
@pytest.mark.parametrize("unconfirmed_formal_chain,expected", [
({"unit_cost": "50"}, "0.00"), ({"unit_cost": "45"}, "-20.00"),
], indirect=["unconfirmed_formal_chain"])
def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfirmed_formal_chain, expected):
business, receipt, *_ = unconfirmed_formal_chain
assert confirm(client, admin["headers"], receipt).status_code == 200
result = calculate(client, admin["headers"], business)
assert result.status_code == 200, result.text
with get_session_factory()() as s:
ledger = s.scalar(select(PerformanceLedger).where(PerformanceLedger.business_id == business["id"]))
assert ledger.converted_profit == Decimal(expected)
assert ledger.performance_amount == Decimal(expected) * Decimal("0.01")
@pytest.mark.parametrize("role,profit,converted,performance", [
("salesperson", "0", "0", "0"),
("salesperson", "10", "-20", "-0.20"),
("info_officer", "0", "0", "0.10"),
])
def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance):
data = post(client, "/api/v1/system/params/preview", admin["headers"], {
"business_role": role, "customer_type": "personal", "product_category": "municipal_water",
"ship_date": "2026-03-10", "receipt_date": "2026-03-10", "revenue": "100",
"profit": profit, "received_amount": "100",
})
assert Decimal(data["converted_profit"]) == Decimal(converted)
assert Decimal(data["performance_amount"]) == Decimal(performance)
@@ -115,6 +115,30 @@ class TestCrudFlow:
class TestInitialPermissions:
def test_历史目录外权限不计入角色权限数(self, client, admin, make_role):
from app.core.db import get_session_factory
from app.models import Permission, RolePermission
role = make_role()
with get_session_factory()() as session:
permission = Permission(perm_code=f"obsolete:{uuid.uuid4().hex}", perm_name="旧权限")
session.add(permission)
session.flush()
permission_id = permission.id
session.add(RolePermission(role_id=role["id"], permission_id=permission_id))
session.commit()
try:
listing = client.get(_ROLES, headers=admin["headers"], params={
"keyword": role["role_code"],
}).json()["data"]["list"][0]
detail = client.get(f"{_ROLES}/{role['id']}/permissions", headers=admin["headers"])
assert detail.status_code == 200
assert listing["permission_count"] == len(detail.json()["data"]["permission_codes"]) == 0
finally:
with get_session_factory()() as session:
session.delete(session.get(Permission, permission_id))
session.commit()
def test_角色和权限一起创建且绑定后原令牌即时继承(self, client, admin, normal):
import json
@@ -131,6 +155,11 @@ class TestInitialPermissions:
assert response.status_code == 200, response.text
role = response.json()["data"]
assert role["permission_count"] == 2
listed = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"]
assert listed["list"][0]["permission_count"] == 2
updated = client.put(f"{_ROLES}/{role['id']}", headers=admin["headers"],
json={"role_name": "区域维护", "sort_order": 0})
assert updated.status_code == 200 and updated.json()["data"]["permission_count"] == 2
with get_session_factory()() as session:
log = session.scalar(select(OperationLog).where(
OperationLog.user_id == admin["id"], OperationLog.summary.contains(code),
@@ -0,0 +1,23 @@
"""来源单价人工确认的双权限校验。"""
import pytest
from tests.integration.test_authorization import role, save
from tests.integration.test_tplus_workbench import workspace as workspace
pytestmark = pytest.mark.integration
def test_确认来源单价需要业务更新和核算准备双权限(client, admin, normal, workspace):
business_only = role(client, admin, ["business:businesses:view", "business:businesses:update"])
payroll_only = role(client, admin, ["payroll:view", "payroll:prepare"])
url = "/api/v1/business/businesses/1/source-documents/1/missing-price"
payload = {"revision": "0" * 64, "evidence": "权限回归"}
for item in (business_only, payroll_only):
assert save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
assert client.put(url, headers=normal["headers"], json=payload).status_code == 403
assert save(client, admin, normal["id"], role_codes=[
"user", business_only["role_code"], payroll_only["role_code"]]).status_code == 200
response = client.put(url, headers=normal["headers"], json=payload)
assert response.status_code == 400
assert "来源业务" in response.json()["message"]
@@ -0,0 +1,104 @@
"""在隔离 MySQL 中验证托管状态及命名锁,不连接 T+ 源库。"""
import threading
import time
from uuid import uuid4
from app.core.config import get_settings
from app.core.db import get_session_factory
from app.models import TPlusRuntimeControl
from app.services.tplus import supervisor
def test_托管服务在真实命名锁下启动并安全停止(monkeypatch):
factory = get_session_factory()
monkeypatch.setattr(supervisor, "LOCK_NAME", f"ys:test:supervisor:{uuid4().hex}")
monkeypatch.setattr(supervisor, "health_error", lambda _settings: None)
monkeypatch.setattr(supervisor, "tick", lambda *_args, **_kwargs: None)
entered, release, stop = threading.Event(), threading.Event(), threading.Event()
calls = []
def worker(_session, **_kwargs):
calls.append(1)
entered.set()
assert release.wait(15)
monkeypatch.setattr(supervisor, "run_once", worker)
with factory() as session:
row = session.get(TPlusRuntimeControl, 1)
row.desired_state = "RUNNING"
session.commit()
result = []
thread = threading.Thread(target=lambda: result.append(
supervisor.run(factory, get_settings(), interval=0.05, stop_event=stop)
))
thread.start()
try:
assert entered.wait(10), "托管服务未启动工作线程"
assert supervisor.run(factory, get_settings(), stop_event=threading.Event()) == 1
with factory() as session:
row = session.get(TPlusRuntimeControl, 1)
row.desired_state = "STOPPED"
session.commit()
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
with factory() as session:
state = session.get(TPlusRuntimeControl, 1).actual_state
if state == "STOPPING":
break
time.sleep(0.05)
assert state == "STOPPING"
release.set()
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
with factory() as session:
state = session.get(TPlusRuntimeControl, 1).actual_state
if state == "STOPPED":
break
time.sleep(0.05)
assert state == "STOPPED"
assert len(calls) == 1
finally:
release.set()
stop.set()
thread.join(15)
assert not thread.is_alive()
assert result == [0]
def test_来源异常时真实数据库记录降级状态且不领取任务(monkeypatch):
factory = get_session_factory()
monkeypatch.setattr(supervisor, "LOCK_NAME", f"ys:test:supervisor:{uuid4().hex}")
monkeypatch.setattr(supervisor, "health_error", lambda _settings: "T+ 数据库连接不可用")
claimed = threading.Event()
monkeypatch.setattr(supervisor, "run_once", lambda *_args, **_kwargs: claimed.set())
with factory() as session:
session.get(TPlusRuntimeControl, 1).desired_state = "RUNNING"
session.commit()
stop = threading.Event()
result = []
thread = threading.Thread(target=lambda: result.append(
supervisor.run(factory, get_settings(), interval=0.05, stop_event=stop)
))
thread.start()
try:
deadline = time.monotonic() + 10
state = None
while time.monotonic() < deadline:
with factory() as session:
row = session.get(TPlusRuntimeControl, 1)
state = row.actual_state
error = row.last_error
if state == "DEGRADED":
break
time.sleep(0.05)
assert state == "DEGRADED"
assert error == "T+ 数据库连接不可用"
assert not claimed.is_set()
finally:
stop.set()
thread.join(15)
assert not thread.is_alive()
assert result == [0]