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
@@ -0,0 +1,50 @@
"""release_a_clients_engagement_mode
Revision ID: 20260414_release_a_clients
Revises: 7f1c9b2d4a10
Create Date: 2026-04-14
"""
from alembic import op
import sqlalchemy as sa
revision = "20260414_release_a_clients"
down_revision = "7f1c9b2d4a10"
branch_labels = None
depends_on = None
def upgrade():
# engagement_mode column already exists from the earlier partial migration,
# so do NOT add it again.
# Make partner_id nullable in a SQLite-safe way
with op.batch_alter_table("clients") as batch_op:
batch_op.alter_column(
"partner_id",
existing_type=sa.Integer(),
nullable=True,
)
# Remove server default from engagement_mode if it exists
with op.batch_alter_table("clients") as batch_op:
batch_op.alter_column(
"engagement_mode",
existing_type=sa.String(length=32),
server_default=None,
)
def downgrade():
# Revert partner_id back to NOT NULL
with op.batch_alter_table("clients") as batch_op:
batch_op.alter_column(
"partner_id",
existing_type=sa.Integer(),
nullable=False,
)
# Drop engagement_mode
with op.batch_alter_table("clients") as batch_op:
batch_op.drop_column("engagement_mode")
@@ -0,0 +1,21 @@
from alembic import op
import sqlalchemy as sa
revision = "20260415_release_b1_b2"
down_revision = "20260414_release_a_clients"
def upgrade():
op.create_table(
"client_associations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("association_type", sa.String(50), nullable=False),
sa.Column("firm_tenant_id", sa.Integer(), nullable=True),
sa.Column("partner_user_id", sa.Integer(), nullable=True),
sa.Column("consultant_id", sa.Integer(), nullable=True),
sa.Column("created_source", sa.String(50), nullable=False),
)
def downgrade():
op.drop_table("client_associations")
@@ -0,0 +1,73 @@
from alembic import op
import sqlalchemy as sa
revision = "20260416_release_b6_cleanup"
down_revision = "20260415_release_b1_b2"
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
rows = conn.execute(
sa.text(
"""
SELECT c.id, c.tenant_id, c.branch_id, c.partner_id
FROM clients c
LEFT JOIN client_associations a
ON a.client_id = c.id
WHERE a.id IS NULL
"""
)
).fetchall()
for row in rows:
association_type = "firm" if row.tenant_id else "self_service_unassigned"
created_source = "system_admin" if row.tenant_id else "self_service"
conn.execute(
sa.text(
"""
INSERT INTO client_associations
(
client_id,
association_type,
firm_tenant_id,
consultant_id,
partner_user_id,
created_source
)
VALUES
(
:client_id,
:association_type,
:firm_tenant_id,
:consultant_id,
:partner_user_id,
:created_source
)
"""
),
{
"client_id": row.id,
"association_type": association_type,
"firm_tenant_id": row.tenant_id,
"consultant_id": None,
"partner_user_id": row.partner_id,
"created_source": created_source,
},
)
def downgrade():
conn = op.get_bind()
conn.execute(
sa.text(
"""
DELETE FROM client_associations
WHERE created_source IN ('system_admin', 'self_service')
"""
)
)
@@ -0,0 +1,21 @@
from alembic import op
import sqlalchemy as sa
revision = "20260419_client_portal_user_link"
down_revision = "20260416_release_b6_cleanup"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("clients") as batch_op:
batch_op.add_column(sa.Column("portal_user_id", sa.Integer(), nullable=True))
batch_op.create_index("ix_clients_portal_user_id", ["portal_user_id"], unique=False)
batch_op.create_foreign_key("fk_clients_portal_user_id_users", "users", ["portal_user_id"], ["id"])
def downgrade():
with op.batch_alter_table("clients") as batch_op:
batch_op.drop_constraint("fk_clients_portal_user_id_users", type_="foreignkey")
batch_op.drop_index("ix_clients_portal_user_id")
batch_op.drop_column("portal_user_id")
@@ -0,0 +1,164 @@
"""split services into system catalogue and firm task templates
Revision ID: 20260420_services_catalogue_and_firm_templates
Revises: 20260419_client_portal_user_link
Create Date: 2026-04-20 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260420_services_catalogue_and_firm_templates"
down_revision: Union[str, None] = "20260419_client_portal_user_link"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade() -> None:
if not _has_table("service_catalogues"):
op.create_table(
"service_catalogues",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("service_code", sa.String(length=50), nullable=False),
sa.Column("service_name", sa.String(length=200), nullable=False),
sa.Column("category", sa.String(length=100), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("recurrence_type", sa.String(length=50), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_client_requestable", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("service_code", name="uq_service_catalogues_code"),
)
_create_index_if_missing("service_catalogues", "ix_service_catalogues_service_code", ["service_code"])
_create_index_if_missing("service_catalogues", "ix_service_catalogues_service_name", ["service_name"])
_create_index_if_missing("service_catalogues", "ix_service_catalogues_category", ["category"])
if not _has_table("firm_service_task_templates"):
op.create_table(
"firm_service_task_templates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("task_name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sequence_no", sa.Integer(), nullable=False),
sa.Column("default_role_name", sa.String(length=100), nullable=True),
sa.Column("sla_days", sa.Integer(), nullable=True),
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("requires_review", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "service_catalogue_id", "sequence_no", name="uq_firm_service_task_sequence"),
)
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_tenant_id", ["tenant_id"])
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_branch_id", ["branch_id"])
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_service_catalogue_id", ["service_catalogue_id"])
bind = op.get_bind()
if _has_table("services"):
bind.execute(sa.text(
"""
INSERT INTO service_catalogues (
service_code, service_name, category, description, recurrence_type,
is_active, is_client_requestable, is_consultant_requestable,
created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc
)
SELECT
s.service_code,
MIN(s.service_name) AS service_name,
MIN(s.category) AS category,
MIN(s.description) AS description,
NULL AS recurrence_type,
MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_active,
MAX(CASE WHEN s.is_client_requestable THEN 1 ELSE 0 END) AS is_client_requestable,
MAX(CASE WHEN s.is_consultant_requestable THEN 1 ELSE 0 END) AS is_consultant_requestable,
MIN(s.created_by_user_id) AS created_by_user_id,
MIN(s.updated_by_user_id) AS updated_by_user_id,
MIN(s.created_at_utc) AS created_at_utc,
MAX(s.updated_at_utc) AS updated_at_utc
FROM services s
WHERE NOT EXISTS (
SELECT 1 FROM service_catalogues sc WHERE sc.service_code = s.service_code
)
GROUP BY s.service_code
"""
))
if _has_table("services") and _has_table("service_task_templates"):
bind.execute(sa.text(
"""
INSERT INTO firm_service_task_templates (
tenant_id, branch_id, service_catalogue_id,
task_name, description, sequence_no, default_role_name, sla_days,
is_mandatory, requires_review, is_active,
created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc
)
SELECT
s.tenant_id,
s.branch_id,
sc.id,
st.task_name,
st.description,
st.sequence_no,
st.default_role_name,
NULL AS sla_days,
st.is_mandatory,
st.requires_review,
st.is_active,
st.created_by_user_id,
st.updated_by_user_id,
st.created_at_utc,
st.updated_at_utc
FROM service_task_templates st
JOIN services s ON s.id = st.service_id
JOIN service_catalogues sc ON sc.service_code = s.service_code
WHERE NOT EXISTS (
SELECT 1 FROM firm_service_task_templates ft
WHERE ft.tenant_id = s.tenant_id
AND ft.service_catalogue_id = sc.id
AND ft.sequence_no = st.sequence_no
)
"""
))
def downgrade() -> None:
pass
@@ -0,0 +1,108 @@
from alembic import op
import sqlalchemy as sa
revision = "20260420_services_catalogue_firm_toggle"
down_revision = "20260420_services_catalogue_and_firm_templates"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade():
if not _has_table("firm_service_selections"):
op.create_table(
"firm_service_selections",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("default_branch_id", sa.Integer(), nullable=True),
sa.Column("activated_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("activated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["default_branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["activated_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "service_catalogue_id", name="uq_firm_service_selections_tenant_catalogue"),
)
_create_index_if_missing(
"firm_service_selections",
"ix_firm_service_selections_tenant_id",
["tenant_id"],
)
_create_index_if_missing(
"firm_service_selections",
"ix_firm_service_selections_service_catalogue_id",
["service_catalogue_id"],
)
_create_index_if_missing(
"firm_service_selections",
"ix_firm_service_selections_default_branch_id",
["default_branch_id"],
)
conn = op.get_bind()
if _has_table("services") and _has_table("service_catalogues"):
conn.execute(sa.text(
"""
INSERT INTO firm_service_selections (
tenant_id,
service_catalogue_id,
is_enabled,
default_branch_id,
activated_by_user_id,
updated_by_user_id,
activated_at_utc,
updated_at_utc
)
SELECT
s.tenant_id,
sc.id,
MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_enabled,
MIN(s.branch_id) AS default_branch_id,
MIN(s.created_by_user_id) AS activated_by_user_id,
MIN(s.updated_by_user_id) AS updated_by_user_id,
MIN(s.created_at_utc) AS activated_at_utc,
MAX(s.updated_at_utc) AS updated_at_utc
FROM services s
JOIN service_catalogues sc
ON sc.service_code = s.service_code
WHERE NOT EXISTS (
SELECT 1
FROM firm_service_selections fss
WHERE fss.tenant_id = s.tenant_id
AND fss.service_catalogue_id = sc.id
)
GROUP BY s.tenant_id, sc.id
"""
))
def downgrade():
if _has_table("firm_service_selections"):
op.drop_table("firm_service_selections")
@@ -0,0 +1,226 @@
"""services phase s1: categories, recurrence, sort order, applicability flags
Revision ID: 20260422_services_phase_s1
Revises: 20260420_services_catalogue_firm_toggle
Create Date: 2026-04-22 20:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260422_services_phase_s1"
down_revision = "20260420_services_catalogue_firm_toggle"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
try:
cols = _inspector().get_columns(table_name)
except Exception:
return False
return any(col.get("name") == column_name for col in cols)
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _normalize_code(name: str) -> str:
text = (name or "").strip().upper()
out = []
last_sep = False
for ch in text:
if ch.isalnum():
out.append(ch)
last_sep = False
else:
if not last_sep:
out.append("_")
last_sep = True
code = "".join(out).strip("_")
return code or "UNCATEGORIZED"
def upgrade():
conn = op.get_bind()
if not _has_table("service_categories"):
op.create_table(
"service_categories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=50), nullable=True),
sa.Column("name", sa.String(length=100), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
if _has_table("service_categories"):
if not _has_column("service_categories", "code"):
op.add_column("service_categories", sa.Column("code", sa.String(length=50), nullable=True))
if not _has_column("service_categories", "name"):
op.add_column("service_categories", sa.Column("name", sa.String(length=100), nullable=True))
if not _has_column("service_categories", "sort_order"):
op.add_column("service_categories", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"))
if not _has_column("service_categories", "is_active"):
op.add_column("service_categories", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
conn.execute(sa.text(
"UPDATE service_categories SET name = COALESCE(name, 'Category ' || id) WHERE name IS NULL"
))
rows = conn.execute(sa.text(
"SELECT id, name FROM service_categories WHERE (code IS NULL OR TRIM(code) = '')"
)).fetchall()
used_codes = set()
existing_code_rows = conn.execute(sa.text(
"SELECT code FROM service_categories WHERE code IS NOT NULL AND TRIM(code) <> ''"
)).fetchall()
for row in existing_code_rows:
used_codes.add(row[0])
for row in rows:
category_id = row[0]
name = row[1] or f"Category {category_id}"
base_code = _normalize_code(name)
code = base_code
n = 2
while code in used_codes:
code = f"{base_code}_{n}"
n += 1
used_codes.add(code)
conn.execute(
sa.text("UPDATE service_categories SET code = :code, name = COALESCE(name, :name) WHERE id = :id"),
{"code": code, "name": name, "id": category_id},
)
conn.execute(sa.text("UPDATE service_categories SET sort_order = 0 WHERE sort_order IS NULL"))
conn.execute(sa.text("UPDATE service_categories SET is_active = TRUE WHERE is_active IS NULL"))
_create_index_if_missing("service_categories", "ix_service_categories_code", ["code"])
_create_index_if_missing("service_categories", "ix_service_categories_name", ["name"])
_create_index_if_missing("service_categories", "ix_service_categories_sort_order", ["sort_order"])
if _has_table("service_catalogues"):
additions = [
("category_id", sa.Integer(), True, None),
("recurrence_type", sa.String(length=50), True, None),
("sort_order", sa.Integer(), False, "0"),
("applicable_individual", sa.Boolean(), False, sa.false()),
("applicable_proprietorship", sa.Boolean(), False, sa.false()),
("applicable_partnership", sa.Boolean(), False, sa.false()),
("applicable_llp", sa.Boolean(), False, sa.false()),
("applicable_company", sa.Boolean(), False, sa.false()),
("applicable_trust", sa.Boolean(), False, sa.false()),
("applicable_society", sa.Boolean(), False, sa.false()),
]
for name, typ, nullable, default in additions:
if not _has_column("service_catalogues", name):
kwargs = {"nullable": nullable}
if default is not None:
kwargs["server_default"] = default
op.add_column("service_catalogues", sa.Column(name, typ, **kwargs))
conn.execute(sa.text("UPDATE service_catalogues SET sort_order = 0 WHERE sort_order IS NULL"))
for flag in [
"applicable_individual",
"applicable_proprietorship",
"applicable_partnership",
"applicable_llp",
"applicable_company",
"applicable_trust",
"applicable_society",
]:
if _has_column("service_catalogues", flag):
conn.execute(sa.text(f"UPDATE service_catalogues SET {flag} = FALSE WHERE {flag} IS NULL"))
_create_index_if_missing("service_catalogues", "ix_service_catalogues_category_id", ["category_id"])
_create_index_if_missing("service_catalogues", "ix_service_catalogues_recurrence_type", ["recurrence_type"])
_create_index_if_missing("service_catalogues", "ix_service_catalogues_sort_order", ["sort_order"])
if _has_column("service_catalogues", "category"):
rows = conn.execute(sa.text(
"""
SELECT DISTINCT TRIM(category) AS category
FROM service_catalogues
WHERE category IS NOT NULL AND TRIM(category) <> ''
ORDER BY TRIM(category)
"""
)).fetchall()
next_sort = conn.execute(sa.text("SELECT COALESCE(MAX(sort_order), 0) FROM service_categories")).scalar() or 0
for row in rows:
category_name = row[0]
existing = conn.execute(
sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"),
{"name": category_name},
).fetchone()
if not existing:
base_code = _normalize_code(category_name)
code = base_code
n = 2
while conn.execute(
sa.text("SELECT 1 FROM service_categories WHERE code = :code"),
{"code": code},
).fetchone():
code = f"{base_code}_{n}"
n += 1
next_sort += 1
conn.execute(
sa.text(
"""
INSERT INTO service_categories (code, name, sort_order, is_active)
VALUES (:code, :name, :sort_order, TRUE)
"""
),
{"code": code, "name": category_name, "sort_order": next_sort},
)
if _has_column("service_catalogues", "category_id"):
rows = conn.execute(sa.text(
"""
SELECT id, category
FROM service_catalogues
WHERE category_id IS NULL
AND category IS NOT NULL
AND TRIM(category) <> ''
"""
)).fetchall()
for row in rows:
catalogue_id = row[0]
category_name = row[1].strip()
match = conn.execute(
sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"),
{"name": category_name},
).fetchone()
if match:
conn.execute(
sa.text("UPDATE service_catalogues SET category_id = :category_id WHERE id = :catalogue_id"),
{"category_id": match[0], "catalogue_id": catalogue_id},
)
def downgrade():
pass
@@ -0,0 +1,55 @@
"""services phase s2: default task templates
Revision ID: 20260423_services_phase_s2_default_templates
Revises: 20260422_services_phase_s1
Create Date: 2026-04-23 19:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260423_services_phase_s2_default_templates"
down_revision = "20260422_services_phase_s1"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def upgrade():
if not _has_table("service_default_task_templates"):
op.create_table(
"service_default_task_templates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("task_name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("default_role_name", sa.String(length=100), nullable=True),
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("requires_review", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.UniqueConstraint("service_catalogue_id", "sequence_no", name="uq_service_default_task_templates_sequence"),
)
if not _has_index("service_default_task_templates", "ix_service_default_task_templates_service_catalogue_id"):
op.create_index("ix_service_default_task_templates_service_catalogue_id", "service_default_task_templates", ["service_catalogue_id"], unique=False)
def downgrade():
if _has_table("service_default_task_templates"):
op.drop_table("service_default_task_templates")
@@ -0,0 +1,77 @@
"""restrict partner service management permissions
Revision ID: 20260424_partner_service_restrictions
Revises: 20260423_services_phase_s2_default_templates
Create Date: 2026-04-24 20:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260424_partner_service_restrictions"
down_revision = "20260423_services_phase_s2_default_templates"
branch_labels = None
depends_on = None
PARTNER_REMOVE_PERMISSIONS = (
"services.create",
"services.edit",
"services.deactivate",
"services.cross_branch",
"services.import",
"services.selection.manage",
"service_tasks.create",
"service_tasks.edit",
"service_tasks.import",
"service_tasks.deactivate",
)
def upgrade():
conn = op.get_bind()
conn.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE role_id IN (SELECT id FROM roles WHERE name = :role_name)
AND permission_id IN (SELECT id FROM permissions WHERE code IN :codes)
"""
).bindparams(sa.bindparam("codes", expanding=True)),
{"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS},
)
def downgrade():
conn = op.get_bind()
rows = conn.execute(
sa.text(
"""
SELECT r.id AS role_id, p.id AS permission_id
FROM roles r
JOIN permissions p ON p.code IN :codes
WHERE r.name = :role_name
"""
).bindparams(sa.bindparam("codes", expanding=True)),
{"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS},
).fetchall()
for row in rows:
exists = conn.execute(
sa.text(
"""
SELECT 1 FROM role_permissions
WHERE role_id = :role_id AND permission_id = :permission_id
"""
),
{"role_id": row.role_id, "permission_id": row.permission_id},
).fetchone()
if not exists:
conn.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
VALUES (:role_id, :permission_id)
"""
),
{"role_id": row.role_id, "permission_id": row.permission_id},
)
@@ -0,0 +1,87 @@
"""client service subscriptions and lightweight assignment
Revision ID: 20260425_client_service_subscriptions
Revises: 20260424_partner_service_restrictions
Create Date: 2026-04-25 20:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260425_client_service_subscriptions"
down_revision = "20260424_partner_service_restrictions"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade():
if not _has_table("client_service_subscriptions"):
op.create_table(
"client_service_subscriptions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("firm_service_selection_id", sa.Integer(), nullable=True),
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True),
sa.Column("start_date", sa.Date(), nullable=True),
sa.Column("end_date", sa.Date(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["firm_service_selection_id"], ["firm_service_selections.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "client_id", "service_catalogue_id", name="uq_client_service_subscription_tenant_client_service"),
)
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_tenant_id", ["tenant_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_branch_id", ["branch_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_client_id", ["client_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_service_catalogue_id", ["service_catalogue_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_firm_service_selection_id", ["firm_service_selection_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_partner_user_id", ["assigned_partner_user_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_manager_user_id", ["assigned_manager_user_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_staff_user_id", ["assigned_staff_user_id"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_status", ["status"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_active", ["is_active"])
def downgrade():
if _has_table("client_service_subscriptions"):
op.drop_table("client_service_subscriptions")
@@ -0,0 +1,88 @@
"""S4.3/S4.4 service execution task instances
Revision ID: 20260426_service_execution_tasks
Revises: 20260425_client_service_subscriptions
Create Date: 2026-04-26 20:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260426_service_execution_tasks"
down_revision = "20260425_client_service_subscriptions"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(name: str) -> bool:
return name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False):
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade():
if not _has_table("client_service_task_instances"):
op.create_table(
"client_service_task_instances",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("subscription_id", sa.Integer(), nullable=False),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("firm_task_template_id", sa.Integer(), nullable=True),
sa.Column("task_name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("default_role_name", sa.String(length=100), nullable=True),
sa.Column("assigned_to_user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("started_at_utc", sa.DateTime(), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["subscription_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assigned_to_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("subscription_id", "firm_task_template_id", name="uq_client_service_task_subscription_template"),
)
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_tenant_id", ["tenant_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_branch_id", ["branch_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_subscription_id", ["subscription_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_client_id", ["client_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_service_catalogue_id", ["service_catalogue_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assigned_to_user_id", ["assigned_to_user_id"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_status", ["status"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_active", ["is_active"])
def downgrade():
if _has_table("client_service_task_instances"):
op.drop_table("client_service_task_instances")
@@ -0,0 +1,96 @@
"""engagement year columns and locking
Revision ID: 20260429_engagement_year_locking
Revises: 20260426_service_execution_tasks
Create Date: 2026-04-29
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260429_engagement_year_locking"
down_revision: Union[str, None] = "20260426_service_execution_tasks"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(c["name"] == column_name for c in insp.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade() -> None:
if not _has_column("client_service_subscriptions", "financial_year"):
op.add_column("client_service_subscriptions", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26"))
if not _has_column("client_service_subscriptions", "assessment_year"):
op.add_column("client_service_subscriptions", sa.Column("assessment_year", sa.String(length=9), nullable=True))
if not _has_column("client_service_subscriptions", "is_locked"):
op.add_column("client_service_subscriptions", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()))
if not _has_column("client_service_subscriptions", "locked_at_utc"):
op.add_column("client_service_subscriptions", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True))
if not _has_column("client_service_subscriptions", "locked_by_user_id"):
op.add_column("client_service_subscriptions", sa.Column("locked_by_user_id", sa.Integer(), nullable=True))
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_financial_year", ["financial_year"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assessment_year", ["assessment_year"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_locked", ["is_locked"])
if not _has_column("client_service_task_instances", "financial_year"):
op.add_column("client_service_task_instances", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26"))
if not _has_column("client_service_task_instances", "assessment_year"):
op.add_column("client_service_task_instances", sa.Column("assessment_year", sa.String(length=9), nullable=True))
if not _has_column("client_service_task_instances", "is_locked"):
op.add_column("client_service_task_instances", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()))
if not _has_column("client_service_task_instances", "locked_at_utc"):
op.add_column("client_service_task_instances", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True))
if not _has_column("client_service_task_instances", "locked_by_user_id"):
op.add_column("client_service_task_instances", sa.Column("locked_by_user_id", sa.Integer(), nullable=True))
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_financial_year", ["financial_year"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assessment_year", ["assessment_year"])
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_locked", ["is_locked"])
# SQLite/PostgreSQL compatible replacement of uniqueness to include financial_year.
with op.batch_alter_table("client_service_subscriptions") as batch_op:
try:
batch_op.drop_constraint("uq_client_service_subscription_tenant_client_service", type_="unique")
except Exception:
pass
try:
batch_op.create_unique_constraint(
"uq_client_service_subscription_tenant_client_service_year",
["tenant_id", "client_id", "service_catalogue_id", "financial_year"],
)
except Exception:
pass
with op.batch_alter_table("client_service_task_instances") as batch_op:
try:
batch_op.drop_constraint("uq_client_service_task_subscription_template", type_="unique")
except Exception:
pass
try:
batch_op.create_unique_constraint(
"uq_client_service_task_subscription_template_year",
["subscription_id", "firm_task_template_id", "financial_year"],
)
except Exception:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,76 @@
"""add engagement type to services and engagements
Revision ID: 20260501_services_engagement_type
Revises: 20260429_engagement_year_locking
Create Date: 2026-05-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260501_services_engagement_type"
down_revision: Union[str, None] = "20260429_engagement_year_locking"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(c["name"] == column_name for c in insp.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _has_column("service_catalogues", "engagement_type"):
op.add_column(
"service_catalogues",
sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"),
)
if not _has_column("client_service_subscriptions", "engagement_type"):
op.add_column(
"client_service_subscriptions",
sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"),
)
# Backfill existing engagement records from their service catalogue classification.
bind = op.get_bind()
bind.execute(sa.text("""
UPDATE client_service_subscriptions
SET engagement_type = COALESCE(
(
SELECT service_catalogues.engagement_type
FROM service_catalogues
WHERE service_catalogues.id = client_service_subscriptions.service_catalogue_id
),
'non_audit'
)
WHERE engagement_type IS NULL OR engagement_type = ''
"""))
_create_index_if_missing("service_catalogues", "ix_service_catalogues_engagement_type", ["engagement_type"])
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type", ["engagement_type"])
def downgrade() -> None:
if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type"):
op.drop_index("ix_client_service_subscriptions_engagement_type", table_name="client_service_subscriptions")
if _has_index("service_catalogues", "ix_service_catalogues_engagement_type"):
op.drop_index("ix_service_catalogues_engagement_type", table_name="service_catalogues")
if _has_column("client_service_subscriptions", "engagement_type"):
op.drop_column("client_service_subscriptions", "engagement_type")
if _has_column("service_catalogues", "engagement_type"):
op.drop_column("service_catalogues", "engagement_type")
@@ -0,0 +1,65 @@
"""tenant firm type and engagement review partner
Revision ID: 20260502_tenant_firm_type_review_partner
Revises: 20260501_services_engagement_type
Create Date: 2026-05-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260502_tenant_firm_type_review_partner"
down_revision: Union[str, None] = "20260501_services_engagement_type"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(c["name"] == column_name for c in insp.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _has_column("tenants", "firm_type"):
op.add_column(
"tenants",
sa.Column("firm_type", sa.String(length=30), nullable=False, server_default="proprietorship"),
)
_create_index_if_missing("tenants", "ix_tenants_firm_type", ["firm_type"])
if not _has_column("client_service_subscriptions", "review_partner_user_id"):
op.add_column(
"client_service_subscriptions",
sa.Column("review_partner_user_id", sa.Integer(), nullable=True),
)
_create_index_if_missing(
"client_service_subscriptions",
"ix_client_service_subscriptions_review_partner_user_id",
["review_partner_user_id"],
)
def downgrade() -> None:
if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_review_partner_user_id"):
op.drop_index("ix_client_service_subscriptions_review_partner_user_id", table_name="client_service_subscriptions")
if _has_column("client_service_subscriptions", "review_partner_user_id"):
op.drop_column("client_service_subscriptions", "review_partner_user_id")
if _has_index("tenants", "ix_tenants_firm_type"):
op.drop_index("ix_tenants_firm_type", table_name="tenants")
if _has_column("tenants", "firm_type"):
op.drop_column("tenants", "firm_type")
@@ -0,0 +1,120 @@
"""rename audit classification to assurance and add client-wise review partner
Revision ID: 20260503_assurance_review_partner
Revises: 20260502_tenant_firm_type_review_partner
Create Date: 2026-05-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260503_assurance_review_partner"
down_revision: Union[str, None] = "20260502_tenant_firm_type_review_partner"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(c["name"] == column_name for c in insp.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
bind = op.get_bind()
# Client master: optional default review partner used for partnership + assurance engagements.
if not _has_column("clients", "default_review_partner_user_id"):
op.add_column("clients", sa.Column("default_review_partner_user_id", sa.Integer(), nullable=True))
_create_index_if_missing(
"clients",
"ix_clients_default_review_partner_user_id",
["default_review_partner_user_id"],
)
# Safety only: normally this column is created by 20260502_tenant_firm_type_review_partner.
if not _has_column("client_service_subscriptions", "review_partner_user_id"):
op.add_column(
"client_service_subscriptions",
sa.Column("review_partner_user_id", sa.Integer(), nullable=True),
)
_create_index_if_missing(
"client_service_subscriptions",
"ix_client_service_subscriptions_review_partner_user_id",
["review_partner_user_id"],
)
# Convert old audit / non-audit values to final assurance / non-assurance values.
bind.execute(sa.text("""
UPDATE service_catalogues
SET engagement_type = CASE
WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance'
ELSE 'non_assurance'
END
"""))
bind.execute(sa.text("""
UPDATE client_service_subscriptions
SET engagement_type = CASE
WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance'
ELSE 'non_assurance'
END
"""))
# Backfill review partner snapshots only for partnership-firm assurance engagements.
# Uses tenants.firm_type created by 20260502, not a separate firm_type column.
bind.execute(sa.text("""
UPDATE client_service_subscriptions
SET review_partner_user_id = (
SELECT clients.default_review_partner_user_id
FROM clients
WHERE clients.id = client_service_subscriptions.client_id
)
WHERE engagement_type = 'assurance'
AND review_partner_user_id IS NULL
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = client_service_subscriptions.tenant_id
AND COALESCE(tenants.firm_type, 'proprietorship') = 'partnership'
)
"""))
def downgrade() -> None:
bind = op.get_bind()
bind.execute(sa.text("""
UPDATE service_catalogues
SET engagement_type = CASE
WHEN engagement_type = 'assurance' THEN 'audit'
ELSE 'non_audit'
END
"""))
bind.execute(sa.text("""
UPDATE client_service_subscriptions
SET engagement_type = CASE
WHEN engagement_type = 'assurance' THEN 'audit'
ELSE 'non_audit'
END
"""))
# Do not drop client_service_subscriptions.review_partner_user_id here.
# That column belongs to the previous 20260502 migration.
if _has_index("clients", "ix_clients_default_review_partner_user_id"):
op.drop_index("ix_clients_default_review_partner_user_id", table_name="clients")
if _has_column("clients", "default_review_partner_user_id"):
op.drop_column("clients", "default_review_partner_user_id")
@@ -0,0 +1,142 @@
"""services phase 4A due date engine foundation
Revision ID: 20260504_due_date_engine_foundation
Revises: 20260503_assurance_review_partner
Create Date: 2026-05-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260504_due_date_engine_foundation"
down_revision: Union[str, None] = "20260503_assurance_review_partner"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _insp():
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return table_name in _insp().get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(c["name"] == column_name for c in _insp().get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if _has_table(table_name) and not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _has_table("service_due_date_rules"):
op.create_table(
"service_due_date_rules",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("rule_name", sa.String(length=150), nullable=False),
sa.Column("period_type", sa.String(length=30), nullable=False, server_default="yearly"),
sa.Column("due_year_basis", sa.String(length=40), nullable=False, server_default="assessment_year_start"),
sa.Column("due_day", sa.Integer(), nullable=True),
sa.Column("due_month", sa.Integer(), nullable=True),
sa.Column("due_month_offset", sa.Integer(), nullable=False, server_default="0"),
sa.Column("days_offset_after_event", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("service_catalogue_id", "rule_name", name="uq_service_due_date_rules_catalogue_name"),
)
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_service_catalogue_id", ["service_catalogue_id"])
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_period_type", ["period_type"])
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_is_active", ["is_active"])
if not _has_table("service_due_date_extensions"):
op.create_table(
"service_due_date_extensions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("due_date_rule_id", sa.Integer(), nullable=True),
sa.Column("financial_year", sa.String(length=9), nullable=False),
sa.Column("assessment_year", sa.String(length=9), nullable=True),
sa.Column("period_label", sa.String(length=30), nullable=True),
sa.Column("previous_due_date", sa.Date(), nullable=True),
sa.Column("extended_due_date", sa.Date(), nullable=False),
sa.Column("extension_sequence", sa.Integer(), nullable=False, server_default="1"),
sa.Column("notification_reference", sa.String(length=200), nullable=True),
sa.Column("notification_date", sa.Date(), nullable=True),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["due_date_rule_id"], ["service_due_date_rules.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
)
for index_name, columns in [
("ix_service_due_date_extensions_tenant_id", ["tenant_id"]),
("ix_service_due_date_extensions_service_catalogue_id", ["service_catalogue_id"]),
("ix_service_due_date_extensions_due_date_rule_id", ["due_date_rule_id"]),
("ix_service_due_date_extensions_financial_year", ["financial_year"]),
("ix_service_due_date_extensions_assessment_year", ["assessment_year"]),
("ix_service_due_date_extensions_period_label", ["period_label"]),
("ix_service_due_date_extensions_extended_due_date", ["extended_due_date"]),
]:
_create_index_if_missing("service_due_date_extensions", index_name, columns)
subscription_columns = [
("due_date_rule_id", sa.Column("due_date_rule_id", sa.Integer(), nullable=True)),
("original_due_date", sa.Column("original_due_date", sa.Date(), nullable=True)),
("current_due_date", sa.Column("current_due_date", sa.Date(), nullable=True)),
("due_date_source", sa.Column("due_date_source", sa.String(length=30), nullable=True)),
]
for column_name, column in subscription_columns:
if not _has_column("client_service_subscriptions", column_name):
op.add_column("client_service_subscriptions", column)
for index_name, columns in [
("ix_client_service_subscriptions_due_date_rule_id", ["due_date_rule_id"]),
("ix_client_service_subscriptions_original_due_date", ["original_due_date"]),
("ix_client_service_subscriptions_current_due_date", ["current_due_date"]),
]:
_create_index_if_missing("client_service_subscriptions", index_name, columns)
def downgrade() -> None:
for index_name in [
"ix_client_service_subscriptions_current_due_date",
"ix_client_service_subscriptions_original_due_date",
"ix_client_service_subscriptions_due_date_rule_id",
]:
if _has_index("client_service_subscriptions", index_name):
op.drop_index(index_name, table_name="client_service_subscriptions")
for column_name in ["due_date_source", "current_due_date", "original_due_date", "due_date_rule_id"]:
if _has_column("client_service_subscriptions", column_name):
op.drop_column("client_service_subscriptions", column_name)
if _has_table("service_due_date_extensions"):
op.drop_table("service_due_date_extensions")
if _has_table("service_due_date_rules"):
op.drop_table("service_due_date_rules")
@@ -0,0 +1,56 @@
"""services phase 4A renewal before expiry due dates
Revision ID: 20260505_renewal_before_expiry_due_dates
Revises: 20260504_due_date_engine_foundation
Create Date: 2026-05-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260505_renewal_before_expiry_due_dates"
down_revision: Union[str, None] = "20260504_due_date_engine_foundation"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _insp():
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return table_name in _insp().get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(c["name"] == column_name for c in _insp().get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
def upgrade() -> None:
if _has_table("service_due_date_rules") and not _has_column("service_due_date_rules", "renewal_days_before_expiry"):
op.add_column("service_due_date_rules", sa.Column("renewal_days_before_expiry", sa.Integer(), nullable=True))
if _has_table("client_service_subscriptions") and not _has_column("client_service_subscriptions", "expiry_date"):
op.add_column("client_service_subscriptions", sa.Column("expiry_date", sa.Date(), nullable=True))
if _has_table("client_service_subscriptions") and not _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"):
op.create_index("ix_client_service_subscriptions_expiry_date", "client_service_subscriptions", ["expiry_date"])
def downgrade() -> None:
if _has_table("client_service_subscriptions") and _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"):
op.drop_index("ix_client_service_subscriptions_expiry_date", table_name="client_service_subscriptions")
if _has_table("client_service_subscriptions") and _has_column("client_service_subscriptions", "expiry_date"):
op.drop_column("client_service_subscriptions", "expiry_date")
if _has_table("service_due_date_rules") and _has_column("service_due_date_rules", "renewal_days_before_expiry"):
op.drop_column("service_due_date_rules", "renewal_days_before_expiry")
@@ -0,0 +1,60 @@
"""Work tracker Phase 4B internal target dates
Revision ID: 20260506_work_tracker_phase_4b
Revises: 20260505_renewal_before_expiry_due_dates
Create Date: 2026-05-03
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260506_work_tracker_phase_4b"
down_revision = "20260505_renewal_before_expiry_due_dates"
branch_labels = None
depends_on = None
def _table_columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
try:
return {column["name"] for column in inspector.get_columns(table_name)}
except Exception:
return set()
def _index_names(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
try:
return {index["name"] for index in inspector.get_indexes(table_name) if index.get("name")}
except Exception:
return set()
def upgrade() -> None:
columns = _table_columns("client_service_task_instances")
if "internal_target_date" not in columns:
op.add_column("client_service_task_instances", sa.Column("internal_target_date", sa.Date(), nullable=True))
indexes = _index_names("client_service_task_instances")
if "ix_client_service_task_instances_internal_target_date" not in indexes:
op.create_index(
"ix_client_service_task_instances_internal_target_date",
"client_service_task_instances",
["internal_target_date"],
unique=False,
)
def downgrade() -> None:
indexes = _index_names("client_service_task_instances")
if "ix_client_service_task_instances_internal_target_date" in indexes:
op.drop_index("ix_client_service_task_instances_internal_target_date", table_name="client_service_task_instances")
columns = _table_columns("client_service_task_instances")
if "internal_target_date" in columns:
op.drop_column("client_service_task_instances", "internal_target_date")
@@ -0,0 +1,69 @@
"""services phase 4C task communication timeline
Revision ID: 20260507_task_communication_phase_4c
Revises: 20260506_work_tracker_phase_4b
Create Date: 2026-05-03
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260507_task_communication_phase_4c"
down_revision = "20260506_work_tracker_phase_4b"
branch_labels = None
depends_on = None
def _insp():
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return table_name in _insp().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
def upgrade() -> None:
if not _has_table("service_task_comments"):
op.create_table(
"service_task_comments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False),
sa.Column("task_instance_id", sa.Integer(), sa.ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False),
sa.Column("comment_type", sa.String(length=40), nullable=False, server_default="internal_note"),
sa.Column("visibility", sa.String(length=30), nullable=False, server_default="internal"),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
index_specs = [
("ix_service_task_comments_tenant_id", ["tenant_id"]),
("ix_service_task_comments_branch_id", ["branch_id"]),
("ix_service_task_comments_subscription_id", ["subscription_id"]),
("ix_service_task_comments_task_instance_id", ["task_instance_id"]),
("ix_service_task_comments_comment_type", ["comment_type"]),
("ix_service_task_comments_visibility", ["visibility"]),
("ix_service_task_comments_created_by_user_id", ["created_by_user_id"]),
("ix_service_task_comments_is_deleted", ["is_deleted"]),
]
for index_name, columns in index_specs:
if not _has_index("service_task_comments", index_name):
op.create_index(index_name, "service_task_comments", columns, unique=False)
def downgrade() -> None:
if _has_table("service_task_comments"):
op.drop_table("service_task_comments")
@@ -0,0 +1,119 @@
"""Phase 5A.1 consultant portal foundation
Revision ID: 20260508_consultant_portal_phase_5a1
Revises: 20260507_task_communication_phase_4c
Create Date: 2026-05-08
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260508_consultant_portal_phase_5a1"
down_revision: Union[str, None] = "20260507_task_communication_phase_4c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("consultant_profiles"):
op.create_table(
"consultant_profiles",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("consultant_type", sa.String(length=50), nullable=False, server_default="external_consultant"),
sa.Column("firm_name", sa.String(length=200), nullable=True),
sa.Column("contact_person", sa.String(length=200), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("specialisation", sa.String(length=200), nullable=True),
sa.Column("gstin", sa.String(length=20), nullable=True),
sa.Column("pan", sa.String(length=20), nullable=True),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("onboarding_status", sa.String(length=30), nullable=False, server_default="approved"),
sa.Column("is_platform_partner", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_franchise_partner", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_saas_customer", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
sa.UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
)
op.create_index("ix_consultant_profiles_tenant_id", "consultant_profiles", ["tenant_id"])
op.create_index("ix_consultant_profiles_branch_id", "consultant_profiles", ["branch_id"])
op.create_index("ix_consultant_profiles_user_id", "consultant_profiles", ["user_id"])
op.create_index("ix_consultant_profiles_email", "consultant_profiles", ["email"])
op.create_index("ix_consultant_profiles_status", "consultant_profiles", ["status"])
op.create_index("ix_consultant_profiles_onboarding_status", "consultant_profiles", ["onboarding_status"])
op.create_index("ix_consultant_profiles_is_active", "consultant_profiles", ["is_active"])
op.create_index("ix_consultant_profiles_consultant_type", "consultant_profiles", ["consultant_type"])
if not _has_table("client_consultant_links"):
op.create_table(
"client_consultant_links",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True),
sa.Column("relationship_type", sa.String(length=50), nullable=False, server_default="accounts_consultant"),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("can_view_client", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("can_view_services", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("can_view_due_dates", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("can_view_communications", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
)
op.create_index("ix_client_consultant_links_tenant_id", "client_consultant_links", ["tenant_id"])
op.create_index("ix_client_consultant_links_branch_id", "client_consultant_links", ["branch_id"])
op.create_index("ix_client_consultant_links_client_id", "client_consultant_links", ["client_id"])
op.create_index("ix_client_consultant_links_consultant_id", "client_consultant_links", ["consultant_id"])
op.create_index("ix_client_consultant_links_service_catalogue_id", "client_consultant_links", ["service_catalogue_id"])
op.create_index("ix_client_consultant_links_relationship_type", "client_consultant_links", ["relationship_type"])
op.create_index("ix_client_consultant_links_is_active", "client_consultant_links", ["is_active"])
def downgrade() -> None:
if _has_table("client_consultant_links"):
op.drop_index("ix_client_consultant_links_is_active", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_relationship_type", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_service_catalogue_id", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_consultant_id", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_client_id", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_branch_id", table_name="client_consultant_links")
op.drop_index("ix_client_consultant_links_tenant_id", table_name="client_consultant_links")
op.drop_table("client_consultant_links")
if _has_table("consultant_profiles"):
op.drop_index("ix_consultant_profiles_consultant_type", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_is_active", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_onboarding_status", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_status", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_email", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_user_id", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_branch_id", table_name="consultant_profiles")
op.drop_index("ix_consultant_profiles_tenant_id", table_name="consultant_profiles")
op.drop_table("consultant_profiles")
@@ -0,0 +1,102 @@
"""Phase 5A.2 consultant-managed clients
Revision ID: 20260509_consultant_managed_clients_phase_5a2
Revises: 20260508_consultant_portal_phase_5a1
Create Date: 2026-05-09
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260509_consultant_managed_clients_phase_5a2"
down_revision: Union[str, None] = "20260508_consultant_portal_phase_5a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return any(ix.get("name") == index_name for ix in inspector.get_indexes(table_name))
def upgrade() -> None:
if not _has_table("consultant_managed_clients"):
op.create_table(
"consultant_managed_clients",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("linked_firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
sa.Column("client_code", sa.String(length=50), nullable=True),
sa.Column("client_name", sa.String(length=200), nullable=False),
sa.Column("trade_name", sa.String(length=200), nullable=True),
sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"),
sa.Column("pan", sa.String(length=20), nullable=True),
sa.Column("gstin", sa.String(length=20), nullable=True),
sa.Column("tan", sa.String(length=20), nullable=True),
sa.Column("contact_person_name", sa.String(length=200), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("address_line_1", sa.String(length=255), nullable=True),
sa.Column("address_line_2", sa.String(length=255), nullable=True),
sa.Column("city", sa.String(length=100), nullable=True),
sa.Column("state", sa.String(length=100), nullable=True),
sa.Column("pincode", sa.String(length=20), nullable=True),
sa.Column("country", sa.String(length=100), nullable=True, server_default="India"),
sa.Column("service_interest", sa.Text(), nullable=True),
sa.Column("relationship_stage", sa.String(length=30), nullable=False, server_default="managed"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
)
op.create_index("ix_consultant_managed_clients_tenant_id", "consultant_managed_clients", ["tenant_id"])
op.create_index("ix_consultant_managed_clients_branch_id", "consultant_managed_clients", ["branch_id"])
op.create_index("ix_consultant_managed_clients_consultant_id", "consultant_managed_clients", ["consultant_id"])
op.create_index("ix_consultant_managed_clients_linked_firm_client_id", "consultant_managed_clients", ["linked_firm_client_id"])
op.create_index("ix_consultant_managed_clients_client_code", "consultant_managed_clients", ["client_code"])
op.create_index("ix_consultant_managed_clients_client_name", "consultant_managed_clients", ["client_name"])
op.create_index("ix_consultant_managed_clients_pan", "consultant_managed_clients", ["pan"])
op.create_index("ix_consultant_managed_clients_gstin", "consultant_managed_clients", ["gstin"])
op.create_index("ix_consultant_managed_clients_email", "consultant_managed_clients", ["email"])
op.create_index("ix_consultant_managed_clients_relationship_stage", "consultant_managed_clients", ["relationship_stage"])
op.create_index("ix_consultant_managed_clients_status", "consultant_managed_clients", ["status"])
op.create_index("ix_consultant_managed_clients_is_active", "consultant_managed_clients", ["is_active"])
def downgrade() -> None:
if _has_table("consultant_managed_clients"):
for index_name in [
"ix_consultant_managed_clients_is_active",
"ix_consultant_managed_clients_status",
"ix_consultant_managed_clients_relationship_stage",
"ix_consultant_managed_clients_email",
"ix_consultant_managed_clients_gstin",
"ix_consultant_managed_clients_pan",
"ix_consultant_managed_clients_client_name",
"ix_consultant_managed_clients_client_code",
"ix_consultant_managed_clients_linked_firm_client_id",
"ix_consultant_managed_clients_consultant_id",
"ix_consultant_managed_clients_branch_id",
"ix_consultant_managed_clients_tenant_id",
]:
if _has_index("consultant_managed_clients", index_name):
op.drop_index(index_name, table_name="consultant_managed_clients")
op.drop_table("consultant_managed_clients")
@@ -0,0 +1,79 @@
"""consultant workspace foundation phase 5a3
Revision ID: 20260510_consultant_workspace_phase_5a3
Revises: 20260509_consultant_managed_clients_phase_5a2
Create Date: 2026-05-10 00:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260510_consultant_workspace_phase_5a3"
down_revision = "20260509_consultant_managed_clients_phase_5a2"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "consultant_workspaces"):
op.create_table(
"consultant_workspaces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("workspace_code", sa.String(length=50), nullable=False),
sa.Column("workspace_name", sa.String(length=200), nullable=False),
sa.Column("workspace_type", sa.String(length=50), nullable=False, server_default="consultant_saas"),
sa.Column("plan_code", sa.String(length=50), nullable=False, server_default="starter"),
sa.Column("billing_cycle", sa.String(length=30), nullable=False, server_default="manual"),
sa.Column("subscription_status", sa.String(length=30), nullable=False, server_default="trial"),
sa.Column("subscription_start_date", sa.Date(), nullable=True),
sa.Column("subscription_end_date", sa.Date(), nullable=True),
sa.Column("max_managed_clients", sa.Integer(), nullable=False, server_default="25"),
sa.Column("max_user_accounts", sa.Integer(), nullable=False, server_default="1"),
sa.Column("allow_client_portal", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("allow_firm_referrals", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("allow_service_marketplace", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
sa.UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
)
op.create_index("ix_consultant_workspaces_tenant_id", "consultant_workspaces", ["tenant_id"])
op.create_index("ix_consultant_workspaces_branch_id", "consultant_workspaces", ["branch_id"])
op.create_index("ix_consultant_workspaces_consultant_id", "consultant_workspaces", ["consultant_id"])
op.create_index("ix_consultant_workspaces_workspace_code", "consultant_workspaces", ["workspace_code"])
op.create_index("ix_consultant_workspaces_workspace_type", "consultant_workspaces", ["workspace_type"])
op.create_index("ix_consultant_workspaces_plan_code", "consultant_workspaces", ["plan_code"])
op.create_index("ix_consultant_workspaces_subscription_status", "consultant_workspaces", ["subscription_status"])
op.create_index("ix_consultant_workspaces_billing_cycle", "consultant_workspaces", ["billing_cycle"])
op.create_index("ix_consultant_workspaces_is_active", "consultant_workspaces", ["is_active"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "consultant_workspaces"):
op.drop_index("ix_consultant_workspaces_is_active", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_billing_cycle", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_subscription_status", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_plan_code", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_workspace_type", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_workspace_code", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_consultant_id", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_branch_id", table_name="consultant_workspaces")
op.drop_index("ix_consultant_workspaces_tenant_id", table_name="consultant_workspaces")
op.drop_table("consultant_workspaces")
@@ -0,0 +1,86 @@
"""consultant communications and service requests phase 5a5 5a6
Revision ID: 20260511_consultant_completion_phase_5a5_5a6
Revises: 20260510_consultant_workspace_phase_5a3
Create Date: 2026-05-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260511_consultant_completion_phase_5a5_5a6"
down_revision = "20260510_consultant_workspace_phase_5a3"
branch_labels = None
depends_on = None
def _has_table(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _has_column(bind, table_name: str, column_name: str) -> bool:
if not _has_table(bind, table_name):
return False
return column_name in {col["name"] for col in sa.inspect(bind).get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if not _has_table(bind, "consultant_service_requests"):
op.create_table(
"consultant_service_requests",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("managed_client_id", sa.Integer(), sa.ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True),
sa.Column("firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True),
sa.Column("request_no", sa.String(length=50), nullable=False),
sa.Column("request_type", sa.String(length=50), nullable=False, server_default="service_request"),
sa.Column("status", sa.String(length=40), nullable=False, server_default="submitted"),
sa.Column("priority", sa.String(length=30), nullable=False, server_default="normal"),
sa.Column("requested_service_name", sa.String(length=200), nullable=False),
sa.Column("requested_due_date", sa.Date(), nullable=True),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("consultant_notes", sa.Text(), nullable=True),
sa.Column("firm_response", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
)
for index_name, columns in {
"ix_consultant_service_requests_tenant_id": ["tenant_id"],
"ix_consultant_service_requests_branch_id": ["branch_id"],
"ix_consultant_service_requests_consultant_id": ["consultant_id"],
"ix_consultant_service_requests_managed_client_id": ["managed_client_id"],
"ix_consultant_service_requests_firm_client_id": ["firm_client_id"],
"ix_consultant_service_requests_service_catalogue_id": ["service_catalogue_id"],
"ix_consultant_service_requests_request_no": ["request_no"],
"ix_consultant_service_requests_status": ["status"],
"ix_consultant_service_requests_priority": ["priority"],
"ix_consultant_service_requests_is_active": ["is_active"],
}.items():
try:
op.create_index(index_name, "consultant_service_requests", columns)
except Exception:
pass
if _has_table(bind, "service_catalogues") and not _has_column(bind, "service_catalogues", "is_consultant_requestable"):
with op.batch_alter_table("service_catalogues") as batch_op:
batch_op.add_column(sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.text("0")))
def downgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "consultant_service_requests"):
op.drop_table("consultant_service_requests")
@@ -0,0 +1,79 @@
"""consultant conversion and workspace limits phase 5a7 5a8
Revision ID: 20260512_consultant_conversion_limits_phase_5a7_5a8
Revises: 20260511_consultant_completion_phase_5a5_5a6
Create Date: 2026-05-12
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260512_consultant_conversion_limits_phase_5a7_5a8"
down_revision = "20260511_consultant_completion_phase_5a5_5a6"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return column_name in {col["name"] for col in inspector.get_columns(table_name)}
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)}
def upgrade() -> None:
table = "consultant_managed_clients"
if not _has_column(table, "conversion_status"):
op.add_column(table, sa.Column("conversion_status", sa.String(length=30), nullable=False, server_default="not_requested"))
if not _has_column(table, "conversion_requested_at_utc"):
op.add_column(table, sa.Column("conversion_requested_at_utc", sa.DateTime(timezone=True), nullable=True))
if not _has_column(table, "conversion_requested_by_user_id"):
op.add_column(table, sa.Column("conversion_requested_by_user_id", sa.Integer(), nullable=True))
if not _has_column(table, "conversion_reviewed_at_utc"):
op.add_column(table, sa.Column("conversion_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True))
if not _has_column(table, "conversion_reviewed_by_user_id"):
op.add_column(table, sa.Column("conversion_reviewed_by_user_id", sa.Integer(), nullable=True))
if not _has_column(table, "conversion_notes"):
op.add_column(table, sa.Column("conversion_notes", sa.Text(), nullable=True))
if not _has_column(table, "conversion_firm_notes"):
op.add_column(table, sa.Column("conversion_firm_notes", sa.Text(), nullable=True))
if not _has_index(table, "ix_consultant_managed_clients_conversion_status"):
op.create_index("ix_consultant_managed_clients_conversion_status", table, ["conversion_status"])
if not _has_index(table, "ix_consultant_managed_clients_conversion_requested_by_user_id"):
op.create_index("ix_consultant_managed_clients_conversion_requested_by_user_id", table, ["conversion_requested_by_user_id"])
if not _has_index(table, "ix_consultant_managed_clients_conversion_reviewed_by_user_id"):
op.create_index("ix_consultant_managed_clients_conversion_reviewed_by_user_id", table, ["conversion_reviewed_by_user_id"])
# SQLite supports ALTER TABLE ADD COLUMN but does not always enforce server default cleanup safely.
# Keeping the server default is intentional so existing local installs remain simple and stable.
def downgrade() -> None:
table = "consultant_managed_clients"
for index_name in [
"ix_consultant_managed_clients_conversion_reviewed_by_user_id",
"ix_consultant_managed_clients_conversion_requested_by_user_id",
"ix_consultant_managed_clients_conversion_status",
]:
if _has_index(table, index_name):
op.drop_index(index_name, table_name=table)
for column_name in [
"conversion_firm_notes",
"conversion_notes",
"conversion_reviewed_by_user_id",
"conversion_reviewed_at_utc",
"conversion_requested_by_user_id",
"conversion_requested_at_utc",
"conversion_status",
]:
if _has_column(table, column_name):
op.drop_column(table, column_name)
@@ -0,0 +1,69 @@
"""Phase 6A employee core foundation
Revision ID: 20260513_phase_6a_employees_core
Revises: 20260512_consultant_conversion_limits_phase_5a7_5a8
Create Date: 2026-05-05
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260513_phase_6a_employees_core"
down_revision = "20260512_consultant_conversion_limits_phase_5a7_5a8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employees",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("employee_code", sa.String(length=50), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("alternate_mobile", sa.String(length=20), nullable=True),
sa.Column("date_of_joining", sa.Date(), nullable=True),
sa.Column("date_of_leaving", sa.Date(), nullable=True),
sa.Column("employment_type", sa.String(length=50), nullable=False, server_default="full_time"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("department", sa.String(length=100), nullable=True),
sa.Column("designation", sa.String(length=100), nullable=True),
sa.Column("reporting_manager_user_id", sa.Integer(), nullable=True),
sa.Column("pan", sa.String(length=20), nullable=True),
sa.Column("uan", sa.String(length=30), nullable=True),
sa.Column("esi_no", sa.String(length=30), nullable=True),
sa.Column("pf_no", sa.String(length=30), nullable=True),
sa.Column("aadhaar_last4", sa.String(length=4), nullable=True),
sa.Column("bank_name", sa.String(length=100), nullable=True),
sa.Column("bank_account_no", sa.String(length=40), nullable=True),
sa.Column("bank_ifsc", sa.String(length=20), nullable=True),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("emergency_contact_name", sa.String(length=200), nullable=True),
sa.Column("emergency_contact_mobile", sa.String(length=20), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["reporting_manager_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "employee_code", name="uq_employees_tenant_code"),
sa.UniqueConstraint("tenant_id", "user_id", name="uq_employees_tenant_user"),
)
for col in ["tenant_id", "branch_id", "user_id", "employee_code", "full_name", "email", "status", "is_active", "department", "designation", "reporting_manager_user_id", "pan"]:
op.create_index(f"ix_employees_{col}", "employees", [col], unique=False)
def downgrade() -> None:
op.drop_table("employees")
@@ -0,0 +1,63 @@
"""Phase 6B employee ESS and registration requests
Revision ID: 20260514_phase_6b_employee_ess_registration
Revises: 20260513_phase_6a_employees_core
Create Date: 2026-05-10
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260514_phase_6b_employee_ess_registration"
down_revision = "20260513_phase_6a_employees_core"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_registration_requests",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("requested_employee_code", sa.String(length=50), nullable=True),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("department", sa.String(length=100), nullable=True),
sa.Column("designation", sa.String(length=100), nullable=True),
sa.Column("date_of_joining", sa.Date(), nullable=True),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_employee_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_employee_id"], ["employees.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for col in [
"tenant_id",
"branch_id",
"user_id",
"requested_employee_code",
"full_name",
"email",
"department",
"designation",
"status",
"created_employee_id",
]:
op.create_index(f"ix_employee_registration_requests_{col}", "employee_registration_requests", [col], unique=False)
def downgrade() -> None:
op.drop_table("employee_registration_requests")
@@ -0,0 +1,65 @@
"""Phase 6C employee attendance foundation
Revision ID: 20260515_phase_6c_employee_attendance
Revises: 20260514_phase_6b_employee_ess_registration
Create Date: 2026-05-10
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260515_phase_6c_employee_attendance"
down_revision = "20260514_phase_6b_employee_ess_registration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_attendance",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("employee_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("attendance_date", sa.Date(), nullable=False),
sa.Column("punch_in_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("punch_out_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("work_duration_minutes", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="present"),
sa.Column("approval_status", sa.String(length=30), nullable=False, server_default="approved"),
sa.Column("source", sa.String(length=30), nullable=False, server_default="self_punch"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "employee_id", "attendance_date", name="uq_employee_attendance_employee_date"),
)
for col in [
"tenant_id",
"branch_id",
"employee_id",
"user_id",
"attendance_date",
"status",
"approval_status",
"source",
]:
op.create_index(f"ix_employee_attendance_{col}", "employee_attendance", [col], unique=False)
def downgrade() -> None:
op.drop_table("employee_attendance")
@@ -0,0 +1,110 @@
"""Phase 6D employee leave management
Revision ID: 20260516_phase_6d_employee_leave
Revises: 20260515_phase_6c_employee_attendance
Create Date: 2026-05-10
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260516_phase_6d_employee_leave"
down_revision = "20260515_phase_6c_employee_attendance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_leave_types",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=30), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("annual_quota_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("carry_forward_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("allow_negative_balance", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("requires_approval", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_paid", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_leave_types_tenant_branch_code"),
)
for col in ["tenant_id", "branch_id", "code", "is_active"]:
op.create_index(f"ix_employee_leave_types_{col}", "employee_leave_types", [col], unique=False)
op.create_table(
"employee_leave_balances",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("employee_id", sa.Integer(), nullable=False),
sa.Column("leave_type_id", sa.Integer(), nullable=False),
sa.Column("opening_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("credited_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("availed_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("adjusted_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("balance_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "employee_id", "leave_type_id", name="uq_employee_leave_balances_employee_type"),
)
for col in ["tenant_id", "branch_id", "employee_id", "leave_type_id"]:
op.create_index(f"ix_employee_leave_balances_{col}", "employee_leave_balances", [col], unique=False)
op.create_table(
"employee_leave_requests",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("employee_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("leave_type_id", sa.Integer(), nullable=False),
sa.Column("from_date", sa.Date(), nullable=False),
sa.Column("to_date", sa.Date(), nullable=False),
sa.Column("days", sa.Integer(), nullable=False, server_default="1"),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("request_source", sa.String(length=30), nullable=False, server_default="employee_portal"),
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for col in ["tenant_id", "branch_id", "employee_id", "user_id", "leave_type_id", "from_date", "to_date", "status", "request_source"]:
op.create_index(f"ix_employee_leave_requests_{col}", "employee_leave_requests", [col], unique=False)
def downgrade() -> None:
op.drop_table("employee_leave_requests")
op.drop_table("employee_leave_balances")
op.drop_table("employee_leave_types")
@@ -0,0 +1,108 @@
"""Phase 6E employee document management
Revision ID: 20260517_phase_6e_employee_documents
Revises: 20260516_phase_6d_employee_leave
Create Date: 2026-05-10
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260517_phase_6e_employee_documents"
down_revision = "20260516_phase_6d_employee_leave"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_document_types",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=150), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("allow_employee_upload", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("requires_verification", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_document_types_tenant_branch_code"),
)
op.create_index("ix_employee_document_types_tenant_id", "employee_document_types", ["tenant_id"])
op.create_index("ix_employee_document_types_branch_id", "employee_document_types", ["branch_id"])
op.create_index("ix_employee_document_types_code", "employee_document_types", ["code"])
op.create_index("ix_employee_document_types_is_active", "employee_document_types", ["is_active"])
op.create_table(
"employee_documents",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("employee_id", sa.Integer(), nullable=False),
sa.Column("document_type_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("document_no", sa.String(length=100), nullable=True),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("expiry_date", sa.Date(), nullable=True),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("stored_filename", sa.String(length=255), nullable=False),
sa.Column("storage_path", sa.String(length=500), nullable=False),
sa.Column("content_type", sa.String(length=150), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="uploaded"),
sa.Column("visibility", sa.String(length=30), nullable=False, server_default="employee_and_hr"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("verified_by_user_id", sa.Integer(), nullable=True),
sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("verification_notes", sa.Text(), nullable=True),
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["document_type_id"], ["employee_document_types.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["verified_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_employee_documents_tenant_id", "employee_documents", ["tenant_id"])
op.create_index("ix_employee_documents_branch_id", "employee_documents", ["branch_id"])
op.create_index("ix_employee_documents_employee_id", "employee_documents", ["employee_id"])
op.create_index("ix_employee_documents_document_type_id", "employee_documents", ["document_type_id"])
op.create_index("ix_employee_documents_title", "employee_documents", ["title"])
op.create_index("ix_employee_documents_document_no", "employee_documents", ["document_no"])
op.create_index("ix_employee_documents_expiry_date", "employee_documents", ["expiry_date"])
op.create_index("ix_employee_documents_status", "employee_documents", ["status"])
op.create_index("ix_employee_documents_visibility", "employee_documents", ["visibility"])
def downgrade() -> None:
op.drop_index("ix_employee_documents_visibility", table_name="employee_documents")
op.drop_index("ix_employee_documents_status", table_name="employee_documents")
op.drop_index("ix_employee_documents_expiry_date", table_name="employee_documents")
op.drop_index("ix_employee_documents_document_no", table_name="employee_documents")
op.drop_index("ix_employee_documents_title", table_name="employee_documents")
op.drop_index("ix_employee_documents_document_type_id", table_name="employee_documents")
op.drop_index("ix_employee_documents_employee_id", table_name="employee_documents")
op.drop_index("ix_employee_documents_branch_id", table_name="employee_documents")
op.drop_index("ix_employee_documents_tenant_id", table_name="employee_documents")
op.drop_table("employee_documents")
op.drop_index("ix_employee_document_types_is_active", table_name="employee_document_types")
op.drop_index("ix_employee_document_types_code", table_name="employee_document_types")
op.drop_index("ix_employee_document_types_branch_id", table_name="employee_document_types")
op.drop_index("ix_employee_document_types_tenant_id", table_name="employee_document_types")
op.drop_table("employee_document_types")
@@ -0,0 +1,126 @@
"""DS6 permanent client document vault
Revision ID: 20260518_ds6_permanent_client_document_vault
Revises:
Create Date: 2026-05-18
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260518_ds6_permanent_client_document_vault"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"permanent_client_documents",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("document_code", sa.String(80), nullable=False),
sa.Column("category", sa.String(120), nullable=False, server_default="Other Permanent Documents"),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(30), nullable=False, server_default="active"),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "client_id", "document_code", name="uq_permanent_client_documents_code"),
)
for col in ["tenant_id", "branch_id", "client_id", "document_code", "category", "status", "is_deleted"]:
op.create_index(f"ix_permanent_client_documents_{col}", "permanent_client_documents", [col])
op.create_table(
"permanent_client_document_versions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
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"), nullable=True),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("stored_filename", sa.String(255), nullable=False),
sa.Column("content_type", sa.String(150), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("file_hash_sha256", sa.String(64), nullable=False),
sa.Column("storage_backend", sa.String(40), nullable=False, server_default="LOCAL_PERMANENT"),
sa.Column("local_relative_path", sa.String(1000), nullable=False),
sa.Column("storage_status", sa.String(30), nullable=False, server_default="stored"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("uploaded_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("document_id", "version_no", name="uq_permanent_client_document_versions_no"),
)
for col in ["document_id", "tenant_id", "branch_id", "client_id", "file_hash_sha256", "storage_backend", "storage_status"]:
op.create_index(f"ix_permanent_client_document_versions_{col}", "permanent_client_document_versions", [col])
op.create_table(
"permanent_document_storage_jobs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False),
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False),
sa.Column("job_type", sa.String(40), nullable=False, server_default="store_permanent_version"),
sa.Column("status", sa.String(30), nullable=False, server_default="pending"),
sa.Column("priority", sa.Integer(), nullable=False, server_default="5"),
sa.Column("staging_relative_path", sa.String(1000), nullable=False),
sa.Column("target_relative_path", sa.String(1000), nullable=False),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("expected_hash_sha256", sa.String(64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_hash_sha256", sa.String(64), nullable=True),
sa.Column("local_final_path", sa.String(1000), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]:
op.create_index(f"ix_permanent_document_storage_jobs_{col}", "permanent_document_storage_jobs", [col])
op.create_table(
"permanent_document_download_requests",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False),
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False),
sa.Column("request_status", sa.String(30), nullable=False, server_default="pending"),
sa.Column("local_relative_path", sa.String(1000), nullable=True),
sa.Column("expected_hash_sha256", sa.String(64), nullable=False),
sa.Column("cached_relative_path", sa.String(1000), nullable=True),
sa.Column("cached_hash_sha256", sa.String(64), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("requested_ip", sa.String(80), nullable=True),
sa.Column("requested_user_agent", sa.String(500), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True),
)
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "request_status", "expected_hash_sha256", "requested_by_user_id"]:
op.create_index(f"ix_permanent_document_download_requests_{col}", "permanent_document_download_requests", [col])
def downgrade() -> None:
op.drop_table("permanent_document_download_requests")
op.drop_table("permanent_document_storage_jobs")
op.drop_table("permanent_client_document_versions")
op.drop_table("permanent_client_documents")
@@ -0,0 +1,121 @@
"""phase 6f employee onboarding and offboarding
Revision ID: 20260518_phase_6f_employee_onboarding_offboarding
Revises: 20260517_phase_6e_employee_documents
Create Date: 2026-05-18
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260518_phase_6f_employee_onboarding_offboarding"
down_revision = "20260517_phase_6e_employee_documents"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_onboarding_checklist_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"),
sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
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", "branch_id", "code", name="uq_employee_onboarding_items_tenant_branch_code"),
)
op.create_index("ix_employee_onboarding_checklist_items_tenant_id", "employee_onboarding_checklist_items", ["tenant_id"])
op.create_index("ix_employee_onboarding_checklist_items_branch_id", "employee_onboarding_checklist_items", ["branch_id"])
op.create_index("ix_employee_onboarding_checklist_items_code", "employee_onboarding_checklist_items", ["code"])
op.create_index("ix_employee_onboarding_checklist_items_is_active", "employee_onboarding_checklist_items", ["is_active"])
op.create_table(
"employee_onboarding_tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
sa.Column("checklist_item_id", sa.Integer(), sa.ForeignKey("employee_onboarding_checklist_items.id", ondelete="SET NULL"), nullable=True),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
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", "employee_id", "checklist_item_id", name="uq_employee_onboarding_task_employee_item"),
)
for col in ["tenant_id", "branch_id", "employee_id", "checklist_item_id", "due_date", "status", "assigned_to_user_id"]:
op.create_index(f"ix_employee_onboarding_tasks_{col}", "employee_onboarding_tasks", [col])
op.create_table(
"employee_offboarding_requests",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("request_type", sa.String(length=50), nullable=False, server_default="resignation"),
sa.Column("requested_relieving_date", sa.Date(), nullable=True),
sa.Column("approved_relieving_date", sa.Date(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("handover_notes", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
)
for col in ["tenant_id", "branch_id", "employee_id", "user_id", "request_type", "requested_relieving_date", "approved_relieving_date", "status"]:
op.create_index(f"ix_employee_offboarding_requests_{col}", "employee_offboarding_requests", [col])
op.create_table(
"employee_offboarding_tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("request_id", sa.Integer(), sa.ForeignKey("employee_offboarding_requests.id", ondelete="CASCADE"), nullable=False),
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
)
for col in ["tenant_id", "branch_id", "request_id", "employee_id", "due_date", "status", "assigned_to_user_id"]:
op.create_index(f"ix_employee_offboarding_tasks_{col}", "employee_offboarding_tasks", [col])
def downgrade() -> None:
op.drop_table("employee_offboarding_tasks")
op.drop_table("employee_offboarding_requests")
op.drop_table("employee_onboarding_tasks")
op.drop_table("employee_onboarding_checklist_items")
@@ -0,0 +1,113 @@
"""Phase 6G employee payroll foundation
Revision ID: 20260519_phase_6g_employee_payroll
Revises: 20260518_phase_6f_employee_onboarding_offboarding
Create Date: 2026-05-19
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260519_phase_6g_employee_payroll"
down_revision = "20260518_phase_6f_employee_onboarding_offboarding"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"employee_salary_structures",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
sa.Column("effective_from", sa.Date(), nullable=False),
sa.Column("effective_to", sa.Date(), nullable=True),
sa.Column("pay_cycle", sa.String(length=30), nullable=False, server_default="monthly"),
sa.Column("monthly_ctc_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
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", "employee_id", "effective_from", name="uq_employee_salary_structure_effective"),
)
op.create_index("ix_employee_salary_structures_tenant_id", "employee_salary_structures", ["tenant_id"])
op.create_index("ix_employee_salary_structures_branch_id", "employee_salary_structures", ["branch_id"])
op.create_index("ix_employee_salary_structures_employee_id", "employee_salary_structures", ["employee_id"])
op.create_table(
"employee_payroll_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("pay_year", sa.Integer(), nullable=False),
sa.Column("pay_month", sa.Integer(), nullable=False),
sa.Column("run_name", sa.String(length=150), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"),
sa.Column("total_employees", sa.Integer(), nullable=False, server_default="0"),
sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("processed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("paid_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("paid_at_utc", sa.DateTime(timezone=True), nullable=True),
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", "branch_id", "pay_year", "pay_month", name="uq_employee_payroll_run_period"),
)
op.create_index("ix_employee_payroll_runs_tenant_id", "employee_payroll_runs", ["tenant_id"])
op.create_index("ix_employee_payroll_runs_branch_id", "employee_payroll_runs", ["branch_id"])
op.create_index("ix_employee_payroll_runs_period", "employee_payroll_runs", ["pay_year", "pay_month"])
op.create_table(
"employee_payslips",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=False),
sa.Column("payroll_run_id", sa.Integer(), sa.ForeignKey("employee_payroll_runs.id", ondelete="CASCADE"), nullable=False),
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
sa.Column("salary_structure_id", sa.Integer(), sa.ForeignKey("employee_salary_structures.id", ondelete="SET NULL"), nullable=True),
sa.Column("pay_year", sa.Integer(), nullable=False),
sa.Column("pay_month", sa.Integer(), nullable=False),
sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="generated"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("generated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
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", "payroll_run_id", "employee_id", name="uq_employee_payslip_run_employee"),
)
op.create_index("ix_employee_payslips_tenant_id", "employee_payslips", ["tenant_id"])
op.create_index("ix_employee_payslips_branch_id", "employee_payslips", ["branch_id"])
op.create_index("ix_employee_payslips_payroll_run_id", "employee_payslips", ["payroll_run_id"])
op.create_index("ix_employee_payslips_employee_id", "employee_payslips", ["employee_id"])
def downgrade() -> None:
op.drop_table("employee_payslips")
op.drop_table("employee_payroll_runs")
op.drop_table("employee_salary_structures")
@@ -0,0 +1,63 @@
"""Phase 6K geo-fenced attendance and OD approval
Revision ID: 20260520_phase_6k_geo_attendance
Revises: 20260519_phase_6g_employee_payroll
Create Date: 2026-05-20
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260520_phase_6k_geo_attendance"
down_revision = "20260519_phase_6g_employee_payroll"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("branch_settings", sa.Column("attendance_geo_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("branch_settings", sa.Column("attendance_geo_radius_meters", sa.Integer(), nullable=False, server_default="100"))
op.add_column("branch_settings", sa.Column("attendance_ip_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("branch_settings", sa.Column("attendance_allowed_ip_csv", sa.Text(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_latitude", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_longitude", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_accuracy_meters", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_distance_meters", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_ip", sa.String(length=80), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_geo_status", sa.String(length=40), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_in_ip_status", sa.String(length=40), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_latitude", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_longitude", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_accuracy_meters", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_distance_meters", sa.Float(), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_ip", sa.String(length=80), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_geo_status", sa.String(length=40), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_ip_status", sa.String(length=40), nullable=True))
op.create_index("ix_employee_attendance_punch_in_geo_status", "employee_attendance", ["punch_in_geo_status"])
op.create_index("ix_employee_attendance_punch_in_ip_status", "employee_attendance", ["punch_in_ip_status"])
op.create_index("ix_employee_attendance_punch_out_geo_status", "employee_attendance", ["punch_out_geo_status"])
op.create_index("ix_employee_attendance_punch_out_ip_status", "employee_attendance", ["punch_out_ip_status"])
def downgrade() -> None:
op.drop_index("ix_employee_attendance_punch_out_ip_status", table_name="employee_attendance")
op.drop_index("ix_employee_attendance_punch_out_geo_status", table_name="employee_attendance")
op.drop_index("ix_employee_attendance_punch_in_ip_status", table_name="employee_attendance")
op.drop_index("ix_employee_attendance_punch_in_geo_status", table_name="employee_attendance")
for col in [
"punch_out_ip_status", "punch_out_geo_status", "punch_out_ip", "punch_out_distance_meters",
"punch_out_accuracy_meters", "punch_out_longitude", "punch_out_latitude",
"punch_in_ip_status", "punch_in_geo_status", "punch_in_ip", "punch_in_distance_meters",
"punch_in_accuracy_meters", "punch_in_longitude", "punch_in_latitude",
]:
op.drop_column("employee_attendance", col)
op.drop_column("branch_settings", "attendance_allowed_ip_csv")
op.drop_column("branch_settings", "attendance_ip_enabled")
op.drop_column("branch_settings", "attendance_geo_radius_meters")
op.drop_column("branch_settings", "attendance_geo_enabled")
@@ -0,0 +1,50 @@
"""Phase 6L branch timezone and attendance timing rules
Revision ID: 20260521_phase_6l_branch_timezone_attendance_rules
Revises: 20260520_phase_6k_geo_attendance
Create Date: 2026-05-21
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260521_phase_6l_branch_timezone_attendance_rules"
down_revision = "20260520_phase_6k_geo_attendance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("branch_settings", sa.Column("attendance_grace_minutes", sa.Integer(), nullable=False, server_default="10"))
op.add_column("branch_settings", sa.Column("attendance_half_day_after_time", sa.Time(), nullable=True))
op.add_column("branch_settings", sa.Column("attendance_rule_enabled", sa.Boolean(), nullable=False, server_default=sa.true()))
op.add_column("employee_attendance", sa.Column("punch_in_local_at", sa.DateTime(timezone=False), nullable=True))
op.add_column("employee_attendance", sa.Column("punch_out_local_at", sa.DateTime(timezone=False), nullable=True))
op.add_column("employee_attendance", sa.Column("branch_timezone", sa.String(length=64), nullable=False, server_default="Asia/Kolkata"))
op.add_column("employee_attendance", sa.Column("scheduled_start_local", sa.Time(), nullable=True))
op.add_column("employee_attendance", sa.Column("scheduled_end_local", sa.Time(), nullable=True))
op.add_column("employee_attendance", sa.Column("late_by_minutes", sa.Integer(), nullable=True))
op.add_column("employee_attendance", sa.Column("attendance_rule_status", sa.String(length=40), nullable=True))
op.add_column("employee_attendance", sa.Column("is_weekly_off", sa.Boolean(), nullable=False, server_default=sa.false()))
op.create_index("ix_employee_attendance_attendance_rule_status", "employee_attendance", ["attendance_rule_status"])
def downgrade() -> None:
op.drop_index("ix_employee_attendance_attendance_rule_status", table_name="employee_attendance")
for col in [
"is_weekly_off",
"attendance_rule_status",
"late_by_minutes",
"scheduled_end_local",
"scheduled_start_local",
"branch_timezone",
"punch_out_local_at",
"punch_in_local_at",
]:
op.drop_column("employee_attendance", col)
op.drop_column("branch_settings", "attendance_rule_enabled")
op.drop_column("branch_settings", "attendance_half_day_after_time")
op.drop_column("branch_settings", "attendance_grace_minutes")
@@ -0,0 +1,181 @@
"""Phase B1 B2 firm billing and fee structure import
Revision ID: 20260522_phase_b1_b2_billing
Revises: 20260521_phase_6l_branch_timezone_attendance_rules
Create Date: 2026-05-22
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260522_phase_b1_b2_billing"
down_revision = "20260521_phase_6l_branch_timezone_attendance_rules"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"billing_settings",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("invoice_prefix", sa.String(length=40), nullable=False, server_default="INV"),
sa.Column("next_invoice_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("padding", sa.Integer(), nullable=False, server_default="4"),
sa.Column("default_gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("default_tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
sa.Column("legal_name", sa.String(length=200), nullable=True),
sa.Column("gstin", sa.String(length=20), nullable=True),
sa.Column("billing_address", sa.Text(), nullable=True),
sa.Column("bank_details", sa.Text(), nullable=True),
sa.Column("terms", sa.Text(), nullable=True),
sa.Column("footer_note", sa.Text(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
)
op.create_index("ix_billing_settings_tenant_id", "billing_settings", ["tenant_id"])
op.create_index("ix_billing_settings_branch_id", "billing_settings", ["branch_id"])
op.create_table(
"billing_fee_groups",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("partner_id", sa.Integer(), nullable=True),
sa.Column("group_code", sa.String(length=80), nullable=False),
sa.Column("group_name", sa.String(length=200), nullable=False),
sa.Column("billing_mode", sa.String(length=20), nullable=False, server_default="PACKAGE"),
sa.Column("frequency", sa.String(length=20), nullable=False, server_default="Monthly"),
sa.Column("fee_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
sa.Column("effective_from", sa.Date(), nullable=True),
sa.Column("effective_to", sa.Date(), nullable=True),
sa.Column("auto_generate", 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(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["partner_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
)
op.create_index("ix_billing_fee_groups_tenant_id", "billing_fee_groups", ["tenant_id"])
op.create_index("ix_billing_fee_groups_branch_id", "billing_fee_groups", ["branch_id"])
op.create_index("ix_billing_fee_groups_client_id", "billing_fee_groups", ["client_id"])
op.create_index("ix_billing_fee_groups_partner_id", "billing_fee_groups", ["partner_id"])
op.create_index("ix_billing_fee_groups_group_code", "billing_fee_groups", ["group_code"])
op.create_index("ix_billing_fee_groups_is_active", "billing_fee_groups", ["is_active"])
op.create_table(
"billing_invoices",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("engagement_id", sa.Integer(), nullable=True),
sa.Column("invoice_no", sa.String(length=60), nullable=False),
sa.Column("invoice_date", sa.Date(), nullable=False),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("billing_period_from", sa.Date(), nullable=True),
sa.Column("billing_period_to", sa.Date(), nullable=True),
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("round_off", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("terms", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("approved_by_user_id", sa.Integer(), nullable=True),
sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["approved_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
)
op.create_index("ix_billing_invoices_tenant_id", "billing_invoices", ["tenant_id"])
op.create_index("ix_billing_invoices_branch_id", "billing_invoices", ["branch_id"])
op.create_index("ix_billing_invoices_client_id", "billing_invoices", ["client_id"])
op.create_index("ix_billing_invoices_engagement_id", "billing_invoices", ["engagement_id"])
op.create_index("ix_billing_invoices_invoice_no", "billing_invoices", ["invoice_no"])
op.create_index("ix_billing_invoices_invoice_date", "billing_invoices", ["invoice_date"])
op.create_index("ix_billing_invoices_due_date", "billing_invoices", ["due_date"])
op.create_index("ix_billing_invoices_status", "billing_invoices", ["status"])
op.create_table(
"billing_fee_group_services",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("fee_group_id", sa.Integer(), nullable=False),
sa.Column("service_id", sa.Integer(), nullable=False),
sa.Column("line_description", sa.String(length=500), nullable=True),
sa.Column("allocation_type", sa.String(length=20), nullable=False, server_default="Included"),
sa.Column("line_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("percentage", sa.Numeric(5, 2), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="RESTRICT"),
sa.UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
)
op.create_index("ix_billing_fee_group_services_fee_group_id", "billing_fee_group_services", ["fee_group_id"])
op.create_index("ix_billing_fee_group_services_service_id", "billing_fee_group_services", ["service_id"])
op.create_table(
"billing_invoice_lines",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("service_id", sa.Integer(), nullable=True),
sa.Column("engagement_id", sa.Integer(), nullable=True),
sa.Column("fee_group_id", sa.Integer(), nullable=True),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("billing_period_from", sa.Date(), nullable=True),
sa.Column("billing_period_to", sa.Date(), nullable=True),
sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"),
sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="SET NULL"),
)
op.create_index("ix_billing_invoice_lines_invoice_id", "billing_invoice_lines", ["invoice_id"])
op.create_index("ix_billing_invoice_lines_service_id", "billing_invoice_lines", ["service_id"])
op.create_index("ix_billing_invoice_lines_engagement_id", "billing_invoice_lines", ["engagement_id"])
op.create_index("ix_billing_invoice_lines_fee_group_id", "billing_invoice_lines", ["fee_group_id"])
def downgrade() -> None:
op.drop_table("billing_invoice_lines")
op.drop_table("billing_fee_group_services")
op.drop_table("billing_invoices")
op.drop_table("billing_fee_groups")
op.drop_table("billing_settings")
@@ -0,0 +1,70 @@
"""Phase B3 generate draft invoices from fee structures
Revision ID: 20260523_phase_b3_billing_generation
Revises: 20260522_phase_b1_b2_billing
Create Date: 2026-05-23
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260523_phase_b3_billing_generation"
down_revision = "20260522_phase_b1_b2_billing"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"billing_invoice_generation_batches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("billing_period_from", sa.Date(), nullable=False),
sa.Column("billing_period_to", sa.Date(), nullable=False),
sa.Column("frequency", sa.String(length=20), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT_CREATED"),
sa.Column("selected_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_invoice_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("skipped_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("generated_by_user_id", sa.Integer(), nullable=True),
sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"]),
)
op.create_index("ix_billing_invoice_generation_batches_tenant_id", "billing_invoice_generation_batches", ["tenant_id"])
op.create_index("ix_billing_invoice_generation_batches_branch_id", "billing_invoice_generation_batches", ["branch_id"])
op.create_index("ix_billing_invoice_generation_batches_billing_period_from", "billing_invoice_generation_batches", ["billing_period_from"])
op.create_index("ix_billing_invoice_generation_batches_billing_period_to", "billing_invoice_generation_batches", ["billing_period_to"])
op.create_index("ix_billing_invoice_generation_batches_frequency", "billing_invoice_generation_batches", ["frequency"])
op.create_index("ix_billing_invoice_generation_batches_status", "billing_invoice_generation_batches", ["status"])
with op.batch_alter_table("billing_invoices") as batch_op:
batch_op.add_column(sa.Column("generation_batch_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
"fk_billing_invoices_generation_batch_id",
"billing_invoice_generation_batches",
["generation_batch_id"],
["id"],
ondelete="SET NULL",
)
batch_op.create_index("ix_billing_invoices_generation_batch_id", ["generation_batch_id"])
def downgrade() -> None:
with op.batch_alter_table("billing_invoices") as batch_op:
batch_op.drop_index("ix_billing_invoices_generation_batch_id")
batch_op.drop_constraint("fk_billing_invoices_generation_batch_id", type_="foreignkey")
batch_op.drop_column("generation_batch_id")
op.drop_index("ix_billing_invoice_generation_batches_status", table_name="billing_invoice_generation_batches")
op.drop_index("ix_billing_invoice_generation_batches_frequency", table_name="billing_invoice_generation_batches")
op.drop_index("ix_billing_invoice_generation_batches_billing_period_to", table_name="billing_invoice_generation_batches")
op.drop_index("ix_billing_invoice_generation_batches_billing_period_from", table_name="billing_invoice_generation_batches")
op.drop_index("ix_billing_invoice_generation_batches_branch_id", table_name="billing_invoice_generation_batches")
op.drop_index("ix_billing_invoice_generation_batches_tenant_id", table_name="billing_invoice_generation_batches")
op.drop_table("billing_invoice_generation_batches")
@@ -0,0 +1,234 @@
"""Phase PB1 platform SaaS billing foundation
Revision ID: 20260524_phase_pb1_platform_billing
Revises: 20260523_phase_b3_billing_generation
Create Date: 2026-05-24
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260524_phase_pb1_platform_billing"
down_revision = "20260523_phase_b3_billing_generation"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"platform_plans",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("code", sa.String(length=80), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("target_account_type", sa.String(length=30), nullable=False, server_default="AUDIT_FIRM"),
sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"),
sa.Column("base_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
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_platform_plans_code"),
)
op.create_index("ix_platform_plans_code", "platform_plans", ["code"])
op.create_index("ix_platform_plans_target_account_type", "platform_plans", ["target_account_type"])
op.create_index("ix_platform_plans_billing_cycle", "platform_plans", ["billing_cycle"])
op.create_index("ix_platform_plans_is_active", "platform_plans", ["is_active"])
op.create_table(
"platform_plan_features",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("plan_id", sa.Integer(), nullable=False),
sa.Column("feature_code", sa.String(length=100), nullable=False),
sa.Column("feature_name", sa.String(length=200), nullable=False),
sa.Column("limit_value", sa.String(length=100), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="CASCADE"),
)
op.create_index("ix_platform_plan_features_plan_id", "platform_plan_features", ["plan_id"])
op.create_index("ix_platform_plan_features_feature_code", "platform_plan_features", ["feature_code"])
op.create_table(
"platform_billing_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("account_type", sa.String(length=30), nullable=False),
sa.Column("account_code", sa.String(length=80), nullable=False),
sa.Column("display_name", sa.String(length=220), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=True),
sa.Column("consultant_id", sa.Integer(), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("gstin", sa.String(length=20), nullable=True),
sa.Column("pan", sa.String(length=20), nullable=True),
sa.Column("billing_address", sa.Text(), nullable=True),
sa.Column("state", sa.String(length=100), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("account_type", "account_code", name="uq_platform_billing_accounts_type_code"),
)
op.create_index("ix_platform_billing_accounts_account_type", "platform_billing_accounts", ["account_type"])
op.create_index("ix_platform_billing_accounts_account_code", "platform_billing_accounts", ["account_code"])
op.create_index("ix_platform_billing_accounts_display_name", "platform_billing_accounts", ["display_name"])
op.create_index("ix_platform_billing_accounts_tenant_id", "platform_billing_accounts", ["tenant_id"])
op.create_index("ix_platform_billing_accounts_client_id", "platform_billing_accounts", ["client_id"])
op.create_index("ix_platform_billing_accounts_consultant_id", "platform_billing_accounts", ["consultant_id"])
op.create_index("ix_platform_billing_accounts_status", "platform_billing_accounts", ["status"])
op.create_table(
"platform_subscriptions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("plan_id", sa.Integer(), nullable=False),
sa.Column("subscription_code", sa.String(length=100), nullable=False),
sa.Column("start_date", sa.Date(), nullable=False),
sa.Column("end_date", sa.Date(), nullable=True),
sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"),
sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"),
sa.Column("auto_generate_invoice", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
)
op.create_index("ix_platform_subscriptions_account_id", "platform_subscriptions", ["account_id"])
op.create_index("ix_platform_subscriptions_plan_id", "platform_subscriptions", ["plan_id"])
op.create_index("ix_platform_subscriptions_subscription_code", "platform_subscriptions", ["subscription_code"])
op.create_index("ix_platform_subscriptions_billing_cycle", "platform_subscriptions", ["billing_cycle"])
op.create_index("ix_platform_subscriptions_status", "platform_subscriptions", ["status"])
op.create_table(
"platform_invoices",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("subscription_id", sa.Integer(), nullable=True),
sa.Column("invoice_no", sa.String(length=80), nullable=False),
sa.Column("invoice_date", sa.Date(), nullable=False),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("billing_period_from", sa.Date(), nullable=True),
sa.Column("billing_period_to", sa.Date(), nullable=True),
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("posted_by_user_id", sa.Integer(), nullable=True),
sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["subscription_id"], ["platform_subscriptions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["posted_by_user_id"], ["users.id"]),
sa.UniqueConstraint("invoice_no", name="uq_platform_invoices_invoice_no"),
)
op.create_index("ix_platform_invoices_account_id", "platform_invoices", ["account_id"])
op.create_index("ix_platform_invoices_subscription_id", "platform_invoices", ["subscription_id"])
op.create_index("ix_platform_invoices_invoice_no", "platform_invoices", ["invoice_no"])
op.create_index("ix_platform_invoices_invoice_date", "platform_invoices", ["invoice_date"])
op.create_index("ix_platform_invoices_due_date", "platform_invoices", ["due_date"])
op.create_index("ix_platform_invoices_status", "platform_invoices", ["status"])
op.create_table(
"platform_invoice_lines",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("charge_type", sa.String(length=40), nullable=False, server_default="SUBSCRIPTION"),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("reference_type", sa.String(length=60), nullable=True),
sa.Column("reference_id", sa.Integer(), nullable=True),
sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"),
sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"),
)
op.create_index("ix_platform_invoice_lines_invoice_id", "platform_invoice_lines", ["invoice_id"])
op.create_index("ix_platform_invoice_lines_charge_type", "platform_invoice_lines", ["charge_type"])
op.create_table(
"platform_payments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("payment_date", sa.Date(), nullable=False),
sa.Column("amount", sa.Numeric(14, 2), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False, server_default="Bank"),
sa.Column("reference_no", sa.String(length=100), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
)
op.create_index("ix_platform_payments_invoice_id", "platform_payments", ["invoice_id"])
op.create_index("ix_platform_payments_account_id", "platform_payments", ["account_id"])
op.create_index("ix_platform_payments_payment_date", "platform_payments", ["payment_date"])
def downgrade() -> None:
op.drop_index("ix_platform_payments_payment_date", table_name="platform_payments")
op.drop_index("ix_platform_payments_account_id", table_name="platform_payments")
op.drop_index("ix_platform_payments_invoice_id", table_name="platform_payments")
op.drop_table("platform_payments")
op.drop_index("ix_platform_invoice_lines_charge_type", table_name="platform_invoice_lines")
op.drop_index("ix_platform_invoice_lines_invoice_id", table_name="platform_invoice_lines")
op.drop_table("platform_invoice_lines")
op.drop_index("ix_platform_invoices_status", table_name="platform_invoices")
op.drop_index("ix_platform_invoices_due_date", table_name="platform_invoices")
op.drop_index("ix_platform_invoices_invoice_date", table_name="platform_invoices")
op.drop_index("ix_platform_invoices_invoice_no", table_name="platform_invoices")
op.drop_index("ix_platform_invoices_subscription_id", table_name="platform_invoices")
op.drop_index("ix_platform_invoices_account_id", table_name="platform_invoices")
op.drop_table("platform_invoices")
op.drop_index("ix_platform_subscriptions_status", table_name="platform_subscriptions")
op.drop_index("ix_platform_subscriptions_billing_cycle", table_name="platform_subscriptions")
op.drop_index("ix_platform_subscriptions_subscription_code", table_name="platform_subscriptions")
op.drop_index("ix_platform_subscriptions_plan_id", table_name="platform_subscriptions")
op.drop_index("ix_platform_subscriptions_account_id", table_name="platform_subscriptions")
op.drop_table("platform_subscriptions")
op.drop_index("ix_platform_billing_accounts_status", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_consultant_id", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_client_id", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_tenant_id", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_display_name", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_account_code", table_name="platform_billing_accounts")
op.drop_index("ix_platform_billing_accounts_account_type", table_name="platform_billing_accounts")
op.drop_table("platform_billing_accounts")
op.drop_index("ix_platform_plan_features_feature_code", table_name="platform_plan_features")
op.drop_index("ix_platform_plan_features_plan_id", table_name="platform_plan_features")
op.drop_table("platform_plan_features")
op.drop_index("ix_platform_plans_is_active", table_name="platform_plans")
op.drop_index("ix_platform_plans_billing_cycle", table_name="platform_plans")
op.drop_index("ix_platform_plans_target_account_type", table_name="platform_plans")
op.drop_index("ix_platform_plans_code", table_name="platform_plans")
op.drop_table("platform_plans")
@@ -0,0 +1,84 @@
"""Phase PB5 marketplace lead foundation
Revision ID: 20260525_phase_pb5_marketplace_leads
Revises: 20260524_phase_pb1_platform_billing
Create Date: 2026-05-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260525_phase_pb5_marketplace_leads"
down_revision = "20260524_phase_pb1_platform_billing"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"marketplace_leads",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("lead_no", sa.String(length=50), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False, server_default="manual"),
sa.Column("service_category", sa.String(length=100), nullable=True),
sa.Column("service_requested", sa.String(length=200), nullable=False),
sa.Column("lead_name", sa.String(length=200), nullable=False),
sa.Column("business_name", sa.String(length=200), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("city", sa.String(length=100), nullable=True),
sa.Column("state", sa.String(length=100), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"),
sa.Column("priority", sa.String(length=30), nullable=False, server_default="NORMAL"),
sa.Column("estimated_value", sa.Numeric(12, 2), nullable=False, server_default="0.00"),
sa.Column("assigned_tenant_id", sa.Integer(), nullable=True),
sa.Column("assigned_branch_id", sa.Integer(), nullable=True),
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
sa.Column("converted_client_id", sa.Integer(), nullable=True),
sa.Column("converted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("converted_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["assigned_tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["assigned_branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["converted_client_id"], ["clients.id"]),
sa.ForeignKeyConstraint(["converted_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"),
)
for col in ["lead_no", "source", "service_category", "service_requested", "lead_name", "business_name", "email", "mobile", "status", "priority", "assigned_tenant_id", "assigned_branch_id", "assigned_partner_user_id", "converted_client_id"]:
op.create_index(f"ix_marketplace_leads_{col}", "marketplace_leads", [col])
op.create_table(
"marketplace_lead_assignments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("lead_id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("partner_user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="ASSIGNED"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["lead_id"], ["marketplace_leads.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["partner_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]),
)
for col in ["lead_id", "tenant_id", "branch_id", "partner_user_id", "status"]:
op.create_index(f"ix_marketplace_lead_assignments_{col}", "marketplace_lead_assignments", [col])
def downgrade() -> None:
op.drop_table("marketplace_lead_assignments")
op.drop_table("marketplace_leads")
@@ -0,0 +1,119 @@
"""phase ds1 ds2 engagement documents
Revision ID: 20260526_phase_ds1_ds2_documents
Revises: 20260525_phase_pb5_marketplace_leads
Create Date: 2026-05-14
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260526_phase_ds1_ds2_documents"
down_revision = "20260525_phase_pb5_marketplace_leads"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"engagement_documents",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("engagement_id", sa.Integer(), nullable=False),
sa.Column("financial_year", sa.String(length=9), nullable=False),
sa.Column("assessment_year", sa.String(length=9), nullable=True),
sa.Column("document_code", sa.String(length=80), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "engagement_id", "document_code", name="uq_engagement_documents_code"),
)
for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "financial_year", "assessment_year", "document_code", "document_type", "status", "is_deleted"]:
op.create_index(f"ix_engagement_documents_{col}", "engagement_documents", [col])
op.create_table(
"engagement_document_versions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("document_id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("engagement_id", sa.Integer(), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("stored_filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=150), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("file_hash_sha256", sa.String(length=64), nullable=False),
sa.Column("storage_backend", sa.String(length=40), nullable=False, server_default="LOCAL_YEAR_WISE"),
sa.Column("local_relative_path", sa.String(length=1000), nullable=False),
sa.Column("storage_status", sa.String(length=30), nullable=False, server_default="stored"),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("document_id", "version_no", name="uq_engagement_document_versions_no"),
)
for col in ["document_id", "tenant_id", "branch_id", "client_id", "engagement_id", "file_hash_sha256", "storage_backend", "storage_status"]:
op.create_index(f"ix_engagement_document_versions_{col}", "engagement_document_versions", [col])
op.create_table(
"document_access_logs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=True),
sa.Column("engagement_id", sa.Integer(), nullable=True),
sa.Column("document_id", sa.Integer(), nullable=True),
sa.Column("version_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("result", sa.String(length=40), nullable=False, server_default="success"),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("ip_address", sa.String(length=80), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "document_id", "version_id", "action", "result", "user_id"]:
op.create_index(f"ix_document_access_logs_{col}", "document_access_logs", [col])
def downgrade() -> None:
op.drop_table("document_access_logs")
op.drop_table("engagement_document_versions")
op.drop_table("engagement_documents")
@@ -0,0 +1,87 @@
"""phase ds3 ds4 branch storage nodes and document jobs
Revision ID: 20260527_phase_ds3_ds4_storage_jobs
Revises: 20260526_phase_ds1_ds2_documents
Create Date: 2026-05-14
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260527_phase_ds3_ds4_storage_jobs"
down_revision = "20260526_phase_ds1_ds2_documents"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"branch_storage_nodes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("node_code", sa.String(length=80), nullable=False),
sa.Column("node_name", sa.String(length=200), nullable=False),
sa.Column("connector_url", sa.String(length=500), nullable=True),
sa.Column("secret_key_hash", sa.String(length=64), nullable=False),
sa.Column("storage_root_path", sa.String(length=1000), nullable=True),
sa.Column("storage_mode", sa.String(length=40), nullable=False, server_default="pull_jobs"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("last_seen_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_seen_ip", sa.String(length=80), nullable=True),
sa.Column("quota_limit_bytes", sa.Integer(), nullable=True),
sa.Column("used_storage_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("subscription_required", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "branch_id", "node_code", name="uq_branch_storage_node_code"),
)
for col in ["tenant_id", "branch_id", "node_code", "storage_mode", "status", "is_active"]:
op.create_index(f"ix_branch_storage_nodes_{col}", "branch_storage_nodes", [col])
op.create_table(
"document_storage_jobs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("storage_node_id", sa.Integer(), nullable=False),
sa.Column("document_id", sa.Integer(), nullable=False),
sa.Column("version_id", sa.Integer(), nullable=False),
sa.Column("job_type", sa.String(length=40), nullable=False, server_default="store_version"),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("priority", sa.Integer(), nullable=False, server_default="5"),
sa.Column("staging_relative_path", sa.String(length=1000), nullable=False),
sa.Column("target_relative_path", sa.String(length=1000), nullable=False),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_hash_sha256", sa.String(length=64), nullable=True),
sa.Column("local_final_path", sa.String(length=1000), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]:
op.create_index(f"ix_document_storage_jobs_{col}", "document_storage_jobs", [col])
def downgrade() -> None:
op.drop_table("document_storage_jobs")
op.drop_table("branch_storage_nodes")
@@ -0,0 +1,64 @@
"""phase ds5 secure local download streaming requests
Revision ID: 20260528_phase_ds5_secure_download_streaming
Revises: 20260527_phase_ds3_ds4_storage_jobs
Create Date: 2026-05-14
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260528_phase_ds5_secure_download_streaming"
down_revision = "20260527_phase_ds3_ds4_storage_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"document_download_requests",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("storage_node_id", sa.Integer(), nullable=False),
sa.Column("document_id", sa.Integer(), nullable=False),
sa.Column("version_id", sa.Integer(), nullable=False),
sa.Column("request_status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("local_relative_path", sa.String(length=1000), nullable=True),
sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False),
sa.Column("cached_relative_path", sa.String(length=1000), nullable=True),
sa.Column("cached_hash_sha256", sa.String(length=64), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("requested_by_user_id", sa.Integer(), nullable=True),
sa.Column("requested_ip", sa.String(length=80), nullable=True),
sa.Column("requested_user_agent", sa.String(length=500), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for col in [
"tenant_id",
"branch_id",
"storage_node_id",
"document_id",
"version_id",
"request_status",
"expected_hash_sha256",
"requested_by_user_id",
]:
op.create_index(f"ix_document_download_requests_{col}", "document_download_requests", [col])
def downgrade() -> None:
op.drop_table("document_download_requests")
@@ -0,0 +1,125 @@
"""Phase 7H common alerts foundation
Revision ID: 20260529_phase_7h_common_alerts
Revises: af48d99d7321
Create Date: 2026-05-20
This migration is intentionally idempotent for SQLite/dev environments.
If a previous failed Alembic run already created user_alerts before the
revision was recorded, re-running `alembic upgrade head` will safely continue.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260529_phase_7h_common_alerts"
down_revision = "af48d99d7321"
branch_labels = None
depends_on = None
TABLE_NAME = "user_alerts"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _existing_columns(inspector: sa.Inspector, table_name: str) -> set[str]:
if not _table_exists(inspector, table_name):
return set()
return {col["name"] for col in inspector.get_columns(table_name)}
def _existing_indexes(inspector: sa.Inspector, table_name: str) -> set[str]:
if not _table_exists(inspector, table_name):
return set()
return {idx["name"] for idx in inspector.get_indexes(table_name)}
def _add_column_if_missing(inspector: sa.Inspector, column_name: str, column: sa.Column) -> None:
existing = _existing_columns(inspector, TABLE_NAME)
if column_name not in existing:
op.add_column(TABLE_NAME, column)
def _create_index_if_missing(inspector: sa.Inspector, index_name: str, columns: list[str]) -> None:
existing = _existing_indexes(inspector, TABLE_NAME)
if index_name not in existing:
op.create_index(index_name, TABLE_NAME, columns)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, TABLE_NAME):
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("role_context", sa.String(length=50), nullable=True),
sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general"),
sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("target_url", sa.String(length=500), nullable=True),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
inspector = sa.inspect(bind)
else:
# Safety path for a failed/partial migration run. SQLite cannot add
# foreign-key constraints after table creation, but it can add any
# missing columns so the model and UI continue to work.
_add_column_if_missing(inspector, "tenant_id", sa.Column("tenant_id", sa.Integer(), nullable=True))
_add_column_if_missing(inspector, "branch_id", sa.Column("branch_id", sa.Integer(), nullable=True))
_add_column_if_missing(inspector, "user_id", sa.Column("user_id", sa.Integer(), nullable=False, server_default="0"))
_add_column_if_missing(inspector, "role_context", sa.Column("role_context", sa.String(length=50), nullable=True))
_add_column_if_missing(inspector, "alert_type", sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general"))
_add_column_if_missing(inspector, "priority", sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"))
_add_column_if_missing(inspector, "title", sa.Column("title", sa.String(length=255), nullable=False, server_default="Alert"))
_add_column_if_missing(inspector, "message", sa.Column("message", sa.Text(), nullable=True))
_add_column_if_missing(inspector, "target_url", sa.Column("target_url", sa.String(length=500), nullable=True))
_add_column_if_missing(inspector, "is_read", sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()))
_add_column_if_missing(inspector, "read_at_utc", sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing(inspector, "created_at_utc", sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
_add_column_if_missing(inspector, "created_by_user_id", sa.Column("created_by_user_id", sa.Integer(), nullable=True))
inspector = sa.inspect(bind)
_create_index_if_missing(inspector, "ix_user_alerts_tenant_id", ["tenant_id"])
_create_index_if_missing(inspector, "ix_user_alerts_branch_id", ["branch_id"])
_create_index_if_missing(inspector, "ix_user_alerts_user_id", ["user_id"])
_create_index_if_missing(inspector, "ix_user_alerts_role_context", ["role_context"])
_create_index_if_missing(inspector, "ix_user_alerts_alert_type", ["alert_type"])
_create_index_if_missing(inspector, "ix_user_alerts_priority", ["priority"])
_create_index_if_missing(inspector, "ix_user_alerts_is_read", ["is_read"])
_create_index_if_missing(inspector, "ix_user_alerts_created_at_utc", ["created_at_utc"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, TABLE_NAME):
return
existing_indexes = _existing_indexes(inspector, TABLE_NAME)
for index_name in [
"ix_user_alerts_created_at_utc",
"ix_user_alerts_is_read",
"ix_user_alerts_priority",
"ix_user_alerts_alert_type",
"ix_user_alerts_role_context",
"ix_user_alerts_user_id",
"ix_user_alerts_branch_id",
"ix_user_alerts_tenant_id",
]:
if index_name in existing_indexes:
op.drop_index(index_name, table_name=TABLE_NAME)
op.drop_table(TABLE_NAME)
@@ -0,0 +1,55 @@
"""Phase 7Q.2 firm branding settings
Revision ID: 20260530_phase_7q2_firm_branding
Revises: 20260529_phase_7h_common_alerts
Create Date: 2026-05-21
Adds tenant-level branding fields and branch-level invoice/payment presentation fields.
The migration is idempotent for SQLite/dev environments.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260530_phase_7q2_firm_branding"
down_revision = "20260529_phase_7h_common_alerts"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def upgrade() -> None:
_add_if_missing("tenants", sa.Column("display_name", sa.String(length=200), nullable=True))
_add_if_missing("tenants", sa.Column("logo_path", sa.String(length=500), nullable=True))
_add_if_missing("tenants", sa.Column("favicon_path", sa.String(length=500), nullable=True))
_add_if_missing("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
_add_if_missing("tenants", sa.Column("accent_color", sa.String(length=20), nullable=True))
_add_if_missing("tenants", sa.Column("website_url", sa.String(length=255), nullable=True))
_add_if_missing("tenants", sa.Column("contact_email", sa.String(length=255), nullable=True))
_add_if_missing("tenants", sa.Column("contact_mobile", sa.String(length=50), nullable=True))
_add_if_missing("branch_settings", sa.Column("invoice_footer_text", sa.Text(), nullable=True))
_add_if_missing("branch_settings", sa.Column("bank_name", sa.String(length=200), nullable=True))
_add_if_missing("branch_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True))
_add_if_missing("branch_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True))
_add_if_missing("branch_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True))
_add_if_missing("branch_settings", sa.Column("upi_id", sa.String(length=100), nullable=True))
def downgrade() -> None:
# SQLite cannot reliably drop columns on older setups without table rebuild.
# Keep downgrade no-op to avoid damaging live tenant/branch settings.
pass
@@ -0,0 +1,45 @@
"""Phase 7Q.3 user profile photo and qualification fields
Revision ID: 20260531_phase_7q3_user_profile
Revises: 20260530_phase_7q2_firm_branding
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260531_phase_7q3_user_profile"
down_revision = "20260530_phase_7q2_firm_branding"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
return {col["name"] for col in inspector.get_columns(table_name)}
def upgrade() -> None:
existing = _columns("users")
with op.batch_alter_table("users") as batch_op:
if "profile_photo_path" not in existing:
batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=500), nullable=True))
if "qualification" not in existing:
batch_op.add_column(sa.Column("qualification", sa.String(length=200), nullable=True))
if "designation" not in existing:
batch_op.add_column(sa.Column("designation", sa.String(length=200), nullable=True))
if "mobile" not in existing:
batch_op.add_column(sa.Column("mobile", sa.String(length=30), nullable=True))
if "bio" not in existing:
batch_op.add_column(sa.Column("bio", sa.Text(), nullable=True))
if "signature_image_path" not in existing:
batch_op.add_column(sa.Column("signature_image_path", sa.String(length=500), nullable=True))
def downgrade() -> None:
existing = _columns("users")
with op.batch_alter_table("users") as batch_op:
for column_name in ["signature_image_path", "bio", "mobile", "designation", "qualification", "profile_photo_path"]:
if column_name in existing:
batch_op.drop_column(column_name)
@@ -0,0 +1,56 @@
"""Phase 7R.1 firm billing settings
Revision ID: 20260601_phase_7r1_billing_settings
Revises: 20260531_phase_7q3_user_profile
Create Date: 2026-06-01
Adds professional billing settings fields used by GST invoice formatting,
payment tracking and future online payment phases. Idempotent for SQLite dev use.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260601_phase_7r1_billing_settings"
down_revision = "20260531_phase_7q3_user_profile"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def upgrade() -> None:
_add_if_missing("billing_settings", sa.Column("pan", sa.String(length=10), nullable=True))
_add_if_missing("billing_settings", sa.Column("state_code", sa.String(length=2), nullable=True))
_add_if_missing("billing_settings", sa.Column("contact_email", sa.String(length=255), nullable=True))
_add_if_missing("billing_settings", sa.Column("contact_mobile", sa.String(length=50), nullable=True))
_add_if_missing("billing_settings", sa.Column("website_url", sa.String(length=255), nullable=True))
_add_if_missing("billing_settings", sa.Column("invoice_title", sa.String(length=80), nullable=True))
_add_if_missing("billing_settings", sa.Column("invoice_number_format", sa.String(length=120), nullable=True))
_add_if_missing("billing_settings", sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="15"))
_add_if_missing("billing_settings", sa.Column("default_sac_code", sa.String(length=20), nullable=True))
_add_if_missing("billing_settings", sa.Column("bank_name", sa.String(length=200), nullable=True))
_add_if_missing("billing_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True))
_add_if_missing("billing_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True))
_add_if_missing("billing_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True))
_add_if_missing("billing_settings", sa.Column("upi_id", sa.String(length=100), nullable=True))
_add_if_missing("billing_settings", sa.Column("authorised_signatory_name", sa.String(length=200), nullable=True))
_add_if_missing("billing_settings", sa.Column("declaration", sa.Text(), nullable=True))
def downgrade() -> None:
# No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can require
# table rebuild and may damage live billing configuration.
pass
@@ -0,0 +1,55 @@
"""Phase 7R.2 GST invoice format
Revision ID: 20260602_phase_7r2_gst_invoice
Revises: 20260601_phase_7r1_billing_settings
Create Date: 2026-06-02
Adds GST invoice snapshot fields, place of supply, reverse charge,
SAC code per line and amount-in-words support. The migration is
idempotent for the existing SQLite development workflow.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260602_phase_7r2_gst_invoice"
down_revision = "20260601_phase_7r1_billing_settings"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def upgrade() -> None:
_add_if_missing("billing_invoices", sa.Column("invoice_title", sa.String(length=80), nullable=True))
_add_if_missing("billing_invoices", sa.Column("place_of_supply", sa.String(length=120), nullable=True))
_add_if_missing("billing_invoices", sa.Column("reverse_charge", sa.Boolean(), nullable=False, server_default=sa.false()))
_add_if_missing("billing_invoices", sa.Column("client_legal_name", sa.String(length=200), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_trade_name", sa.String(length=200), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_gstin", sa.String(length=20), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_pan", sa.String(length=20), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_billing_address", sa.Text(), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_state", sa.String(length=100), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_state_code", sa.String(length=2), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_email", sa.String(length=255), nullable=True))
_add_if_missing("billing_invoices", sa.Column("client_mobile", sa.String(length=50), nullable=True))
_add_if_missing("billing_invoices", sa.Column("amount_in_words", sa.String(length=500), nullable=True))
_add_if_missing("billing_invoice_lines", sa.Column("sac_code", sa.String(length=20), nullable=True))
def downgrade() -> None:
# No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can
# require table rebuild and may damage existing billing records.
pass
@@ -0,0 +1,97 @@
"""Phase 7R.3 payment tracking and receipts
Revision ID: 20260603_phase_7r3_payments
Revises: 20260602_phase_7r2_gst_invoice
Create Date: 2026-06-03
Adds invoice collection totals and a payment/receipt ledger. The migration
is intentionally idempotent for SQLite development databases used in this
project.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260603_phase_7r3_payments"
down_revision = "20260602_phase_7r2_gst_invoice"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def _index_exists(index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
for table_name in inspector.get_table_names():
for idx in inspector.get_indexes(table_name):
if idx.get("name") == index_name:
return True
return False
def upgrade() -> None:
_add_if_missing("billing_invoices", sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
_add_if_missing("billing_invoices", sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
_add_if_missing("billing_invoices", sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
_add_if_missing("billing_invoices", sa.Column("balance_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
if "billing_payments" not in _tables():
op.create_table(
"billing_payments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("invoice_id", sa.Integer(), sa.ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False),
sa.Column("receipt_no", sa.String(length=60), nullable=False),
sa.Column("receipt_date", sa.Date(), nullable=False),
sa.Column("payment_date", sa.Date(), nullable=False),
sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("mode", sa.String(length=30), nullable=False, server_default="BANK"),
sa.Column("reference_no", sa.String(length=120), nullable=True),
sa.Column("payment_gateway", sa.String(length=50), nullable=True),
sa.Column("gateway_transaction_id", sa.String(length=120), nullable=True),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="RECEIVED"),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
)
for name, cols in {
"ix_billing_payments_tenant_id": ["tenant_id"],
"ix_billing_payments_branch_id": ["branch_id"],
"ix_billing_payments_invoice_id": ["invoice_id"],
"ix_billing_payments_client_id": ["client_id"],
"ix_billing_payments_receipt_no": ["receipt_no"],
"ix_billing_payments_payment_date": ["payment_date"],
"ix_billing_payments_status": ["status"],
}.items():
if not _index_exists(name):
op.create_index(name, "billing_payments", cols)
bind = op.get_bind()
if "billing_invoices" in _tables():
bind.execute(sa.text("UPDATE billing_invoices SET balance_amount = COALESCE(total_amount, 0) WHERE COALESCE(balance_amount, 0) = 0 AND COALESCE(amount_received, 0) = 0 AND COALESCE(tds_deducted, 0) = 0"))
def downgrade() -> None:
# No-op downgrade for SQLite/dev safety.
pass
@@ -0,0 +1,103 @@
"""Phase 7R.6 PayUMoney online payment gateway
Revision ID: 20260606_phase_7r6_payumoney
Revises: 20260603_phase_7r3_payments
Create Date: 2026-06-06
Adds PayUMoney settings and a small online payment transaction ledger.
The migration is intentionally defensive for SQLite development databases.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260606_phase_7r6_payumoney"
down_revision = "20260603_phase_7r3_payments"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if table in _tables() and column.name not in _columns(table):
op.add_column(table, column)
def _index_exists(index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
for table_name in inspector.get_table_names():
for idx in inspector.get_indexes(table_name):
if idx.get("name") == index_name:
return True
return False
def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None:
if table in _tables() and not _index_exists(name):
op.create_index(name, table, columns, unique=unique)
def upgrade() -> None:
_add_if_missing("billing_settings", sa.Column("payumoney_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
_add_if_missing("billing_settings", sa.Column("payumoney_mode", sa.String(length=20), nullable=False, server_default="TEST"))
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_key", sa.String(length=120), nullable=True))
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_salt", sa.String(length=200), nullable=True))
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_id", sa.String(length=120), nullable=True))
_add_if_missing("billing_settings", sa.Column("payumoney_product_info", sa.String(length=200), nullable=True))
if "billing_online_payment_transactions" not in _tables():
op.create_table(
"billing_online_payment_transactions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("provider", sa.String(length=40), nullable=False, server_default="PAYUMONEY"),
sa.Column("mode", sa.String(length=20), nullable=False, server_default="TEST"),
sa.Column("txnid", sa.String(length=80), nullable=False),
sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
sa.Column("productinfo", sa.String(length=250), nullable=True),
sa.Column("firstname", sa.String(length=120), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=50), nullable=True),
sa.Column("payu_payment_id", sa.String(length=120), nullable=True),
sa.Column("bank_ref_num", sa.String(length=120), nullable=True),
sa.Column("mihpayid", sa.String(length=120), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="INITIATED"),
sa.Column("gateway_status", sa.String(length=80), nullable=True),
sa.Column("response_hash", sa.String(length=200), nullable=True),
sa.Column("raw_response", sa.Text(), nullable=True),
sa.Column("receipt_payment_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["receipt_payment_id"], ["billing_payments.id"], ondelete="SET NULL"),
sa.UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
)
_create_index_if_missing("ix_billing_online_payment_transactions_tenant_id", "billing_online_payment_transactions", ["tenant_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_branch_id", "billing_online_payment_transactions", ["branch_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_invoice_id", "billing_online_payment_transactions", ["invoice_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_client_id", "billing_online_payment_transactions", ["client_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_txnid", "billing_online_payment_transactions", ["txnid"])
_create_index_if_missing("ix_billing_online_payment_transactions_status", "billing_online_payment_transactions", ["status"])
def downgrade() -> None:
if "billing_online_payment_transactions" in _tables():
op.drop_table("billing_online_payment_transactions")
@@ -0,0 +1,73 @@
"""Phase 7R.6A Cashfree payment gateway integration
Revision ID: 20260607_phase_7r6a_cashfree
Revises: 20260606_phase_7r6_payumoney
Create Date: 2026-06-07
Adds Cashfree gateway settings and generic Cashfree fields to the existing online
payment transaction ledger. Defensive for SQLite development databases.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260607_phase_7r6a_cashfree"
down_revision = "20260606_phase_7r6_payumoney"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {c["name"] for c in inspector.get_columns(table_name)}
def _add_if_missing(table: str, column: sa.Column) -> None:
if table in _tables() and column.name not in _columns(table):
op.add_column(table, column)
def _index_exists(index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
for table_name in inspector.get_table_names():
for idx in inspector.get_indexes(table_name):
if idx.get("name") == index_name:
return True
return False
def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None:
if table in _tables() and not _index_exists(name):
op.create_index(name, table, columns, unique=unique)
def upgrade() -> None:
_add_if_missing("billing_settings", sa.Column("cashfree_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
_add_if_missing("billing_settings", sa.Column("cashfree_mode", sa.String(length=20), nullable=False, server_default="TEST"))
_add_if_missing("billing_settings", sa.Column("cashfree_client_id", sa.String(length=180), nullable=True))
_add_if_missing("billing_settings", sa.Column("cashfree_client_secret", sa.String(length=240), nullable=True))
_add_if_missing("billing_settings", sa.Column("cashfree_api_version", sa.String(length=20), nullable=False, server_default="2023-08-01"))
_add_if_missing("billing_settings", sa.Column("cashfree_order_note", sa.String(length=250), nullable=True))
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_order_id", sa.String(length=120), nullable=True))
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_cf_order_id", sa.String(length=120), nullable=True))
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_session_id", sa.String(length=500), nullable=True))
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_id", sa.String(length=120), nullable=True))
_add_if_missing("billing_online_payment_transactions", sa.Column("webhook_event_id", sa.String(length=120), nullable=True))
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_order_id", "billing_online_payment_transactions", ["cashfree_order_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_cf_order_id", "billing_online_payment_transactions", ["cashfree_cf_order_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_payment_id", "billing_online_payment_transactions", ["cashfree_payment_id"])
_create_index_if_missing("ix_billing_online_payment_transactions_webhook_event_id", "billing_online_payment_transactions", ["webhook_event_id"])
def downgrade() -> None:
# SQLite cannot safely drop columns in older versions. Keep columns to avoid data loss.
pass
@@ -0,0 +1,111 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260608_phase_7s1_email_notifications"
down_revision = "20260607_phase_7r6a_cashfree"
branch_labels = None
depends_on = None
def _has_table(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _has_table(bind, "email_settings"):
op.create_table(
"email_settings",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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"), nullable=True),
sa.Column("smtp_host", sa.String(length=255), nullable=True),
sa.Column("smtp_port", sa.Integer(), nullable=True),
sa.Column("smtp_username", sa.String(length=255), nullable=True),
sa.Column("smtp_password", sa.String(length=500), nullable=True),
sa.Column("smtp_security", sa.String(length=20), nullable=False, server_default="SSL"),
sa.Column("smtp_timeout_seconds", sa.Integer(), nullable=False, server_default="20"),
sa.Column("from_email", sa.String(length=255), nullable=True),
sa.Column("from_name", sa.String(length=255), nullable=True),
sa.Column("reply_to_email", sa.String(length=255), nullable=True),
sa.Column("imap_host", sa.String(length=255), nullable=True),
sa.Column("imap_port", sa.Integer(), nullable=True),
sa.Column("imap_username", sa.String(length=255), nullable=True),
sa.Column("imap_password", sa.String(length=500), nullable=True),
sa.Column("imap_security", sa.String(length=20), nullable=False, server_default="SSL"),
sa.Column("send_auth_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("send_alert_emails", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("send_billing_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("send_task_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "branch_id", name="uq_email_settings_tenant_branch"),
)
op.create_index("ix_email_settings_tenant_id", "email_settings", ["tenant_id"])
op.create_index("ix_email_settings_branch_id", "email_settings", ["branch_id"])
op.create_index("ix_email_settings_is_active", "email_settings", ["is_active"])
if not _has_table(bind, "email_templates"):
op.create_table(
"email_templates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
sa.Column("template_code", sa.String(length=80), nullable=False),
sa.Column("template_name", sa.String(length=160), nullable=False),
sa.Column("subject_template", sa.String(length=500), nullable=False),
sa.Column("body_template", sa.Text(), nullable=False),
sa.Column("is_html", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "branch_id", "template_code", name="uq_email_templates_scope_code"),
)
op.create_index("ix_email_templates_tenant_id", "email_templates", ["tenant_id"])
op.create_index("ix_email_templates_branch_id", "email_templates", ["branch_id"])
op.create_index("ix_email_templates_template_code", "email_templates", ["template_code"])
op.create_index("ix_email_templates_is_active", "email_templates", ["is_active"])
if not _has_table(bind, "email_logs"):
op.create_table(
"email_logs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
sa.Column("recipient_email", sa.String(length=255), nullable=False),
sa.Column("subject", sa.String(length=500), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="PENDING"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("related_module", sa.String(length=80), nullable=True),
sa.Column("related_id", sa.Integer(), nullable=True),
sa.Column("template_code", sa.String(length=80), nullable=True),
sa.Column("provider_message_id", sa.String(length=255), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_email_logs_tenant_id", "email_logs", ["tenant_id"])
op.create_index("ix_email_logs_branch_id", "email_logs", ["branch_id"])
op.create_index("ix_email_logs_recipient_email", "email_logs", ["recipient_email"])
op.create_index("ix_email_logs_status", "email_logs", ["status"])
op.create_index("ix_email_logs_sent_at", "email_logs", ["sent_at"])
op.create_index("ix_email_logs_related_module", "email_logs", ["related_module"])
op.create_index("ix_email_logs_related_id", "email_logs", ["related_id"])
op.create_index("ix_email_logs_template_code", "email_logs", ["template_code"])
op.create_index("ix_email_logs_created_at_utc", "email_logs", ["created_at_utc"])
def downgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "email_logs"):
op.drop_table("email_logs")
if _has_table(bind, "email_templates"):
op.drop_table("email_templates")
if _has_table(bind, "email_settings"):
op.drop_table("email_settings")
@@ -0,0 +1,50 @@
"""Phase 7S.1C email notification preferences
Revision ID: 20260609_phase_7s1c_email_preferences
Revises: 20260608_phase_7s1_email_notifications
Create Date: 2026-06-09
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260609_phase_7s1c_email_preferences"
down_revision = "20260608_phase_7s1_email_notifications"
branch_labels = None
depends_on = None
_NEW_COLUMNS: list[tuple[str, sa.Column]] = [
("send_invoice_emails", sa.Column("send_invoice_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_payment_emails", sa.Column("send_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_client_emails", sa.Column("send_client_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_document_emails", sa.Column("send_document_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_consultant_emails", sa.Column("send_consultant_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_partner_review_emails", sa.Column("send_partner_review_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_leave_attendance_emails", sa.Column("send_leave_attendance_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
("send_online_payment_emails", sa.Column("send_online_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
]
def _existing_columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
return {col["name"] for col in inspector.get_columns(table_name)}
def upgrade() -> None:
existing = _existing_columns("email_settings")
with op.batch_alter_table("email_settings") as batch_op:
for name, column in _NEW_COLUMNS:
if name not in existing:
batch_op.add_column(column)
def downgrade() -> None:
existing = _existing_columns("email_settings")
with op.batch_alter_table("email_settings") as batch_op:
for name, _column in reversed(_NEW_COLUMNS):
if name in existing:
batch_op.drop_column(name)
@@ -0,0 +1,84 @@
"""Phase 7S.1E email queue and retry metadata
Revision ID: 20260610_phase_7s1e_email_queue
Revises: 20260609_phase_7s1c_email_preferences
Create Date: 2026-06-10
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260610_phase_7s1e_email_queue"
down_revision = "20260609_phase_7s1c_email_preferences"
branch_labels = None
depends_on = None
def _existing_columns(table_name: str) -> set[str]:
bind = op.get_bind()
inspector = sa.inspect(bind)
try:
return {col["name"] for col in inspector.get_columns(table_name)}
except Exception:
return set()
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
if column.name not in _existing_columns(table_name):
op.add_column(table_name, column)
def upgrade() -> None:
_add_column_if_missing("email_logs", sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"))
_add_column_if_missing("email_logs", sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"))
_add_column_if_missing("email_logs", sa.Column("queue_priority", sa.Integer(), nullable=False, server_default="100"))
_add_column_if_missing("email_logs", sa.Column("is_retryable", sa.Boolean(), nullable=False, server_default=sa.true()))
_add_column_if_missing("email_logs", sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing("email_logs", sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing("email_logs", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing("email_logs", sa.Column("processing_started_at", sa.DateTime(timezone=True), nullable=True))
existing_indexes = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes("email_logs")}
for name, cols in {
"ix_email_logs_queue_priority": ["queue_priority"],
"ix_email_logs_is_retryable": ["is_retryable"],
"ix_email_logs_queued_at": ["queued_at"],
"ix_email_logs_next_retry_at": ["next_retry_at"],
"ix_email_logs_last_attempt_at": ["last_attempt_at"],
"ix_email_logs_processing_started_at": ["processing_started_at"],
}.items():
if name not in existing_indexes:
op.create_index(name, "email_logs", cols)
def downgrade() -> None:
# Safe downgrade for development only. SQLite may not support all drop-column
# operations depending on version, so production rollback should use backups.
for name in [
"ix_email_logs_processing_started_at",
"ix_email_logs_last_attempt_at",
"ix_email_logs_next_retry_at",
"ix_email_logs_queued_at",
"ix_email_logs_is_retryable",
"ix_email_logs_queue_priority",
]:
try:
op.drop_index(name, table_name="email_logs")
except Exception:
pass
for col in [
"processing_started_at",
"last_attempt_at",
"next_retry_at",
"queued_at",
"is_retryable",
"queue_priority",
"max_attempts",
"attempt_count",
]:
try:
op.drop_column("email_logs", col)
except Exception:
pass
@@ -0,0 +1,113 @@
"""Phase 7S.2 IMAP incoming email reading
Revision ID: 20260611_phase_7s2_imap_incoming
Revises: 20260610_phase_7s1e_email_queue
Create Date: 2026-06-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260611_phase_7s2_imap_incoming"
down_revision = "20260610_phase_7s1e_email_queue"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in sa.inspect(op.get_bind()).get_table_names()
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
existing = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes(table_name)} if _has_table(table_name) else set()
if index_name not in existing:
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _has_table("email_incoming_messages"):
op.create_table(
"email_incoming_messages",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
sa.Column("mailbox_email", sa.String(length=255), nullable=False),
sa.Column("folder_name", sa.String(length=120), nullable=False, server_default="INBOX"),
sa.Column("provider_uid", sa.String(length=120), nullable=False),
sa.Column("provider_message_id", sa.String(length=500), nullable=True),
sa.Column("sender_email", sa.String(length=255), nullable=True),
sa.Column("sender_name", sa.String(length=255), nullable=True),
sa.Column("recipient_emails", sa.Text(), nullable=True),
sa.Column("cc_emails", sa.Text(), nullable=True),
sa.Column("subject", sa.String(length=500), nullable=True),
sa.Column("body_text", sa.Text(), nullable=True),
sa.Column("body_html", sa.Text(), nullable=True),
sa.Column("raw_headers", sa.Text(), nullable=True),
sa.Column("received_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"),
sa.Column("matched_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("matched_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
sa.Column("matched_consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True),
sa.Column("related_module", sa.String(length=80), nullable=True),
sa.Column("related_id", sa.Integer(), nullable=True),
sa.Column("has_attachments", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("attachment_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("fetched_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "branch_id", "mailbox_email", "folder_name", "provider_uid", name="uq_email_incoming_scope_mailbox_folder_uid"),
)
for name, cols in {
"ix_email_incoming_messages_tenant_id": ["tenant_id"],
"ix_email_incoming_messages_branch_id": ["branch_id"],
"ix_email_incoming_messages_mailbox_email": ["mailbox_email"],
"ix_email_incoming_messages_folder_name": ["folder_name"],
"ix_email_incoming_messages_provider_uid": ["provider_uid"],
"ix_email_incoming_messages_provider_message_id": ["provider_message_id"],
"ix_email_incoming_messages_sender_email": ["sender_email"],
"ix_email_incoming_messages_subject": ["subject"],
"ix_email_incoming_messages_received_at_utc": ["received_at_utc"],
"ix_email_incoming_messages_status": ["status"],
"ix_email_incoming_messages_matched_user_id": ["matched_user_id"],
"ix_email_incoming_messages_matched_client_id": ["matched_client_id"],
"ix_email_incoming_messages_matched_consultant_id": ["matched_consultant_id"],
"ix_email_incoming_messages_related_module": ["related_module"],
"ix_email_incoming_messages_related_id": ["related_id"],
"ix_email_incoming_messages_has_attachments": ["has_attachments"],
"ix_email_incoming_messages_fetched_at_utc": ["fetched_at_utc"],
}.items():
_create_index_if_missing("email_incoming_messages", name, cols)
if not _has_table("email_incoming_attachments"):
op.create_table(
"email_incoming_attachments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("incoming_message_id", sa.Integer(), sa.ForeignKey("email_incoming_messages.id", ondelete="CASCADE"), nullable=False),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
sa.Column("filename", sa.String(length=255), nullable=True),
sa.Column("content_type", sa.String(length=120), nullable=True),
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("storage_path", sa.String(length=1000), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
for name, cols in {
"ix_email_incoming_attachments_incoming_message_id": ["incoming_message_id"],
"ix_email_incoming_attachments_tenant_id": ["tenant_id"],
"ix_email_incoming_attachments_branch_id": ["branch_id"],
}.items():
_create_index_if_missing("email_incoming_attachments", name, cols)
def downgrade() -> None:
try:
op.drop_table("email_incoming_attachments")
except Exception:
pass
try:
op.drop_table("email_incoming_messages")
except Exception:
pass
@@ -0,0 +1,103 @@
"""Phase 7S.3 email-to-engagement mapping
Revision ID: 20260612_phase_7s3_email_mapping
Revises: 20260611_phase_7s2_imap_incoming
Create Date: 2026-06-12
SQLite-safe revision:
- Adds reference id columns as plain Integer columns instead of adding
ForeignKey constraints through ALTER TABLE, because SQLite cannot ALTER
constraints without batch copy/move migration.
- SQLAlchemy model relationships can still use these id columns for lookups.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260612_phase_7s3_email_mapping"
down_revision = "20260611_phase_7s2_imap_incoming"
branch_labels = None
depends_on = None
def _inspector():
return sa.inspect(op.get_bind())
def _has_table(table_name: str) -> bool:
return table_name in _inspector().get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {col["name"] for col in _inspector().get_columns(table_name)}
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if not _has_table(table_name):
return
existing = {idx["name"] for idx in _inspector().get_indexes(table_name)}
if index_name not in existing:
op.create_index(index_name, table_name, columns)
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
if _has_table(table_name) and not _has_column(table_name, column.name):
op.add_column(table_name, column)
def upgrade() -> None:
if not _has_table("email_incoming_messages"):
return
_add_column_if_missing(
"email_incoming_messages",
sa.Column("tracking_code", sa.String(length=80), nullable=True),
)
# SQLite does not support ALTER TABLE ADD CONSTRAINT for ForeignKey.
# Keep these as nullable integer reference columns. Application code and
# SQLAlchemy models can still resolve the linked records safely.
_add_column_if_missing(
"email_incoming_messages",
sa.Column("matched_engagement_id", sa.Integer(), nullable=True),
)
_add_column_if_missing(
"email_incoming_messages",
sa.Column("matched_task_id", sa.Integer(), nullable=True),
)
_add_column_if_missing(
"email_incoming_messages",
sa.Column("matched_invoice_id", sa.Integer(), nullable=True),
)
_add_column_if_missing(
"email_incoming_messages",
sa.Column("mapping_status", sa.String(length=30), nullable=False, server_default="UNMAPPED"),
)
_add_column_if_missing(
"email_incoming_messages",
sa.Column("mapping_notes", sa.Text(), nullable=True),
)
_add_column_if_missing(
"email_incoming_messages",
sa.Column("mapped_at_utc", sa.DateTime(timezone=True), nullable=True),
)
for name, cols in {
"ix_email_incoming_messages_tracking_code": ["tracking_code"],
"ix_email_incoming_messages_matched_engagement_id": ["matched_engagement_id"],
"ix_email_incoming_messages_matched_task_id": ["matched_task_id"],
"ix_email_incoming_messages_matched_invoice_id": ["matched_invoice_id"],
"ix_email_incoming_messages_mapping_status": ["mapping_status"],
"ix_email_incoming_messages_mapped_at_utc": ["mapped_at_utc"],
}.items():
_create_index_if_missing("email_incoming_messages", name, cols)
def downgrade() -> None:
# Keep defensive because SQLite cannot reliably drop columns/constraints
# in all local development environments without a batch table rewrite.
pass
@@ -0,0 +1,72 @@
"""Phase 7T.1 domain mapping table
Revision ID: 20260613_phase_7t1_domain_mapping
Revises: 20260612_phase_7s3_email_mapping
Create Date: 2026-06-13
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260613_phase_7t1_domain_mapping"
down_revision = "20260612_phase_7s3_email_mapping"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _table_exists("domain_mappings"):
return
op.create_table(
"domain_mappings",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("domain_name", sa.String(length=255), nullable=False),
sa.Column("domain_type", sa.String(length=60), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("consultant_id", sa.Integer(), nullable=True),
sa.Column("parent_tenant_id", sa.Integer(), nullable=True),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"),
sa.Column("verification_token", sa.String(length=120), nullable=True),
sa.Column("dns_txt_name", sa.String(length=255), nullable=True),
sa.Column("dns_txt_value", sa.String(length=255), nullable=True),
sa.Column("ssl_mode", sa.String(length=30), nullable=False, server_default="manual"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["parent_tenant_id"], ["tenants.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint("domain_name", name="uq_domain_mappings_domain_name"),
)
op.create_index("ix_domain_mappings_domain_name", "domain_mappings", ["domain_name"])
op.create_index("ix_domain_mappings_domain_type", "domain_mappings", ["domain_type"])
op.create_index("ix_domain_mappings_status", "domain_mappings", ["status"])
op.create_index("ix_domain_mappings_tenant_id", "domain_mappings", ["tenant_id"])
op.create_index("ix_domain_mappings_branch_id", "domain_mappings", ["branch_id"])
op.create_index("ix_domain_mappings_consultant_id", "domain_mappings", ["consultant_id"])
op.create_index("ix_domain_mappings_parent_tenant_id", "domain_mappings", ["parent_tenant_id"])
op.create_index("ix_domain_mappings_is_primary", "domain_mappings", ["is_primary"])
op.create_index("ix_domain_mappings_is_verified", "domain_mappings", ["is_verified"])
op.create_index("ix_domain_mappings_verification_token", "domain_mappings", ["verification_token"])
def downgrade() -> None:
if not _table_exists("domain_mappings"):
return
op.drop_table("domain_mappings")
@@ -0,0 +1,59 @@
"""Phase 7T.9 SSL automation status fields
Revision ID: 20260614_phase_7t9_ssl_automation
Revises: 20260613_phase_7t1_domain_mapping
Create Date: 2026-06-14
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260614_phase_7t9_ssl_automation"
down_revision = "20260613_phase_7t1_domain_mapping"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
if table_name not in inspector.get_table_names():
return False
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
if not _column_exists(table_name, column.name):
op.add_column(table_name, column)
def upgrade() -> None:
if not _table_exists("domain_mappings"):
return
_add_column_if_missing("domain_mappings", sa.Column("ssl_status", sa.String(length=30), nullable=False, server_default="not_checked"))
_add_column_if_missing("domain_mappings", sa.Column("ssl_provider", sa.String(length=60), nullable=True))
_add_column_if_missing("domain_mappings", sa.Column("ssl_last_checked_at_utc", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing("domain_mappings", sa.Column("ssl_not_after_utc", sa.DateTime(timezone=True), nullable=True))
_add_column_if_missing("domain_mappings", sa.Column("ssl_issuer", sa.String(length=255), nullable=True))
_add_column_if_missing("domain_mappings", sa.Column("ssl_subject", sa.String(length=255), nullable=True))
_add_column_if_missing("domain_mappings", sa.Column("ssl_last_error", sa.Text(), nullable=True))
bind = op.get_bind()
inspector = sa.inspect(bind)
indexes = {idx["name"] for idx in inspector.get_indexes("domain_mappings")}
if "ix_domain_mappings_ssl_status" not in indexes:
op.create_index("ix_domain_mappings_ssl_status", "domain_mappings", ["ssl_status"], unique=False)
def downgrade() -> None:
# SQLite-safe downgrade is intentionally conservative. Existing projects can keep
# these nullable/status fields without affecting domain resolution.
pass
@@ -0,0 +1,141 @@
"""Phase 8A - task level documents and task template uploads
Revision ID: 20260615_phase_8a_task_docs
Revises: 20260614_phase_7t9_ssl_automation
Create Date: 2026-06-02
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260615_phase_8a_task_docs"
down_revision = "20260614_phase_7t9_ssl_automation"
branch_labels = None
depends_on = None
def _inspect(bind):
return sa.inspect(bind)
def _has_table(bind, table_name: str) -> bool:
return _inspect(bind).has_table(table_name)
def _has_column(bind, table_name: str, column_name: str) -> bool:
if not _has_table(bind, table_name):
return False
return column_name in {col["name"] for col in _inspect(bind).get_columns(table_name)}
def _has_index(bind, table_name: str, index_name: str) -> bool:
if not _has_table(bind, table_name):
return False
return index_name in {ix["name"] for ix in _inspect(bind).get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if not _has_table(bind, "firm_task_document_requirements"):
op.create_table(
"firm_task_document_requirements",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("firm_task_template_id", sa.Integer(), nullable=False),
sa.Column("document_name", sa.String(length=200), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False, server_default="GENERAL"),
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("allowed_file_types", sa.String(length=255), nullable=True),
sa.Column("instructions", sa.Text(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_req_tenant_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_req_service_catalogue_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_req_firm_task_template_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_created_by_user_id"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_updated_by_user_id"),
sa.UniqueConstraint("tenant_id", "firm_task_template_id", "document_name", name="uq_firm_task_document_requirements_name"),
)
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_tenant_id"):
op.create_index("ix_firm_task_document_requirements_tenant_id", "firm_task_document_requirements", ["tenant_id"])
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_service_catalogue_id"):
op.create_index("ix_firm_task_document_requirements_service_catalogue_id", "firm_task_document_requirements", ["service_catalogue_id"])
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_firm_task_template_id"):
op.create_index("ix_firm_task_document_requirements_firm_task_template_id", "firm_task_document_requirements", ["firm_task_template_id"])
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_document_type"):
op.create_index("ix_firm_task_document_requirements_document_type", "firm_task_document_requirements", ["document_type"])
if not _has_table(bind, "firm_task_document_templates"):
op.create_table(
"firm_task_document_templates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
sa.Column("firm_task_template_id", sa.Integer(), nullable=False),
sa.Column("template_name", sa.String(length=200), nullable=False),
sa.Column("template_category", sa.String(length=100), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("stored_filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=150), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("local_relative_path", sa.String(length=1000), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_tpl_tenant_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_tpl_service_catalogue_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_tpl_firm_task_template_id", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_firm_task_doc_tpl_uploaded_by_user_id"),
)
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_tenant_id"):
op.create_index("ix_firm_task_document_templates_tenant_id", "firm_task_document_templates", ["tenant_id"])
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_service_catalogue_id"):
op.create_index("ix_firm_task_document_templates_service_catalogue_id", "firm_task_document_templates", ["service_catalogue_id"])
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_firm_task_template_id"):
op.create_index("ix_firm_task_document_templates_firm_task_template_id", "firm_task_document_templates", ["firm_task_template_id"])
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_template_category"):
op.create_index("ix_firm_task_document_templates_template_category", "firm_task_document_templates", ["template_category"])
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_is_active"):
op.create_index("ix_firm_task_document_templates_is_active", "firm_task_document_templates", ["is_active"])
# SQLite-safe: add nullable columns only. Do not create batch foreign-key constraints here.
# SQLAlchemy relationships in the app still work through these *_id columns.
if _has_table(bind, "engagement_documents"):
with op.batch_alter_table("engagement_documents") as batch:
if not _has_column(bind, "engagement_documents", "task_instance_id"):
batch.add_column(sa.Column("task_instance_id", sa.Integer(), nullable=True))
if not _has_column(bind, "engagement_documents", "document_requirement_id"):
batch.add_column(sa.Column("document_requirement_id", sa.Integer(), nullable=True))
if not _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"):
op.create_index("ix_engagement_documents_task_instance_id", "engagement_documents", ["task_instance_id"])
if not _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"):
op.create_index("ix_engagement_documents_document_requirement_id", "engagement_documents", ["document_requirement_id"])
def downgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "engagement_documents"):
if _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"):
op.drop_index("ix_engagement_documents_document_requirement_id", table_name="engagement_documents")
if _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"):
op.drop_index("ix_engagement_documents_task_instance_id", table_name="engagement_documents")
with op.batch_alter_table("engagement_documents") as batch:
if _has_column(bind, "engagement_documents", "document_requirement_id"):
batch.drop_column("document_requirement_id")
if _has_column(bind, "engagement_documents", "task_instance_id"):
batch.drop_column("task_instance_id")
if _has_table(bind, "firm_task_document_templates"):
op.drop_table("firm_task_document_templates")
if _has_table(bind, "firm_task_document_requirements"):
op.drop_table("firm_task_document_requirements")
@@ -0,0 +1,174 @@
"""Phase 8B - Notice and Case Management
Revision ID: 20260616_phase_8b_notice_cases
Revises: 20260615_phase_8a_task_docs
Create Date: 2026-06-16
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260616_phase_8b_notice_cases"
down_revision = "20260615_phase_8a_task_docs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notice_cases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("engagement_id", sa.Integer(), nullable=True),
sa.Column("case_code", sa.String(80), nullable=False),
sa.Column("department", sa.String(40), nullable=False),
sa.Column("case_type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("reference_no", sa.String(150), nullable=True),
sa.Column("din_ack_no", sa.String(150), nullable=True),
sa.Column("notice_date", sa.Date(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("financial_year", sa.String(9), nullable=True),
sa.Column("assessment_year", sa.String(9), nullable=True),
sa.Column("period_label", sa.String(60), nullable=True),
sa.Column("status", sa.String(40), nullable=False, server_default="open"),
sa.Column("priority", sa.String(20), nullable=False, server_default="normal"),
sa.Column("issue_summary", sa.Text(), nullable=True),
sa.Column("remarks", sa.Text(), nullable=True),
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True),
sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_cases_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_cases_branch", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], name="fk_notice_cases_client", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], name="fk_notice_cases_engagement", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"], name="fk_notice_cases_partner", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"], name="fk_notice_cases_manager", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"], name="fk_notice_cases_staff", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["archived_by_user_id"], ["users.id"], name="fk_notice_cases_archived_by", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_cases_created_by", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_notice_cases_updated_by", ondelete="SET NULL"),
sa.UniqueConstraint("tenant_id", "case_code", name="uq_notice_cases_tenant_code"),
)
for col in ["tenant_id","branch_id","client_id","engagement_id","case_code","department","case_type","reference_no","din_ack_no","notice_date","due_date","financial_year","assessment_year","period_label","status","priority","assigned_partner_user_id","assigned_manager_user_id","assigned_staff_user_id","is_archived"]:
op.create_index(f"ix_notice_cases_{col}", "notice_cases", [col])
op.create_table(
"notice_case_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("case_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(60), nullable=False),
sa.Column("event_date", sa.Date(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("next_due_date", sa.Date(), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_events_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_events_branch", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_events_case", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_events_created_by", ondelete="SET NULL"),
)
for col in ["tenant_id","branch_id","case_id","event_type","event_date","next_due_date","created_by_user_id"]:
op.create_index(f"ix_notice_case_events_{col}", "notice_case_events", [col])
op.create_table(
"notice_case_hearings",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("case_id", sa.Integer(), nullable=False),
sa.Column("hearing_date", sa.Date(), nullable=False),
sa.Column("hearing_time", sa.String(20), nullable=True),
sa.Column("venue_or_mode", sa.String(200), nullable=True),
sa.Column("officer_name", sa.String(150), nullable=True),
sa.Column("agenda", sa.Text(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=True),
sa.Column("status", sa.String(30), nullable=False, server_default="scheduled"),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_hearings_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_hearings_branch", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_hearings_case", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_hearings_created_by", ondelete="SET NULL"),
)
for col in ["tenant_id","branch_id","case_id","hearing_date","status","created_by_user_id"]:
op.create_index(f"ix_notice_case_hearings_{col}", "notice_case_hearings", [col])
op.create_table(
"notice_case_orders",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("case_id", sa.Integer(), nullable=False),
sa.Column("order_type", sa.String(60), nullable=False),
sa.Column("order_no", sa.String(150), nullable=True),
sa.Column("order_date", sa.Date(), nullable=False),
sa.Column("demand_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("tax_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("interest_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("penalty_amount", sa.Integer(), nullable=False, server_default="0"),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("appeal_due_date", sa.Date(), nullable=True),
sa.Column("appeal_filed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_orders_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_orders_branch", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_orders_case", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_orders_created_by", ondelete="SET NULL"),
)
for col in ["tenant_id","branch_id","case_id","order_type","order_no","order_date","appeal_due_date","appeal_filed","created_by_user_id"]:
op.create_index(f"ix_notice_case_orders_{col}", "notice_case_orders", [col])
op.create_table(
"notice_case_documents",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=True),
sa.Column("case_id", sa.Integer(), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=True),
sa.Column("document_type", sa.String(80), nullable=False, server_default="GENERAL"),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("stored_filename", sa.String(255), nullable=False),
sa.Column("content_type", sa.String(150), nullable=True),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("local_relative_path", sa.String(1000), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("status", sa.String(30), nullable=False, server_default="active"),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_by_user_id", sa.Integer(), nullable=True),
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_documents_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_documents_branch", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_documents_case", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["event_id"], ["notice_case_events.id"], name="fk_notice_case_documents_event", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], name="fk_notice_case_documents_deleted_by", ondelete="SET NULL"),
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_notice_case_documents_uploaded_by", ondelete="SET NULL"),
)
for col in ["tenant_id","branch_id","case_id","event_id","document_type","status","is_deleted","uploaded_by_user_id"]:
op.create_index(f"ix_notice_case_documents_{col}", "notice_case_documents", [col])
def downgrade() -> None:
op.drop_table("notice_case_documents")
op.drop_table("notice_case_orders")
op.drop_table("notice_case_hearings")
op.drop_table("notice_case_events")
op.drop_table("notice_cases")
@@ -0,0 +1,89 @@
"""Phase v2.0.4-A - Financial Year Master and Active FY Context
Revision ID: 20260617_phase_204a_financial_year
Revises: 20260616_phase_8b_notice_cases
Create Date: 2026-06-17
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from alembic import op
import sqlalchemy as sa
revision = "20260617_phase_204a_financial_year"
down_revision = "20260616_phase_8b_notice_cases"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"financial_years",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("year_code", sa.String(9), nullable=False),
sa.Column("assessment_year", sa.String(9), nullable=False),
sa.Column("start_date", sa.Date(), nullable=False),
sa.Column("end_date", sa.Date(), nullable=False),
sa.Column("is_current", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("locked_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_financial_years_tenant", ondelete="CASCADE"),
sa.ForeignKeyConstraint(["locked_by_user_id"], ["users.id"], name="fk_financial_years_locked_by", ondelete="SET NULL"),
sa.UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"),
)
op.create_index("ix_financial_years_tenant_id", "financial_years", ["tenant_id"])
op.create_index("ix_financial_years_year_code", "financial_years", ["year_code"])
op.create_index("ix_financial_years_assessment_year", "financial_years", ["assessment_year"])
op.create_index("ix_financial_years_is_current", "financial_years", ["is_current"])
op.create_index("ix_financial_years_is_locked", "financial_years", ["is_locked"])
op.create_index("ix_financial_years_locked_by_user_id", "financial_years", ["locked_by_user_id"])
# Seed the default FY for every existing tenant so the selector works immediately
# after upgrade. Further years can be created from System Settings.
conn = op.get_bind()
tenants = conn.execute(sa.text("SELECT id FROM tenants")).fetchall()
now = datetime.now(timezone.utc)
for row in tenants:
tenant_id = row[0]
exists = conn.execute(
sa.text("SELECT id FROM financial_years WHERE tenant_id = :tenant_id AND year_code = :year_code"),
{"tenant_id": tenant_id, "year_code": "2025-26"},
).first()
if exists:
continue
conn.execute(
sa.text(
"""
INSERT INTO financial_years
(tenant_id, year_code, assessment_year, start_date, end_date, is_current, is_locked, created_at_utc, updated_at_utc)
VALUES
(:tenant_id, :year_code, :assessment_year, :start_date, :end_date, :is_current, :is_locked, :created_at_utc, :updated_at_utc)
"""
),
{
"tenant_id": tenant_id,
"year_code": "2025-26",
"assessment_year": "2026-27",
"start_date": date(2025, 4, 1),
"end_date": date(2026, 3, 31),
"is_current": True,
"is_locked": False,
"created_at_utc": now,
"updated_at_utc": now,
},
)
def downgrade() -> None:
op.drop_index("ix_financial_years_locked_by_user_id", table_name="financial_years")
op.drop_index("ix_financial_years_is_locked", table_name="financial_years")
op.drop_index("ix_financial_years_is_current", table_name="financial_years")
op.drop_index("ix_financial_years_assessment_year", table_name="financial_years")
op.drop_index("ix_financial_years_year_code", table_name="financial_years")
op.drop_index("ix_financial_years_tenant_id", table_name="financial_years")
op.drop_table("financial_years")
@@ -0,0 +1,107 @@
"""Phase v2.0.4-D - Billing financial year isolation
Revision ID: 20260618_phase_204d_billing_fy
Revises: 20260617_phase_204a_financial_year
Create Date: 2026-06-18
"""
from __future__ import annotations
from datetime import date, datetime
from alembic import op
import sqlalchemy as sa
revision = "20260618_phase_204d_billing_fy"
down_revision = "20260617_phase_204a_financial_year"
branch_labels = None
depends_on = None
def _fy_label(value) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
value = value.date()
if isinstance(value, str):
try:
value = date.fromisoformat(value[:10])
except Exception:
return None
if not isinstance(value, date):
return None
start_year = value.year if value.month >= 4 else value.year - 1
return f"{start_year}-{str(start_year + 1)[-2:]}"
def _has_table(conn, table_name: str) -> bool:
return sa.inspect(conn).has_table(table_name)
def _has_column(conn, table_name: str, column_name: str) -> bool:
if not _has_table(conn, table_name):
return False
return any(col["name"] == column_name for col in sa.inspect(conn).get_columns(table_name))
def _create_index_if_missing(conn, index_name: str, table_name: str, columns: list[str]) -> None:
if not _has_table(conn, table_name):
return
existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)}
if index_name not in existing:
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(conn, index_name: str, table_name: str) -> None:
if not _has_table(conn, table_name):
return
existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)}
if index_name in existing:
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
conn = op.get_bind()
if _has_table(conn, "billing_invoice_generation_batches") and not _has_column(conn, "billing_invoice_generation_batches", "financial_year"):
op.add_column("billing_invoice_generation_batches", sa.Column("financial_year", sa.String(length=20), nullable=True))
rows = conn.execute(sa.text("SELECT id, billing_period_from FROM billing_invoice_generation_batches")).mappings().all()
for row in rows:
fy = _fy_label(row["billing_period_from"])
if fy:
conn.execute(sa.text("UPDATE billing_invoice_generation_batches SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
_create_index_if_missing(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches", ["financial_year"])
if _has_table(conn, "billing_invoices") and not _has_column(conn, "billing_invoices", "financial_year"):
op.add_column("billing_invoices", sa.Column("financial_year", sa.String(length=20), nullable=True))
rows = conn.execute(sa.text("SELECT id, billing_period_from, invoice_date FROM billing_invoices")).mappings().all()
for row in rows:
fy = _fy_label(row["billing_period_from"]) or _fy_label(row["invoice_date"])
if fy:
conn.execute(sa.text("UPDATE billing_invoices SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
_create_index_if_missing(conn, "ix_billing_invoices_financial_year", "billing_invoices", ["financial_year"])
if _has_table(conn, "billing_payments") and not _has_column(conn, "billing_payments", "financial_year"):
op.add_column("billing_payments", sa.Column("financial_year", sa.String(length=20), nullable=True))
rows = conn.execute(sa.text("SELECT id, payment_date FROM billing_payments")).mappings().all()
for row in rows:
fy = _fy_label(row["payment_date"])
if fy:
conn.execute(sa.text("UPDATE billing_payments SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
_create_index_if_missing(conn, "ix_billing_payments_financial_year", "billing_payments", ["financial_year"])
def downgrade() -> None:
conn = op.get_bind()
if _has_column(conn, "billing_payments", "financial_year"):
_drop_index_if_exists(conn, "ix_billing_payments_financial_year", "billing_payments")
op.drop_column("billing_payments", "financial_year")
if _has_column(conn, "billing_invoices", "financial_year"):
_drop_index_if_exists(conn, "ix_billing_invoices_financial_year", "billing_invoices")
op.drop_column("billing_invoices", "financial_year")
if _has_column(conn, "billing_invoice_generation_batches", "financial_year"):
_drop_index_if_exists(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches")
op.drop_column("billing_invoice_generation_batches", "financial_year")
@@ -0,0 +1,51 @@
"""Phase v2.0.4-E - Year Lock and Backup Export
Revision ID: 20260619_phase_204e_year_lock_backup
Revises: 20260618_phase_204d_billing_fy
Create Date: 2026-06-19
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260619_phase_204e_year_lock_backup"
down_revision = "20260618_phase_204d_billing_fy"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"year_backup_exports",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("financial_year_id", sa.Integer(), nullable=False),
sa.Column("year_code", sa.String(length=9), nullable=False),
sa.Column("assessment_year", sa.String(length=9), nullable=True),
sa.Column("export_status", sa.String(length=30), nullable=False, server_default="completed"),
sa.Column("export_file_path", sa.String(length=1000), nullable=False),
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("manifest_json", sa.Text(), nullable=True),
sa.Column("generated_by_user_id", sa.Integer(), nullable=True),
sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["financial_year_id"], ["financial_years.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"], ondelete="SET NULL"),
)
op.create_index("ix_year_backup_exports_tenant_id", "year_backup_exports", ["tenant_id"])
op.create_index("ix_year_backup_exports_financial_year_id", "year_backup_exports", ["financial_year_id"])
op.create_index("ix_year_backup_exports_year_code", "year_backup_exports", ["year_code"])
op.create_index("ix_year_backup_exports_assessment_year", "year_backup_exports", ["assessment_year"])
op.create_index("ix_year_backup_exports_export_status", "year_backup_exports", ["export_status"])
op.create_index("ix_year_backup_exports_generated_by_user_id", "year_backup_exports", ["generated_by_user_id"])
def downgrade() -> None:
op.drop_index("ix_year_backup_exports_generated_by_user_id", table_name="year_backup_exports")
op.drop_index("ix_year_backup_exports_export_status", table_name="year_backup_exports")
op.drop_index("ix_year_backup_exports_assessment_year", table_name="year_backup_exports")
op.drop_index("ix_year_backup_exports_year_code", table_name="year_backup_exports")
op.drop_index("ix_year_backup_exports_financial_year_id", table_name="year_backup_exports")
op.drop_index("ix_year_backup_exports_tenant_id", table_name="year_backup_exports")
op.drop_table("year_backup_exports")
@@ -0,0 +1,270 @@
"""reconcile common tables and clients safely
Revision ID: 7f1c9b2d4a10
Revises: 3d5ce4b11767
Create Date: 2026-04-05 14:30:00.000000
NOTE:
- This is a manual repair/reconciliation migration.
- It is intentionally written to CREATE missing tables/indexes only.
- It does NOT drop existing tables.
- It is safe for the current state where revision 3d5ce4b11767 has already been applied.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "7f1c9b2d4a10"
down_revision: Union[str, None] = "940fb75ddce6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _inspector():
bind = op.get_bind()
return sa.inspect(bind)
def _has_table(table_name: str) -> bool:
return table_name in _inspector().get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
try:
indexes = _inspector().get_indexes(table_name)
except Exception:
return False
return any(ix.get("name") == index_name for ix in indexes)
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
if not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def upgrade() -> None:
if not _has_table("audit_logs"):
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("actor_email", sa.String(length=255), nullable=True),
sa.Column("actor_tenant_id", sa.Integer(), nullable=True),
sa.Column("actor_branch_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=120), nullable=False),
sa.Column("entity_type", sa.String(length=120), nullable=False),
sa.Column("entity_id", sa.String(length=120), nullable=True),
sa.Column("entity_name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("target_tenant_id", sa.Integer(), nullable=True),
sa.Column("target_branch_id", sa.Integer(), nullable=True),
sa.Column("ip_address", sa.String(length=100), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("details_json", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
)
_create_index_if_missing("audit_logs", "ix_audit_logs_action", ["action"])
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_branch_id", ["actor_branch_id"])
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_tenant_id", ["actor_tenant_id"])
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_user_id", ["actor_user_id"])
_create_index_if_missing("audit_logs", "ix_audit_logs_created_at_utc", ["created_at_utc"])
_create_index_if_missing("audit_logs", "ix_audit_logs_entity_type", ["entity_type"])
_create_index_if_missing("audit_logs", "ix_audit_logs_status", ["status"])
_create_index_if_missing("audit_logs", "ix_audit_logs_target_branch_id", ["target_branch_id"])
_create_index_if_missing("audit_logs", "ix_audit_logs_target_tenant_id", ["target_tenant_id"])
if not _has_table("services"):
op.create_table(
"services",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("service_code", sa.String(length=50), nullable=False),
sa.Column("service_name", sa.String(length=200), nullable=False),
sa.Column("category", sa.String(length=100), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_client_requestable", sa.Boolean(), nullable=False),
sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "branch_id", "service_code", name="uq_services_tenant_branch_code"),
)
_create_index_if_missing("services", "ix_services_tenant_id", ["tenant_id"])
_create_index_if_missing("services", "ix_services_branch_id", ["branch_id"])
_create_index_if_missing("services", "ix_services_service_code", ["service_code"])
_create_index_if_missing("services", "ix_services_service_name", ["service_name"])
if not _has_table("service_task_templates"):
op.create_table(
"service_task_templates",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("service_id", sa.Integer(), nullable=False),
sa.Column("task_name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sequence_no", sa.Integer(), nullable=False),
sa.Column("default_role_name", sa.String(length=100), nullable=True),
sa.Column("is_mandatory", sa.Boolean(), nullable=False),
sa.Column("requires_review", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["service_id"], ["services.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("service_id", "sequence_no", name="uq_service_task_templates_sequence"),
)
_create_index_if_missing("service_task_templates", "ix_service_task_templates_service_id", ["service_id"])
if not _has_table("invite_tokens"):
op.create_table(
"invite_tokens",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("expires_at_utc", sa.DateTime(), nullable=False),
sa.Column("used_at_utc", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_invite_token_hash"),
)
_create_index_if_missing("invite_tokens", "ix_invite_tokens_user_id", ["user_id"])
_create_index_if_missing("invite_tokens", "ix_invite_tokens_token_hash", ["token_hash"])
if not _has_table("password_reset_tokens"):
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
sa.Column("expires_at_utc", sa.DateTime(), nullable=False),
sa.Column("used_at_utc", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),
)
_create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_user_id", ["user_id"])
_create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_token_hash", ["token_hash"])
if not _has_table("clients"):
op.create_table(
"clients",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("partner_id", sa.Integer(), nullable=False),
sa.Column("client_code", sa.String(length=50), nullable=False),
sa.Column("client_name", sa.String(length=200), nullable=False),
sa.Column("trade_name", sa.String(length=200), nullable=True),
sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"),
sa.Column("pan", sa.String(length=20), nullable=True),
sa.Column("gstin", sa.String(length=20), nullable=True),
sa.Column("tan", sa.String(length=20), nullable=True),
sa.Column("cin_llpin", sa.String(length=30), nullable=True),
sa.Column("msme_no", sa.String(length=50), nullable=True),
sa.Column("iec_code", sa.String(length=30), nullable=True),
sa.Column("contact_person_name", sa.String(length=200), nullable=True),
sa.Column("contact_person_designation", sa.String(length=200), nullable=True),
sa.Column("mobile", sa.String(length=20), nullable=True),
sa.Column("alternate_mobile", sa.String(length=20), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("alternate_email", sa.String(length=255), nullable=True),
sa.Column("address_line_1", sa.String(length=255), nullable=True),
sa.Column("address_line_2", sa.String(length=255), nullable=True),
sa.Column("city", sa.String(length=100), nullable=True),
sa.Column("state", sa.String(length=100), nullable=True),
sa.Column("pincode", sa.String(length=20), nullable=True),
sa.Column("country", sa.String(length=100), nullable=True, server_default="India"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("client_category", sa.String(length=100), nullable=True),
sa.Column("risk_category", sa.String(length=50), nullable=True),
sa.Column("onboarding_date", sa.Date(), nullable=True),
sa.Column("closing_date", sa.Date(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("gst_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("income_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("tds_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("roc_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("audit_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("pf_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("esi_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("professional_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("payroll_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("msme_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("import_export_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["partner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
sa.UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
sa.UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
)
_create_index_if_missing("clients", "ix_clients_tenant_id", ["tenant_id"])
_create_index_if_missing("clients", "ix_clients_branch_id", ["branch_id"])
_create_index_if_missing("clients", "ix_clients_partner_id", ["partner_id"])
_create_index_if_missing("clients", "ix_clients_client_code", ["client_code"])
_create_index_if_missing("clients", "ix_clients_client_name", ["client_name"])
_create_index_if_missing("clients", "ix_clients_pan", ["pan"])
_create_index_if_missing("clients", "ix_clients_gstin", ["gstin"])
_create_index_if_missing("clients", "ix_clients_status", ["status"])
if not _has_table("client_audit_logs"):
op.create_table(
"client_audit_logs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("branch_id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("summary", sa.String(length=255), nullable=False),
sa.Column("payload_json", sa.JSON(), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["client_id"], ["clients.id"]),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]),
)
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_client_id", ["client_id"])
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_tenant_id", ["tenant_id"])
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_branch_id", ["branch_id"])
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_action", ["action"])
def downgrade() -> None:
# Intentionally conservative:
# only remove tables introduced by this repair migration that are not baseline-owned.
# We do NOT drop services/auth-flow/audit tables here to avoid accidental data loss.
if _has_table("client_audit_logs"):
op.drop_table("client_audit_logs")
if _has_table("clients"):
op.drop_table("clients")
@@ -0,0 +1,251 @@
"""0001 baseline common
Revision ID: 940fb75ddce6
Revises:
Create Date: 2026-03-15 19:52:18.618166
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '940fb75ddce6'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('login_attempts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('locked_until_utc', sa.DateTime(), nullable=True),
sa.Column('updated_at_utc', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key', name='uq_login_attempt_key')
)
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_login_attempts_key'), ['key'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('code', sa.String(length=150), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code', name='uq_permission_code')
)
with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_permissions_code'), ['code'], unique=False)
op.create_table('roles',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', name='uq_role_name')
)
with op.batch_alter_table('roles', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_roles_name'), ['name'], unique=False)
op.create_table('tenants',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('default_timezone', sa.String(length=64), nullable=False),
sa.Column('default_session_duration_minutes', sa.Integer(), nullable=False),
sa.Column('default_otp_required_roles_csv', sa.String(length=200), nullable=False),
sa.Column('default_storage_mode', sa.String(length=20), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('tenants', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_tenants_code'), ['code'], unique=True)
op.create_table('branches',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('timezone', sa.String(length=64), nullable=False),
sa.Column('office_start_time', sa.Time(), nullable=True),
sa.Column('office_end_time', sa.Time(), nullable=True),
sa.Column('allow_login', sa.Boolean(), nullable=False),
sa.Column('allow_new_assignments', sa.Boolean(), nullable=False),
sa.Column('is_head_office', sa.Boolean(), nullable=False),
sa.Column('smtp_host', sa.String(length=255), nullable=True),
sa.Column('smtp_port', sa.Integer(), nullable=True),
sa.Column('smtp_username', sa.String(length=255), nullable=True),
sa.Column('smtp_password', sa.String(length=255), nullable=True),
sa.Column('smtp_use_tls', sa.Boolean(), nullable=False),
sa.Column('local_storage_path', sa.String(length=500), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'code', name='uq_branch_tenant_code')
)
with op.batch_alter_table('branches', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_branches_code'), ['code'], unique=False)
batch_op.create_index(batch_op.f('ix_branches_tenant_id'), ['tenant_id'], unique=False)
op.create_table('role_permissions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_perm')
)
with op.batch_alter_table('role_permissions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_role_permissions_permission_id'), ['permission_id'], unique=False)
batch_op.create_index(batch_op.f('ix_role_permissions_role_id'), ['role_id'], unique=False)
op.create_table('branch_settings',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('branch_id', sa.Integer(), nullable=False),
sa.Column('address_line1', sa.String(length=255), nullable=True),
sa.Column('address_line2', sa.String(length=255), nullable=True),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('state', sa.String(length=100), nullable=True),
sa.Column('pin_code', sa.String(length=10), nullable=True),
sa.Column('gstin', sa.String(length=20), nullable=True),
sa.Column('pan', sa.String(length=10), nullable=True),
sa.Column('letterhead_logo_path', sa.String(length=500), nullable=True),
sa.Column('letterhead_signature_path', sa.String(length=500), nullable=True),
sa.Column('letterhead_stamp_path', sa.String(length=500), nullable=True),
sa.Column('geo_address', sa.String(length=500), nullable=True),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('working_days_csv', sa.String(length=50), nullable=False),
sa.Column('holidays_json', sa.Text(), nullable=False),
sa.Column('timezone_locked', sa.Boolean(), nullable=False),
sa.Column('email_from_name', sa.String(length=200), nullable=True),
sa.Column('email_from_email', sa.String(length=255), nullable=True),
sa.Column('email_reply_to', sa.String(length=255), nullable=True),
sa.Column('default_cc_csv', sa.String(length=500), nullable=True),
sa.Column('default_bcc_csv', sa.String(length=500), nullable=True),
sa.Column('email_signature_html', sa.Text(), nullable=True),
sa.Column('storage_mode', sa.String(length=20), nullable=False),
sa.Column('folder_template', sa.String(length=500), nullable=False),
sa.Column('max_file_mb', sa.Integer(), nullable=False),
sa.Column('allowed_ext_csv', sa.String(length=500), nullable=False),
sa.Column('retention_years', sa.Integer(), nullable=False),
sa.Column('otp_required_roles_csv', sa.String(length=200), nullable=False),
sa.Column('session_duration_minutes', sa.Integer(), nullable=False),
sa.Column('lockout_attempts', sa.Integer(), nullable=False),
sa.Column('lockout_minutes', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('branch_id', name='uq_branch_settings_branch')
)
with op.batch_alter_table('branch_settings', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_branch_settings_branch_id'), ['branch_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('branch_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('allow_login', sa.Boolean(), nullable=False),
sa.Column('is_locked', sa.Boolean(), nullable=False),
sa.Column('locked_at_utc', sa.DateTime(), nullable=True),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('must_change_password', sa.Boolean(), nullable=False),
sa.Column('password_changed_at_utc', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email', name='uq_user_email')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_branch_id'), ['branch_id'], unique=False)
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False)
batch_op.create_index(batch_op.f('ix_users_tenant_id'), ['tenant_id'], unique=False)
op.create_table('refresh_tokens',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('created_at_utc', sa.DateTime(), nullable=False),
sa.Column('expires_at_utc', sa.DateTime(), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('rotated_from_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash', name='uq_refresh_token_hash')
)
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_refresh_tokens_token_hash'), ['token_hash'], unique=False)
batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_id'], unique=False)
op.create_table('user_roles',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role')
)
with op.batch_alter_table('user_roles', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_roles_role_id'), ['role_id'], unique=False)
batch_op.create_index(batch_op.f('ix_user_roles_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_roles', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_roles_user_id'))
batch_op.drop_index(batch_op.f('ix_user_roles_role_id'))
op.drop_table('user_roles')
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_refresh_tokens_user_id'))
batch_op.drop_index(batch_op.f('ix_refresh_tokens_token_hash'))
op.drop_table('refresh_tokens')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_tenant_id'))
batch_op.drop_index(batch_op.f('ix_users_email'))
batch_op.drop_index(batch_op.f('ix_users_branch_id'))
op.drop_table('users')
with op.batch_alter_table('branch_settings', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_branch_settings_branch_id'))
op.drop_table('branch_settings')
with op.batch_alter_table('role_permissions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_role_permissions_role_id'))
batch_op.drop_index(batch_op.f('ix_role_permissions_permission_id'))
op.drop_table('role_permissions')
with op.batch_alter_table('branches', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_branches_tenant_id'))
batch_op.drop_index(batch_op.f('ix_branches_code'))
op.drop_table('branches')
with op.batch_alter_table('tenants', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tenants_code'))
op.drop_table('tenants')
with op.batch_alter_table('roles', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_roles_name'))
op.drop_table('roles')
with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_permissions_code'))
op.drop_table('permissions')
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_login_attempts_key'))
op.drop_table('login_attempts')
# ### end Alembic commands ###
@@ -0,0 +1,23 @@
"""merge ds5 and ds6 document heads
Revision ID: af48d99d7321
Revises: 20260518_ds6_permanent_client_document_vault, 20260528_phase_ds5_secure_download_streaming
Create Date: 2026-05-18 20:41:43.386276
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'af48d99d7321'
down_revision: Union[str, None] = ('20260518_ds6_permanent_client_document_vault', '20260528_phase_ds5_secure_download_streaming')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass