diff --git a/alembic/env.py b/alembic/env.py index 51ad783..4e55d5c 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -45,6 +45,7 @@ from app.modules.alerts import models as alerts_models # noqa: F401 from app.modules.email_integration import models as email_integration_models # noqa: F401 from app.modules.domain_management import models as domain_management_models # noqa: F401 from app.modules.notice_cases import models as notice_cases_models # noqa: F401 +from app.modules.registrations import models as registration_models # noqa: F401 config = context.config diff --git a/alembic/versions/20260723_phase4_registration_lifecycle.py b/alembic/versions/20260723_phase4_registration_lifecycle.py new file mode 100644 index 0000000..b49c085 --- /dev/null +++ b/alembic/versions/20260723_phase4_registration_lifecycle.py @@ -0,0 +1,32 @@ +"""Phase 4 registrations, related persons, renewal lifecycle and expiry dashboard. + +Revision ID: 20260723_phase4_registration_lifecycle +Revises: 20260723_phase3_consultant_task_execution +""" +from alembic import op +import sqlalchemy as sa + +revision="20260723_phase4_registration_lifecycle" +down_revision="20260723_phase3_consultant_task_execution" +branch_labels=None +depends_on=None + +def upgrade(): + op.create_table("registration_types",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("code",sa.String(60),nullable=False),sa.Column("name",sa.String(160),nullable=False),sa.Column("category",sa.String(80),nullable=False,server_default="registration"),sa.Column("issuing_authority",sa.String(200)),sa.Column("identifier_label",sa.String(100),nullable=False,server_default="Registration Number"),sa.Column("supports_expiry",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("supports_related_person",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("is_system",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("sort_order",sa.Integer(),nullable=False,server_default="0"),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False),sa.UniqueConstraint("code",name="uq_registration_types_code")) + op.create_index("ix_registration_types_code","registration_types",["code"]) + op.create_table("client_related_persons",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("person_type",sa.String(60),nullable=False),sa.Column("full_name",sa.String(200),nullable=False),sa.Column("designation",sa.String(120)),sa.Column("pan",sa.String(20)),sa.Column("din",sa.String(20)),sa.Column("date_of_birth",sa.Date()),sa.Column("appointment_date",sa.Date()),sa.Column("cessation_date",sa.Date()),sa.Column("mobile",sa.String(20)),sa.Column("email",sa.String(255)),sa.Column("authorised_signatory",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("notes",sa.Text()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False)) + for c in ["tenant_id","client_id","person_type","pan","din"]: op.create_index(f"ix_client_related_persons_{c}","client_related_persons",[c]) + op.create_table("registration_lifecycle_rules",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE")),sa.Column("registration_type_id",sa.Integer(),sa.ForeignKey("registration_types.id"),nullable=False),sa.Column("entity_type",sa.String(100)),sa.Column("jurisdiction",sa.String(160)),sa.Column("state_code",sa.String(10)),sa.Column("cycle_type",sa.String(40),nullable=False,server_default="expiry_renewal"),sa.Column("frequency_months",sa.Integer()),sa.Column("renewal_window_days",sa.Integer(),nullable=False,server_default="60"),sa.Column("reminder_days_csv",sa.String(100),nullable=False,server_default="90,60,30,15,7,0"),sa.Column("grace_period_days",sa.Integer(),nullable=False,server_default="0"),sa.Column("service_catalogue_id",sa.Integer(),sa.ForeignKey("service_catalogue.id",ondelete="SET NULL")),sa.Column("requires_client_approval",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("requires_partner_review",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("auto_create_task",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("effective_from",sa.Date()),sa.Column("effective_to",sa.Date()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False)) + for c in ["tenant_id","registration_type_id","entity_type","service_catalogue_id"]: op.create_index(f"ix_registration_lifecycle_rules_{c}","registration_lifecycle_rules",[c]) + op.create_table("client_registrations",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="SET NULL")),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("registration_type_id",sa.Integer(),sa.ForeignKey("registration_types.id"),nullable=False),sa.Column("related_person_id",sa.Integer(),sa.ForeignKey("client_related_persons.id",ondelete="SET NULL")),sa.Column("registration_number",sa.String(120),nullable=False),sa.Column("jurisdiction",sa.String(160)),sa.Column("state_code",sa.String(10)),sa.Column("issue_date",sa.Date()),sa.Column("effective_from",sa.Date()),sa.Column("valid_until",sa.Date()),sa.Column("next_action_date",sa.Date()),sa.Column("status",sa.String(40),nullable=False,server_default="active"),sa.Column("primary_registration",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("lifecycle_rule_id",sa.Integer(),sa.ForeignKey("registration_lifecycle_rules.id")),sa.Column("responsible_party",sa.String(30),nullable=False,server_default="firm"),sa.Column("assigned_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("assigned_consultant_id",sa.Integer(),sa.ForeignKey("consultant_profiles.id",ondelete="SET NULL")),sa.Column("auto_create_task",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("portal_url",sa.String(500)),sa.Column("notes",sa.Text()),sa.Column("metadata_json",sa.JSON()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False),sa.UniqueConstraint("tenant_id","registration_type_id","registration_number",name="uq_client_registration_number")) + for c in ["tenant_id","branch_id","client_id","registration_type_id","related_person_id","registration_number","valid_until","next_action_date","status","lifecycle_rule_id","assigned_user_id","assigned_consultant_id"]: op.create_index(f"ix_client_registrations_{c}","client_registrations",[c]) + op.create_table("client_digital_signatures",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("related_person_id",sa.Integer(),sa.ForeignKey("client_related_persons.id",ondelete="CASCADE"),nullable=False),sa.Column("certificate_serial_number",sa.String(200)),sa.Column("provider",sa.String(120)),sa.Column("token_make",sa.String(120)),sa.Column("class_type",sa.String(60)),sa.Column("issued_on",sa.Date()),sa.Column("expires_on",sa.Date(),nullable=False),sa.Column("status",sa.String(30),nullable=False,server_default="active"),sa.Column("custody_location",sa.String(200)),sa.Column("renewal_responsibility",sa.String(30),nullable=False,server_default="firm"),sa.Column("assigned_consultant_id",sa.Integer(),sa.ForeignKey("consultant_profiles.id",ondelete="SET NULL")),sa.Column("notes",sa.Text()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False)) + for c in ["tenant_id","client_id","related_person_id","certificate_serial_number","expires_on","status","assigned_consultant_id"]: op.create_index(f"ix_client_digital_signatures_{c}","client_digital_signatures",[c]) + op.create_table("registration_document_templates",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE")),sa.Column("registration_type_id",sa.Integer(),sa.ForeignKey("registration_types.id"),nullable=False),sa.Column("action_type",sa.String(40),nullable=False,server_default="renewal"),sa.Column("document_name",sa.String(200),nullable=False),sa.Column("description",sa.Text()),sa.Column("mandatory",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("sort_order",sa.Integer(),nullable=False,server_default="0"),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.true())) + op.create_table("registration_renewal_tasks",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="SET NULL")),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("registration_id",sa.Integer(),sa.ForeignKey("client_registrations.id",ondelete="CASCADE"),nullable=False),sa.Column("lifecycle_rule_id",sa.Integer(),sa.ForeignKey("registration_lifecycle_rules.id",ondelete="SET NULL")),sa.Column("cycle_due_date",sa.Date(),nullable=False),sa.Column("action_type",sa.String(40),nullable=False,server_default="renewal"),sa.Column("title",sa.String(255),nullable=False),sa.Column("status",sa.String(40),nullable=False,server_default="pending"),sa.Column("priority",sa.String(20),nullable=False,server_default="normal"),sa.Column("assigned_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("assigned_consultant_id",sa.Integer(),sa.ForeignKey("consultant_profiles.id",ondelete="SET NULL")),sa.Column("client_approval_required",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("partner_review_required",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("submitted_on",sa.Date()),sa.Column("acknowledgement_number",sa.String(160)),sa.Column("completed_on",sa.Date()),sa.Column("notes",sa.Text()),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False),sa.UniqueConstraint("registration_id","cycle_due_date",name="uq_registration_renewal_cycle")) + for c in ["tenant_id","branch_id","client_id","registration_id","lifecycle_rule_id","cycle_due_date","status","assigned_user_id","assigned_consultant_id"]: op.create_index(f"ix_registration_renewal_tasks_{c}","registration_renewal_tasks",[c]) + op.create_table("registration_renewal_task_documents",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("renewal_task_id",sa.Integer(),sa.ForeignKey("registration_renewal_tasks.id",ondelete="CASCADE"),nullable=False),sa.Column("template_id",sa.Integer(),sa.ForeignKey("registration_document_templates.id",ondelete="SET NULL")),sa.Column("document_name",sa.String(200),nullable=False),sa.Column("mandatory",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("status",sa.String(30),nullable=False,server_default="pending"),sa.Column("document_id",sa.Integer()),sa.Column("remarks",sa.Text())) + op.create_table("client_registration_actions",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("registration_id",sa.Integer(),sa.ForeignKey("client_registrations.id",ondelete="CASCADE"),nullable=False),sa.Column("renewal_task_id",sa.Integer(),sa.ForeignKey("registration_renewal_tasks.id",ondelete="SET NULL")),sa.Column("action_type",sa.String(40),nullable=False),sa.Column("previous_valid_until",sa.Date()),sa.Column("new_valid_until",sa.Date()),sa.Column("application_date",sa.Date()),sa.Column("submission_date",sa.Date()),sa.Column("acknowledgement_number",sa.String(160)),sa.Column("completion_date",sa.Date()),sa.Column("status",sa.String(40),nullable=False,server_default="completed"),sa.Column("performed_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("performed_by_consultant_id",sa.Integer(),sa.ForeignKey("consultant_profiles.id",ondelete="SET NULL")),sa.Column("notes",sa.Text()),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False)) + +def downgrade(): + for table in ["client_registration_actions","registration_renewal_task_documents","registration_renewal_tasks","registration_document_templates","client_digital_signatures","client_registrations","registration_lifecycle_rules","client_related_persons","registration_types"]: op.drop_table(table) diff --git a/app/core/startup.py b/app/core/startup.py index 7b224d4..e8e274a 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -25,6 +25,8 @@ from app.modules.documents.models import EngagementDocument, EngagementDocumentV from app.modules.alerts.models import UserAlert from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, NoticeCaseDocument from app.modules.notifications.automation import start_notification_scheduler +from app.modules.registrations.service import seed_registration_types +from app.modules.registrations import models as registration_models # noqa: F401 DEFAULT_ROLES = [ "System Admin", @@ -955,5 +957,9 @@ def on_startup(app: FastAPI) -> None: finally: db.close() + # Phase 4 registration lifecycle masters are idempotently seeded. + with CommonSessionLocal() as registration_db: + seed_registration_types(registration_db) + # Phase 7O: start alert notification/escalation automation after schema and seed checks. start_notification_scheduler() diff --git a/app/modules/notifications/automation.py b/app/modules/notifications/automation.py index 438e54f..b329f61 100644 --- a/app/modules/notifications/automation.py +++ b/app/modules/notifications/automation.py @@ -167,6 +167,14 @@ def run_notification_escalation_once(*, db: Session | None = None, now: datetime result = AutomationResult() try: + # Phase 4: materialize registration renewal/update work before alert processing. + try: + if _table_exists("registration_renewal_tasks"): + from app.modules.registrations.service import run_registration_lifecycle_once + run_registration_lifecycle_once(session, today=now_utc.date()) + except Exception: + logger.exception("Registration lifecycle automation failed") + if not _table_exists("user_alerts"): logger.warning("Phase 7O skipped: user_alerts table is not available. Apply Phase 7H first.") return result diff --git a/app/modules/registrations/__init__.py b/app/modules/registrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/registrations/models.py b/app/modules/registrations/models.py new file mode 100644 index 0000000..49b42bf --- /dev/null +++ b/app/modules/registrations/models.py @@ -0,0 +1,190 @@ +from __future__ import annotations +from datetime import date, datetime, timezone +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + + +def utcnow(): return datetime.now(timezone.utc) + +class RegistrationType(CommonBase): + __tablename__="registration_types" + __table_args__=(UniqueConstraint("code", name="uq_registration_types_code"),) + id: Mapped[int]=mapped_column(Integer, primary_key=True) + code: Mapped[str]=mapped_column(String(60), nullable=False, index=True) + name: Mapped[str]=mapped_column(String(160), nullable=False) + category: Mapped[str]=mapped_column(String(80), nullable=False, default="registration") + issuing_authority: Mapped[str|None]=mapped_column(String(200)) + identifier_label: Mapped[str]=mapped_column(String(100), nullable=False, default="Registration Number") + supports_expiry: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + supports_related_person: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + is_system: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + is_active: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + sort_order: Mapped[int]=mapped_column(Integer, nullable=False, default=0) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class ClientRelatedPerson(CommonBase): + __tablename__="client_related_persons" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + person_type: Mapped[str]=mapped_column(String(60), nullable=False, index=True) + full_name: Mapped[str]=mapped_column(String(200), nullable=False) + designation: Mapped[str|None]=mapped_column(String(120)) + pan: Mapped[str|None]=mapped_column(String(20), index=True) + din: Mapped[str|None]=mapped_column(String(20), index=True) + date_of_birth: Mapped[date|None]=mapped_column(Date) + appointment_date: Mapped[date|None]=mapped_column(Date) + cessation_date: Mapped[date|None]=mapped_column(Date) + mobile: Mapped[str|None]=mapped_column(String(20)) + email: Mapped[str|None]=mapped_column(String(255)) + authorised_signatory: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + notes: Mapped[str|None]=mapped_column(Text) + created_by_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class ClientRegistration(CommonBase): + __tablename__="client_registrations" + __table_args__=(UniqueConstraint("tenant_id","registration_type_id","registration_number", name="uq_client_registration_number"),) + id: Mapped[int]=mapped_column(Integer, primary_key=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"), index=True) + client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True) + related_person_id: Mapped[int|None]=mapped_column(ForeignKey("client_related_persons.id", ondelete="SET NULL"), index=True) + registration_number: Mapped[str]=mapped_column(String(120), nullable=False, index=True) + jurisdiction: Mapped[str|None]=mapped_column(String(160)) + state_code: Mapped[str|None]=mapped_column(String(10)) + issue_date: Mapped[date|None]=mapped_column(Date) + effective_from: Mapped[date|None]=mapped_column(Date) + valid_until: Mapped[date|None]=mapped_column(Date, index=True) + next_action_date: Mapped[date|None]=mapped_column(Date, index=True) + status: Mapped[str]=mapped_column(String(40), nullable=False, default="active", index=True) + primary_registration: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + lifecycle_rule_id: Mapped[int|None]=mapped_column(ForeignKey("registration_lifecycle_rules.id", use_alter=True, name="fk_client_registration_rule"), index=True) + responsible_party: Mapped[str]=mapped_column(String(30), nullable=False, default="firm") + assigned_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + assigned_consultant_id: Mapped[int|None]=mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), index=True) + auto_create_task: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + portal_url: Mapped[str|None]=mapped_column(String(500)) + notes: Mapped[str|None]=mapped_column(Text) + metadata_json: Mapped[dict|None]=mapped_column(JSON) + created_by_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class ClientDigitalSignature(CommonBase): + __tablename__="client_digital_signatures" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + related_person_id: Mapped[int]=mapped_column(ForeignKey("client_related_persons.id", ondelete="CASCADE"), nullable=False, index=True) + certificate_serial_number: Mapped[str|None]=mapped_column(String(200), index=True) + provider: Mapped[str|None]=mapped_column(String(120)) + token_make: Mapped[str|None]=mapped_column(String(120)) + class_type: Mapped[str|None]=mapped_column(String(60)) + issued_on: Mapped[date|None]=mapped_column(Date) + expires_on: Mapped[date]=mapped_column(Date, nullable=False, index=True) + status: Mapped[str]=mapped_column(String(30), nullable=False, default="active", index=True) + custody_location: Mapped[str|None]=mapped_column(String(200)) + renewal_responsibility: Mapped[str]=mapped_column(String(30), nullable=False, default="firm") + assigned_consultant_id: Mapped[int|None]=mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), index=True) + notes: Mapped[str|None]=mapped_column(Text) + created_by_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class RegistrationLifecycleRule(CommonBase): + __tablename__="registration_lifecycle_rules" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int|None]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True) + entity_type: Mapped[str|None]=mapped_column(String(100), index=True) + jurisdiction: Mapped[str|None]=mapped_column(String(160)) + state_code: Mapped[str|None]=mapped_column(String(10)) + cycle_type: Mapped[str]=mapped_column(String(40), nullable=False, default="expiry_renewal") + frequency_months: Mapped[int|None]=mapped_column(Integer) + renewal_window_days: Mapped[int]=mapped_column(Integer, nullable=False, default=60) + reminder_days_csv: Mapped[str]=mapped_column(String(100), nullable=False, default="90,60,30,15,7,0") + grace_period_days: Mapped[int]=mapped_column(Integer, nullable=False, default=0) + service_catalogue_id: Mapped[int|None]=mapped_column(ForeignKey("service_catalogue.id", ondelete="SET NULL"), index=True) + requires_client_approval: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + requires_partner_review: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + auto_create_task: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + is_active: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + effective_from: Mapped[date|None]=mapped_column(Date) + effective_to: Mapped[date|None]=mapped_column(Date) + created_by_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class RegistrationDocumentTemplate(CommonBase): + __tablename__="registration_document_templates" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int|None]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True) + action_type: Mapped[str]=mapped_column(String(40), nullable=False, default="renewal", index=True) + document_name: Mapped[str]=mapped_column(String(200), nullable=False) + description: Mapped[str|None]=mapped_column(Text) + mandatory: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + sort_order: Mapped[int]=mapped_column(Integer, nullable=False, default=0) + is_active: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + +class RegistrationRenewalTask(CommonBase): + __tablename__="registration_renewal_tasks" + __table_args__=(UniqueConstraint("registration_id","cycle_due_date", name="uq_registration_renewal_cycle"),) + id: Mapped[int]=mapped_column(Integer, primary_key=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"), index=True) + client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + registration_id: Mapped[int]=mapped_column(ForeignKey("client_registrations.id", ondelete="CASCADE"), nullable=False, index=True) + lifecycle_rule_id: Mapped[int|None]=mapped_column(ForeignKey("registration_lifecycle_rules.id", ondelete="SET NULL"), index=True) + cycle_due_date: Mapped[date]=mapped_column(Date, nullable=False, index=True) + action_type: Mapped[str]=mapped_column(String(40), nullable=False, default="renewal") + title: Mapped[str]=mapped_column(String(255), nullable=False) + status: Mapped[str]=mapped_column(String(40), nullable=False, default="pending", index=True) + priority: Mapped[str]=mapped_column(String(20), nullable=False, default="normal") + assigned_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + assigned_consultant_id: Mapped[int|None]=mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), index=True) + client_approval_required: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + partner_review_required: Mapped[bool]=mapped_column(Boolean, nullable=False, default=False) + submitted_on: Mapped[date|None]=mapped_column(Date) + acknowledgement_number: Mapped[str|None]=mapped_column(String(160)) + completed_on: Mapped[date|None]=mapped_column(Date) + notes: Mapped[str|None]=mapped_column(Text) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) + +class RegistrationRenewalTaskDocument(CommonBase): + __tablename__="registration_renewal_task_documents" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + renewal_task_id: Mapped[int]=mapped_column(ForeignKey("registration_renewal_tasks.id", ondelete="CASCADE"), nullable=False, index=True) + template_id: Mapped[int|None]=mapped_column(ForeignKey("registration_document_templates.id", ondelete="SET NULL")) + document_name: Mapped[str]=mapped_column(String(200), nullable=False) + mandatory: Mapped[bool]=mapped_column(Boolean, nullable=False, default=True) + status: Mapped[str]=mapped_column(String(30), nullable=False, default="pending") + document_id: Mapped[int|None]=mapped_column(Integer) + remarks: Mapped[str|None]=mapped_column(Text) + +class ClientRegistrationAction(CommonBase): + __tablename__="client_registration_actions" + id: Mapped[int]=mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + registration_id: Mapped[int]=mapped_column(ForeignKey("client_registrations.id", ondelete="CASCADE"), nullable=False, index=True) + renewal_task_id: Mapped[int|None]=mapped_column(ForeignKey("registration_renewal_tasks.id", ondelete="SET NULL"), index=True) + action_type: Mapped[str]=mapped_column(String(40), nullable=False, index=True) + previous_valid_until: Mapped[date|None]=mapped_column(Date) + new_valid_until: Mapped[date|None]=mapped_column(Date) + application_date: Mapped[date|None]=mapped_column(Date) + submission_date: Mapped[date|None]=mapped_column(Date) + acknowledgement_number: Mapped[str|None]=mapped_column(String(160)) + completion_date: Mapped[date|None]=mapped_column(Date) + status: Mapped[str]=mapped_column(String(40), nullable=False, default="completed") + performed_by_user_id: Mapped[int|None]=mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + performed_by_consultant_id: Mapped[int|None]=mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL")) + notes: Mapped[str|None]=mapped_column(Text) + created_at_utc: Mapped[datetime]=mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) diff --git a/app/modules/registrations/service.py b/app/modules/registrations/service.py new file mode 100644 index 0000000..3753432 --- /dev/null +++ b/app/modules/registrations/service.py @@ -0,0 +1,113 @@ +from __future__ import annotations +from datetime import date, datetime, timedelta, timezone +from calendar import monthrange +from sqlalchemy import and_, or_, select +from sqlalchemy.orm import Session +from app.modules.alerts.service import create_alert +from app.modules.clients.models import Client +from app.modules.consultants.models import ConsultantProfile +from app.modules.registrations.models import ( + RegistrationType, ClientRegistration, ClientRelatedPerson, ClientDigitalSignature, + RegistrationLifecycleRule, RegistrationDocumentTemplate, RegistrationRenewalTask, + RegistrationRenewalTaskDocument, ClientRegistrationAction, +) + +DEFAULT_TYPES = [ + ("PAN","PAN","Income Tax Department",False,False), + ("TAN","TAN","Income Tax Department",False,False), + ("GSTIN","GST Registration","GST Department",False,False), + ("CIN","Company Registration","Ministry of Corporate Affairs",False,False), + ("LLPIN","LLP Registration","Ministry of Corporate Affairs",False,False), + ("DSC","Digital Signature Certificate","Certifying Authority",True,True), + ("UDYAM","Udyam / MSME Registration","Ministry of MSME",False,False), + ("FSSAI","FSSAI Licence","FSSAI",True,False), + ("IEC","Import Export Code","DGFT",False,False), + ("SHOP_ACT","Shops and Establishment","State Authority",True,False), + ("TRADE_LICENCE","Trade Licence","Local Authority",True,False), + ("PT","Professional Tax","State Authority",True,False), + ("PF","EPFO Registration","EPFO",False,False), + ("ESI","ESIC Registration","ESIC",False,False), + ("12AB","Section 12AB Registration","Income Tax Department",True,False), + ("80G","Section 80G Approval","Income Tax Department",True,False), + ("FCRA","FCRA Registration","Ministry of Home Affairs",True,False), + ("RERA","RERA Registration","RERA Authority",True,False), +] + +def seed_registration_types(db: Session) -> None: + existing={x for x in db.execute(select(RegistrationType.code)).scalars().all()} + for i,(code,name,authority,expiry,person) in enumerate(DEFAULT_TYPES,1): + if code not in existing: + db.add(RegistrationType(code=code,name=name,issuing_authority=authority,supports_expiry=expiry,supports_related_person=person,sort_order=i*10)) + db.commit() + +def add_months(value: date, months: int) -> date: + m=value.month-1+months; y=value.year+m//12; m=m%12+1 + return date(y,m,min(value.day,monthrange(y,m)[1])) + +def resolve_next_action(reg: ClientRegistration, rule: RegistrationLifecycleRule|None) -> date|None: + if reg.valid_until and rule and rule.cycle_type=="expiry_renewal": + return reg.valid_until-timedelta(days=max(0,rule.renewal_window_days)) + base=reg.effective_from or reg.issue_date + if base and rule and rule.frequency_months: + candidate=add_months(base,rule.frequency_months) + while candidate<=date.today(): candidate=add_months(candidate,rule.frequency_months) + return candidate + return reg.next_action_date + +def create_task_documents(db: Session, task: RegistrationRenewalTask, registration_type_id: int) -> None: + rows=db.execute(select(RegistrationDocumentTemplate).where(RegistrationDocumentTemplate.registration_type_id==registration_type_id,RegistrationDocumentTemplate.action_type==task.action_type,RegistrationDocumentTemplate.is_active.is_(True)).order_by(RegistrationDocumentTemplate.sort_order)).scalars().all() + for row in rows: + db.add(RegistrationRenewalTaskDocument(renewal_task_id=task.id,template_id=row.id,document_name=row.document_name,mandatory=row.mandatory)) + +def _alert_once(db: Session,user_id:int|None,title:str,message:str,target_url:str,tenant_id:int,branch_id:int|None): + if not user_id:return + today=datetime.now(timezone.utc).date() + from app.modules.alerts.models import UserAlert + exists=db.execute(select(UserAlert.id).where(UserAlert.user_id==user_id,UserAlert.title==title,UserAlert.target_url==target_url,UserAlert.created_at_utc>=datetime.combine(today,datetime.min.time(),tzinfo=timezone.utc))).scalar_one_or_none() + if not exists:create_alert(db,user_id=user_id,title=title,message=message,tenant_id=tenant_id,branch_id=branch_id,role_context="registrations",alert_type="registration_expiry",priority="high",target_url=target_url,commit=False) + +def run_registration_lifecycle_once(db: Session, today: date|None=None) -> dict: + today=today or date.today(); created=alerts=0 + regs=db.execute(select(ClientRegistration).where(ClientRegistration.status.in_(["active","renewal_due","update_due"]))).scalars().all() + for reg in regs: + rule=db.get(RegistrationLifecycleRule,reg.lifecycle_rule_id) if reg.lifecycle_rule_id else None + if not rule: + rule=db.execute(select(RegistrationLifecycleRule).join(Client, Client.id==reg.client_id).where(RegistrationLifecycleRule.registration_type_id==reg.registration_type_id,RegistrationLifecycleRule.is_active.is_(True),or_(RegistrationLifecycleRule.tenant_id.is_(None),RegistrationLifecycleRule.tenant_id==reg.tenant_id),or_(RegistrationLifecycleRule.entity_type.is_(None),RegistrationLifecycleRule.entity_type==Client.client_type)).order_by(RegistrationLifecycleRule.tenant_id.desc(),RegistrationLifecycleRule.id.desc())).scalars().first() + due=reg.valid_until or reg.next_action_date + if not due: continue + next_action=resolve_next_action(reg,rule) + if next_action and reg.next_action_date!=next_action: reg.next_action_date=next_action + window=(rule.renewal_window_days if rule else 60) + if due-timedelta(days=window)<=today and reg.auto_create_task: + task=db.execute(select(RegistrationRenewalTask).where(RegistrationRenewalTask.registration_id==reg.id,RegistrationRenewalTask.cycle_due_date==due)).scalar_one_or_none() + if not task: + rt=db.get(RegistrationType,reg.registration_type_id); client=db.get(Client,reg.client_id) + action="renewal" if reg.valid_until else "update" + task=RegistrationRenewalTask(tenant_id=reg.tenant_id,branch_id=reg.branch_id,client_id=reg.client_id,registration_id=reg.id,lifecycle_rule_id=rule.id if rule else None,cycle_due_date=due,action_type=action,title=f"{action.title()} - {rt.name} - {client.client_name}",priority="critical" if due<=today else "high",assigned_user_id=reg.assigned_user_id,assigned_consultant_id=reg.assigned_consultant_id,client_approval_required=bool(rule and rule.requires_client_approval),partner_review_required=bool(rule and rule.requires_partner_review)) + db.add(task);db.flush();create_task_documents(db,task,reg.registration_type_id);created+=1 + client=db.get(Client,reg.client_id) + days=(due-today).days + title=f"{db.get(RegistrationType,reg.registration_type_id).name} {'expired' if days<0 else 'due'}: {client.client_name}" + msg=f"Registration {reg.registration_number} is {'overdue by '+str(abs(days))+' day(s)' if days<0 else 'due in '+str(days)+' day(s)'} on {due:%d-%m-%Y}." + reminder_days={int(x.strip()) for x in ((rule.reminder_days_csv if rule else "60,30,15,7,0") or "").split(",") if x.strip().lstrip("-").isdigit()} + should_alert = days in reminder_days or days < 0 + if should_alert: + _alert_once(db,reg.assigned_user_id or client.partner_id,title,msg,f"/registrations/tasks/{task.id}",reg.tenant_id,reg.branch_id);alerts+=1 + if should_alert and reg.assigned_consultant_id: + cp=db.get(ConsultantProfile,reg.assigned_consultant_id); _alert_once(db,cp.user_id if cp else None,title,msg,f"/registrations/tasks/{task.id}",reg.tenant_id,reg.branch_id) + reg.status="expired" if due

