2026-08-02 16:34:06 +08:00
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 10:44:34 +08:00
|
|
|
def test_generate_ppt_does_not_shadow_module_jsonify():
|
|
|
|
|
import symtable
|
|
|
|
|
|
|
|
|
|
route_path = Path(__file__).resolve().parents[1] / "api/insurance/ppt/routes.py"
|
|
|
|
|
module = symtable.symtable(route_path.read_text(encoding="utf-8"), str(route_path), "exec")
|
|
|
|
|
generate_ppt = next(child for child in module.get_children() if child.get_name() == "generate_ppt")
|
|
|
|
|
jsonify = generate_ppt.lookup("jsonify")
|
|
|
|
|
|
|
|
|
|
assert jsonify.is_global()
|
|
|
|
|
assert not jsonify.is_local()
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 16:34:06 +08:00
|
|
|
def _evidence(document_id=1, page=2):
|
|
|
|
|
return {
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"pageNumber": page,
|
|
|
|
|
"bbox": [10, 20, 100, 40],
|
|
|
|
|
"tableId": None,
|
|
|
|
|
"rowId": None,
|
|
|
|
|
"columnId": None,
|
|
|
|
|
"textHash": "a" * 64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _legacy_data(document_id=1):
|
|
|
|
|
return {
|
|
|
|
|
"product_name": "储蓄计划 A",
|
|
|
|
|
"product_type": "savings",
|
|
|
|
|
"insured": {"age": 35, "gender": "male", "smoker": "no"},
|
|
|
|
|
"policy": {
|
|
|
|
|
"currency": "HKD",
|
|
|
|
|
"annual_premium": 100000,
|
|
|
|
|
"premium_payment_period": 5,
|
|
|
|
|
"total_premium": None,
|
|
|
|
|
},
|
|
|
|
|
"benefit_illustration": [{
|
|
|
|
|
"policy_year": 10,
|
|
|
|
|
"scenario_type": "base",
|
|
|
|
|
"total_surrender_value": 650000,
|
|
|
|
|
}],
|
|
|
|
|
"meta": {"evidence": [
|
|
|
|
|
{"fieldPath": "policy.annual_premium", "evidence": _evidence(document_id, 2)},
|
|
|
|
|
{"fieldPath": "benefit.base.10.default.total_surrender_value", "evidence": _evidence(document_id, 8)},
|
|
|
|
|
]},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_candidate_reconciliation_preserves_conflicts_and_context_hint_cannot_override():
|
|
|
|
|
from insurance.plan_data.field_values import make_candidate, reconcile_candidates
|
|
|
|
|
|
|
|
|
|
evidence = [_evidence()]
|
|
|
|
|
result = reconcile_candidates([
|
|
|
|
|
make_candidate("policy.annualPremium", "100,000", normalized_value=100000, source="pdf_table", evidence=evidence),
|
|
|
|
|
make_candidate("policy.annualPremium", 120000, source="llm", confidence=0.99),
|
|
|
|
|
make_candidate("identity.productName", "文件名产品", source="context_hint"),
|
|
|
|
|
make_candidate("identity.productName", "PDF 产品", source="regex", evidence=evidence),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
assert result["policy.annualPremium"]["status"] == "conflict"
|
|
|
|
|
assert result["policy.annualPremium"]["conflictCandidates"] == [100000, 120000]
|
|
|
|
|
assert result["identity.productName"]["status"] == "conflict"
|
|
|
|
|
assert set(result["identity.productName"]["conflictCandidates"]) == {"PDF 产品", "文件名产品"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_conversion_uses_field_values_and_never_derives_contract_total():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
|
|
|
|
|
plan_data = legacy_to_plan_data(_legacy_data())
|
|
|
|
|
|
|
|
|
|
assert plan_data["policy"]["annualPremium"]["value"] == 100000
|
|
|
|
|
assert plan_data["policy"]["annualPremium"]["status"] == "extracted"
|
|
|
|
|
assert plan_data["policy"]["contractualTotalPremium"]["status"] == "missing"
|
|
|
|
|
assert plan_data["policy"]["contractualTotalPremium"]["value"] is None
|
|
|
|
|
assert plan_data["benefitScenarios"][0]["rows"][0]["totalSurrenderValue"]["evidence"]
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 10:44:34 +08:00
|
|
|
def test_poster_flat_fields_survive_snapshot_conversion():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.snapshot_validation import validate_plan_data
|
|
|
|
|
|
|
|
|
|
poster_data = {
|
|
|
|
|
"product_name": "测试重疾计划",
|
|
|
|
|
"plan_type": "ci",
|
|
|
|
|
"age": 38,
|
|
|
|
|
"gender": "female",
|
|
|
|
|
"smoking_status": "no",
|
|
|
|
|
"currency": "HKD",
|
|
|
|
|
"sum_assured": 1_000_000,
|
|
|
|
|
"annual_premium": 25_000,
|
|
|
|
|
"premium_term": 20,
|
|
|
|
|
"meta": {"evidence": []},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
plan_data = legacy_to_plan_data(poster_data)
|
|
|
|
|
result = validate_plan_data(plan_data, for_confirmation=True)
|
|
|
|
|
|
|
|
|
|
assert plan_data["identity"]["insuredAge"]["value"] == 38
|
|
|
|
|
assert plan_data["identity"]["insuredGender"]["value"] == "female"
|
|
|
|
|
assert plan_data["identity"]["insuredSmoker"]["value"] == "no"
|
|
|
|
|
assert plan_data["policy"]["currency"]["value"] == "HKD"
|
|
|
|
|
assert plan_data["policy"]["sumAssured"]["value"] == 1_000_000
|
|
|
|
|
assert plan_data["policy"]["premiumPaymentPeriod"]["value"] == 20
|
|
|
|
|
assert result["blockingCount"] == 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 16:34:06 +08:00
|
|
|
def test_projection_uses_confirmed_table_milestones_and_keeps_unknown_null():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.projections import to_deck_contract, to_poster_projection
|
|
|
|
|
|
|
|
|
|
plan_data = legacy_to_plan_data(_legacy_data())
|
|
|
|
|
poster = to_poster_projection(plan_data)
|
|
|
|
|
deck = to_deck_contract(plan_data)
|
|
|
|
|
|
|
|
|
|
assert poster["surrender_value_10"] == 650000
|
|
|
|
|
assert poster["surrender_value_20"] is None
|
|
|
|
|
assert poster["total_premium"] is None
|
|
|
|
|
assert deck["policy"]["contractualTotalPremium"] is None
|
|
|
|
|
assert deck["benefitRows"][0]["totalSurrenderValue"] == 650000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migration_037_and_snapshot_lifecycle_are_versioned_and_owned():
|
|
|
|
|
from flask import Flask
|
|
|
|
|
from sqlalchemy import inspect
|
|
|
|
|
|
|
|
|
|
from insurance.db.database import db
|
|
|
|
|
from insurance.db.migrate_036 import migrate as migrate_036
|
|
|
|
|
from insurance.db.migrate_037 import migrate as migrate_037
|
|
|
|
|
from insurance.models.insurance_document import InsuranceDocument
|
|
|
|
|
from insurance.plan_data.service import (
|
|
|
|
|
SnapshotError,
|
|
|
|
|
confirm_snapshot,
|
|
|
|
|
create_draft_from_legacy,
|
|
|
|
|
patch_draft,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
app.config.update(
|
|
|
|
|
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
|
|
|
|
|
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
|
|
|
|
)
|
|
|
|
|
db.init_app(app)
|
|
|
|
|
with app.app_context():
|
|
|
|
|
migrate_036()
|
|
|
|
|
migrate_037()
|
|
|
|
|
document = InsuranceDocument(
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
sha256="b" * 64,
|
|
|
|
|
storage_key="/private/plan.pdf",
|
|
|
|
|
original_name="plan.pdf",
|
|
|
|
|
mime_type="application/pdf",
|
|
|
|
|
file_size=100,
|
|
|
|
|
page_count=8,
|
|
|
|
|
pdf_kind="native",
|
|
|
|
|
status="parsed",
|
|
|
|
|
parser_version="test-v1",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(document)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
legacy = _legacy_data(document.id)
|
|
|
|
|
draft = create_draft_from_legacy(document.id, "user-1", legacy)
|
|
|
|
|
confirmed = confirm_snapshot(draft.id, "user-1")
|
|
|
|
|
|
|
|
|
|
assert draft.snapshot_version == 1
|
|
|
|
|
assert confirmed.snapshot_version == 2
|
|
|
|
|
assert confirmed.previous_snapshot_id == draft.id
|
|
|
|
|
assert confirmed.status == "confirmed"
|
|
|
|
|
assert confirmed.snapshot_hash != draft.snapshot_hash
|
|
|
|
|
assert confirmed.validation()["blockingCount"] == 0
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SnapshotError) as stale:
|
|
|
|
|
patch_draft(
|
|
|
|
|
confirmed.id,
|
|
|
|
|
"user-1",
|
|
|
|
|
expected_hash="wrong",
|
|
|
|
|
changes=[{
|
|
|
|
|
"fieldPath": "policy.annualPremium",
|
|
|
|
|
"value": 110000,
|
|
|
|
|
"overrideReason": "客户确认修正",
|
|
|
|
|
}],
|
|
|
|
|
)
|
|
|
|
|
assert stale.value.code == "SNAPSHOT_VERSION_CONFLICT"
|
|
|
|
|
|
|
|
|
|
edited = patch_draft(
|
|
|
|
|
confirmed.id,
|
|
|
|
|
"user-1",
|
|
|
|
|
expected_hash=confirmed.snapshot_hash,
|
|
|
|
|
changes=[{
|
|
|
|
|
"fieldPath": "policy.annualPremium",
|
|
|
|
|
"value": 110000,
|
|
|
|
|
"overrideReason": "客户确认修正",
|
|
|
|
|
}],
|
|
|
|
|
)
|
|
|
|
|
assert edited.snapshot_version == 3
|
|
|
|
|
assert edited.plan_data()["policy"]["annualPremium"]["status"] == "confirmed"
|
|
|
|
|
assert confirmed.plan_data()["policy"]["annualPremium"]["value"] == 100000
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SnapshotError):
|
|
|
|
|
confirm_snapshot(edited.id, "user-2")
|
|
|
|
|
|
|
|
|
|
tables = set(inspect(db.engine).get_table_names())
|
|
|
|
|
assert {
|
|
|
|
|
"insurance_plan_snapshots",
|
|
|
|
|
"insurance_field_evidence",
|
|
|
|
|
"insurance_plan_overrides",
|
|
|
|
|
} <= tables
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 10:44:34 +08:00
|
|
|
def test_confirmation_warns_when_critical_amount_evidence_is_missing():
|
2026-08-02 16:34:06 +08:00
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.snapshot_validation import validate_plan_data
|
|
|
|
|
|
|
|
|
|
data = _legacy_data()
|
|
|
|
|
data["meta"]["evidence"] = []
|
|
|
|
|
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
|
|
|
|
|
2026-08-04 10:44:34 +08:00
|
|
|
assert result["blockingCount"] == 0
|
|
|
|
|
assert result["warningCount"] == 1
|
2026-08-02 16:34:06 +08:00
|
|
|
assert any(item["code"] == "CRITICAL_EVIDENCE_MISSING" for item in result["issues"])
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 10:44:34 +08:00
|
|
|
def test_optional_benefit_and_withdrawal_data_do_not_block_confirmation():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.snapshot_validation import validate_plan_data
|
|
|
|
|
|
|
|
|
|
data = _legacy_data()
|
|
|
|
|
data["meta"]["evidence"] = [
|
|
|
|
|
{"fieldPath": "policy.annual_premium", "evidence": _evidence(1, 2)},
|
|
|
|
|
]
|
|
|
|
|
data["withdrawal_illustration"] = [{
|
|
|
|
|
"policy_year": 10,
|
|
|
|
|
"annual_withdrawal": 50000,
|
|
|
|
|
"surrender_value_after": 300000,
|
|
|
|
|
}]
|
|
|
|
|
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
|
|
|
|
|
|
|
|
|
assert result["blockingCount"] == 0
|
|
|
|
|
assert not any(
|
|
|
|
|
item["code"] == "CRITICAL_EVIDENCE_MISSING"
|
|
|
|
|
and (
|
|
|
|
|
".benefitScenarios." in item["path"]
|
|
|
|
|
or ".withdrawals." in item["path"]
|
|
|
|
|
)
|
|
|
|
|
for item in result["issues"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_optional_projection_sections_do_not_block_confirmation():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.snapshot_validation import validate_plan_data
|
|
|
|
|
|
|
|
|
|
data = _legacy_data()
|
|
|
|
|
data["benefit_illustration"] = []
|
|
|
|
|
data["withdrawal_illustration"] = []
|
|
|
|
|
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
|
|
|
|
|
|
|
|
|
assert result["blockingCount"] == 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 10:45:25 +08:00
|
|
|
def test_confirmation_does_not_treat_source_labels_as_amounts():
|
|
|
|
|
from insurance.plan_data.conversion import legacy_to_plan_data
|
|
|
|
|
from insurance.plan_data.snapshot_validation import validate_plan_data
|
|
|
|
|
|
|
|
|
|
data = _legacy_data()
|
|
|
|
|
data["policy"]["annual_premium_source_label"] = "IUL利益表保费列"
|
|
|
|
|
result = validate_plan_data(legacy_to_plan_data(data), for_confirmation=True)
|
|
|
|
|
|
|
|
|
|
assert not any(
|
|
|
|
|
item["path"].endswith("annualPremiumSourceLabel")
|
|
|
|
|
for item in result["issues"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 16:34:06 +08:00
|
|
|
def test_generation_task_exposes_frozen_versions_without_server_paths():
|
|
|
|
|
from insurance.models.generation_task import GenerationTask
|
|
|
|
|
|
|
|
|
|
task = GenerationTask(
|
|
|
|
|
id="task-1",
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
artifact_type="ppt",
|
|
|
|
|
operation="generate",
|
|
|
|
|
workspace_id="session-1",
|
|
|
|
|
input_revision=7,
|
|
|
|
|
submit_revision=7,
|
|
|
|
|
snapshot_id=3,
|
|
|
|
|
snapshot_hash="c" * 64,
|
|
|
|
|
renderer_version="renderer-v2",
|
|
|
|
|
result_state="stale",
|
|
|
|
|
output_json=json.dumps({"filePath": "/private/output.pptx", "downloadUrl": "/download/1"}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
payload = task.to_dict()
|
|
|
|
|
assert payload["snapshotId"] == 3
|
|
|
|
|
assert payload["submitRevision"] == 7
|
|
|
|
|
assert payload["rendererVersion"] == "renderer-v2"
|
|
|
|
|
assert payload["resultState"] == "stale"
|
|
|
|
|
assert payload["output"] == {"downloadUrl": "/download/1"}
|