feat(ai): add expense application feedback ledger
This commit is contained in:
@@ -9,19 +9,21 @@ import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine, make_url
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from alembic import command
|
||||
from app.core.config import get_settings
|
||||
from app.db.migration_preflight import MigrationPreflightError, validate_migration_state
|
||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES
|
||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260713_0002"
|
||||
HEAD_REVISION = "20260714_0003"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
|
||||
def _normalize_probe_component(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
|
||||
@@ -143,6 +145,25 @@ def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
|
||||
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "CASCADE"
|
||||
|
||||
|
||||
def _assert_composite_foreign_key(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
constrained_columns: tuple[str, ...],
|
||||
referred_table: str,
|
||||
referred_columns: tuple[str, ...] = ("tenant_id", "id"),
|
||||
) -> None:
|
||||
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
|
||||
matching = [
|
||||
item
|
||||
for item in foreign_keys
|
||||
if tuple(item["constrained_columns"]) == constrained_columns
|
||||
and item["referred_table"] == referred_table
|
||||
and tuple(item["referred_columns"]) == referred_columns
|
||||
]
|
||||
assert len(matching) == 1
|
||||
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "RESTRICT"
|
||||
|
||||
|
||||
def _assert_head_schema(engine: Engine) -> None:
|
||||
names = _table_names(engine)
|
||||
assert MIGRATION_OWNED_TABLES.issubset(names)
|
||||
@@ -163,6 +184,12 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"uq_expense_case_links_resource",
|
||||
("resource_type", "resource_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"business_events",
|
||||
"uq_business_events_tenant_case_id",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"business_events",
|
||||
@@ -175,6 +202,30 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"uq_auth_sessions_token_hash",
|
||||
("token_hash",),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"ai_decisions",
|
||||
"uq_ai_decisions_tenant_case_id",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"ai_decisions",
|
||||
"uq_ai_decisions_tenant_idempotency",
|
||||
("tenant_id", "idempotency_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"ai_decision_feedback",
|
||||
"uq_ai_decision_feedback_tenant_idempotency",
|
||||
("tenant_id", "idempotency_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
"uq_workflow_outcomes_tenant_idempotency",
|
||||
("tenant_id", "idempotency_key"),
|
||||
)
|
||||
|
||||
_assert_indexes(
|
||||
engine,
|
||||
@@ -214,8 +265,85 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"ai_decisions",
|
||||
{
|
||||
"ix_ai_decisions_tenant_case_time": (
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"created_at",
|
||||
),
|
||||
"ix_ai_decisions_tenant_subject": (
|
||||
"tenant_id",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"ai_decision_feedback",
|
||||
{
|
||||
"ix_ai_decision_feedback_tenant_decision_time": (
|
||||
"tenant_id",
|
||||
"decision_id",
|
||||
"created_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
{
|
||||
"ix_workflow_outcomes_tenant_case_time": (
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"effective_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_cascade_foreign_key(engine, "expense_case_links")
|
||||
_assert_cascade_foreign_key(engine, "business_events")
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"ai_decisions",
|
||||
("tenant_id", "expense_case_id"),
|
||||
"expense_cases",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"ai_decisions",
|
||||
("tenant_id", "expense_case_id", "business_event_id"),
|
||||
"business_events",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"ai_decision_feedback",
|
||||
("tenant_id", "decision_id"),
|
||||
"ai_decisions",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
("tenant_id", "expense_case_id"),
|
||||
"expense_cases",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
("tenant_id", "expense_case_id", "decision_id"),
|
||||
"ai_decisions",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
("tenant_id", "expense_case_id", "business_event_id"),
|
||||
"business_events",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
|
||||
|
||||
def _assert_runtime_cascade(engine: Engine) -> None:
|
||||
@@ -271,6 +399,194 @@ def _assert_runtime_cascade(engine: Engine) -> None:
|
||||
) == 0
|
||||
|
||||
|
||||
def _assert_learning_ledger_tenant_boundary(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO expense_cases (
|
||||
id, tenant_id, case_no, scene_code, title, current_stage, status
|
||||
) VALUES
|
||||
(
|
||||
'learning-probe-case', 'learning-probe', 'CASE-LEARNING-PROBE',
|
||||
'travel', '学习闭环迁移验证', 'claiming', 'active'
|
||||
),
|
||||
(
|
||||
'learning-probe-case-b', 'learning-probe', 'CASE-LEARNING-PROBE-B',
|
||||
'travel', '学习闭环同租户第二费用单', 'claiming', 'active'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO business_events (
|
||||
id, tenant_id, expense_case_id, aggregate_type, aggregate_id,
|
||||
event_type, event_version, idempotency_key, correlation_id,
|
||||
actor_id, actor_type, payload_json, delivery_status, delivery_attempts
|
||||
) VALUES (
|
||||
'learning-probe-event', 'learning-probe', 'learning-probe-case',
|
||||
'expense_claim', 'learning-probe-claim', 'claim_draft_created', 1,
|
||||
'learning-probe-event-key', 'learning-probe-correlation',
|
||||
'learning-probe-user', 'user', '{}', 'pending', 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_decisions (
|
||||
id, tenant_id, expense_case_id, business_event_id,
|
||||
expense_claim_id, agent_run_id, correlation_id, subject_type,
|
||||
subject_id, decision_type, decision_source, status,
|
||||
automation_mode, confidence, suggestion_json, evidence_json,
|
||||
version_json, schema_version, idempotency_key, content_fingerprint
|
||||
) VALUES (
|
||||
'learning-probe-decision', 'learning-probe', 'learning-probe-case',
|
||||
'learning-probe-event', 'learning-probe-claim', NULL,
|
||||
'learning-probe-correlation', 'expense_claim', 'learning-probe-claim',
|
||||
'expense_application_prefill', 'hybrid', 'executed', 'prefill', 0.9,
|
||||
'{}', '{}', '{}', 1, 'learning-probe-decision-key',
|
||||
'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_decision_feedback (
|
||||
id, tenant_id, decision_id, expense_claim_id, correlation_id,
|
||||
feedback_type, action_type, actor_id, actor_type, evidence_source,
|
||||
verification_status, final_value_json, changed_fields_json, idempotency_key,
|
||||
content_fingerprint
|
||||
) VALUES (
|
||||
'learning-probe-feedback', 'learning-probe', 'learning-probe-decision',
|
||||
'learning-probe-claim', 'learning-probe-correlation', 'accepted',
|
||||
'save_draft', 'learning-probe-user', 'user',
|
||||
'client_action_confirmation', 'client_observed', '{}', '[]',
|
||||
'learning-probe-feedback-key',
|
||||
'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO workflow_outcomes (
|
||||
id, tenant_id, expense_case_id, decision_id, business_event_id,
|
||||
expense_claim_id, correlation_id, outcome_type, outcome_status,
|
||||
actor_id, actor_type, result_json, idempotency_key,
|
||||
content_fingerprint
|
||||
) VALUES (
|
||||
'learning-probe-outcome', 'learning-probe', 'learning-probe-case',
|
||||
'learning-probe-decision', 'learning-probe-event',
|
||||
'learning-probe-claim', 'learning-probe-correlation', 'draft_saved',
|
||||
'recorded', 'learning-probe-user', 'user', '{}',
|
||||
'learning-probe-outcome-key',
|
||||
'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_decisions (
|
||||
id, tenant_id, expense_case_id, expense_claim_id,
|
||||
correlation_id, subject_type, subject_id, decision_type,
|
||||
decision_source, status, automation_mode, suggestion_json,
|
||||
evidence_json, version_json, schema_version, idempotency_key,
|
||||
content_fingerprint
|
||||
) VALUES (
|
||||
'cross-tenant-decision', 'other-tenant', 'learning-probe-case',
|
||||
'cross-tenant-claim', 'cross-tenant-correlation', 'expense_claim',
|
||||
'cross-tenant-claim', 'expense_application_prefill', 'heuristic',
|
||||
'executed', 'prefill', '{}', '{}', '{}', 1,
|
||||
'cross-tenant-key',
|
||||
'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_decision_feedback (
|
||||
id, tenant_id, decision_id, expense_claim_id, correlation_id,
|
||||
feedback_type, action_type, actor_id, actor_type, evidence_source,
|
||||
verification_status, training_eligible, final_value_json,
|
||||
changed_fields_json, idempotency_key, content_fingerprint
|
||||
) VALUES (
|
||||
'unverified-training-feedback', 'learning-probe',
|
||||
'learning-probe-decision', 'learning-probe-claim',
|
||||
'unverified-training-correlation', 'accepted', 'save_draft',
|
||||
'learning-probe-user', 'user', 'client_action_confirmation',
|
||||
'client_observed', TRUE, '{}', '[]',
|
||||
'unverified-training-feedback-key',
|
||||
'sha256:9999999999999999999999999999999999999999999999999999999999999999'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_decisions (
|
||||
id, tenant_id, expense_case_id, business_event_id,
|
||||
expense_claim_id, correlation_id, subject_type, subject_id,
|
||||
decision_type, decision_source, status, automation_mode,
|
||||
suggestion_json, evidence_json, version_json, schema_version,
|
||||
idempotency_key, content_fingerprint
|
||||
) VALUES (
|
||||
'cross-case-event-decision', 'learning-probe',
|
||||
'learning-probe-case-b', 'learning-probe-event',
|
||||
'cross-case-event-claim', 'cross-case-event-correlation',
|
||||
'expense_claim', 'cross-case-event-claim',
|
||||
'expense_application_prefill', 'heuristic', 'executed',
|
||||
'prefill', '{}', '{}', '{}', 1,
|
||||
'cross-case-event-key',
|
||||
'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO workflow_outcomes (
|
||||
id, tenant_id, expense_case_id, decision_id,
|
||||
expense_claim_id, correlation_id, outcome_type, outcome_status,
|
||||
actor_id, actor_type, result_json, idempotency_key,
|
||||
content_fingerprint
|
||||
) VALUES (
|
||||
'cross-case-decision-outcome', 'learning-probe',
|
||||
'learning-probe-case-b', 'learning-probe-decision',
|
||||
'cross-case-decision-claim', 'cross-case-decision-correlation',
|
||||
'draft_saved', 'recorded', 'learning-probe-user', 'user', '{}',
|
||||
'cross-case-decision-key',
|
||||
'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _create_legacy_sentinel(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
@@ -337,9 +653,12 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
||||
create_legacy_schema(engine)
|
||||
assert "expense_claims" in _table_names(engine)
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
_assert_learning_ledger_tenant_boundary(engine)
|
||||
_assert_runtime_cascade(engine)
|
||||
|
||||
_create_legacy_sentinel(engine)
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"owned_table",
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"]),
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]),
|
||||
)
|
||||
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
|
||||
engine: Engine,
|
||||
@@ -87,6 +87,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
"20260713_0002",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] - {"auth_sessions"},
|
||||
),
|
||||
(
|
||||
"20260714_0003",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
@@ -982,6 +983,23 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
||||
assert event.expense_case_id == case_link.expense_case_id
|
||||
assert event.payload_json["previous_status"] == "draft"
|
||||
assert event.payload_json["next_status"] == "submitted"
|
||||
decision = db.scalar(select(AIDecision).where(AIDecision.expense_claim_id == claim.id))
|
||||
feedback = db.scalar(
|
||||
select(AIDecisionFeedback).where(AIDecisionFeedback.expense_claim_id == claim.id)
|
||||
)
|
||||
outcome = db.scalar(
|
||||
select(WorkflowOutcome).where(WorkflowOutcome.expense_claim_id == claim.id)
|
||||
)
|
||||
assert decision is not None
|
||||
assert decision.expense_case_id == case_link.expense_case_id
|
||||
assert decision.correlation_id == event.correlation_id
|
||||
assert decision.business_event_id == event.id
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "accepted"
|
||||
assert feedback.action_type == "submit"
|
||||
assert outcome is not None
|
||||
assert outcome.outcome_type == "application_submitted"
|
||||
assert outcome.business_event_id == event.id
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_submission"
|
||||
@@ -1033,6 +1051,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
|
||||
assert list(db.scalars(select(BudgetTransaction)).all()) == []
|
||||
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
|
||||
assert list(db.scalars(select(BusinessEvent)).all()) == []
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||
|
||||
|
||||
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
||||
|
||||
@@ -16,10 +16,13 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
|
||||
assert MIGRATION_OWNED_TABLES == frozenset(
|
||||
{
|
||||
"ai_decision_feedback",
|
||||
"ai_decisions",
|
||||
"auth_sessions",
|
||||
"business_events",
|
||||
"expense_case_links",
|
||||
"expense_cases",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
)
|
||||
assert table_names
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
@@ -28,7 +29,12 @@ def build_session_factory() -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentRequest:
|
||||
def build_request(
|
||||
*,
|
||||
run_id: str,
|
||||
tenant_id: str = "tenant-a",
|
||||
changed_fields: list[dict[str, str]] | None = None,
|
||||
) -> UserAgentRequest:
|
||||
return UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id="owner@example.com",
|
||||
@@ -40,6 +46,20 @@ def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentReque
|
||||
"name": "张三",
|
||||
"employee_no": "E001",
|
||||
"department_name": "市场部",
|
||||
"application_preview": {
|
||||
"modelReviewStatus": "completed",
|
||||
"fields": {
|
||||
"applicationType": "差旅费用申请",
|
||||
"time": "2026-07-13 至 2026-07-14",
|
||||
"location": "上海",
|
||||
"reason": "客户现场差旅",
|
||||
"amount": "880元",
|
||||
},
|
||||
"aiDecisionFeedback": {
|
||||
"schemaVersion": 1,
|
||||
"changedFields": changed_fields or [],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -84,11 +104,14 @@ def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
updated = UserAgentService(db)._update_expense_application_record(
|
||||
build_request(run_id="application-draft-update"),
|
||||
service = UserAgentService(db)
|
||||
request = build_request(run_id="application-draft-update")
|
||||
updated = service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="更新后的申请事由"),
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=service._build_application_current_user(request),
|
||||
)
|
||||
|
||||
assert updated.status == "draft"
|
||||
@@ -103,6 +126,65 @@ def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
assert link is not None
|
||||
assert link.tenant_id == "tenant-a"
|
||||
decision = db.scalar(select(AIDecision).where(AIDecision.expense_claim_id == claim.id))
|
||||
feedback = db.scalar(
|
||||
select(AIDecisionFeedback).where(AIDecisionFeedback.expense_claim_id == claim.id)
|
||||
)
|
||||
outcome = db.scalar(
|
||||
select(WorkflowOutcome).where(WorkflowOutcome.expense_claim_id == claim.id)
|
||||
)
|
||||
assert decision is not None
|
||||
assert decision.business_event_id == event.id
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "accepted"
|
||||
assert outcome is not None
|
||||
assert outcome.outcome_type == "draft_saved"
|
||||
|
||||
|
||||
def test_untrusted_application_path_does_not_write_learning_ledger() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
claim = UserAgentService(db)._create_expense_application_record(
|
||||
build_request(run_id="application-untrusted-path"),
|
||||
build_facts(),
|
||||
submit=False,
|
||||
)
|
||||
|
||||
assert db.get(ExpenseClaim, claim.id) is not None
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
) is not None
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preview_patch",
|
||||
[
|
||||
{"modelReviewStatus": "template"},
|
||||
{"applicationEditMode": True},
|
||||
],
|
||||
)
|
||||
def test_non_ai_or_detail_preview_is_not_recorded_as_ai_feedback(
|
||||
preview_patch: dict[str, object],
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
request = build_request(run_id="application-ineligible-preview")
|
||||
request.context_json["application_preview"].update(preview_patch)
|
||||
service = UserAgentService(db)
|
||||
claim = service._create_expense_application_record(
|
||||
request,
|
||||
build_facts(),
|
||||
submit=False,
|
||||
learning_current_user=service._build_application_current_user(request),
|
||||
)
|
||||
|
||||
assert db.get(ExpenseClaim, claim.id) is not None
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||
|
||||
|
||||
def test_ai_application_draft_creation_rolls_back_when_event_write_fails(
|
||||
@@ -217,8 +299,21 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
|
||||
request = build_request(run_id="application-draft-idempotent")
|
||||
facts = build_facts(reason="同一份草稿")
|
||||
service = UserAgentService(db)
|
||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
||||
learning_current_user = service._build_application_current_user(request)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
facts,
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
facts,
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
|
||||
events = list(
|
||||
db.scalars(
|
||||
@@ -228,6 +323,9 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "claim_draft_updated"
|
||||
assert events[0].payload_json["previous_status"] == "returned"
|
||||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||||
assert len(list(db.scalars(select(AIDecisionFeedback)).all())) == 1
|
||||
assert len(list(db.scalars(select(WorkflowOutcome)).all())) == 1
|
||||
|
||||
|
||||
def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> None:
|
||||
@@ -262,17 +360,20 @@ def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> N
|
||||
|
||||
request = build_request(run_id="application-draft-multi-version")
|
||||
service = UserAgentService(db)
|
||||
learning_current_user = service._build_application_current_user(request)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="第一版草稿"),
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="第二版草稿"),
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
|
||||
events = list(
|
||||
@@ -283,3 +384,71 @@ def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> N
|
||||
assert len(events) == 2
|
||||
assert {event.event_type for event in events} == {"claim_draft_updated"}
|
||||
assert len({event.idempotency_key for event in events}) == 2
|
||||
assert len(list(db.scalars(select(AIDecision)).all())) == 2
|
||||
|
||||
|
||||
def test_ai_application_draft_records_explicit_field_correction() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = Employee(
|
||||
id="owner-1",
|
||||
employee_no="E001",
|
||||
name="张三",
|
||||
email="owner@example.com",
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="application-1",
|
||||
claim_no="AP-DRAFT-FEEDBACK-001",
|
||||
employee_id=owner.id,
|
||||
employee_name=owner.name,
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="原申请事由",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([owner, claim])
|
||||
db.commit()
|
||||
|
||||
service = UserAgentService(db)
|
||||
request = build_request(
|
||||
run_id="application-draft-feedback",
|
||||
changed_fields=[
|
||||
{
|
||||
"fieldKey": "reason",
|
||||
"suggestedValue": "客户拜访",
|
||||
"finalValue": "客户端伪造值不会直接入账",
|
||||
}
|
||||
],
|
||||
)
|
||||
service._update_expense_application_record(
|
||||
request,
|
||||
build_facts(reason="客户现场实施"),
|
||||
claim,
|
||||
submit=False,
|
||||
learning_current_user=service._build_application_current_user(request),
|
||||
)
|
||||
|
||||
decision = db.scalar(select(AIDecision))
|
||||
feedback = db.scalar(select(AIDecisionFeedback))
|
||||
assert decision is not None
|
||||
assert decision.training_eligible is False
|
||||
assert decision.suggestion_json["field_keys"]
|
||||
assert decision.suggestion_json["value_fingerprint"].startswith("sha256:")
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "edited"
|
||||
assert feedback.verification_status == "client_observed"
|
||||
assert feedback.training_eligible is False
|
||||
assert feedback.changed_fields_json[0]["field_key"] == "reason"
|
||||
assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith(
|
||||
"sha256:"
|
||||
)
|
||||
assert "客户拜访" not in str(feedback.changed_fields_json)
|
||||
assert "客户现场实施" not in str(feedback.final_value_json)
|
||||
|
||||
Reference in New Issue
Block a user