{{client.client_name}} — Registrations

← Dashboard

Registration records

{% for r,t in registrations %}
{{t.name}} — {{r.registration_number}}
Valid until: {{r.valid_until or '—'}} · Next action: {{r.next_action_date or '—'}} · {{r.status}}
{% else %}

No records.

{% endfor %}

Related persons and DSC

{% for p in people %}
{{p.full_name}} — {{p.person_type}}{% if p.din %} · DIN {{p.din}}{% endif %}
{% endfor %}{% for d,p in dscs %}
DSC: {{p.full_name}} · expires {{d.expires_on}} · {{d.status}}
{% endfor %}
{% if can_manage %}

Add Related Person

Add Registration

Add DSC

{% endif %}{% endblock %} diff --git a/app/modules/registrations/templates/registrations/dashboard.html b/app/modules/registrations/templates/registrations/dashboard.html new file mode 100644 index 0000000..d5a98fc --- /dev/null +++ b/app/modules/registrations/templates/registrations/dashboard.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

Registrations & Renewals

Expiry, periodic updates, generated renewal work and escalation.

{% if can_manage %}
Lifecycle Masters
{% endif %}
{% for label,value in [('Active',counts.active),('Due / Update Due',counts.due),('Expired',counts.expired),('Open Renewal Tasks',counts.tasks)] %}
{{ label }}
{{ value }}
{% endfor %}
Open renewal and update work
{% for task,client in tasks %}{% else %}{% endfor %}
DueClientWorkStatus
{{ task.cycle_due_date }}{{ client.client_name }}{{ task.title }}{{ task.status|replace('_',' ')|title }}
No open lifecycle work.
Client registrations
{% for reg,client,rt in registrations %}{% else %}{% endfor %}
ClientTypeNumberExpiry / Next actionStatus
{{ client.client_name }}{{ rt.name }}{{ reg.registration_number }}{{ reg.valid_until or reg.next_action_date or '—' }}{{ reg.status|replace('_',' ')|title }}
No registration records.
{% endblock %} diff --git a/app/modules/registrations/templates/registrations/masters.html b/app/modules/registrations/templates/registrations/masters.html new file mode 100644 index 0000000..7763711 --- /dev/null +++ b/app/modules/registrations/templates/registrations/masters.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

