feat(pos): sistema de imagenes — upload, thumbnail, display en inventario y catalogo
- Add POST/DELETE /items/{id}/image endpoints with Pillow processing (resize 800px, thumbnail 300px, JPEG 85%)
- Validate file type (jpg/png/webp) and size (max 5MB)
- Show image in product detail modal with upload/delete buttons
- Enrich catalog parts list with local inventory image when available
- Image directory created automatically on first upload (pos/static/images/parts/)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
# /home/Autopartes/pos/blueprints/inventory_bp.py
|
# /home/Autopartes/pos/blueprints/inventory_bp.py
|
||||||
"""Inventory blueprint: CRUD for inventory items + stock operations + reports."""
|
"""Inventory blueprint: CRUD for inventory items + stock operations + reports."""
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
@@ -312,6 +314,132 @@ def update_item(item_id):
|
|||||||
return jsonify({'message': 'Item updated'})
|
return jsonify({'message': 'Item updated'})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Image Upload / Delete ─────────────────────
|
||||||
|
|
||||||
|
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'images', 'parts')
|
||||||
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
|
||||||
|
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _process_image(file_data, max_size=800):
|
||||||
|
"""Resize image to max_size and convert to JPEG."""
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(io.BytesIO(file_data))
|
||||||
|
img.thumbnail((max_size, max_size), Image.LANCZOS)
|
||||||
|
if img.mode not in ('RGB', 'L'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
output = io.BytesIO()
|
||||||
|
img.save(output, format='JPEG', quality=85)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _process_thumbnail(file_data, size=300):
|
||||||
|
"""Generate a smaller thumbnail."""
|
||||||
|
return _process_image(file_data, max_size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_image_files(tenant_id, item_id):
|
||||||
|
"""Remove image and thumbnail for the given item from disk."""
|
||||||
|
for suffix in ('', '_thumb'):
|
||||||
|
path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}{suffix}.jpg')
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def upload_image(item_id):
|
||||||
|
"""Upload an image for an inventory item. Accepts multipart file upload.
|
||||||
|
Validates file type (jpg, png, webp) and size (max 5 MB).
|
||||||
|
Saves resized image + thumbnail, updates inventory.image_url.
|
||||||
|
"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': 'No file provided'}), 400
|
||||||
|
|
||||||
|
f = request.files['file']
|
||||||
|
if not f.filename:
|
||||||
|
return jsonify({'error': 'Empty filename'}), 400
|
||||||
|
|
||||||
|
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
return jsonify({'error': f'File type not allowed. Use: {", ".join(ALLOWED_EXTENSIONS)}'}), 400
|
||||||
|
|
||||||
|
raw = f.read()
|
||||||
|
if len(raw) > MAX_IMAGE_BYTES:
|
||||||
|
return jsonify({'error': 'File too large (max 5 MB)'}), 400
|
||||||
|
|
||||||
|
# Verify item exists
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Process and save main image
|
||||||
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||||
|
main_data = _process_image(raw)
|
||||||
|
main_filename = f'{g.tenant_id}_{item_id}.jpg'
|
||||||
|
main_path = os.path.join(IMAGES_DIR, main_filename)
|
||||||
|
with open(main_path, 'wb') as out:
|
||||||
|
out.write(main_data)
|
||||||
|
|
||||||
|
# Process and save thumbnail
|
||||||
|
thumb_data = _process_thumbnail(raw)
|
||||||
|
thumb_filename = f'{g.tenant_id}_{item_id}_thumb.jpg'
|
||||||
|
thumb_path = os.path.join(IMAGES_DIR, thumb_filename)
|
||||||
|
with open(thumb_path, 'wb') as out:
|
||||||
|
out.write(thumb_data)
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
image_url = f'/pos/static/images/parts/{main_filename}'
|
||||||
|
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (image_url, item_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_action(conn, 'IMAGE_UPLOAD', 'inventory', item_id,
|
||||||
|
new_value={'image_url': image_url})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'image_url': image_url,
|
||||||
|
'thumbnail_url': f'/pos/static/images/parts/{thumb_filename}',
|
||||||
|
'message': 'Image uploaded'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def delete_image(item_id):
|
||||||
|
"""Delete the image for an inventory item. Removes files from disk and sets image_url = NULL."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT image_url FROM inventory WHERE id = %s", (item_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
# Remove files from disk
|
||||||
|
_delete_image_files(g.tenant_id, item_id)
|
||||||
|
|
||||||
|
# Clear DB
|
||||||
|
cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_action(conn, 'IMAGE_DELETE', 'inventory', item_id,
|
||||||
|
old_value={'image_url': row[0]})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'message': 'Image deleted'})
|
||||||
|
|
||||||
|
|
||||||
# ─── Stock Operations ──────────────────────────
|
# ─── Stock Operations ──────────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/purchase', methods=['POST'])
|
@inventory_bp.route('/purchase', methods=['POST'])
|
||||||
|
|||||||
@@ -271,12 +271,14 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
|
|||||||
part_id = r[0]
|
part_id = r[0]
|
||||||
oem = r[1]
|
oem = r[1]
|
||||||
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
|
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
|
||||||
|
# Prefer local inventory image over catalog image
|
||||||
|
image_url = (local.get('image_url') if local else None) or r[6]
|
||||||
items.append({
|
items.append({
|
||||||
'id_part': part_id,
|
'id_part': part_id,
|
||||||
'oem_part_number': oem,
|
'oem_part_number': oem,
|
||||||
'name': r[3] or r[2], # prefer Spanish name
|
'name': r[3] or r[2], # prefer Spanish name
|
||||||
'description': r[5] or r[4],
|
'description': r[5] or r[4],
|
||||||
'image_url': r[6],
|
'image_url': image_url,
|
||||||
'local_stock': local['stock'] if local else 0,
|
'local_stock': local['stock'] if local else 0,
|
||||||
'local_price': local['price_1'] if local else None,
|
'local_price': local['price_1'] if local else None,
|
||||||
'bodega_count': bodega_map.get(part_id, 0),
|
'bodega_count': bodega_map.get(part_id, 0),
|
||||||
@@ -558,7 +560,8 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids)
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.catalog_part_id,
|
SELECT i.id, i.part_number, i.catalog_part_id,
|
||||||
i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
||||||
COALESCE(SUM(io.quantity), 0) AS stock
|
COALESCE(SUM(io.quantity), 0) AS stock,
|
||||||
|
i.image_url
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
||||||
WHERE ({where}) AND i.is_active = true{branch_filter}
|
WHERE ({where}) AND i.is_active = true{branch_filter}
|
||||||
@@ -577,6 +580,7 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids)
|
|||||||
'cost': float(r[6]) if r[6] else 0,
|
'cost': float(r[6]) if r[6] else 0,
|
||||||
'tax_rate': float(r[7]) if r[7] else 0.16,
|
'tax_rate': float(r[7]) if r[7] else 0.16,
|
||||||
'stock': r[8],
|
'stock': r[8],
|
||||||
|
'image_url': r[9],
|
||||||
}
|
}
|
||||||
if r[1]:
|
if r[1]:
|
||||||
result[r[1]] = entry
|
result[r[1]] = entry
|
||||||
@@ -598,7 +602,8 @@ def _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_par
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
||||||
i.location, i.unit, i.barcode,
|
i.location, i.unit, i.barcode,
|
||||||
COALESCE(SUM(io.quantity), 0) AS stock
|
COALESCE(SUM(io.quantity), 0) AS stock,
|
||||||
|
i.image_url
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
||||||
WHERE (i.part_number = %s OR i.catalog_part_id = %s)
|
WHERE (i.part_number = %s OR i.catalog_part_id = %s)
|
||||||
@@ -624,6 +629,7 @@ def _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_par
|
|||||||
'unit': row[7] or 'PZA',
|
'unit': row[7] or 'PZA',
|
||||||
'barcode': row[8],
|
'barcode': row[8],
|
||||||
'stock': row[9],
|
'stock': row[9],
|
||||||
|
'image_url': row[10],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -443,6 +443,59 @@
|
|||||||
// PRODUCT DETAIL MODAL (shows item info + movement history)
|
// PRODUCT DETAIL MODAL (shows item info + movement history)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
function uploadItemImage(itemId) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/jpeg,image/png,image/webp';
|
||||||
|
input.onchange = function () {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
var file = input.files[0];
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Imagen demasiado grande (max 5 MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
var statusEl = document.getElementById('imgUploadStatus');
|
||||||
|
if (statusEl) statusEl.textContent = 'Subiendo...';
|
||||||
|
fetch(API + '/items/' + itemId + '/image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
body: fd
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (result) {
|
||||||
|
if (result.image_url) {
|
||||||
|
// Refresh detail view
|
||||||
|
viewProductDetail(itemId);
|
||||||
|
} else {
|
||||||
|
if (statusEl) statusEl.textContent = result.error || 'Error';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (statusEl) statusEl.textContent = 'Error de red';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItemImage(itemId) {
|
||||||
|
if (!confirm('Eliminar imagen de este producto?')) return;
|
||||||
|
fetch(API + '/items/' + itemId + '/image', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (result) {
|
||||||
|
if (result.message) {
|
||||||
|
viewProductDetail(itemId);
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { alert('Error de red'); });
|
||||||
|
}
|
||||||
|
|
||||||
function viewProductDetail(itemId) {
|
function viewProductDetail(itemId) {
|
||||||
apiFetch(API + '/items/' + itemId).then(function (data) {
|
apiFetch(API + '/items/' + itemId).then(function (data) {
|
||||||
if (!data || data.error) {
|
if (!data || data.error) {
|
||||||
@@ -452,6 +505,24 @@
|
|||||||
var history = data.history || [];
|
var history = data.history || [];
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
||||||
|
// Product image section
|
||||||
|
html += '<div style="text-align:center;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
|
||||||
|
if (data.image_url) {
|
||||||
|
html += '<img src="' + esc(data.image_url) + '?t=' + Date.now() + '" alt="' + esc(data.name) + '" style="max-width:100%;max-height:220px;object-fit:contain;border-radius:var(--radius-sm);margin-bottom:8px;display:block;margin-left:auto;margin-right:auto;">';
|
||||||
|
html += '<div style="display:flex;gap:8px;justify-content:center;">';
|
||||||
|
html += '<button class="btn btn--ghost btn--sm" onclick="uploadItemImage(' + data.id + ')">Cambiar imagen</button>';
|
||||||
|
html += '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="deleteItemImage(' + data.id + ')">Eliminar imagen</button>';
|
||||||
|
html += '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="padding:24px;color:var(--color-text-muted);">';
|
||||||
|
html += '<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="opacity:0.4;"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
|
||||||
|
html += '<div style="margin-top:8px;">Sin imagen</div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<button class="btn btn--primary btn--sm" onclick="uploadItemImage(' + data.id + ')">Subir imagen</button>';
|
||||||
|
}
|
||||||
|
html += '<span id="imgUploadStatus" style="display:block;margin-top:4px;font-size:var(--text-caption);color:var(--color-text-muted);"></span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
// Product info header
|
// Product info header
|
||||||
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
|
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
|
||||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">No. Parte</span><strong>' + esc(data.part_number) + '</strong></div>';
|
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">No. Parte</span><strong>' + esc(data.part_number) + '</strong></div>';
|
||||||
@@ -573,6 +644,8 @@
|
|||||||
window.loadItems = function (p, q) { loadItems(p, q); };
|
window.loadItems = function (p, q) { loadItems(p, q); };
|
||||||
window.viewHistory = viewHistory;
|
window.viewHistory = viewHistory;
|
||||||
window.viewProductDetail = viewProductDetail;
|
window.viewProductDetail = viewProductDetail;
|
||||||
|
window.uploadItemImage = uploadItemImage;
|
||||||
|
window.deleteItemImage = deleteItemImage;
|
||||||
window.closeHistoryModal = closeHistoryModal;
|
window.closeHistoryModal = closeHistoryModal;
|
||||||
window.showCreateModal = showCreateModal;
|
window.showCreateModal = showCreateModal;
|
||||||
window.closeCreateModal = closeCreateModal;
|
window.closeCreateModal = closeCreateModal;
|
||||||
|
|||||||
Reference in New Issue
Block a user