Files
2026-08-05 10:52:36 +05:30

502 lines
17 KiB
Python

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_performing_partner_user_id: Optional[int] = 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: 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_performing_partner_user_id: Optional[int] = 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_performing_partner_user_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