Registration Lifecycle Masters

← Dashboard
{% if can_manage %}

Registration Type

Lifecycle Rule

Document Requirement

{% endif %}

Types

{% for x in types %}
{{x.code}} — {{x.name}}
{% endfor %}

Rules & document templates

{% for r,t in rules %}
{{t.name}} — {{r.cycle_type|replace('_',' ')}} — {{r.renewal_window_days}} days
{% endfor %}{% for d,t in templates_rows %}
{{t.name}} / {{d.action_type}} — {{d.document_name}}
{% endfor %}
{% endblock %} diff --git a/app/modules/registrations/templates/registrations/task.html b/app/modules/registrations/templates/registrations/task.html new file mode 100644 index 0000000..1ab0045 --- /dev/null +++ b/app/modules/registrations/templates/registrations/task.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

{{task.title}}

← Dashboard

Lifecycle work

Client
{{client.client_name}}
Registration
{{registration_type.name}} — {{registration.registration_number}}
Due
{{task.cycle_due_date}}
Status
{{task.status}}

Document checklist

{% for d in documents %}
{{d.document_name}} {% if d.mandatory %}*{% endif %} — {{d.status}}
{% else %}

No template requirements.

{% endfor %}

History

{% for a in actions %}
{{a.action_type|title}} · {{a.completion_date or a.created_at_utc.date()}} · {{a.acknowledgement_number or ''}}
{% else %}

