fix: restrict application linking for reimbursement drafts

This commit is contained in:
caoxiaozhu
2026-06-03 16:28:09 +08:00
parent 8887cf5a27
commit 04f0951b3d
4 changed files with 537 additions and 5 deletions

View File

@@ -69,6 +69,36 @@ def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
return claim
def build_application_claim(
*,
id: str,
claim_no: str,
employee: Employee,
status: str = "approved",
amount: Decimal = Decimal("3000.00"),
) -> ExpenseClaim:
return ExpenseClaim(
id=id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id=employee.organization_unit_id,
department_name="Tech",
project_code=None,
expense_type="travel_application",
reason="support deployment",
location="Shanghai",
amount=amount,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
status=status,
approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
risk_flags_json=[],
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
@@ -322,6 +352,12 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
email=user_id,
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -384,6 +420,12 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
grade="P5",
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -474,6 +516,11 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
@@ -550,6 +597,123 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
assert claim.items == []
def test_upsert_linked_application_requires_approved_application() -> None:
user_id = "linked-application-status-block@example.com"
message = "save reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["application_claim_no"] == "AP-202606-STATUS"
assert _count_claims(db) == 1
def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
user_id = "linked-application-duplicate-block@example.com"
message = "save another reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
employee_name=employee.name,
department_name="Tech",
project_code=None,
expense_type="travel",
reason="support deployment",
location="Shanghai",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="draft",
approval_stage="Pending",
risk_flags_json=[
{
"source": "application_link",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
}
],
)
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(query=message, user_id=user_id)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
assert _count_claims(db) == 2
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
with build_session() as db:
employee = Employee(