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:
@@ -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,
|
||||
|
||||
215
app/modules/orders/models/invoice.py
Normal file
215
app/modules/orders/models/invoice.py
Normal 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
|
||||
406
app/modules/orders/models/order.py
Normal file
406
app/modules/orders/models/order.py
Normal 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
|
||||
117
app/modules/orders/models/order_item_exception.py
Normal file
117
app/modules/orders/models/order_item_exception.py
Normal 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")
|
||||
@@ -2,34 +2,133 @@
|
||||
"""
|
||||
Orders module Pydantic schemas.
|
||||
|
||||
Re-exports order-related schemas from their source locations.
|
||||
This module contains the canonical implementations of order-related schemas.
|
||||
"""
|
||||
|
||||
from models.schema.order import (
|
||||
OrderCreate,
|
||||
OrderItemCreate,
|
||||
OrderResponse,
|
||||
OrderItemResponse,
|
||||
OrderListResponse,
|
||||
from app.modules.orders.schemas.order import (
|
||||
# Address schemas
|
||||
AddressSnapshot,
|
||||
AddressSnapshotResponse,
|
||||
# Order item schemas
|
||||
OrderItemCreate,
|
||||
OrderItemExceptionBrief,
|
||||
OrderItemResponse,
|
||||
# Customer schemas
|
||||
CustomerSnapshot,
|
||||
CustomerSnapshotResponse,
|
||||
# Order CRUD schemas
|
||||
OrderCreate,
|
||||
OrderUpdate,
|
||||
OrderTrackingUpdate,
|
||||
OrderItemStateUpdate,
|
||||
# Order response schemas
|
||||
OrderResponse,
|
||||
OrderDetailResponse,
|
||||
OrderListResponse,
|
||||
OrderListItem,
|
||||
# Admin schemas
|
||||
AdminOrderItem,
|
||||
AdminOrderListResponse,
|
||||
AdminOrderStats,
|
||||
AdminOrderStatusUpdate,
|
||||
AdminVendorWithOrders,
|
||||
AdminVendorsWithOrdersResponse,
|
||||
# Letzshop schemas
|
||||
LetzshopOrderImport,
|
||||
LetzshopShippingInfo,
|
||||
LetzshopOrderConfirmItem,
|
||||
LetzshopOrderConfirmRequest,
|
||||
# Shipping schemas
|
||||
MarkAsShippedRequest,
|
||||
ShippingLabelInfo,
|
||||
)
|
||||
from models.schema.invoice import (
|
||||
|
||||
from app.modules.orders.schemas.invoice import (
|
||||
# Invoice settings schemas
|
||||
VendorInvoiceSettingsCreate,
|
||||
VendorInvoiceSettingsUpdate,
|
||||
VendorInvoiceSettingsResponse,
|
||||
# Line item schemas
|
||||
InvoiceLineItem,
|
||||
InvoiceLineItemResponse,
|
||||
# Address schemas
|
||||
InvoiceSellerDetails,
|
||||
InvoiceBuyerDetails,
|
||||
# Invoice CRUD schemas
|
||||
InvoiceCreate,
|
||||
InvoiceManualCreate,
|
||||
InvoiceResponse,
|
||||
InvoiceListResponse,
|
||||
InvoiceStatusUpdate,
|
||||
# Pagination
|
||||
InvoiceListPaginatedResponse,
|
||||
# PDF
|
||||
InvoicePDFGeneratedResponse,
|
||||
InvoiceStatsResponse,
|
||||
# Backward compatibility
|
||||
InvoiceSettingsCreate,
|
||||
InvoiceSettingsUpdate,
|
||||
InvoiceSettingsResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OrderCreate",
|
||||
"OrderItemCreate",
|
||||
"OrderResponse",
|
||||
"OrderItemResponse",
|
||||
"OrderListResponse",
|
||||
# Address schemas
|
||||
"AddressSnapshot",
|
||||
"AddressSnapshotResponse",
|
||||
# Order item schemas
|
||||
"OrderItemCreate",
|
||||
"OrderItemExceptionBrief",
|
||||
"OrderItemResponse",
|
||||
# Customer schemas
|
||||
"CustomerSnapshot",
|
||||
"CustomerSnapshotResponse",
|
||||
# Order CRUD schemas
|
||||
"OrderCreate",
|
||||
"OrderUpdate",
|
||||
"OrderTrackingUpdate",
|
||||
"OrderItemStateUpdate",
|
||||
# Order response schemas
|
||||
"OrderResponse",
|
||||
"OrderDetailResponse",
|
||||
"OrderListResponse",
|
||||
"OrderListItem",
|
||||
# Admin schemas
|
||||
"AdminOrderItem",
|
||||
"AdminOrderListResponse",
|
||||
"AdminOrderStats",
|
||||
"AdminOrderStatusUpdate",
|
||||
"AdminVendorWithOrders",
|
||||
"AdminVendorsWithOrdersResponse",
|
||||
# Letzshop schemas
|
||||
"LetzshopOrderImport",
|
||||
"LetzshopShippingInfo",
|
||||
"LetzshopOrderConfirmItem",
|
||||
"LetzshopOrderConfirmRequest",
|
||||
# Shipping schemas
|
||||
"MarkAsShippedRequest",
|
||||
"ShippingLabelInfo",
|
||||
# Invoice settings schemas
|
||||
"VendorInvoiceSettingsCreate",
|
||||
"VendorInvoiceSettingsUpdate",
|
||||
"VendorInvoiceSettingsResponse",
|
||||
# Line item schemas
|
||||
"InvoiceLineItem",
|
||||
"InvoiceLineItemResponse",
|
||||
# Invoice address schemas
|
||||
"InvoiceSellerDetails",
|
||||
"InvoiceBuyerDetails",
|
||||
# Invoice CRUD schemas
|
||||
"InvoiceCreate",
|
||||
"InvoiceManualCreate",
|
||||
"InvoiceResponse",
|
||||
"InvoiceListResponse",
|
||||
"InvoiceStatusUpdate",
|
||||
# Pagination
|
||||
"InvoiceListPaginatedResponse",
|
||||
# PDF
|
||||
"InvoicePDFGeneratedResponse",
|
||||
"InvoiceStatsResponse",
|
||||
# Backward compatibility
|
||||
"InvoiceSettingsCreate",
|
||||
"InvoiceSettingsUpdate",
|
||||
"InvoiceSettingsResponse",
|
||||
|
||||
316
app/modules/orders/schemas/invoice.py
Normal file
316
app/modules/orders/schemas/invoice.py
Normal file
@@ -0,0 +1,316 @@
|
||||
# app/modules/orders/schemas/invoice.py
|
||||
"""
|
||||
Pydantic schemas for invoice operations.
|
||||
|
||||
Supports invoice settings management and invoice generation.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Settings Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class VendorInvoiceSettingsCreate(BaseModel):
|
||||
"""Schema for creating vendor invoice settings."""
|
||||
|
||||
company_name: str = Field(..., min_length=1, max_length=255)
|
||||
company_address: str | None = Field(None, max_length=255)
|
||||
company_city: str | None = Field(None, max_length=100)
|
||||
company_postal_code: str | None = Field(None, max_length=20)
|
||||
company_country: str = Field(default="LU", min_length=2, max_length=2)
|
||||
|
||||
vat_number: str | None = Field(None, max_length=50)
|
||||
is_vat_registered: bool = True
|
||||
|
||||
is_oss_registered: bool = False
|
||||
oss_registration_country: str | None = Field(None, min_length=2, max_length=2)
|
||||
|
||||
invoice_prefix: str = Field(default="INV", max_length=20)
|
||||
invoice_number_padding: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
payment_terms: str | None = None
|
||||
bank_name: str | None = Field(None, max_length=255)
|
||||
bank_iban: str | None = Field(None, max_length=50)
|
||||
bank_bic: str | None = Field(None, max_length=20)
|
||||
|
||||
footer_text: str | None = None
|
||||
default_vat_rate: Decimal = Field(default=Decimal("17.00"), ge=0, le=100)
|
||||
|
||||
|
||||
class VendorInvoiceSettingsUpdate(BaseModel):
|
||||
"""Schema for updating vendor invoice settings."""
|
||||
|
||||
company_name: str | None = Field(None, min_length=1, max_length=255)
|
||||
company_address: str | None = Field(None, max_length=255)
|
||||
company_city: str | None = Field(None, max_length=100)
|
||||
company_postal_code: str | None = Field(None, max_length=20)
|
||||
company_country: str | None = Field(None, min_length=2, max_length=2)
|
||||
|
||||
vat_number: str | None = None
|
||||
is_vat_registered: bool | None = None
|
||||
|
||||
is_oss_registered: bool | None = None
|
||||
oss_registration_country: str | None = None
|
||||
|
||||
invoice_prefix: str | None = Field(None, max_length=20)
|
||||
invoice_number_padding: int | None = Field(None, ge=1, le=10)
|
||||
|
||||
payment_terms: str | None = None
|
||||
bank_name: str | None = Field(None, max_length=255)
|
||||
bank_iban: str | None = Field(None, max_length=50)
|
||||
bank_bic: str | None = Field(None, max_length=20)
|
||||
|
||||
footer_text: str | None = None
|
||||
default_vat_rate: Decimal | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class VendorInvoiceSettingsResponse(BaseModel):
|
||||
"""Schema for vendor invoice settings response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
|
||||
company_name: str
|
||||
company_address: str | None
|
||||
company_city: str | None
|
||||
company_postal_code: str | None
|
||||
company_country: str
|
||||
|
||||
vat_number: str | None
|
||||
is_vat_registered: bool
|
||||
|
||||
is_oss_registered: bool
|
||||
oss_registration_country: str | None
|
||||
|
||||
invoice_prefix: str
|
||||
invoice_next_number: int
|
||||
invoice_number_padding: int
|
||||
|
||||
payment_terms: str | None
|
||||
bank_name: str | None
|
||||
bank_iban: str | None
|
||||
bank_bic: str | None
|
||||
|
||||
footer_text: str | None
|
||||
default_vat_rate: Decimal
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Line Item Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceLineItem(BaseModel):
|
||||
"""Schema for invoice line item."""
|
||||
|
||||
description: str
|
||||
quantity: int = Field(..., ge=1)
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
sku: str | None = None
|
||||
ean: str | None = None
|
||||
|
||||
|
||||
class InvoiceLineItemResponse(BaseModel):
|
||||
"""Schema for invoice line item in response."""
|
||||
|
||||
description: str
|
||||
quantity: int
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
sku: str | None = None
|
||||
ean: str | None = None
|
||||
|
||||
@property
|
||||
def unit_price(self) -> float:
|
||||
return self.unit_price_cents / 100
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Address Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceSellerDetails(BaseModel):
|
||||
"""Seller details for invoice."""
|
||||
|
||||
company_name: str
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
postal_code: str | None = None
|
||||
country: str
|
||||
vat_number: str | None = None
|
||||
|
||||
|
||||
class InvoiceBuyerDetails(BaseModel):
|
||||
"""Buyer details for invoice."""
|
||||
|
||||
name: str
|
||||
email: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
postal_code: str | None = None
|
||||
country: str
|
||||
vat_number: str | None = None # For B2B
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
"""Schema for creating an invoice from an order."""
|
||||
|
||||
order_id: int
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InvoiceManualCreate(BaseModel):
|
||||
"""Schema for creating a manual invoice (without order)."""
|
||||
|
||||
buyer_details: InvoiceBuyerDetails
|
||||
line_items: list[InvoiceLineItem]
|
||||
notes: str | None = None
|
||||
payment_terms: str | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(BaseModel):
|
||||
"""Schema for invoice response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
order_id: int | None
|
||||
|
||||
invoice_number: str
|
||||
invoice_date: datetime
|
||||
status: str
|
||||
|
||||
seller_details: dict
|
||||
buyer_details: dict
|
||||
line_items: list[dict]
|
||||
|
||||
vat_regime: str
|
||||
destination_country: str | None
|
||||
vat_rate: Decimal
|
||||
vat_rate_label: str | None
|
||||
|
||||
currency: str
|
||||
subtotal_cents: int
|
||||
vat_amount_cents: int
|
||||
total_cents: int
|
||||
|
||||
payment_terms: str | None
|
||||
bank_details: dict | None
|
||||
footer_text: str | None
|
||||
|
||||
pdf_generated_at: datetime | None
|
||||
pdf_path: str | None
|
||||
|
||||
notes: str | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return self.subtotal_cents / 100
|
||||
|
||||
@property
|
||||
def vat_amount(self) -> float:
|
||||
return self.vat_amount_cents / 100
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
class InvoiceListResponse(BaseModel):
|
||||
"""Schema for invoice list response (summary)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_number: str
|
||||
invoice_date: datetime
|
||||
status: str
|
||||
currency: str
|
||||
total_cents: int
|
||||
order_id: int | None
|
||||
|
||||
# Buyer name for display
|
||||
buyer_name: str | None = None
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
class InvoiceStatusUpdate(BaseModel):
|
||||
"""Schema for updating invoice status."""
|
||||
|
||||
status: str = Field(..., pattern="^(draft|issued|paid|cancelled)$")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Paginated Response
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceListPaginatedResponse(BaseModel):
|
||||
"""Paginated invoice list response."""
|
||||
|
||||
items: list[InvoiceListResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PDF Response
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoicePDFGeneratedResponse(BaseModel):
|
||||
"""Response for PDF generation."""
|
||||
|
||||
pdf_path: str
|
||||
message: str = "PDF generated successfully"
|
||||
|
||||
|
||||
class InvoiceStatsResponse(BaseModel):
|
||||
"""Invoice statistics response."""
|
||||
|
||||
total_invoices: int
|
||||
total_revenue_cents: int
|
||||
draft_count: int
|
||||
issued_count: int
|
||||
paid_count: int
|
||||
cancelled_count: int
|
||||
|
||||
@property
|
||||
def total_revenue(self) -> float:
|
||||
return self.total_revenue_cents / 100
|
||||
|
||||
|
||||
# Backward compatibility re-exports
|
||||
InvoiceSettingsCreate = VendorInvoiceSettingsCreate
|
||||
InvoiceSettingsUpdate = VendorInvoiceSettingsUpdate
|
||||
InvoiceSettingsResponse = VendorInvoiceSettingsResponse
|
||||
584
app/modules/orders/schemas/order.py
Normal file
584
app/modules/orders/schemas/order.py
Normal file
@@ -0,0 +1,584 @@
|
||||
# app/modules/orders/schemas/order.py
|
||||
"""
|
||||
Pydantic schemas for unified order operations.
|
||||
|
||||
Supports both direct orders and marketplace orders (Letzshop, etc.)
|
||||
with snapshotted customer and address data.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ============================================================================
|
||||
# Address Snapshot Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AddressSnapshot(BaseModel):
|
||||
"""Address snapshot for order creation."""
|
||||
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
company: str | None = Field(None, max_length=200)
|
||||
address_line_1: str = Field(..., min_length=1, max_length=255)
|
||||
address_line_2: str | None = Field(None, max_length=255)
|
||||
city: str = Field(..., min_length=1, max_length=100)
|
||||
postal_code: str = Field(..., min_length=1, max_length=20)
|
||||
country_iso: str = Field(..., min_length=2, max_length=5)
|
||||
|
||||
|
||||
class AddressSnapshotResponse(BaseModel):
|
||||
"""Address snapshot in order response."""
|
||||
|
||||
first_name: str
|
||||
last_name: str
|
||||
company: str | None
|
||||
address_line_1: str
|
||||
address_line_2: str | None
|
||||
city: str
|
||||
postal_code: str
|
||||
country_iso: str
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Item Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderItemCreate(BaseModel):
|
||||
"""Schema for creating an order item."""
|
||||
|
||||
product_id: int
|
||||
quantity: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class OrderItemExceptionBrief(BaseModel):
|
||||
"""Brief exception info for embedding in order item responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
original_gtin: str | None
|
||||
original_product_name: str | None
|
||||
exception_type: str
|
||||
status: str
|
||||
resolved_product_id: int | None
|
||||
|
||||
|
||||
class OrderItemResponse(BaseModel):
|
||||
"""Schema for order item response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_id: int
|
||||
product_id: int
|
||||
product_name: str
|
||||
product_sku: str | None
|
||||
gtin: str | None
|
||||
gtin_type: str | None
|
||||
quantity: int
|
||||
unit_price: float
|
||||
total_price: float
|
||||
|
||||
# External references (for marketplace items)
|
||||
external_item_id: str | None = None
|
||||
external_variant_id: str | None = None
|
||||
|
||||
# Item state (for marketplace confirmation flow)
|
||||
item_state: str | None = None
|
||||
|
||||
# Inventory tracking
|
||||
inventory_reserved: bool
|
||||
inventory_fulfilled: bool
|
||||
|
||||
# Exception tracking
|
||||
needs_product_match: bool = False
|
||||
exception: OrderItemExceptionBrief | None = None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@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.status in ("pending", "ignored")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Customer Snapshot Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class CustomerSnapshot(BaseModel):
|
||||
"""Customer snapshot for order creation."""
|
||||
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
email: str = Field(..., max_length=255)
|
||||
phone: str | None = Field(None, max_length=50)
|
||||
locale: str | None = Field(None, max_length=10)
|
||||
|
||||
|
||||
class CustomerSnapshotResponse(BaseModel):
|
||||
"""Customer snapshot in order response."""
|
||||
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
phone: str | None
|
||||
locale: str | None
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Create/Update Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
"""Schema for creating an order (direct channel)."""
|
||||
|
||||
customer_id: int | None = None # Optional for guest checkout
|
||||
items: list[OrderItemCreate] = Field(..., min_length=1)
|
||||
|
||||
# Customer info snapshot
|
||||
customer: CustomerSnapshot
|
||||
|
||||
# Addresses (snapshots)
|
||||
shipping_address: AddressSnapshot
|
||||
billing_address: AddressSnapshot | None = None # Use shipping if not provided
|
||||
|
||||
# Optional fields
|
||||
shipping_method: str | None = None
|
||||
customer_notes: str | None = Field(None, max_length=1000)
|
||||
|
||||
# Cart/session info
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
"""Schema for updating order status."""
|
||||
|
||||
status: str | None = Field(
|
||||
None, pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
|
||||
)
|
||||
tracking_number: str | None = None
|
||||
tracking_provider: str | None = None
|
||||
internal_notes: str | None = None
|
||||
|
||||
|
||||
class OrderTrackingUpdate(BaseModel):
|
||||
"""Schema for setting tracking information."""
|
||||
|
||||
tracking_number: str = Field(..., min_length=1, max_length=100)
|
||||
tracking_provider: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class OrderItemStateUpdate(BaseModel):
|
||||
"""Schema for updating item state (marketplace confirmation)."""
|
||||
|
||||
item_id: int
|
||||
state: str = Field(..., pattern="^(confirmed_available|confirmed_unavailable)$")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
"""Schema for order response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
customer_id: int
|
||||
order_number: str
|
||||
|
||||
# Channel/Source
|
||||
channel: str
|
||||
external_order_id: str | None = None
|
||||
external_shipment_id: str | None = None
|
||||
external_order_number: str | None = None
|
||||
|
||||
# Status
|
||||
status: str
|
||||
|
||||
# Financial
|
||||
subtotal: float | None
|
||||
tax_amount: float | None
|
||||
shipping_amount: float | None
|
||||
discount_amount: float | None
|
||||
total_amount: float
|
||||
currency: str
|
||||
|
||||
# VAT information
|
||||
vat_regime: str | None = None
|
||||
vat_rate: float | None = None
|
||||
vat_rate_label: str | None = None
|
||||
vat_destination_country: str | None = None
|
||||
|
||||
# Customer snapshot
|
||||
customer_first_name: str
|
||||
customer_last_name: str
|
||||
customer_email: str
|
||||
customer_phone: str | None
|
||||
customer_locale: str | None
|
||||
|
||||
# Shipping address snapshot
|
||||
ship_first_name: str
|
||||
ship_last_name: str
|
||||
ship_company: str | None
|
||||
ship_address_line_1: str
|
||||
ship_address_line_2: str | None
|
||||
ship_city: str
|
||||
ship_postal_code: str
|
||||
ship_country_iso: str
|
||||
|
||||
# Billing address snapshot
|
||||
bill_first_name: str
|
||||
bill_last_name: str
|
||||
bill_company: str | None
|
||||
bill_address_line_1: str
|
||||
bill_address_line_2: str | None
|
||||
bill_city: str
|
||||
bill_postal_code: str
|
||||
bill_country_iso: str
|
||||
|
||||
# Tracking
|
||||
shipping_method: str | None
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Notes
|
||||
customer_notes: str | None
|
||||
internal_notes: str | None
|
||||
|
||||
# Timestamps
|
||||
order_date: datetime
|
||||
confirmed_at: datetime | None
|
||||
shipped_at: datetime | None
|
||||
delivered_at: datetime | None
|
||||
cancelled_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def customer_full_name(self) -> str:
|
||||
return f"{self.customer_first_name} {self.customer_last_name}".strip()
|
||||
|
||||
@property
|
||||
def ship_full_name(self) -> str:
|
||||
return f"{self.ship_first_name} {self.ship_last_name}".strip()
|
||||
|
||||
@property
|
||||
def is_marketplace_order(self) -> bool:
|
||||
return self.channel != "direct"
|
||||
|
||||
|
||||
class OrderDetailResponse(OrderResponse):
|
||||
"""Schema for detailed order response with items."""
|
||||
|
||||
items: list[OrderItemResponse] = []
|
||||
|
||||
# Vendor info (enriched by API)
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
|
||||
|
||||
class OrderListResponse(BaseModel):
|
||||
"""Schema for paginated order list."""
|
||||
|
||||
orders: list[OrderResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order List Item (Simplified for list views)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderListItem(BaseModel):
|
||||
"""Simplified order item for list views."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
order_number: str
|
||||
channel: str
|
||||
status: str
|
||||
|
||||
# External references
|
||||
external_order_number: str | None = None
|
||||
|
||||
# Customer
|
||||
customer_full_name: str
|
||||
customer_email: str
|
||||
|
||||
# Financial
|
||||
total_amount: float
|
||||
currency: str
|
||||
|
||||
# Shipping
|
||||
ship_country_iso: str
|
||||
|
||||
# Tracking
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Item count
|
||||
item_count: int = 0
|
||||
|
||||
# Timestamps
|
||||
order_date: datetime
|
||||
confirmed_at: datetime | None
|
||||
shipped_at: datetime | None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin Order Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminOrderItem(BaseModel):
|
||||
"""Order item with vendor info for admin list view."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
customer_id: int
|
||||
order_number: str
|
||||
channel: str
|
||||
status: str
|
||||
|
||||
# External references
|
||||
external_order_number: str | None = None
|
||||
external_shipment_id: str | None = None
|
||||
|
||||
# Customer snapshot
|
||||
customer_full_name: str
|
||||
customer_email: str
|
||||
|
||||
# Financial
|
||||
subtotal: float | None
|
||||
tax_amount: float | None
|
||||
shipping_amount: float | None
|
||||
discount_amount: float | None
|
||||
total_amount: float
|
||||
currency: str
|
||||
|
||||
# VAT information
|
||||
vat_regime: str | None = None
|
||||
vat_rate: float | None = None
|
||||
vat_rate_label: str | None = None
|
||||
vat_destination_country: str | None = None
|
||||
|
||||
# Shipping
|
||||
ship_country_iso: str
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Item count
|
||||
item_count: int = 0
|
||||
|
||||
# Timestamps
|
||||
order_date: datetime
|
||||
confirmed_at: datetime | None
|
||||
shipped_at: datetime | None
|
||||
delivered_at: datetime | None
|
||||
cancelled_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AdminOrderListResponse(BaseModel):
|
||||
"""Cross-vendor order list for admin."""
|
||||
|
||||
orders: list[AdminOrderItem]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class AdminOrderStats(BaseModel):
|
||||
"""Order statistics for admin dashboard."""
|
||||
|
||||
total_orders: int = 0
|
||||
pending_orders: int = 0
|
||||
processing_orders: int = 0
|
||||
shipped_orders: int = 0
|
||||
delivered_orders: int = 0
|
||||
cancelled_orders: int = 0
|
||||
refunded_orders: int = 0
|
||||
total_revenue: float = 0.0
|
||||
|
||||
# By channel
|
||||
direct_orders: int = 0
|
||||
letzshop_orders: int = 0
|
||||
|
||||
# Vendors
|
||||
vendors_with_orders: int = 0
|
||||
|
||||
|
||||
class AdminOrderStatusUpdate(BaseModel):
|
||||
"""Admin version of status update with reason."""
|
||||
|
||||
status: str = Field(
|
||||
..., pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
|
||||
)
|
||||
tracking_number: str | None = None
|
||||
tracking_provider: str | None = None
|
||||
reason: str | None = Field(None, description="Reason for status change")
|
||||
|
||||
|
||||
class AdminVendorWithOrders(BaseModel):
|
||||
"""Vendor with order count."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
vendor_code: str
|
||||
order_count: int = 0
|
||||
|
||||
|
||||
class AdminVendorsWithOrdersResponse(BaseModel):
|
||||
"""Response for vendors with orders list."""
|
||||
|
||||
vendors: list[AdminVendorWithOrders]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Letzshop-specific Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LetzshopOrderImport(BaseModel):
|
||||
"""Schema for importing a Letzshop order from shipment data."""
|
||||
|
||||
shipment_id: str
|
||||
order_id: str
|
||||
order_number: str
|
||||
order_date: datetime
|
||||
|
||||
# Customer
|
||||
customer_email: str
|
||||
customer_locale: str | None = None
|
||||
|
||||
# Shipping address
|
||||
ship_first_name: str
|
||||
ship_last_name: str
|
||||
ship_company: str | None = None
|
||||
ship_address_line_1: str
|
||||
ship_address_line_2: str | None = None
|
||||
ship_city: str
|
||||
ship_postal_code: str
|
||||
ship_country_iso: str
|
||||
|
||||
# Billing address
|
||||
bill_first_name: str
|
||||
bill_last_name: str
|
||||
bill_company: str | None = None
|
||||
bill_address_line_1: str
|
||||
bill_address_line_2: str | None = None
|
||||
bill_city: str
|
||||
bill_postal_code: str
|
||||
bill_country_iso: str
|
||||
|
||||
# Totals
|
||||
total_amount: float
|
||||
currency: str = "EUR"
|
||||
|
||||
# State
|
||||
letzshop_state: str # unconfirmed, confirmed, declined
|
||||
|
||||
# Items
|
||||
inventory_units: list[dict]
|
||||
|
||||
# Raw data
|
||||
raw_data: dict | None = None
|
||||
|
||||
|
||||
class LetzshopShippingInfo(BaseModel):
|
||||
"""Shipping info retrieved from Letzshop."""
|
||||
|
||||
tracking_number: str
|
||||
tracking_provider: str
|
||||
shipment_id: str
|
||||
|
||||
|
||||
class LetzshopOrderConfirmItem(BaseModel):
|
||||
"""Schema for confirming/declining a single item."""
|
||||
|
||||
item_id: int
|
||||
external_item_id: str
|
||||
action: str = Field(..., pattern="^(confirm|decline)$")
|
||||
|
||||
|
||||
class LetzshopOrderConfirmRequest(BaseModel):
|
||||
"""Schema for confirming/declining order items."""
|
||||
|
||||
items: list[LetzshopOrderConfirmItem]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Mark as Shipped Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MarkAsShippedRequest(BaseModel):
|
||||
"""Schema for marking an order as shipped with tracking info."""
|
||||
|
||||
tracking_number: str | None = Field(None, max_length=100)
|
||||
tracking_url: str | None = Field(None, max_length=500)
|
||||
shipping_carrier: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ShippingLabelInfo(BaseModel):
|
||||
"""Shipping label information for an order."""
|
||||
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
label_url: str | None = None
|
||||
tracking_number: str | None = None
|
||||
tracking_url: str | None = None
|
||||
@@ -2,26 +2,26 @@
|
||||
"""
|
||||
Orders module services.
|
||||
|
||||
Re-exports order-related services from their source locations.
|
||||
This module contains the canonical implementations of order-related services.
|
||||
"""
|
||||
|
||||
from app.services.order_service import (
|
||||
from app.modules.orders.services.order_service import (
|
||||
order_service,
|
||||
OrderService,
|
||||
)
|
||||
from app.services.order_inventory_service import (
|
||||
from app.modules.orders.services.order_inventory_service import (
|
||||
order_inventory_service,
|
||||
OrderInventoryService,
|
||||
)
|
||||
from app.services.order_item_exception_service import (
|
||||
from app.modules.orders.services.order_item_exception_service import (
|
||||
order_item_exception_service,
|
||||
OrderItemExceptionService,
|
||||
)
|
||||
from app.services.invoice_service import (
|
||||
from app.modules.orders.services.invoice_service import (
|
||||
invoice_service,
|
||||
InvoiceService,
|
||||
)
|
||||
from app.services.invoice_pdf_service import (
|
||||
from app.modules.orders.services.invoice_pdf_service import (
|
||||
invoice_pdf_service,
|
||||
InvoicePDFService,
|
||||
)
|
||||
|
||||
150
app/modules/orders/services/invoice_pdf_service.py
Normal file
150
app/modules/orders/services/invoice_pdf_service.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# app/modules/orders/services/invoice_pdf_service.py
|
||||
"""
|
||||
Invoice PDF generation service using WeasyPrint.
|
||||
|
||||
Renders HTML invoice templates to PDF using Jinja2 + WeasyPrint.
|
||||
Stores generated PDFs in the configured storage location.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.orders.models.invoice import Invoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "invoices"
|
||||
|
||||
# PDF storage directory (relative to project root)
|
||||
PDF_STORAGE_DIR = Path("storage") / "invoices"
|
||||
|
||||
|
||||
class InvoicePDFService:
|
||||
"""Service for generating invoice PDFs."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PDF service with Jinja2 environment."""
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
def _ensure_storage_dir(self, vendor_id: int) -> Path:
|
||||
"""Ensure the storage directory exists for a vendor."""
|
||||
storage_path = PDF_STORAGE_DIR / str(vendor_id)
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
return storage_path
|
||||
|
||||
def _get_pdf_filename(self, invoice: Invoice) -> str:
|
||||
"""Generate PDF filename for an invoice."""
|
||||
safe_number = invoice.invoice_number.replace("/", "-").replace("\\", "-")
|
||||
return f"{safe_number}.pdf"
|
||||
|
||||
def generate_pdf(
|
||||
self,
|
||||
db: Session,
|
||||
invoice: Invoice,
|
||||
force_regenerate: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Generate PDF for an invoice.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
invoice: Invoice to generate PDF for
|
||||
force_regenerate: If True, regenerate even if PDF already exists
|
||||
|
||||
Returns:
|
||||
Path to the generated PDF file
|
||||
"""
|
||||
# Check if PDF already exists
|
||||
if invoice.pdf_path and not force_regenerate:
|
||||
if Path(invoice.pdf_path).exists():
|
||||
logger.debug(f"PDF already exists for invoice {invoice.invoice_number}")
|
||||
return invoice.pdf_path
|
||||
|
||||
# Ensure storage directory exists
|
||||
storage_dir = self._ensure_storage_dir(invoice.vendor_id)
|
||||
pdf_filename = self._get_pdf_filename(invoice)
|
||||
pdf_path = storage_dir / pdf_filename
|
||||
|
||||
# Render HTML template
|
||||
html_content = self._render_html(invoice)
|
||||
|
||||
# Generate PDF using WeasyPrint
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_doc = HTML(string=html_content, base_url=str(TEMPLATE_DIR))
|
||||
html_doc.write_pdf(str(pdf_path))
|
||||
|
||||
logger.info(f"Generated PDF for invoice {invoice.invoice_number} at {pdf_path}")
|
||||
except ImportError:
|
||||
logger.error("WeasyPrint not installed. Install with: pip install weasyprint")
|
||||
raise RuntimeError("WeasyPrint not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate PDF for invoice {invoice.invoice_number}: {e}")
|
||||
raise
|
||||
|
||||
# Update invoice record with PDF path and timestamp
|
||||
invoice.pdf_path = str(pdf_path)
|
||||
invoice.pdf_generated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
return str(pdf_path)
|
||||
|
||||
def _render_html(self, invoice: Invoice) -> str:
|
||||
"""Render the invoice HTML template."""
|
||||
template = self.env.get_template("invoice.html")
|
||||
|
||||
context = {
|
||||
"invoice": invoice,
|
||||
"seller": invoice.seller_details,
|
||||
"buyer": invoice.buyer_details,
|
||||
"line_items": invoice.line_items,
|
||||
"bank_details": invoice.bank_details,
|
||||
"payment_terms": invoice.payment_terms,
|
||||
"footer_text": invoice.footer_text,
|
||||
"now": datetime.now(UTC),
|
||||
}
|
||||
|
||||
return template.render(**context)
|
||||
|
||||
def get_pdf_path(self, invoice: Invoice) -> str | None:
|
||||
"""Get the PDF path for an invoice if it exists."""
|
||||
if invoice.pdf_path and Path(invoice.pdf_path).exists():
|
||||
return invoice.pdf_path
|
||||
return None
|
||||
|
||||
def delete_pdf(self, invoice: Invoice, db: Session) -> bool:
|
||||
"""Delete the PDF file for an invoice."""
|
||||
if not invoice.pdf_path:
|
||||
return False
|
||||
|
||||
pdf_path = Path(invoice.pdf_path)
|
||||
if pdf_path.exists():
|
||||
try:
|
||||
pdf_path.unlink()
|
||||
logger.info(f"Deleted PDF for invoice {invoice.invoice_number}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete PDF {pdf_path}: {e}")
|
||||
return False
|
||||
|
||||
invoice.pdf_path = None
|
||||
invoice.pdf_generated_at = None
|
||||
db.flush()
|
||||
|
||||
return True
|
||||
|
||||
def regenerate_pdf(self, db: Session, invoice: Invoice) -> str:
|
||||
"""Force regenerate PDF for an invoice."""
|
||||
return self.generate_pdf(db, invoice, force_regenerate=True)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
invoice_pdf_service = InvoicePDFService()
|
||||
587
app/modules/orders/services/invoice_service.py
Normal file
587
app/modules/orders/services/invoice_service.py
Normal file
@@ -0,0 +1,587 @@
|
||||
# app/modules/orders/services/invoice_service.py
|
||||
"""
|
||||
Invoice service for generating and managing invoices.
|
||||
|
||||
Handles:
|
||||
- Vendor invoice settings management
|
||||
- Invoice generation from orders
|
||||
- VAT calculation (Luxembourg, EU, B2B reverse charge)
|
||||
- Invoice number sequencing
|
||||
- PDF generation (via separate module)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from app.exceptions.invoice import (
|
||||
InvoiceNotFoundException,
|
||||
InvoiceSettingsNotFoundException,
|
||||
OrderNotFoundException,
|
||||
)
|
||||
from app.modules.orders.models.invoice import (
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
VATRegime,
|
||||
VendorInvoiceSettings,
|
||||
)
|
||||
from app.modules.orders.models.order import Order
|
||||
from app.modules.orders.schemas.invoice import (
|
||||
VendorInvoiceSettingsCreate,
|
||||
VendorInvoiceSettingsUpdate,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# EU VAT rates by country code (2024 standard rates)
|
||||
EU_VAT_RATES: dict[str, Decimal] = {
|
||||
"AT": Decimal("20.00"),
|
||||
"BE": Decimal("21.00"),
|
||||
"BG": Decimal("20.00"),
|
||||
"HR": Decimal("25.00"),
|
||||
"CY": Decimal("19.00"),
|
||||
"CZ": Decimal("21.00"),
|
||||
"DK": Decimal("25.00"),
|
||||
"EE": Decimal("22.00"),
|
||||
"FI": Decimal("24.00"),
|
||||
"FR": Decimal("20.00"),
|
||||
"DE": Decimal("19.00"),
|
||||
"GR": Decimal("24.00"),
|
||||
"HU": Decimal("27.00"),
|
||||
"IE": Decimal("23.00"),
|
||||
"IT": Decimal("22.00"),
|
||||
"LV": Decimal("21.00"),
|
||||
"LT": Decimal("21.00"),
|
||||
"LU": Decimal("17.00"),
|
||||
"MT": Decimal("18.00"),
|
||||
"NL": Decimal("21.00"),
|
||||
"PL": Decimal("23.00"),
|
||||
"PT": Decimal("23.00"),
|
||||
"RO": Decimal("19.00"),
|
||||
"SK": Decimal("20.00"),
|
||||
"SI": Decimal("22.00"),
|
||||
"ES": Decimal("21.00"),
|
||||
"SE": Decimal("25.00"),
|
||||
}
|
||||
|
||||
LU_VAT_RATES = {
|
||||
"standard": Decimal("17.00"),
|
||||
"intermediate": Decimal("14.00"),
|
||||
"reduced": Decimal("8.00"),
|
||||
"super_reduced": Decimal("3.00"),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
"""Service for invoice operations."""
|
||||
|
||||
# =========================================================================
|
||||
# VAT Calculation
|
||||
# =========================================================================
|
||||
|
||||
def get_vat_rate_for_country(self, country_iso: str) -> Decimal:
|
||||
"""Get standard VAT rate for EU country."""
|
||||
return EU_VAT_RATES.get(country_iso.upper(), Decimal("0.00"))
|
||||
|
||||
def get_vat_rate_label(self, country_iso: str, vat_rate: Decimal) -> str:
|
||||
"""Get human-readable VAT rate label."""
|
||||
country_names = {
|
||||
"AT": "Austria", "BE": "Belgium", "BG": "Bulgaria", "HR": "Croatia",
|
||||
"CY": "Cyprus", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia",
|
||||
"FI": "Finland", "FR": "France", "DE": "Germany", "GR": "Greece",
|
||||
"HU": "Hungary", "IE": "Ireland", "IT": "Italy", "LV": "Latvia",
|
||||
"LT": "Lithuania", "LU": "Luxembourg", "MT": "Malta", "NL": "Netherlands",
|
||||
"PL": "Poland", "PT": "Portugal", "RO": "Romania", "SK": "Slovakia",
|
||||
"SI": "Slovenia", "ES": "Spain", "SE": "Sweden",
|
||||
}
|
||||
country_name = country_names.get(country_iso.upper(), country_iso)
|
||||
return f"{country_name} VAT {vat_rate}%"
|
||||
|
||||
def determine_vat_regime(
|
||||
self,
|
||||
seller_country: str,
|
||||
buyer_country: str,
|
||||
buyer_vat_number: str | None,
|
||||
seller_oss_registered: bool,
|
||||
) -> tuple[VATRegime, Decimal, str | None]:
|
||||
"""Determine VAT regime and rate for invoice."""
|
||||
seller_country = seller_country.upper()
|
||||
buyer_country = buyer_country.upper()
|
||||
|
||||
if seller_country == buyer_country:
|
||||
vat_rate = self.get_vat_rate_for_country(seller_country)
|
||||
return VATRegime.DOMESTIC, vat_rate, None
|
||||
|
||||
if buyer_country in EU_VAT_RATES:
|
||||
if buyer_vat_number:
|
||||
return VATRegime.REVERSE_CHARGE, Decimal("0.00"), buyer_country
|
||||
|
||||
if seller_oss_registered:
|
||||
vat_rate = self.get_vat_rate_for_country(buyer_country)
|
||||
return VATRegime.OSS, vat_rate, buyer_country
|
||||
else:
|
||||
vat_rate = self.get_vat_rate_for_country(seller_country)
|
||||
return VATRegime.ORIGIN, vat_rate, buyer_country
|
||||
|
||||
return VATRegime.EXEMPT, Decimal("0.00"), buyer_country
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Settings Management
|
||||
# =========================================================================
|
||||
|
||||
def get_settings(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorInvoiceSettings | None:
|
||||
"""Get vendor invoice settings."""
|
||||
return (
|
||||
db.query(VendorInvoiceSettings)
|
||||
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_settings_or_raise(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Get vendor invoice settings or raise exception."""
|
||||
settings = self.get_settings(db, vendor_id)
|
||||
if not settings:
|
||||
raise InvoiceSettingsNotFoundException(vendor_id)
|
||||
return settings
|
||||
|
||||
def create_settings(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: VendorInvoiceSettingsCreate,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Create vendor invoice settings."""
|
||||
existing = self.get_settings(db, vendor_id)
|
||||
if existing:
|
||||
raise ValidationException(
|
||||
"Invoice settings already exist for this vendor"
|
||||
)
|
||||
|
||||
settings = VendorInvoiceSettings(
|
||||
vendor_id=vendor_id,
|
||||
**data.model_dump(),
|
||||
)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Created invoice settings for vendor {vendor_id}")
|
||||
return settings
|
||||
|
||||
def update_settings(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: VendorInvoiceSettingsUpdate,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Update vendor invoice settings."""
|
||||
settings = self.get_settings_or_raise(db, vendor_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
settings.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Updated invoice settings for vendor {vendor_id}")
|
||||
return settings
|
||||
|
||||
def create_settings_from_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor: Vendor,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Create invoice settings from vendor/company info."""
|
||||
company = vendor.company
|
||||
|
||||
settings = VendorInvoiceSettings(
|
||||
vendor_id=vendor.id,
|
||||
company_name=company.legal_name if company else vendor.name,
|
||||
company_address=vendor.effective_business_address,
|
||||
company_city=None,
|
||||
company_postal_code=None,
|
||||
company_country="LU",
|
||||
vat_number=vendor.effective_tax_number,
|
||||
is_vat_registered=bool(vendor.effective_tax_number),
|
||||
)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Created invoice settings from vendor data for vendor {vendor.id}")
|
||||
return settings
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Number Generation
|
||||
# =========================================================================
|
||||
|
||||
def _get_next_invoice_number(
|
||||
self, db: Session, settings: VendorInvoiceSettings
|
||||
) -> str:
|
||||
"""Generate next invoice number and increment counter."""
|
||||
number = str(settings.invoice_next_number).zfill(settings.invoice_number_padding)
|
||||
invoice_number = f"{settings.invoice_prefix}{number}"
|
||||
|
||||
settings.invoice_next_number += 1
|
||||
db.flush()
|
||||
|
||||
return invoice_number
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Creation
|
||||
# =========================================================================
|
||||
|
||||
def create_invoice_from_order(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
notes: str | None = None,
|
||||
) -> Invoice:
|
||||
"""Create an invoice from an order."""
|
||||
settings = self.get_settings_or_raise(db, vendor_id)
|
||||
|
||||
order = (
|
||||
db.query(Order)
|
||||
.filter(and_(Order.id == order_id, Order.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
if not order:
|
||||
raise OrderNotFoundException(f"Order {order_id} not found")
|
||||
|
||||
existing = (
|
||||
db.query(Invoice)
|
||||
.filter(and_(Invoice.order_id == order_id, Invoice.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValidationException(f"Invoice already exists for order {order_id}")
|
||||
|
||||
buyer_country = order.bill_country_iso
|
||||
vat_regime, vat_rate, destination_country = self.determine_vat_regime(
|
||||
seller_country=settings.company_country,
|
||||
buyer_country=buyer_country,
|
||||
buyer_vat_number=None,
|
||||
seller_oss_registered=settings.is_oss_registered,
|
||||
)
|
||||
|
||||
seller_details = {
|
||||
"company_name": settings.company_name,
|
||||
"address": settings.company_address,
|
||||
"city": settings.company_city,
|
||||
"postal_code": settings.company_postal_code,
|
||||
"country": settings.company_country,
|
||||
"vat_number": settings.vat_number,
|
||||
}
|
||||
|
||||
buyer_details = {
|
||||
"name": f"{order.bill_first_name} {order.bill_last_name}".strip(),
|
||||
"email": order.customer_email,
|
||||
"address": order.bill_address_line_1,
|
||||
"city": order.bill_city,
|
||||
"postal_code": order.bill_postal_code,
|
||||
"country": order.bill_country_iso,
|
||||
"vat_number": None,
|
||||
}
|
||||
if order.bill_company:
|
||||
buyer_details["company"] = order.bill_company
|
||||
|
||||
line_items = []
|
||||
for item in order.items:
|
||||
line_items.append({
|
||||
"description": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price_cents": item.unit_price_cents,
|
||||
"total_cents": item.total_price_cents,
|
||||
"sku": item.product_sku,
|
||||
"ean": item.gtin,
|
||||
})
|
||||
|
||||
subtotal_cents = sum(item["total_cents"] for item in line_items)
|
||||
|
||||
if vat_rate > 0:
|
||||
vat_amount_cents = int(subtotal_cents * float(vat_rate) / 100)
|
||||
else:
|
||||
vat_amount_cents = 0
|
||||
|
||||
total_cents = subtotal_cents + vat_amount_cents
|
||||
|
||||
vat_rate_label = None
|
||||
if vat_rate > 0:
|
||||
if destination_country:
|
||||
vat_rate_label = self.get_vat_rate_label(destination_country, vat_rate)
|
||||
else:
|
||||
vat_rate_label = self.get_vat_rate_label(settings.company_country, vat_rate)
|
||||
|
||||
invoice_number = self._get_next_invoice_number(db, settings)
|
||||
|
||||
invoice = Invoice(
|
||||
vendor_id=vendor_id,
|
||||
order_id=order_id,
|
||||
invoice_number=invoice_number,
|
||||
invoice_date=datetime.now(UTC),
|
||||
status=InvoiceStatus.DRAFT.value,
|
||||
seller_details=seller_details,
|
||||
buyer_details=buyer_details,
|
||||
line_items=line_items,
|
||||
vat_regime=vat_regime.value,
|
||||
destination_country=destination_country,
|
||||
vat_rate=vat_rate,
|
||||
vat_rate_label=vat_rate_label,
|
||||
currency=order.currency,
|
||||
subtotal_cents=subtotal_cents,
|
||||
vat_amount_cents=vat_amount_cents,
|
||||
total_cents=total_cents,
|
||||
payment_terms=settings.payment_terms,
|
||||
bank_details={
|
||||
"bank_name": settings.bank_name,
|
||||
"iban": settings.bank_iban,
|
||||
"bic": settings.bank_bic,
|
||||
} if settings.bank_iban else None,
|
||||
footer_text=settings.footer_text,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
db.add(invoice)
|
||||
db.flush()
|
||||
db.refresh(invoice)
|
||||
|
||||
logger.info(
|
||||
f"Created invoice {invoice_number} for order {order_id} "
|
||||
f"(vendor={vendor_id}, total={total_cents/100:.2f} EUR, VAT={vat_regime.value})"
|
||||
)
|
||||
|
||||
return invoice
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Retrieval
|
||||
# =========================================================================
|
||||
|
||||
def get_invoice(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by ID."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(and_(Invoice.id == invoice_id, Invoice.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_invoice_or_raise(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Get invoice by ID or raise exception."""
|
||||
invoice = self.get_invoice(db, vendor_id, invoice_id)
|
||||
if not invoice:
|
||||
raise InvoiceNotFoundException(invoice_id)
|
||||
return invoice
|
||||
|
||||
def get_invoice_by_number(
|
||||
self, db: Session, vendor_id: int, invoice_number: str
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by invoice number."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.invoice_number == invoice_number,
|
||||
Invoice.vendor_id == vendor_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_invoice_by_order_id(
|
||||
self, db: Session, vendor_id: int, order_id: int
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by order ID."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.order_id == order_id,
|
||||
Invoice.vendor_id == vendor_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_invoices(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
) -> tuple[list[Invoice], int]:
|
||||
"""List invoices for vendor with pagination."""
|
||||
query = db.query(Invoice).filter(Invoice.vendor_id == vendor_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Invoice.status == status)
|
||||
|
||||
total = query.count()
|
||||
|
||||
invoices = (
|
||||
query.order_by(Invoice.invoice_date.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
return invoices, total
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Status Management
|
||||
# =========================================================================
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
new_status: str,
|
||||
) -> Invoice:
|
||||
"""Update invoice status."""
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
|
||||
valid_statuses = [s.value for s in InvoiceStatus]
|
||||
if new_status not in valid_statuses:
|
||||
raise ValidationException(f"Invalid status: {new_status}")
|
||||
|
||||
if invoice.status == InvoiceStatus.CANCELLED.value:
|
||||
raise ValidationException("Cannot change status of cancelled invoice")
|
||||
|
||||
invoice.status = new_status
|
||||
invoice.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(invoice)
|
||||
|
||||
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
|
||||
return invoice
|
||||
|
||||
def mark_as_issued(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Mark invoice as issued."""
|
||||
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.ISSUED.value)
|
||||
|
||||
def mark_as_paid(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Mark invoice as paid."""
|
||||
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.PAID.value)
|
||||
|
||||
def cancel_invoice(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Cancel invoice."""
|
||||
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.CANCELLED.value)
|
||||
|
||||
# =========================================================================
|
||||
# Statistics
|
||||
# =========================================================================
|
||||
|
||||
def get_invoice_stats(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Get invoice statistics for vendor."""
|
||||
total_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(Invoice.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_revenue = (
|
||||
db.query(func.sum(Invoice.total_cents))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status.in_([
|
||||
InvoiceStatus.ISSUED.value,
|
||||
InvoiceStatus.PAID.value,
|
||||
]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
draft_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status == InvoiceStatus.DRAFT.value,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
paid_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status == InvoiceStatus.PAID.value,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_invoices": total_count,
|
||||
"total_revenue_cents": total_revenue,
|
||||
"total_revenue": total_revenue / 100 if total_revenue else 0,
|
||||
"draft_count": draft_count,
|
||||
"paid_count": paid_count,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# PDF Generation
|
||||
# =========================================================================
|
||||
|
||||
def generate_pdf(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
force_regenerate: bool = False,
|
||||
) -> str:
|
||||
"""Generate PDF for an invoice."""
|
||||
from app.modules.orders.services.invoice_pdf_service import invoice_pdf_service
|
||||
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
return invoice_pdf_service.generate_pdf(db, invoice, force_regenerate)
|
||||
|
||||
def get_pdf_path(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
) -> str | None:
|
||||
"""Get PDF path for an invoice if it exists."""
|
||||
from app.modules.orders.services.invoice_pdf_service import invoice_pdf_service
|
||||
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
return invoice_pdf_service.get_pdf_path(invoice)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
invoice_service = InvoiceService()
|
||||
591
app/modules/orders/services/order_inventory_service.py
Normal file
591
app/modules/orders/services/order_inventory_service.py
Normal file
@@ -0,0 +1,591 @@
|
||||
# app/modules/orders/services/order_inventory_service.py
|
||||
"""
|
||||
Order-Inventory Integration Service.
|
||||
|
||||
This service orchestrates inventory operations for orders:
|
||||
- Reserve inventory when orders are confirmed
|
||||
- Fulfill (deduct) inventory when orders are shipped
|
||||
- Release reservations when orders are cancelled
|
||||
|
||||
All operations are logged to the inventory_transactions table for audit trail.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
InsufficientInventoryException,
|
||||
InventoryNotFoundException,
|
||||
OrderNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from app.modules.inventory.models.inventory_transaction import (
|
||||
InventoryTransaction,
|
||||
TransactionType,
|
||||
)
|
||||
from app.modules.inventory.schemas.inventory import InventoryReserve
|
||||
from app.modules.inventory.services.inventory_service import inventory_service
|
||||
from app.modules.orders.models.order import Order, OrderItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default location for inventory operations
|
||||
DEFAULT_LOCATION = "DEFAULT"
|
||||
|
||||
|
||||
class OrderInventoryService:
|
||||
"""
|
||||
Orchestrate order and inventory operations together.
|
||||
"""
|
||||
|
||||
def get_order_with_items(
|
||||
self, db: Session, vendor_id: int, order_id: int
|
||||
) -> Order:
|
||||
"""Get order with items or raise OrderNotFoundException."""
|
||||
order = (
|
||||
db.query(Order)
|
||||
.filter(Order.id == order_id, Order.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
if not order:
|
||||
raise OrderNotFoundException(f"Order {order_id} not found")
|
||||
return order
|
||||
|
||||
def _find_inventory_location(
|
||||
self, db: Session, product_id: int, vendor_id: int
|
||||
) -> str | None:
|
||||
"""
|
||||
Find the location with available inventory for a product.
|
||||
"""
|
||||
inventory = (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == product_id,
|
||||
Inventory.vendor_id == vendor_id,
|
||||
Inventory.quantity > Inventory.reserved_quantity,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return inventory.location if inventory else None
|
||||
|
||||
def _is_placeholder_product(self, order_item: OrderItem) -> bool:
|
||||
"""Check if the order item uses a placeholder product."""
|
||||
if not order_item.product:
|
||||
return True
|
||||
return order_item.product.gtin == "0000000000000"
|
||||
|
||||
def _log_transaction(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
product_id: int,
|
||||
inventory: Inventory,
|
||||
transaction_type: TransactionType,
|
||||
quantity_change: int,
|
||||
order: Order,
|
||||
reason: str | None = None,
|
||||
) -> InventoryTransaction:
|
||||
"""Create an inventory transaction record for audit trail."""
|
||||
transaction = InventoryTransaction.create_transaction(
|
||||
vendor_id=vendor_id,
|
||||
product_id=product_id,
|
||||
inventory_id=inventory.id if inventory else None,
|
||||
transaction_type=transaction_type,
|
||||
quantity_change=quantity_change,
|
||||
quantity_after=inventory.quantity if inventory else 0,
|
||||
reserved_after=inventory.reserved_quantity if inventory else 0,
|
||||
location=inventory.location if inventory else None,
|
||||
warehouse=inventory.warehouse if inventory else None,
|
||||
order_id=order.id,
|
||||
order_number=order.order_number,
|
||||
reason=reason,
|
||||
created_by="system",
|
||||
)
|
||||
db.add(transaction)
|
||||
return transaction
|
||||
|
||||
def reserve_for_order(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
skip_missing: bool = True,
|
||||
) -> dict:
|
||||
"""Reserve inventory for all items in an order."""
|
||||
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||
|
||||
reserved_count = 0
|
||||
skipped_items = []
|
||||
|
||||
for item in order.items:
|
||||
if self._is_placeholder_product(item):
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"reason": "placeholder_product",
|
||||
})
|
||||
continue
|
||||
|
||||
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
||||
|
||||
if not location:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": "no_inventory",
|
||||
})
|
||||
continue
|
||||
else:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {item.product_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
reserve_data = InventoryReserve(
|
||||
product_id=item.product_id,
|
||||
location=location,
|
||||
quantity=item.quantity,
|
||||
)
|
||||
updated_inventory = inventory_service.reserve_inventory(
|
||||
db, vendor_id, reserve_data
|
||||
)
|
||||
reserved_count += 1
|
||||
|
||||
self._log_transaction(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
product_id=item.product_id,
|
||||
inventory=updated_inventory,
|
||||
transaction_type=TransactionType.RESERVE,
|
||||
quantity_change=0,
|
||||
order=order,
|
||||
reason=f"Reserved for order {order.order_number}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Reserved {item.quantity} units of product {item.product_id} "
|
||||
f"for order {order.order_number}"
|
||||
)
|
||||
except InsufficientInventoryException:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": "insufficient_inventory",
|
||||
})
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number}: reserved {reserved_count} items, "
|
||||
f"skipped {len(skipped_items)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"order_number": order.order_number,
|
||||
"reserved_count": reserved_count,
|
||||
"skipped_items": skipped_items,
|
||||
}
|
||||
|
||||
def fulfill_order(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
skip_missing: bool = True,
|
||||
) -> dict:
|
||||
"""Fulfill (deduct) inventory when an order is shipped."""
|
||||
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||
|
||||
fulfilled_count = 0
|
||||
skipped_items = []
|
||||
|
||||
for item in order.items:
|
||||
if item.is_fully_shipped:
|
||||
continue
|
||||
|
||||
if self._is_placeholder_product(item):
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"reason": "placeholder_product",
|
||||
})
|
||||
continue
|
||||
|
||||
quantity_to_fulfill = item.remaining_quantity
|
||||
|
||||
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
||||
|
||||
if not location:
|
||||
inventory = (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == item.product_id,
|
||||
Inventory.vendor_id == vendor_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if inventory:
|
||||
location = inventory.location
|
||||
|
||||
if not location:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": "no_inventory",
|
||||
})
|
||||
continue
|
||||
else:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {item.product_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
reserve_data = InventoryReserve(
|
||||
product_id=item.product_id,
|
||||
location=location,
|
||||
quantity=quantity_to_fulfill,
|
||||
)
|
||||
updated_inventory = inventory_service.fulfill_reservation(
|
||||
db, vendor_id, reserve_data
|
||||
)
|
||||
fulfilled_count += 1
|
||||
|
||||
item.shipped_quantity = item.quantity
|
||||
item.inventory_fulfilled = True
|
||||
|
||||
self._log_transaction(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
product_id=item.product_id,
|
||||
inventory=updated_inventory,
|
||||
transaction_type=TransactionType.FULFILL,
|
||||
quantity_change=-quantity_to_fulfill,
|
||||
order=order,
|
||||
reason=f"Fulfilled for order {order.order_number}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fulfilled {quantity_to_fulfill} units of product {item.product_id} "
|
||||
f"for order {order.order_number}"
|
||||
)
|
||||
except (InsufficientInventoryException, InventoryNotFoundException) as e:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": str(e),
|
||||
})
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number}: fulfilled {fulfilled_count} items, "
|
||||
f"skipped {len(skipped_items)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"order_number": order.order_number,
|
||||
"fulfilled_count": fulfilled_count,
|
||||
"skipped_items": skipped_items,
|
||||
}
|
||||
|
||||
def fulfill_item(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
quantity: int | None = None,
|
||||
skip_missing: bool = True,
|
||||
) -> dict:
|
||||
"""Fulfill (deduct) inventory for a specific order item."""
|
||||
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||
|
||||
item = None
|
||||
for order_item in order.items:
|
||||
if order_item.id == item_id:
|
||||
item = order_item
|
||||
break
|
||||
|
||||
if not item:
|
||||
raise ValidationException(f"Item {item_id} not found in order {order_id}")
|
||||
|
||||
if item.is_fully_shipped:
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": 0,
|
||||
"message": "Item already fully shipped",
|
||||
}
|
||||
|
||||
quantity_to_fulfill = quantity or item.remaining_quantity
|
||||
|
||||
if quantity_to_fulfill > item.remaining_quantity:
|
||||
raise ValidationException(
|
||||
f"Cannot ship {quantity_to_fulfill} units - only {item.remaining_quantity} remaining"
|
||||
)
|
||||
|
||||
if quantity_to_fulfill <= 0:
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": 0,
|
||||
"message": "Nothing to fulfill",
|
||||
}
|
||||
|
||||
if self._is_placeholder_product(item):
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": 0,
|
||||
"message": "Placeholder product - skipped",
|
||||
}
|
||||
|
||||
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
||||
|
||||
if not location:
|
||||
inventory = (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == item.product_id,
|
||||
Inventory.vendor_id == vendor_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if inventory:
|
||||
location = inventory.location
|
||||
|
||||
if not location:
|
||||
if skip_missing:
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": 0,
|
||||
"message": "No inventory found",
|
||||
}
|
||||
else:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {item.product_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
reserve_data = InventoryReserve(
|
||||
product_id=item.product_id,
|
||||
location=location,
|
||||
quantity=quantity_to_fulfill,
|
||||
)
|
||||
updated_inventory = inventory_service.fulfill_reservation(
|
||||
db, vendor_id, reserve_data
|
||||
)
|
||||
|
||||
item.shipped_quantity += quantity_to_fulfill
|
||||
|
||||
if item.is_fully_shipped:
|
||||
item.inventory_fulfilled = True
|
||||
|
||||
self._log_transaction(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
product_id=item.product_id,
|
||||
inventory=updated_inventory,
|
||||
transaction_type=TransactionType.FULFILL,
|
||||
quantity_change=-quantity_to_fulfill,
|
||||
order=order,
|
||||
reason=f"Partial shipment for order {order.order_number}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fulfilled {quantity_to_fulfill} of {item.quantity} units "
|
||||
f"for item {item_id} in order {order.order_number}"
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": quantity_to_fulfill,
|
||||
"shipped_quantity": item.shipped_quantity,
|
||||
"remaining_quantity": item.remaining_quantity,
|
||||
"is_fully_shipped": item.is_fully_shipped,
|
||||
}
|
||||
|
||||
except (InsufficientInventoryException, InventoryNotFoundException) as e:
|
||||
if skip_missing:
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"fulfilled_quantity": 0,
|
||||
"message": str(e),
|
||||
}
|
||||
else:
|
||||
raise
|
||||
|
||||
def release_order_reservation(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
skip_missing: bool = True,
|
||||
) -> dict:
|
||||
"""Release reserved inventory when an order is cancelled."""
|
||||
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||
|
||||
released_count = 0
|
||||
skipped_items = []
|
||||
|
||||
for item in order.items:
|
||||
if self._is_placeholder_product(item):
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"reason": "placeholder_product",
|
||||
})
|
||||
continue
|
||||
|
||||
inventory = (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == item.product_id,
|
||||
Inventory.vendor_id == vendor_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not inventory:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": "no_inventory",
|
||||
})
|
||||
continue
|
||||
else:
|
||||
raise InventoryNotFoundException(
|
||||
f"No inventory found for product {item.product_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
reserve_data = InventoryReserve(
|
||||
product_id=item.product_id,
|
||||
location=inventory.location,
|
||||
quantity=item.quantity,
|
||||
)
|
||||
updated_inventory = inventory_service.release_reservation(
|
||||
db, vendor_id, reserve_data
|
||||
)
|
||||
released_count += 1
|
||||
|
||||
self._log_transaction(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
product_id=item.product_id,
|
||||
inventory=updated_inventory,
|
||||
transaction_type=TransactionType.RELEASE,
|
||||
quantity_change=0,
|
||||
order=order,
|
||||
reason=f"Released for cancelled order {order.order_number}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Released {item.quantity} units of product {item.product_id} "
|
||||
f"for cancelled order {order.order_number}"
|
||||
)
|
||||
except Exception as e:
|
||||
if skip_missing:
|
||||
skipped_items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"reason": str(e),
|
||||
})
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number}: released {released_count} items, "
|
||||
f"skipped {len(skipped_items)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"order_number": order.order_number,
|
||||
"released_count": released_count,
|
||||
"skipped_items": skipped_items,
|
||||
}
|
||||
|
||||
def handle_status_change(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
old_status: str | None,
|
||||
new_status: str,
|
||||
) -> dict | None:
|
||||
"""Handle inventory operations based on order status changes."""
|
||||
if old_status == new_status:
|
||||
return None
|
||||
|
||||
result = None
|
||||
|
||||
if new_status == "processing":
|
||||
result = self.reserve_for_order(db, vendor_id, order_id, skip_missing=True)
|
||||
logger.info(f"Order {order_id} confirmed: inventory reserved")
|
||||
|
||||
elif new_status == "shipped":
|
||||
result = self.fulfill_order(db, vendor_id, order_id, skip_missing=True)
|
||||
logger.info(f"Order {order_id} shipped: inventory fulfilled")
|
||||
|
||||
elif new_status == "partially_shipped":
|
||||
logger.info(
|
||||
f"Order {order_id} partially shipped: use fulfill_item for item-level fulfillment"
|
||||
)
|
||||
result = {"order_id": order_id, "status": "partially_shipped"}
|
||||
|
||||
elif new_status == "cancelled":
|
||||
if old_status and old_status not in ("cancelled", "refunded"):
|
||||
result = self.release_order_reservation(
|
||||
db, vendor_id, order_id, skip_missing=True
|
||||
)
|
||||
logger.info(f"Order {order_id} cancelled: reservations released")
|
||||
|
||||
return result
|
||||
|
||||
def get_shipment_status(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
) -> dict:
|
||||
"""Get detailed shipment status for an order."""
|
||||
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||
|
||||
items = []
|
||||
for item in order.items:
|
||||
items.append({
|
||||
"item_id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"product_name": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"shipped_quantity": item.shipped_quantity,
|
||||
"remaining_quantity": item.remaining_quantity,
|
||||
"is_fully_shipped": item.is_fully_shipped,
|
||||
"is_partially_shipped": item.is_partially_shipped,
|
||||
})
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"order_number": order.order_number,
|
||||
"order_status": order.status,
|
||||
"is_fully_shipped": order.is_fully_shipped,
|
||||
"is_partially_shipped": order.is_partially_shipped,
|
||||
"shipped_item_count": order.shipped_item_count,
|
||||
"total_item_count": len(order.items),
|
||||
"total_shipped_units": order.total_shipped_units,
|
||||
"total_ordered_units": order.total_ordered_units,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# Create service instance
|
||||
order_inventory_service = OrderInventoryService()
|
||||
466
app/modules/orders/services/order_item_exception_service.py
Normal file
466
app/modules/orders/services/order_item_exception_service.py
Normal file
@@ -0,0 +1,466 @@
|
||||
# app/modules/orders/services/order_item_exception_service.py
|
||||
"""
|
||||
Service for managing order item exceptions (unmatched products).
|
||||
|
||||
This service handles:
|
||||
- Creating exceptions when products are not found during order import
|
||||
- Resolving exceptions by assigning products
|
||||
- Auto-matching when new products are imported
|
||||
- Querying and statistics for exceptions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
ExceptionAlreadyResolvedException,
|
||||
InvalidProductForExceptionException,
|
||||
OrderItemExceptionNotFoundException,
|
||||
ProductNotFoundException,
|
||||
)
|
||||
from app.modules.orders.models.order import Order, OrderItem
|
||||
from app.modules.orders.models.order_item_exception import OrderItemException
|
||||
from models.database.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrderItemExceptionService:
|
||||
"""Service for order item exception CRUD and resolution workflow."""
|
||||
|
||||
# =========================================================================
|
||||
# Exception Creation
|
||||
# =========================================================================
|
||||
|
||||
def create_exception(
|
||||
self,
|
||||
db: Session,
|
||||
order_item: OrderItem,
|
||||
vendor_id: int,
|
||||
original_gtin: str | None,
|
||||
original_product_name: str | None,
|
||||
original_sku: str | None,
|
||||
exception_type: str = "product_not_found",
|
||||
) -> OrderItemException:
|
||||
"""Create an exception record for an unmatched order item."""
|
||||
exception = OrderItemException(
|
||||
order_item_id=order_item.id,
|
||||
vendor_id=vendor_id,
|
||||
original_gtin=original_gtin,
|
||||
original_product_name=original_product_name,
|
||||
original_sku=original_sku,
|
||||
exception_type=exception_type,
|
||||
status="pending",
|
||||
)
|
||||
db.add(exception)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Created order item exception {exception.id} for order item "
|
||||
f"{order_item.id}, GTIN: {original_gtin}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
# =========================================================================
|
||||
# Exception Retrieval
|
||||
# =========================================================================
|
||||
|
||||
def get_exception_by_id(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""Get an exception by ID, optionally filtered by vendor."""
|
||||
query = db.query(OrderItemException).filter(
|
||||
OrderItemException.id == exception_id
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
exception = query.first()
|
||||
|
||||
if not exception:
|
||||
raise OrderItemExceptionNotFoundException(exception_id)
|
||||
|
||||
return exception
|
||||
|
||||
def get_pending_exceptions(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[OrderItemException], int]:
|
||||
"""Get exceptions with pagination and filtering."""
|
||||
query = (
|
||||
db.query(OrderItemException)
|
||||
.join(OrderItem)
|
||||
.join(Order)
|
||||
.options(
|
||||
joinedload(OrderItemException.order_item).joinedload(OrderItem.order)
|
||||
)
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(OrderItemException.status == status)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
OrderItemException.original_gtin.ilike(search_pattern),
|
||||
OrderItemException.original_product_name.ilike(search_pattern),
|
||||
OrderItemException.original_sku.ilike(search_pattern),
|
||||
Order.order_number.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
|
||||
exceptions = (
|
||||
query.order_by(OrderItemException.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return exceptions, total
|
||||
|
||||
def get_exceptions_for_order(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> list[OrderItemException]:
|
||||
"""Get all exceptions for items in an order."""
|
||||
return (
|
||||
db.query(OrderItemException)
|
||||
.join(OrderItem)
|
||||
.filter(OrderItem.order_id == order_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Exception Statistics
|
||||
# =========================================================================
|
||||
|
||||
def get_exception_stats(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Get exception counts by status."""
|
||||
query = db.query(
|
||||
OrderItemException.status,
|
||||
func.count(OrderItemException.id).label("count"),
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
results = query.group_by(OrderItemException.status).all()
|
||||
|
||||
stats = {
|
||||
"pending": 0,
|
||||
"resolved": 0,
|
||||
"ignored": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
for status, count in results:
|
||||
if status in stats:
|
||||
stats[status] = count
|
||||
stats["total"] += count
|
||||
|
||||
orders_query = (
|
||||
db.query(func.count(func.distinct(OrderItem.order_id)))
|
||||
.join(OrderItemException)
|
||||
.filter(OrderItemException.status == "pending")
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
orders_query = orders_query.filter(
|
||||
OrderItemException.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
stats["orders_with_exceptions"] = orders_query.scalar() or 0
|
||||
|
||||
return stats
|
||||
|
||||
# =========================================================================
|
||||
# Exception Resolution
|
||||
# =========================================================================
|
||||
|
||||
def resolve_exception(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
product_id: int,
|
||||
resolved_by: int,
|
||||
notes: str | None = None,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""Resolve an exception by assigning a product."""
|
||||
exception = self.get_exception_by_id(db, exception_id, vendor_id)
|
||||
|
||||
if exception.status == "resolved":
|
||||
raise ExceptionAlreadyResolvedException(exception_id)
|
||||
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
if product.vendor_id != exception.vendor_id:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product belongs to a different vendor"
|
||||
)
|
||||
|
||||
if not product.is_active:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product is not active"
|
||||
)
|
||||
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = datetime.now(UTC)
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = notes
|
||||
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
|
||||
if product.marketplace_product:
|
||||
order_item.product_name = product.marketplace_product.get_title("en")
|
||||
order_item.product_sku = product.vendor_sku or order_item.product_sku
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Resolved exception {exception_id} with product {product_id} "
|
||||
f"by user {resolved_by}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
def ignore_exception(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
resolved_by: int,
|
||||
notes: str,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""Mark an exception as ignored."""
|
||||
exception = self.get_exception_by_id(db, exception_id, vendor_id)
|
||||
|
||||
if exception.status == "resolved":
|
||||
raise ExceptionAlreadyResolvedException(exception_id)
|
||||
|
||||
exception.status = "ignored"
|
||||
exception.resolved_at = datetime.now(UTC)
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = notes
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Ignored exception {exception_id} by user {resolved_by}: {notes}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
# =========================================================================
|
||||
# Auto-Matching
|
||||
# =========================================================================
|
||||
|
||||
def auto_match_by_gtin(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin: str,
|
||||
product_id: int,
|
||||
) -> list[OrderItemException]:
|
||||
"""Auto-resolve pending exceptions matching a GTIN."""
|
||||
if not gtin:
|
||||
return []
|
||||
|
||||
pending = (
|
||||
db.query(OrderItemException)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItemException.vendor_id == vendor_id,
|
||||
OrderItemException.original_gtin == gtin,
|
||||
OrderItemException.status == "pending",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not pending:
|
||||
return []
|
||||
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
logger.warning(f"Product {product_id} not found for auto-match")
|
||||
return []
|
||||
|
||||
resolved = []
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for exception in pending:
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = now
|
||||
exception.resolution_notes = "Auto-matched during product import"
|
||||
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
if product.marketplace_product:
|
||||
order_item.product_name = product.marketplace_product.get_title("en")
|
||||
|
||||
resolved.append(exception)
|
||||
|
||||
if resolved:
|
||||
db.flush()
|
||||
logger.info(
|
||||
f"Auto-matched {len(resolved)} exceptions for GTIN {gtin} "
|
||||
f"with product {product_id}"
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
def auto_match_batch(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin_to_product: dict[str, int],
|
||||
) -> int:
|
||||
"""Batch auto-match multiple GTINs after bulk import."""
|
||||
if not gtin_to_product:
|
||||
return 0
|
||||
|
||||
total_resolved = 0
|
||||
|
||||
for gtin, product_id in gtin_to_product.items():
|
||||
resolved = self.auto_match_by_gtin(db, vendor_id, gtin, product_id)
|
||||
total_resolved += len(resolved)
|
||||
|
||||
return total_resolved
|
||||
|
||||
# =========================================================================
|
||||
# Confirmation Checks
|
||||
# =========================================================================
|
||||
|
||||
def order_has_unresolved_exceptions(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> bool:
|
||||
"""Check if order has any unresolved exceptions."""
|
||||
count = (
|
||||
db.query(func.count(OrderItemException.id))
|
||||
.join(OrderItem)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItemException.status.in_(["pending", "ignored"]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
return count > 0
|
||||
|
||||
def get_unresolved_exception_count(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> int:
|
||||
"""Get count of unresolved exceptions for an order."""
|
||||
return (
|
||||
db.query(func.count(OrderItemException.id))
|
||||
.join(OrderItem)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItemException.status.in_(["pending", "ignored"]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
# =========================================================================
|
||||
# Bulk Operations
|
||||
# =========================================================================
|
||||
|
||||
def bulk_resolve_by_gtin(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin: str,
|
||||
product_id: int,
|
||||
resolved_by: int,
|
||||
notes: str | None = None,
|
||||
) -> int:
|
||||
"""Bulk resolve all pending exceptions for a GTIN."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
if product.vendor_id != vendor_id:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product belongs to a different vendor"
|
||||
)
|
||||
|
||||
pending = (
|
||||
db.query(OrderItemException)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItemException.vendor_id == vendor_id,
|
||||
OrderItemException.original_gtin == gtin,
|
||||
OrderItemException.status == "pending",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
resolution_notes = notes or f"Bulk resolved for GTIN {gtin}"
|
||||
|
||||
for exception in pending:
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = now
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = resolution_notes
|
||||
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
if product.marketplace_product:
|
||||
order_item.product_name = product.marketplace_product.get_title("en")
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Bulk resolved {len(pending)} exceptions for GTIN {gtin} "
|
||||
f"with product {product_id} by user {resolved_by}"
|
||||
)
|
||||
|
||||
return len(pending)
|
||||
|
||||
|
||||
# Global service instance
|
||||
order_item_exception_service = OrderItemExceptionService()
|
||||
1325
app/modules/orders/services/order_service.py
Normal file
1325
app/modules/orders/services/order_service.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user