major refactoring adding vendor and customer features

This commit is contained in:
2025-10-11 09:09:25 +02:00
parent f569995883
commit dd16198276
126 changed files with 15109 additions and 3747 deletions

View File

@@ -2,9 +2,11 @@
"""Database models package."""
from .base import Base
from .customer import Customer
from .order import Order
from .user import User
from .marketplace_product import MarketplaceProduct
from .stock import Stock
from .inventory import Inventory
from .vendor import Vendor
from .product import Product
from .marketplace_import_job import MarketplaceImportJob
@@ -13,7 +15,9 @@ __all__ = [
"Base",
"User",
"MarketplaceProduct",
"Stock",
"Inventory",
"Customer",
"Order",
"Vendor",
"Product",
"MarketplaceImportJob",

View File

@@ -0,0 +1,64 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Numeric
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class Customer(Base, TimestampMixin):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
email = Column(String(255), nullable=False, index=True) # Unique within vendor scope
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
customer_number = Column(String(100), nullable=False, index=True) # Vendor-specific ID
preferences = Column(JSON, default=dict)
marketing_consent = Column(Boolean, default=False)
last_order_date = Column(DateTime)
total_orders = Column(Integer, default=0)
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="customers")
addresses = relationship("CustomerAddress", back_populates="customer")
orders = relationship("Order", back_populates="customer")
def __repr__(self):
return f"<Customer(id={self.id}, vendor_id={self.vendor_id}, email='{self.email}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerAddress(Base, TimestampMixin):
__tablename__ = "customer_addresses"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
address_type = Column(String(50), nullable=False) # 'billing', 'shipping'
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
company = Column(String(200))
address_line_1 = Column(String(255), nullable=False)
address_line_2 = Column(String(255))
city = Column(String(100), nullable=False)
postal_code = Column(String(20), nullable=False)
country = Column(String(100), nullable=False)
is_default = Column(Boolean, default=False)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="addresses")
def __repr__(self):
return f"<CustomerAddress(id={self.id}, customer_id={self.customer_id}, type='{self.address_type}')>"

View File

@@ -0,0 +1,41 @@
# models/database/inventory.py
from datetime import datetime
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Inventory(Base, TimestampMixin):
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0)
# Optional: Keep GTIN for reference/reporting
gtin = Column(String, index=True)
# Relationships
product = relationship("Product", back_populates="inventory_entries")
vendor = relationship("Vendor")
# Constraints
__table_args__ = (
UniqueConstraint("product_id", "location", name="uq_inventory_product_location"),
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
Index("idx_inventory_product_location", "product_id", "location"),
)
def __repr__(self):
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
@property
def available_quantity(self):
"""Calculate available quantity (total - reserved)."""
return max(0, self.quantity - self.reserved_quantity)

View File

@@ -1,10 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,20 +11,17 @@ class MarketplaceImportJob(Base, TimestampMixin):
__tablename__ = "marketplace_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Import configuration
marketplace = Column(String, nullable=False, index=True, default="Letzshop")
source_url = Column(String, nullable=False)
# Status tracking
status = Column(
String, nullable=False, default="pending"
) # pending, processing, completed, failed, completed_with_errors
source_url = Column(String, nullable=False)
marketplace = Column(
String, nullable=False, index=True, default="Letzshop"
) # Index for marketplace filtering
vendor_name = Column(String, nullable=False, index=True) # Index for vendor filtering
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False
) # Add proper foreign key
user_id = Column(
Integer, ForeignKey("users.id"), nullable=False
) # Foreign key to users table
# Results
imported_count = Column(Integer, default=0)
@@ -35,28 +30,26 @@ class MarketplaceImportJob(Base, TimestampMixin):
total_processed = Column(Integer, default=0)
# Error handling
error_message = Column(String)
error_message = Column(Text)
# Timestamps
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Relationship to user
user = relationship("User", foreign_keys=[user_id])
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
user = relationship("User", foreign_keys=[user_id])
# Additional indexes for marketplace import job queries
# Indexes for performance
__table_args__ = (
Index(
"idx_marketplace_import_user_marketplace", "user_id", "marketplace"
), # User's marketplace imports
Index("idx_marketplace_import_vendor_status", "status"), # Vendor import status
Index("idx_marketplace_import_vendor_id", "vendor_id"),
Index("idx_import_vendor_status", "vendor_id", "status"),
Index("idx_import_vendor_created", "vendor_id", "created_at"),
Index("idx_import_user_marketplace", "user_id", "marketplace"),
)
def __repr__(self):
return (
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', vendor='{self.vendor_name}', "
f"status='{self.status}', imported={self.imported_count})>"
f"<MarketplaceImportJob(id={self.id}, vendor_id={self.vendor_id}, "
f"marketplace='{self.marketplace}', status='{self.status}', "
f"imported={self.imported_count})>"
)

View File

@@ -21,7 +21,7 @@ class MarketplaceProduct(Base, TimestampMixin):
availability = Column(String, index=True) # Index for filtering
price = Column(String)
brand = Column(String, index=True) # Index for filtering
gtin = Column(String, index=True) # Index for stock lookups
gtin = Column(String, index=True) # Index for inventory lookups
mpn = Column(String)
condition = Column(String)
adult = Column(String)
@@ -57,13 +57,6 @@ class MarketplaceProduct(Base, TimestampMixin):
) # Index for marketplace filtering
vendor_name = Column(String, index=True, nullable=True) # Index for vendor filtering
# Relationship to stock (one-to-many via GTIN)
stock_entries = relationship(
"Stock",
foreign_keys="Stock.gtin",
primaryjoin="MarketplaceProduct.gtin == Stock.gtin",
viewonly=True,
)
product = relationship("Product", back_populates="marketplace_product")
# Additional indexes for marketplace queries

