feat: add inventory CSV import with warehouse/bin locations

- Add warehouse and bin_location columns to Inventory model
- Create inventory_import_service for bulk TSV/CSV import
- Add POST /api/v1/admin/inventory/import endpoint
- Add Import button and modal to inventory admin page
- Support both single-unit rows and explicit QUANTITY column

File format: BIN, EAN, PRODUCT (optional), QUANTITY (optional)
Products matched by GTIN/EAN, unmatched items reported.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 12:27:12 +01:00
parent d65ffa58f6
commit 63396ea6b6
6 changed files with 685 additions and 5 deletions

View File

@@ -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')

View File

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

View File

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

View File

@@ -23,6 +23,13 @@
width='w-80'
) }}
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
<button
@click="showImportModal = true"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Import
</button>
</div>
{% endcall %}
@@ -456,6 +463,139 @@
</div>
</div>
{% endcall %}
<!-- Import Inventory Modal -->
{% call modal_simple('importModal', 'Import Inventory', show_var='showImportModal', size='md') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Upload a TSV or CSV file to import inventory. Products are matched by EAN/GTIN.
</p>
<!-- File Format Info -->
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2">File Format:</p>
<p class="text-blue-700 dark:text-blue-300 text-xs font-mono mb-1">BIN EAN PRODUCT QUANTITY</p>
<p class="text-blue-600 dark:text-blue-400 text-xs">
<strong>Required:</strong> BIN, EAN<br>
<strong>Optional:</strong> PRODUCT (display only), QUANTITY (defaults to 1 per row)
</p>
</div>
<form @submit.prevent="executeImport()">
<!-- Vendor Selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vendor <span class="text-red-500">*</span>
</label>
<select
x-model="importForm.vendor_id"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Select vendor...</option>
<template x-for="vendor in vendorsList" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
</template>
</select>
</div>
<!-- Warehouse -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Warehouse
</label>
<input
type="text"
x-model="importForm.warehouse"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="strassen"
>
</div>
<!-- File Upload -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
File <span class="text-red-500">*</span>
</label>
<input
type="file"
@change="importForm.file = $event.target.files[0]"
accept=".tsv,.csv,.txt"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 file:mr-4 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-purple-100 file:text-purple-700 dark:file:bg-purple-900 dark:file:text-purple-300 hover:file:bg-purple-200"
>
</div>
<!-- Clear Existing Option -->
<div class="mb-4">
<label class="flex items-center">
<input
type="checkbox"
x-model="importForm.clear_existing"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Clear existing inventory for this warehouse before import
</span>
</label>
</div>
<!-- Import Result -->
<template x-if="importResult">
<div class="p-3 rounded-lg" :class="importResult.success ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'">
<p class="font-medium" :class="importResult.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'">
<span x-text="importResult.success ? 'Import Successful!' : 'Import Failed'"></span>
</p>
<template x-if="importResult.success">
<div class="text-sm text-green-700 dark:text-green-300 mt-1">
<p>Rows processed: <span x-text="importResult.total_rows"></span></p>
<p>Entries created: <span x-text="importResult.entries_created"></span></p>
<p>Entries updated: <span x-text="importResult.entries_updated"></span></p>
<p>Total quantity: <span x-text="importResult.quantity_imported"></span></p>
<template x-if="importResult.unmatched_gtins?.length > 0">
<div class="mt-2">
<p class="font-medium text-orange-600 dark:text-orange-400">
Unmatched GTINs (<span x-text="importResult.unmatched_gtins.length"></span>):
</p>
<ul class="text-xs mt-1 max-h-32 overflow-y-auto">
<template x-for="item in importResult.unmatched_gtins" :key="item.gtin">
<li class="font-mono" x-text="item.gtin + ' (' + item.quantity + ' units) - ' + item.product_name"></li>
</template>
</ul>
</div>
</template>
</div>
</template>
<template x-if="!importResult.success && importResult.errors?.length > 0">
<ul class="text-sm text-red-700 dark:text-red-300 mt-1">
<template x-for="error in importResult.errors" :key="error">
<li x-text="error"></li>
</template>
</ul>
</template>
</div>
</template>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeImportModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span x-text="importResult?.success ? 'Close' : 'Cancel'"></span>
</button>
<button
type="submit"
:disabled="importing || !importForm.vendor_id || !importForm.file"
x-show="!importResult?.success"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-text="importing ? 'Importing...' : 'Import'"></span>
</button>
</div>
</form>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,4 +1,16 @@
# models/database/inventory.py
"""
Inventory model for tracking stock at warehouse/bin locations.
Each entry represents a quantity of a product at a specific bin location
within a warehouse. Products can be scattered across multiple bins.
Example:
Warehouse: "strassen"
Bin: "SA-10-02"
Product: GTIN 4007817144145
Quantity: 3
"""
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
@@ -14,11 +26,17 @@ class Inventory(Base, TimestampMixin):
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
location = Column(String, nullable=False, index=True)
# Location: warehouse + bin
warehouse = Column(String, nullable=False, default="strassen", index=True)
bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02"
# Legacy field - kept for backward compatibility, will be removed
location = Column(String, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0)
# Optional: Keep GTIN for reference/reporting
# Keep GTIN for reference/reporting (matches Product.gtin)
gtin = Column(String, index=True)
# Relationships
@@ -28,10 +46,10 @@ class Inventory(Base, TimestampMixin):
# Constraints
__table_args__ = (
UniqueConstraint(
"product_id", "location", name="uq_inventory_product_location"
"product_id", "warehouse", "bin_location", name="uq_inventory_product_warehouse_bin"
),
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
Index("idx_inventory_product_location", "product_id", "location"),
Index("idx_inventory_warehouse_bin", "warehouse", "bin_location"),
)
def __repr__(self):

View File

@@ -65,6 +65,7 @@ function adminInventory() {
showAdjustModal: false,
showSetModal: false,
showDeleteModal: false,
showImportModal: false,
selectedItem: null,
// Form data
@@ -76,6 +77,17 @@ function adminInventory() {
quantity: 0
},
// Import form
importForm: {
vendor_id: '',
warehouse: 'strassen',
file: null,
clear_existing: false
},
importing: false,
importResult: null,
vendorsList: [],
// Debounce timer
searchTimeout: null,
@@ -144,6 +156,9 @@ function adminInventory() {
this.initVendorSelector();
});
// Load vendors list for import modal
await this.loadVendorsList();
// Check localStorage for saved vendor
const savedVendorId = localStorage.getItem('inventory_selected_vendor_id');
if (savedVendorId) {
@@ -501,6 +516,93 @@ function adminInventory() {
this.pagination.page = pageNum;
this.loadInventory();
}
},
// ============================================================
// Import Methods
// ============================================================
/**
* Load vendors list for import modal
*/
async loadVendorsList() {
try {
const response = await apiClient.get('/admin/vendors', { limit: 100 });
this.vendorsList = response.vendors || [];
} catch (error) {
adminInventoryLog.error('Failed to load vendors:', error);
}
},
/**
* Execute inventory import
*/
async executeImport() {
if (!this.importForm.vendor_id || !this.importForm.file) {
Utils.showToast('Please select a vendor and file', 'error');
return;
}
this.importing = true;
this.importResult = null;
try {
const formData = new FormData();
formData.append('file', this.importForm.file);
formData.append('vendor_id', this.importForm.vendor_id);
formData.append('warehouse', this.importForm.warehouse || 'strassen');
formData.append('clear_existing', this.importForm.clear_existing);
const response = await fetch('/api/v1/admin/inventory/import', {
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Import failed');
}
this.importResult = await response.json();
if (this.importResult.success) {
adminInventoryLog.info('Import successful:', this.importResult);
Utils.showToast(
`Imported ${this.importResult.quantity_imported} units (${this.importResult.entries_created} new, ${this.importResult.entries_updated} updated)`,
'success'
);
// Refresh inventory list
await this.refresh();
} else {
Utils.showToast('Import completed with errors', 'warning');
}
} catch (error) {
adminInventoryLog.error('Import failed:', error);
this.importResult = {
success: false,
errors: [error.message || 'Import failed']
};
Utils.showToast(error.message || 'Import failed', 'error');
} finally {
this.importing = false;
}
},
/**
* Close import modal and reset form
*/
closeImportModal() {
this.showImportModal = false;
this.importResult = null;
this.importForm = {
vendor_id: '',
warehouse: 'strassen',
file: null,
clear_existing: false
};
}
};
}