Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,686 changes: 1,686 additions & 0 deletions PRPs/PRP-2-data-platform-schema.md

Large diffs are not rendered by default.

28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,25 @@ uv sync
# or: pip install -e ".[dev]"
```

4. **Verify database connectivity**
4. **Run database migrations**

```bash
uv run alembic upgrade head
```

5. **Verify database connectivity**

```bash
uv run python scripts/check_db.py
```

5. **Start the API server**
6. **Start the API server**

```bash
uv run uvicorn app.main:app --reload --port 8123
```

6. **Verify the API is running**
7. **Verify the API is running**

```bash
curl http://localhost:8123/health
Expand Down Expand Up @@ -85,15 +91,27 @@ uv run alembic upgrade head
app/
├── core/ # Config, database, logging, middleware, exceptions
├── shared/ # Pagination, timestamps, error schemas
├── features/ # Vertical slices (ingest, forecasting, etc.)
├── features/
│ └── data_platform/ # Store, product, calendar, sales tables
└── main.py # FastAPI entry point

tests/ # Test fixtures and helpers
alembic/ # Database migrations
examples/ # Runnable examples
examples/
├── schema/ # Table documentation
└── queries/ # Example SQL queries
scripts/ # Utility scripts
```

### Database Schema

The data platform includes 7 tables for retail demand forecasting:

**Dimensions**: `store`, `product`, `calendar`
**Facts**: `sales_daily`, `price_history`, `promotion`, `inventory_snapshot_daily`

See [examples/schema/README.md](examples/schema/README.md) for detailed schema documentation.

## API Documentation

Once the server is running:
Expand Down
3 changes: 3 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from app.core.config import get_settings
from app.core.database import Base

# Import all models for Alembic autogenerate detection
from app.features.data_platform import models as data_platform_models # noqa: F401

# Alembic Config object
config = context.config

Expand Down
185 changes: 185 additions & 0 deletions alembic/versions/e1165ebcef61_create_data_platform_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""create_data_platform_tables

Revision ID: e1165ebcef61
Revises:
Create Date: 2026-01-26 09:57:38.704052

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'e1165ebcef61'
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:
"""Apply migration."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('calendar',
sa.Column('date', sa.Date(), nullable=False),
sa.Column('day_of_week', sa.Integer(), nullable=False),
sa.Column('month', sa.Integer(), nullable=False),
sa.Column('quarter', sa.Integer(), nullable=False),
sa.Column('year', sa.Integer(), nullable=False),
sa.Column('is_holiday', sa.Boolean(), nullable=False),
sa.Column('holiday_name', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('day_of_week >= 0 AND day_of_week <= 6', name='ck_calendar_day_of_week'),
sa.CheckConstraint('month >= 1 AND month <= 12', name='ck_calendar_month'),
sa.CheckConstraint('quarter >= 1 AND quarter <= 4', name='ck_calendar_quarter'),
sa.PrimaryKeyConstraint('date')
)
op.create_index(op.f('ix_calendar_year'), 'calendar', ['year'], unique=False)
op.create_table('product',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sku', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('brand', sa.String(length=100), nullable=True),
sa.Column('base_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('base_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_product_category'), 'product', ['category'], unique=False)
op.create_index(op.f('ix_product_sku'), 'product', ['sku'], unique=True)
op.create_table('store',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('region', sa.String(length=50), nullable=True),
sa.Column('city', sa.String(length=50), nullable=True),
sa.Column('store_type', sa.String(length=30), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_store_code'), 'store', ['code'], unique=True)
op.create_table('inventory_snapshot_daily',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('on_hand_qty', sa.Integer(), nullable=False),
sa.Column('on_order_qty', sa.Integer(), nullable=False),
sa.Column('is_stockout', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('on_hand_qty >= 0', name='ck_inventory_on_hand_positive'),
sa.CheckConstraint('on_order_qty >= 0', name='ck_inventory_on_order_positive'),
sa.ForeignKeyConstraint(['date'], ['calendar.date'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['store_id'], ['store.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date', 'store_id', 'product_id', name='uq_inventory_snapshot_daily_grain')
)
# Note: Single-column index on 'date' is omitted - covered by composite index below
op.create_index(op.f('ix_inventory_snapshot_daily_product_id'), 'inventory_snapshot_daily', ['product_id'], unique=False)
op.create_index(op.f('ix_inventory_snapshot_daily_store_id'), 'inventory_snapshot_daily', ['store_id'], unique=False)
op.create_index('ix_inventory_snapshot_date_store', 'inventory_snapshot_daily', ['date', 'store_id'], unique=False)
op.create_table('price_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('valid_from', sa.Date(), nullable=False),
sa.Column('valid_to', sa.Date(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('price >= 0', name='ck_price_history_price_positive'),
sa.CheckConstraint('valid_to IS NULL OR valid_to >= valid_from', name='ck_price_history_valid_dates'),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['store_id'], ['store.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_price_history_product_id'), 'price_history', ['product_id'], unique=False)
op.create_index('ix_price_history_product_validity', 'price_history', ['product_id', 'valid_from', 'valid_to'], unique=False)
op.create_index(op.f('ix_price_history_store_id'), 'price_history', ['store_id'], unique=False)
op.create_index(op.f('ix_price_history_valid_from'), 'price_history', ['valid_from'], unique=False)
op.create_table('promotion',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('discount_pct', sa.Numeric(precision=5, scale=4), nullable=True),
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('discount_amount IS NULL OR discount_amount >= 0', name='ck_promotion_discount_amount_positive'),
sa.CheckConstraint('discount_pct IS NULL OR (discount_pct >= 0 AND discount_pct <= 1)', name='ck_promotion_discount_pct_range'),
sa.CheckConstraint('end_date >= start_date', name='ck_promotion_valid_dates'),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['store_id'], ['store.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_promotion_product_dates', 'promotion', ['product_id', 'start_date', 'end_date'], unique=False)
op.create_index(op.f('ix_promotion_product_id'), 'promotion', ['product_id'], unique=False)
op.create_index(op.f('ix_promotion_start_date'), 'promotion', ['start_date'], unique=False)
op.create_index(op.f('ix_promotion_store_id'), 'promotion', ['store_id'], unique=False)
op.create_table('sales_daily',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('quantity >= 0', name='ck_sales_daily_quantity_positive'),
sa.CheckConstraint('total_amount >= 0', name='ck_sales_daily_amount_positive'),
sa.CheckConstraint('unit_price >= 0', name='ck_sales_daily_price_positive'),
sa.ForeignKeyConstraint(['date'], ['calendar.date'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['store_id'], ['store.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date', 'store_id', 'product_id', name='uq_sales_daily_grain')
)
# Note: Single-column index on 'date' is omitted - covered by composite indexes below
op.create_index('ix_sales_daily_date_product', 'sales_daily', ['date', 'product_id'], unique=False)
op.create_index('ix_sales_daily_date_store', 'sales_daily', ['date', 'store_id'], unique=False)
op.create_index(op.f('ix_sales_daily_product_id'), 'sales_daily', ['product_id'], unique=False)
op.create_index(op.f('ix_sales_daily_store_id'), 'sales_daily', ['store_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Revert migration."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_sales_daily_store_id'), table_name='sales_daily')
op.drop_index(op.f('ix_sales_daily_product_id'), table_name='sales_daily')
op.drop_index('ix_sales_daily_date_store', table_name='sales_daily')
op.drop_index('ix_sales_daily_date_product', table_name='sales_daily')
op.drop_table('sales_daily')
op.drop_index(op.f('ix_promotion_store_id'), table_name='promotion')
op.drop_index(op.f('ix_promotion_start_date'), table_name='promotion')
op.drop_index(op.f('ix_promotion_product_id'), table_name='promotion')
op.drop_index('ix_promotion_product_dates', table_name='promotion')
op.drop_table('promotion')
op.drop_index(op.f('ix_price_history_valid_from'), table_name='price_history')
op.drop_index(op.f('ix_price_history_store_id'), table_name='price_history')
op.drop_index('ix_price_history_product_validity', table_name='price_history')
op.drop_index(op.f('ix_price_history_product_id'), table_name='price_history')
op.drop_table('price_history')
op.drop_index('ix_inventory_snapshot_date_store', table_name='inventory_snapshot_daily')
op.drop_index(op.f('ix_inventory_snapshot_daily_store_id'), table_name='inventory_snapshot_daily')
op.drop_index(op.f('ix_inventory_snapshot_daily_product_id'), table_name='inventory_snapshot_daily')
op.drop_table('inventory_snapshot_daily')
op.drop_index(op.f('ix_store_code'), table_name='store')
op.drop_table('store')
op.drop_index(op.f('ix_product_sku'), table_name='product')
op.drop_index(op.f('ix_product_category'), table_name='product')
op.drop_table('product')
op.drop_index(op.f('ix_calendar_year'), table_name='calendar')
op.drop_table('calendar')
# ### end Alembic commands ###
26 changes: 26 additions & 0 deletions app/features/data_platform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Data platform feature for retail forecasting mini-warehouse.

This module provides the core data models for the ForecastLabAI system:
- Dimension tables: Store, Product, Calendar
- Fact tables: SalesDaily, PriceHistory, Promotion, InventorySnapshotDaily
"""

from app.features.data_platform.models import (
Calendar,
InventorySnapshotDaily,
PriceHistory,
Product,
Promotion,
SalesDaily,
Store,
)

__all__ = [
"Calendar",
"InventorySnapshotDaily",
"PriceHistory",
"Product",
"Promotion",
"SalesDaily",
"Store",
]
Loading
Loading