No completed lifecycle actions.

{% endfor %}
{% if can_manage %}

Update progress

Complete and schedule next cycle

{% endif %}
{% endblock %} diff --git a/app/modules/registrations/ui.py b/app/modules/registrations/ui.py new file mode 100644 index 0000000..e5171c0 --- /dev/null +++ b/app/modules/registrations/ui.py @@ -0,0 +1,150 @@ +from __future__ import annotations +from datetime import date +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import or_, 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.clients.models import Client +from app.modules.consultants.models import ConsultantProfile +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.registrations.models import * +from app.modules.registrations.service import seed_registration_types, run_registration_lifecycle_once, complete_renewal + +router=APIRouter(prefix="/registrations", tags=["registrations-ui"]) + +def _ctx(request,user,db,**kw): + 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)};base.update(kw);return base + +def _user(request,db): + user=get_current_user(request,db) + if not user: return None + require_permission(db,user,"clients.view");return user + +def _tenant(request,user): return int(request.session.get("active_tenant_id") or user.tenant_id) +def _branch(request,user): + raw=request.session.get("active_branch_id");return int(raw) if raw not in (None,"",0,"0") else None + +def _can_manage(db,user): + try: require_permission(db,user,"clients.edit");return True + except Exception:return False + +def _d(value): return date.fromisoformat(value) if value else None + +@router.get("",response_class=HTMLResponse) +def dashboard(request:Request): + with CommonSessionLocal() as db: + user=_user(request,db) + if not user:return RedirectResponse('/login',303) + seed_registration_types(db);run_registration_lifecycle_once(db) + tid=_tenant(request,user); bid=_branch(request,user) + regq=select(ClientRegistration,Client,RegistrationType).join(Client,Client.id==ClientRegistration.client_id).join(RegistrationType,RegistrationType.id==ClientRegistration.registration_type_id).where(ClientRegistration.tenant_id==tid) + taskq=select(RegistrationRenewalTask,Client).join(Client,Client.id==RegistrationRenewalTask.client_id).where(RegistrationRenewalTask.tenant_id==tid,RegistrationRenewalTask.status.notin_(["completed","cancelled"])) + if bid: regq=regq.where(ClientRegistration.branch_id==bid);taskq=taskq.where(RegistrationRenewalTask.branch_id==bid) + regs=db.execute(regq.order_by(ClientRegistration.valid_until.asc().nullslast(),Client.client_name)).all();tasks=db.execute(taskq.order_by(RegistrationRenewalTask.cycle_due_date)).all() + counts={"active":0,"due":0,"expired":0,"tasks":len(tasks)} + for reg,_,_ in regs: + if reg.status=="expired":counts["expired"]+=1 + elif reg.status in {"renewal_due","update_due"}:counts["due"]+=1 + else:counts["active"]+=1 + return templates.TemplateResponse("modules/registrations/templates/registrations/dashboard.html",_ctx(request,user,db,registrations=regs,tasks=tasks,counts=counts,can_manage=_can_manage(db,user),today=date.today())) + +@router.get("/masters",response_class=HTMLResponse) +def masters(request:Request): + with CommonSessionLocal() as db: + user=_user(request,db) + if not user:return RedirectResponse('/login',303) + seed_registration_types(db);tid=_tenant(request,user) + return templates.TemplateResponse("modules/registrations/templates/registrations/masters.html",_ctx(request,user,db,types=db.execute(select(RegistrationType).order_by(RegistrationType.sort_order)).scalars().all(),rules=db.execute(select(RegistrationLifecycleRule,RegistrationType).join(RegistrationType).where(or_(RegistrationLifecycleRule.tenant_id.is_(None),RegistrationLifecycleRule.tenant_id==tid))).all(),templates_rows=db.execute(select(RegistrationDocumentTemplate,RegistrationType).join(RegistrationType).where(or_(RegistrationDocumentTemplate.tenant_id.is_(None),RegistrationDocumentTemplate.tenant_id==tid))).all(),can_manage=_can_manage(db,user))) + +@router.post("/masters/types") +async def create_type(request:Request,code:str=Form(...),name:str=Form(...),category:str=Form("registration"),issuing_authority:str=Form(""),supports_expiry:bool=Form(False),supports_related_person:bool=Form(False),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit") + code=code.strip().upper();existing=db.execute(select(RegistrationType).where(RegistrationType.code==code)).scalar_one_or_none() + if existing: existing.name=name.strip();existing.category=category;existing.issuing_authority=issuing_authority or None;existing.supports_expiry=supports_expiry;existing.supports_related_person=supports_related_person;existing.is_active=True + else: db.add(RegistrationType(code=code,name=name.strip(),category=category,issuing_authority=issuing_authority or None,supports_expiry=supports_expiry,supports_related_person=supports_related_person,is_system=False)) + db.commit();return RedirectResponse('/registrations/masters',303) + +@router.post("/masters/rules") +async def create_rule(request:Request,registration_type_id:int=Form(...),entity_type:str=Form(""),cycle_type:str=Form(...),frequency_months:str=Form(""),renewal_window_days:int=Form(60),reminder_days_csv:str=Form("90,60,30,15,7,0"),grace_period_days:int=Form(0),requires_client_approval:bool=Form(False),requires_partner_review:bool=Form(False),auto_create_task:bool=Form(False),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit") + db.add(RegistrationLifecycleRule(tenant_id=_tenant(request,user),registration_type_id=registration_type_id,entity_type=entity_type or None,cycle_type=cycle_type,frequency_months=int(frequency_months) if frequency_months else None,renewal_window_days=renewal_window_days,reminder_days_csv=reminder_days_csv,grace_period_days=grace_period_days,requires_client_approval=requires_client_approval,requires_partner_review=requires_partner_review,auto_create_task=auto_create_task,created_by_user_id=user.id));db.commit();return RedirectResponse('/registrations/masters',303) + +@router.post("/masters/documents") +async def create_doc_template(request:Request,registration_type_id:int=Form(...),action_type:str=Form(...),document_name:str=Form(...),description:str=Form(""),mandatory:bool=Form(False),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit") + db.add(RegistrationDocumentTemplate(tenant_id=_tenant(request,user),registration_type_id=registration_type_id,action_type=action_type,document_name=document_name.strip(),description=description or None,mandatory=mandatory));db.commit();return RedirectResponse('/registrations/masters',303) + +@router.get("/clients/{client_id}",response_class=HTMLResponse) +def client_register(request:Request,client_id:int): + with CommonSessionLocal() as db: + user=_user(request,db) + if not user:return RedirectResponse('/login',303) + client=db.get(Client,client_id) + if not client or client.tenant_id!=_tenant(request,user):return RedirectResponse('/registrations',303) + seed_registration_types(db) + regs=db.execute(select(ClientRegistration,RegistrationType).join(RegistrationType).where(ClientRegistration.client_id==client_id).order_by(RegistrationType.name)).all() + people=db.execute(select(ClientRelatedPerson).where(ClientRelatedPerson.client_id==client_id).order_by(ClientRelatedPerson.full_name)).scalars().all() + dscs=db.execute(select(ClientDigitalSignature,ClientRelatedPerson).join(ClientRelatedPerson).where(ClientDigitalSignature.client_id==client_id).order_by(ClientDigitalSignature.expires_on)).all() + rules=db.execute(select(RegistrationLifecycleRule).where(or_(RegistrationLifecycleRule.tenant_id.is_(None),RegistrationLifecycleRule.tenant_id==client.tenant_id),RegistrationLifecycleRule.is_active.is_(True))).scalars().all() + consultants=db.execute(select(ConsultantProfile).where(ConsultantProfile.tenant_id==client.tenant_id,ConsultantProfile.is_active.is_(True))).scalars().all() + return templates.TemplateResponse("modules/registrations/templates/registrations/client.html",_ctx(request,user,db,client=client,registrations=regs,people=people,dscs=dscs,types=db.execute(select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(RegistrationType.sort_order)).scalars().all(),rules=rules,consultants=consultants,can_manage=_can_manage(db,user))) + +@router.post("/clients/{client_id}/people") +async def add_person(request:Request,client_id:int,person_type:str=Form(...),full_name:str=Form(...),designation:str=Form(""),pan:str=Form(""),din:str=Form(""),date_of_birth:str=Form(""),appointment_date:str=Form(""),mobile:str=Form(""),email:str=Form(""),authorised_signatory:bool=Form(False),notes:str=Form(""),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");c=db.get(Client,client_id) + db.add(ClientRelatedPerson(tenant_id=c.tenant_id,client_id=c.id,person_type=person_type,full_name=full_name.strip(),designation=designation or None,pan=pan.strip().upper() or None,din=din or None,date_of_birth=_d(date_of_birth),appointment_date=_d(appointment_date),mobile=mobile or None,email=email or None,authorised_signatory=authorised_signatory,notes=notes or None,created_by_user_id=user.id));db.commit();return RedirectResponse(f'/registrations/clients/{client_id}',303) + +@router.post("/clients/{client_id}/records") +async def add_registration(request:Request,client_id:int,registration_type_id:int=Form(...),related_person_id:str=Form(""),registration_number:str=Form(...),jurisdiction:str=Form(""),state_code:str=Form(""),issue_date:str=Form(""),effective_from:str=Form(""),valid_until:str=Form(""),next_action_date:str=Form(""),lifecycle_rule_id:str=Form(""),responsible_party:str=Form("firm"),assigned_user_id:str=Form(""),assigned_consultant_id:str=Form(""),auto_create_task:bool=Form(False),notes:str=Form(""),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");c=db.get(Client,client_id) + db.add(ClientRegistration(tenant_id=c.tenant_id,branch_id=c.branch_id,client_id=c.id,registration_type_id=registration_type_id,related_person_id=int(related_person_id) if related_person_id else None,registration_number=registration_number.strip().upper(),jurisdiction=jurisdiction or None,state_code=state_code or None,issue_date=_d(issue_date),effective_from=_d(effective_from),valid_until=_d(valid_until),next_action_date=_d(next_action_date),lifecycle_rule_id=int(lifecycle_rule_id) if lifecycle_rule_id else None,responsible_party=responsible_party,assigned_user_id=int(assigned_user_id) if assigned_user_id else c.partner_id,assigned_consultant_id=int(assigned_consultant_id) if assigned_consultant_id else None,auto_create_task=auto_create_task,notes=notes or None,created_by_user_id=user.id));db.commit();run_registration_lifecycle_once(db);return RedirectResponse(f'/registrations/clients/{client_id}',303) + +@router.post("/clients/{client_id}/dsc") +async def add_dsc(request:Request,client_id:int,related_person_id:int=Form(...),certificate_serial_number:str=Form(""),provider:str=Form(""),token_make:str=Form(""),class_type:str=Form(""),issued_on:str=Form(""),expires_on:str=Form(...),custody_location:str=Form(""),renewal_responsibility:str=Form("firm"),assigned_consultant_id:str=Form(""),notes:str=Form(""),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");c=db.get(Client,client_id) + expiry=_d(expires_on) + dsc=ClientDigitalSignature(tenant_id=c.tenant_id,client_id=c.id,related_person_id=related_person_id,certificate_serial_number=certificate_serial_number or None,provider=provider or None,token_make=token_make or None,class_type=class_type or None,issued_on=_d(issued_on),expires_on=expiry,custody_location=custody_location or None,renewal_responsibility=renewal_responsibility,assigned_consultant_id=int(assigned_consultant_id) if assigned_consultant_id else None,notes=notes or None,created_by_user_id=user.id) + db.add(dsc) + dsc_type=db.execute(select(RegistrationType).where(RegistrationType.code=="DSC")).scalar_one() + serial=(certificate_serial_number or f"DSC-{client_id}-{related_person_id}-{expiry.isoformat()}").strip().upper() + existing=db.execute(select(ClientRegistration).where(ClientRegistration.tenant_id==c.tenant_id,ClientRegistration.registration_type_id==dsc_type.id,ClientRegistration.registration_number==serial)).scalar_one_or_none() + if not existing: + db.add(ClientRegistration(tenant_id=c.tenant_id,branch_id=c.branch_id,client_id=c.id,registration_type_id=dsc_type.id,related_person_id=related_person_id,registration_number=serial,issue_date=_d(issued_on),effective_from=_d(issued_on),valid_until=expiry,responsible_party=renewal_responsibility,assigned_user_id=c.partner_id,assigned_consultant_id=int(assigned_consultant_id) if assigned_consultant_id else None,auto_create_task=True,notes=notes or None,created_by_user_id=user.id)) + db.commit();run_registration_lifecycle_once(db);return RedirectResponse(f'/registrations/clients/{client_id}',303) + +@router.get("/tasks/{task_id}",response_class=HTMLResponse) +def task_detail(request:Request,task_id:int): + with CommonSessionLocal() as db: + user=_user(request,db) + if not user:return RedirectResponse('/login',303) + task=db.get(RegistrationRenewalTask,task_id) + if not task or task.tenant_id!=_tenant(request,user):return RedirectResponse('/registrations',303) + reg=db.get(ClientRegistration,task.registration_id);client=db.get(Client,task.client_id);rt=db.get(RegistrationType,reg.registration_type_id) + docs=db.execute(select(RegistrationRenewalTaskDocument).where(RegistrationRenewalTaskDocument.renewal_task_id==task.id)).scalars().all() + actions=db.execute(select(ClientRegistrationAction).where(ClientRegistrationAction.registration_id==reg.id).order_by(ClientRegistrationAction.created_at_utc.desc())).scalars().all() + return templates.TemplateResponse("modules/registrations/templates/registrations/task.html",_ctx(request,user,db,task=task,registration=reg,client=client,registration_type=rt,documents=docs,actions=actions,can_manage=_can_manage(db,user))) + +@router.post("/tasks/{task_id}/status") +async def update_task(request:Request,task_id:int,status:str=Form(...),submitted_on:str=Form(""),acknowledgement_number:str=Form(""),notes:str=Form(""),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");task=db.get(RegistrationRenewalTask,task_id);task.status=status;task.submitted_on=_d(submitted_on);task.acknowledgement_number=acknowledgement_number or None;task.notes=notes or None;db.commit();return RedirectResponse(f'/registrations/tasks/{task_id}',303) + +@router.post("/tasks/{task_id}/complete") +async def complete(request:Request,task_id:int,new_valid_until:str=Form(""),acknowledgement_number:str=Form(""),notes:str=Form(""),csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");task=db.get(RegistrationRenewalTask,task_id);complete_renewal(db,task,new_valid_until=_d(new_valid_until),acknowledgement_number=acknowledgement_number or None,actor_user_id=user.id,notes=notes or None);return RedirectResponse(f'/registrations/tasks/{task_id}',303) + +@router.post("/run-engine") +async def run_engine(request:Request,csrf_token:str=Form(...)): + with CommonSessionLocal() as db: + user=_user(request,db);validate_csrf(request,csrf_token);require_permission(db,user,"clients.edit");run_registration_lifecycle_once(db);return RedirectResponse('/registrations',303) diff --git a/app/ui/app.py b/app/ui/app.py index 50872b1..d3ecaf9 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -33,6 +33,7 @@ from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_r from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router from app.modules.peer_review_export.ui import router as peer_review_export_router from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router +from app.modules.registrations.ui import router as registrations_ui_router def mount_ui(app: FastAPI) -> None: @@ -64,6 +65,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(system_admin_dashboard_router) app.include_router(work_detail_ui_router) app.include_router(clients_ui_router) + app.include_router(registrations_ui_router) app.include_router(employees_ui_router) app.include_router(manager_dashboard_router) app.include_router(managers_ui_router) diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html index a2213cd..22efecf 100644 --- a/app/ui/templates/base/layout.html +++ b/app/ui/templates/base/layout.html @@ -375,6 +375,7 @@ {% if can_manage_clients(current_user, ui_perms, ui_roles) %}Add Client{% endif %} {% if can_manage_clients(current_user, ui_perms, ui_roles) %}Import Clients{% endif %} {% if can_export_clients(current_user, ui_perms, ui_roles) %}Export Clients{% endif %} + {% if can_view_clients(current_user, ui_perms, ui_roles) %}Registrations & Renewals{% endif %} {% endif %} {% if has_firm_consultants_menu %}
Consultants