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]
@@ -0,0 +1,31 @@
"""业务列表按照核算准备结果筛选时保持准确分页。"""
from types import SimpleNamespace
from app.schemas.business import BusinessOut, BusinessQuery
from app.services import business_service, business_source
def test_准备原因筛选跨批次分页并按实际状态计数(monkeypatch):
rows = [(SimpleNamespace(id=i, integration_json={"source_database": "test"}),)
for i in range(105, 0, -1)]
def scan(_session, _query, before_id, limit=100):
return [row for row in rows if before_id is None or row[0].id < before_id][:limit]
def summaries(_session, businesses, *, snapshot_cache=None):
return {row.id: {"status": "PENDING" if row.id % 2 else "READY",
"reasons": ([{"code": "GUIDANCE_PENDING", "message": "指导成本待补"}]
if row.id % 2 else [])} for row in businesses}
monkeypatch.setattr(business_service.business_repository, "scan_businesses", scan)
monkeypatch.setattr(business_source, "readiness_summaries", summaries)
monkeypatch.setattr(business_service, "_to_out", lambda row: BusinessOut(
id=row[0].id, business_no=str(row[0].id), customer_id=1))
result = business_service.list_businesses(None, BusinessQuery(
pageNum=3, pageSize=10, readiness_reason="GUIDANCE_PENDING", pending_only=True))
assert result.total == 53
assert [row.id for row in result.records] == list(range(65, 45, -2))
assert all(row.readiness_status == "PENDING" for row in result.records)
+24 -2
View File
@@ -141,16 +141,37 @@ def business_work(s, user, kind, previous=None):
return run
def imported(case):
def confirm_test_receipts(s, user):
"""金额回归的明确前置:经独立确认服务确认测试回款,不关闭默认门槛。"""
from app.schemas.receipt import ReceiptConfirmIn
from app.services import receipt_confirmation, receipt_service
for row in list(s.scalars(select(Receipt))):
receipt_service.confirm_receipt(s, row.id, ReceiptConfirmIn(
expected_revision=receipt_confirmation.revision(row),
), operator=user)
def imported(case, *, confirmed=True):
s, user, _manager = case
pre = business_work(s, user, "BUSINESS_PREVIEW")
assert pre.status == "SUCCEEDED", pre.summary_json
assert pre.status == "SUCCEEDED", pre.snapshot_json["plan"]
assert pre.summary_json["counts"] == {"CREATE": 1}
run = business_work(s, user, "BUSINESS_APPLY", pre)
assert run.status == "SUCCEEDED", run.summary_json
if confirmed:
confirm_test_receipts(s, user)
return s.scalar(select(Business))
def test_正式导入不自动确认_保存资料也不能代替确认(payroll_case):
s, user, _ = payroll_case
business = imported(payroll_case, confirmed=False)
assert s.scalar(select(Receipt)).confirmation_status == 0
with pytest.raises(BadRequestError, match="人工确认"):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
def test_工资角色可写且未授权对象不能冒用身份(payroll_case):
_, user, _ = payroll_case
assert can_write_payroll(user)
@@ -248,6 +269,7 @@ def replace_input(s, record, values):
)
s.add(newer)
s.commit()
confirm_test_receipts(s, s.get(User, newer.requested_by))
return newer
+137
View File
@@ -0,0 +1,137 @@
"""历史任职不能被当前岗位覆盖,预演、台账与工资分项必须使用同一依据。"""
from datetime import date
from decimal import Decimal
import pytest
from pydantic import ValidationError
from sqlalchemy import select
from app.models import Customer, PerformanceLedger, Product, TPlusFact
from app.schemas.tplus_workbench import PersonFact
from app.services import payroll_calculation, settlement_service
from tests.unit.test_formal_payroll import imported
from tests.unit.test_formal_payroll import payroll_case as payroll_case
from tests.unit.test_tplus_fifo import inputs, run
from tests.unit.test_tplus_workbench import apply, fact, preview, source
from tests.unit.test_tplus_workbench import work_session as work_session
def periods():
return [{"start_month": "2026-03", "end_month": "2026-07", "role_code": "sales_manager"}]
def test_按月份保存已确认岗位而不补造转岗日():
person = PersonFact(
code="010110",
name="历史岗位样本",
role_code="salesperson",
status=1,
role_history=periods(),
)
assert person.model_dump()["role_history"] == periods()
@pytest.mark.parametrize(
"history",
[
[{"start_month": "2026-13", "end_month": "2026-07", "role_code": "sales_manager"}],
[{"start_month": "2026-08", "end_month": "2026-07", "role_code": "sales_manager"}],
[
*periods(),
{"start_month": "2026-07", "end_month": "2026-09", "role_code": "salesperson"},
],
],
)
def test_无效或重叠历史岗位拒绝入库(history):
with pytest.raises(ValidationError):
PersonFact(
code="010110",
name="历史岗位样本",
role_code="salesperson",
status=1,
role_history=history,
)
def test_七月经理本人业务在八月转岗后仍排除():
group, ship, cash, facts, params = inputs()
ship["date"], cash["date"] = "2026-07-16", "2026-08-16"
facts["personnel"]["001"].update(role_history=periods())
params["2026-08-16"] = params.pop("2026-03-10")
params["august_confirmed"] = True
line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0]
assert line["performance"] == "0.00"
assert line["excluded_manager_own"]
assert line["original_role_code"] == "sales_manager"
assert line["role_code"] == "salesperson"
def test_八月新业务按业务员且不沿用七月经理岗位():
group, ship, cash, facts, params = inputs()
ship["date"] = cash["date"] = "2026-08-16"
facts["personnel"]["001"].update(role_history=periods())
params["2026-08-16"] = params.pop("2026-03-10")
params["august_confirmed"] = True
line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0]
assert line["performance"] == "0.20" and not line["excluded_manager_own"]
def test_历史经理贯通正式导入台账及月结岗位(payroll_case):
session, operator, _ = payroll_case
fact = session.scalar(
select(TPlusFact).where(TPlusFact.kind == "PERSONNEL", TPlusFact.fact_key == "00043")
)
fact.data_json = {**fact.data_json, "role_history": periods()}
session.commit()
business = imported(payroll_case)
fact.data_json = {**fact.data_json, "role_history": []}
session.commit()
result = payroll_calculation.calculate(session, business.id, "2026-03", operator=operator)
assert result["performance"] == result["payable"] == "0.00"
row = session.scalar(select(PerformanceLedger).where(PerformanceLedger.calc_type == 1))
assert row.role_code == "sales_manager"
assert row.snapshot_json["manager_own_excluded"]
settlement = settlement_service.generate(session, "2026-03", operator=operator)
item = next(item for item in settlement.items if item.personnel_id == row.personnel_id)
assert item.role_code == "sales_manager"
def test_源客户允许负责人未知且不冒用营销人员姓名(work_session):
session = work_session
source(session, "customers", [{"id": 9647, "name": "真实样本客户", "partnerType": 211}])
fact(session, "CUSTOMER", "9647", {"customer_type": "enterprise"})
imported_run = apply(session, preview(session, ["customers"]))
assert imported_run.summary_json["counts"] == {"CREATE": 1}
customer = session.scalar(select(Customer))
assert customer.owner_name == "" and customer.customer_type == "enterprise"
def test_带真实发货参照价格的产品可预览并正式导入(work_session):
session = work_session
source(session, "products", [{"id": 9, "name": "样本产品", "unit_name": "根"}])
source(
session,
"shipments",
[{"id": 20, "code": "SA-SAMPLE", "voucherdate": "2026-07-16", "voucherState": 189}],
)
source(
session,
"shipment_items",
[
{
"id": 21,
"source_parent_id": 20,
"idinventory": 9,
"taxAmount": "3850.00000000000000",
"quantity": "28.00000000000000",
"taxPrice": "137.50000000000000",
}
],
)
fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "根"})
imported_run = apply(session, preview(session, ["products"]))
assert imported_run.summary_json["counts"] == {"CREATE": 1}
product = session.scalar(select(Product))
assert product.stock_quantity == Decimal("28")
assert product.source_trade_json["price"] == "137.5000"
@@ -0,0 +1,71 @@
"""手工业务再次准备时保留交接及已封存成本。"""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
PayrollInput,
Personnel,
Shipment,
ShipmentItem,
User,
)
from app.schemas.payroll import PayrollSetupIn
from app.services import payroll_inputs, payroll_months
from tests.unit.test_tplus_workbench import work_session as work_session
def test_手工业务再次保存保留交接并拒绝指导单价微小变更(work_session, monkeypatch):
session = work_session
sales = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
manager_role = BusinessRole(role_name="经理", role_code="sales_manager", calc_template="SALES_MANAGER", sort_order=2)
customer = Customer(company_name="手工客户", owner_name="负责人", customer_type="personal")
session.add_all([manager_role, customer])
session.flush()
owner = Personnel(personnel_name="业务员", business_role_id=sales.id)
receiver = Personnel(personnel_name="接手人员", business_role_id=sales.id)
manager = Personnel(personnel_name="经理", business_role_id=manager_role.id)
session.add_all([owner, receiver, manager])
session.flush()
business = Business(business_no="MANUAL-01", customer_id=customer.id)
session.add(business)
session.flush()
session.add(BusinessItem(business_id=business.id, product_name="管材", spec_model="",
product_category="municipal_water", quantity=Decimal("5"), sale_amount=Decimal("500")))
shipment = Shipment(business_id=business.id, shipment_no="SH-MANUAL", ship_date=date(2026, 3, 10))
session.add(shipment)
session.flush()
item = ShipmentItem(shipment_id=shipment.id, product_name="管材", spec_model="",
quantity=Decimal("5"), amount=Decimal("500"))
session.add(item)
session.commit()
monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None)
payload = {"month": "2026-03", "evidence": "已核实", "opening_balance": "0", "history_confirmed": True,
"guidance_finalized": True, "shipment_ownerships": [{"shipment_item_id": item.id,
"personnel_id": owner.id, "manager_id": manager.id, "unit_cost": "1"}],
"handovers": [{"business_key": business.business_no, "mode": "RESIGNATION",
"from_personnel_id": str(owner.id), "to_personnel_id": str(receiver.id), "effective_date": "2026-03-20"}]}
payroll_inputs.setup(session, business.id, PayrollSetupIn(**payload), session.get(User, 1))
retry = {**payload, "expected_version": 1}
retry.pop("handovers")
payroll_inputs.setup(session, business.id, PayrollSetupIn(**retry), session.get(User, 1))
latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc()))
assert latest.data_json["facts"]["handovers"][0]["to_personnel_id"] == str(receiver.id)
changed = {**retry, "expected_version": 2,
"shipment_ownerships": [{**retry["shipment_ownerships"][0], "unit_cost": "1.0001"}]}
with pytest.raises(BadRequestError, match="指导成本已封存"):
payroll_inputs.setup(session, business.id, PayrollSetupIn(**changed), session.get(User, 1))
equivalent = {**changed, "shipment_ownerships": [{**changed["shipment_ownerships"][0], "unit_cost": "1.0000"}]}
payroll_inputs.setup(session, business.id, PayrollSetupIn(**equivalent), session.get(User, 1))
payroll_inputs.setup(session, business.id, PayrollSetupIn(**{**equivalent, "expected_version": 3, "handovers": []}),
session.get(User, 1))
latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc()))
assert latest.data_json["facts"]["handovers"] == []
+2
View File
@@ -51,6 +51,8 @@ EXPECTED_TABLES = {
"monthly_settlements",
"settlement_items",
"adjustment_orders",
"tplus_business_documents",
"tplus_business_sync_configs",
"tplus_sync_batches",
"tplus_sync_errors",
"tplus_sync_records",
@@ -0,0 +1,62 @@
"""在显式指定的临时 MySQL 库中验证工资月份行锁。"""
import os
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from datetime import datetime
from threading import Event
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models import PayrollMonth
from app.services.payroll_months import lock_month, lock_month_row
@pytest.mark.skipif(not os.environ.get("PAYROLL_LOCK_TEST_URL"), reason="需要独立的临时 MySQL 测试库")
def test_同步等待工资事务提交并读取最新生成状态(monkeypatch):
from app.services import payroll_months
engine = create_engine(os.environ["PAYROLL_LOCK_TEST_URL"])
PayrollMonth.__table__.create(engine)
monkeypatch.setattr(payroll_months, "ensure_payroll_writer", lambda _operator: None)
actor = SimpleNamespace(id=1, username="test")
started = Event()
def check_sync():
with Session(engine) as session:
started.set()
control = lock_month_row(session, "2026-03", actor)
return control.generated_at
try:
with Session(engine) as generation, ThreadPoolExecutor(max_workers=1) as pool:
control = lock_month(generation, "2026-03", actor)
result = pool.submit(check_sync)
assert started.wait(5)
with pytest.raises(TimeoutError):
result.result(timeout=0.25)
control.generated_at = datetime(2026, 3, 31)
generation.commit()
assert result.result(timeout=10) == datetime(2026, 3, 31)
started.clear()
def generate():
with Session(engine) as session:
started.set()
control = lock_month(session, "2026-04", actor)
control.generated_at = datetime(2026, 4, 30)
session.commit()
with Session(engine) as sync, ThreadPoolExecutor(max_workers=1) as pool:
lock_month_row(sync, "2026-04", actor)
result = pool.submit(generate)
assert started.wait(5)
with pytest.raises(TimeoutError):
result.result(timeout=0.25)
sync.commit()
result.result(timeout=10)
finally:
engine.dispose()
+24 -19
View File
@@ -23,30 +23,35 @@ from app.services.param_registry import (
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = {
"coefficient.personal.first", "coefficient.personal.start", "coefficient.personal.slope",
"coefficient.personal.floor", "coefficient.personal.boundary",
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"manager.share.team",
"manager.share.company",
"approval.adjustment.enabled",
"approval.monthly_settlement.enabled",
"approval.secondary_allocation.enabled",
"barter.discount",
"coefficient.personal.boundary",
"coefficient.personal.first",
"coefficient.personal.floor",
"coefficient.personal.slope",
"coefficient.personal.start",
"enterprise.rate",
"info.rate",
"prepay.rate",
"barter.discount",
"manager.personal.included",
"manager.share.company",
"manager.share.team",
"payment.start.ratio",
"payment.weight.mode",
"product.threshold",
"salary.basic.mode",
"prepay.rate",
"probation.annual.threshold",
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
"approval.adjustment.enabled",
"manager.personal.included",
"warning.enabled",
"warning.receipt.overdue.days",
"product.threshold",
"receipt.confirmation.enabled",
"salary.basic.mode",
"salesperson.days.boundary",
"salesperson.rate.floor",
"salesperson.rate.slope",
"salesperson.rate.start",
"warning.approval.timeout.days",
"warning.enabled",
"warning.receipt.overdue.days"
}
@@ -87,7 +92,7 @@ class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共27项(self) -> None:
assert len(PARAM_REGISTRY) == 27
assert len(PARAM_REGISTRY) == 28
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
@@ -0,0 +1,106 @@
"""核算资料参照必须能完整遍历大于单页的发货和人员。"""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.models import (
Business,
BusinessRole,
Personnel,
Shipment,
ShipmentItem,
TPlusFact,
TPlusSourceLink,
User,
)
from app.services import authorization_metadata, authorization_service
from tests.unit.test_tplus_business_sync import dataset, sync
from tests.unit.test_tplus_workbench import work_session as work_session
def test_核算参照按页返回超过二百条的发货与人员(work_session, monkeypatch):
session = work_session
dataset(session, shipping=True)
sync(session)
business = session.scalar(select(Business))
role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
for number in range(205):
shipment = Shipment(business_id=business.id, shipment_no=f"P{number:04}",
ship_date=date(2026, 3, 1), total_amount=Decimal("1"))
session.add(shipment)
session.flush()
session.add(ShipmentItem(shipment_id=shipment.id, product_name="测试产品", spec_model="",
quantity=Decimal("1"), amount=Decimal("1")))
session.add(Personnel(personnel_name=f"测试人员{number:04}", business_role_id=role.id))
session.flush()
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
first = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=1, page_size=200)
second = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=2, page_size=200)
assert first["business_key"] == second["business_key"] == "customer:8"
assert first["shipments_total"] == second["shipments_total"] == 206
assert first["personnel_total"] == second["personnel_total"] == 205
assert (len(first["shipments"]), len(second["shipments"])) == (200, 6)
assert (len(first["personnel"]), len(second["personnel"])) == (200, 5)
assert len({row["id"] for row in first["shipments"] + second["shipments"]}) == 206
def test_来源人员只按已确认身份关联预填且未匹配保持空值(work_session, monkeypatch):
session = work_session
dataset(session, shipping=True)
sync(session)
business = session.scalar(select(Business))
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
before = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
assert before["shipments"][0]["source_personnel_name"] == "来源员工"
assert before["shipments"][0]["matched_personnel_id"] is None
role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
person = Personnel(personnel_name="来源员工", business_role_id=role.id)
session.add(person)
session.flush()
# 相同姓名本身不是身份关联。
unmatched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
assert unmatched["shipments"][0]["matched_personnel_id"] is None
other_source = TPlusSourceLink(source_database="other_source", entity_type="personnel", source_id="1", target_id=person.id)
session.add(other_source)
session.flush()
assert authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]["matched_personnel_id"] is None
session.delete(other_source)
session.flush()
session.add(TPlusSourceLink(source_database="test_source", entity_type="personnel", source_id="1", target_id=person.id))
session.flush()
matched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
assert matched["shipments"][0]["matched_personnel_id"] == person.id
def test_核算参照复用同来源已封存的单位指导成本(work_session, monkeypatch):
session = work_session
dataset(session, shipping=True)
sync(session)
business = session.scalar(select(Business))
item = session.scalar(select(ShipmentItem))
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
initial = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
assert initial["guidance_status"] == "MISSING" and initial["guidance_unit_cost"] is None
data = {"product_id": "9", "month": "2026-03", "basis": "UNIT",
"amount": "2.1234", "unit": item.unit, "is_finalized": True}
session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="price9-03",
version=1, data_json=data, evidence="历史记录", requested_by=1))
session.flush()
ready = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
assert (ready["guidance_status"], ready["guidance_unit_cost"]) == ("FINALIZED_UNIT", "2.1234")
session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="duplicate9-03",
version=1, data_json=data, evidence="历史重复记录", requested_by=1))
session.flush()
conflict = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
assert conflict["guidance_status"] == "AMBIGUOUS" and conflict["guidance_unit_cost"] is None
@pytest.mark.parametrize("page_num,page_size", [(0, 200), (1, 0), (1, 201)])
def test_核算参照拒绝无效页码与页大小(work_session, monkeypatch, page_num, page_size):
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
with pytest.raises(ValueError):
authorization_metadata.payroll_options(work_session, work_session.get(User, 1), 1,
page_num=page_num, page_size=page_size)
@@ -0,0 +1,38 @@
"""人工确认绑定被核对内容,不能由核算状态、凭证或同步状态推导。"""
from datetime import date, datetime
from decimal import Decimal
from app.models import Receipt
from app.services import receipt_confirmation
from app.services.param_service import ParamSnapshot
def receipt():
return Receipt(id=1, business_id=2, receipt_date=date(2026, 3, 10), amount=Decimal("100"), is_barter=0)
def test_人工确认默认开启且与工资审批独立(monkeypatch):
monkeypatch.setattr(receipt_confirmation, "resolve_snapshot", lambda *args: ParamSnapshot.from_rows(date.today(), []))
assert receipt_confirmation.enabled(None)
def test_已核算及同步来源不能替代确认():
row = receipt()
row.settlement_status = 1
row.t_plus_id = "source1"
row.sync_batch_no = "SYNC-SUCCEEDED"
assert not receipt_confirmation.is_confirmed(row)
def test_关键内容变化使确认失效而备注不影响():
row = receipt()
row.confirmation_status = 1
row.confirmed_by = "test_operator"
row.confirmed_at = datetime.now()
row.confirmed_revision = receipt_confirmation.revision(row)
assert receipt_confirmation.is_confirmed(row)
row.remark = "只改备注"
assert receipt_confirmation.is_confirmed(row)
row.amount = Decimal("101")
assert not receipt_confirmation.is_confirmed(row)
@@ -0,0 +1,44 @@
"""V29 能识别先于迁移版本登记的兼容字段。"""
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy.dialects.mysql import DATETIME, TINYINT, VARCHAR
def migration(monkeypatch, columns):
path = Path(__file__).resolve().parents[2] / "alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py"
spec = importlib.util.spec_from_file_location("receipt_confirmation_migration", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
statements = []
bind = SimpleNamespace(execute=lambda sql: statements.append(str(sql)))
monkeypatch.setattr(module, "op", SimpleNamespace(get_bind=lambda: bind))
monkeypatch.setattr(module, "inspect", lambda _: SimpleNamespace(get_columns=lambda table: columns))
return module, statements
def existing_columns():
return [
{"name": "confirmation_status", "type": TINYINT(), "nullable": False, "default": "'0'"},
{"name": "confirmed_by", "type": VARCHAR(64, collation="utf8mb4_general_ci"), "nullable": True},
{"name": "confirmed_at", "type": DATETIME(), "nullable": True},
{"name": "confirmed_revision", "type": VARCHAR(64), "nullable": True},
]
def test_兼容已有字段且仍执行权限和参数初始化(monkeypatch):
module, statements = migration(monkeypatch, existing_columns())
module.upgrade()
assert len(statements) == 4
assert all(not statement.startswith("ALTER TABLE") for statement in statements)
assert any("receipt.confirmation.enabled" in statement for statement in statements)
def test_已有字段不完整时拒绝迁移(monkeypatch):
module, statements = migration(monkeypatch, existing_columns()[:1])
with pytest.raises(RuntimeError, match="字段不完整"):
module.upgrade()
assert not statements
@@ -0,0 +1,31 @@
"""来源单价确认接口的权限边界。"""
from unittest.mock import Mock
import pytest
from app.api.v1.business.businesses import confirm_source_price
from app.core.exceptions import ForbiddenError
from app.schemas.business_source import SourcePriceConfirmIn
from app.services import authorization_service, business_source
from app.services.authorization_policy import permission_for
def test_来源单价确认路由要求业务更新权限():
assert permission_for("business.businesses", "confirm_source_price", "PUT") == "business:businesses:update"
def test_无核算准备权限不能确认来源单价(monkeypatch):
session, actor, meta = Mock(), Mock(), Mock()
confirm = Mock()
def deny(current_session, current_actor, permission):
assert (current_session, current_actor, permission) == (session, actor, "payroll:prepare")
raise ForbiddenError("无权核算准备")
monkeypatch.setattr(authorization_service, "require_permission", deny)
monkeypatch.setattr(business_source, "confirm_missing_source_price", confirm)
payload = SourcePriceConfirmIn(revision="0" * 64, evidence="核对原单")
with pytest.raises(ForbiddenError):
confirm_source_price(session, actor, 1, 1, payload, meta)
confirm.assert_not_called()
@@ -0,0 +1,573 @@
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
from contextlib import nullcontext
from datetime import date
from decimal import Decimal
from fractions import Fraction
from types import SimpleNamespace
from uuid import NAMESPACE_URL, uuid4, uuid5
import pytest
from sqlalchemy import func, select
from app.core.exceptions import BadRequestError, ForbiddenError
from app.core.response import PageQuery
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
OwnershipChangeLog,
PayrollInput,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusBusinessSyncConfig,
TPlusSourceHead,
TPlusSyncBatch,
TPlusWorkRun,
User,
)
from app.repositories import customer_repository, product_repository
from app.schemas.business_source import (
SalesRelationConfirmIn,
SalesRelationReleaseIn,
SourceMaterialsIn,
SourcePriceConfirmIn,
)
from app.schemas.category_batch import (
CustomerCategoryBatch,
CustomerOwnershipBatch,
ProductCategoryBatch,
)
from app.schemas.customer import CustomerQuery
from app.schemas.payroll import PayrollSetupIn
from app.schemas.product import ProductQuery
from app.schemas.report import BusinessStatsQuery
from app.services import (
business_service,
business_source,
category_batch,
customer_service,
payroll_months,
receipt_confirmation,
report_service,
)
from app.services.business_source import ensure_source_editable
from app.services.tplus import business_sync, business_sync_control, sales_relations
from app.services.tplus.snapshots import source_manifest
from app.services.tplus.workbench import WorkbenchError, digest, execute_next
from tests.unit.test_tplus_workbench import fact, source
from tests.unit.test_tplus_workbench import work_session as work_session
def dataset(s, *, shipping=False, cash=False):
entities = {
"customers": [{"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228}],
"products": [{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"}],
"personnel": [{"id": 1, "code": "00043", "name": "来源员工", "isSalesman": 1}],
"businesses": [{"id": 11, "code": "SO-11", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-12", "voucherState": 189}],
"business_items": [{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxPrice": "100", "taxAmount": "1000"}],
"shipments": [{"id": 21, "code": "SH-21", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-13", "voucherState": 189}] if shipping else [],
"shipment_items": [{"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderId": 11, "saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500"}] if shipping else [],
"receipts": [{"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200", "voucherDate": "2026-03-14", "voucherstate": 189, "accounting_voucher_count": 1, "valid_accounting_voucher_count": 1, "accounting_voucher_id": 501}] if cash else [],
"receipt_items": [],
}
for entity, rows in entities.items():
source(s, entity, rows)
def sync(s):
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", requested_by=1,
kind="BUSINESS_SYNC", status="RUNNING", input_json={"start_date": "2026-03-01"})
s.add(run)
business_sync.execute_sync(s, run)
s.commit()
return run
def test_自动接入按实际发布快照去重(work_session, monkeypatch):
session = work_session
dataset(session)
monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source")
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
start_date=date(2026, 3, 1), version=1, updated_by=1)
session.add(config)
session.commit()
manifest = list(source_manifest(session, "test_source").items())
expected_key = str(uuid5(NAMESPACE_URL, digest(["test_source", 1, manifest, "business-sync-v1"])))
first = business_sync_control.schedule(session)
assert session.scalar(select(TPlusWorkRun.request_key)) == expected_key
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
session.commit()
# 历史成功批次可能声明实体,但没有发布该实体;它不能改变实际来源版本。
session.add(TPlusSyncBatch(batch_no=uuid4().hex[:24], source_database="test_source",
sync_mode="FULL", status="SUCCEEDED", entity_types_json=["customers"],
audit_result_json={"observed_entities": []}))
session.commit()
second = business_sync_control.schedule(session)
session.expire_all()
assert config.last_error is None
assert second["run_no"] == first["run_no"]
assert session.scalar(select(func.count()).select_from(TPlusWorkRun)) == 1
run = session.scalar(select(TPlusWorkRun))
run.status, config.last_error = "FAILED", "TASK_AUTHORIZATION_REVOKED"
session.commit()
assert business_sync_control.schedule(session)["status"] == "FAILED"
session.expire_all()
assert config.last_error == "TASK_AUTHORIZATION_REVOKED"
@pytest.mark.parametrize("config_owner", [1, 2])
def test_排队后撤权仅提示对应授权人的自动接入配置(work_session, monkeypatch, config_owner):
from app.services import authorization_service
session = work_session
if config_owner == 2:
session.add(User(id=2, username="other_operator", password="test_hash_only", status=1))
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
start_date=date(2026, 3, 1), version=1, updated_by=config_owner)
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={})
session.add_all([config, run])
session.commit()
def denied(*_args):
raise ForbiddenError()
monkeypatch.setattr(authorization_service, "require_permission", denied)
assert execute_next(session) == run.run_no
session.refresh(run)
session.refresh(config)
assert (run.status, run.error_code) == ("FAILED", "TASK_AUTHORIZATION_REVOKED")
assert config.last_error == ("TASK_AUTHORIZATION_REVOKED" if config_owner == 1 else None)
@pytest.mark.parametrize("config_owner", [1, 2])
def test_重试成功仅清除对应授权人的接入错误(work_session, monkeypatch, config_owner):
session = work_session
if config_owner == 2:
session.add(User(id=2, username="other_operator", password="test_hash_only", status=1))
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
start_date=date(2026, 3, 1), version=1, updated_by=config_owner,
last_error="TASK_AUTHORIZATION_REVOKED")
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={})
session.add_all([config, run])
session.commit()
def success(_session, task):
task.status = "SUCCEEDED"
monkeypatch.setattr(business_sync, "execute_sync", success)
assert execute_next(session) == run.run_no
session.refresh(config)
assert config.last_error == (None if config_owner == 1 else "TASK_AUTHORIZATION_REVOKED")
@pytest.mark.parametrize(("value", "scale"), [("999999999999.99", 2), ("9999999999.9999", 4)])
def test_目标精度范围内的来源数值可接入(value, scale):
assert business_sync.decimal_value(value, scale) == Decimal(value)
@pytest.mark.parametrize(("value", "scale"), [("1000000000000", 2), ("10000000000", 4), ("1.001", 2)])
def test_来源数值超出目标范围或精度时拒绝(value, scale):
with pytest.raises(WorkbenchError, match="SOURCE_VALUE_INVALID"):
business_sync.decimal_value(value, scale)
def test_订单明细合计溢出时标记待核对(work_session):
s = work_session
dataset(s)
source(s, "business_items", [
{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"},
{"id": 13, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"},
])
run = sync(s)
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "businesses"))
assert doc.status == "PENDING"
assert doc.reason == "SOURCE_VALUE_INVALID"
assert run.summary_json["counts"]["PENDING"] == 1
def test_只有订单且分类指导价均缺失仍自动建业务(work_session):
s = work_session
dataset(s)
run = sync(s)
business = s.scalar(select(Business))
assert business is not None and business.business_date == date(2026, 3, 12)
assert s.scalar(select(Customer)).customer_type is None
assert s.scalar(select(Product)).product_category is None
assert s.scalar(select(BusinessItem)).product_category is None
assert business.total_sale_amount == 1000
assert business.total_freight_amount is None
assert business.owner_personnel_id is None
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
assert run.status == "SUCCEEDED"
sync(s)
assert business.total_freight_amount is None
business_service._recalc_totals(s, business)
assert business.total_cost_amount is None
assert business.total_freight_amount is None
assert business.total_sale_amount == 1000
def test_已生成工资的月份不接入新来源单据(work_session):
from datetime import datetime
from app.models import PayrollMonth
session = work_session
dataset(session, cash=True)
session.add(PayrollMonth(month="2026-03", maker_id=1, maker_name="test_operator",
generated_at=datetime(2026, 3, 31)))
session.commit()
run = sync(session)
assert session.scalar(select(func.count()).select_from(Receipt)) == 0
documents = list(session.scalars(select(TPlusBusinessDocument)))
assert any(doc.reason == "PAYROLL_MONTH_LOCKED" for doc in documents)
assert run.status == "PARTIAL"
def test_运费仅在所有发货均确认后汇总(work_session):
s = work_session
dataset(s, shipping=True)
sync(s)
business = s.scalar(select(Business))
shipment = s.scalar(select(Shipment))
assert business.total_freight_amount is None
shipment.freight_amount = Decimal("0.00")
s.flush()
business_sync._refresh_business_totals(s, business)
assert business.total_freight_amount == Decimal("0.00")
def test_来源订单与发货数量按产品单位分别汇总(work_session):
s = work_session
dataset(s, shipping=True, cash=True)
sync(s)
business = s.scalar(select(Business))
rows = business_source.source_quantities(s, business.id, PageQuery()).records
assert len(rows) == 1
assert rows[0]["product_name"] == "管材" and rows[0]["unit"] == "根"
assert Decimal(rows[0]["order_quantity"]) == Decimal("10")
assert Decimal(rows[0]["shipment_quantity"]) == Decimal("5")
assert business.total_sale_amount == Decimal("1000")
assert business.total_received_amount == Decimal("200")
sync(s)
assert business_source.source_quantities(s, business.id, PageQuery()).records == rows
def test_来源数量按不同产品单位分组并分页(work_session):
s = work_session
dataset(s, shipping=True)
source(s, "products", [
{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"},
{"id": 10, "code": "P10", "name": "钢板", "specification": "Q235", "unit_name": "吨"},
])
source(s, "business_items", [
{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxAmount": "1000"},
{"id": 13, "source_parent_id": 11, "idinventory": 10, "quantity": "3", "taxAmount": "300"},
])
source(s, "shipment_items", [
{"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderDetailId": 12,
"quantity": "5", "taxAmount": "500"},
{"id": 23, "source_parent_id": 21, "idinventory": 10, "saleOrderDetailId": 13,
"quantity": "2", "taxAmount": "200"},
])
sync(s)
business = s.scalar(select(Business))
first = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=1))
second = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=2))
assert first.total == second.total == 2
rows = {row["unit"]: row for row in first.records + second.records}
assert (Decimal(rows["根"]["order_quantity"]), Decimal(rows["根"]["shipment_quantity"])) == (10, 5)
assert (Decimal(rows["吨"]["order_quantity"]), Decimal(rows["吨"]["shipment_quantity"])) == (3, 2)
def test_仅待核对单据的业务不填零成本运费且重试修复旧汇总(work_session):
s = work_session
dataset(s)
source(s, "businesses", [{"id": 11, "code": "SO-11", "idcustomer": 8,
"voucherdate": "2026-03-12", "voucherState": 0}])
run = sync(s)
business = s.scalar(select(Business))
assert run.status == "PARTIAL"
assert business.total_cost_amount is None and business.total_freight_amount is None
business.total_cost_amount = Decimal("0.00")
business.total_freight_amount = Decimal("0.00")
s.commit()
sync(s)
assert business.total_cost_amount is None and business.total_freight_amount is None
def test_待核对回款来源客户变化时不静默归入原客户(work_session):
s = work_session
dataset(s, cash=True)
source(s, "customers", [
{"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228},
{"id": 10, "code": "C10", "name": "其他客户", "partnerType": 228},
])
receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1,
"isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189}
source(s, "receipts", [receipt])
sync(s)
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
original_business_id = doc.business_id
assert doc.reason == "SOURCE_VALUE_INVALID" and original_business_id is not None
source(s, "receipts", [{**receipt, "idpartner": 10}])
sync(s)
assert (doc.status, doc.reason, doc.business_id) == (
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id)
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
displayed = business_source.documents(s, original_business_id, PageQuery(), entity="receipts").records[0]
assert displayed["changes"] == [{"field": "customer_id", "label": "客户标识", "before": "8", "after": "10"}]
source(s, "receipts", [{**receipt, "idpartner": 10, "amount": "200"}])
sync(s)
assert (doc.status, doc.reason, doc.business_id) == (
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id)
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
source(s, "receipts", [{**receipt, "amount": "200"}])
sync(s)
assert doc.status == "APPLIED" and doc.business_id == original_business_id
assert s.scalar(select(func.count()).select_from(Receipt)) == 1
def test_待核对回款同客户更新来源日期及人员(work_session):
s = work_session
dataset(s, cash=True)
receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1,
"isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189}
source(s, "receipts", [receipt])
sync(s)
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
source(s, "receipts", [{**receipt, "idperson": 2, "voucherDate": "2026-03-15"}])
sync(s)
assert doc.reason == "SOURCE_VALUE_INVALID"
assert doc.data_json["source_date"] == "2026-03-15"
assert doc.data_json["source_personnel_id"] == "2"
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
@pytest.mark.parametrize("entity", ["businesses", "shipments"])
def test_待核对订单或发货客户变化需核对(work_session, entity):
s = work_session
dataset(s, shipping=entity == "shipments")
source(s, "customers", [
{"id": 8, "code": "C8", "name": "自动客户"},
{"id": 10, "code": "C10", "name": "其他客户"},
])
raw = {"id": 11 if entity == "businesses" else 21, "idcustomer": 8,
"voucherdate": "2026-03-12", "voucherState": 0}
source(s, entity, [raw])
sync(s)
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == entity))
original_business_id = doc.business_id
assert doc.reason == "SOURCE_DOCUMENT_INACTIVE" and original_business_id is not None
source(s, entity, [{**raw, "idcustomer": 10, "voucherState": 189}])
sync(s)
assert (doc.status, doc.reason, doc.business_id, doc.target_id) == (
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id, None)
source(s, entity, [{**raw, "voucherState": 189}])
sync(s)
assert doc.status == "APPLIED" and doc.business_id == original_business_id
@pytest.mark.parametrize("mismatch", ["product", "order"])
def test_发货关联订单的产品或父单不一致时不静默归并(work_session, mismatch):
s = work_session
dataset(s, shipping=True)
if mismatch == "product":
source(s, "products", [
{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"},
{"id": 10, "code": "P10", "name": "其他管材", "specification": "DN200", "unit_name": "根"},
])
source(s, "shipment_items", [{
"id": 22, "source_parent_id": 21,
"idinventory": 10 if mismatch == "product" else 9,
"saleOrderId": 99 if mismatch == "order" else 11,
"saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500",
}])
sync(s)
business = s.scalar(select(Business))
assert s.scalar(select(func.count()).select_from(BusinessItem)) == 2
assert business.total_sale_amount is None
assert business.integration_json["sales_relation_pending"] is True
shipment = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments"))
assert (shipment.status, shipment.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH")
sync(s)
assert shipment.status == "CONFLICT"
assert shipment.reason == "ORDER_SHIPMENT_MISMATCH"
def test_已有错误关联的发货重跑时标记冲突且保留正式数据(work_session, monkeypatch):
s = work_session
dataset(s, shipping=True)
source(s, "shipment_items", [{
"id": 22, "source_parent_id": 21, "idinventory": 9,
"saleOrderId": 99, "saleOrderDetailId": 12,
"quantity": "5", "taxPrice": "100", "taxAmount": "500",
}])
original = business_sync._shipment_order_line
def old_match(ctx, business_id, child):
doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.source_id == "12",
))
return doc, False
monkeypatch.setattr(business_sync, "_shipment_order_line", old_match)
sync(s)
shipment = s.scalar(select(Shipment))
shipment_id = shipment.id
monkeypatch.setattr(business_sync, "_shipment_order_line", original)
sync(s)
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments"))
assert (doc.status, doc.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH")
assert business_source.sales_relation_pending(s, doc.business_id)
assert s.get(Shipment, shipment_id) is not None
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
def test_基础数据可筛选待分类与待归属(work_session):
s = work_session
dataset(s)
sync(s)
role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
person = Personnel(personnel_name="已归属人员", business_role_id=role.id)
s.add(person)
s.flush()
s.add(Customer(company_name="已分类客户", owner_name="", customer_type="personal", owner_personnel_id=person.id))
s.add(Product(product_name="已分类产品", spec_model="", product_category="municipal_water", status=1))
s.flush()
customers = CustomerQuery(category_missing=True, owner_missing=True)
products = ProductQuery(category_missing=True)
assert customer_repository.count_customers(s, customers) == 1
assert customer_repository.list_customers(s, customers)[0][0].company_name == "自动客户"
assert product_repository.count_products(s, products) == 1
assert product_repository.list_products(s, products)[0].product_name == "管材"
assert customer_repository.count_customers(s, CustomerQuery(customer_type="personal", category_missing=True)) == 0
assert product_repository.count_products(s, ProductQuery(product_category="municipal_water", category_missing=True)) == 0
@pytest.mark.parametrize("frozen", [False, True])
def test_批量补分类仅补空值并保护冻结明细(work_session, frozen):
s = work_session
dataset(s)
sync(s)
product = s.scalar(select(Product))
customer = s.scalar(select(Customer))
item = s.scalar(select(BusinessItem))
existing = Product(product_name="已有分类", spec_model="", product_category="rail_transit", status=1)
s.add(existing)
if frozen:
s.add(PayrollInput(business_id=item.business_id, version=1, source_database="test_source",
group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1))
s.commit()
actor = s.get(User, 1)
with pytest.raises(BadRequestError, match="分类已变化"):
category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id, existing.id], category="municipal_water"), actor)
assert s.get(Product, product.id).product_category is None
result = category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id], category="municipal_water"), actor)
assert result["updated"] == 1
s.refresh(item)
assert item.product_category == (None if frozen else "municipal_water")
category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="enterprise"), actor)
assert s.get(Customer, customer.id).customer_type == "enterprise"
with pytest.raises(BadRequestError, match="分类已变化"):
category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="personal"), actor)
assert s.get(Customer, customer.id).customer_type == "enterprise"
def test_业务详情补产品分类同步其他未封存业务(work_session, monkeypatch):
from app.services import authorization_service
s = work_session
dataset(s)
sync(s)
first = s.scalar(select(Business))
product = s.scalar(select(Product))
others = [Business(business_no=f"OTHER-{n}", customer_id=first.customer_id) for n in range(2)]
s.add_all(others)
s.flush()
items = [BusinessItem(business_id=b.id, product_id=product.id, product_name="管材",
quantity=1, sale_amount=100) for b in others]
s.add_all(items)
s.add(PayrollInput(business_id=others[1].id, version=1, source_database="test_source",
group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1))
s.commit()
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
payload = SourceMaterialsIn(products=[{"product_id": product.id, "category": "municipal_water"}])
business_source.save_materials(s, first.id, payload, s.get(User, 1))
for item in items:
s.refresh(item)
assert items[0].product_category == "municipal_water"
assert items[1].product_category is None
def test_可平账回款仅按有效封存口径展示(work_session):
s = work_session
dataset(s, cash=True)
sync(s)
business = s.scalar(select(Business))
receipt = s.scalar(select(Receipt))
assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None
data = {"input_schema": "TPLUS_SYNC_V1", "shipments": [],
"receipts": [{"amount": "200.00", "voucher_confirmed": True, "exclusion": None}],
"maps": {"shipment_items": {}, "receipts": {str(receipt.id): receipt.id}}}
snapshot = PayrollInput(business_id=business.id, version=1, source_database="test_source",
group_key="frozen", fingerprint="f" * 64, data_json=data, requested_by=1)
s.add(snapshot)
s.commit()
assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 200
snapshot.data_json = {**data, "receipts": [{"amount": "200.00", "voucher_confirmed": False,
"exclusion": "NOT_GENERATED_VOUCHER"}]}
s.commit()
assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 0
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
doc.status = "CONFLICT"
s.commit()
assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None
def test_来源核算准备只要求截止月份回款已确认(work_session, monkeypatch):
from datetime import datetime
s = work_session
dataset(s, shipping=True, cash=True)
cash = {"idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200",
"voucherstate": 189, "accounting_voucher_count": 1,
"valid_accounting_voucher_count": 1, "accounting_voucher_id": 501}
source(s, "receipts", [{**cash, "id": 31, "voucherDate": "2026-03-31"},
{**cash, "id": 32, "voucherDate": "2026-04-01"}])
sync(s)
business = s.scalar(select(Business))
role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
person = Personnel(personnel_name="经办人", business_role_id=role.id)
s.add(person)
s.flush()
item = s.scalar(select(ShipmentItem))
payload = PayrollSetupIn(month="2026-03", evidence="已核实", opening_balance="0",
history_confirmed=True, guidance_finalized=True,
shipment_ownerships=[{"shipment_item_id": item.id, "personnel_id": person.id,
"unit_cost": "1", "freight_amount": "0"}])
actor = s.get(User, 1)
s.commit()
monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None)
monkeypatch.setattr(business_source, "readiness", lambda *args: {"reasons": []})
receipts = list(s.scalars(select(Receipt).order_by(Receipt.receipt_date)))
with pytest.raises(BadRequestError, match="人工确认"):
business_source.prepare(s, business.id, payload, actor)
past = s.get(Receipt, receipts[0].id)
past.confirmation_status = 1
past.confirmed_by = "reviewer"
past.confirmed_at = datetime.now()
past.confirmed_revision = receipt_confirmation.revision(past)
s.commit()
result = business_source.prepare(s, business.id, payload, actor)
snapshot = s.get(PayrollInput, result["input_id"])
assert len(snapshot.data_json["receipts"]) == 2
with pytest.raises(BadRequestError, match="人工确认"):
business_source.prepare(s, business.id, payload.model_copy(update={"month": "2026-04", "expected_version": 1}), actor)
@@ -70,3 +70,25 @@ def test_连接文件不接受sa或应用配置覆盖(tmp_path):
)
with pytest.raises(RuntimeError):
resolve_tplus_settings(settings)
@pytest.mark.parametrize(
("environment", "allowed", "accepted"),
[("dev", False, False), ("prod", True, False), ("dev", True, True)],
)
def test_sa仅允许显式启用的开发环境(environment, allowed, accepted):
settings = Settings(
_env_file=None,
app_secret_key="x" * 32,
app_env=environment,
t_plus_enabled=True,
t_plus_user="sa",
t_plus_database="test_source",
t_plus_odbc_driver="test_driver",
t_plus_allow_dev_sa=allowed,
)
if accepted:
assert resolve_tplus_settings(settings).t_plus_user == "sa"
else:
with pytest.raises(RuntimeError, match="不能使用 sa"):
resolve_tplus_settings(settings)
+14
View File
@@ -243,9 +243,23 @@ def test_离职交接转给新人员_在职交接保留发货人():
f["handovers"] = [handover]
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002"
handover["mode"] = "ACTIVE"
handover["shipping_ownership_confirmed"] = True
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001"
def test_在职交接未确认发货归属时不生成绩效():
g, s, c, f, p = inputs()
handover = {"business_key": "business1", "mode": "ACTIVE", "effective_date": "2026-03-05",
"from_personnel_id": "001", "to_personnel_id": "002", "shipping_ownership_confirmed": False}
f["handovers"] = [handover]
result = run(g, [s], [c], f, p)
assert result["status"] == "PARTIAL"
assert "HANDOVER_SHIPMENTS_PENDING" in result["lines"][0]["reasons"]
assert result["lines"][0]["performance"] is None
handover["business_key"] = "another-business"
assert run(g, [s], [c], f, p)["status"] == "SUCCEEDED"
def test_输入金额拒绝浮点与无穷():
for value in [1.1, "NaN", "Infinity"]:
with pytest.raises(ValueError):
@@ -0,0 +1,95 @@
"""月度指导价检查必须读取同账套的历史封存发货。"""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import (
Business,
Customer,
PayrollInput,
Product,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusSourceLink,
User,
)
from app.schemas.business_source import GuidanceConfirmIn
from app.services import business_source
from app.services.tplus.guidance import frozen_guidance_shipments
from tests.unit.test_tplus_business_sync import dataset, sync
from tests.unit.test_tplus_workbench import work_session as work_session
def test_登记指导成本校验已应用的发货单版本(work_session, monkeypatch):
session = work_session
dataset(session, shipping=True)
sync(session)
business = session.scalar(select(Business))
item = session.scalar(select(ShipmentItem))
actor = session.get(User, 1)
monkeypatch.setattr(business_source, "source_snapshot_ready", lambda *_: True)
payload = GuidanceConfirmIn(shipment_item_id=item.id, amount="2.1234", evidence="核对指导成本")
created = business_source.confirm_guidance(session, business.id, payload, actor)
assert created["status"] == "CREATED"
assert business_source.confirm_guidance(session, business.id, payload, actor)["status"] == "REUSED"
parent = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.entity_type == "shipments"
))
line = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.entity_type == "shipment_items"
))
parent.applied_record_id = line.applied_record_id
session.commit()
with pytest.raises(BadRequestError, match="来源单据已变化"):
business_source.confirm_guidance(session, business.id, payload, actor)
def test_只返回来源产品与历史月份匹配的已封存发货(work_session):
session = work_session
customer = Customer(company_name="测试客户", owner_name="", customer_type="personal")
product = Product(product_name="测试产品", spec_model="", unit="根", product_category="municipal_water")
session.add_all([customer, product])
session.flush()
session.add(TPlusSourceLink(source_database="test_source", entity_type="products", source_id="9", target_id=product.id))
business = Business(business_no="GUIDE-HISTORY", customer_id=customer.id)
session.add(business)
session.flush()
shipment = Shipment(business_id=business.id, shipment_no="GUIDE-SHIP", ship_date=date(2026, 4, 1))
session.add(shipment)
session.flush()
item = ShipmentItem(shipment_id=shipment.id, product_id=product.id, product_name="测试产品", spec_model="",
unit="根", quantity=Decimal("2"), amount=Decimal("100"))
session.add(item)
session.flush()
record = {"line_id": "source-line-999", "product_id": "9", "date": "2026-03-10", "unit": "根", "unit_cost": "10"}
session.add_all([
PayrollInput(business_id=business.id, version=1, source_database="test_source", group_key="g",
fingerprint="a" * 64, data_json={"shipments": [record]}, requested_by=1),
PayrollInput(business_id=business.id, version=2, source_database="test_source", group_key="g",
fingerprint="b" * 64, data_json={"shipments": [{**record, "unit_cost": "11"}]}, requested_by=1),
])
session.flush()
rows = list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
assert [row["unit_cost"] for row in rows] == ["10", "11"]
assert list(frozen_guidance_shipments(session, "test_source", "9", "2026-04")) == []
assert list(frozen_guidance_shipments(session, "other_source", "9", "2026-03")) == []
assert list(frozen_guidance_shipments(session, "test_source", "10", "2026-03")) == []
session.delete(item)
session.flush()
assert len(list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))) == 2
broken = session.get(PayrollInput, 1)
broken.data_json = {"shipments": [{**record, "product_id": None}]}
session.flush()
with pytest.raises(BadRequestError, match="缺少来源产品身份"):
list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
broken = session.get(PayrollInput, 1)
broken.data_json = {"shipments": [{"date": "2026-03-10", "unit_cost": "10"}]}
session.flush()
with pytest.raises(BadRequestError, match="已封存发货数据异常"):
list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
@@ -0,0 +1,103 @@
"""指导成本解析只认已登记事实:多条候选不猜、单位与封存状态必须显式匹配。"""
from decimal import Decimal
import pytest
from app.core.exceptions import BadRequestError
from app.services.tplus.guidance import check_unit_guidance_conflicts, resolve_guidance
class Fact:
"""最小事实替身:resolve_guidance 只读取 kind 与 data_json。"""
def __init__(self, data_json):
self.kind = "GUIDANCE"
self.data_json = data_json
def facts_of(*rows):
"""按 (kind, fact_key) 组装成 latest_guidance_facts 的返回结构。"""
return {("GUIDANCE", str(index)): Fact(row) for index, row in enumerate(rows)}
def final_row(**overrides):
row = {
"product_id": 9,
"month": "2026-03",
"basis": "UNIT",
"unit": "根",
"is_finalized": True,
}
row.update(overrides)
return row
def test_没有匹配事实时返回缺失():
state, guidance = resolve_guidance(facts_of(), 9, "line-1", "2026-03", "根")
assert state == "MISSING"
assert guidance is None
def test_多条候选时不选择其中一条():
rows = facts_of(final_row(), final_row(shipment_line_id="line-1"))
state, guidance = resolve_guidance(rows, 9, "line-1", "2026-03", "根")
assert state == "AMBIGUOUS"
assert guidance is None
def test_唯一候选且单位一致时返回已封存事实():
row = final_row()
state, guidance = resolve_guidance(facts_of(row), 9, "line-1", "2026-03", "根")
assert state == "FINALIZED"
assert guidance is row
def test_单位不一致时不采用该事实():
state, guidance = resolve_guidance(facts_of(final_row(unit="吨")), 9, "line-1", "2026-03", "根")
assert state == "UNIT_MISMATCH"
assert guidance is None
def test_未封存事实不能作为指导价依据():
state, guidance = resolve_guidance(facts_of(final_row(is_finalized=False)), 9, "line-1", "2026-03", "根")
assert state == "UNFINALIZED"
assert guidance is None
def test_按明细行登记的指导价只对该行生效():
rows = facts_of(final_row(basis="LINE", shipment_line_id="line-1"))
assert resolve_guidance(rows, 9, "line-1", "2026-03", "根")[0] == "FINALIZED"
assert resolve_guidance(rows, 9, "line-2", "2026-03", "根")[0] == "MISSING"
def test_同一产品同月已有事实时拒绝重复登记():
with pytest.raises(BadRequestError, match="重叠"):
check_unit_guidance_conflicts(facts_of(final_row()), 9, "2026-03", "根", "10", [])
def test_历史封存明细的成本或单位不一致时拒绝():
with pytest.raises(BadRequestError, match="不一致"):
check_unit_guidance_conflicts(
facts_of(), 9, "2026-03", "根", "10",
[{"unit_cost": "11", "unit": "根"}],
)
with pytest.raises(BadRequestError, match="不一致"):
check_unit_guidance_conflicts(
facts_of(), 9, "2026-03", "根", "10",
[{"unit_cost": "10", "unit": "吨"}],
)
def test_事实为空且历史一致时放行():
check_unit_guidance_conflicts(
facts_of(), 9, "2026-03", "根", Decimal("10"),
[{"unit_cost": "10", "unit": "根"}, {"unit_cost": "10.00", "unit": "根"}],
)
def test_金额非法时拒绝():
for bad in ("abc", "-1", "nan"):
with pytest.raises(BadRequestError, match="不一致"):
check_unit_guidance_conflicts(facts_of(), 9, "2026-03", "根", bad, [])
@@ -0,0 +1,176 @@
"""运行控制以实际心跳呈现状态,并保护并发变更。"""
from datetime import datetime, timedelta
from threading import Event
from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import ConflictError, ForbiddenError
from app.models import OperationLog, TPlusRuntimeControl, User
from app.schemas.tplus import TPlusRuntimeUpdate
from app.services.tplus import runtime_control, supervisor, task_service
from tests.unit.test_tplus_workbench import work_session as work_session
def test_启动请求不冒充运行且冲突受保护(work_session):
session = work_session
session.add(TPlusRuntimeControl(id=1))
session.commit()
actor = task_service.TaskActor(session.get(User, 1))
payload = TPlusRuntimeUpdate(desired_state="RUNNING", expected_version=0)
status = runtime_control.save(session, payload, actor)
assert status["desired_state"] == "RUNNING"
assert status["actual_state"] == "OFFLINE"
assert status["version"] == 1
assert session.scalar(select(OperationLog).where(OperationLog.action == "修改读取服务状态"))
with pytest.raises(ConflictError):
runtime_control.save(session, payload, actor)
assert supervisor.may_claim(session)
session.rollback()
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=1), actor)
assert not supervisor.may_claim(session)
def test_过期心跳显示离线且权限拒绝(work_session, monkeypatch):
session = work_session
now = datetime(2026, 11, 5, 10)
session.add(TPlusRuntimeControl(id=1, desired_state="RUNNING", actual_state="RUNNING",
worker_phase="RUNNING", heartbeat_at=now - timedelta(seconds=91)))
session.commit()
assert runtime_control.overview(session, now=now)["actual_state"] == "OFFLINE"
def denied(*_args):
raise ForbiddenError()
monkeypatch.setattr(runtime_control, "require_permission", denied)
with pytest.raises(ForbiddenError):
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=0),
task_service.TaskActor(session.get(User, 1)))
assert session.get(TPlusRuntimeControl, 1).desired_state == "RUNNING"
def test_健康检查拒绝外部地址():
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32,
t_plus_supervisor_health_url="http://example.com/api/health")
assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址"
@pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}'])
def test_健康检查结构异常时返回未就绪(monkeypatch, body):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32)
response = MagicMock()
response.__enter__.return_value.read.return_value = body
monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response)
assert supervisor.health_error(settings) == "系统或依赖尚未就绪"
@pytest.mark.parametrize("connected", [True, False])
def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, connected):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
t_plus_database="source", t_plus_user="reader",
t_plus_odbc_driver="test-driver")
response = MagicMock()
response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}'
monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response)
import pyodbc
monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"])
engine = MagicMock()
if not connected:
engine.connect.side_effect = SQLAlchemyError("secret-connection-string")
monkeypatch.setattr(supervisor, "create_engine", lambda *_args, **_kwargs: engine)
result = supervisor.health_error(settings)
assert result is None if connected else result == "T+ 数据库连接不可用,请检查连接配置"
if connected:
connection = engine.connect.return_value.__enter__.return_value
assert connection.connection.driver_connection.timeout == settings.t_plus_query_timeout
connection.exec_driver_sql.assert_called_once_with("SELECT 1")
engine.dispose.assert_called_once()
def test_来源不可用时托管器不排队或领取任务(monkeypatch):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.return_value.scalar.return_value = 1
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
session = factory.return_value.__enter__.return_value
session.scalar.return_value.desired_state = "RUNNING"
stopped = Event()
def unavailable(_settings):
stopped.set()
return "T+ 数据库连接不可用,请检查连接配置"
maintenance, worker = MagicMock(), MagicMock()
monkeypatch.setattr(supervisor, "health_error", unavailable)
monkeypatch.setattr(supervisor, "tick", maintenance)
monkeypatch.setattr(supervisor, "run_once", worker)
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
maintenance.assert_not_called()
worker.assert_not_called()
def test_托管检查异常记录类型但不泄露内容(caplog):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("secret-value"),
MagicMock(scalar=lambda: 1)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
assert supervisor.run(factory, MagicMock()) == 1
assert "RuntimeError" in caplog.text
assert "secret-value" not in caplog.text
@pytest.mark.parametrize("release", [None, 0])
def test_托管锁释放失败时使连接失效(release):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1),
MagicMock(scalar=lambda: release)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
connection.invalidate.assert_called_once()
connection.close.assert_called_once()
def test_停止状态写入失败仍释放托管锁():
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), MagicMock(scalar=lambda: 1)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
factory.return_value.__enter__.side_effect = RuntimeError("数据库暂不可用")
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
assert connection.execute.call_count == 2
connection.invalidate.assert_not_called()
connection.close.assert_called_once()
def test_释放托管锁抛错时使连接失效():
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("连接异常")]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
connection.invalidate.assert_called_once()
connection.close.assert_called_once()
+101
View File
@@ -3,11 +3,13 @@
from contextlib import nullcontext
from datetime import datetime
from decimal import Decimal
from types import SimpleNamespace
from uuid import uuid4
import pytest
from pydantic import ValidationError
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import (
@@ -22,6 +24,7 @@ from app.models import (
TPlusSourceLink,
TPlusSyncBatch,
TPlusSyncRecord,
TPlusWorkLine,
TPlusWorkRun,
)
from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery
@@ -163,6 +166,55 @@ def prepare_person(session):
)
def test_旧基础明细从原快照补名称和工号且不改历史(work_session, monkeypatch):
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
s = work_session
source(s, "personnel", [{"id": 1, "code": "00001", "name": "旧快照人员", "mobilePhoneNo": "PRIVATE"}])
run = preview(s, ["personnel"])
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
original = {k: v for k, v in line.data_json.items() if k not in ("source_name", "source_no")}
line.data_json = original
s.commit()
# 后续采集改名,历史任务仍应使用当时的记录版本。
source(s, "personnel", [{"id": 1, "code": "00001", "name": "后来改名"}])
result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0]
assert result["data"]["source_name"] == "旧快照人员"
assert result["data"]["source_no"] == "00001"
assert "mobilePhoneNo" not in result["data"]
assert "PRIVATE" not in str(result)
assert line.data_json == original
report = "".join(workbench.report_csv(s, run.run_no))
assert "旧快照人员" in report and "00001" in report
assert "后来改名" not in report
def test_名单缺失人员显示任务冻结的姓名工号(work_session, monkeypatch):
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
s = work_session
prepare_person(s)
fact(s, "PERSONNEL", "09999", {"code": "09999", "name": "名单待核对人员", "role_code": "salesperson", "status": 1})
run = preview(s, ["personnel"])
result = workbench.list_lines(s, run.run_no, WorkQuery(action="PENDING")).records[0]
assert result["reason"] == "ROSTER_SOURCE_MISSING"
assert result["data"]["source_name"] == "名单待核对人员"
assert result["data"]["source_no"] == "09999"
def test_基础明细不使用不匹配实体的快照补名称(work_session, monkeypatch):
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
s = work_session
source(s, "products", [{"id": 1, "code": "P1", "name": "产品私有名称", "unit_name": "根"}])
record = s.scalar(select(TPlusSyncRecord).where(TPlusSyncRecord.entity_type == "products"))
source(s, "personnel", [{"id": 1, "code": "00001", "name": "人员"}])
run = preview(s, ["personnel"])
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
line.data_json = {"record_id": record.id, "values": {}}
s.commit()
result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0]
assert not result["data"].get("source_name")
assert "产品私有名称" not in "".join(workbench.report_csv(s, run.run_no))
def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import task_service
@@ -447,6 +499,55 @@ def test_工作请求幂等且不同意图不复用(work_session):
workbench.queue_work(s, payload.model_copy(update={"entity_types": ["customers"]}), 1)
@pytest.mark.parametrize("race", ["same", "different", "unrelated"])
def test_工作区排队完整性错误只核对一次既有任务(monkeypatch, race):
from app.core.exceptions import BadRequestError, ConflictError
from app.services import authorization_service
payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"])
found = SimpleNamespace(input_json=payload.model_dump(mode="json", exclude={"request_key"}),
source_database="test_source", requested_by=2 if race == "different" else 1)
results = iter([None, found if race != "unrelated" else None])
rollbacks = []
class Session:
def get(self, *_args): return object()
def scalar(self, _query): return next(results)
def begin_nested(self): return nullcontext()
def add(self, _row): pass
def flush(self): raise IntegrityError("insert", {}, Exception())
def rollback(self): rollbacks.append(True)
monkeypatch.setattr(authorization_service, "require_permission", lambda *_args: None)
monkeypatch.setattr(workbench, "_work_source_database", lambda _session: "test_source")
monkeypatch.setattr(workbench, "work_out", lambda row: row)
if race == "same":
assert workbench.queue_work(Session(), payload, 1) is found
else:
with pytest.raises(BadRequestError if race == "different" else ConflictError):
workbench.queue_work(Session(), payload, 1)
assert rollbacks == [True]
def test_旧业务预览不可排队执行且历史排队任务不写业务(work_session):
from app.core.exceptions import BadRequestError
from app.models import Business, PayrollInput
s = work_session
payload = WorkCreateIn(kind="BUSINESS_APPLY", request_key=uuid4(), preview_no="old-preview")
with pytest.raises(BadRequestError, match="旧规则业务预览仅供核对"):
workbench.queue_work(s, payload, 1)
legacy = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
requested_by=1, kind="BUSINESS_APPLY", status="QUEUED", input_json={"preview_no": "old-preview"})
s.add(legacy)
s.commit()
assert workbench.execute_next(s) == legacy.run_no
s.refresh(legacy)
assert (legacy.status, legacy.error_code) == ("FAILED", "LEGACY_BUSINESS_APPLY_DISABLED")
assert s.scalar(select(func.count()).select_from(Business)) == 0
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
def test_工作区接口拒绝未登录请求(client):
for url in (
"/api/v1/system/tplus/facts",
@@ -0,0 +1,79 @@
"""采集与业务接入分别记录结果。"""
from contextlib import nullcontext
from datetime import date
from types import SimpleNamespace
import pytest
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, ConflictError
from app.services.tplus import business_sync_control, worker
def test_自动接入排队失败不改变成功采集状态(monkeypatch):
batch = SimpleNamespace(requested_by=None, source_database="test_source", sync_mode="FULL", status="RUNNING")
session = SimpleNamespace(rollback=lambda: None)
failures = []
schedule_failures = []
monkeypatch.setattr(worker.task_service, "get_settings", lambda: object())
monkeypatch.setattr(worker, "resolve_tplus_settings", lambda _: SimpleNamespace(t_plus_database="test_source"))
monkeypatch.setattr(worker.task_service, "fail_batch", lambda *args: failures.append(args))
class SyncService:
def __init__(self, source, target):
pass
def execute_claimed(self, task):
task.status = "SUCCEEDED"
def fail_schedule(session):
raise RuntimeError("排队失败")
monkeypatch.setattr(worker, "TPlusSyncService", SyncService)
monkeypatch.setattr(business_sync_control, "schedule", fail_schedule)
monkeypatch.setattr(business_sync_control, "record_schedule_failure", lambda session, source: schedule_failures.append(source))
worker._execute(session, batch, lambda: nullcontext(object()))
assert batch.status == "SUCCEEDED"
assert failures == []
assert schedule_failures == ["test_source"]
@pytest.mark.parametrize("race", ["same", "different", "unrelated"])
def test_业务接入排队完整性错误只核对一次既有任务(monkeypatch, race):
found = SimpleNamespace(kind="BUSINESS_SYNC", requested_by=1, source_database="test_source",
input_json={"start_date": "2026-03-01"})
if race == "different":
found.requested_by = 2
results = iter([None, found if race != "unrelated" else None])
rollbacks = []
class Session:
def get(self, *_args):
return object()
def scalar(self, _query):
return next(results)
def begin_nested(self):
return nullcontext()
def add(self, _row):
pass
def flush(self):
raise IntegrityError("insert", {}, Exception())
def rollback(self):
rollbacks.append(True)
monkeypatch.setattr(business_sync_control, "require_permission", lambda *_args: None)
monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source")
monkeypatch.setattr(business_sync_control, "work_out", lambda row: row)
args = (Session(), 1, date(2026, 3, 1), "same-key")
if race == "same":
assert business_sync_control.queue(*args) is found
else:
with pytest.raises(BadRequestError if race == "different" else ConflictError):
business_sync_control.queue(*args)
assert rollbacks == [True]