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:
@@ -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')
|
||||||
@@ -14,11 +14,13 @@ Vendor selection is passed as a request parameter.
|
|||||||
|
|
||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
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 app.services.inventory_service import inventory_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.inventory import (
|
from models.schema.inventory import (
|
||||||
@@ -284,3 +286,95 @@ def delete_inventory(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return InventoryMessageResponse(message="Inventory deleted successfully")
|
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,
|
||||||
|
)
|
||||||
|
|||||||
250
app/services/inventory_import_service.py
Normal file
250
app/services/inventory_import_service.py
Normal 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()
|
||||||
@@ -23,6 +23,13 @@
|
|||||||
width='w-80'
|
width='w-80'
|
||||||
) }}
|
) }}
|
||||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
{{ 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>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
@@ -456,6 +463,139 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
# models/database/inventory.py
|
# 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 import Column, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -14,11 +26,17 @@ class Inventory(Base, TimestampMixin):
|
|||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.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)
|
quantity = Column(Integer, nullable=False, default=0)
|
||||||
reserved_quantity = Column(Integer, 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)
|
gtin = Column(String, index=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -28,10 +46,10 @@ class Inventory(Base, TimestampMixin):
|
|||||||
# Constraints
|
# Constraints
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
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_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):
|
def __repr__(self):
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function adminInventory() {
|
|||||||
showAdjustModal: false,
|
showAdjustModal: false,
|
||||||
showSetModal: false,
|
showSetModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
|
showImportModal: false,
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
@@ -76,6 +77,17 @@ function adminInventory() {
|
|||||||
quantity: 0
|
quantity: 0
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Import form
|
||||||
|
importForm: {
|
||||||
|
vendor_id: '',
|
||||||
|
warehouse: 'strassen',
|
||||||
|
file: null,
|
||||||
|
clear_existing: false
|
||||||
|
},
|
||||||
|
importing: false,
|
||||||
|
importResult: null,
|
||||||
|
vendorsList: [],
|
||||||
|
|
||||||
// Debounce timer
|
// Debounce timer
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
|
|
||||||
@@ -144,6 +156,9 @@ function adminInventory() {
|
|||||||
this.initVendorSelector();
|
this.initVendorSelector();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load vendors list for import modal
|
||||||
|
await this.loadVendorsList();
|
||||||
|
|
||||||
// Check localStorage for saved vendor
|
// Check localStorage for saved vendor
|
||||||
const savedVendorId = localStorage.getItem('inventory_selected_vendor_id');
|
const savedVendorId = localStorage.getItem('inventory_selected_vendor_id');
|
||||||
if (savedVendorId) {
|
if (savedVendorId) {
|
||||||
@@ -501,6 +516,93 @@ function adminInventory() {
|
|||||||
this.pagination.page = pageNum;
|
this.pagination.page = pageNum;
|
||||||
this.loadInventory();
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user