Align client import with PAN identity and consultant linkage
This commit is contained in:
@@ -2,17 +2,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.client_identity.service import ensure_identity, normalize_pan, placeholder_email
|
||||||
from app.modules.clients import repository
|
from app.modules.clients import repository
|
||||||
|
from app.modules.clients.models import Client
|
||||||
from app.modules.clients.schemas import ClientCreate
|
from app.modules.clients.schemas import ClientCreate
|
||||||
from app.modules.clients.service import create_client_service
|
from app.modules.clients.service import create_client_service
|
||||||
|
from app.modules.consultants.models import ConsultantProfile
|
||||||
from app.modules.consultants.service import get_consultant
|
from app.modules.consultants.service import get_consultant
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
|
||||||
|
|
||||||
TEMPLATE_COLUMNS = [
|
TEMPLATE_COLUMNS = [
|
||||||
"uploader_user_id",
|
"uploader_user_id",
|
||||||
@@ -69,11 +75,29 @@ TEMPLATE_COLUMNS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
BOOL_FIELDS = {
|
BOOL_FIELDS = {
|
||||||
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
|
"gst_applicable",
|
||||||
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
|
"income_tax_applicable",
|
||||||
"payroll_applicable", "msme_applicable", "import_export_applicable",
|
"tds_applicable",
|
||||||
|
"roc_applicable",
|
||||||
|
"audit_applicable",
|
||||||
|
"pf_applicable",
|
||||||
|
"esi_applicable",
|
||||||
|
"professional_tax_applicable",
|
||||||
|
"payroll_applicable",
|
||||||
|
"msme_applicable",
|
||||||
|
"import_export_applicable",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
||||||
|
VALID_ROUTING_MODES = {
|
||||||
|
"client_direct",
|
||||||
|
"consultant_primary",
|
||||||
|
"client_and_consultant",
|
||||||
|
"firm_only",
|
||||||
|
}
|
||||||
|
VALID_REFERRAL_STATUSES = {"active", "inactive", "ended", "pending"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImportPreview:
|
class ImportPreview:
|
||||||
valid_rows: list[dict]
|
valid_rows: list[dict]
|
||||||
@@ -84,198 +108,380 @@ class ImportPreview:
|
|||||||
def _clean(value: Any) -> str | None:
|
def _clean(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
txt = str(value).strip()
|
text = str(value).strip()
|
||||||
return txt or None
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
def _to_bool(value: Any) -> bool:
|
def _to_bool(value: Any) -> bool:
|
||||||
txt = str(value or '').strip().lower()
|
return str(value or "").strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||||
return txt in {'1','true','yes','y','on'}
|
|
||||||
|
|
||||||
|
|
||||||
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
def _to_int(value: Any) -> int | None:
|
||||||
wb = Workbook()
|
text = _clean(value)
|
||||||
ws = wb.active
|
if not text:
|
||||||
ws.title = 'clients_import'
|
return None
|
||||||
ws.append(TEMPLATE_COLUMNS)
|
try:
|
||||||
sample = [
|
return int(float(text))
|
||||||
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
|
except (TypeError, ValueError):
|
||||||
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
|
return None
|
||||||
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
|
|
||||||
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
|
|
||||||
]
|
def _normalise_email(value: Any) -> str | None:
|
||||||
ws.append(sample)
|
text = _clean(value)
|
||||||
ref = wb.create_sheet('instructions')
|
return text.lower() if text else None
|
||||||
ref.append(['Field', 'Notes'])
|
|
||||||
ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.'])
|
|
||||||
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
|
def _normalise_pan_for_preview(value: Any) -> str | None:
|
||||||
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
|
text = re.sub(r"\s+", "", str(value or "").upper())
|
||||||
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
|
return text or None
|
||||||
ref.append(['referred_by_consultant_id', 'Optional active consultant profile id who introduced the client.'])
|
|
||||||
ref.append(['primary_consultant_id', 'Optional active consultant profile id for the operational client link.'])
|
|
||||||
ref.append(['communication_routing_mode', 'client_direct, consultant_primary, client_and_consultant, or firm_only.'])
|
|
||||||
ref.append(['email', 'Used as the client frontend login email.'])
|
|
||||||
ref.append(['portal_password', 'Minimum 8 characters.'])
|
|
||||||
ref.append(['portal_password_confirm', 'Must match portal_password.'])
|
|
||||||
bio = io.BytesIO()
|
|
||||||
wb.save(bio)
|
|
||||||
return bio.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
|
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
|
||||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
headers = [str(cell.value).strip() if cell.value is not None else "" for cell in ws[1]]
|
||||||
values = [c.value for c in ws[row_idx]]
|
values = [cell.value for cell in ws[row_idx]]
|
||||||
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
|
return {
|
||||||
|
headers[index]: values[index] if index < len(values) else None
|
||||||
|
for index in range(len(headers))
|
||||||
|
if headers[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
|
def _consultant_for_id(
|
||||||
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
db: Session,
|
||||||
ws = wb[wb.sheetnames[0]]
|
*,
|
||||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
tenant_id: int,
|
||||||
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
|
value: Any,
|
||||||
|
field_name: str,
|
||||||
|
messages: list[str],
|
||||||
|
) -> int | None:
|
||||||
|
text = _clean(value)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
consultant_id = _to_int(text)
|
||||||
|
if not consultant_id:
|
||||||
|
messages.append(f"{field_name} must contain a valid consultant profile id.")
|
||||||
|
return None
|
||||||
|
consultant = get_consultant(db, tenant_id=tenant_id, consultant_id=consultant_id)
|
||||||
|
if not consultant or not consultant.is_active:
|
||||||
|
messages.append(f"{field_name} must be an active consultant profile id in the same firm.")
|
||||||
|
return None
|
||||||
|
return int(consultant.id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
worksheet = workbook.active
|
||||||
|
worksheet.title = "clients_import"
|
||||||
|
worksheet.append(TEMPLATE_COLUMNS)
|
||||||
|
|
||||||
|
sample_values = {
|
||||||
|
"uploader_user_id": current_user.id,
|
||||||
|
"firm_tenant_id": tenant_id,
|
||||||
|
"partner_user_id": partner_id or current_user.id,
|
||||||
|
"referred_by_consultant_id": "",
|
||||||
|
"primary_consultant_id": "",
|
||||||
|
"referral_date": "",
|
||||||
|
"referral_reference": "",
|
||||||
|
"referral_status": "active",
|
||||||
|
"communication_routing_mode": "client_and_consultant",
|
||||||
|
"branch_id": getattr(current_user, "branch_id", "") or "",
|
||||||
|
"client_code": "CLT-001",
|
||||||
|
"client_name": "Sample Client",
|
||||||
|
"client_type": "Other",
|
||||||
|
"engagement_mode": "internal_managed",
|
||||||
|
"email": "client@example.com",
|
||||||
|
"portal_password": "ChangeMe@123",
|
||||||
|
"portal_password_confirm": "ChangeMe@123",
|
||||||
|
"mobile": "9876543210",
|
||||||
|
"pan": "ABCDE1234F",
|
||||||
|
"gstin": "",
|
||||||
|
"tan": "",
|
||||||
|
"cin_llpin": "",
|
||||||
|
"msme_no": "",
|
||||||
|
"iec_code": "",
|
||||||
|
"contact_person_name": "Client Contact",
|
||||||
|
"contact_person_designation": "Proprietor",
|
||||||
|
"alternate_mobile": "",
|
||||||
|
"alternate_email": "",
|
||||||
|
"address_line_1": "Address line 1",
|
||||||
|
"address_line_2": "",
|
||||||
|
"city": "Chennai",
|
||||||
|
"state": "Tamil Nadu",
|
||||||
|
"pincode": "600001",
|
||||||
|
"country": "India",
|
||||||
|
"client_category": "",
|
||||||
|
"risk_category": "",
|
||||||
|
"onboarding_date": "",
|
||||||
|
"closing_date": "",
|
||||||
|
"notes": "",
|
||||||
|
"status": "active",
|
||||||
|
"gst_applicable": "yes",
|
||||||
|
"income_tax_applicable": "yes",
|
||||||
|
"tds_applicable": "no",
|
||||||
|
"roc_applicable": "no",
|
||||||
|
"audit_applicable": "no",
|
||||||
|
"pf_applicable": "no",
|
||||||
|
"esi_applicable": "no",
|
||||||
|
"professional_tax_applicable": "no",
|
||||||
|
"payroll_applicable": "no",
|
||||||
|
"msme_applicable": "no",
|
||||||
|
"import_export_applicable": "no",
|
||||||
|
}
|
||||||
|
worksheet.append([sample_values.get(column, "") for column in TEMPLATE_COLUMNS])
|
||||||
|
worksheet.freeze_panes = "A2"
|
||||||
|
worksheet.auto_filter.ref = worksheet.dimensions
|
||||||
|
|
||||||
|
instructions = workbook.create_sheet("instructions")
|
||||||
|
instructions.append(["Field", "Notes"])
|
||||||
|
notes = {
|
||||||
|
"uploader_user_id": "Must match the logged-in uploader user id exactly.",
|
||||||
|
"firm_tenant_id": "Must match the active firm/tenant context.",
|
||||||
|
"partner_user_id": "Must be an active Partner user in the same firm.",
|
||||||
|
"referred_by_consultant_id": "Optional active consultant profile id who introduced the client.",
|
||||||
|
"primary_consultant_id": "Optional active consultant profile id for the operational client link.",
|
||||||
|
"referral_date": "Optional referral date supported by the existing client schema.",
|
||||||
|
"referral_reference": "Optional referral or source reference.",
|
||||||
|
"referral_status": "active, inactive, ended, or pending.",
|
||||||
|
"communication_routing_mode": "client_direct, consultant_primary, client_and_consultant, or firm_only.",
|
||||||
|
"branch_id": "Optional. If blank, uploader or partner branch is used.",
|
||||||
|
"pan": "Required for Phase 6 PAN identity; must be unique within the firm and valid, e.g. ABCDE1234F.",
|
||||||
|
"email": "Optional only when PAN is provided. A non-deliverable @invalid.local placeholder is generated when blank.",
|
||||||
|
"portal_password": "Retained. Required for import and must contain at least 8 characters.",
|
||||||
|
"portal_password_confirm": "Retained. Must exactly match portal_password.",
|
||||||
|
}
|
||||||
|
for field, note in notes.items():
|
||||||
|
instructions.append([field, note])
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
current_user,
|
||||||
|
scope,
|
||||||
|
role_names: set[str],
|
||||||
|
upload_bytes: bytes,
|
||||||
|
) -> ImportPreview:
|
||||||
|
workbook = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
||||||
|
worksheet = workbook[workbook.sheetnames[0]]
|
||||||
|
headers = [str(cell.value).strip() if cell.value is not None else "" for cell in worksheet[1]]
|
||||||
|
missing = [column for column in TEMPLATE_COLUMNS if column not in headers]
|
||||||
if missing:
|
if missing:
|
||||||
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
|
return ImportPreview(
|
||||||
|
valid_rows=[],
|
||||||
|
errors=[{"row_number": 1, "messages": [f'Missing required columns: {", ".join(missing)}']}],
|
||||||
|
total_rows=0,
|
||||||
|
)
|
||||||
|
|
||||||
valid_rows = []
|
valid_rows: list[dict] = []
|
||||||
errors = []
|
errors: list[dict] = []
|
||||||
active_tenant_id = int(scope.tenant_id)
|
active_tenant_id = int(scope.tenant_id)
|
||||||
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
active_branch_id = int(scope.branch_id or getattr(current_user, "branch_id", 0) or 0)
|
||||||
|
seen_client_codes: dict[str, int] = {}
|
||||||
|
seen_pans: dict[str, int] = {}
|
||||||
|
seen_emails: dict[str, int] = {}
|
||||||
|
|
||||||
for row_idx in range(2, ws.max_row + 1):
|
for row_idx in range(2, worksheet.max_row + 1):
|
||||||
raw = _row_dict(ws, row_idx)
|
raw = _row_dict(worksheet, row_idx)
|
||||||
if not any(v not in (None, '') for v in raw.values()):
|
if not any(value not in (None, "") for value in raw.values()):
|
||||||
continue
|
continue
|
||||||
msgs: list[str] = []
|
|
||||||
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
|
|
||||||
|
|
||||||
try:
|
messages: list[str] = []
|
||||||
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
|
cleaned = {
|
||||||
except Exception:
|
key: (_to_bool(value) if key in BOOL_FIELDS else _clean(value))
|
||||||
uploader_user_id = 0
|
for key, value in raw.items()
|
||||||
try:
|
}
|
||||||
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
|
|
||||||
except Exception:
|
uploader_user_id = _to_int(cleaned.get("uploader_user_id")) or 0
|
||||||
firm_tenant_id = 0
|
firm_tenant_id = _to_int(cleaned.get("firm_tenant_id")) or 0
|
||||||
try:
|
partner_user_id = _to_int(cleaned.get("partner_user_id")) or 0
|
||||||
partner_user_id = int(cleaned.get('partner_user_id') or 0)
|
branch_id = _to_int(cleaned.get("branch_id")) or 0
|
||||||
except Exception:
|
|
||||||
partner_user_id = 0
|
|
||||||
try:
|
|
||||||
branch_id = int(cleaned.get('branch_id') or 0)
|
|
||||||
except Exception:
|
|
||||||
branch_id = 0
|
|
||||||
|
|
||||||
if uploader_user_id != int(current_user.id):
|
if uploader_user_id != int(current_user.id):
|
||||||
msgs.append('uploader_user_id must match the currently logged-in user id.')
|
messages.append("uploader_user_id must match the currently logged-in user id.")
|
||||||
if firm_tenant_id != active_tenant_id:
|
if firm_tenant_id != active_tenant_id:
|
||||||
msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.')
|
messages.append("firm_tenant_id must match the active firm/tenant context of the uploader.")
|
||||||
partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None
|
|
||||||
|
partner = (
|
||||||
|
repository.get_partner_for_tenant(
|
||||||
|
db,
|
||||||
|
partner_user_id=partner_user_id,
|
||||||
|
tenant_id=firm_tenant_id,
|
||||||
|
)
|
||||||
|
if partner_user_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
if not partner:
|
if not partner:
|
||||||
msgs.append('partner_user_id must belong to an active Partner user in the same firm.')
|
messages.append("partner_user_id must belong to an active Partner user in the same firm.")
|
||||||
if 'partner' in role_names and partner_user_id != int(current_user.id):
|
if "partner" in {str(name).lower() for name in role_names} and partner_user_id != int(current_user.id):
|
||||||
msgs.append('Partner uploader can import only for their own partner_user_id.')
|
messages.append("Partner uploader can import only for their own partner_user_id.")
|
||||||
|
|
||||||
if branch_id:
|
if branch_id:
|
||||||
branch = repository.get_branch(db, branch_id)
|
branch = repository.get_branch(db, branch_id)
|
||||||
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
||||||
msgs.append('branch_id must belong to the same firm/tenant.')
|
messages.append("branch_id must belong to the same firm/tenant.")
|
||||||
else:
|
else:
|
||||||
branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
branch_id = int(
|
||||||
|
getattr(partner, "branch_id", None)
|
||||||
|
or active_branch_id
|
||||||
|
or getattr(current_user, "branch_id", 0)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
if not branch_id:
|
if not branch_id:
|
||||||
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
|
messages.append("branch_id is required when uploader and partner have no branch mapped.")
|
||||||
|
|
||||||
for consultant_field in ("referred_by_consultant_id", "primary_consultant_id"):
|
referred_id = _consultant_for_id(
|
||||||
raw_consultant_id = cleaned.get(consultant_field)
|
db,
|
||||||
if raw_consultant_id:
|
tenant_id=firm_tenant_id,
|
||||||
try:
|
value=cleaned.get("referred_by_consultant_id"),
|
||||||
consultant_id = int(raw_consultant_id)
|
field_name="referred_by_consultant_id",
|
||||||
except Exception:
|
messages=messages,
|
||||||
consultant_id = 0
|
)
|
||||||
consultant = get_consultant(db, tenant_id=firm_tenant_id, consultant_id=consultant_id) if consultant_id else None
|
primary_id = _consultant_for_id(
|
||||||
if not consultant or not consultant.is_active:
|
db,
|
||||||
msgs.append(f"{consultant_field} must be an active consultant profile id in the same firm.")
|
tenant_id=firm_tenant_id,
|
||||||
|
value=cleaned.get("primary_consultant_id"),
|
||||||
|
field_name="primary_consultant_id",
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
routing_mode = (cleaned.get("communication_routing_mode") or "client_and_consultant").lower()
|
||||||
|
if routing_mode not in VALID_ROUTING_MODES:
|
||||||
|
messages.append("communication_routing_mode is invalid.")
|
||||||
|
|
||||||
|
referral_status = (cleaned.get("referral_status") or "active").lower()
|
||||||
|
if referral_status not in VALID_REFERRAL_STATUSES:
|
||||||
|
messages.append("referral_status must be active, inactive, ended, or pending.")
|
||||||
|
|
||||||
|
client_code = (cleaned.get("client_code") or "").strip()
|
||||||
|
if client_code:
|
||||||
|
code_key = client_code.upper()
|
||||||
|
if code_key in seen_client_codes:
|
||||||
|
messages.append(f"Duplicate client_code in upload; first used on row {seen_client_codes[code_key]}.")
|
||||||
|
else:
|
||||||
|
seen_client_codes[code_key] = row_idx
|
||||||
|
|
||||||
|
pan = _normalise_pan_for_preview(cleaned.get("pan"))
|
||||||
|
if not pan:
|
||||||
|
messages.append("PAN is required for Phase 6 client identity and PAN login.")
|
||||||
|
elif not PAN_RE.fullmatch(pan):
|
||||||
|
messages.append("PAN must be a valid 10-character value such as ABCDE1234F.")
|
||||||
|
else:
|
||||||
|
if pan in seen_pans:
|
||||||
|
messages.append(f"Duplicate PAN in upload; first used on row {seen_pans[pan]}.")
|
||||||
|
else:
|
||||||
|
seen_pans[pan] = row_idx
|
||||||
|
existing_pan = repository.get_client_by_pan(db, tenant_id=firm_tenant_id, pan=pan)
|
||||||
|
if existing_pan:
|
||||||
|
messages.append(
|
||||||
|
f"PAN already exists for client {existing_pan.client_code} - {existing_pan.client_name}; existing records are never overwritten by import."
|
||||||
|
)
|
||||||
|
|
||||||
|
email = _normalise_email(cleaned.get("email"))
|
||||||
|
if not email and pan and PAN_RE.fullmatch(pan):
|
||||||
|
email = placeholder_email(firm_tenant_id, pan)
|
||||||
|
if not email:
|
||||||
|
messages.append("Email is required when a valid PAN is not available for placeholder generation.")
|
||||||
|
else:
|
||||||
|
if email in seen_emails:
|
||||||
|
messages.append(f"Duplicate email in upload; first used on row {seen_emails[email]}.")
|
||||||
|
else:
|
||||||
|
seen_emails[email] = row_idx
|
||||||
|
existing_user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
messages.append("Email is already used by another ERP login.")
|
||||||
|
|
||||||
|
password = cleaned.get("portal_password") or ""
|
||||||
|
password_confirm = cleaned.get("portal_password_confirm") or ""
|
||||||
|
if not password:
|
||||||
|
messages.append("portal_password is required for imported clients.")
|
||||||
|
elif len(password) < 8:
|
||||||
|
messages.append("portal_password must contain at least 8 characters.")
|
||||||
|
if password != password_confirm:
|
||||||
|
messages.append("portal_password and portal_password_confirm must match.")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'tenant_id': firm_tenant_id,
|
"tenant_id": firm_tenant_id,
|
||||||
'branch_id': branch_id,
|
"branch_id": branch_id,
|
||||||
'partner_id': partner_user_id or None,
|
"partner_id": partner_user_id or None,
|
||||||
'referred_by_consultant_id': int(cleaned.get('referred_by_consultant_id')) if cleaned.get('referred_by_consultant_id') else None,
|
"referred_by_consultant_id": referred_id,
|
||||||
'primary_consultant_id': int(cleaned.get('primary_consultant_id')) if cleaned.get('primary_consultant_id') else None,
|
"primary_consultant_id": primary_id,
|
||||||
'referral_date': cleaned.get('referral_date'),
|
"referral_date": cleaned.get("referral_date"),
|
||||||
'referral_reference': cleaned.get('referral_reference'),
|
"referral_reference": cleaned.get("referral_reference"),
|
||||||
'referral_status': cleaned.get('referral_status') or 'active',
|
"referral_status": referral_status,
|
||||||
'communication_routing_mode': cleaned.get('communication_routing_mode') or 'client_and_consultant',
|
"communication_routing_mode": routing_mode,
|
||||||
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
|
"engagement_mode": cleaned.get("engagement_mode") or "internal_managed",
|
||||||
'client_code': cleaned.get('client_code') or '',
|
"client_code": client_code,
|
||||||
'client_name': cleaned.get('client_name') or '',
|
"client_name": cleaned.get("client_name") or "",
|
||||||
'trade_name': None,
|
"trade_name": None,
|
||||||
'client_type': cleaned.get('client_type') or 'Other',
|
"client_type": cleaned.get("client_type") or "Other",
|
||||||
'pan': cleaned.get('pan'),
|
"pan": pan,
|
||||||
'gstin': cleaned.get('gstin'),
|
"gstin": cleaned.get("gstin"),
|
||||||
'tan': cleaned.get('tan'),
|
"tan": cleaned.get("tan"),
|
||||||
'cin_llpin': cleaned.get('cin_llpin'),
|
"cin_llpin": cleaned.get("cin_llpin"),
|
||||||
'msme_no': cleaned.get('msme_no'),
|
"msme_no": cleaned.get("msme_no"),
|
||||||
'iec_code': cleaned.get('iec_code'),
|
"iec_code": cleaned.get("iec_code"),
|
||||||
'contact_person_name': cleaned.get('contact_person_name'),
|
"contact_person_name": cleaned.get("contact_person_name"),
|
||||||
'contact_person_designation': cleaned.get('contact_person_designation'),
|
"contact_person_designation": cleaned.get("contact_person_designation"),
|
||||||
'mobile': cleaned.get('mobile'),
|
"mobile": cleaned.get("mobile"),
|
||||||
'alternate_mobile': cleaned.get('alternate_mobile'),
|
"alternate_mobile": cleaned.get("alternate_mobile"),
|
||||||
'email': cleaned.get('email'),
|
"email": email,
|
||||||
'alternate_email': cleaned.get('alternate_email'),
|
"alternate_email": _normalise_email(cleaned.get("alternate_email")),
|
||||||
'address_line_1': cleaned.get('address_line_1'),
|
"address_line_1": cleaned.get("address_line_1"),
|
||||||
'address_line_2': cleaned.get('address_line_2'),
|
"address_line_2": cleaned.get("address_line_2"),
|
||||||
'city': cleaned.get('city'),
|
"city": cleaned.get("city"),
|
||||||
'state': cleaned.get('state'),
|
"state": cleaned.get("state"),
|
||||||
'pincode': cleaned.get('pincode'),
|
"pincode": cleaned.get("pincode"),
|
||||||
'country': cleaned.get('country') or 'India',
|
"country": cleaned.get("country") or "India",
|
||||||
'status': cleaned.get('status') or 'active',
|
"status": cleaned.get("status") or "active",
|
||||||
'client_category': cleaned.get('client_category'),
|
"client_category": cleaned.get("client_category"),
|
||||||
'risk_category': cleaned.get('risk_category'),
|
"risk_category": cleaned.get("risk_category"),
|
||||||
'onboarding_date': cleaned.get('onboarding_date'),
|
"onboarding_date": cleaned.get("onboarding_date"),
|
||||||
'closing_date': cleaned.get('closing_date'),
|
"closing_date": cleaned.get("closing_date"),
|
||||||
'notes': cleaned.get('notes'),
|
"notes": cleaned.get("notes"),
|
||||||
'gst_applicable': cleaned.get('gst_applicable') or False,
|
"gst_applicable": bool(cleaned.get("gst_applicable")),
|
||||||
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
|
"income_tax_applicable": bool(cleaned.get("income_tax_applicable")),
|
||||||
'tds_applicable': cleaned.get('tds_applicable') or False,
|
"tds_applicable": bool(cleaned.get("tds_applicable")),
|
||||||
'roc_applicable': cleaned.get('roc_applicable') or False,
|
"roc_applicable": bool(cleaned.get("roc_applicable")),
|
||||||
'audit_applicable': cleaned.get('audit_applicable') or False,
|
"audit_applicable": bool(cleaned.get("audit_applicable")),
|
||||||
'pf_applicable': cleaned.get('pf_applicable') or False,
|
"pf_applicable": bool(cleaned.get("pf_applicable")),
|
||||||
'esi_applicable': cleaned.get('esi_applicable') or False,
|
"esi_applicable": bool(cleaned.get("esi_applicable")),
|
||||||
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
|
"professional_tax_applicable": bool(cleaned.get("professional_tax_applicable")),
|
||||||
'payroll_applicable': cleaned.get('payroll_applicable') or False,
|
"payroll_applicable": bool(cleaned.get("payroll_applicable")),
|
||||||
'msme_applicable': cleaned.get('msme_applicable') or False,
|
"msme_applicable": bool(cleaned.get("msme_applicable")),
|
||||||
'import_export_applicable': cleaned.get('import_export_applicable') or False,
|
"import_export_applicable": bool(cleaned.get("import_export_applicable")),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ClientCreate(**payload)
|
ClientCreate(**payload)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
msgs.append(str(exc))
|
messages.append(str(exc))
|
||||||
|
|
||||||
if not cleaned.get('portal_password'):
|
if messages:
|
||||||
msgs.append('portal_password is required for imported clients.')
|
errors.append({"row_number": row_idx, "messages": messages, "row": cleaned})
|
||||||
if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'):
|
|
||||||
msgs.append('portal_password and portal_password_confirm must match.')
|
|
||||||
|
|
||||||
# intra-file duplicate client codes
|
|
||||||
if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows):
|
|
||||||
msgs.append('Duplicate client_code found within the same upload file.')
|
|
||||||
|
|
||||||
if msgs:
|
|
||||||
errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned})
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
valid_rows.append({
|
valid_rows.append(
|
||||||
'row_number': row_idx,
|
{
|
||||||
'tenant_id': firm_tenant_id,
|
"row_number": row_idx,
|
||||||
'branch_id': branch_id,
|
"tenant_id": firm_tenant_id,
|
||||||
'partner_id': partner_user_id,
|
"branch_id": branch_id,
|
||||||
'client_payload': payload,
|
"partner_id": partner_user_id,
|
||||||
'portal_password': cleaned.get('portal_password'),
|
"client_payload": payload,
|
||||||
'portal_password_confirm': cleaned.get('portal_password_confirm'),
|
"portal_password": password,
|
||||||
})
|
"portal_password_confirm": password_confirm,
|
||||||
|
"email_was_placeholder": email.endswith("@invalid.local"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
|
return ImportPreview(
|
||||||
|
valid_rows=valid_rows,
|
||||||
|
errors=errors,
|
||||||
|
total_rows=len(valid_rows) + len(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
||||||
@@ -283,26 +489,88 @@ def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def deserialize_preview_rows(raw: str) -> list[dict]:
|
def deserialize_preview_rows(raw: str) -> list[dict]:
|
||||||
rows = json.loads(raw or '[]')
|
rows = json.loads(raw or "[]")
|
||||||
return rows if isinstance(rows, list) else []
|
return rows if isinstance(rows, list) else []
|
||||||
|
|
||||||
|
|
||||||
def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict:
|
def commit_import(
|
||||||
created = []
|
db: Session,
|
||||||
failures = []
|
*,
|
||||||
|
current_user,
|
||||||
|
scope,
|
||||||
|
current_user_roles: list[str],
|
||||||
|
preview_rows: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
created: list[dict] = []
|
||||||
|
failures: list[dict] = []
|
||||||
|
|
||||||
for item in preview_rows:
|
for item in preview_rows:
|
||||||
try:
|
try:
|
||||||
data = ClientCreate(**item['client_payload'])
|
payload = dict(item["client_payload"])
|
||||||
|
pan = normalize_pan(payload.get("pan"))
|
||||||
|
payload["pan"] = pan
|
||||||
|
payload["email"] = (
|
||||||
|
_normalise_email(payload.get("email"))
|
||||||
|
or placeholder_email(int(payload["tenant_id"]), pan)
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_pan = repository.get_client_by_pan(
|
||||||
|
db,
|
||||||
|
tenant_id=int(payload["tenant_id"]),
|
||||||
|
pan=pan,
|
||||||
|
)
|
||||||
|
if existing_pan:
|
||||||
|
raise ValueError(
|
||||||
|
f"PAN already exists for client {existing_pan.client_code} - {existing_pan.client_name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_user = db.execute(
|
||||||
|
select(User).where(User.email == payload["email"])
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
raise ValueError("Email is already used by another ERP login.")
|
||||||
|
|
||||||
|
data = ClientCreate(**payload)
|
||||||
row = create_client_service(
|
row = create_client_service(
|
||||||
db,
|
db,
|
||||||
data=data,
|
data=data,
|
||||||
actor_user_id=current_user.id,
|
actor_user_id=current_user.id,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
current_user_roles=current_user_roles,
|
current_user_roles=current_user_roles,
|
||||||
portal_password=item.get('portal_password'),
|
portal_password=item.get("portal_password"),
|
||||||
portal_password_confirm=item.get('portal_password_confirm'),
|
portal_password_confirm=item.get("portal_password_confirm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = ensure_identity(
|
||||||
|
db,
|
||||||
|
client=row,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
requested_email=row.email,
|
||||||
|
)
|
||||||
|
if identity.status == "not_invited" and row.portal_user_id:
|
||||||
|
identity.status = "active"
|
||||||
|
identity.activated_at_utc = identity.activated_at_utc or identity.updated_at_utc
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
created.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"client_code": row.client_code,
|
||||||
|
"client_name": row.client_name,
|
||||||
|
"pan": row.pan,
|
||||||
|
"portal_user_id": row.portal_user_id,
|
||||||
|
"identity_id": identity.id,
|
||||||
|
"login_email": identity.login_email,
|
||||||
|
"email_is_placeholder": bool(identity.email_is_placeholder),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))})
|
db.rollback()
|
||||||
return {'created': created, 'failures': failures}
|
failures.append(
|
||||||
|
{
|
||||||
|
"row_number": item.get("row_number"),
|
||||||
|
"message": str(getattr(exc, "detail", exc)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"created": created, "failures": failures}
|
||||||
|
|||||||
Reference in New Issue
Block a user