from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Literal from uuid import UUID from pydantic import BaseModel, Field, field_validator # Schema-layer bounds. The calculator + DB don't crash with these # guard-rails — without them, year > 9999 trips ``fiscal_year_window`` # (Python date max is 9999 → ValueError), and Decimals > Numeric(4,1) # overflow at INSERT time; both surface as 500s instead of the clean # 422 the caller deserves. 2200 covers ~175 years of forward-dated # projection, more than any realistic HR-tools input. _MAX_LEAVE_DAYS # is 99.9 (matching the Numeric(4,1) column ceiling: ±999.9) — any # single-year carry / used larger than that is a typo, and capping at # 99.9 means the BALANCE math (settlement + carry - used, stored in # the same Numeric(4,1) shape) can't overflow at INSERT. _MAX_DATE = date(2200, 12, 31) _MIN_DATE = date(1900, 1, 1) _MAX_LEAVE_DAYS = Decimal("99.9") # ── Calculator request / response ────────────────────────────────── class AnnualLeaveCalculateRequest(BaseModel): person_id: UUID | None = Field( None, description="Selected person; lets the calculator default the employee_name " "/ employee_no in the persisted history row. When None the call is a free-form " "sandbox calculation and is still persisted but unlinked to identity_persons.", ) employee_no: str | None = Field(None, max_length=64) employee_name: str | None = Field(None, max_length=128) work_start_date: date hire_date: date termination_date: date carry_legal_days: Decimal = Field(ge=0, le=_MAX_LEAVE_DAYS) carry_welfare_days: Decimal = Field(ge=0, le=_MAX_LEAVE_DAYS) used_legal_days: Decimal = Field(ge=0, le=_MAX_LEAVE_DAYS) used_welfare_days: Decimal = Field(ge=0, le=_MAX_LEAVE_DAYS) notes: str | None = Field(None, max_length=2000) @field_validator("work_start_date", "hire_date", "termination_date") @classmethod def _bound_date(cls, v: date) -> date: if v < _MIN_DATE or v > _MAX_DATE: raise ValueError( f"date must fall within [{_MIN_DATE}, {_MAX_DATE}]" ) return v class SegmentResponse(BaseModel): index: int start_date: date end_date: date days: int legal_basis: int welfare_basis: int legal_formula: str welfare_formula: str legal_raw_value: Decimal welfare_raw_value: Decimal class BlockResponse(BaseModel): label: Literal["entitlement", "settlement"] range_start: date range_end: date segments: list[SegmentResponse] legal_raw: Decimal welfare_raw: Decimal legal_rounded: Decimal welfare_rounded: Decimal legal_capped: Decimal welfare_capped: Decimal #: 15-day combined cap (statutory kept, welfare reduced). cap_applied: bool #: 年假基数 cap — the prorated result was pulled back to the #: employee's own basis. Distinct rule, distinct explanation. basis_cap_applied: bool = False class AnnualLeaveCalculateResponse(BaseModel): id: UUID # history record id person_id: UUID | None employee_no: str | None employee_name: str | None work_start_date: date hire_date: date termination_date: date fiscal_year_start: date fiscal_year_end: date entitlement: BlockResponse settlement: BlockResponse carry_legal_days: Decimal carry_welfare_days: Decimal used_legal_days: Decimal used_welfare_days: Decimal # 本期余额 = 本期(结算)额度 − 当年已休; excludes carryover so that # period_balance + carry == balance (总余额). period_balance_legal_days: Decimal period_balance_welfare_days: Decimal balance_legal_days: Decimal balance_welfare_days: Decimal calculated_by_user_id: UUID calculated_by_name: str notes: str | None created_at: datetime # ── History list response ────────────────────────────────────────── class AnnualLeaveHistoryItem(BaseModel): id: UUID person_id: UUID | None employee_no: str | None employee_name: str | None work_start_date: date hire_date: date termination_date: date balance_legal_days: Decimal balance_welfare_days: Decimal calculated_by_name: str created_at: datetime