gmal-scope-builder/backend/alembic/versions/001_initial_schema.py
DJP e18976fdb2 Initial commit - GMAL Scope Builder
Dockerized web app (FastAPI + React + PostgreSQL) for scoping client ratecards
against the GMAL master asset database. Features:
- GMAL data ingestion from Excel (390 assets, 120 roles, 5 model types)
- AI-powered document parsing and asset extraction (Claude Opus 4.6)
- AI matching engine with parallel batching, confidence scoring, caveats
- Ratecard builder with hours x volume calculation
- Excel and PDF export
- GMAL browser and inline editor
- AI cost tracking per project (persisted to DB)
- Debug panel for AI call inspection
- Dark theme UI with gold (#FFC407) accent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:35:14 -04:00

181 lines
7.8 KiB
Python

"""Initial schema
Revision ID: 001
Revises:
Create Date: 2026-03-27
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '001'
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:
model_type_enum = sa.Enum(
'current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory',
name='modeltype', create_type=False,
)
project_status_enum = sa.Enum(
'draft', 'parsing', 'matching', 'review', 'building', 'finalized',
name='projectstatus', create_type=False,
)
match_confidence_enum = sa.Enum(
'exact', 'close', 'multiple', 'none',
name='matchconfidence', create_type=False,
)
# Create enum types explicitly
op.execute("DO $$ BEGIN CREATE TYPE modeltype AS ENUM ('current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
op.execute("DO $$ BEGIN CREATE TYPE projectstatus AS ENUM ('draft', 'parsing', 'matching', 'review', 'building', 'finalized'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
op.execute("DO $$ BEGIN CREATE TYPE matchconfidence AS ENUM ('exact', 'close', 'multiple', 'none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
# gmal_assets
op.create_table(
'gmal_assets',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('gmal_id', sa.String(20), nullable=False, unique=True),
sa.Column('swop_asset_id', sa.String(50)),
sa.Column('region', sa.String(20)),
sa.Column('category', sa.String(100)),
sa.Column('sub_category', sa.String(100), index=True),
sa.Column('sub_category_description', sa.Text()),
sa.Column('asset_name', sa.String(255)),
sa.Column('complexity_level', sa.Integer()),
sa.Column('complexity_name', sa.String(20)),
sa.Column('unique_name', sa.String(255)),
sa.Column('asset_description', sa.Text()),
sa.Column('complexity_description', sa.Text()),
sa.Column('caveats', sa.Text()),
sa.Column('ai_enhanced_description', sa.Text()),
sa.Column('master_adapt', sa.String(20)),
sa.Column('ai_efficiency_pct', sa.Numeric(5, 2)),
sa.Column('has_hour_routes', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('ix_gmal_assets_gmal_id', 'gmal_assets', ['gmal_id'])
# roles
op.create_table(
'roles',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('discipline', sa.String(100), nullable=False, index=True),
sa.Column('role_title', sa.String(200), nullable=False),
sa.Column('entity', sa.String(100)),
sa.Column('resource_location', sa.String(50)),
sa.Column('unique_name', sa.String(255)),
sa.Column('sort_order', sa.Integer()),
sa.Column('is_programme_role', sa.Boolean(), default=False),
sa.UniqueConstraint('role_title', 'entity', name='uq_role_title_entity'),
)
# gmal_hours
op.create_table(
'gmal_hours',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False),
sa.Column('model_type', model_type_enum, nullable=False),
sa.Column('hours', sa.Numeric(10, 2), nullable=False),
sa.UniqueConstraint('gmal_asset_id', 'role_id', 'model_type', name='uq_gmal_role_model'),
)
op.create_index('idx_gmal_hours_asset', 'gmal_hours', ['gmal_asset_id'])
op.create_index('idx_gmal_hours_model', 'gmal_hours', ['model_type'])
# gmal_service_lines
op.create_table(
'gmal_service_lines',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('number', sa.String(20)),
sa.Column('name', sa.String(255)),
sa.Column('type', sa.String(50)),
sa.Column('gmal_id', sa.String(20), index=True),
)
# role_level_mappings
op.create_table(
'role_level_mappings',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('role_name', sa.String(200)),
sa.Column('number', sa.String(20)),
sa.Column('level_name', sa.String(200)),
sa.Column('type', sa.String(50)),
)
# projects
op.create_table(
'projects',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('client_name', sa.String(255)),
sa.Column('description', sa.Text()),
sa.Column('model_type', model_type_enum, default='current_oplus'),
sa.Column('status', project_status_enum, default='draft'),
sa.Column('source_filename', sa.String(255)),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
)
# client_assets
op.create_table(
'client_assets',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False),
sa.Column('raw_name', sa.String(500)),
sa.Column('raw_description', sa.Text()),
sa.Column('volume', sa.Integer(), default=1),
sa.Column('sort_order', sa.Integer()),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
# matches
op.create_table(
'matches',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id', ondelete='CASCADE'), nullable=False),
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
sa.Column('confidence', match_confidence_enum, nullable=False),
sa.Column('confidence_score', sa.Numeric(3, 2)),
sa.Column('ai_reasoning', sa.Text()),
sa.Column('caveat_text', sa.Text()),
sa.Column('is_selected', sa.Boolean(), default=False),
sa.Column('rank', sa.Integer(), default=1),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_matches_client_asset', 'matches', ['client_asset_id'])
# ratecard_lines
op.create_table(
'ratecard_lines',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False),
sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id'), nullable=False),
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False),
sa.Column('base_hours', sa.Numeric(10, 2)),
sa.Column('volume', sa.Integer(), default=1),
sa.Column('total_hours', sa.Numeric(10, 2)),
sa.Column('manual_override', sa.Numeric(10, 2)),
sa.Column('notes', sa.Text()),
)
op.create_index('idx_ratecard_project', 'ratecard_lines', ['project_id'])
def downgrade() -> None:
op.drop_table('ratecard_lines')
op.drop_table('matches')
op.drop_table('client_assets')
op.drop_table('projects')
op.drop_table('role_level_mappings')
op.drop_table('gmal_service_lines')
op.drop_table('gmal_hours')
op.drop_table('roles')
op.drop_table('gmal_assets')
sa.Enum(name='matchconfidence').drop(op.get_bind(), checkfirst=True)
sa.Enum(name='projectstatus').drop(op.get_bind(), checkfirst=True)
sa.Enum(name='modeltype').drop(op.get_bind(), checkfirst=True)