refactor: migrate modules from re-exports to canonical implementations

Move actual code implementations into module directories:
- orders: 5 services, 4 models, order/invoice schemas
- inventory: 3 services, 2 models, 30+ schemas
- customers: 3 services, 2 models, customer schemas
- messaging: 3 services, 2 models, message/notification schemas
- monitoring: background_tasks_service
- marketplace: 5+ services including letzshop submodule
- dev_tools: code_quality_service, test_runner_service
- billing: billing_service
- contracts: definition.py

Legacy files in app/services/, models/database/, models/schema/
now re-export from canonical module locations for backwards
compatibility. Architecture validator passes with 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

@@ -2,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,
)

View 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()

View 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()

View 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()

View 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()

File diff suppressed because it is too large Load Diff