86
models/database/order.py Normal file
View File

@@ -0,0 +1,86 @@
# models/database/order.py
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text, Boolean
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Order(Base, TimestampMixin):
"""Customer orders."""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
order_number = Column(String, nullable=False, unique=True, index=True)
# Order status
status = Column(String, nullable=False, default="pending", index=True)
# pending, processing, shipped, delivered, cancelled, refunded
# Financial
subtotal = Column(Float, nullable=False)
tax_amount = Column(Float, default=0.0)
shipping_amount = Column(Float, default=0.0)
discount_amount = Column(Float, default=0.0)
total_amount = Column(Float, nullable=False)
currency = Column(String, default="EUR")
# Addresses (stored as IDs)
shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
billing_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
# Shipping
shipping_method = Column(String, nullable=True)
tracking_number = Column(String, nullable=True)
# Notes
customer_notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True)
# Timestamps
paid_at = Column(DateTime, nullable=True)
shipped_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
cancelled_at = Column(DateTime, nullable=True)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id])
billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id])
def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', status='{self.status}')>"
class OrderItem(Base, TimestampMixin):
"""Individual items in an order."""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Product details at time of order (snapshot)
product_name = Column(String, nullable=False)
product_sku = Column(String, nullable=True)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
total_price = Column(Float, nullable=False)
# Inventory tracking
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product")
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id})>"

View File

@@ -1,13 +1,12 @@
# models/database/product.py
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
__tablename__ = "products"
@@ -15,12 +14,12 @@ class Product(Base, TimestampMixin):
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
# Vendor-specific overrides (can override the main product data)
product_id = Column(String) # Vendor's internal product ID
price = Column(Float) # Override main product price
# Vendor-specific overrides
product_id = Column(String) # Vendor's internal SKU
price = Column(Float)
sale_price = Column(Float)
currency = Column(String)
availability = Column(String) # Override availability
availability = Column(String)
condition = Column(String)
# Vendor-specific metadata
@@ -28,13 +27,14 @@ class Product(Base, TimestampMixin):
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory management
# Inventory settings
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Relationships
vendor = relationship("Vendor", back_populates="product")
vendor = relationship("Vendor", back_populates="products")
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
inventory_entries = relationship("Inventory", back_populates="product", cascade="all, delete-orphan")
# Constraints
__table_args__ = (
@@ -42,3 +42,16 @@ class Product(Base, TimestampMixin):
Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "vendor_id", "is_featured"),
)
def __repr__(self):
return f"<Product(id={self.id}, vendor_id={self.vendor_id}, product_id='{self.product_id}')>"
@property
def total_inventory(self):
"""Calculate total inventory across all locations."""
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self):
"""Calculate available inventory (total - reserved)."""
return sum(inv.available_quantity for inv in self.inventory_entries)

View File

@@ -1,35 +0,0 @@
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Stock(Base, TimestampMixin):
__tablename__ = "stock"
id = Column(Integer, primary_key=True, index=True)
gtin = Column(
String, index=True, nullable=False
) # Foreign key relationship would be ideal
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) # For orders being processed
vendor_id = Column(Integer, ForeignKey("vendors.id")) # Optional: vendor -specific stock
# Relationships
vendor = relationship("Vendor")
# Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = (
UniqueConstraint("gtin", "location", name="uq_stock_gtin_location"),
Index(
"idx_stock_gtin_location", "gtin", "location"
), # Composite index for efficient queries
)
def __repr__(self):
return f"<Stock(gtin='{self.gtin}', location='{self.location}', quantity={self.quantity})>"

View File

@@ -14,6 +14,8 @@ class User(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # user, admin, vendor_owner
is_active = Column(Boolean, default=True, nullable=False)
@@ -23,7 +25,16 @@ class User(Base, TimestampMixin):
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"
)
# Vendor relationships
owned_vendors = relationship("Vendor", back_populates="owner")
vendor_memberships = relationship("VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username

View File

@@ -1,7 +1,6 @@
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text)
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,15 +12,22 @@ class Vendor(Base, TimestampMixin):
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB"
vendor_name = Column(String, nullable=False) # Display name
subdomain = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String, nullable=False) # Display name
description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
theme_config = Column(JSON, default=dict)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
website = Column(String)
# Letzshop URLs - multi-language support
letzshop_csv_url_fr = Column(String)
letzshop_csv_url_en = Column(String)
letzshop_csv_url_de = Column(String)
# Business information
business_address = Column(Text)
tax_number = Column(String)
@@ -32,7 +38,47 @@ class Vendor(Base, TimestampMixin):
# Relationships
owner = relationship("User", back_populates="owned_vendors")
product = relationship("Product", back_populates="vendor")
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="vendor"
)
vendor_users = relationship("VendorUser", back_populates="vendor")
products = relationship("Product", back_populates="vendor")
customers = relationship("Customer", back_populates="vendor")
orders = relationship("Order", back_populates="vendor")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
def __repr__(self):
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
invited_by = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
user = relationship("User", foreign_keys=[user_id], back_populates="vendor_memberships")
inviter = relationship("User", foreign_keys=[invited_by])
role = relationship("Role", back_populates="vendor_users")
def __repr__(self):
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id})>"
class Role(Base, TimestampMixin):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
name = Column(String(100), nullable=False) # "Owner", "Manager", "Editor", "Viewer"
permissions = Column(JSON, default=list) # ["products.create", "orders.view", etc.]
# Relationships
vendor = relationship("Vendor")
vendor_users = relationship("VendorUser", back_populates="role")
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"