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:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

@@ -2,15 +2,12 @@
"""
Orders module database models.
Re-exports order-related models from their source locations.
This module contains the canonical implementations of order-related models.
"""
from models.database.order import (
Order,
OrderItem,
)
from models.database.order_item_exception import OrderItemException
from models.database.invoice import (
from app.modules.orders.models.order import Order, OrderItem
from app.modules.orders.models.order_item_exception import OrderItemException
from app.modules.orders.models.invoice import (
Invoice,
InvoiceStatus,
VATRegime,

View File

@@ -0,0 +1,215 @@
# app/modules/orders/models/invoice.py
"""
Invoice database models for the OMS.
Provides models for:
- VendorInvoiceSettings: Per-vendor invoice configuration (company details, VAT, numbering)
- Invoice: Invoice records with snapshots of seller/buyer details
"""
import enum
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
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

View File

@@ -0,0 +1,406 @@
# app/modules/orders/models/order.py
"""
Unified Order model for all sales channels.
Supports:
- Direct orders (from vendor's own storefront)
- Marketplace orders (Letzshop, etc.)
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
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.modules.orders.models.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

View File

@@ -0,0 +1,117 @@
# app/modules/orders/models/order_item_exception.py
"""
Order Item Exception model for tracking unmatched products during marketplace imports.
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.
"""
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
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")