Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Notice and case management module."""
+169
View File
@@ -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])
+423
View File
@@ -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 %}
+469
View File
@@ -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()