Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
Alembic integration for the Common DB baseline.
|
||||
|
||||
Create migration:
|
||||
alembic revision --autogenerate -m "message"
|
||||
|
||||
Apply migrations:
|
||||
alembic upgrade head
|
||||
|
||||
Downgrade:
|
||||
alembic downgrade -1
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.engine import URL
|
||||
|
||||
from app.core.settings import get_settings
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTANT FOR ALEMBIC AUTOGENERATE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Alembic only sees tables that are registered in CommonBase.metadata.
|
||||
# A model is registered only after its module is imported. Keep every SQLAlchemy
|
||||
# model module here so a fresh baseline migration can generate all tables.
|
||||
# Do not remove these imports even if they look unused.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Core / tenancy models
|
||||
from app.modules.core.tenancy import models as tenancy_models # noqa: F401
|
||||
from app.modules.core.tenancy import settings_models as tenancy_settings_models # noqa: F401
|
||||
|
||||
# IAM / RBAC / token / password-flow models
|
||||
from app.modules.core.iam import models as iam_models # noqa: F401
|
||||
from app.modules.core.iam import tokens_models as iam_tokens_models # noqa: F401
|
||||
from app.modules.core.iam import password_flows_models as iam_password_flows_models # noqa: F401
|
||||
from app.modules.core.rbac import models as rbac_models # noqa: F401
|
||||
|
||||
# Audit log models
|
||||
from app.modules.core.audit import models as audit_models # noqa: F401
|
||||
|
||||
# Business module models
|
||||
from app.modules.clients import models as client_models # noqa: F401
|
||||
from app.modules.clients import association_models as client_association_models # noqa: F401
|
||||
from app.modules.services import models as service_models # noqa: F401
|
||||
from app.modules.consultants import models as consultant_models # noqa: F401
|
||||
from app.modules.employees import models as employee_models # noqa: F401
|
||||
from app.modules.billing import models as billing_models # noqa: F401
|
||||
from app.modules.platform_billing import models as platform_billing_models # noqa: F401
|
||||
from app.modules.marketplace import models as marketplace_models # noqa: F401
|
||||
from app.modules.documents import models as documents_models # noqa: F401
|
||||
from app.modules.alerts import models as alerts_models # noqa: F401
|
||||
from app.modules.email_integration import models as email_integration_models # noqa: F401
|
||||
from app.modules.domain_management import models as domain_management_models # noqa: F401
|
||||
from app.modules.notice_cases import models as notice_cases_models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = CommonBase.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
s = get_settings()
|
||||
if (s.DB_BACKEND or "sqlite").lower() == "sqlite":
|
||||
return f"sqlite:///{s.SQLITE_COMMON_PATH}"
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=s.PG_USER,
|
||||
password=s.PG_PASSWORD,
|
||||
host=s.PG_HOST,
|
||||
port=s.PG_PORT,
|
||||
database=s.PG_DB_COMMON,
|
||||
).render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
render_as_batch=("sqlite" in get_url()),
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user