from __future__ import annotations from io import BytesIO from typing import Any from openpyxl import Workbook, load_workbook from openpyxl.styles import Font from sqlalchemy import select from sqlalchemy.orm import Session from app.modules.services.models import FirmServiceTaskTemplate, ServiceCatalogue def build_import_template_workbook() -> bytes: wb = Workbook() ws_services = wb.active ws_services.title = "service_catalogue" service_headers = [ "service_code", "service_name", "category", "description", "recurrence_type", "is_active", "is_client_requestable", "is_consultant_requestable", ] ws_services.append(service_headers) ws_services.append([ "GST-MONTHLY", "GST Monthly Return Filing", "GST", "Monthly GST compliance service", "MONTHLY", "TRUE", "TRUE", "FALSE", ]) ws_tasks = wb.create_sheet("firm_task_templates") task_headers = [ "service_code", "sequence_no", "task_name", "description", "default_role_name", "sla_days", "is_mandatory", "requires_review", "is_active", ] ws_tasks.append(task_headers) ws_tasks.append([ "GST-MONTHLY", 1, "Collect Purchase and Sales Data", "Collect source data from client", "Staff", 3, "TRUE", "FALSE", "TRUE", ]) ws_tasks.append([ "GST-MONTHLY", 2, "Review and File Return", "Manager review and final filing", "Partner", 2, "TRUE", "TRUE", "TRUE", ]) for ws in [ws_services, ws_tasks]: for cell in ws[1]: cell.font = Font(bold=True) for col in ws.columns: max_len = 0 col_letter = col[0].column_letter for cell in col: val = "" if cell.value is None else str(cell.value) max_len = max(max_len, len(val)) ws.column_dimensions[col_letter].width = min(max(max_len + 2, 14), 40) out = BytesIO() wb.save(out) return out.getvalue() def _norm_text(value: Any) -> str: if value is None: return "" return str(value).strip() def _norm_upper(value: Any) -> str: return _norm_text(value).upper() def _norm_bool(value: Any, default: bool = False) -> bool: if value is None or value == "": return default if isinstance(value, bool): return value return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} def _norm_int(value: Any, default: int | None = None) -> int | None: if value is None or value == "": return default try: return int(value) except Exception: return default def _sheet_headers(ws) -> dict[str, int]: headers = {} first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), []) for idx, val in enumerate(first_row): key = _norm_text(val).lower() if key: headers[key] = idx return headers def _cell(row: tuple, headers: dict[str, int], key: str) -> Any: idx = headers.get(key.lower()) if idx is None or idx >= len(row): return None return row[idx] def parse_import_workbook(file_bytes: bytes) -> dict: wb = load_workbook(BytesIO(file_bytes), data_only=True) errors: list[str] = [] catalogue_rows: list[dict] = [] task_rows: list[dict] = [] if "service_catalogue" not in wb.sheetnames: return {"ok": False, "errors": ["Workbook must contain a sheet named 'service_catalogue'."], "catalogue_rows": [], "task_rows": []} ws_services = wb["service_catalogue"] headers = _sheet_headers(ws_services) for h in ["service_code", "service_name"]: if h not in headers: errors.append(f"Service catalogue sheet missing required column: {h}") for row_no, row in enumerate(ws_services.iter_rows(min_row=2, values_only=True), start=2): service_code = _norm_upper(_cell(row, headers, "service_code")) service_name = _norm_text(_cell(row, headers, "service_name")) if not service_code and not service_name: continue if not service_code: errors.append(f"Service catalogue row {row_no}: service_code is required.") continue if not service_name: errors.append(f"Service catalogue row {row_no}: service_name is required.") continue catalogue_rows.append({ "service_code": service_code, "service_name": service_name, "category": _norm_text(_cell(row, headers, "category")) or None, "description": _norm_text(_cell(row, headers, "description")) or None, "recurrence_type": _norm_text(_cell(row, headers, "recurrence_type")) or None, "is_active": _norm_bool(_cell(row, headers, "is_active"), True), "is_client_requestable": _norm_bool(_cell(row, headers, "is_client_requestable"), False), "is_consultant_requestable": _norm_bool(_cell(row, headers, "is_consultant_requestable"), False), }) if "firm_task_templates" in wb.sheetnames: ws_tasks = wb["firm_task_templates"] task_headers = _sheet_headers(ws_tasks) for h in ["service_code", "sequence_no", "task_name"]: if h not in task_headers: errors.append(f"Firm task templates sheet missing required column: {h}") for row_no, row in enumerate(ws_tasks.iter_rows(min_row=2, values_only=True), start=2): service_code = _norm_upper(_cell(row, task_headers, "service_code")) task_name = _norm_text(_cell(row, task_headers, "task_name")) sequence_no = _norm_int(_cell(row, task_headers, "sequence_no")) if not service_code and not task_name: continue if not service_code: errors.append(f"Firm task templates row {row_no}: service_code is required.") continue if not task_name: errors.append(f"Firm task templates row {row_no}: task_name is required.") continue if sequence_no is None: errors.append(f"Firm task templates row {row_no}: sequence_no must be numeric.") continue task_rows.append({ "service_code": service_code, "sequence_no": sequence_no, "task_name": task_name, "description": _norm_text(_cell(row, task_headers, "description")) or None, "default_role_name": _norm_text(_cell(row, task_headers, "default_role_name")) or None, "sla_days": _norm_int(_cell(row, task_headers, "sla_days")), "is_mandatory": _norm_bool(_cell(row, task_headers, "is_mandatory"), True), "requires_review": _norm_bool(_cell(row, task_headers, "requires_review"), False), "is_active": _norm_bool(_cell(row, task_headers, "is_active"), True), }) service_codes = {row["service_code"] for row in catalogue_rows} for row in task_rows: if row["service_code"] not in service_codes: errors.append(f"Task row for service_code '{row['service_code']}' does not match any service in service_catalogue sheet.") return {"ok": len(errors) == 0, "errors": errors, "catalogue_rows": catalogue_rows, "task_rows": task_rows} def apply_import_payload( db: Session, *, tenant_id: int, branch_id: int | None, actor_user_id: int, catalogue_rows: list[dict], task_rows: list[dict], ) -> dict: created_catalogue = 0 updated_catalogue = 0 created_tasks = 0 updated_tasks = 0 catalogue_map: dict[str, ServiceCatalogue] = {} for row in catalogue_rows: service = db.execute(select(ServiceCatalogue).where(ServiceCatalogue.service_code == row["service_code"])).scalar_one_or_none() if service is None: service = ServiceCatalogue( service_code=row["service_code"], service_name=row["service_name"], category=row["category"], description=row["description"], recurrence_type=row["recurrence_type"], is_active=row["is_active"], is_client_requestable=row["is_client_requestable"], is_consultant_requestable=row["is_consultant_requestable"], created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, ) db.add(service) db.flush() created_catalogue += 1 else: service.service_name = row["service_name"] service.category = row["category"] service.description = row["description"] service.recurrence_type = row["recurrence_type"] service.is_active = row["is_active"] service.is_client_requestable = row["is_client_requestable"] service.is_consultant_requestable = row["is_consultant_requestable"] service.updated_by_user_id = actor_user_id updated_catalogue += 1 catalogue_map[row["service_code"]] = service for row in task_rows: service = catalogue_map[row["service_code"]] task = db.execute( select(FirmServiceTaskTemplate).where( FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.service_catalogue_id == service.id, FirmServiceTaskTemplate.sequence_no == row["sequence_no"], ) ).scalar_one_or_none() if task is None: task = FirmServiceTaskTemplate( tenant_id=tenant_id, branch_id=branch_id, service_catalogue_id=service.id, sequence_no=row["sequence_no"], task_name=row["task_name"], description=row["description"], default_role_name=row["default_role_name"], sla_days=row["sla_days"], is_mandatory=row["is_mandatory"], requires_review=row["requires_review"], is_active=row["is_active"], created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, ) db.add(task) created_tasks += 1 else: task.task_name = row["task_name"] task.description = row["description"] task.default_role_name = row["default_role_name"] task.sla_days = row["sla_days"] task.is_mandatory = row["is_mandatory"] task.requires_review = row["requires_review"] task.is_active = row["is_active"] task.branch_id = branch_id task.updated_by_user_id = actor_user_id updated_tasks += 1 db.commit() return { "created_catalogue": created_catalogue, "updated_catalogue": updated_catalogue, "created_tasks": created_tasks, "updated_tasks": updated_tasks, }