386 lines
12 KiB
Python
386 lines
12 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,
|
|
)
|
|
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
|
|
default_review_partner_user_id: Optional[int] = None
|
|
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
|
|
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", "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("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("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
|
|
default_review_partner_user_id: Optional[int] = 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
|
|
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", "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("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("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
|
|
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 |