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

@@ -1,250 +1,25 @@
# app/services/inventory_import_service.py
"""
Inventory import service for bulk importing stock from TSV/CSV files.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports two formats:
1. One row per unit (quantity = count of rows):
BIN EAN PRODUCT
SA-10-02 0810050910101 Product Name
SA-10-02 0810050910101 Product Name (2nd unit)
The canonical implementation is now in:
app/modules/inventory/services/inventory_import_service.py
2. With explicit quantity column:
BIN EAN PRODUCT QUANTITY
SA-10-02 0810050910101 Product Name 12
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
Products are matched by GTIN/EAN to existing vendor products.
from app.modules.inventory.services import inventory_import_service
"""
import csv
import io
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from app.modules.inventory.services.inventory_import_service import (
inventory_import_service,
InventoryImportService,
ImportResult,
)
from sqlalchemy.orm import Session
from models.database.inventory import Inventory
from models.database.product import Product
logger = logging.getLogger(__name__)
@dataclass
class ImportResult:
"""Result of an inventory import operation."""
success: bool = True
total_rows: int = 0
entries_created: int = 0
entries_updated: int = 0
quantity_imported: int = 0
unmatched_gtins: list = field(default_factory=list)
errors: list = field(default_factory=list)
class InventoryImportService:
"""Service for importing inventory from TSV/CSV files."""
def import_from_text(
self,
db: Session,
content: str,
vendor_id: int,
warehouse: str = "strassen",
delimiter: str = "\t",
clear_existing: bool = False,
) -> ImportResult:
"""
Import inventory from TSV/CSV text content.
Args:
db: Database session
content: TSV/CSV content as string
vendor_id: Vendor ID for inventory
warehouse: Warehouse name (default: "strassen")
delimiter: Column delimiter (default: tab)
clear_existing: If True, clear existing inventory before import
Returns:
ImportResult with summary and errors
"""
result = ImportResult()
try:
# Parse CSV/TSV
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
# Normalize headers (case-insensitive, strip whitespace)
if reader.fieldnames:
reader.fieldnames = [h.strip().upper() for h in reader.fieldnames]
# Validate required columns
required = {"BIN", "EAN"}
if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
result.success = False
result.errors.append(
f"Missing required columns. Found: {reader.fieldnames}, Required: {required}"
)
return result
has_quantity = "QUANTITY" in reader.fieldnames
# Group entries by (EAN, BIN)
# Key: (ean, bin) -> quantity
inventory_data: dict[tuple[str, str], int] = defaultdict(int)
product_names: dict[str, str] = {} # EAN -> product name (for logging)
for row in reader:
result.total_rows += 1
ean = row.get("EAN", "").strip()
bin_loc = row.get("BIN", "").strip()
product_name = row.get("PRODUCT", "").strip()
if not ean or not bin_loc:
result.errors.append(f"Row {result.total_rows}: Missing EAN or BIN")
continue
# Get quantity
if has_quantity:
try:
qty = int(row.get("QUANTITY", "1").strip())
except ValueError:
result.errors.append(
f"Row {result.total_rows}: Invalid quantity '{row.get('QUANTITY')}'"
)
continue
else:
qty = 1 # Each row = 1 unit
inventory_data[(ean, bin_loc)] += qty
if product_name:
product_names[ean] = product_name
# Clear existing inventory if requested
if clear_existing:
db.query(Inventory).filter(
Inventory.vendor_id == vendor_id,
Inventory.warehouse == warehouse,
).delete()
db.flush()
# Build EAN to Product mapping for this vendor
products = (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.gtin.isnot(None),
)
.all()
)
ean_to_product: dict[str, Product] = {p.gtin: p for p in products if p.gtin}
# Track unmatched GTINs
unmatched: dict[str, int] = {} # EAN -> total quantity
# Process inventory entries
for (ean, bin_loc), quantity in inventory_data.items():
product = ean_to_product.get(ean)
if not product:
# Track unmatched
if ean not in unmatched:
unmatched[ean] = 0
unmatched[ean] += quantity
continue
# Upsert inventory entry
existing = (
db.query(Inventory)
.filter(
Inventory.product_id == product.id,
Inventory.warehouse == warehouse,
Inventory.bin_location == bin_loc,
)
.first()
)
if existing:
existing.quantity = quantity
existing.gtin = ean
result.entries_updated += 1
else:
inv = Inventory(
product_id=product.id,
vendor_id=vendor_id,
warehouse=warehouse,
bin_location=bin_loc,
location=bin_loc, # Legacy field
quantity=quantity,
gtin=ean,
)
db.add(inv)
result.entries_created += 1
result.quantity_imported += quantity
db.flush()
# Format unmatched GTINs for result
for ean, qty in unmatched.items():
product_name = product_names.get(ean, "Unknown")
result.unmatched_gtins.append(
{"gtin": ean, "quantity": qty, "product_name": product_name}
)
if result.unmatched_gtins:
logger.warning(
f"Import had {len(result.unmatched_gtins)} unmatched GTINs"
)
except Exception as e:
logger.exception("Inventory import failed")
result.success = False
result.errors.append(str(e))
return result
def import_from_file(
self,
db: Session,
file_path: str,
vendor_id: int,
warehouse: str = "strassen",
clear_existing: bool = False,
) -> ImportResult:
"""
Import inventory from a TSV/CSV file.
Args:
db: Database session
file_path: Path to TSV/CSV file
vendor_id: Vendor ID for inventory
warehouse: Warehouse name
clear_existing: If True, clear existing inventory before import
Returns:
ImportResult with summary and errors
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return ImportResult(success=False, errors=[f"Failed to read file: {e}"])
# Detect delimiter
first_line = content.split("\n")[0] if content else ""
delimiter = "\t" if "\t" in first_line else ","
return self.import_from_text(
db=db,
content=content,
vendor_id=vendor_id,
warehouse=warehouse,
delimiter=delimiter,
clear_existing=clear_existing,
)
# Singleton instance
inventory_import_service = InventoryImportService()
__all__ = [
"inventory_import_service",
"InventoryImportService",
"ImportResult",
]