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

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

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

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

No files matched your search

+465
View File
@@ -0,0 +1,465 @@
"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。"""
import copy
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from uuid import uuid4
import pytest
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
Business,
BusinessRole,
Customer,
PayrollAllocation,
PayrollInput,
PayrollMonth,
PerformanceLedger,
Personnel,
Product,
Receipt,
ShipmentItem,
TPlusSourceLink,
TPlusWorkRun,
User,
)
from app.services import manager_share, payroll_calculation, payroll_months
from app.services.payroll_access import can_write_payroll
from app.services.tplus.business_import import execute_business
from app.services.tplus.workbench import digest
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
from tests.unit.test_tplus_workbench import work_session as work_session
@pytest.fixture
def payroll_case(work_session, monkeypatch):
s = work_session
user = User(username="test_maker", password="test_hash_only", status=1)
s.add(user)
role = BusinessRole(
role_name="经理",
role_code="sales_manager",
calc_template="SALES_MANAGER",
status=1,
sort_order=2,
)
s.add(role)
s.flush()
manager = Personnel(
personnel_name="测试经理", business_role_id=role.id, status=1, source_no="90001"
)
s.add(manager)
s.commit()
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id)
prepare_calc(s)
apply(s, preview(s, ["personnel"]))
product = Product(
product_name="测试产品",
spec_model="测试规格",
unit="件",
product_category="municipal_water",
status=1,
)
customer = Customer(company_name="测试客户", owner_name="负责人", customer_type="personal")
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()
s.add(
TPlusSourceLink(
source_database="test_source",
entity_type="personnel",
source_id="90001",
target_id=manager.id,
)
)
s.commit()
fact(
s,
"PERSONNEL",
"90001",
{
"code": "90001",
"name": manager.personnel_name,
"role_code": "sales_manager",
"status": 1,
},
)
fact(
s,
"MANAGER",
"manager-biz",
{"business_key": "biz", "manager_id": "90001", "effective_date": "2026-03-01"},
)
from app.models import TPlusFact
guidance = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
guidance.data_json = {**guidance.data_json, "is_finalized": True}
s.commit()
return s, user, manager
def business_work(s, user, kind, previous=None):
run = TPlusWorkRun(
run_no=uuid4().hex,
request_key=str(uuid4()),
source_database="test_source",
requested_by=user.id,
kind=kind,
status="RUNNING",
input_json={"preview_no": previous.run_no}
if previous
else {"month": "2026-03", "group_keys": ["g"]},
)
s.add(run)
s.flush()
execute_business(s, run)
s.commit()
return run
def imported(case):
s, user, _manager = case
pre = business_work(s, user, "BUSINESS_PREVIEW")
assert pre.status == "SUCCEEDED", pre.summary_json
assert pre.summary_json["counts"] == {"CREATE": 1}
run = business_work(s, user, "BUSINESS_APPLY", pre)
assert run.status == "SUCCEEDED", run.summary_json
return s.scalar(select(Business))
def test_指定账号而非管理员或岗位放行(payroll_case):
_, user, _ = payroll_case
assert can_write_payroll(user)
assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1))
assert not can_write_payroll(None)
def test_正式导入防重与来源可追溯(payroll_case):
s, user, _ = payroll_case
business = imported(payroll_case)
assert s.scalar(select(func.count()).select_from(Receipt)) == 1
assert s.scalar(select(func.count()).select_from(ShipmentItem)) == 1
again = business_work(s, user, "BUSINESS_PREVIEW")
assert again.summary_json["counts"] == {"SKIP": 1}
business_work(s, user, "BUSINESS_APPLY", again)
assert s.scalar(select(func.count()).select_from(Business)) == 1
record = payroll_calculation.latest_input(s, business.id)
assert record.data_json["source_facts"]
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
s, user, manager = payroll_case
business = imported(payroll_case)
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert result["performance"] == "0.20"
assert result["payable"] == "0.20"
ledger = s.get(PerformanceLedger, result["ledger_ids"][0])
assert ledger.snapshot_json["manager_id"] == manager.id
assert s.scalar(select(PayrollAllocation)).amount == Decimal("100.00")
with pytest.raises(BadRequestError, match="已有核算"):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
def test_输入变化使旧预览失效(payroll_case):
s, user, _ = payroll_case
pre = business_work(s, user, "BUSINESS_PREVIEW")
product = s.scalar(select(Product))
product.product_category = "rail_transit"
s.commit()
with pytest.raises(ValueError, match="PREVIEW_STALE"):
business_work(s, user, "BUSINESS_APPLY", pre)
s.rollback()
assert s.scalar(select(func.count()).select_from(Business)) == 0
def test_经理汇总读取固化业务分管(payroll_case):
s, user, manager = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
rows = list(s.scalars(select(PerformanceLedger)))
owner = s.get(Personnel, rows[0].personnel_id)
owner.manager_id = None
s.commit()
buckets = manager_share.collect_team_buckets(s, rows, False)
assert buckets[manager.id]["performance"] == Decimal("0.20")
def test_生成标记独立于审批状态(payroll_case):
s, user, _ = payroll_case
control = payroll_months.lock_month(s, "2026-03", user)
from datetime import datetime
control.generated_at = datetime(2026, 3, 31)
s.commit()
with pytest.raises(BadRequestError, match="已锁定"):
payroll_months.ensure_allocation_editable(s, "2026-03", user)
s.rollback()
assert payroll_months.ensure_allocation_editable(s, "2026-04", user).generated_at is None
def test_生成失败不留下月份锁(payroll_case):
s, user, _ = payroll_case
from app.services.settlement_service import generate
with pytest.raises(BadRequestError, match="无可结算"):
generate(s, "2026-03", operator=user)
assert s.scalar(select(PayrollMonth)) is None
def test_非制作人无法生成工资(payroll_case):
s, user, _ = payroll_case
with pytest.raises(ForbiddenError):
payroll_months.lock_month(s, "2026-03", SimpleNamespace(id=user.id + 1, status=1))
def replace_input(s, record, values):
newer = PayrollInput(
business_id=record.business_id,
version=record.version + 1,
source_database=record.source_database,
group_key=record.group_key,
fingerprint=digest(values),
data_json=values,
requested_by=record.requested_by,
)
s.add(newer)
s.commit()
return newer
@pytest.mark.parametrize(
"amount,performance,payable,start",
[("50", "0.10", "0.00", False), ("60", "0.12", "0.04", True), ("100", "0.20", "0.20", True)],
)
def test_起付百分之六十与平方权重(payroll_case, amount, performance, payable, start):
s, user, _ = payroll_case
business = imported(payroll_case)
record = payroll_calculation.latest_input(s, business.id)
data = copy.deepcopy(record.data_json)
data["receipts"][0]["amount"] = amount
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal(amount)
replace_input(s, record, data)
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert (result["performance"], result["payable"], result["start_payment_ok"]) == (
performance,
payable,
start,
)
def test_跨月续算仅计应付差额且离职未发给接手人(payroll_case):
s, user, _manager = payroll_case
business = imported(payroll_case)
record = payroll_calculation.latest_input(s, business.id)
data = copy.deepcopy(record.data_json)
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal("80")
data["receipts"][0]["amount"] = "80"
record = replace_input(s, record, data)
march = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert march["payable"] == "0.10"
old = s.get(PerformanceLedger, march["ledger_ids"][0])
new_person = Personnel(
personnel_name="接手业务员",
business_role_id=s.get(Personnel, old.personnel_id).business_role_id,
status=1,
)
s.add(new_person)
s.flush()
second = Receipt(
business_id=business.id,
receipt_batch_no="HK-NEXT",
receipt_date=date(2026, 4, 10),
amount=Decimal("20"),
is_barter=0,
settlement_status=0,
)
s.add(second)
s.flush()
data = copy.deepcopy(record.data_json)
data["receipts"].append(
{
"source_id": "11",
"source_no": "HK-NEXT",
"date": "2026-04-10",
"group_key": "g",
"amount": "20",
"voucher_confirmed": True,
}
)
data["maps"]["receipts"]["11"] = second.id
data["maps"]["personnel"]["next-person"] = new_person.id
data["facts"]["personnel"]["next-person"] = {"role_code": "salesperson", "status": 1}
data["facts"]["handovers"] = [
{
"mode": "RESIGNATION",
"business_key": "biz",
"from_personnel_id": "00043",
"to_personnel_id": "next-person",
"effective_date": "2026-04-01",
}
]
replace_input(s, record, data)
april = payroll_calculation.calculate(s, business.id, "2026-04", operator=user)
assert april["payable"] == "0.10"
assert old.payable_amount == Decimal("0.10") and old.personnel_id != new_person.id
assert all(
s.get(PerformanceLedger, lid).personnel_id == new_person.id for lid in april["ledger_ids"]
)
assert sum(s.scalars(select(PerformanceLedger.payable_amount))) == Decimal("0.20")
rebuilt = payroll_calculation.calculate(
s, business.id, "2026-03", operator=user, reason="复核分次回款"
)
assert rebuilt["recalculated_months"] == ["2026-03", "2026-04"]
active = list(s.scalars(select(PerformanceLedger).where(PerformanceLedger.is_active == 1)))
assert sum(r.payable_amount for r in active) == Decimal("0.20")
assert all(r.calc_reason == "复核分次回款" for r in active)
def test_已核算源金额变化不静默改工资(payroll_case):
s, user, _ = payroll_case
business = imported(payroll_case)
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal("99")
s.commit()
with pytest.raises(BadRequestError, match="不一致"):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
def test_同客户不同合同合并平账_一款对应多发货(payroll_case):
from tests.unit.test_tplus_workbench import source
s, user, _ = payroll_case
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-ONE",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
},
{
"id": 22,
"code": "SH-TWO",
"idcustomer": 8,
"voucherdate": "2026-03-11",
"voucherState": 189,
},
],
)
source(
s,
"shipment_items",
[
{
"id": 21,
"source_parent_id": 20,
"idinventory": 9,
"taxAmount": "100",
"quantity": "2",
},
{
"id": 23,
"source_parent_id": 22,
"idinventory": 9,
"taxAmount": "100",
"quantity": "2",
},
],
)
source(
s,
"receipts",
[
{
"id": 10,
"code": "RC-ONE",
"idpartner": 8,
"voucherDate": "2026-03-12",
"isReceiveFlag": 1,
"voucherstate": 189,
"amount": "200",
}
],
)
fact(
s,
"SHIPMENT",
"23",
{
"sales_amount_excludes_freight": True,
"business_key": "second-contract",
"personnel_id": "00043",
},
)
fact(
s,
"MANAGER",
"second-manager",
{"business_key": "second-contract", "manager_id": "90001", "effective_date": "2026-03-01"},
)
business = imported(payroll_case)
assert s.scalar(select(func.count()).select_from(Business)) == 1
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert len(result["ledger_ids"]) == 2
assert sum(s.scalars(select(PayrollAllocation.amount))) == Decimal("200.00")
record = payroll_calculation.latest_input(s, business.id)
assert record.data_json["group"]["shipment_ids"] == ["20", "22"]
def test_封存发货月指导成本不随后续回款改变(payroll_case):
from app.models import TPlusFact
from app.schemas.tplus_workbench import FactIn
from app.services.tplus import workbench
s, user, _ = payroll_case
cost = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
with pytest.raises(BadRequestError, match="已封存"):
workbench.save_fact(
s,
FactIn(
kind="GUIDANCE",
fact_key=cost.fact_key,
expected_version=cost.version,
evidence="试图修改已封存月份",
data={**cost.data_json, "amount": "30"},
),
user.id,
)
def test_同客户旧项目期初不一致必须先合并事实(payroll_case):
from app.models import TPlusFact
s, user, _ = payroll_case
group = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GROUP")).data_json
fact(s, "GROUP", "old-contract-group", {**group, "opening_balance": "10"})
run = business_work(s, user, "BUSINESS_PREVIEW")
assert run.status == "PARTIAL"
assert run.summary_json["counts"] == {"PENDING": 1}
assert s.scalar(select(func.count()).select_from(Business)) == 0
+3
View File
@@ -12,6 +12,9 @@ from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {
"tplus_automation_states",
"payroll_months", "payroll_inputs", "payroll_allocations",
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
"users",
"roles",
"user_roles",
@@ -0,0 +1,23 @@
"""隔离库校验拒绝开发库、相似前缀及遗漏授权标记。"""
import pytest
from tests.isolation import ensure_test_database
@pytest.mark.parametrize(
"database,expected",
[
("payroll", "payroll"),
("payroll_test_prod", "payroll_test_prod"),
("payroll_wbtest_1234abcd", None),
("payroll_wbtest_1234abcd", "payroll_wbtest_5678abcd"),
],
)
def test_拒绝非隔离目标(database, expected):
with pytest.raises(RuntimeError, match="拒绝清理"):
ensure_test_database(database, expected)
def test_接受显式隔离库():
ensure_test_database("payroll_wbtest_1234abcd", "payroll_wbtest_1234abcd")
+160
View File
@@ -0,0 +1,160 @@
"""周期和自动重试只能生成原始采集,验证重启防重与人工介入边界。"""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import func, select
from app.core.config import Settings
from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError
from app.services.tplus import automation
from tests.unit.test_tplus_workbench import work_session as work_session
NOW = datetime(2026, 10, 30, 10)
@pytest.fixture
def settings():
return Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit",
t_plus_automation_enabled=True)
def run(s, settings, now=NOW):
return automation.tick(s, settings=settings, now=now)
def fail(s, number, *, status="FAILED", code="SOURCE_TIMEOUT", at=NOW):
row = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
row.status, row.finished_at = status, at
s.add(TPlusSyncError(batch_no=number, entity_type="system", source_table="system", error_code=code, error_message="固定错误说明"))
s.commit()
def test_默认关闭不会排队(work_session, settings):
result = run(work_session, settings.model_copy(update={"t_plus_automation_enabled": False}))
assert result == {"status": "DISABLED", "queued": False}
assert work_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_同周期重复检查和重启只有一次九类采集(work_session, settings):
s = work_session
first = run(s, settings)
for _ in range(3):
assert not run(s, settings)["queued"]
batch = s.scalar(select(TPlusSyncBatch))
assert batch.batch_no == first["batch_no"] and batch.sync_mode == "FULL"
assert len(batch.entity_types_json) == 9 and batch.trigger_type == "SCHEDULED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_停机多周只补最近一次(work_session, settings):
s = work_session
first = run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.status, batch.finished_at = "SUCCEEDED", NOW
s.commit()
later = run(s, settings, NOW + timedelta(weeks=4))
assert later["queued"] and later["period"] != first["period"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 2
def test_人工任务占用时不堆积周期任务(work_session, settings):
s = work_session
s.add(TPlusSyncBatch(batch_no="manual", sync_mode="FULL", status="RUNNING", source_database="test_source", entity_types_json=["personnel"]))
s.commit()
assert run(s, settings)["status"] == "DEFERRED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_明确超时仅按五十五分钟最多重试两次(work_session, settings):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
assert not run(s, settings, NOW + timedelta(minutes=4))["queued"]
second = run(s, settings, NOW + timedelta(minutes=5))
assert second["queued"]
fail(s, second["batch_no"], at=NOW + timedelta(minutes=5))
assert not run(s, settings, NOW + timedelta(minutes=19))["queued"]
third = run(s, settings, NOW + timedelta(minutes=20))
assert third["queued"]
fail(s, third["batch_no"], at=NOW + timedelta(minutes=21))
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
rows = list(s.scalars(select(TPlusSyncBatch).order_by(TPlusSyncBatch.id)))
assert [(r.trigger_type, r.automatic_attempt) for r in rows] == [("SCHEDULED", 0), ("AUTO_RETRY", 1), ("AUTO_RETRY", 2)]
assert rows[1].parent_batch_no == rows[0].batch_no and rows[2].parent_batch_no == rows[1].batch_no
@pytest.mark.parametrize("status,code", [("PARTIAL", "SOURCE_TIMEOUT"), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "STAGE_ERROR"), ("FAILED", "EXECUTION_ERROR")])
def test_部分失败或不明确错误不自动重试(work_session, settings, status, code):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"], status=status, code=code)
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_ISO年周和到期时间边界稳定():
assert automation.due_period(datetime(2027, 1, 1, 10))[0] == "2026-W53"
assert automation.due_period(datetime(2026, 11, 2, 1, 59))[0] == "2026-W44"
assert automation.due_period(datetime(2026, 11, 2, 2))[0] == "2026-W45"
def test_站内提示覆盖排队超时和无成功同步(work_session, settings):
s = work_session
run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.create_time = NOW - timedelta(minutes=16)
s.commit()
result = automation.overview(s, settings=settings, now=NOW)
assert {r["code"] for r in result["alerts"]} >= {"QUEUE_OVERDUE", "SYNC_STALE", "WORKER_OFFLINE"}
def test_读取心跳不把当前采集写成工资(work_session, settings):
from sqlalchemy.orm import sessionmaker
s = work_session
automation.heartbeat(sessionmaker(s.get_bind()), settings, "owner", "RUNNING", now=NOW)
s.expire_all()
state = s.scalar(select(TPlusAutomationState))
assert state.heartbeat_at == NOW and state.worker_phase == "RUNNING"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
@pytest.mark.parametrize("status,code", [("SUCCEEDED", None), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "SOURCE_TIMEOUT")])
def test_人工重试结果接入周期且不重置自动次数(work_session, settings, monkeypatch, status, code):
from uuid import uuid4
from app.schemas.tplus import TPlusRetryIn
from app.services.tplus import task_service
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
auto = run(s, settings, NOW + timedelta(minutes=5))
fail(s, auto["batch_no"], at=NOW + timedelta(minutes=5))
manual = task_service.retry_task(s, auto["batch_no"], TPlusRetryIn(request_key=uuid4()))
assert run(s, settings, NOW + timedelta(minutes=6))["status"] == "DEFERRED"
if code:
fail(s, manual.batch_no, status=status, code=code, at=NOW + timedelta(minutes=6))
else:
manual.status, manual.finished_at = status, NOW + timedelta(minutes=6)
s.commit()
outcome = run(s, settings, NOW + timedelta(minutes=30))
assert outcome["queued"] == (code == "SOURCE_TIMEOUT")
state = s.scalar(select(TPlusAutomationState))
assert state.retry_attempts == (2 if outcome["queued"] else 1)
if status == "SUCCEEDED":
assert state.last_success_at == manual.finished_at
if outcome["queued"]:
child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome["batch_no"]))
assert child.parent_batch_no == manual.batch_no
def test_停止标记阻止周期排队且不报告进程离线(work_session, settings, tmp_path):
marker = tmp_path / "stop.requested"
marker.touch()
stopped = settings.model_copy(update={"t_plus_stop_file": str(marker)})
assert run(work_session, stopped)["status"] == "STOP_REQUESTED"
assert not automation.overview(work_session, settings=stopped, now=NOW)["alerts"]
+1 -1
View File
@@ -79,7 +79,7 @@ def test_T加查询文件覆盖六类实体() -> None:
def test_T加高风险映射保持待确认() -> None:
assert get_mapping("businesses").status is MappingStatus.PENDING
assert "成本口径" in get_mapping("businesses").unresolved
assert "指导价取值时点" in get_mapping("businesses").unresolved
def test_未知T加实体被拒绝() -> None:
@@ -0,0 +1,15 @@
"""只接受明确驱动状态,不把含密码的未知异常当作可重试错误。"""
import pytest
from app.services.tplus.errors import classify_source_error
@pytest.mark.parametrize("state,expected", [("08S01", "SOURCE_CONNECTION_LOST"), ("HYT00", "SOURCE_TIMEOUT"), ("28000", "SOURCE_AUTH_ERROR"), ("42S22", "SOURCE_SCHEMA_ERROR"), ("42000", "READ_ERROR")])
def test_源错误分类只依赖固定状态(state, expected):
assert classify_source_error(Exception(state, "password=不可回显")) == expected
def test_未知异常不宽泛自动重试():
assert classify_source_error(RuntimeError("secret=不可回显")) == "READ_ERROR"
assert classify_source_error(TimeoutError()) == "SOURCE_TIMEOUT"
+252
View File
@@ -0,0 +1,252 @@
"""预演只分配资金,覆盖先旧后新、预收与缺条件的核算边界。"""
from datetime import date
from decimal import Decimal
import pytest
from app.services.tplus.fifo import money, preview_group
def inputs():
"""明确口径的简单业务,测试不依赖真实客户数据。"""
group = {
"key": "g",
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
}
ship = {
"source_id": "1",
"line_id": "1",
"date": "2026-03-10",
"group_key": "g",
"amount": "100",
"cost_total": "50",
"category": "municipal_water",
"personnel_id": "001",
"customer_type": "personal",
"business_key": "business1",
}
cash = {
"source_id": "2",
"date": "2026-03-10",
"group_key": "g",
"amount": "100",
"voucher_confirmed": True,
}
facts = {"personnel": {"001": {"role_code": "salesperson"}}}
params = {
"2026-03-10": {
"thresholds": {"municipal_water": "30"},
"start": "10",
"slope": "4/360",
"floor": "4",
"boundary": 360,
"enterprise": "3",
}
}
return group, ship, cash, facts, params
def run(group, ships, cash, facts, params, until=date(2026, 3, 31)):
return preview_group(group, ships, cash, facts, params, until)
def test_跨月预演区分累计与当月计提():
g, s, c, f, p = inputs()
result = run(g, [s], [c], f, p, date(2026, 4, 30))
assert Decimal(result["performance_total"]) > 0
assert result["month_performance_total"] == "0.00"
assert result["lines"][0]["shipment_remaining_after"] == "0.00"
def test_经理本人业务交接后仍排除():
g, s, c, f, p = inputs()
f["personnel"]["001"]["role_code"] = "sales_manager"
f["personnel"]["002"] = {"role_code": "salesperson"}
f["handovers"] = [
{
"business_key": "business1",
"mode": "RESIGNATION",
"effective_date": "2026-03-15",
"from_personnel_id": "001",
"to_personnel_id": "002",
}
]
line = run(g, [s], [c], f, p)["lines"][0]
assert line["excluded_manager_own"] and line["performance"] == "0.00"
def test_未知运费影响同组顺序时不猜零费用():
g, s, c, f, p = inputs()
unknown = {**s, "pending": ["FREIGHT_PENDING"]}
result = run(g, [unknown, {**s, "source_id": "2"}], [c], f, p)
assert result["status"] == "PENDING"
assert result["lines"] == [] and result["reasons"] == ["FREIGHT_PENDING"]
def test_范围外的未来负向单据不阻塞本月():
g, s, c, f, p = inputs()
future = {**s, "source_id": "99", "date": "2026-05-01", "amount": "-100"}
result = run(g, [s, future], [c], f, p)
assert result["status"] == "SUCCEEDED"
assert result["receipt_total"] == "100.00"
def test_一款多发货金额守恒且同日按源id排序():
g, s, c, f, p = inputs()
result = run(
g,
[
{**s, "source_id": "9", "amount": "60", "cost_total": "30"},
{**s, "source_id": "3", "amount": "60", "cost_total": "30"},
],
[c],
f,
p,
)
assert [r["shipment_id"] for r in result["lines"]] == ["3", "9"]
assert result["receipt_total"] == result["allocated_total"] == "100.00"
assert result["shipment_remaining"] == "20.00"
def test_多款一发货和成本分摊尾差():
g, s, c, f, p = inputs()
s = {**s, "amount": "3", "cost_total": "1"}
cash = [{**c, "source_id": str(i), "amount": "1"} for i in range(3)]
result = run(g, [s], cash, f, p)
assert sum(Decimal(r["allocated_cost"]) for r in result["lines"]) == Decimal("1")
assert result["shipment_remaining"] == "0.00"
def test_旧应收优先冲平且不算新绩效():
g, s, c, f, p = inputs()
g["opening_balance"] = "70"
result = run(g, [s], [c], f, p)
assert result["lines"][0]["kind"] == "OPENING"
assert result["lines"][0]["performance"] is None
assert result["lines"][1]["amount"] == "30.00"
assert result["opening_remaining"] == "0.00"
def test_明确未生成凭证的款排除且不阻塞合格回款():
g, s, c, f, p = inputs()
excluded = {
**c,
"source_id": "99",
"amount": "45",
"voucher_confirmed": False,
"exclusion": "NOT_GENERATED_VOUCHER",
}
result = run(g, [s], [excluded, c], f, p)
assert result["status"] == "SUCCEEDED"
assert result["receipt_total"] == result["allocated_total"] == "100.00"
assert result["excluded_receipt_total"] == "45.00"
line = next(row for row in result["lines"] if row["kind"] == "EXCLUDED")
assert line["performance"] is None and line["reasons"] == ["NOT_GENERATED_VOUCHER"]
def test_离职人员缺交接依据不猜绩效归属():
g, s, c, f, p = inputs()
f["personnel"]["001"]["status"] = 0
line = run(g, [s], [c], f, p)["lines"][0]
assert line["status"] == "PENDING" and line["performance"] is None
assert "HANDOVER_PENDING" in line["reasons"]
def test_预收参数取值时点不明确时不猜测():
g, s, c, f, p = inputs()
c["date"] = "2026-03-01"
line = run(g, [s], [c], f, p)["lines"][0]
assert line["status"] == "PENDING"
assert "PREPAYMENT_PARAMETERS_PENDING" in line["reasons"]
def test_预收未发货不计提_发货后最高比例():
g, s, c, f, p = inputs()
c["date"] = "2026-03-01"
p["prepayment_parameter_basis"] = "SHIPMENT"
before = run(g, [s], [c], f, p, date(2026, 3, 5))
assert before["prepayment_remaining"] == "100.00"
after = run(g, [s], [c], f, p)
assert after["lines"][0]["rate"] == "1/100"
assert after["lines"][0]["effective_date"] == "2026-03-10"
@pytest.mark.parametrize(
"missing,reason", [("cost_total", "GUIDANCE_PENDING"), ("category", "PRODUCT_CATEGORY_PENDING")]
)
def test_缺必要字段不按零计算(missing, reason):
g, s, c, f, p = inputs()
s.pop(missing)
result = run(g, [s], [c], f, p)
assert result["lines"][0]["performance"] is None
assert reason in result["lines"][0]["reasons"]
def test_凭证状态未确认阻止本组分配():
g, s, c, f, p = inputs()
c["voucher_confirmed"] = False
assert run(g, [s], [c], f, p)["reasons"] == ["VOUCHER_STATE_PENDING"]
def test_经理本人业务排除():
g, s, c, f, p = inputs()
f["personnel"]["001"]["role_code"] = "sales_manager"
assert run(g, [s], [c], f, p)["lines"][0]["performance"] == "0.00"
def test_负单据不进入普通fifo():
g, s, c, f, p = inputs()
c["amount"] = "-1"
assert run(g, [s], [c], f, p)["reasons"] == ["NEGATIVE_OR_ZERO_DOCUMENT"]
def test_已核对更正发货保留源记录但排除绩效():
g, s, c, f, p = inputs()
correction = {
**s,
"source_id": "99",
"amount": "-100",
"performance_excluded": True,
"exclusion_reason": "CORRECTION_EXCLUDED",
"correction_pair_key": "pair-1",
}
result = run(g, [correction, s], [c], f, p)
excluded = next(row for row in result["lines"] if row["kind"] == "EXCLUDED")
assert result["status"] == "SUCCEEDED"
assert result["excluded_shipment_total"] == "-100.00"
assert excluded["reasons"] == ["CORRECTION_EXCLUDED"]
assert result["performance_total"] != "0.00"
def test_禁止跨组及重复输入():
g, s, c, f, p = inputs()
with pytest.raises(ValueError, match="跨"):
run(g, [s], [{**c, "group_key": "other"}], f, p)
with pytest.raises(ValueError, match="重复"):
run(g, [s, s], [c], f, p)
def test_离职交接转给新人员_在职交接保留发货人():
g, s, c, f, p = inputs()
f["personnel"]["002"] = {"role_code": "salesperson"}
handover = {
"business_key": "business1",
"mode": "RESIGNATION",
"effective_date": "2026-03-05",
"from_personnel_id": "001",
"to_personnel_id": "002",
}
f["handovers"] = [handover]
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002"
handover["mode"] = "ACTIVE"
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001"
def test_输入金额拒绝浮点与无穷():
for value in [1.1, "NaN", "Infinity"]:
with pytest.raises(ValueError):
money(value)
@@ -0,0 +1,40 @@
"""恢复库核对包不能用缺记录证明零运费、已发绩效或已确认零期初。"""
from app.services.tplus.materials import QUERIES, render_markdown, summarize
def records():
data = {name: [] for name in QUERIES}
data["customer"] = [{"id": 9395, "code": "0060201", "name": "测试客户", "customerType": None}]
return data
def test_缺初始化记录只形成候选余额():
report = summarize(records(), 9395, "2026-03")
assert report["opening_candidate"] == "0.00" and not report["opening_confirmed"]
assert not report["ready_for_amount"] and not report["formal_payroll_written"]
assert {m["key"] for m in report["missing"]} >= {"GUIDANCE", "FREIGHT", "HISTORICAL_PAYROLL"}
def test_2017初始化不直接当作切换余额():
data = records()
data["opening"] = [{"origDate": "2017-05-31", "amount": "10000"}]
report = summarize(data, 9395, "2026-03")
assert report["opening_candidate"] is None
assert "2026-02-28" in report["opening_explanation"]
def test_超过范围明确标记核对包不完整():
data = records()
data["items"] = [{"id": i} for i in range(201)]
report = summarize(data, 9395, "2026-03")
assert report["truncated"] == ["items"]
assert len(report["evidence"]["items"]) == 200
def test_报告明确待补不制造实际金额():
report = summarize(records(), 9395, "2026-03")
report["evidence_hash"] = "test"
output = render_markdown(report)
assert "候选期初余额:0.00" in output
assert "实际工资金额尚未验收" in output
@@ -80,7 +80,8 @@ def test_多订单和成本日期仍需业务确认不自动选择():
result = _audit().run()
assert result["order_coverage"]["multiple_orders"] == 2
keys = {item["key"] for item in result["pending_confirmations"]}
assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys
assert {"cost_basis", "voucher_state", "opening_balance", "allocation"} <= keys
assert not {"receipt_date", "ship_date"} & keys
def test_SQL查询有固定白名单且不接受自定义表名():
+16
View File
@@ -108,3 +108,19 @@ def test_未确认时间增量不能创建误导性批次(target):
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_已读数据后明确超时仍标记可重试失败(target):
service = _service(target, [])
def source(entity, **kwargs):
if entity == "products":
return iter([{"id": 1}])
raise TimeoutError("敏感驱动信息不得回显")
service.reader.iter_entity.side_effect = source
batch = service.run(entity_types=["products", "customers", "personnel"])
assert batch.status == "FAILED"
assert batch.staged_record_count == 1
assert service.reader.iter_entity.call_count == 2
assert target.scalar(select(TPlusSyncError.error_code)) == "SOURCE_TIMEOUT"
+822
View File
@@ -0,0 +1,822 @@
"""真实事务验证导入、资料版本和预演隔离;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
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:
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):
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",
},
)
@@ -0,0 +1,35 @@
"""停止标记控制真实CLI领取边界,不中断正在完成的单次处理。"""
from argparse import Namespace
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app import cli
from app.core.config import Settings
@pytest.mark.parametrize("already_stopped", [False, True])
def test_停止后不再领取下一任务(monkeypatch, tmp_path, already_stopped):
marker = tmp_path / "stop.requested"
if already_stopped:
marker.touch()
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(cli, "get_settings", lambda: settings)
monkeypatch.setattr(cli, "get_session_factory", lambda: lambda: nullcontext(object()))
def finish(_session, **_kwargs):
marker.touch()
return "completed"
consume = Mock(side_effect=finish)
pulse = Mock()
monkeypatch.setattr(cli, "run_once", consume)
monkeypatch.setattr(cli, "get_batch", lambda *_: SimpleNamespace(status="SUCCEEDED"))
monkeypatch.setattr("app.services.tplus.automation.heartbeat", pulse)
result = cli._tplus_worker(Namespace(connection_file=None, poll_seconds=2, watch=True, stop_file=str(marker)))
assert result == 0
assert consume.call_count == (0 if already_stopped else 1)
assert pulse.call_args.args[3] == "STOPPED"