diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index 29bcbf2..b9416af 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -1,7 +1,9 @@ # /home/Autopartes/pos/blueprints/inventory_bp.py """Inventory blueprint: CRUD for inventory items + stock operations + reports.""" +import io import json +import os from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission @@ -312,6 +314,132 @@ def update_item(item_id): 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//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//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 ────────────────────────── @inventory_bp.route('/purchase', methods=['POST']) diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index 0ea1057..bb181bd 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -271,12 +271,14 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per part_id = r[0] oem = r[1] 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({ 'id_part': part_id, 'oem_part_number': oem, 'name': r[3] or r[2], # prefer Spanish name 'description': r[5] or r[4], - 'image_url': r[6], + 'image_url': image_url, 'local_stock': local['stock'] if local else 0, 'local_price': local['price_1'] if local else None, '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""" SELECT i.id, i.part_number, i.catalog_part_id, 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 LEFT JOIN inventory_operations io ON io.inventory_id = i.id 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, 'tax_rate': float(r[7]) if r[7] else 0.16, 'stock': r[8], + 'image_url': r[9], } if r[1]: 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""" SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate, 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 LEFT JOIN inventory_operations io ON io.inventory_id = i.id 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', 'barcode': row[8], 'stock': row[9], + 'image_url': row[10], } diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index f2ed69c..21030f4 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -443,6 +443,59 @@ // 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) { apiFetch(API + '/items/' + itemId).then(function (data) { if (!data || data.error) { @@ -452,6 +505,24 @@ var history = data.history || []; var html = ''; + // Product image section + html += '
'; + if (data.image_url) { + html += '' + esc(data.name) + ''; + html += '
'; + html += ''; + html += ''; + html += '
'; + } else { + html += '
'; + html += ''; + html += '
Sin imagen
'; + html += '
'; + html += ''; + } + html += ''; + html += '
'; + // Product info header html += '
'; html += '
No. Parte' + esc(data.part_number) + '
'; @@ -573,6 +644,8 @@ window.loadItems = function (p, q) { loadItems(p, q); }; window.viewHistory = viewHistory; window.viewProductDetail = viewProductDetail; + window.uploadItemImage = uploadItemImage; + window.deleteItemImage = deleteItemImage; window.closeHistoryModal = closeHistoryModal; window.showCreateModal = showCreateModal; window.closeCreateModal = closeCreateModal;