424 lines
18 KiB
Python
424 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from fastapi import UploadFile
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from app.core.settings import get_settings
|
|
from app.modules.clients.models import Client
|
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
|
from app.modules.notice_cases.models import (
|
|
NoticeCase,
|
|
NoticeCaseDocument,
|
|
NoticeCaseEvent,
|
|
NoticeCaseHearing,
|
|
NoticeCaseOrder,
|
|
)
|
|
from app.modules.services.models import ClientServiceSubscription
|
|
|
|
DEPARTMENTS = ["GST", "Income Tax", "ROC", "PF", "ESI", "Labour", "MSME", "Other"]
|
|
CASE_TYPES = ["Notice", "Assessment", "Appeal", "Rectification", "Refund", "Registration", "Investigation", "Other"]
|
|
CASE_STATUSES = ["open", "reply_pending", "reply_filed", "hearing_scheduled", "order_received", "appeal_pending", "appeal_filed", "closed", "archived"]
|
|
CASE_PRIORITIES = ["low", "normal", "high", "urgent"]
|
|
EVENT_TYPES = ["Notice Received", "Reply Filed", "Hearing", "Order Received", "Appeal Filed", "Rectification Filed", "Payment Made", "Internal Note", "Client Clarification", "Other"]
|
|
HEARING_STATUSES = ["scheduled", "attended", "adjourned", "missed", "cancelled"]
|
|
ORDER_TYPES = ["Assessment Order", "Appeal Order", "Rectification Order", "Refund Order", "Penalty Order", "Other"]
|
|
CASE_DOCUMENT_TYPES = ["NOTICE", "REPLY", "APPEAL", "ORDER", "CHALLAN", "WORKING", "CLIENT_DOCUMENT", "ACKNOWLEDGEMENT", "OTHER"]
|
|
|
|
|
|
def _has(permissions: list[str], code: str) -> bool:
|
|
return code in set(permissions or [])
|
|
|
|
|
|
def can_view_notice_cases(db: Session, user) -> bool:
|
|
perms = get_user_permissions(db, user.id)
|
|
return _has(perms, "notice_cases.view")
|
|
|
|
|
|
def can_manage_notice_cases(db: Session, user) -> bool:
|
|
perms = get_user_permissions(db, user.id)
|
|
return _has(perms, "notice_cases.create") or _has(perms, "notice_cases.edit")
|
|
|
|
|
|
def can_upload_notice_case_documents(db: Session, user) -> bool:
|
|
perms = get_user_permissions(db, user.id)
|
|
return _has(perms, "notice_cases.documents.upload")
|
|
|
|
|
|
def can_delete_notice_case_documents(db: Session, user) -> bool:
|
|
perms = get_user_permissions(db, user.id)
|
|
return _has(perms, "notice_cases.documents.delete")
|
|
|
|
|
|
def user_can_access_case(
|
|
db: Session,
|
|
user,
|
|
case: NoticeCase,
|
|
*,
|
|
active_tenant_id: int | None,
|
|
active_branch_id: int | None,
|
|
active_financial_year: str | None = None,
|
|
active_assessment_year: str | None = None,
|
|
) -> bool:
|
|
roles = set(get_user_roles(db, user.id))
|
|
perms = set(get_user_permissions(db, user.id))
|
|
if "System Admin" in roles and "notice_cases.cross_tenant" in perms:
|
|
return active_tenant_id in (None, case.tenant_id) or True
|
|
if case.tenant_id != int(active_tenant_id or getattr(user, "tenant_id", 0) or 0):
|
|
return False
|
|
if active_branch_id and case.branch_id and case.branch_id != int(active_branch_id):
|
|
return False
|
|
if "notice_cases.view" not in perms:
|
|
return False
|
|
fy = (active_financial_year or "").strip()
|
|
ay = (active_assessment_year or "").strip()
|
|
if fy and (case.financial_year or "").strip() and (case.financial_year or "").strip() != fy:
|
|
return False
|
|
if ay and not fy and (case.assessment_year or "").strip() and (case.assessment_year or "").strip() != ay:
|
|
return False
|
|
if "notice_cases.view.own_only" in perms and not ({case.assigned_partner_user_id, case.assigned_manager_user_id, case.assigned_staff_user_id} & {user.id}):
|
|
return False
|
|
return True
|
|
|
|
|
|
def parse_date(value: str | None) -> date | None:
|
|
value = (value or "").strip()
|
|
if not value:
|
|
return None
|
|
return date.fromisoformat(value)
|
|
|
|
|
|
def normalize_choice(value: str | None, choices: list[str], default: str) -> str:
|
|
value = (value or "").strip()
|
|
return value if value in choices else default
|
|
|
|
|
|
def make_case_code(db: Session, tenant_id: int, case_id: int, department: str) -> str:
|
|
prefix = "CASE"
|
|
dept = (department or "GEN").upper().replace(" ", "")[:4]
|
|
return f"{prefix}-{tenant_id}-{dept}-{case_id:06d}"
|
|
|
|
|
|
def list_clients_for_case(db: Session, *, tenant_id: int, branch_id: int | None) -> list[Client]:
|
|
stmt = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False))
|
|
if branch_id:
|
|
stmt = stmt.where(Client.branch_id == branch_id)
|
|
return list(db.execute(stmt.order_by(Client.client_name.asc())).scalars())
|
|
|
|
|
|
def list_engagements_for_client(
|
|
db: Session,
|
|
*,
|
|
tenant_id: int,
|
|
client_id: int,
|
|
financial_year: str | None = None,
|
|
) -> list[ClientServiceSubscription]:
|
|
stmt = select(ClientServiceSubscription).where(
|
|
ClientServiceSubscription.tenant_id == tenant_id,
|
|
ClientServiceSubscription.client_id == client_id,
|
|
)
|
|
if financial_year:
|
|
stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
|
return list(
|
|
db.execute(
|
|
stmt.order_by(ClientServiceSubscription.financial_year.desc(), ClientServiceSubscription.id.desc())
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None):
|
|
from app.modules.core.iam.models import User
|
|
|
|
stmt = select(User).where(User.tenant_id == tenant_id)
|
|
if branch_id:
|
|
stmt = stmt.where(or_(User.branch_id == branch_id, User.branch_id.is_(None)))
|
|
order_cols = [User.full_name.asc()]
|
|
if hasattr(User, "login_id"):
|
|
order_cols.append(User.login_id.asc())
|
|
elif hasattr(User, "email"):
|
|
order_cols.append(User.email.asc())
|
|
return list(db.execute(stmt.order_by(*order_cols)).scalars())
|
|
|
|
|
|
def list_cases(
|
|
db: Session,
|
|
*,
|
|
tenant_id: int,
|
|
branch_id: int | None,
|
|
q: str = "",
|
|
department: str = "",
|
|
status: str = "",
|
|
include_archived: bool = False,
|
|
financial_year: str | None = None,
|
|
assessment_year: str | None = None,
|
|
) -> list[NoticeCase]:
|
|
stmt = (
|
|
select(NoticeCase)
|
|
.options(joinedload(NoticeCase.client), joinedload(NoticeCase.assigned_partner), joinedload(NoticeCase.assigned_manager), joinedload(NoticeCase.assigned_staff))
|
|
.where(NoticeCase.tenant_id == tenant_id)
|
|
)
|
|
if branch_id:
|
|
stmt = stmt.where(NoticeCase.branch_id == branch_id)
|
|
if not include_archived:
|
|
stmt = stmt.where(NoticeCase.is_archived.is_(False))
|
|
if department:
|
|
stmt = stmt.where(NoticeCase.department == department)
|
|
if status:
|
|
stmt = stmt.where(NoticeCase.status == status)
|
|
fy = (financial_year or "").strip()
|
|
ay = (assessment_year or "").strip()
|
|
if fy:
|
|
stmt = stmt.where(NoticeCase.financial_year == fy)
|
|
elif ay:
|
|
stmt = stmt.where(NoticeCase.assessment_year == ay)
|
|
q = (q or "").strip()
|
|
if q:
|
|
like = f"%{q}%"
|
|
stmt = stmt.join(Client, Client.id == NoticeCase.client_id).where(
|
|
or_(
|
|
NoticeCase.case_code.ilike(like),
|
|
NoticeCase.title.ilike(like),
|
|
NoticeCase.reference_no.ilike(like),
|
|
NoticeCase.din_ack_no.ilike(like),
|
|
Client.client_name.ilike(like),
|
|
Client.client_code.ilike(like),
|
|
Client.pan.ilike(like),
|
|
Client.gstin.ilike(like),
|
|
)
|
|
)
|
|
return list(db.execute(stmt.order_by(NoticeCase.due_date.asc().nullslast(), NoticeCase.updated_at_utc.desc())).unique().scalars())
|
|
|
|
|
|
def case_dashboard_summary(rows: list[NoticeCase]) -> dict[str, int]:
|
|
today = date.today()
|
|
open_statuses = {"open", "reply_pending", "hearing_scheduled", "appeal_pending"}
|
|
summary = {
|
|
"total": len(rows),
|
|
"open": 0,
|
|
"reply_pending": 0,
|
|
"hearing_scheduled": 0,
|
|
"overdue": 0,
|
|
"closed": 0,
|
|
}
|
|
for row in rows:
|
|
status = (row.status or "").strip().lower()
|
|
if status in open_statuses:
|
|
summary["open"] += 1
|
|
if status == "reply_pending":
|
|
summary["reply_pending"] += 1
|
|
if status == "hearing_scheduled":
|
|
summary["hearing_scheduled"] += 1
|
|
if status in {"closed", "archived"}:
|
|
summary["closed"] += 1
|
|
if row.due_date and row.due_date < today and status not in {"closed", "archived"}:
|
|
summary["overdue"] += 1
|
|
return summary
|
|
|
|
|
|
def get_case(db: Session, case_id: int) -> NoticeCase | None:
|
|
return db.execute(
|
|
select(NoticeCase)
|
|
.options(
|
|
joinedload(NoticeCase.client),
|
|
joinedload(NoticeCase.engagement),
|
|
joinedload(NoticeCase.assigned_partner),
|
|
joinedload(NoticeCase.assigned_manager),
|
|
joinedload(NoticeCase.assigned_staff),
|
|
joinedload(NoticeCase.events),
|
|
joinedload(NoticeCase.hearings),
|
|
joinedload(NoticeCase.orders),
|
|
joinedload(NoticeCase.documents).joinedload(NoticeCaseDocument.uploaded_by),
|
|
)
|
|
.where(NoticeCase.id == case_id)
|
|
).unique().scalar_one_or_none()
|
|
|
|
|
|
def create_case(db: Session, *, tenant_id: int, branch_id: int | None, user, data: dict) -> NoticeCase:
|
|
client = db.get(Client, int(data["client_id"]))
|
|
if not client or client.tenant_id != tenant_id:
|
|
raise ValueError("Invalid client selected.")
|
|
if branch_id and client.branch_id != branch_id:
|
|
raise ValueError("Selected client does not belong to the active branch.")
|
|
engagement_id = int(data["engagement_id"]) if data.get("engagement_id") else None
|
|
active_fy = (data.get("active_financial_year") or "").strip()[:9] or None
|
|
active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None
|
|
selected_fy = (data.get("financial_year") or "").strip()[:9] or active_fy
|
|
selected_ay = (data.get("assessment_year") or "").strip()[:9] or active_ay
|
|
if engagement_id:
|
|
engagement = db.get(ClientServiceSubscription, engagement_id)
|
|
if not engagement or engagement.tenant_id != tenant_id or engagement.client_id != client.id:
|
|
raise ValueError("Invalid engagement selected.")
|
|
engagement_fy = (engagement.financial_year or "").strip()
|
|
engagement_ay = (engagement.assessment_year or "").strip()
|
|
if selected_fy and engagement_fy and selected_fy != engagement_fy:
|
|
raise ValueError("Selected engagement belongs to a different financial year.")
|
|
selected_fy = selected_fy or engagement_fy or None
|
|
selected_ay = selected_ay or engagement_ay or None
|
|
row = NoticeCase(
|
|
tenant_id=tenant_id,
|
|
branch_id=client.branch_id,
|
|
client_id=client.id,
|
|
engagement_id=engagement_id,
|
|
case_code="PENDING",
|
|
department=normalize_choice(data.get("department"), DEPARTMENTS, "GST"),
|
|
case_type=normalize_choice(data.get("case_type"), CASE_TYPES, "Notice"),
|
|
title=(data.get("title") or "").strip()[:255],
|
|
reference_no=(data.get("reference_no") or "").strip()[:150] or None,
|
|
din_ack_no=(data.get("din_ack_no") or "").strip()[:150] or None,
|
|
notice_date=parse_date(data.get("notice_date")),
|
|
due_date=parse_date(data.get("due_date")),
|
|
financial_year=selected_fy,
|
|
assessment_year=selected_ay,
|
|
period_label=(data.get("period_label") or "").strip()[:60] or None,
|
|
status=normalize_choice(data.get("status"), CASE_STATUSES, "open"),
|
|
priority=normalize_choice(data.get("priority"), CASE_PRIORITIES, "normal"),
|
|
issue_summary=(data.get("issue_summary") or "").strip() or None,
|
|
remarks=(data.get("remarks") or "").strip() or None,
|
|
assigned_partner_user_id=int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None,
|
|
assigned_manager_user_id=int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None,
|
|
assigned_staff_user_id=int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
if not row.title:
|
|
raise ValueError("Case title is required.")
|
|
db.add(row)
|
|
db.flush()
|
|
row.case_code = make_case_code(db, tenant_id, row.id, row.department)
|
|
return row
|
|
|
|
|
|
def update_case(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCase:
|
|
case.department = normalize_choice(data.get("department"), DEPARTMENTS, case.department)
|
|
case.case_type = normalize_choice(data.get("case_type"), CASE_TYPES, case.case_type)
|
|
case.title = (data.get("title") or case.title).strip()[:255]
|
|
case.reference_no = (data.get("reference_no") or "").strip()[:150] or None
|
|
case.din_ack_no = (data.get("din_ack_no") or "").strip()[:150] or None
|
|
case.notice_date = parse_date(data.get("notice_date"))
|
|
case.due_date = parse_date(data.get("due_date"))
|
|
active_fy = (data.get("active_financial_year") or "").strip()[:9] or None
|
|
active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None
|
|
case.financial_year = (data.get("financial_year") or "").strip()[:9] or active_fy
|
|
case.assessment_year = (data.get("assessment_year") or "").strip()[:9] or active_ay
|
|
case.period_label = (data.get("period_label") or "").strip()[:60] or None
|
|
case.status = normalize_choice(data.get("status"), CASE_STATUSES, case.status)
|
|
case.priority = normalize_choice(data.get("priority"), CASE_PRIORITIES, case.priority)
|
|
case.issue_summary = (data.get("issue_summary") or "").strip() or None
|
|
case.remarks = (data.get("remarks") or "").strip() or None
|
|
case.assigned_partner_user_id = int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None
|
|
case.assigned_manager_user_id = int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None
|
|
case.assigned_staff_user_id = int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None
|
|
case.updated_by_user_id = user.id
|
|
return case
|
|
|
|
|
|
def add_event(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseEvent:
|
|
row = NoticeCaseEvent(
|
|
tenant_id=case.tenant_id,
|
|
branch_id=case.branch_id,
|
|
case_id=case.id,
|
|
event_type=normalize_choice(data.get("event_type"), EVENT_TYPES, "Internal Note"),
|
|
event_date=parse_date(data.get("event_date")) or date.today(),
|
|
description=(data.get("description") or "").strip(),
|
|
next_due_date=parse_date(data.get("next_due_date")),
|
|
created_by_user_id=user.id,
|
|
)
|
|
if not row.description:
|
|
raise ValueError("Event description is required.")
|
|
if row.next_due_date:
|
|
case.due_date = row.next_due_date
|
|
case.updated_by_user_id = user.id
|
|
db.add(row)
|
|
return row
|
|
|
|
|
|
def add_hearing(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseHearing:
|
|
row = NoticeCaseHearing(
|
|
tenant_id=case.tenant_id,
|
|
branch_id=case.branch_id,
|
|
case_id=case.id,
|
|
hearing_date=parse_date(data.get("hearing_date")) or date.today(),
|
|
hearing_time=(data.get("hearing_time") or "").strip()[:20] or None,
|
|
venue_or_mode=(data.get("venue_or_mode") or "").strip()[:200] or None,
|
|
officer_name=(data.get("officer_name") or "").strip()[:150] or None,
|
|
agenda=(data.get("agenda") or "").strip() or None,
|
|
outcome=(data.get("outcome") or "").strip() or None,
|
|
status=normalize_choice(data.get("status"), HEARING_STATUSES, "scheduled"),
|
|
created_by_user_id=user.id,
|
|
)
|
|
case.status = "hearing_scheduled" if row.status == "scheduled" else case.status
|
|
case.due_date = row.hearing_date
|
|
case.updated_by_user_id = user.id
|
|
db.add(row)
|
|
return row
|
|
|
|
|
|
def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseOrder:
|
|
row = NoticeCaseOrder(
|
|
tenant_id=case.tenant_id,
|
|
branch_id=case.branch_id,
|
|
case_id=case.id,
|
|
order_type=normalize_choice(data.get("order_type"), ORDER_TYPES, "Other"),
|
|
order_no=(data.get("order_no") or "").strip()[:150] or None,
|
|
order_date=parse_date(data.get("order_date")) or date.today(),
|
|
demand_amount=int(data.get("demand_amount") or 0),
|
|
tax_amount=int(data.get("tax_amount") or 0),
|
|
interest_amount=int(data.get("interest_amount") or 0),
|
|
penalty_amount=int(data.get("penalty_amount") or 0),
|
|
summary=(data.get("summary") or "").strip() or None,
|
|
appeal_due_date=parse_date(data.get("appeal_due_date")),
|
|
appeal_filed=bool(data.get("appeal_filed")),
|
|
created_by_user_id=user.id,
|
|
)
|
|
case.status = "appeal_filed" if row.appeal_filed else "order_received"
|
|
case.due_date = row.appeal_due_date
|
|
case.updated_by_user_id = user.id
|
|
db.add(row)
|
|
return row
|
|
|
|
|
|
def _storage_root() -> Path:
|
|
settings = get_settings()
|
|
base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage"
|
|
return Path(base)
|
|
|
|
|
|
def case_document_absolute_path(document: NoticeCaseDocument) -> Path:
|
|
return _storage_root() / document.local_relative_path
|
|
|
|
|
|
def save_case_document(db: Session, *, case: NoticeCase, upload_file: UploadFile, user, title: str, document_type: str, description: str | None, event_id: int | None = None) -> NoticeCaseDocument:
|
|
original = Path(upload_file.filename or "case_document.bin").name
|
|
ext = Path(original).suffix.lower()
|
|
stored = f"case_{case.id}_{uuid4().hex}{ext}"
|
|
fy_folder = f"FY{case.financial_year}" if case.financial_year else "FY_UNASSIGNED"
|
|
rel = Path("notice_cases") / fy_folder / str(case.tenant_id) / str(case.client_id) / case.case_code / stored
|
|
absolute = _storage_root() / rel
|
|
absolute.parent.mkdir(parents=True, exist_ok=True)
|
|
data = upload_file.file.read()
|
|
absolute.write_bytes(data)
|
|
latest_version = db.execute(select(func.max(NoticeCaseDocument.version_no)).where(NoticeCaseDocument.case_id == case.id, NoticeCaseDocument.title == (title or original))).scalar_one() or 0
|
|
row = NoticeCaseDocument(
|
|
tenant_id=case.tenant_id,
|
|
branch_id=case.branch_id,
|
|
case_id=case.id,
|
|
event_id=event_id,
|
|
document_type=normalize_choice(document_type, CASE_DOCUMENT_TYPES, "OTHER"),
|
|
title=(title or original).strip()[:255],
|
|
description=(description or "").strip() or None,
|
|
original_filename=original,
|
|
stored_filename=stored,
|
|
content_type=upload_file.content_type,
|
|
file_size_bytes=len(data),
|
|
local_relative_path=str(rel).replace("\\", "/"),
|
|
version_no=int(latest_version) + 1,
|
|
uploaded_by_user_id=user.id,
|
|
)
|
|
db.add(row)
|
|
case.updated_by_user_id = user.id
|
|
return row
|