补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
823 lines
26 KiB
Python
823 lines
26 KiB
Python
"""真实事务验证导入、资料版本和预演隔离;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",
|
||
},
|
||
)
|