diff --git a/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py b/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py new file mode 100644 index 00000000..a3dbd375 --- /dev/null +++ b/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py @@ -0,0 +1,76 @@ +"""add_warehouse_and_bin_location_to_inventory + +Revision ID: e1bfb453fbe9 +Revises: j8e9f0a1b2c3 +Create Date: 2025-12-25 12:21:24.006548 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision: str = 'e1bfb453fbe9' +down_revision: Union[str, None] = 'j8e9f0a1b2c3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Check if columns already exist (idempotent) + result = conn.execute(text("PRAGMA table_info(inventory)")) + columns = {row[1] for row in result.fetchall()} + + if 'warehouse' not in columns: + op.add_column('inventory', sa.Column('warehouse', sa.String(), nullable=False, server_default='strassen')) + + if 'bin_location' not in columns: + op.add_column('inventory', sa.Column('bin_location', sa.String(), nullable=False, server_default='')) + + # Migrate existing data: copy location to bin_location, set default warehouse + conn.execute(text(""" + UPDATE inventory + SET bin_location = COALESCE(location, 'UNKNOWN'), + warehouse = 'strassen' + WHERE bin_location IS NULL OR bin_location = '' + """)) + + # Create indexes if they don't exist + indexes = conn.execute(text("PRAGMA index_list(inventory)")) + existing_indexes = {row[1] for row in indexes.fetchall()} + + if 'idx_inventory_warehouse_bin' not in existing_indexes: + op.create_index('idx_inventory_warehouse_bin', 'inventory', ['warehouse', 'bin_location'], unique=False) + if 'ix_inventory_bin_location' not in existing_indexes: + op.create_index(op.f('ix_inventory_bin_location'), 'inventory', ['bin_location'], unique=False) + if 'ix_inventory_warehouse' not in existing_indexes: + op.create_index(op.f('ix_inventory_warehouse'), 'inventory', ['warehouse'], unique=False) + + +def downgrade() -> None: + conn = op.get_bind() + + # Check which indexes exist before dropping + indexes = conn.execute(text("PRAGMA index_list(inventory)")) + existing_indexes = {row[1] for row in indexes.fetchall()} + + if 'ix_inventory_warehouse' in existing_indexes: + op.drop_index(op.f('ix_inventory_warehouse'), table_name='inventory') + if 'ix_inventory_bin_location' in existing_indexes: + op.drop_index(op.f('ix_inventory_bin_location'), table_name='inventory') + if 'idx_inventory_warehouse_bin' in existing_indexes: + op.drop_index('idx_inventory_warehouse_bin', table_name='inventory') + + # Check if columns exist before dropping + result = conn.execute(text("PRAGMA table_info(inventory)")) + columns = {row[1] for row in result.fetchall()} + + if 'bin_location' in columns: + op.drop_column('inventory', 'bin_location') + if 'warehouse' in columns: + op.drop_column('inventory', 'warehouse') diff --git a/app/api/v1/admin/inventory.py b/app/api/v1/admin/inventory.py index d5a2cd30..a7a02638 100644 --- a/app/api/v1/admin/inventory.py +++ b/app/api/v1/admin/inventory.py @@ -14,11 +14,13 @@ Vendor selection is passed as a request parameter. import logging -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile +from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db +from app.services.inventory_import_service import inventory_import_service from app.services.inventory_service import inventory_service from models.database.user import User from models.schema.inventory import ( @@ -284,3 +286,95 @@ def delete_inventory( db.commit() return InventoryMessageResponse(message="Inventory deleted successfully") + + +# ============================================================================ +# Import Endpoints +# ============================================================================ + + +class UnmatchedGtin(BaseModel): + """GTIN that couldn't be matched to a product.""" + + gtin: str + quantity: int + product_name: str + + +class InventoryImportResponse(BaseModel): + """Response from inventory import.""" + + success: bool + total_rows: int + entries_created: int + entries_updated: int + quantity_imported: int + unmatched_gtins: list[UnmatchedGtin] + errors: list[str] + + +@router.post("/import", response_model=InventoryImportResponse) +async def import_inventory( + file: UploadFile = File(..., description="TSV/CSV file with BIN, EAN, PRODUCT, QUANTITY columns"), + vendor_id: int = Form(..., description="Vendor ID"), + warehouse: str = Form("strassen", description="Warehouse name"), + clear_existing: bool = Form(False, description="Clear existing inventory before import"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Import inventory from a TSV/CSV file. + + File format (TSV recommended): + - Required columns: BIN, EAN + - Optional columns: PRODUCT (for display), QUANTITY (defaults to 1 per row) + + If QUANTITY column is present, each row represents the quantity specified. + If QUANTITY is absent, each row counts as 1 unit (rows with same EAN+BIN are summed). + + Products are matched by GTIN/EAN. Unmatched GTINs are reported in the response. + """ + # Verify vendor exists + inventory_service.verify_vendor_exists(db, vendor_id) + + # Read file content + content = await file.read() + try: + content_str = content.decode("utf-8") + except UnicodeDecodeError: + content_str = content.decode("latin-1") + + # Detect delimiter + first_line = content_str.split("\n")[0] if content_str else "" + delimiter = "\t" if "\t" in first_line else "," + + # Run import + result = inventory_import_service.import_from_text( + db=db, + content=content_str, + vendor_id=vendor_id, + warehouse=warehouse, + delimiter=delimiter, + clear_existing=clear_existing, + ) + + if result.success: + db.commit() + logger.info( + f"Admin {current_admin.email} imported inventory: " + f"{result.entries_created} created, {result.entries_updated} updated, " + f"{result.quantity_imported} total units" + ) + else: + db.rollback() + logger.error(f"Inventory import failed: {result.errors}") + + return InventoryImportResponse( + success=result.success, + total_rows=result.total_rows, + entries_created=result.entries_created, + entries_updated=result.entries_updated, + quantity_imported=result.quantity_imported, + unmatched_gtins=[UnmatchedGtin(**g) for g in result.unmatched_gtins], + errors=result.errors, + ) diff --git a/app/services/inventory_import_service.py b/app/services/inventory_import_service.py new file mode 100644 index 00000000..b7d3cfa7 --- /dev/null +++ b/app/services/inventory_import_service.py @@ -0,0 +1,250 @@ +# app/services/inventory_import_service.py +""" +Inventory import service for bulk importing stock from TSV/CSV files. + +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) + +2. With explicit quantity column: + BIN EAN PRODUCT QUANTITY + SA-10-02 0810050910101 Product Name 12 + +Products are matched by GTIN/EAN to existing vendor products. +""" + +import csv +import io +import logging +from collections import defaultdict +from dataclasses import dataclass, field + +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() diff --git a/app/templates/admin/inventory.html b/app/templates/admin/inventory.html index 6384039f..da5d633d 100644 --- a/app/templates/admin/inventory.html +++ b/app/templates/admin/inventory.html @@ -23,6 +23,13 @@ width='w-80' ) }} {{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }} + {% endcall %} @@ -456,6 +463,139 @@ {% endcall %} + + +{% call modal_simple('importModal', 'Import Inventory', show_var='showImportModal', size='md') %} +
+ Upload a TSV or CSV file to import inventory. Products are matched by EAN/GTIN. +
+ + +File Format:
+BIN EAN PRODUCT QUANTITY
+
+ Required: BIN, EAN
+ Optional: PRODUCT (display only), QUANTITY (defaults to 1 per row)
+