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

@@ -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 %}