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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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'
|
||||
) }}
|
||||
{{ 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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user