feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
1 parent
1b074576fe
commit
108bf87225
154 files changed
+9515
-645
No files matched your search
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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查询有固定白名单且不接受自定义表名():
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user