"""真实事务验证导入、资料版本和预演隔离;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", }, )