2026-11-01 20:42:53 -05:00
|
|
|
|
"""真实事务验证导入、资料版本和预演隔离;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,
|
|
|
|
|
|
)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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:
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.models import (
|
|
|
|
|
|
AuthorizationSetting,
|
|
|
|
|
|
Permission,
|
|
|
|
|
|
Role,
|
|
|
|
|
|
RolePermission,
|
|
|
|
|
|
User,
|
|
|
|
|
|
UserRole,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.permission_catalog import CATALOG, ROLE_DEFAULTS
|
|
|
|
|
|
|
|
|
|
|
|
session.add(AuthorizationSetting(id=1, revision=1))
|
|
|
|
|
|
actor = User(id=1, username="test_operator", password="test_hash_only", status=1)
|
|
|
|
|
|
session.add(actor)
|
|
|
|
|
|
permission_rows = {code: Permission(perm_code=code, perm_name=code, perm_type=2, status=1)
|
|
|
|
|
|
for code in CATALOG}
|
|
|
|
|
|
session.add_all(permission_rows.values())
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
for code, grants in ROLE_DEFAULTS.items():
|
|
|
|
|
|
role = Role(role_code=code, role_name=code, status=1)
|
|
|
|
|
|
session.add(role)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
session.add_all([RolePermission(role_id=role.id, permission_id=permission_rows[p].id) for p in grants])
|
|
|
|
|
|
if code == "admin":
|
|
|
|
|
|
actor.user_roles.append(UserRole(role_id=role.id))
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch):
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
from app.services.tplus import task_service
|
|
|
|
|
|
|
|
|
|
|
|
s = work_session
|
|
|
|
|
|
prepare_person(s)
|
|
|
|
|
|
run = preview(s, ["personnel"])
|
|
|
|
|
|
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
|
|
|
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
|
|
|
|
|
|
|
|
|
|
def disabled_source():
|
|
|
|
|
|
raise RuntimeError("T+ 数据源未启用")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(workbench, "_source_database", disabled_source)
|
|
|
|
|
|
facts = workbench.list_facts(s, WorkQuery())
|
|
|
|
|
|
assert facts.total == 1
|
|
|
|
|
|
assert workbench.list_work(s, WorkQuery()).total == 1
|
|
|
|
|
|
assert workbench.get_run(s, run.run_no).run_no == run.run_no
|
|
|
|
|
|
assert workbench.fact_history(s, facts.records[0]["id"], WorkQuery()).total == 1
|
|
|
|
|
|
assert workbench.list_lines(s, run.run_no, WorkQuery()).total == 1
|
|
|
|
|
|
assert "仅供核对" in "".join(workbench.report_csv(s, run.run_no))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_关闭源读取有多个账套时拒绝猜测(work_session, monkeypatch):
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
from app.core.exceptions import BadRequestError
|
|
|
|
|
|
from app.services.tplus import task_service
|
|
|
|
|
|
|
|
|
|
|
|
prepare_person(work_session)
|
|
|
|
|
|
work_session.add(TPlusFact(
|
|
|
|
|
|
source_database="another_source", kind="PRODUCT", fact_key="9", version=1,
|
|
|
|
|
|
data_json={"category": "municipal_water", "unit": "件"},
|
|
|
|
|
|
evidence="其他账套测试", requested_by=1,
|
|
|
|
|
|
))
|
|
|
|
|
|
work_session.commit()
|
|
|
|
|
|
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
|
|
|
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
|
|
|
|
with pytest.raises(BadRequestError, match="多个账套"):
|
|
|
|
|
|
workbench.list_facts(work_session, WorkQuery())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_历史查询显式账套范围不能串账(work_session, monkeypatch):
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
from app.services.tplus import task_service
|
|
|
|
|
|
|
|
|
|
|
|
prepare_person(work_session)
|
|
|
|
|
|
settings = Settings(
|
|
|
|
|
|
_env_file=None, app_secret_key="x" * 32, t_plus_database="another_source"
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
|
|
|
|
assert workbench.list_facts(work_session, WorkQuery()).total == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_关闭读取时概况仍显示最近成功采集(work_session, monkeypatch):
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
from app.services.tplus import query_service, task_service
|
|
|
|
|
|
|
|
|
|
|
|
prepare_person(work_session)
|
|
|
|
|
|
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
|
|
|
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
|
|
|
|
overview = query_service.overview(work_session)
|
|
|
|
|
|
assert overview.is_configured is False
|
|
|
|
|
|
assert "仍可查看" in overview.configuration_message
|
|
|
|
|
|
assert overview.automation.enabled is False
|
|
|
|
|
|
assert overview.automation.last_success_at is not None
|
|
|
|
|
|
assert overview.total_staged_records >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_关闭SQL源读取仍可维护已存账套补充资料(work_session, monkeypatch):
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
from app.core.exceptions import BadRequestError
|
|
|
|
|
|
from app.services.tplus import task_service
|
|
|
|
|
|
|
|
|
|
|
|
prepare_person(work_session)
|
|
|
|
|
|
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
|
|
|
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
|
|
|
|
def disabled_source():
|
|
|
|
|
|
raise BadRequestError("T+ 数据源未启用")
|
|
|
|
|
|
monkeypatch.setattr(workbench, "_source_database", disabled_source)
|
|
|
|
|
|
data = fact(work_session, "PRODUCT", "9", {"category":"municipal_water", "unit":"件"})
|
|
|
|
|
|
assert data["version"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|