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