refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,82 +1,23 @@
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
# models/database/customer.py
|
||||
"""
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
The canonical implementation is now in:
|
||||
app/modules/customers/models/customer.py
|
||||
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.customers.models import Customer, CustomerAddress
|
||||
"""
|
||||
|
||||
from app.modules.customers.models.customer import (
|
||||
Customer,
|
||||
CustomerAddress,
|
||||
)
|
||||
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)
|
||||
|
||||
# Language preference (NULL = use vendor storefront_language default)
|
||||
# Supported: en, fr, de, lb
|
||||
preferred_language = Column(String(5), nullable=True)
|
||||
|
||||
# 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_name = Column(String(100), nullable=False)
|
||||
country_iso = Column(String(5), 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}')>"
|
||||
__all__ = [
|
||||
"Customer",
|
||||
"CustomerAddress",
|
||||
]
|
||||
|
||||
@@ -1,61 +1,19 @@
|
||||
# models/database/inventory.py
|
||||
"""
|
||||
Inventory model for tracking stock at warehouse/bin locations.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
Each entry represents a quantity of a product at a specific bin location
|
||||
within a warehouse. Products can be scattered across multiple bins.
|
||||
The canonical implementation is now in:
|
||||
app/modules/inventory/models/inventory.py
|
||||
|
||||
Example:
|
||||
Warehouse: "strassen"
|
||||
Bin: "SA-10-02"
|
||||
Product: GTIN 4007817144145
|
||||
Quantity: 3
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.inventory.models import Inventory
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
|
||||
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: warehouse + bin
|
||||
warehouse = Column(String, nullable=False, default="strassen", index=True)
|
||||
bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02"
|
||||
|
||||
# Legacy field - kept for backward compatibility, will be removed
|
||||
location = Column(String, index=True)
|
||||
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
reserved_quantity = Column(Integer, default=0)
|
||||
|
||||
# Keep GTIN for reference/reporting (matches Product.gtin)
|
||||
gtin = Column(String, index=True)
|
||||
|
||||
# Relationships
|
||||
product = relationship("Product", back_populates="inventory_entries")
|
||||
vendor = relationship("Vendor")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "warehouse", "bin_location", name="uq_inventory_product_warehouse_bin"
|
||||
),
|
||||
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
|
||||
Index("idx_inventory_warehouse_bin", "warehouse", "bin_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)
|
||||
__all__ = [
|
||||
"Inventory",
|
||||
]
|
||||
|
||||
@@ -1,170 +1,23 @@
|
||||
# models/database/inventory_transaction.py
|
||||
"""
|
||||
Inventory Transaction Model - Audit trail for all stock movements.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
This model tracks every change to inventory quantities, providing:
|
||||
- Complete audit trail for compliance and debugging
|
||||
- Order-linked transactions for traceability
|
||||
- Support for different transaction types (reserve, fulfill, adjust, etc.)
|
||||
The canonical implementation is now in:
|
||||
app/modules/inventory/models/inventory_transaction.py
|
||||
|
||||
All stock movements should create a transaction record.
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.inventory.models import InventoryTransaction, TransactionType
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Enum as SQLEnum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
from app.modules.inventory.models.inventory_transaction import (
|
||||
InventoryTransaction,
|
||||
TransactionType,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, Enum):
|
||||
"""Types of inventory transactions."""
|
||||
|
||||
# Order-related
|
||||
RESERVE = "reserve" # Stock reserved for order
|
||||
FULFILL = "fulfill" # Reserved stock consumed (shipped)
|
||||
RELEASE = "release" # Reserved stock released (cancelled)
|
||||
|
||||
# Manual adjustments
|
||||
ADJUST = "adjust" # Manual adjustment (+/-)
|
||||
SET = "set" # Set to exact quantity
|
||||
|
||||
# Imports
|
||||
IMPORT = "import" # Initial import/sync
|
||||
|
||||
# Returns
|
||||
RETURN = "return" # Stock returned from customer
|
||||
|
||||
|
||||
class InventoryTransaction(Base):
|
||||
"""
|
||||
Audit log for inventory movements.
|
||||
|
||||
Every change to inventory quantity creates a transaction record,
|
||||
enabling complete traceability of stock levels over time.
|
||||
"""
|
||||
|
||||
__tablename__ = "inventory_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Core references
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
inventory_id = Column(
|
||||
Integer, ForeignKey("inventory.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# Transaction details
|
||||
transaction_type = Column(
|
||||
SQLEnum(TransactionType), nullable=False, index=True
|
||||
)
|
||||
quantity_change = Column(Integer, nullable=False) # Positive = add, negative = remove
|
||||
|
||||
# Quantities after transaction (snapshot)
|
||||
quantity_after = Column(Integer, nullable=False)
|
||||
reserved_after = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Location context
|
||||
location = Column(String, nullable=True)
|
||||
warehouse = Column(String, nullable=True)
|
||||
|
||||
# Order reference (for order-related transactions)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
|
||||
order_number = Column(String, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
reason = Column(Text, nullable=True) # Human-readable reason
|
||||
created_by = Column(String, nullable=True) # User/system that created
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor")
|
||||
product = relationship("Product")
|
||||
inventory = relationship("Inventory")
|
||||
order = relationship("Order")
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("idx_inv_tx_vendor_product", "vendor_id", "product_id"),
|
||||
Index("idx_inv_tx_vendor_created", "vendor_id", "created_at"),
|
||||
Index("idx_inv_tx_order", "order_id"),
|
||||
Index("idx_inv_tx_type_created", "transaction_type", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<InventoryTransaction {self.id}: "
|
||||
f"{self.transaction_type.value} {self.quantity_change:+d} "
|
||||
f"for product {self.product_id}>"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_transaction(
|
||||
cls,
|
||||
vendor_id: int,
|
||||
product_id: int,
|
||||
transaction_type: TransactionType,
|
||||
quantity_change: int,
|
||||
quantity_after: int,
|
||||
reserved_after: int = 0,
|
||||
inventory_id: int | None = None,
|
||||
location: str | None = None,
|
||||
warehouse: str | None = None,
|
||||
order_id: int | None = None,
|
||||
order_number: str | None = None,
|
||||
reason: str | None = None,
|
||||
created_by: str | None = None,
|
||||
) -> "InventoryTransaction":
|
||||
"""
|
||||
Factory method to create a transaction record.
|
||||
|
||||
Args:
|
||||
vendor_id: Vendor ID
|
||||
product_id: Product ID
|
||||
transaction_type: Type of transaction
|
||||
quantity_change: Change in quantity (positive = add, negative = remove)
|
||||
quantity_after: Total quantity after this transaction
|
||||
reserved_after: Reserved quantity after this transaction
|
||||
inventory_id: Optional inventory record ID
|
||||
location: Optional location
|
||||
warehouse: Optional warehouse
|
||||
order_id: Optional order ID (for order-related transactions)
|
||||
order_number: Optional order number for display
|
||||
reason: Optional human-readable reason
|
||||
created_by: Optional user/system identifier
|
||||
|
||||
Returns:
|
||||
InventoryTransaction instance (not yet added to session)
|
||||
"""
|
||||
return cls(
|
||||
vendor_id=vendor_id,
|
||||
product_id=product_id,
|
||||
inventory_id=inventory_id,
|
||||
transaction_type=transaction_type,
|
||||
quantity_change=quantity_change,
|
||||
quantity_after=quantity_after,
|
||||
reserved_after=reserved_after,
|
||||
location=location,
|
||||
warehouse=warehouse,
|
||||
order_id=order_id,
|
||||
order_number=order_number,
|
||||
reason=reason,
|
||||
created_by=created_by,
|
||||
)
|
||||
__all__ = [
|
||||
"InventoryTransaction",
|
||||
"TransactionType",
|
||||
]
|
||||
|
||||
@@ -1,215 +1,27 @@
|
||||
# models/database/invoice.py
|
||||
"""
|
||||
Invoice database models for the OMS.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
Provides models for:
|
||||
- VendorInvoiceSettings: Per-vendor invoice configuration (company details, VAT, numbering)
|
||||
- Invoice: Invoice records with snapshots of seller/buyer details
|
||||
The canonical implementation is now in:
|
||||
app/modules/orders/models/invoice.py
|
||||
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.orders.models import Invoice, InvoiceStatus, VATRegime, VendorInvoiceSettings
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
from app.modules.orders.models.invoice import (
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
VATRegime,
|
||||
VendorInvoiceSettings,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorInvoiceSettings(Base, TimestampMixin):
|
||||
"""
|
||||
Per-vendor invoice configuration.
|
||||
|
||||
Stores company details, VAT number, invoice numbering preferences,
|
||||
and payment information for invoice generation.
|
||||
|
||||
One-to-one relationship with Vendor.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_invoice_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
|
||||
)
|
||||
|
||||
# Legal company details for invoice header
|
||||
company_name = Column(String(255), nullable=False) # Legal name for invoices
|
||||
company_address = Column(String(255), nullable=True) # Street address
|
||||
company_city = Column(String(100), nullable=True)
|
||||
company_postal_code = Column(String(20), nullable=True)
|
||||
company_country = Column(String(2), nullable=False, default="LU") # ISO country code
|
||||
|
||||
# VAT information
|
||||
vat_number = Column(String(50), nullable=True) # e.g., "LU12345678"
|
||||
is_vat_registered = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# OSS (One-Stop-Shop) for EU VAT
|
||||
is_oss_registered = Column(Boolean, default=False, nullable=False)
|
||||
oss_registration_country = Column(String(2), nullable=True) # ISO country code
|
||||
|
||||
# Invoice numbering
|
||||
invoice_prefix = Column(String(20), default="INV", nullable=False)
|
||||
invoice_next_number = Column(Integer, default=1, nullable=False)
|
||||
invoice_number_padding = Column(Integer, default=5, nullable=False) # e.g., INV00001
|
||||
|
||||
# Payment information
|
||||
payment_terms = Column(Text, nullable=True) # e.g., "Payment due within 30 days"
|
||||
bank_name = Column(String(255), nullable=True)
|
||||
bank_iban = Column(String(50), nullable=True)
|
||||
bank_bic = Column(String(20), nullable=True)
|
||||
|
||||
# Invoice footer
|
||||
footer_text = Column(Text, nullable=True) # Custom footer text
|
||||
|
||||
# Default VAT rate for Luxembourg invoices (17% standard)
|
||||
default_vat_rate = Column(Numeric(5, 2), default=17.00, nullable=False)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="invoice_settings")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VendorInvoiceSettings(vendor_id={self.vendor_id}, company='{self.company_name}')>"
|
||||
|
||||
def get_next_invoice_number(self) -> str:
|
||||
"""Generate the next invoice number and increment counter."""
|
||||
number = str(self.invoice_next_number).zfill(self.invoice_number_padding)
|
||||
return f"{self.invoice_prefix}{number}"
|
||||
|
||||
|
||||
class InvoiceStatus(str, enum.Enum):
|
||||
"""Invoice status enumeration."""
|
||||
|
||||
DRAFT = "draft"
|
||||
ISSUED = "issued"
|
||||
PAID = "paid"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class VATRegime(str, enum.Enum):
|
||||
"""VAT regime for invoice calculation."""
|
||||
|
||||
DOMESTIC = "domestic" # Same country as seller
|
||||
OSS = "oss" # EU cross-border with OSS registration
|
||||
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
|
||||
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
|
||||
EXEMPT = "exempt" # VAT exempt
|
||||
|
||||
|
||||
class Invoice(Base, TimestampMixin):
|
||||
"""
|
||||
Invoice record with snapshots of seller/buyer details.
|
||||
|
||||
Stores complete invoice data including snapshots of seller and buyer
|
||||
details at time of creation for audit purposes.
|
||||
"""
|
||||
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
|
||||
|
||||
# Invoice identification
|
||||
invoice_number = Column(String(50), nullable=False)
|
||||
invoice_date = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default=InvoiceStatus.DRAFT.value, nullable=False)
|
||||
|
||||
# Seller details snapshot (captured at invoice creation)
|
||||
seller_details = Column(JSON, nullable=False)
|
||||
# Structure: {
|
||||
# "company_name": str,
|
||||
# "address": str,
|
||||
# "city": str,
|
||||
# "postal_code": str,
|
||||
# "country": str,
|
||||
# "vat_number": str | None
|
||||
# }
|
||||
|
||||
# Buyer details snapshot (captured at invoice creation)
|
||||
buyer_details = Column(JSON, nullable=False)
|
||||
# Structure: {
|
||||
# "name": str,
|
||||
# "email": str,
|
||||
# "address": str,
|
||||
# "city": str,
|
||||
# "postal_code": str,
|
||||
# "country": str,
|
||||
# "vat_number": str | None (for B2B)
|
||||
# }
|
||||
|
||||
# Line items snapshot
|
||||
line_items = Column(JSON, nullable=False)
|
||||
# Structure: [{
|
||||
# "description": str,
|
||||
# "quantity": int,
|
||||
# "unit_price_cents": int,
|
||||
# "total_cents": int,
|
||||
# "sku": str | None,
|
||||
# "ean": str | None
|
||||
# }]
|
||||
|
||||
# VAT information
|
||||
vat_regime = Column(String(20), default=VATRegime.DOMESTIC.value, nullable=False)
|
||||
destination_country = Column(String(2), nullable=True) # For OSS invoices
|
||||
vat_rate = Column(Numeric(5, 2), nullable=False) # e.g., 17.00 for 17%
|
||||
vat_rate_label = Column(String(50), nullable=True) # e.g., "Luxembourg Standard VAT"
|
||||
|
||||
# Amounts (stored in cents for precision)
|
||||
currency = Column(String(3), default="EUR", nullable=False)
|
||||
subtotal_cents = Column(Integer, nullable=False) # Before VAT
|
||||
vat_amount_cents = Column(Integer, nullable=False) # VAT amount
|
||||
total_cents = Column(Integer, nullable=False) # After VAT
|
||||
|
||||
# Payment information
|
||||
payment_terms = Column(Text, nullable=True)
|
||||
bank_details = Column(JSON, nullable=True) # IBAN, BIC snapshot
|
||||
footer_text = Column(Text, nullable=True)
|
||||
|
||||
# PDF storage
|
||||
pdf_generated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
pdf_path = Column(String(500), nullable=True) # Path to stored PDF
|
||||
|
||||
# Notes
|
||||
notes = Column(Text, nullable=True) # Internal notes
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="invoices")
|
||||
order = relationship("Order", back_populates="invoices")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_invoice_vendor_number", "vendor_id", "invoice_number", unique=True),
|
||||
Index("idx_invoice_vendor_date", "vendor_id", "invoice_date"),
|
||||
Index("idx_invoice_status", "vendor_id", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Invoice(id={self.id}, number='{self.invoice_number}', status='{self.status}')>"
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
"""Get subtotal in EUR."""
|
||||
return self.subtotal_cents / 100
|
||||
|
||||
@property
|
||||
def vat_amount(self) -> float:
|
||||
"""Get VAT amount in EUR."""
|
||||
return self.vat_amount_cents / 100
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
"""Get total in EUR."""
|
||||
return self.total_cents / 100
|
||||
__all__ = [
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
"VATRegime",
|
||||
"VendorInvoiceSettings",
|
||||
]
|
||||
|
||||
@@ -1,272 +1,31 @@
|
||||
# models/database/message.py
|
||||
"""
|
||||
Messaging system database models.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
Supports three communication channels:
|
||||
- Admin <-> Vendor
|
||||
- Vendor <-> Customer
|
||||
- Admin <-> Customer
|
||||
The canonical implementation is now in:
|
||||
app/modules/messaging/models/message.py
|
||||
|
||||
Multi-tenant isolation is enforced via vendor_id for conversations
|
||||
involving customers.
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.messaging.models import message
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
from app.modules.messaging.models.message import (
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
ConversationType,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
ParticipantType,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ConversationType(str, enum.Enum):
|
||||
"""Defines the three supported conversation channels."""
|
||||
|
||||
ADMIN_VENDOR = "admin_vendor"
|
||||
VENDOR_CUSTOMER = "vendor_customer"
|
||||
ADMIN_CUSTOMER = "admin_customer"
|
||||
|
||||
|
||||
class ParticipantType(str, enum.Enum):
|
||||
"""Type of participant in a conversation."""
|
||||
|
||||
ADMIN = "admin" # User with role="admin"
|
||||
VENDOR = "vendor" # User with role="vendor" (via VendorUser)
|
||||
CUSTOMER = "customer" # Customer model
|
||||
|
||||
|
||||
def _enum_values(enum_class):
|
||||
"""Extract enum values for SQLAlchemy Enum column."""
|
||||
return [e.value for e in enum_class]
|
||||
|
||||
|
||||
class Conversation(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a threaded conversation between participants.
|
||||
|
||||
Multi-tenancy: vendor_id is required for vendor_customer and admin_customer
|
||||
conversations to ensure customer data isolation.
|
||||
"""
|
||||
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Conversation type determines participant structure
|
||||
conversation_type = Column(
|
||||
Enum(ConversationType, values_callable=_enum_values),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Subject line for the conversation thread
|
||||
subject = Column(String(500), nullable=False)
|
||||
|
||||
# For vendor_customer and admin_customer conversations
|
||||
# Required for multi-tenant data isolation
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Status flags
|
||||
is_closed = Column(Boolean, default=False, nullable=False)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
closed_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
|
||||
closed_by_id = Column(Integer, nullable=True)
|
||||
|
||||
# Last activity tracking for sorting
|
||||
last_message_at = Column(DateTime, nullable=True, index=True)
|
||||
message_count = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
participants = relationship(
|
||||
"ConversationParticipant",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
messages = relationship(
|
||||
"Message",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Message.created_at",
|
||||
)
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("ix_conversations_type_vendor", "conversation_type", "vendor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Conversation(id={self.id}, type='{self.conversation_type.value}', "
|
||||
f"subject='{self.subject[:30]}...')>"
|
||||
)
|
||||
|
||||
|
||||
class ConversationParticipant(Base, TimestampMixin):
|
||||
"""
|
||||
Links participants (users or customers) to conversations.
|
||||
|
||||
Polymorphic relationship:
|
||||
- participant_type="admin" or "vendor": references users.id
|
||||
- participant_type="customer": references customers.id
|
||||
"""
|
||||
|
||||
__tablename__ = "conversation_participants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(
|
||||
Integer,
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Polymorphic participant reference
|
||||
participant_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
|
||||
participant_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# For vendor participants, track which vendor they represent
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Unread tracking per participant
|
||||
unread_count = Column(Integer, default=0, nullable=False)
|
||||
last_read_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Notification preferences for this conversation
|
||||
email_notifications = Column(Boolean, default=True, nullable=False)
|
||||
muted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="participants")
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"conversation_id",
|
||||
"participant_type",
|
||||
"participant_id",
|
||||
name="uq_conversation_participant",
|
||||
),
|
||||
Index(
|
||||
"ix_participant_lookup",
|
||||
"participant_type",
|
||||
"participant_id",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ConversationParticipant(conversation_id={self.conversation_id}, "
|
||||
f"type='{self.participant_type.value}', id={self.participant_id})>"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base, TimestampMixin):
|
||||
"""
|
||||
Individual message within a conversation thread.
|
||||
|
||||
Sender polymorphism follows same pattern as participant.
|
||||
"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(
|
||||
Integer,
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Polymorphic sender reference
|
||||
sender_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
|
||||
sender_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# Message content
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
# System messages (e.g., "conversation closed")
|
||||
is_system_message = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Soft delete for moderation
|
||||
is_deleted = Column(Boolean, default=False, nullable=False)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
deleted_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
|
||||
deleted_by_id = Column(Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
attachments = relationship(
|
||||
"MessageAttachment",
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Message(id={self.id}, conversation_id={self.conversation_id}, "
|
||||
f"sender={self.sender_type.value}:{self.sender_id})>"
|
||||
)
|
||||
|
||||
|
||||
class MessageAttachment(Base, TimestampMixin):
|
||||
"""
|
||||
File attachments for messages.
|
||||
|
||||
Files are stored in platform storage (local/S3) with references here.
|
||||
"""
|
||||
|
||||
__tablename__ = "message_attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(
|
||||
Integer,
|
||||
ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# File metadata
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_path = Column(String(1000), nullable=False) # Storage path
|
||||
file_size = Column(Integer, nullable=False) # Size in bytes
|
||||
mime_type = Column(String(100), nullable=False)
|
||||
|
||||
# For image attachments
|
||||
is_image = Column(Boolean, default=False, nullable=False)
|
||||
image_width = Column(Integer, nullable=True)
|
||||
image_height = Column(Integer, nullable=True)
|
||||
thumbnail_path = Column(String(1000), nullable=True)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="attachments")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MessageAttachment(id={self.id}, filename='{self.original_filename}')>"
|
||||
__all__ = [
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
"ConversationType",
|
||||
"Message",
|
||||
"MessageAttachment",
|
||||
"ParticipantType",
|
||||
]
|
||||
|
||||
@@ -1,406 +1,20 @@
|
||||
# models/database/order.py
|
||||
"""
|
||||
Unified Order model for all sales channels.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
Supports:
|
||||
- Direct orders (from vendor's own storefront)
|
||||
- Marketplace orders (Letzshop, etc.)
|
||||
The canonical implementation is now in:
|
||||
app/modules/orders/models/order.py
|
||||
|
||||
Design principles:
|
||||
- Customer/address data is snapshotted at order time (preserves history)
|
||||
- customer_id FK links to Customer record (may be inactive for marketplace imports)
|
||||
- channel field distinguishes order source
|
||||
- external_* fields store marketplace-specific references
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
||||
See docs/architecture/money-handling.md for details.
|
||||
from app.modules.orders.models import Order, OrderItem
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from typing import TYPE_CHECKING
|
||||
from app.modules.orders.models.order import Order, OrderItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.database.order_item_exception import OrderItemException
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.utils.money import cents_to_euros, euros_to_cents
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Order(Base, TimestampMixin):
|
||||
"""
|
||||
Unified order model for all sales channels.
|
||||
|
||||
Stores orders from direct sales and marketplaces (Letzshop, etc.)
|
||||
with snapshotted customer and address data.
|
||||
|
||||
All monetary amounts are stored as integer cents for precision.
|
||||
"""
|
||||
|
||||
__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(100), nullable=False, unique=True, index=True)
|
||||
|
||||
# === Channel/Source ===
|
||||
channel = Column(
|
||||
String(50), default="direct", nullable=False, index=True
|
||||
) # direct, letzshop
|
||||
|
||||
# External references (for marketplace orders)
|
||||
external_order_id = Column(
|
||||
String(100), nullable=True, index=True
|
||||
) # Marketplace order ID
|
||||
external_shipment_id = Column(
|
||||
String(100), nullable=True, index=True
|
||||
) # Marketplace shipment ID
|
||||
external_order_number = Column(String(100), nullable=True) # Marketplace order #
|
||||
external_data = Column(JSON, nullable=True) # Raw marketplace data for debugging
|
||||
|
||||
# === Status ===
|
||||
# pending: awaiting confirmation
|
||||
# processing: confirmed, being prepared
|
||||
# shipped: shipped with tracking
|
||||
# delivered: delivered to customer
|
||||
# cancelled: order cancelled/declined
|
||||
# refunded: order refunded
|
||||
status = Column(String(50), nullable=False, default="pending", index=True)
|
||||
|
||||
# === Financials (stored as integer cents) ===
|
||||
subtotal_cents = Column(Integer, nullable=True) # May not be available from marketplace
|
||||
tax_amount_cents = Column(Integer, nullable=True)
|
||||
shipping_amount_cents = Column(Integer, nullable=True)
|
||||
discount_amount_cents = Column(Integer, nullable=True)
|
||||
total_amount_cents = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), default="EUR")
|
||||
|
||||
# === VAT Information ===
|
||||
# VAT regime: domestic, oss, reverse_charge, origin, exempt
|
||||
vat_regime = Column(String(20), nullable=True)
|
||||
# VAT rate as percentage (e.g., 17.00 for 17%)
|
||||
vat_rate = Column(Numeric(5, 2), nullable=True)
|
||||
# Human-readable VAT label (e.g., "Luxembourg VAT 17%")
|
||||
vat_rate_label = Column(String(100), nullable=True)
|
||||
# Destination country for cross-border sales (ISO code)
|
||||
vat_destination_country = Column(String(2), nullable=True)
|
||||
|
||||
# === Customer Snapshot (preserved at order time) ===
|
||||
customer_first_name = Column(String(100), nullable=False)
|
||||
customer_last_name = Column(String(100), nullable=False)
|
||||
customer_email = Column(String(255), nullable=False)
|
||||
customer_phone = Column(String(50), nullable=True)
|
||||
customer_locale = Column(String(10), nullable=True) # en, fr, de, lb
|
||||
|
||||
# === Shipping Address Snapshot ===
|
||||
ship_first_name = Column(String(100), nullable=False)
|
||||
ship_last_name = Column(String(100), nullable=False)
|
||||
ship_company = Column(String(200), nullable=True)
|
||||
ship_address_line_1 = Column(String(255), nullable=False)
|
||||
ship_address_line_2 = Column(String(255), nullable=True)
|
||||
ship_city = Column(String(100), nullable=False)
|
||||
ship_postal_code = Column(String(20), nullable=False)
|
||||
ship_country_iso = Column(String(5), nullable=False)
|
||||
|
||||
# === Billing Address Snapshot ===
|
||||
bill_first_name = Column(String(100), nullable=False)
|
||||
bill_last_name = Column(String(100), nullable=False)
|
||||
bill_company = Column(String(200), nullable=True)
|
||||
bill_address_line_1 = Column(String(255), nullable=False)
|
||||
bill_address_line_2 = Column(String(255), nullable=True)
|
||||
bill_city = Column(String(100), nullable=False)
|
||||
bill_postal_code = Column(String(20), nullable=False)
|
||||
bill_country_iso = Column(String(5), nullable=False)
|
||||
|
||||
# === Tracking ===
|
||||
shipping_method = Column(String(100), nullable=True)
|
||||
tracking_number = Column(String(100), nullable=True)
|
||||
tracking_provider = Column(String(100), nullable=True)
|
||||
tracking_url = Column(String(500), nullable=True) # Full tracking URL
|
||||
shipment_number = Column(String(100), nullable=True) # Carrier shipment number (e.g., H74683403433)
|
||||
shipping_carrier = Column(String(50), nullable=True) # Carrier code (greco, colissimo, etc.)
|
||||
|
||||
# === Notes ===
|
||||
customer_notes = Column(Text, nullable=True)
|
||||
internal_notes = Column(Text, nullable=True)
|
||||
|
||||
# === Timestamps ===
|
||||
order_date = Column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
) # When customer placed order
|
||||
confirmed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
shipped_at = Column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# === Relationships ===
|
||||
vendor = relationship("Vendor")
|
||||
customer = relationship("Customer", back_populates="orders")
|
||||
items = relationship(
|
||||
"OrderItem", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
invoices = relationship(
|
||||
"Invoice", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Composite indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("idx_order_vendor_status", "vendor_id", "status"),
|
||||
Index("idx_order_vendor_channel", "vendor_id", "channel"),
|
||||
Index("idx_order_vendor_date", "vendor_id", "order_date"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>"
|
||||
|
||||
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float | None:
|
||||
"""Get subtotal in euros."""
|
||||
if self.subtotal_cents is not None:
|
||||
return cents_to_euros(self.subtotal_cents)
|
||||
return None
|
||||
|
||||
@subtotal.setter
|
||||
def subtotal(self, value: float | None):
|
||||
"""Set subtotal from euros."""
|
||||
self.subtotal_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def tax_amount(self) -> float | None:
|
||||
"""Get tax amount in euros."""
|
||||
if self.tax_amount_cents is not None:
|
||||
return cents_to_euros(self.tax_amount_cents)
|
||||
return None
|
||||
|
||||
@tax_amount.setter
|
||||
def tax_amount(self, value: float | None):
|
||||
"""Set tax amount from euros."""
|
||||
self.tax_amount_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def shipping_amount(self) -> float | None:
|
||||
"""Get shipping amount in euros."""
|
||||
if self.shipping_amount_cents is not None:
|
||||
return cents_to_euros(self.shipping_amount_cents)
|
||||
return None
|
||||
|
||||
@shipping_amount.setter
|
||||
def shipping_amount(self, value: float | None):
|
||||
"""Set shipping amount from euros."""
|
||||
self.shipping_amount_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def discount_amount(self) -> float | None:
|
||||
"""Get discount amount in euros."""
|
||||
if self.discount_amount_cents is not None:
|
||||
return cents_to_euros(self.discount_amount_cents)
|
||||
return None
|
||||
|
||||
@discount_amount.setter
|
||||
def discount_amount(self, value: float | None):
|
||||
"""Set discount amount from euros."""
|
||||
self.discount_amount_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
"""Get total amount in euros."""
|
||||
return cents_to_euros(self.total_amount_cents)
|
||||
|
||||
@total_amount.setter
|
||||
def total_amount(self, value: float):
|
||||
"""Set total amount from euros."""
|
||||
self.total_amount_cents = euros_to_cents(value)
|
||||
|
||||
# === NAME PROPERTIES ===
|
||||
|
||||
@property
|
||||
def customer_full_name(self) -> str:
|
||||
"""Customer full name from snapshot."""
|
||||
return f"{self.customer_first_name} {self.customer_last_name}".strip()
|
||||
|
||||
@property
|
||||
def ship_full_name(self) -> str:
|
||||
"""Shipping address full name."""
|
||||
return f"{self.ship_first_name} {self.ship_last_name}".strip()
|
||||
|
||||
@property
|
||||
def bill_full_name(self) -> str:
|
||||
"""Billing address full name."""
|
||||
return f"{self.bill_first_name} {self.bill_last_name}".strip()
|
||||
|
||||
@property
|
||||
def is_marketplace_order(self) -> bool:
|
||||
"""Check if this is a marketplace order."""
|
||||
return self.channel != "direct"
|
||||
|
||||
@property
|
||||
def is_fully_shipped(self) -> bool:
|
||||
"""Check if all items are fully shipped."""
|
||||
if not self.items:
|
||||
return False
|
||||
return all(item.is_fully_shipped for item in self.items)
|
||||
|
||||
@property
|
||||
def is_partially_shipped(self) -> bool:
|
||||
"""Check if some items are shipped but not all."""
|
||||
if not self.items:
|
||||
return False
|
||||
has_shipped = any(item.shipped_quantity > 0 for item in self.items)
|
||||
all_shipped = all(item.is_fully_shipped for item in self.items)
|
||||
return has_shipped and not all_shipped
|
||||
|
||||
@property
|
||||
def shipped_item_count(self) -> int:
|
||||
"""Count of fully shipped items."""
|
||||
return sum(1 for item in self.items if item.is_fully_shipped)
|
||||
|
||||
@property
|
||||
def total_shipped_units(self) -> int:
|
||||
"""Total quantity shipped across all items."""
|
||||
return sum(item.shipped_quantity for item in self.items)
|
||||
|
||||
@property
|
||||
def total_ordered_units(self) -> int:
|
||||
"""Total quantity ordered across all items."""
|
||||
return sum(item.quantity for item in self.items)
|
||||
|
||||
|
||||
class OrderItem(Base, TimestampMixin):
|
||||
"""
|
||||
Individual items in an order.
|
||||
|
||||
Stores product snapshot at time of order plus external references
|
||||
for marketplace items.
|
||||
|
||||
All monetary amounts are stored as integer cents for precision.
|
||||
"""
|
||||
|
||||
__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 Snapshot (preserved at order time) ===
|
||||
product_name = Column(String(255), nullable=False)
|
||||
product_sku = Column(String(100), nullable=True)
|
||||
gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc.
|
||||
gtin_type = Column(String(20), nullable=True) # ean13, upc, isbn, etc.
|
||||
|
||||
# === Pricing (stored as integer cents) ===
|
||||
quantity = Column(Integer, nullable=False)
|
||||
unit_price_cents = Column(Integer, nullable=False)
|
||||
total_price_cents = Column(Integer, nullable=False)
|
||||
|
||||
# === External References (for marketplace items) ===
|
||||
external_item_id = Column(String(100), nullable=True) # e.g., Letzshop inventory unit ID
|
||||
external_variant_id = Column(String(100), nullable=True) # e.g., Letzshop variant ID
|
||||
|
||||
# === Item State (for marketplace confirmation flow) ===
|
||||
# confirmed_available: item confirmed and available
|
||||
# confirmed_unavailable: item confirmed but not available (declined)
|
||||
item_state = Column(String(50), nullable=True)
|
||||
|
||||
# === Inventory Tracking ===
|
||||
inventory_reserved = Column(Boolean, default=False)
|
||||
inventory_fulfilled = Column(Boolean, default=False)
|
||||
|
||||
# === Shipment Tracking ===
|
||||
shipped_quantity = Column(Integer, default=0, nullable=False) # Units shipped so far
|
||||
|
||||
# === Exception Tracking ===
|
||||
# True if product was not found by GTIN during import (linked to placeholder)
|
||||
needs_product_match = Column(Boolean, default=False, index=True)
|
||||
|
||||
# === Relationships ===
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product")
|
||||
exception = relationship(
|
||||
"OrderItemException",
|
||||
back_populates="order_item",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
|
||||
|
||||
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
||||
|
||||
@property
|
||||
def unit_price(self) -> float:
|
||||
"""Get unit price in euros."""
|
||||
return cents_to_euros(self.unit_price_cents)
|
||||
|
||||
@unit_price.setter
|
||||
def unit_price(self, value: float):
|
||||
"""Set unit price from euros."""
|
||||
self.unit_price_cents = euros_to_cents(value)
|
||||
|
||||
@property
|
||||
def total_price(self) -> float:
|
||||
"""Get total price in euros."""
|
||||
return cents_to_euros(self.total_price_cents)
|
||||
|
||||
@total_price.setter
|
||||
def total_price(self, value: float):
|
||||
"""Set total price from euros."""
|
||||
self.total_price_cents = euros_to_cents(value)
|
||||
|
||||
# === STATUS PROPERTIES ===
|
||||
|
||||
@property
|
||||
def is_confirmed(self) -> bool:
|
||||
"""Check if item has been confirmed (available or unavailable)."""
|
||||
return self.item_state in ("confirmed_available", "confirmed_unavailable")
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if item is confirmed as available."""
|
||||
return self.item_state == "confirmed_available"
|
||||
|
||||
@property
|
||||
def is_declined(self) -> bool:
|
||||
"""Check if item was declined (unavailable)."""
|
||||
return self.item_state == "confirmed_unavailable"
|
||||
|
||||
@property
|
||||
def has_unresolved_exception(self) -> bool:
|
||||
"""Check if item has an unresolved exception blocking confirmation."""
|
||||
if not self.exception:
|
||||
return False
|
||||
return self.exception.blocks_confirmation
|
||||
|
||||
# === SHIPMENT PROPERTIES ===
|
||||
|
||||
@property
|
||||
def remaining_quantity(self) -> int:
|
||||
"""Quantity not yet shipped."""
|
||||
return max(0, self.quantity - self.shipped_quantity)
|
||||
|
||||
@property
|
||||
def is_fully_shipped(self) -> bool:
|
||||
"""Check if all units have been shipped."""
|
||||
return self.shipped_quantity >= self.quantity
|
||||
|
||||
@property
|
||||
def is_partially_shipped(self) -> bool:
|
||||
"""Check if some but not all units have been shipped."""
|
||||
return 0 < self.shipped_quantity < self.quantity
|
||||
__all__ = [
|
||||
"Order",
|
||||
"OrderItem",
|
||||
]
|
||||
|
||||
@@ -1,117 +1,19 @@
|
||||
# models/database/order_item_exception.py
|
||||
"""
|
||||
Order Item Exception model for tracking unmatched products during marketplace imports.
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
When a marketplace order contains a GTIN that doesn't match any product in the
|
||||
vendor's catalog, the order is still imported but the item is linked to a
|
||||
placeholder product and an exception is recorded here for resolution.
|
||||
The canonical implementation is now in:
|
||||
app/modules/orders/models/order_item_exception.py
|
||||
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.orders.models import OrderItemException
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.modules.orders.models.order_item_exception import OrderItemException
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class OrderItemException(Base, TimestampMixin):
|
||||
"""
|
||||
Tracks unmatched order items requiring admin/vendor resolution.
|
||||
|
||||
When a marketplace order is imported and a product cannot be found by GTIN,
|
||||
the order item is linked to a placeholder product and this exception record
|
||||
is created. The order cannot be confirmed until all exceptions are resolved.
|
||||
"""
|
||||
|
||||
__tablename__ = "order_item_exceptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Link to the order item (one-to-one)
|
||||
order_item_id = Column(
|
||||
Integer,
|
||||
ForeignKey("order_items.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# Vendor ID for efficient querying (denormalized from order)
|
||||
vendor_id = Column(
|
||||
Integer, ForeignKey("vendors.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Original data from marketplace (preserved for matching)
|
||||
original_gtin = Column(String(50), nullable=True, index=True)
|
||||
original_product_name = Column(String(500), nullable=True)
|
||||
original_sku = Column(String(100), nullable=True)
|
||||
|
||||
# Exception classification
|
||||
# product_not_found: GTIN not in vendor catalog
|
||||
# gtin_mismatch: GTIN format issue
|
||||
# duplicate_gtin: Multiple products with same GTIN
|
||||
exception_type = Column(
|
||||
String(50), nullable=False, default="product_not_found"
|
||||
)
|
||||
|
||||
# Resolution status
|
||||
# pending: Awaiting resolution
|
||||
# resolved: Product has been assigned
|
||||
# ignored: Marked as ignored (still blocks confirmation)
|
||||
status = Column(String(50), nullable=False, default="pending", index=True)
|
||||
|
||||
# Resolution details (populated when resolved)
|
||||
resolved_product_id = Column(
|
||||
Integer, ForeignKey("products.id"), nullable=True
|
||||
)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
order_item = relationship("OrderItem", back_populates="exception")
|
||||
vendor = relationship("Vendor")
|
||||
resolved_product = relationship("Product")
|
||||
resolver = relationship("User")
|
||||
|
||||
# Composite indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("idx_exception_vendor_status", "vendor_id", "status"),
|
||||
Index("idx_exception_gtin", "vendor_id", "original_gtin"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<OrderItemException(id={self.id}, "
|
||||
f"order_item_id={self.order_item_id}, "
|
||||
f"gtin='{self.original_gtin}', "
|
||||
f"status='{self.status}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Check if exception is pending resolution."""
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
"""Check if exception has been resolved."""
|
||||
return self.status == "resolved"
|
||||
|
||||
@property
|
||||
def is_ignored(self) -> bool:
|
||||
"""Check if exception has been ignored."""
|
||||
return self.status == "ignored"
|
||||
|
||||
@property
|
||||
def blocks_confirmation(self) -> bool:
|
||||
"""Check if this exception blocks order confirmation."""
|
||||
# Both pending and ignored exceptions block confirmation
|
||||
return self.status in ("pending", "ignored")
|
||||
__all__ = [
|
||||
"OrderItemException",
|
||||
]
|
||||
|
||||
@@ -1,85 +1,19 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
# models/database/password_reset_token.py
|
||||
"""
|
||||
LEGACY LOCATION - Re-exports from module for backwards compatibility.
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
The canonical implementation is now in:
|
||||
app/modules/customers/models/password_reset_token.py
|
||||
|
||||
from app.core.database import Base
|
||||
This file exists to maintain backwards compatibility with code that
|
||||
imports from the old location. All new code should import directly
|
||||
from the module:
|
||||
|
||||
from app.modules.customers.models import PasswordResetToken
|
||||
"""
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
"""Password reset token for customer accounts.
|
||||
from app.modules.customers.models.password_reset_token import PasswordResetToken
|
||||
|
||||
Security:
|
||||
- Tokens are stored as SHA256 hashes, not plaintext
|
||||
- Tokens expire after 1 hour
|
||||
- Only one active token per customer (old tokens invalidated on new request)
|
||||
"""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
# Token expiry in hours
|
||||
TOKEN_EXPIRY_HOURS = 1
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
|
||||
token_hash = Column(String(64), nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
customer = relationship("Customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PasswordResetToken(id={self.id}, customer_id={self.customer_id}, expires_at={self.expires_at})>"
|
||||
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a token using SHA256."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def create_for_customer(cls, db: Session, customer_id: int) -> str:
|
||||
"""Create a new password reset token for a customer.
|
||||
|
||||
Invalidates any existing tokens for the customer.
|
||||
Returns the plaintext token (to be sent via email).
|
||||
"""
|
||||
# Invalidate existing tokens for this customer
|
||||
db.query(cls).filter(
|
||||
cls.customer_id == customer_id,
|
||||
cls.used_at.is_(None),
|
||||
).delete()
|
||||
|
||||
# Generate new token
|
||||
plaintext_token = secrets.token_urlsafe(32)
|
||||
token_hash = cls.hash_token(plaintext_token)
|
||||
|
||||
# Create token record
|
||||
token = cls(
|
||||
customer_id=customer_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
|
||||
return plaintext_token
|
||||
|
||||
@classmethod
|
||||
def find_valid_token(cls, db: Session, plaintext_token: str) -> "PasswordResetToken | None":
|
||||
"""Find a valid (not expired, not used) token."""
|
||||
token_hash = cls.hash_token(plaintext_token)
|
||||
|
||||
return db.query(cls).filter(
|
||||
cls.token_hash == token_hash,
|
||||
cls.expires_at > datetime.utcnow(),
|
||||
cls.used_at.is_(None),
|
||||
).first()
|
||||
|
||||
def mark_used(self, db: Session) -> None:
|
||||
"""Mark this token as used."""
|
||||
self.used_at = datetime.utcnow()
|
||||
db.flush()
|
||||
__all__ = [
|
||||
"PasswordResetToken",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user