Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
from app.modules.clients.service import create_client_service
|
||||
|
||||
TEMPLATE_COLUMNS = [
|
||||
"uploader_user_id",
|
||||
"firm_tenant_id",
|
||||
"partner_user_id",
|
||||
"branch_id",
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"engagement_mode",
|
||||
"email",
|
||||
"portal_password",
|
||||
"portal_password_confirm",
|
||||
"mobile",
|
||||
"pan",
|
||||
"gstin",
|
||||
"tan",
|
||||
"cin_llpin",
|
||||
"msme_no",
|
||||
"iec_code",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"alternate_mobile",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"client_category",
|
||||
"risk_category",
|
||||
"onboarding_date",
|
||||
"closing_date",
|
||||
"notes",
|
||||
"status",
|
||||
"gst_applicable",
|
||||
"income_tax_applicable",
|
||||
"tds_applicable",
|
||||
"roc_applicable",
|
||||
"audit_applicable",
|
||||
"pf_applicable",
|
||||
"esi_applicable",
|
||||
"professional_tax_applicable",
|
||||
"payroll_applicable",
|
||||
"msme_applicable",
|
||||
"import_export_applicable",
|
||||
]
|
||||
|
||||
BOOL_FIELDS = {
|
||||
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
|
||||
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
|
||||
"payroll_applicable", "msme_applicable", "import_export_applicable",
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ImportPreview:
|
||||
valid_rows: list[dict]
|
||||
errors: list[dict]
|
||||
total_rows: int
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
txt = str(value).strip()
|
||||
return txt or None
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> bool:
|
||||
txt = str(value or '').strip().lower()
|
||||
return txt in {'1','true','yes','y','on'}
|
||||
|
||||
|
||||
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'clients_import'
|
||||
ws.append(TEMPLATE_COLUMNS)
|
||||
sample = [
|
||||
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
|
||||
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
|
||||
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
|
||||
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
|
||||
]
|
||||
ws.append(sample)
|
||||
ref = wb.create_sheet('instructions')
|
||||
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.'])
|
||||
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
|
||||
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
|
||||
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]:
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
values = [c.value for c in ws[row_idx]]
|
||||
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
|
||||
|
||||
|
||||
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
|
||||
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
|
||||
if missing:
|
||||
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
|
||||
|
||||
valid_rows = []
|
||||
errors = []
|
||||
active_tenant_id = int(scope.tenant_id)
|
||||
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
raw = _row_dict(ws, row_idx)
|
||||
if not any(v not in (None, '') for v in raw.values()):
|
||||
continue
|
||||
msgs: list[str] = []
|
||||
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
|
||||
|
||||
try:
|
||||
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
|
||||
except Exception:
|
||||
uploader_user_id = 0
|
||||
try:
|
||||
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
|
||||
except Exception:
|
||||
firm_tenant_id = 0
|
||||
try:
|
||||
partner_user_id = int(cleaned.get('partner_user_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):
|
||||
msgs.append('uploader_user_id must match the currently logged-in user id.')
|
||||
if firm_tenant_id != active_tenant_id:
|
||||
msgs.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
|
||||
if not partner:
|
||||
msgs.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):
|
||||
msgs.append('Partner uploader can import only for their own partner_user_id.')
|
||||
|
||||
if branch_id:
|
||||
branch = repository.get_branch(db, branch_id)
|
||||
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
||||
msgs.append('branch_id must belong to the same firm/tenant.')
|
||||
else:
|
||||
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:
|
||||
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
|
||||
|
||||
payload = {
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id or None,
|
||||
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
|
||||
'client_code': cleaned.get('client_code') or '',
|
||||
'client_name': cleaned.get('client_name') or '',
|
||||
'trade_name': None,
|
||||
'client_type': cleaned.get('client_type') or 'Other',
|
||||
'pan': cleaned.get('pan'),
|
||||
'gstin': cleaned.get('gstin'),
|
||||
'tan': cleaned.get('tan'),
|
||||
'cin_llpin': cleaned.get('cin_llpin'),
|
||||
'msme_no': cleaned.get('msme_no'),
|
||||
'iec_code': cleaned.get('iec_code'),
|
||||
'contact_person_name': cleaned.get('contact_person_name'),
|
||||
'contact_person_designation': cleaned.get('contact_person_designation'),
|
||||
'mobile': cleaned.get('mobile'),
|
||||
'alternate_mobile': cleaned.get('alternate_mobile'),
|
||||
'email': cleaned.get('email'),
|
||||
'alternate_email': cleaned.get('alternate_email'),
|
||||
'address_line_1': cleaned.get('address_line_1'),
|
||||
'address_line_2': cleaned.get('address_line_2'),
|
||||
'city': cleaned.get('city'),
|
||||
'state': cleaned.get('state'),
|
||||
'pincode': cleaned.get('pincode'),
|
||||
'country': cleaned.get('country') or 'India',
|
||||
'status': cleaned.get('status') or 'active',
|
||||
'client_category': cleaned.get('client_category'),
|
||||
'risk_category': cleaned.get('risk_category'),
|
||||
'onboarding_date': cleaned.get('onboarding_date'),
|
||||
'closing_date': cleaned.get('closing_date'),
|
||||
'notes': cleaned.get('notes'),
|
||||
'gst_applicable': cleaned.get('gst_applicable') or False,
|
||||
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
|
||||
'tds_applicable': cleaned.get('tds_applicable') or False,
|
||||
'roc_applicable': cleaned.get('roc_applicable') or False,
|
||||
'audit_applicable': cleaned.get('audit_applicable') or False,
|
||||
'pf_applicable': cleaned.get('pf_applicable') or False,
|
||||
'esi_applicable': cleaned.get('esi_applicable') or False,
|
||||
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
|
||||
'payroll_applicable': cleaned.get('payroll_applicable') or False,
|
||||
'msme_applicable': cleaned.get('msme_applicable') or False,
|
||||
'import_export_applicable': cleaned.get('import_export_applicable') or False,
|
||||
}
|
||||
|
||||
try:
|
||||
ClientCreate(**payload)
|
||||
except Exception as exc:
|
||||
msgs.append(str(exc))
|
||||
|
||||
if not cleaned.get('portal_password'):
|
||||
msgs.append('portal_password is required for imported clients.')
|
||||
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
|
||||
|
||||
valid_rows.append({
|
||||
'row_number': row_idx,
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id,
|
||||
'client_payload': payload,
|
||||
'portal_password': cleaned.get('portal_password'),
|
||||
'portal_password_confirm': cleaned.get('portal_password_confirm'),
|
||||
})
|
||||
|
||||
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
|
||||
|
||||
|
||||
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
||||
return json.dumps(valid_rows, default=str)
|
||||
|
||||
|
||||
def deserialize_preview_rows(raw: str) -> list[dict]:
|
||||
rows = json.loads(raw or '[]')
|
||||
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:
|
||||
created = []
|
||||
failures = []
|
||||
for item in preview_rows:
|
||||
try:
|
||||
data = ClientCreate(**item['client_payload'])
|
||||
row = create_client_service(
|
||||
db,
|
||||
data=data,
|
||||
actor_user_id=current_user.id,
|
||||
scope=scope,
|
||||
current_user_roles=current_user_roles,
|
||||
portal_password=item.get('portal_password'),
|
||||
portal_password_confirm=item.get('portal_password_confirm'),
|
||||
)
|
||||
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
|
||||
except Exception as exc:
|
||||
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