BuyingAgent/backend/migrations/versions/0003_discovery_schema.py

230 lines
13 KiB
Python
Raw Normal View History

"""Discovery, taxonomy, search runs, and project candidates.
Revision ID: 0003_discovery_schema
Revises: 0002_domain_schema
Create Date: 2026-10-04
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0003_discovery_schema"
down_revision: str | None = "0002_domain_schema"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"buying_projects",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("workspace_id", sa.String(64)),
sa.Column("profile_id", sa.String(128), nullable=False),
sa.Column("profile_version", sa.Integer(), nullable=False),
sa.Column("shortlist_size", sa.Integer(), nullable=False, server_default="5"),
sa.Column("schedule_interval_minutes", sa.Integer(), nullable=False, server_default="180"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True)),
sa.ForeignKeyConstraint(
["profile_id", "profile_version"],
["product_profiles.id", "product_profiles.version"],
name="fk_buying_projects_profile",
),
sa.CheckConstraint("status IN ('ACTIVE', 'PURCHASED', 'ARCHIVED')", name="ck_buying_projects_status"),
sa.CheckConstraint("shortlist_size > 0", name="ck_buying_projects_shortlist"),
sa.CheckConstraint("schedule_interval_minutes > 0", name="ck_buying_projects_interval"),
)
op.create_table(
"buying_project_criteria",
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), primary_key=True),
sa.Column("version", sa.Integer(), primary_key=True),
sa.Column("definition", postgresql.JSONB, nullable=False),
sa.Column("definition_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("version > 0", name="ck_project_criteria_version"),
)
op.create_table(
"source_categories",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("external_category_key", sa.String(64), nullable=False),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("last_verified_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("source_id", "external_category_key", name="uq_source_category"),
)
op.create_table(
"source_filter_definitions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
sa.Column("external_filter_key", sa.String(64), nullable=False),
sa.Column("label", sa.String(256), nullable=False),
sa.Column("filter_type", sa.String(32), nullable=False),
sa.Column("canonical_unit", sa.String(32)),
sa.Column("status", sa.String(32), nullable=False),
sa.UniqueConstraint("source_category_id", "external_filter_key", name="uq_source_filter"),
)
op.create_table(
"source_filter_options",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), nullable=False),
sa.Column("source_value", sa.String(256), nullable=False),
sa.Column("normalized_value", postgresql.JSONB),
sa.Column("operator", sa.String(16)),
sa.Column("unit", sa.String(32)),
sa.UniqueConstraint("filter_id", "source_value", name="uq_filter_option"),
)
op.create_table(
"source_filter_ranges",
sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), primary_key=True),
sa.Column("minimum", sa.Numeric(12, 2)),
sa.Column("maximum", sa.Numeric(12, 2)),
sa.Column("step", sa.Numeric(12, 2)),
sa.Column("unit", sa.String(32)),
)
op.create_table(
"criterion_definitions",
sa.Column("key", sa.String(128), primary_key=True),
sa.Column("value_type", sa.String(32), nullable=False),
sa.Column("unit", sa.String(32)),
sa.Column("semantic", sa.String(128)),
sa.Column("description", sa.Text()),
)
op.create_table(
"criterion_mappings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("criterion_key", sa.String(128), sa.ForeignKey("criterion_definitions.key"), nullable=False),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
sa.Column("external_filter_key", sa.String(64), nullable=False),
sa.Column("source_value", sa.String(256)),
sa.Column("encoding", postgresql.JSONB, nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("definition_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"discovery_searches",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
sa.Column("criteria_version", sa.Integer(), nullable=False),
sa.Column("mapping_version", sa.String(64), nullable=False),
sa.Column("generated_url", sa.Text(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("broad_result_threshold", sa.Integer(), nullable=False, server_default="300"),
sa.Column("next_run_at", sa.DateTime(timezone=True)),
sa.Column("last_successful_run_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"search_runs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("discovery_search_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("discovery_searches.id"), nullable=False),
sa.Column("criteria_version", sa.Integer(), nullable=False),
sa.Column("mapping_version", sa.String(64), nullable=False),
sa.Column("generated_url", sa.Text(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("page_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("http_status_summary", postgresql.JSONB, nullable=False),
sa.Column("error_code", sa.String(64)),
)
op.create_table(
"source_product_references",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
sa.Column("source_external_id", sa.String(256)),
sa.Column("source_url", sa.Text()),
sa.Column("source_title", sa.String(512), nullable=False),
sa.Column("manufacturer", sa.String(256)),
sa.Column("model", sa.String(256)),
sa.Column("manufacturer_sku", sa.String(128)),
sa.Column("gtin_ean", sa.String(32)),
sa.Column("normalized_spec_fingerprint", sa.Text()),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("lifecycle_status", sa.String(32), nullable=False),
sa.Column("match_status", sa.String(32), nullable=False),
sa.Column("consecutive_missing_success_runs", sa.Integer(), nullable=False, server_default="0"),
sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")),
)
op.create_table(
"product_observations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("raw_title", sa.String(512), nullable=False),
sa.Column("best_price", sa.Numeric(12, 2)),
sa.Column("currency", sa.String(3)),
sa.Column("raw_attributes", postgresql.JSONB, nullable=False),
sa.Column("normalized_attributes", postgresql.JSONB, nullable=False),
sa.Column("availability", sa.String(32)),
sa.Column("seller_count", sa.Integer()),
)
op.create_table(
"search_run_items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False),
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
sa.Column("observation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_observations.id")),
sa.Column("diff_status", sa.String(32), nullable=False),
sa.UniqueConstraint("search_run_id", "source_product_reference_id", name="uq_run_item"),
)
op.create_table(
"project_candidates",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False),
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("match_status", sa.String(32), nullable=False),
sa.Column("product_eligibility", sa.String(16)),
sa.Column("product_score", sa.Numeric(6, 2)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("buying_project_id", "source_product_reference_id", name="uq_project_candidate"),
)
op.create_table(
"candidate_status_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("candidate_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("project_candidates.id"), nullable=False),
sa.Column("from_status", sa.String(32)),
sa.Column("to_status", sa.String(32), nullable=False),
sa.Column("reason", sa.String(128)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_discovery_searches_next_run", "discovery_searches", ["next_run_at"])
op.create_index("ix_search_runs_search", "search_runs", ["discovery_search_id", "started_at"])
def downgrade() -> None:
op.drop_table("candidate_status_history")
op.drop_table("project_candidates")
op.drop_table("search_run_items")
op.drop_table("product_observations")
op.drop_table("source_product_references")
op.drop_table("search_runs")
op.drop_table("discovery_searches")
op.drop_table("criterion_mappings")
op.drop_table("criterion_definitions")
op.drop_table("source_filter_ranges")
op.drop_table("source_filter_options")
op.drop_table("source_filter_definitions")
op.drop_table("source_categories")
op.drop_table("buying_project_criteria")
op.drop_table("buying_projects")