PayrollManagementSystem/backend/tests/unit/test_tplus_workbench.py
liuwenxin b8920c0d28 fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
2026-11-05 19:32:20 +08:00

1046 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""真实事务验证导入、资料版本和预演隔离;SQLite 仅替代存储。"""
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 (
Base,
BusinessRole,
Customer,
PerformanceLedger,
Personnel,
Product,
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusSyncBatch,
TPlusSyncRecord,
TPlusWorkLine,
TPlusWorkRun,
)
from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery
from app.services.tplus import workbench
from app.services.tplus.mapping import get_mapping
from app.services.tplus.master_import import execute_master
from app.services.tplus.source_heads import observe_rows
from app.services.tplus.staging import stage_row
@pytest.fixture
def work_session(monkeypatch):
engine = create_engine("sqlite://")
metadata = MetaData()
for model_table in Base.metadata.sorted_tables:
table = model_table.to_metadata(metadata)
for column in table.columns:
if column.type.__class__.__name__ in ("BIGINT", "TINYINT"):
column.type = Integer()
if column.name == "update_time":
column.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
metadata.create_all(engine)
monkeypatch.setattr(workbench, "_source_database", lambda: "test_source")
monkeypatch.setattr(workbench, "sync_lock", lambda s: nullcontext())
with Session(engine, expire_on_commit=False) as session:
from app.models import (
AuthorizationSetting,
Permission,
Role,
RolePermission,
User,
UserRole,
)
from app.services.permission_catalog import CATALOG, ROLE_DEFAULTS
session.add(AuthorizationSetting(id=1, revision=1))
actor = User(id=1, username="test_operator", password="test_hash_only", status=1)
session.add(actor)
permission_rows = {code: Permission(perm_code=code, perm_name=code, perm_type=2, status=1)
for code in CATALOG}
session.add_all(permission_rows.values())
session.flush()
for code, grants in ROLE_DEFAULTS.items():
role = Role(role_code=code, role_name=code, status=1)
session.add(role)
session.flush()
session.add_all([RolePermission(role_id=role.id, permission_id=permission_rows[p].id) for p in grants])
if code == "admin":
actor.user_roles.append(UserRole(role_id=role.id))
session.add(
BusinessRole(
role_name="营销员",
role_code="salesperson",
calc_template="SALESPERSON",
sort_order=1,
status=1,
)
)
session.commit()
yield session
engine.dispose()
def source(session, entity, rows, batch_no=None):
number = batch_no or uuid4().hex[:24]
batch = TPlusSyncBatch(
batch_no=number,
sync_mode="FULL",
status="SUCCEEDED",
source_database="test_source",
entity_types_json=[entity],
audit_result_json={"source_head_version": 1, "observed_entities": [entity]},
finished_at=datetime.now(),
)
session.add(batch)
session.flush()
for row in rows:
stage_row(
session,
batch_no=number,
entity_type=entity,
source_table=get_mapping(entity).source_table,
row=row,
)
observe_rows(session, batch, get_mapping(entity), rows)
session.commit()
return batch
def fact(session, kind, key, data):
return workbench.save_fact(
session,
FactIn(
kind=kind, fact_key=key, data=data, evidence="测试用明确客户答复", expected_version=0
),
1,
)
def preview(session, entities, ids=None):
run = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
source_database="test_source",
requested_by=1,
kind="MASTER_PREVIEW",
status="RUNNING",
input_json={"entity_types": entities, "source_ids": ids or []},
)
session.add(run)
execute_master(session, run)
session.commit()
return run
def apply(session, run):
task = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
source_database="test_source",
requested_by=1,
kind="MASTER_APPLY",
status="RUNNING",
input_json={"preview_no": run.run_no},
)
session.add(task)
execute_master(session, task)
session.commit()
return task
def prepare_person(session):
source(session, "personnel", [{"id": 1, "code": "00043", "name": "测试人员"}])
fact(
session,
"PERSONNEL",
"00043",
{"code": "00043", "name": "测试人员", "role_code": "salesperson", "status": 1},
)
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
s = work_session
prepare_person(s)
run = preview(s, ["personnel"])
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
def disabled_source():
raise RuntimeError("T+ 数据源未启用")
monkeypatch.setattr(workbench, "_source_database", disabled_source)
facts = workbench.list_facts(s, WorkQuery())
assert facts.total == 1
assert workbench.list_work(s, WorkQuery()).total == 1
assert workbench.get_run(s, run.run_no).run_no == run.run_no
assert workbench.fact_history(s, facts.records[0]["id"], WorkQuery()).total == 1
assert workbench.list_lines(s, run.run_no, WorkQuery()).total == 1
assert "仅供核对" in "".join(workbench.report_csv(s, run.run_no))
def test_关闭源读取有多个账套时拒绝猜测(work_session, monkeypatch):
from app.core.config import Settings
from app.core.exceptions import BadRequestError
from app.services.tplus import task_service
prepare_person(work_session)
work_session.add(TPlusFact(
source_database="another_source", kind="PRODUCT", fact_key="9", version=1,
data_json={"category": "municipal_water", "unit": "件"},
evidence="其他账套测试", requested_by=1,
))
work_session.commit()
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
with pytest.raises(BadRequestError, match="多个账套"):
workbench.list_facts(work_session, WorkQuery())
def test_历史查询显式账套范围不能串账(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import task_service
prepare_person(work_session)
settings = Settings(
_env_file=None, app_secret_key="x" * 32, t_plus_database="another_source"
)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
assert workbench.list_facts(work_session, WorkQuery()).total == 0
def test_关闭读取时概况仍显示最近成功采集(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import query_service, task_service
prepare_person(work_session)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
overview = query_service.overview(work_session)
assert overview.is_configured is False
assert "仍可查看" in overview.configuration_message
assert overview.automation.enabled is False
assert overview.automation.last_success_at is not None
assert overview.total_staged_records >= 0
def test_关闭SQL源读取仍可维护已存账套补充资料(work_session, monkeypatch):
from app.core.config import Settings
from app.core.exceptions import BadRequestError
from app.services.tplus import task_service
prepare_person(work_session)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
def disabled_source():
raise BadRequestError("T+ 数据源未启用")
monkeypatch.setattr(workbench, "_source_database", disabled_source)
data = fact(work_session, "PRODUCT", "9", {"category":"municipal_water", "unit":"件"})
assert data["version"] == 1
def test_人员真实导入且重跑不重复_保留人工工资与岗位(work_session):
s = work_session
prepare_person(s)
result = apply(s, preview(s, ["personnel"]))
assert result.summary_json["counts"]["CREATE"] == 1
person = s.scalar(select(Personnel))
assert person.source_no == "00043" and person.manager_id is None
person.salary_standard = Decimal("1234.56")
person.is_probation = 1
s.commit()
again = preview(s, ["personnel"])
assert again.summary_json["counts"] == {"SKIP": 1}
apply(s, again)
assert s.scalar(select(func.count()).select_from(Personnel)) == 1
assert person.salary_standard == Decimal("1234.56") and person.is_probation == 1
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
@pytest.mark.parametrize("change", ["source", "fact", "target"])
def test_旧预览源或人工资料或目标变化必须拒绝(work_session, change):
s = work_session
prepare_person(s)
first = preview(s, ["personnel"])
if change == "source":
source(s, "personnel", [{"id": 1, "code": "00043", "name": "新名字"}])
elif change == "fact":
s.add(
TPlusFact(
source_database="test_source",
kind="PRODUCT",
fact_key="1",
version=1,
data_json={"category": "rail_transit", "unit": "件"},
evidence="新资料",
requested_by=1,
)
)
s.commit()
else:
# 新增相同身份的人工人员也应使原 CREATE 预览过期。
s.add(Personnel(personnel_name="测试人员", business_role_id=1))
s.commit()
from app.services.tplus.workbench import WorkbenchError
with pytest.raises(WorkbenchError, match="PREVIEW_STALE"):
apply(s, first)
def test_源A变B再变A指向本次观察的A(work_session):
s = work_session
for name in ("A", "B", "A"):
source(s, "customers", [{"id": 1, "name": name}])
head = s.scalar(select(TPlusSourceHead))
assert s.get(TPlusSyncRecord, head.record_id).payload_json["name"] == "A"
assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
def test_没有当前读取证据不能将历史staging直接导入(work_session):
from app.services.tplus.workbench import WorkbenchError
with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"):
preview(work_session, ["personnel"])
def test_空客户分类及产品类别保持待补_不取Tplus成本(work_session):
s = work_session
source(s, "products", [{"id": 1, "name": "产品", "unit_name": "件", "invSCost": "123"}])
source(s, "customers", [{"id": 2, "name": "客户", "partnerType": 211, "customerType": None}])
run = preview(s, ["products", "customers"])
assert run.summary_json["counts"] == {"PENDING": 2}
apply(s, run)
assert s.scalar(select(func.count()).select_from(Product)) == 0
assert s.scalar(select(func.count()).select_from(Customer)) == 0
def test_客户改名按源ID更新_人工分类不覆盖(work_session):
s = work_session
fact(s, "CUSTOMER", "2", {"customer_type": "personal", "owner_name": "客户负责人"})
source(s, "customers", [{"id": 2, "name": "旧名", "partnerType": 211}])
apply(s, preview(s, ["customers"]))
customer = s.scalar(select(Customer))
customer.customer_type = "enterprise"
s.commit()
source(s, "customers", [{"id": 2, "name": "新名", "partnerType": 211}])
apply(s, preview(s, ["customers"]))
assert customer.company_name == "新名" and customer.customer_type == "enterprise"
assert s.scalar(select(func.count()).select_from(Customer)) == 1
def test_客户供应商混合性质不导入(work_session):
s = work_session
source(s, "customers", [{"id": 2, "name": "混合", "partnerType": 228}])
assert preview(s, ["customers"]).summary_json["counts"] == {"SKIP": 1}
def test_未关联的同名目标不按名字合并(work_session):
s = work_session
prepare_person(s)
s.add(Personnel(personnel_name="测试人员", business_role_id=1))
s.commit()
run = preview(s, ["personnel"])
assert run.summary_json["counts"] == {"CONFLICT": 1}
assert s.scalar(select(func.count()).select_from(TPlusSourceLink)) == 0
def test_重复源工号不生成两个人(work_session):
s = work_session
prepare_person(s)
source(
s,
"personnel",
[
{"id": 1, "code": "00043", "name": "测试人员"},
{"id": 2, "code": "00043", "name": "测试人员"},
],
)
assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2}
def test_资料版本冲突不覆盖_历史保留(work_session):
from app.core.exceptions import BadRequestError
s = work_session
data = {"category": "rail_transit", "unit": "件"}
fact(s, "PRODUCT", "1", data)
with pytest.raises(BadRequestError):
fact(s, "PRODUCT", "1", data)
workbench.save_fact(
s,
FactIn(kind="PRODUCT", fact_key="1", data=data, expected_version=1, evidence="核对后补充"),
1,
)
assert s.scalar(select(func.count()).select_from(TPlusFact)) == 2
def test_旧工作台不能绕过指导成本封存检查(work_session):
from app.core.exceptions import BadRequestError
data = {"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"}
with pytest.raises(BadRequestError, match="业务详情"):
fact(work_session, "GUIDANCE", "legacy", {**data, "is_finalized": True})
work_session.add(TPlusFact(
source_database="test_source", kind="GUIDANCE", fact_key="sealed", version=1,
data_json={**data, "is_finalized": True}, evidence="已核对", requested_by=1,
))
work_session.commit()
with pytest.raises(BadRequestError, match="已有封存"):
fact(work_session, "GUIDANCE", "another-key", data)
def test_更正事实要求排除或原业务归属():
with pytest.raises(ValidationError):
FactIn(
kind="CORRECTION",
fact_key="1",
evidence="客户确认",
data={"entity": "SHIPMENT", "pair_key": "pair-1"},
)
shipment = FactIn(
kind="CORRECTION",
fact_key="1",
evidence="客户确认",
data={
"entity": "SHIPMENT",
"pair_key": "pair-1",
"exclude_performance": True,
},
)
assert shipment.data["exclude_performance"] is True
receipt = FactIn(
kind="CORRECTION",
fact_key="2",
evidence="客户确认",
data={
"entity": "RECEIPT",
"pair_key": "pair-1",
"redirect_business_key": "group-a",
},
)
assert receipt.data["redirect_business_key"] == "group-a"
def test_资料入口拒绝任意字段与非有限金额():
with pytest.raises(ValidationError):
FactIn(
kind="PRODUCT",
fact_key="1",
evidence="依据",
data={"category": "rail_transit", "unit": "件", "password": "not-allowed"},
)
with pytest.raises(ValidationError):
FactIn(
kind="GUIDANCE",
fact_key="1",
evidence="依据",
data={
"product_id": "1",
"month": "2026-03",
"basis": "UNIT",
"amount": "NaN",
"unit": "件",
},
)
def test_工作请求幂等且不同意图不复用(work_session):
from app.core.exceptions import BadRequestError
s = work_session
payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"])
a = workbench.queue_work(s, payload, 1)
assert workbench.queue_work(s, payload, 1)["run_no"] == a["run_no"]
with pytest.raises(BadRequestError):
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",
"/api/v1/system/tplus/work-runs",
"/api/v1/system/tplus/source-records?entity=personnel",
):
assert client.get(url).status_code == 401
def prepare_calc(session):
"""完整准备一个已确认口径的隔离组,不借用真实客户数据。"""
prepare_person(session)
source(
session,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
}
],
)
source(
session,
"shipment_items",
[{"id": 21, "source_parent_id": 20, "idinventory": 9, "taxAmount": "100", "quantity": "2"}],
)
source(
session,
"receipts",
[
{
"id": 10,
"code": "RC-DEMO",
"idpartner": 8,
"voucherDate": "2026-03-10",
"isReceiveFlag": 1,
"voucherstate": 189,
"amount": "100",
}
],
)
fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "件"})
fact(session, "CUSTOMER", "8", {"customer_type": "personal", "owner_name": "负责人"})
fact(
session,
"GUIDANCE",
"price9-03",
{"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"},
)
fact(session, "RECEIPT", "10", {"voucher_confirmed": True, "voucher_reference": "测试会计凭证"})
fact(
session,
"SHIPMENT",
"21",
{"sales_amount_excludes_freight": True, "business_key": "biz", "personnel_id": "00043"},
)
group = {
"customer_id": "8",
"shipment_ids": ["20"],
"receipt_ids": ["10"],
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
"guidance_month_basis": "SHIPMENT",
}
fact(session, "GROUP", "g", group)
session.commit()
return group
def calc(session, keys=None):
from app.services.tplus.calculation_preview import execute_preview
run = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
kind="CALC_PREVIEW",
source_database="test_source",
requested_by=1,
status="RUNNING",
input_json={"month": "2026-03", "group_keys": keys or ["g"]},
)
session.add(run)
execute_preview(session, run)
session.commit()
return run
def test_真实暂存组装预演结果可复现_正式表不变(work_session):
s = work_session
prepare_calc(s)
first = calc(s)
second = calc(s)
assert first.snapshot_hash == second.snapshot_hash
assert first.summary_json == second.summary_json
assert first.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
from app.models import MonthlySettlement, Receipt
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
assert s.scalar(select(func.count()).select_from(MonthlySettlement)) == 0
def test_一个组资料缺失不阻塞另一组计算(work_session):
s = work_session
prepare_calc(s)
fact(
s,
"GROUP",
"missing",
{
"customer_id": "9",
"shipment_ids": ["9000"],
"receipt_ids": [],
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
},
)
run = calc(s, ["g", "missing"])
assert run.status == "PARTIAL"
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert run.summary_json["groups"]["missing"]["reasons"] == ["GROUP_SOURCE_MISSING"]
def test_跨客户和跨组重复消耗被拦截(work_session):
s = work_session
group = prepare_calc(s)
fact(s, "GROUP", "other", group)
run = calc(s)
assert run.summary_json["groups"]["g"]["reasons"] == ["OVERLAPPING_GROUPS"]
assert run.status == "PARTIAL"
def test_重叠组不阻塞无关正常组(work_session):
s = work_session
prepare_calc(s)
for key in ("bad1", "bad2"):
fact(s, "GROUP", key, {"customer_id": "999", "shipment_ids": ["9999"], "receipt_ids": []})
run = calc(s, ["g", "bad1", "bad2"])
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert run.summary_json["groups"]["bad1"]["reasons"] == ["OVERLAPPING_GROUPS"]
assert run.summary_json["groups"]["bad2"]["reasons"] == ["OVERLAPPING_GROUPS"]
def test_已核销不能代替凭证确认(work_session):
s = work_session
prepare_calc(s)
existing = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")]
workbench.save_fact(
s,
FactIn(
kind="RECEIPT",
fact_key="10",
expected_version=existing.version,
evidence="凭证尚未生成",
data={"voucher_confirmed": False, "voucher_reference": "仅已核销"},
),
1,
)
assert calc(s).summary_json["groups"]["g"]["reasons"] == ["VOUCHER_STATE_PENDING"]
@pytest.mark.parametrize(
"count,valid,manual,confirmed,reason",
[
(1, 1, None, True, None),
(0, 0, None, False, None),
(1, 0, None, False, "ACCOUNTING_VOUCHER_INVALID"),
(2, 1, None, False, "ACCOUNTING_VOUCHER_INVALID"),
(1, 1, False, False, "VOUCHER_EVIDENCE_CONFLICT"),
(0, 0, True, False, "VOUCHER_EVIDENCE_CONFLICT"),
],
)
def test_生成凭证按真实总账关联核实而非审核状态(count, valid, manual, confirmed, reason):
from types import SimpleNamespace
from app.services.tplus.calculation_preview import _voucher_evidence
raw = {
"accounting_voucher_count": count,
"valid_accounting_voucher_count": valid,
"accounting_voucher_id": 123 if valid else None,
"accounting_voucher_code": "GL-EXAMPLE",
"accounting_voucher_state": 181,
}
fact = (
SimpleNamespace(data_json={"voucher_confirmed": manual, "voucher_reference": "人工核对"})
if manual is not None
else None
)
allowed, evidence, pending = _voucher_evidence(raw, fact)
assert allowed is confirmed
assert evidence["basis"] == "GL_SrcVoucherInfo/GL_Doc"
if reason:
assert reason in pending
else:
assert pending == []
def test_总账已生成无需逐单手工勾选且凭证编号可追溯(work_session):
from app.models import TPlusWorkLine
s = work_session
prepare_calc(s)
original = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")]
s.delete(original)
source(
s,
"receipts",
[
{
"id": 10,
"code": "RC-DEMO",
"idpartner": 8,
"voucherDate": "2026-03-10",
"isReceiveFlag": 1,
"voucherstate": 189,
"amount": "100",
"accounting_voucher_count": 1,
"valid_accounting_voucher_count": 1,
"accounting_voucher_id": 123,
"accounting_voucher_code": "GL-EXAMPLE",
}
],
)
run = calc(s)
assert run.status == "SUCCEEDED"
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
assert line.data_json["accounting_voucher_code"] == "GL-EXAMPLE"
assert line.data_json["voucher_evidence"]["basis"] == "GL_SrcVoucherInfo/GL_Doc"
def test_新回执固定发货月指导成本_历史空开关不再阻塞(work_session):
s = work_session
group = prepare_calc(s)
group["guidance_month_basis"] = None
workbench.save_fact(
s,
FactIn(
kind="GROUP", fact_key="g", expected_version=1, evidence="历史空开关由2026-10-30客户答复替代", data=group
),
1,
)
assert calc(s).summary_json["groups"]["g"]["status"] == "SUCCEEDED"
from app.models import TPlusWorkLine
assert (
s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.entity_type == "group")).data_json[
"performance"
]
== "0.20"
)
def test_后续读取中断不发布部分当前指针(work_session):
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import WorkbenchError
s = work_session
prepare_person(s)
s.add(
TPlusSyncBatch(
batch_no=uuid4().hex[:24],
sync_mode="FULL",
status="FAILED",
source_database="test_source",
entity_types_json=["personnel"],
)
)
s.commit()
with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"):
source_records(s, "test_source", "personnel")
def test_冻结重跑不读取后来修改的指导价(work_session):
from app.services.tplus.calculation_preview import execute_preview
s = work_session
prepare_calc(s)
first = calc(s)
old = workbench.latest_facts(s, "test_source")[("GUIDANCE", "price9-03")]
workbench.save_fact(
s,
FactIn(
kind="GUIDANCE",
fact_key="price9-03",
expected_version=old.version,
evidence="新资料不应改变冻结预演",
data={**old.data_json, "amount": "49"},
),
1,
)
replay = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
kind="CALC_PREVIEW",
source_database="test_source",
requested_by=1,
status="RUNNING",
input_json={"replay_no": first.run_no},
)
s.add(replay)
execute_preview(s, replay)
s.commit()
assert first.snapshot_hash == replay.snapshot_hash
assert first.summary_json == replay.summary_json
assert calc(s).snapshot_hash != first.snapshot_hash
@pytest.mark.parametrize("settle_state", [428, 180, 21, 283, 1645, 1646])
def test_真实结清状态枚举不误判为作废(work_session, settle_state):
s = work_session
prepare_calc(s)
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
"isCancel": settle_state,
"isCounteractDelivery": 0,
}
],
)
run = calc(s)
assert run.status == "SUCCEEDED"
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
@pytest.mark.parametrize(
"state,reverse,expected",
[(181, 0, "SHIPMENT_STATE_PENDING"), (189, 1, "NEGATIVE_EVENT_REVIEW_REQUIRED")],
)
def test_非生效或红冲的销货仍被阻止(work_session, state, reverse, expected):
from app.models import TPlusWorkLine
s = work_session
prepare_calc(s)
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": state,
"isCancel": 428,
"isCounteractDelivery": reverse,
}
],
)
run = calc(s)
assert run.status == "PARTIAL"
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
assert expected in line.data_json["reasons"]
def test_运费明确后从含税销售额扣除(work_session):
s = work_session
prepare_calc(s)
old = workbench.latest_facts(s, "test_source")[("SHIPMENT", "21")]
workbench.save_fact(
s,
FactIn(
kind="SHIPMENT",
fact_key="21",
expected_version=1,
evidence="例单核实含10元运费",
data={**old.data_json, "sales_amount_excludes_freight": False, "freight_amount": "10"},
),
1,
)
result = calc(s)
assert result.summary_json["groups"]["g"]["prepayment_remaining"] == "10.00"
ship = result.snapshot_json["calculation_inputs"]["g"]["shipments"][0]
assert ship["amount"] == "90" and ship["gross_tax_amount"] == "100"
def test_预演采用已导入基础记录的人工分类(work_session):
s = work_session
prepare_calc(s)
product = Product(
product_name="人工分类产品", spec_model="", unit="件", product_category="rail_transit"
)
customer = Customer(
company_name="人工分类客户", owner_name="负责人", customer_type="enterprise"
)
s.add_all([product, customer])
s.flush()
s.add_all(
[
TPlusSourceLink(
source_database="test_source",
entity_type="products",
source_id="9",
target_id=product.id,
),
TPlusSourceLink(
source_database="test_source",
entity_type="customers",
source_id="8",
target_id=customer.id,
),
]
)
s.commit()
run = calc(s)
ship = run.snapshot_json["calculation_inputs"]["g"]["shipments"][0]
assert ship["category"] == "rail_transit" and ship["customer_type"] == "enterprise"
assert ship["classification_evidence"]["product_target_id"] == product.id
@pytest.mark.parametrize("value", ["", "abc", "1,200", "NaN", "-1"])
def test_非法指导价被校验拒绝(value):
with pytest.raises(ValidationError):
FactIn(
kind="GUIDANCE",
fact_key="price",
evidence="输入校验",
data={
"product_id": "9",
"month": "2026-03",
"basis": "UNIT",
"unit": "件",
"amount": value,
},
)
def test_同名人员不同状态在预览即标为冲突(work_session):
s = work_session
source(
s,
"personnel",
[{"id": 1, "code": "001", "name": "重名"}, {"id": 2, "code": "002", "name": "重名"}],
)
for code, status in (("001", 1), ("002", 0)):
fact(
s,
"PERSONNEL",
code,
{"code": code, "name": "重名", "role_code": "salesperson", "status": status},
)
assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2}
def test_报告文本防公式注入且保留前导零():
from app.services.tplus.report_labels import spreadsheet_value
assert spreadsheet_value("00043") == "'00043"
assert spreadsheet_value("\t=1+1") == "'\t=1+1"
assert spreadsheet_value("普通单号-1") == "普通单号-1"
def test_未确认日期更正不开放():
with pytest.raises(ValidationError, match="日期更正规则尚未确认"):
FactIn(
kind="SHIPMENT",
fact_key="21",
evidence="未确认的建议",
data={
"sales_amount_excludes_freight": True,
"business_key": "b",
"personnel_id": "001",
"date_override": "2026-03-01",
},
)