PayrollManagementSystem/backend/tests/unit/test_tplus_workbench.py

930 lines
31 KiB
Python
Raw Normal View History

"""真实事务验证导入、资料版本和预演隔离;SQLite 仅替代存储。"""
from contextlib import nullcontext
from datetime import datetime
from decimal import Decimal
from uuid import uuid4
import pytest
from pydantic import ValidationError
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
from sqlalchemy.orm import Session
from app.models import (
Base,
BusinessRole,
Customer,
PerformanceLedger,
Personnel,
Product,
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusSyncBatch,
TPlusSyncRecord,
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):
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_更正事实要求排除或原业务归属():
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)
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",
},
)