from __future__ import annotations from datetime import date, datetime from typing import Optional from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator from app.modules.clients.constants import ( CLIENT_CATEGORY_OPTIONS, CLIENT_SORT_FIELDS, CLIENT_STATUS, CLIENT_TYPES, ENGAGEMENT_MODES, RISK_CATEGORIES, CLIENT_ACCEPTANCE_STATUS, ) from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper class ClientBase(BaseModel): tenant_id: int branch_id: int partner_id: Optional[int] = None client_group_id: Optional[int] = None group_relationship: Optional[str] = None is_group_head: bool = False default_review_partner_user_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None referral_date: Optional[date] = None referral_reference: Optional[str] = None referral_status: str = "active" communication_routing_mode: str = "client_and_consultant" engagement_mode: str = "internal_managed" client_code: str client_name: str trade_name: Optional[str] = None client_type: str = "Other" pan: Optional[str] = None gstin: Optional[str] = None tan: Optional[str] = None cin_llpin: Optional[str] = None msme_no: Optional[str] = None iec_code: Optional[str] = None contact_person_name: Optional[str] = None contact_person_designation: Optional[str] = None mobile: Optional[str] = None alternate_mobile: Optional[str] = None email: Optional[EmailStr] = None alternate_email: Optional[EmailStr] = None address_line_1: Optional[str] = None address_line_2: Optional[str] = None city: Optional[str] = None state: Optional[str] = None pincode: Optional[str] = None country: Optional[str] = "India" status: str = "active" client_category: Optional[str] = None risk_category: Optional[str] = None onboarding_date: Optional[date] = None closing_date: Optional[date] = None acceptance_status: str = "pending_review" acceptance_required: bool = True independence_check_completed: bool = False conflict_check_completed: bool = False kyc_completed: bool = False engagement_letter_required: bool = True engagement_letter_received: bool = False acceptance_review_notes: Optional[str] = None acceptance_rejection_reason: Optional[str] = None notes: Optional[str] = None gst_applicable: bool = False income_tax_applicable: bool = False tds_applicable: bool = False roc_applicable: bool = False audit_applicable: bool = False pf_applicable: bool = False esi_applicable: bool = False professional_tax_applicable: bool = False payroll_applicable: bool = False msme_applicable: bool = False import_export_applicable: bool = False @field_validator("client_code", "client_name", mode="before") @classmethod def required_text(cls, value): value = normalize_text(value) if not value: raise ValueError("This field is required.") return value @field_validator( "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes", mode="before", ) @classmethod def clean_text(cls, value): return normalize_text(value) @field_validator("pan", "gstin", "tan", mode="before") @classmethod def uppercase_codes(cls, value): return normalize_upper(value) @field_validator("referral_status", "communication_routing_mode", mode="before") @classmethod def clean_referral_fields(cls, value): return (normalize_text(value) or "").lower() @field_validator("referral_status") @classmethod def validate_referral_status(cls, value): if value not in {"active", "inactive", "ended"}: raise ValueError("Invalid referral status.") return value @field_validator("communication_routing_mode") @classmethod def validate_communication_routing_mode(cls, value): if value not in {"client_direct", "consultant_primary", "client_and_consultant", "firm_only"}: raise ValueError("Invalid communication routing mode.") return value @field_validator("engagement_mode", mode="before") @classmethod def clean_engagement_mode(cls, value): value = normalize_text(value) or "internal_managed" return value.lower() @field_validator("engagement_mode") @classmethod def validate_engagement_mode(cls, value): if value not in ENGAGEMENT_MODES: raise ValueError("Invalid engagement mode.") return value @field_validator("acceptance_status", mode="before") @classmethod def clean_acceptance_status(cls, value): value = normalize_text(value) or "pending_review" return value.lower() @field_validator("acceptance_status") @classmethod def validate_acceptance_status(cls, value): if value not in CLIENT_ACCEPTANCE_STATUS: raise ValueError("Invalid client acceptance status.") return value @field_validator("mobile", "alternate_mobile", mode="before") @classmethod def clean_mobile(cls, value): value = normalize_text(value) if value is None: return None value = value.replace(" ", "").replace("-", "") if value.startswith("+91"): value = value[3:] return value @field_validator("pan") @classmethod def validate_pan(cls, value): if value and not PAN_RE.match(value): raise ValueError("Invalid PAN format.") return value @field_validator("gstin") @classmethod def validate_gstin(cls, value): if value and not GSTIN_RE.match(value): raise ValueError("Invalid GSTIN format.") return value @field_validator("tan") @classmethod def validate_tan(cls, value): if value and not TAN_RE.match(value): raise ValueError("Invalid TAN format.") return value @field_validator("mobile", "alternate_mobile") @classmethod def validate_mobile(cls, value): if value and not MOBILE_RE.match(value): raise ValueError("Mobile number must be a valid 10-digit Indian mobile.") return value @field_validator("pincode", mode="before") @classmethod def clean_pincode(cls, value): return normalize_text(value) @field_validator("pincode") @classmethod def validate_pincode(cls, value): if value and not PIN_RE.match(value): raise ValueError("Pincode must be a valid 6-digit code.") return value @model_validator(mode="after") def validate_dates_and_assignment(self): if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date: raise ValueError("Closing date cannot be earlier than onboarding date.") if self.engagement_mode == "internal_managed" and not self.partner_id: raise ValueError("Partner is required for internal managed clients.") return self class ClientCreate(ClientBase): pass class ClientUpdate(BaseModel): tenant_id: Optional[int] = None branch_id: Optional[int] = None partner_id: Optional[int] = None client_group_id: Optional[int] = None group_relationship: Optional[str] = None is_group_head: Optional[bool] = None default_review_partner_user_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None referral_date: Optional[date] = None referral_reference: Optional[str] = None referral_status: Optional[str] = None communication_routing_mode: Optional[str] = None engagement_mode: Optional[str] = None client_name: Optional[str] = None trade_name: Optional[str] = None client_type: Optional[str] = None pan: Optional[str] = None gstin: Optional[str] = None tan: Optional[str] = None cin_llpin: Optional[str] = None msme_no: Optional[str] = None iec_code: Optional[str] = None contact_person_name: Optional[str] = None contact_person_designation: Optional[str] = None mobile: Optional[str] = None alternate_mobile: Optional[str] = None email: Optional[EmailStr] = None alternate_email: Optional[EmailStr] = None address_line_1: Optional[str] = None address_line_2: Optional[str] = None city: Optional[str] = None state: Optional[str] = None pincode: Optional[str] = None country: Optional[str] = None status: Optional[str] = None client_category: Optional[str] = None risk_category: Optional[str] = None onboarding_date: Optional[date] = None closing_date: Optional[date] = None acceptance_status: Optional[str] = None acceptance_required: Optional[bool] = None independence_check_completed: Optional[bool] = None conflict_check_completed: Optional[bool] = None kyc_completed: Optional[bool] = None engagement_letter_required: Optional[bool] = None engagement_letter_received: Optional[bool] = None acceptance_review_notes: Optional[str] = None acceptance_rejection_reason: Optional[str] = None notes: Optional[str] = None gst_applicable: Optional[bool] = None income_tax_applicable: Optional[bool] = None tds_applicable: Optional[bool] = None roc_applicable: Optional[bool] = None audit_applicable: Optional[bool] = None pf_applicable: Optional[bool] = None esi_applicable: Optional[bool] = None professional_tax_applicable: Optional[bool] = None payroll_applicable: Optional[bool] = None msme_applicable: Optional[bool] = None import_export_applicable: Optional[bool] = None model_config = ConfigDict(extra="forbid") @field_validator( "client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes", mode="before", ) @classmethod def clean_text(cls, value): return normalize_text(value) @field_validator("pan", "gstin", "tan", mode="before") @classmethod def uppercase_codes(cls, value): return normalize_upper(value) @field_validator("referral_status", "communication_routing_mode", mode="before") @classmethod def clean_optional_referral_fields(cls, value): value = normalize_text(value) return value.lower() if value else None @field_validator("referral_status") @classmethod def validate_optional_referral_status(cls, value): if value is not None and value not in {"active", "inactive", "ended"}: raise ValueError("Invalid referral status.") return value @field_validator("communication_routing_mode") @classmethod def validate_optional_communication_routing_mode(cls, value): if value is not None and value not in {"client_direct", "consultant_primary", "client_and_consultant", "firm_only"}: raise ValueError("Invalid communication routing mode.") return value @field_validator("engagement_mode", mode="before") @classmethod def clean_engagement_mode(cls, value): if value is None: return None value = normalize_text(value) or None return value.lower() if value else None @field_validator("engagement_mode") @classmethod def validate_engagement_mode(cls, value): if value is not None and value not in ENGAGEMENT_MODES: raise ValueError("Invalid engagement mode.") return value @field_validator("acceptance_status", mode="before") @classmethod def clean_acceptance_status(cls, value): if value is None: return None value = normalize_text(value) or None return value.lower() if value else None @field_validator("acceptance_status") @classmethod def validate_acceptance_status(cls, value): if value is not None and value not in CLIENT_ACCEPTANCE_STATUS: raise ValueError("Invalid client acceptance status.") return value @field_validator("mobile", "alternate_mobile", mode="before") @classmethod def clean_mobile(cls, value): value = normalize_text(value) if value is None: return None value = value.replace(" ", "").replace("-", "") if value.startswith("+91"): value = value[3:] return value @field_validator("pan") @classmethod def validate_pan(cls, value): if value and not PAN_RE.match(value): raise ValueError("Invalid PAN format.") return value @field_validator("gstin") @classmethod def validate_gstin(cls, value): if value and not GSTIN_RE.match(value): raise ValueError("Invalid GSTIN format.") return value @field_validator("tan") @classmethod def validate_tan(cls, value): if value and not TAN_RE.match(value): raise ValueError("Invalid TAN format.") return value @field_validator("mobile", "alternate_mobile") @classmethod def validate_mobile(cls, value): if value and not MOBILE_RE.match(value): raise ValueError("Mobile number must be a valid 10-digit Indian mobile.") return value @field_validator("pincode", mode="before") @classmethod def clean_pincode(cls, value): return normalize_text(value) @field_validator("pincode") @classmethod def validate_pincode(cls, value): if value and not PIN_RE.match(value): raise ValueError("Pincode must be a valid 6-digit code.") return value class ClientOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int tenant_id: int branch_id: int partner_id: Optional[int] = None default_review_partner_user_id: Optional[int] = None engagement_mode: str client_code: str client_name: str trade_name: Optional[str] = None client_type: str pan: Optional[str] = None gstin: Optional[str] = None tan: Optional[str] = None cin_llpin: Optional[str] = None msme_no: Optional[str] = None iec_code: Optional[str] = None contact_person_name: Optional[str] = None contact_person_designation: Optional[str] = None mobile: Optional[str] = None alternate_mobile: Optional[str] = None email: Optional[str] = None alternate_email: Optional[str] = None address_line_1: Optional[str] = None address_line_2: Optional[str] = None city: Optional[str] = None state: Optional[str] = None pincode: Optional[str] = None country: Optional[str] = None status: str client_category: Optional[str] = None risk_category: Optional[str] = None onboarding_date: Optional[date] = None closing_date: Optional[date] = None acceptance_status: str = "pending_review" acceptance_required: bool = True independence_check_completed: bool = False conflict_check_completed: bool = False kyc_completed: bool = False engagement_letter_required: bool = True engagement_letter_received: bool = False acceptance_review_notes: Optional[str] = None acceptance_rejection_reason: Optional[str] = None notes: Optional[str] = None gst_applicable: bool income_tax_applicable: bool tds_applicable: bool roc_applicable: bool audit_applicable: bool pf_applicable: bool esi_applicable: bool professional_tax_applicable: bool payroll_applicable: bool msme_applicable: bool import_export_applicable: bool is_active: bool is_archived: bool created_at_utc: datetime updated_at_utc: datetime class ClientListRow(ClientOut): partner_name: Optional[str] = None branch_name: Optional[str] = None tenant_name: Optional[str] = None class ClientAuditLogOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int client_id: int tenant_id: int branch_id: int actor_user_id: Optional[int] = None action: str summary: str payload_json: Optional[dict] = None created_at_utc: datetime class PaginationMeta(BaseModel): total: int page: int per_page: int class ClientFilterOptions(BaseModel): client_types: list[str] client_statuses: list[str] client_categories: list[str] risk_categories: list[str] class ClientListStats(BaseModel): total: int = 0 active: int = 0 inactive: int = 0 archived: int = 0 class ClientListResponse(BaseModel): rows: list[ClientOut] meta: PaginationMeta stats: ClientListStats | None = None filter_options: ClientFilterOptions | None = None