Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Notice and case management module."""
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class NoticeCase(CommonBase):
|
||||
"""Department notice / case / appeal master.
|
||||
|
||||
A case is intentionally kept separate from engagements because GST/Income Tax/ROC
|
||||
proceedings can continue across years, have independent due dates, and may later
|
||||
be linked to one or more engagements/tasks.
|
||||
"""
|
||||
|
||||
__tablename__ = "notice_cases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "case_code", name="uq_notice_cases_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
case_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
department: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
case_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
reference_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
|
||||
din_ack_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
|
||||
|
||||
notice_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
period_label: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="open", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True)
|
||||
issue_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
assigned_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
assigned_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
archived_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
engagement = relationship("ClientServiceSubscription")
|
||||
assigned_partner = relationship("User", foreign_keys=[assigned_partner_user_id])
|
||||
assigned_manager = relationship("User", foreign_keys=[assigned_manager_user_id])
|
||||
assigned_staff = relationship("User", foreign_keys=[assigned_staff_user_id])
|
||||
events = relationship("NoticeCaseEvent", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseEvent.event_date.desc(), NoticeCaseEvent.id.desc()")
|
||||
hearings = relationship("NoticeCaseHearing", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseHearing.hearing_date.asc(), NoticeCaseHearing.id.asc()")
|
||||
orders = relationship("NoticeCaseOrder", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseOrder.order_date.desc(), NoticeCaseOrder.id.desc()")
|
||||
documents = relationship("NoticeCaseDocument", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseDocument.uploaded_at_utc.desc()")
|
||||
|
||||
|
||||
class NoticeCaseEvent(CommonBase):
|
||||
__tablename__ = "notice_case_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
next_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
case = relationship("NoticeCase", back_populates="events")
|
||||
created_by = relationship("User")
|
||||
|
||||
|
||||
class NoticeCaseHearing(CommonBase):
|
||||
__tablename__ = "notice_case_hearings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
hearing_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
hearing_time: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
venue_or_mode: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
officer_name: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
agenda: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
outcome: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="scheduled", index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
case = relationship("NoticeCase", back_populates="hearings")
|
||||
created_by = relationship("User")
|
||||
|
||||
|
||||
class NoticeCaseOrder(CommonBase):
|
||||
__tablename__ = "notice_case_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
order_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
order_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
|
||||
order_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
demand_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
interest_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
penalty_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
appeal_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
appeal_filed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
case = relationship("NoticeCase", back_populates="orders")
|
||||
created_by = relationship("User")
|
||||
|
||||
|
||||
class NoticeCaseDocument(CommonBase):
|
||||
__tablename__ = "notice_case_documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("notice_case_events.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
document_type: Mapped[str] = mapped_column(String(80), nullable=False, default="GENERAL", index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_type: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
case = relationship("NoticeCase", back_populates="documents")
|
||||
event = relationship("NoticeCaseEvent")
|
||||
uploaded_by = relationship("User", foreign_keys=[uploaded_by_user_id])
|
||||
@@ -0,0 +1,423 @@
|
||||
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
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">{{ case.case_code }} - {{ case.title }}</h1><p class="mt-1 text-sm text-slate-500">{{ case.client.client_name if case.client else '' }} · {{ case.department }} · {{ case.case_type }}</p></div>
|
||||
<div class="flex gap-2"><a href="/notice-cases" class="rounded-lg border border-slate-300 px-4 py-2 text-sm">Back</a>{% if can_manage %}<a href="/notice-cases/{{ case.id }}/edit" class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Edit</a>{% endif %}</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Status</div><div class="font-semibold">{{ case.status|replace('_',' ')|title }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Due Date</div><div class="font-semibold">{{ case.due_date or '-' }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Reference</div><div class="font-semibold">{{ case.reference_no or '-' }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">DIN/Ack</div><div class="font-semibold">{{ case.din_ack_no or '-' }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Issue Summary</h2><p class="mt-3 whitespace-pre-wrap text-sm text-slate-700">{{ case.issue_summary or 'No issue summary captured.' }}</p></section>
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Assignments</h2><dl class="mt-3 grid grid-cols-2 gap-3 text-sm"><dt class="text-slate-500">Partner</dt><dd>{{ case.assigned_partner.full_name if case.assigned_partner else '-' }}</dd><dt class="text-slate-500">Manager</dt><dd>{{ case.assigned_manager.full_name if case.assigned_manager else '-' }}</dd><dt class="text-slate-500">Staff</dt><dd>{{ case.assigned_staff.full_name if case.assigned_staff else '-' }}</dd><dt class="text-slate-500">FY/AY</dt><dd>{{ case.financial_year or '-' }} / {{ case.assessment_year or '-' }}</dd></dl></section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Add Timeline Event</h2><form method="post" action="/notice-cases/{{ case.id }}/events" class="mt-4 space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-3 md:grid-cols-3"><select name="event_type" class="rounded-lg border px-3 py-2 text-sm">{% for e in event_types %}<option>{{ e }}</option>{% endfor %}</select><input type="date" name="event_date" class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="next_due_date" class="rounded-lg border px-3 py-2 text-sm" title="Next due date"></div><textarea name="description" rows="3" required placeholder="Event details" class="w-full rounded-lg border px-3 py-2 text-sm"></textarea><button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Add Event</button></form></section>
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Timeline</h2><div class="mt-4 space-y-3">{% for e in case.events %}<div class="border-l-2 border-brand-600 pl-3"><div class="text-sm font-semibold">{{ e.event_type }} · {{ e.event_date }}</div><div class="whitespace-pre-wrap text-sm text-slate-600">{{ e.description }}</div>{% if e.next_due_date %}<div class="text-xs text-slate-400">Next due: {{ e.next_due_date }}</div>{% endif %}</div>{% else %}<p class="text-sm text-slate-500">No timeline events.</p>{% endfor %}</div></section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Hearings</h2><form method="post" action="/notice-cases/{{ case.id }}/hearings" class="mt-4 grid gap-3 md:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="date" name="hearing_date" required class="rounded-lg border px-3 py-2 text-sm"><input name="hearing_time" placeholder="Time" class="rounded-lg border px-3 py-2 text-sm"><input name="venue_or_mode" placeholder="Venue / Online mode" class="rounded-lg border px-3 py-2 text-sm"><input name="officer_name" placeholder="Officer name" class="rounded-lg border px-3 py-2 text-sm"><select name="status" class="rounded-lg border px-3 py-2 text-sm">{% for s in hearing_statuses %}<option value="{{ s }}">{{ s|title }}</option>{% endfor %}</select><textarea name="agenda" placeholder="Agenda" class="rounded-lg border px-3 py-2 text-sm md:col-span-2"></textarea><button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white md:col-span-2">Add Hearing</button></form><div class="mt-4 space-y-2 text-sm">{% for h in case.hearings %}<div class="rounded-lg bg-slate-50 p-3"><b>{{ h.hearing_date }}</b> {{ h.hearing_time or '' }} · {{ h.status|title }}<br>{{ h.venue_or_mode or '' }} {{ h.officer_name or '' }}</div>{% endfor %}</div></section>
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Orders</h2><form method="post" action="/notice-cases/{{ case.id }}/orders" class="mt-4 grid gap-3 md:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="order_type" class="rounded-lg border px-3 py-2 text-sm">{% for o in order_types %}<option>{{ o }}</option>{% endfor %}</select><input name="order_no" placeholder="Order No" class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="order_date" required class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="appeal_due_date" class="rounded-lg border px-3 py-2 text-sm"><input name="tax_amount" placeholder="Tax" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="interest_amount" placeholder="Interest" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="penalty_amount" placeholder="Penalty" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="demand_amount" placeholder="Total Demand" type="number" class="rounded-lg border px-3 py-2 text-sm"><textarea name="summary" placeholder="Order summary" class="rounded-lg border px-3 py-2 text-sm md:col-span-2"></textarea><label class="text-sm md:col-span-2"><input type="checkbox" name="appeal_filed" value="1"> Appeal filed</label><button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white md:col-span-2">Add Order</button></form><div class="mt-4 space-y-2 text-sm">{% for o in case.orders %}<div class="rounded-lg bg-slate-50 p-3"><b>{{ o.order_type }}</b> · {{ o.order_date }} · Demand: {{ o.demand_amount }}<br>{{ o.summary or '' }}</div>{% endfor %}</div></section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><div class="flex items-center justify-between"><h2 class="font-bold">Case Documents</h2></div>{% if can_upload %}<form method="post" enctype="multipart/form-data" action="/notice-cases/{{ case.id }}/documents/upload" class="mt-4 grid gap-3 md:grid-cols-5"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="title" placeholder="Title" class="rounded-lg border px-3 py-2 text-sm"><select name="document_type" class="rounded-lg border px-3 py-2 text-sm">{% for d in case_document_types %}<option value="{{ d }}">{{ d }}</option>{% endfor %}</select><input name="description" placeholder="Description" class="rounded-lg border px-3 py-2 text-sm"><input type="file" name="file" required class="rounded-lg border px-3 py-2 text-sm"><button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Upload</button></form>{% endif %}<div class="mt-4 overflow-hidden rounded-lg border"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-3 py-2 text-left">Document</th><th class="px-3 py-2">Type</th><th class="px-3 py-2">Uploaded</th><th class="px-3 py-2 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for d in case.documents if not d.is_deleted %}<tr><td class="px-3 py-2"><div class="font-semibold">{{ d.title }}</div><div class="text-xs text-slate-500">{{ d.original_filename }}</div></td><td class="px-3 py-2 text-center">{{ d.document_type }}</td><td class="px-3 py-2 text-center">{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}</td><td class="px-3 py-2 text-right"><a href="/notice-cases/documents/{{ d.id }}/download" class="font-semibold text-brand-700">Download</a>{% if can_delete_documents %}<form method="post" action="/notice-cases/documents/{{ d.id }}/delete" class="inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ml-3 text-red-600" onclick="return confirm('Delete this document?')">Delete</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-3 py-6 text-center text-slate-500">No documents uploaded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ title }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Capture notice/case details, due dates and responsibility.</p>
|
||||
<p class="mt-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Active FY: {{ active_financial_year or '-' }}{% if active_assessment_year %} · AY {{ active_assessment_year }}{% endif %}</p>
|
||||
</div>
|
||||
{% if form_error %}<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ form_error }}</div>{% endif %}
|
||||
<form method="post" class="space-y-5 rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Client</span><select name="client_id" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" {% if mode=='edit' %}disabled{% endif %}>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client_id==c.id or (case and case.client_id==c.id) %}selected{% endif %}>{{ c.client_name }}{% if c.client_code %} ({{ c.client_code }}){% endif %}</option>{% endfor %}</select>{% if mode=='edit' %}<input type="hidden" name="client_id" value="{{ case.client_id }}">{% endif %}</label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Engagement Link</span><select name="engagement_id" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">Not linked</option>{% for e in engagements %}<option value="{{ e.id }}" {% if case and case.engagement_id==e.id %}selected{% endif %}>{{ e.financial_year }} - {{ e.catalogue.service_name if e.catalogue else e.service_catalogue_id }}</option>{% endfor %}</select></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Priority</span><select name="priority" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{% for p in case_priorities %}<option value="{{ p }}" {% if case and case.priority==p %}selected{% endif %}>{{ p|title }}</option>{% endfor %}</select></label>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Department</span><select name="department" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{% for d in departments %}<option value="{{ d }}" {% if case and case.department==d %}selected{% endif %}>{{ d }}</option>{% endfor %}</select></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Case Type</span><select name="case_type" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{% for t in case_types %}<option value="{{ t }}" {% if case and case.case_type==t %}selected{% endif %}>{{ t }}</option>{% endfor %}</select></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Status</span><select name="status" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{% for s in case_statuses %}<option value="{{ s }}" {% if case and case.status==s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option>{% endfor %}</select></label>
|
||||
</div>
|
||||
<label class="block space-y-1"><span class="text-sm font-medium">Title</span><input name="title" value="{{ case.title if case else '' }}" required class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Reference No</span><input name="reference_no" value="{{ case.reference_no if case else '' }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">DIN / Ack No</span><input name="din_ack_no" value="{{ case.din_ack_no if case else '' }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Notice Date</span><input type="date" name="notice_date" value="{{ case.notice_date if case and case.notice_date else '' }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Due Date</span><input type="date" name="due_date" value="{{ case.due_date if case and case.due_date else '' }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Financial Year</span><input name="financial_year" value="{{ case.financial_year if case else active_financial_year or '' }}" placeholder="2025-26" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Assessment Year</span><input name="assessment_year" value="{{ case.assessment_year if case else active_assessment_year or '' }}" placeholder="2026-27" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
<label class="space-y-1"><span class="text-sm font-medium">Period</span><input name="period_label" value="{{ case.period_label if case else '' }}" placeholder="Apr 2025 / Q1 / FY" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"></label>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{% for field,label in [('assigned_partner_user_id','Partner'),('assigned_manager_user_id','Manager'),('assigned_staff_user_id','Staff')] %}
|
||||
<label class="space-y-1"><span class="text-sm font-medium">{{ label }}</span><select name="{{ field }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if case and case[field]==u.id %}selected{% endif %}>{{ u.full_name or u.login_id or u.email }}</option>{% endfor %}</select></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label class="block space-y-1"><span class="text-sm font-medium">Issue Summary</span><textarea name="issue_summary" rows="4" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{{ case.issue_summary if case else '' }}</textarea></label>
|
||||
<label class="block space-y-1"><span class="text-sm font-medium">Remarks</span><textarea name="remarks" rows="3" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{{ case.remarks if case else '' }}</textarea></label>
|
||||
<div class="flex justify-end gap-3"><a href="/notice-cases" class="rounded-lg border border-slate-300 px-4 py-2 text-sm">Cancel</a><button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Notices & Cases</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Track GST, Income Tax, ROC, PF/ESI notices, appeals, hearings, orders and case documents.</p>
|
||||
<p class="mt-1 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Showing: {% if financial_year == 'all' %}All Financial Years{% else %}FY {{ financial_year }}{% endif %}{% if assessment_year %} · AY {{ assessment_year }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if can_manage %}<a href="/notice-cases/new" class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-brand-700">New Case</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold">{{ summary.total }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Open</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ summary.open }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Reply Pending</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ summary.reply_pending }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200"><div class="text-xs text-slate-500">Hearings</div><div class="mt-1 text-2xl font-semibold text-purple-700">{{ summary.hearing_scheduled }}</div></div>
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-red-200"><div class="text-xs text-red-500">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ summary.overdue }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="grid gap-3 rounded-xl bg-white p-4 shadow-sm ring-1 ring-slate-200 md:grid-cols-7">
|
||||
<input name="q" value="{{ q }}" placeholder="Search case/client/ref" class="rounded-lg border border-slate-300 px-3 py-2 text-sm md:col-span-2">
|
||||
<select name="department" class="rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">All Departments</option>{% for d in departments %}<option value="{{ d }}" {% if department==d %}selected{% endif %}>{{ d }}</option>{% endfor %}</select>
|
||||
<select name="status" class="rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">All Status</option>{% for s in case_statuses %}<option value="{{ s }}" {% if status==s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option>{% endfor %}</select>
|
||||
<select name="financial_year" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if financial_year == 'all' %}selected{% endif %}>All FY</option>
|
||||
{% for fy in financial_year_options %}<option value="{{ fy.year_code }}" {% if financial_year == fy.year_code %}selected{% endif %}>FY {{ fy.year_code }}{% if fy.is_current %} (Current){% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
<input name="assessment_year" value="{{ assessment_year }}" placeholder="AY optional" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
{% if include_archived %}<input type="hidden" name="include_archived" value="true">{% endif %}
|
||||
<button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Case</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Department</th><th class="px-4 py-3">FY / AY</th><th class="px-4 py-3">Due Date</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50"><td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.case_code }}</div><div class="text-slate-500">{{ row.title }}</div><div class="text-xs text-slate-400">{{ row.reference_no or row.din_ack_no or '' }}</div></td><td class="px-4 py-3">{{ row.client.client_name if row.client else '-' }}</td><td class="px-4 py-3">{{ row.department }}<div class="text-xs text-slate-400">{{ row.case_type }}</div></td><td class="px-4 py-3"><div>{{ row.financial_year or '-' }}</div><div class="text-xs text-slate-400">{{ row.assessment_year or '-' }}</div></td><td class="px-4 py-3">{{ row.due_date or '-' }}</td><td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status|replace('_',' ')|title }}</span></td><td class="px-4 py-3 text-right"><a href="/notice-cases/{{ row.id }}" class="font-semibold text-brand-700 hover:underline">Open</a></td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No notice/case records found for the selected year/filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,469 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.notice_cases.service import (
|
||||
CASE_DOCUMENT_TYPES,
|
||||
CASE_PRIORITIES,
|
||||
CASE_STATUSES,
|
||||
CASE_TYPES,
|
||||
DEPARTMENTS,
|
||||
case_dashboard_summary,
|
||||
EVENT_TYPES,
|
||||
HEARING_STATUSES,
|
||||
ORDER_TYPES,
|
||||
add_event,
|
||||
add_hearing,
|
||||
add_order,
|
||||
can_delete_notice_case_documents,
|
||||
can_manage_notice_cases,
|
||||
can_upload_notice_case_documents,
|
||||
case_document_absolute_path,
|
||||
create_case,
|
||||
get_case,
|
||||
list_assignable_users,
|
||||
list_cases,
|
||||
list_clients_for_case,
|
||||
list_engagements_for_client,
|
||||
save_case_document,
|
||||
update_case,
|
||||
user_can_access_case,
|
||||
)
|
||||
from app.modules.notice_cases.models import NoticeCaseDocument
|
||||
from app.modules.core.tenancy.models import FinancialYear
|
||||
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
|
||||
|
||||
router = APIRouter(prefix="/notice-cases", tags=["notice-cases-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"departments": DEPARTMENTS,
|
||||
"case_types": CASE_TYPES,
|
||||
"case_statuses": CASE_STATUSES,
|
||||
"case_priorities": CASE_PRIORITIES,
|
||||
"event_types": EVENT_TYPES,
|
||||
"hearing_statuses": HEARING_STATUSES,
|
||||
"order_types": ORDER_TYPES,
|
||||
"case_document_types": CASE_DOCUMENT_TYPES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template_name: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _require_user(request: Request, db, permission: str):
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return None, RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, permission)
|
||||
except Exception:
|
||||
return user, _redirect_denied()
|
||||
return user, None
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
perms = set(get_user_permissions(db, user.id))
|
||||
if value in (None, "", 0, "0"):
|
||||
if "notice_cases.cross_branch" in perms or "notice_cases.cross_tenant" in perms:
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _active_financial_year(request: Request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _active_assessment_year(db, tenant_id: int, financial_year: str | None) -> str | None:
|
||||
fy_code = (financial_year or "").strip()
|
||||
if not fy_code:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(FinancialYear).where(
|
||||
FinancialYear.tenant_id == tenant_id,
|
||||
FinancialYear.year_code == fy_code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row.assessment_year if row else None
|
||||
|
||||
|
||||
def _financial_year_options(db, tenant_id: int):
|
||||
return list(
|
||||
db.execute(
|
||||
select(FinancialYear)
|
||||
.where(FinancialYear.tenant_id == tenant_id)
|
||||
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def _selected_financial_year(request: Request, query_financial_year: str | None) -> str | None:
|
||||
value = (query_financial_year or "").strip()
|
||||
if value.lower() == "all":
|
||||
return None
|
||||
return value or _active_financial_year(request)
|
||||
|
||||
|
||||
def _case_access_allowed(request: Request, db, user, case) -> bool:
|
||||
return user_can_access_case(
|
||||
db,
|
||||
user,
|
||||
case,
|
||||
active_tenant_id=_active_tenant_id(request, user),
|
||||
active_branch_id=_active_branch_id(request, user, db),
|
||||
active_financial_year=_active_financial_year(request),
|
||||
active_assessment_year=_active_assessment_year(db, _active_tenant_id(request, user), _active_financial_year(request)),
|
||||
)
|
||||
|
||||
|
||||
def _form_data(**kwargs):
|
||||
return {k: v for k, v in kwargs.items()}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def cases_list(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
department: str = "",
|
||||
status: str = "",
|
||||
financial_year: str = "",
|
||||
assessment_year: str = "",
|
||||
include_archived: bool = False,
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.view")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
selected_fy = _selected_financial_year(request, financial_year)
|
||||
selected_ay = (assessment_year or "").strip() or None
|
||||
if selected_fy:
|
||||
selected_ay = None
|
||||
rows = list_cases(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
q=q,
|
||||
department=department,
|
||||
status=status,
|
||||
include_archived=include_archived,
|
||||
financial_year=selected_fy,
|
||||
assessment_year=selected_ay,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/notice_cases/templates/notice_cases/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Notices & Cases",
|
||||
rows=rows,
|
||||
summary=case_dashboard_summary(rows),
|
||||
q=q,
|
||||
department=department,
|
||||
status=status,
|
||||
financial_year=selected_fy or "all",
|
||||
assessment_year=selected_ay or "",
|
||||
active_financial_year=_active_financial_year(request),
|
||||
financial_year_options=_financial_year_options(db, tenant_id),
|
||||
include_archived=include_archived,
|
||||
can_manage=can_manage_notice_cases(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
def case_create_page(request: Request, client_id: int | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
active_fy = _active_financial_year(request)
|
||||
active_ay = _active_assessment_year(db, tenant_id, active_fy)
|
||||
clients = list_clients_for_case(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
engagements = list_engagements_for_client(db, tenant_id=tenant_id, client_id=client_id, financial_year=active_fy) if client_id else []
|
||||
users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Create Notice / Case", mode="create", case=None, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=client_id, form_error="", active_financial_year=active_fy, active_assessment_year=active_ay)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
def case_create_submit(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
engagement_id: str = Form(""),
|
||||
department: str = Form("GST"),
|
||||
case_type: str = Form("Notice"),
|
||||
title: str = Form(""),
|
||||
reference_no: str = Form(""),
|
||||
din_ack_no: str = Form(""),
|
||||
notice_date: str = Form(""),
|
||||
due_date: str = Form(""),
|
||||
financial_year: str = Form(""),
|
||||
assessment_year: str = Form(""),
|
||||
period_label: str = Form(""),
|
||||
status: str = Form("open"),
|
||||
priority: str = Form("normal"),
|
||||
assigned_partner_user_id: str = Form(""),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
issue_summary: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
active_fy = _active_financial_year(request)
|
||||
active_ay = _active_assessment_year(db, tenant_id, active_fy)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=active_fy, redirect_url=f"/notice-cases?financial_year={active_fy or ''}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
data = locals().copy(); data.pop("request", None); data.pop("db", None); data.pop("user", None); data.pop("response", None); data.pop("csrf_token", None); data.pop("tenant_id", None); data.pop("branch_id", None)
|
||||
data["active_financial_year"] = active_fy
|
||||
data["active_assessment_year"] = active_ay
|
||||
try:
|
||||
row = create_case(db, tenant_id=tenant_id, branch_id=branch_id, user=user, data=data)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{row.id}?created=1", status_code=303)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
clients = list_clients_for_case(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
engagements = list_engagements_for_client(db, tenant_id=tenant_id, client_id=client_id, financial_year=active_fy) if client_id else []
|
||||
users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Create Notice / Case", mode="create", case=data, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=client_id, form_error=str(exc), active_financial_year=active_fy, active_assessment_year=active_ay)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{case_id}")
|
||||
def case_detail(request: Request, case_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.view")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
return _render(request, "modules/notice_cases/templates/notice_cases/detail.html", db, user, title=case.case_code, case=case, can_manage=can_manage_notice_cases(db, user), can_upload=can_upload_notice_case_documents(db, user), can_delete_documents=can_delete_notice_case_documents(db, user))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{case_id}/edit")
|
||||
def case_edit_page(request: Request, case_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.edit")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
clients = [case.client]
|
||||
active_fy = _active_financial_year(request)
|
||||
active_ay = _active_assessment_year(db, case.tenant_id, active_fy)
|
||||
engagements = list_engagements_for_client(db, tenant_id=case.tenant_id, client_id=case.client_id, financial_year=active_fy)
|
||||
users = list_assignable_users(db, tenant_id=case.tenant_id, branch_id=case.branch_id)
|
||||
return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Edit Notice / Case", mode="edit", case=case, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=case.client_id, form_error="", active_financial_year=active_fy, active_assessment_year=active_ay)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{case_id}/edit")
|
||||
def case_edit_submit(request: Request, case_id: int, department: str = Form("GST"), case_type: str = Form("Notice"), title: str = Form(""), reference_no: str = Form(""), din_ack_no: str = Form(""), notice_date: str = Form(""), due_date: str = Form(""), financial_year: str = Form(""), assessment_year: str = Form(""), period_label: str = Form(""), status: str = Form("open"), priority: str = Form("normal"), assigned_partner_user_id: str = Form(""), assigned_manager_user_id: str = Form(""), assigned_staff_user_id: str = Form(""), issue_summary: str = Form(""), remarks: str = Form(""), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.edit")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, case):
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
|
||||
active_fy = _active_financial_year(request)
|
||||
active_ay = _active_assessment_year(db, case.tenant_id, active_fy)
|
||||
data = locals().copy(); [data.pop(k, None) for k in ["request","db","user","response","case","csrf_token","case_id"]]
|
||||
data["active_financial_year"] = active_fy
|
||||
data["active_assessment_year"] = active_ay
|
||||
try:
|
||||
update_case(db, case=case, user=user, data=data)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?updated=1", status_code=303)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
clients = [case.client]
|
||||
engagements = list_engagements_for_client(db, tenant_id=case.tenant_id, client_id=case.client_id, financial_year=active_fy)
|
||||
users = list_assignable_users(db, tenant_id=case.tenant_id, branch_id=case.branch_id)
|
||||
return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Edit Notice / Case", mode="edit", case=case, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=case.client_id, form_error=str(exc), active_financial_year=active_fy, active_assessment_year=active_ay)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{case_id}/events")
|
||||
def case_add_event(request: Request, case_id: int, event_type: str = Form("Internal Note"), event_date: str = Form(""), description: str = Form(""), next_due_date: str = Form(""), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.events.manage")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, case):
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
|
||||
add_event(db, case=case, user=user, data=locals())
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{case_id}?event_added=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{case_id}/hearings")
|
||||
def case_add_hearing(request: Request, case_id: int, hearing_date: str = Form(""), hearing_time: str = Form(""), venue_or_mode: str = Form(""), officer_name: str = Form(""), agenda: str = Form(""), outcome: str = Form(""), status: str = Form("scheduled"), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.hearings.manage")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, case):
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
|
||||
add_hearing(db, case=case, user=user, data=locals())
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{case_id}?hearing_added=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{case_id}/orders")
|
||||
def case_add_order(request: Request, case_id: int, order_type: str = Form("Other"), order_no: str = Form(""), order_date: str = Form(""), demand_amount: str = Form("0"), tax_amount: str = Form("0"), interest_amount: str = Form("0"), penalty_amount: str = Form("0"), summary: str = Form(""), appeal_due_date: str = Form(""), appeal_filed: str | None = Form(None), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.orders.manage")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, case):
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
|
||||
add_order(db, case=case, user=user, data=locals())
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{case_id}?order_added=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{case_id}/documents/upload")
|
||||
def case_document_upload(request: Request, case_id: int, title: str = Form(""), document_type: str = Form("OTHER"), description: str = Form(""), event_id: str = Form(""), file: UploadFile = File(...), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.documents.upload")
|
||||
if response:
|
||||
return response
|
||||
case = get_case(db, case_id)
|
||||
if not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, case):
|
||||
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
|
||||
if not file or not file.filename:
|
||||
return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303)
|
||||
save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/download")
|
||||
def case_document_download(request: Request, document_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.documents.download")
|
||||
if response:
|
||||
return response
|
||||
doc = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.id == document_id, NoticeCaseDocument.is_deleted.is_(False))).scalar_one_or_none()
|
||||
case = get_case(db, doc.case_id) if doc else None
|
||||
if not doc or not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
path = case_document_absolute_path(doc)
|
||||
if not path.exists():
|
||||
return RedirectResponse(url=f"/notice-cases/{doc.case_id}?error=file_missing", status_code=303)
|
||||
quoted = quote(doc.original_filename)
|
||||
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}"}
|
||||
return StreamingResponse(path.open("rb"), media_type=doc.content_type or "application/octet-stream", headers=headers)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/delete")
|
||||
def case_document_delete(request: Request, document_id: int, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "notice_cases.documents.delete")
|
||||
if response:
|
||||
return response
|
||||
doc = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.id == document_id, NoticeCaseDocument.is_deleted.is_(False))).scalar_one_or_none()
|
||||
case = get_case(db, doc.case_id) if doc else None
|
||||
if not doc or not case or not _case_access_allowed(request, db, user, case):
|
||||
return _redirect_denied()
|
||||
doc.is_deleted = True
|
||||
doc.deleted_at_utc = __import__('datetime').datetime.now(__import__('datetime').timezone.utc)
|
||||
doc.deleted_by_user_id = user.id
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/notice-cases/{doc.case_id}?deleted=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user