from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags class ReimbursementCreate(BaseModel): request_no: str employee_id: str title: str category: str amount: Decimal reason: str | None = None class ReimbursementRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str request_no: str employee_id: str title: str category: str status: str amount: Decimal reason: str | None created_at: datetime updated_at: datetime class ExpenseClaimItemRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str item_date: date item_type: str item_reason: str item_location: str item_note: str = "" item_amount: Decimal invoice_id: str | None is_system_generated: bool = False created_at: datetime updated_at: datetime class ExpenseClaimAttachmentAnalysisRead(BaseModel): severity: str label: str headline: str summary: str points: list[str] = Field(default_factory=list) rule_basis: list[str] = Field(default_factory=list) suggestion: str = "" class ExpenseClaimAttachmentDocumentFieldRead(BaseModel): key: str label: str value: str class ExpenseClaimAttachmentDocumentInfoRead(BaseModel): document_type: str = "other" document_type_label: str = "其他单据" scene_code: str = "other" scene_label: str = "其他票据" fields: list[ExpenseClaimAttachmentDocumentFieldRead] = Field(default_factory=list) class ExpenseClaimAttachmentRequirementRead(BaseModel): matches: bool = False current_expense_type: str = "other" current_expense_type_label: str = "其他" allowed_scene_labels: list[str] = Field(default_factory=list) recognized_scene_code: str = "other" recognized_scene_label: str = "其他票据" recognized_document_type: str = "other" recognized_document_type_label: str = "其他单据" message: str = "" class ExpenseClaimAttachmentRead(BaseModel): file_name: str storage_key: str media_type: str size_bytes: int uploaded_at: datetime | None = None previewable: bool = True preview_kind: str = "" preview_url: str = "" analysis: ExpenseClaimAttachmentAnalysisRead | None = None document_info: ExpenseClaimAttachmentDocumentInfoRead | None = None requirement_check: ExpenseClaimAttachmentRequirementRead | None = None class ExpenseClaimItemUpdate(BaseModel): item_date: date | None = None item_type: str | None = None item_reason: str | None = None item_location: str | None = None item_note: str | None = None item_amount: Decimal | None = None invoice_id: str | None = None class ExpenseClaimItemCreate(BaseModel): item_date: date | None = None item_type: str | None = None item_reason: str | None = None item_location: str | None = None item_note: str | None = None item_amount: Decimal | None = None invoice_id: str | None = None class ExpenseClaimUpdate(BaseModel): reason: str | None = Field(default=None, max_length=500) class ExpenseClaimStandardAdjustmentRisk(BaseModel): risk_id: str | None = Field(default=None, max_length=120) item_id: str | None = Field(default=None, max_length=120) title: str | None = Field(default=None, max_length=120) risk: str | None = Field(default=None, max_length=500) application_days: int | None = Field(default=None, ge=1, le=365) original_amount: Decimal | None = None reimbursable_amount: Decimal | None = None class ExpenseClaimStandardAdjustmentPayload(BaseModel): risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20) class ExpenseClaimRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str claim_no: str employee_id: str | None employee_name: str department_id: str | None department_name: str employee_position: str | None = None employee_grade: str | None = None manager_name: str | None = None finance_owner_name: str | None = None finance_approver_name: str | None = None budget_approver_name: str | None = None budget_approver_grade: str | None = None budget_approver_role_code: str | None = None role_labels: list[str] = Field(default_factory=list) project_code: str | None expense_type: str reason: str location: str amount: Decimal currency: str invoice_count: int occurred_at: datetime submitted_at: datetime | None status: str approval_stage: str | None risk_flags_json: list[Any] = Field(default_factory=list) created_at: datetime updated_at: datetime items: list[ExpenseClaimItemRead] = Field(default_factory=list) @field_validator("risk_flags_json", mode="before") @classmethod def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]: if isinstance(value, list): return dedupe_budget_risk_flags(value) return [] class ExpenseClaimActionResponse(BaseModel): message: str claim_id: str status: str | None = None class ExpenseApplicationPreviewActionPayload(BaseModel): source: str = Field(default="user_message", max_length=80) user_id: str | None = Field(default=None, max_length=120) conversation_id: str | None = Field(default=None, max_length=120) message: str = Field(min_length=1, max_length=4000) context_json: dict[str, Any] = Field(default_factory=dict) class ExpenseApplicationPreviewActionResult(BaseModel): message: str answer: str suggested_actions: list[dict[str, Any]] = Field(default_factory=list) risk_flags: list[str] = Field(default_factory=list) requires_confirmation: bool = False draft_payload: dict[str, Any] | None = None class ExpenseApplicationPreviewActionResponse(BaseModel): status: str = "succeeded" conversation_id: str | None = None result: ExpenseApplicationPreviewActionResult class ExpenseClaimReturnPayload(BaseModel): reason: str | None = Field(default=None, max_length=500) reason_codes: list[str] = Field(default_factory=list, max_length=10) class ExpenseClaimApprovalPayload(BaseModel): opinion: str | None = Field(default=None, max_length=500) class TravelReimbursementCalculatorRequest(BaseModel): days: int = Field(ge=1, le=365) location: str = Field(min_length=1, max_length=120) grade: str | None = Field(default=None, max_length=30) transport_mode: str | None = Field(default=None, max_length=30) origin_location: str | None = Field(default=None, max_length=120) travel_date: date | None = None class TravelReimbursementCalculatorResponse(BaseModel): days: int location: str matched_city: str city_tier: str grade: str grade_band: str grade_band_label: str hotel_rate: Decimal hotel_amount: Decimal allowance_region: str meal_allowance_rate: Decimal basic_allowance_rate: Decimal total_allowance_rate: Decimal allowance_amount: Decimal transport_mode: str = "" transport_origin: str = "" transport_destination: str = "" transport_estimated_amount: Decimal = Decimal("0.00") transport_estimate_basis: str = "" transport_estimate_confidence: str = "" transport_estimate_source: str = "" transport_estimate_rule_code: str = "" transport_estimate_rule_name: str = "" transport_estimate_rule_version: str = "" travel_date: date | None = None total_amount: Decimal rule_name: str rule_version: str formula_text: str summary_text: str class ExpenseClaimAttachmentActionResponse(BaseModel): message: str claim_id: str item_id: str invoice_id: str | None = None item_date: date | None = None item_type: str | None = None item_reason: str | None = None item_location: str | None = None item_note: str | None = None item_amount: Decimal | None = None claim_amount: Decimal | None = None claim_risk_flags: list[Any] = Field(default_factory=list) attachment: ExpenseClaimAttachmentRead | None = None class ExpenseClaimItemActionResponse(BaseModel): message: str claim_id: str item_id: str