diff --git a/dashboard/server.py b/dashboard/server.py index ee801f4..34a2795 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -1,4 +1,4 @@ -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, jsonify, request, send_from_directory, redirect, g from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from sqlalchemy.exc import IntegrityError @@ -13,6 +13,7 @@ from datetime import datetime, timedelta sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) from config import DB_URL +from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth app = Flask(__name__, static_folder='.') @@ -24,9 +25,15 @@ Session = sessionmaker(bind=engine) # Helper Functions # ============================================================================ -def get_all_brands(detailed=False): +def get_all_brands(detailed=False, region_mask=None): session = Session() try: + region_filter = "" + params = {} + if region_mask is not None: + region_filter = " AND (b.region & :rmask) > 0" + params['rmask'] = region_mask + if detailed: sql = text(""" SELECT b.name_brand AS name, @@ -35,9 +42,10 @@ def get_all_brands(detailed=False): FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model - GROUP BY b.name_brand ORDER BY b.name_brand LIMIT 500 + WHERE 1=1""" + region_filter + """ + GROUP BY b.name_brand ORDER BY b.name_brand LIMIT 1000 """) - rows = session.execute(sql).mappings().all() + rows = session.execute(sql, params).mappings().all() return [{'name': r['name'], 'model_count': r['model_count'], 'vehicle_count': r['vehicle_count']} for r in rows] else: @@ -46,9 +54,10 @@ def get_all_brands(detailed=False): FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model - ORDER BY b.name_brand LIMIT 500 + WHERE 1=1""" + region_filter + """ + ORDER BY b.name_brand LIMIT 1000 """) - rows = session.execute(sql).mappings().all() + rows = session.execute(sql, params).mappings().all() return [r['name'] for r in rows] finally: session.close() @@ -172,7 +181,7 @@ def search_vehicles(brand=None, model=None, year=None, engine_name=None, with_pa @app.route('/') def index(): - return send_from_directory('.', 'index.html') + return redirect('/login.html') @app.route('/admin') def admin_page(): @@ -231,10 +240,31 @@ def enhanced_search_js(): # Core API Endpoints # ============================================================================ +@app.route('/api/catalog/stats') +def api_catalog_stats(): + session = Session() + try: + row = session.execute(text(""" + SELECT + (SELECT COUNT(*) FROM brands) AS brands, + (SELECT COUNT(*) FROM models) AS models, + (SELECT COUNT(*) FROM model_year_engine) AS vehicles, + (SELECT COUNT(*) FROM parts) AS parts + """)).mappings().first() + return jsonify({ + 'brands': row['brands'], 'models': row['models'], + 'vehicles': row['vehicles'], 'parts': row['parts'] + }) + finally: + session.close() + + @app.route('/api/brands') def api_brands(): detailed = request.args.get('detailed', 'false').lower() == 'true' - return jsonify(get_all_brands(detailed=detailed)) + region = request.args.get('region') + region_mask = int(region) if region else None + return jsonify(get_all_brands(detailed=detailed, region_mask=region_mask)) @app.route('/api/years') @@ -465,11 +495,12 @@ def api_parts(): data_params['offset'] = offset rows = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, - p.group_id, pg.name_part_group AS group_name, pc.name_part_category AS category_name + p.group_id, pg.name_part_group AS group_name, pc.name_part_category AS category_name, + p.image_url """ + base + where + " ORDER BY p.name_part LIMIT :limit OFFSET :offset"), data_params).mappings().all() parts = [{'id': r['id'], 'oem_part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'group_id': r['group_id'], 'group_name': r['group_name'], - 'category_name': r['category_name'], 'image_url': None} for r in rows] + 'category_name': r['category_name'], 'image_url': r['image_url']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) @@ -485,7 +516,7 @@ def api_part_detail(part_id): try: row = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, - p.description, p.description_es, p.group_id, + p.description, p.description_es, p.group_id, p.image_url, pg.name_part_group AS group_name, pg.name_es AS group_name_es, pc.id_part_category AS category_id, pc.name_part_category AS category_name, pc.name_es AS category_name_es @@ -499,7 +530,7 @@ def api_part_detail(part_id): return jsonify({'id': row['id'], 'oem_part_number': row['oem_part_number'], 'name': row['name'], 'name_es': row['name_es'], 'description': row['description'], 'description_es': row['description_es'], 'group_id': row['group_id'], - 'group_name': row['group_name'], 'image_url': None, + 'group_name': row['group_name'], 'image_url': row['image_url'], 'group_name_es': row['group_name_es'], 'category_id': row['category_id'], 'category_name': row['category_name'], 'category_name_es': row['category_name_es']}) except Exception as e: @@ -591,12 +622,13 @@ def api_vehicle_parts(mye_id): rows = session.execute(text(""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, vp.quantity_required, pp.name_position_part AS position, - pc.name_part_category AS category_name, pg.name_part_group AS group_name + pc.name_part_category AS category_name, pg.name_part_group AS group_name, + p.image_url """ + base + " ORDER BY pc.display_order, pg.display_order, p.name_part LIMIT :limit OFFSET :offset"), data_params).mappings().all() parts = [{'id': r['id'], 'oem_part_number': r['oem_part_number'], 'name': r['name'], 'name_es': r['name_es'], 'quantity_required': r['quantity_required'], 'position': r['position'], 'category_name': r['category_name'], - 'group_name': r['group_name']} for r in rows] + 'group_name': r['group_name'], 'image_url': r['image_url']} for r in rows] total_pages = (total_count + per_page - 1) // per_page return jsonify({'data': parts, 'pagination': {'page': page, 'per_page': per_page, 'total': total_count, 'total_pages': total_pages}}) @@ -1135,7 +1167,7 @@ def api_search(): SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pg.name_part_group AS group_name, pg.id_part_group AS group_id, pc.name_part_category AS category_name, pc.id_part_category AS category_id, - vp.quantity_required, pp.name_position_part AS position + vp.quantity_required, pp.name_position_part AS position, p.image_url FROM vehicle_parts vp JOIN parts p ON vp.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group @@ -1151,7 +1183,7 @@ def api_search(): """), vp_params).mappings().all() for r in rows: results['vehicle_parts'].append({'id': r['id'], 'oem_part_number': r['oem_part_number'], - 'name': r['name'], 'name_es': r['name_es'], 'image_url': None, + 'name': r['name'], 'name_es': r['name_es'], 'image_url': r.get('image_url'), 'group_name': r['group_name'], 'group_id': r['group_id'], 'category_name': r['category_name'], 'category_id': r['category_id'], 'quantity': r['quantity_required'], 'position': r['position'], 'match_type': 'vehicle_part'}) @@ -1181,7 +1213,8 @@ def api_search(): params['offset'] = offset rows = session.execute(text(f""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, - pg.name_part_group AS group_name, pc.name_part_category AS category_name + pg.name_part_group AS group_name, pc.name_part_category AS category_name, + p.image_url FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category WHERE ({where_sql}){cat_filter} @@ -1191,7 +1224,7 @@ def api_search(): """), params).mappings().all() for r in rows: results['parts'].append({'id': r['id'], 'oem_part_number': r['oem_part_number'], - 'name': r['name'], 'name_es': r['name_es'], 'image_url': None, + 'name': r['name'], 'name_es': r['name_es'], 'image_url': r['image_url'], 'group_name': r['group_name'], 'category_name': r['category_name'], 'match_type': 'oem'}) # Aftermarket search @@ -1204,7 +1237,7 @@ def api_search(): af_rows = session.execute(text(f""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pg.name_part_group AS group_name, pc.name_part_category AS category_name, - ap.part_number AS matched_number + ap.part_number AS matched_number, p.image_url FROM aftermarket_parts ap JOIN parts p ON ap.oem_part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category @@ -1213,7 +1246,7 @@ def api_search(): for r in af_rows: if not any(p['id'] == r['id'] for p in results['parts']): results['parts'].append({'id': r['id'], 'oem_part_number': r['oem_part_number'], - 'name': r['name'], 'name_es': r['name_es'], 'image_url': None, + 'name': r['name'], 'name_es': r['name_es'], 'image_url': r['image_url'], 'group_name': r['group_name'], 'category_name': r['category_name'], 'matched_number': r['matched_number'], 'match_type': 'aftermarket'}) @@ -1226,7 +1259,7 @@ def api_search(): cr_rows = session.execute(text(f""" SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name, p.name_es, pg.name_part_group AS group_name, pc.name_part_category AS category_name, - pcr.cross_reference_number AS matched_number + pcr.cross_reference_number AS matched_number, p.image_url FROM part_cross_references pcr JOIN parts p ON pcr.part_id = p.id_part JOIN part_groups pg ON p.group_id = pg.id_part_group JOIN part_categories pc ON pg.category_id = pc.id_part_category @@ -1235,7 +1268,7 @@ def api_search(): for r in cr_rows: if not any(p['id'] == r['id'] for p in results['parts']): results['parts'].append({'id': r['id'], 'oem_part_number': r['oem_part_number'], - 'name': r['name'], 'name_es': r['name_es'], 'image_url': None, + 'name': r['name'], 'name_es': r['name_es'], 'image_url': r['image_url'], 'group_name': r['group_name'], 'category_name': r['category_name'], 'matched_number': r['matched_number'], 'match_type': 'cross_reference'}) @@ -1513,10 +1546,20 @@ def api_admin_stats(): session = Session() try: stats = {} + # Small tables: exact count for table, key in [('part_categories', 'categories'), ('part_groups', 'groups'), - ('parts', 'parts'), ('aftermarket_parts', 'aftermarket'), - ('manufacturers', 'manufacturers'), ('vehicle_parts', 'fitment')]: + ('manufacturers', 'manufacturers')]: stats[key] = session.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() + # Large tables: use pg estimate for speed + for table, key in [('parts', 'parts'), ('aftermarket_parts', 'aftermarket'), + ('vehicle_parts', 'fitment')]: + est = session.execute(text( + "SELECT reltuples::bigint FROM pg_class WHERE relname = :t" + ), {'t': table}).scalar() + if est and est > 0: + stats[key] = est + else: + stats[key] = session.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() return jsonify(stats) except Exception as e: return jsonify({'error': str(e)}), 500 @@ -2317,6 +2360,1500 @@ def api_admin_delete_hotspot(hotspot_id): session.close() +# ============================================================================ +# Captura (Data Entry) Endpoints +# ============================================================================ + +@app.route('/captura') +def captura_page(): + return send_from_directory('.', 'captura.html') + +@app.route('/captura.js') +def captura_js(): + return send_from_directory('.', 'captura.js') + +@app.route('/captura.css') +def captura_css(): + return send_from_directory('.', 'captura.css') + + +@app.route('/api/captura/vehicles/pending') +def api_captura_vehicles_pending(): + session = Session() + try: + brand = request.args.get('brand', '') + model = request.args.get('model', '') + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + filters = ["mye.captura_status = 'pending'"] + params = {'limit': per_page, 'offset': offset} + + if brand: + filters.append("b.name_brand = :brand") + params['brand'] = brand + if model: + filters.append("m.name_model ILIKE :model") + params['model'] = f'%{model}%' + + where = ' AND '.join(filters) + + total = session.execute(text(f""" + SELECT COUNT(*) FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + WHERE {where} + """), params).scalar() + + rows = session.execute(text(f""" + SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, + y.year_car AS year, e.name_engine AS engine, + mye.trim_level, mye.captura_status + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + WHERE {where} + ORDER BY b.name_brand, m.name_model, y.year_car, e.name_engine + LIMIT :limit OFFSET :offset + """), params).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/captura/vehicles/in-progress') +def api_captura_vehicles_in_progress(): + session = Session() + try: + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + total = session.execute(text( + "SELECT COUNT(*) FROM model_year_engine WHERE captura_status = 'in_progress'" + )).scalar() + + rows = session.execute(text(""" + SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, + y.year_car AS year, e.name_engine AS engine, + mye.trim_level, mye.captura_status, + COUNT(vp.id_vehicle_part) AS parts_count + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + LEFT JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye + WHERE mye.captura_status = 'in_progress' + GROUP BY mye.id_mye, b.name_brand, m.name_model, y.year_car, + e.name_engine, mye.trim_level, mye.captura_status + ORDER BY b.name_brand, m.name_model, y.year_car + LIMIT :limit OFFSET :offset + """), {'limit': per_page, 'offset': offset}).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/captura/vehicles//status', methods=['PUT']) +def api_captura_vehicle_status(mye_id): + session = Session() + try: + data = request.get_json() + status = data.get('status') + if status not in ('pending', 'in_progress', 'completed'): + return jsonify({'error': 'Invalid status'}), 400 + session.execute(text( + "UPDATE model_year_engine SET captura_status = :status WHERE id_mye = :id" + ), {'status': status, 'id': mye_id}) + session.commit() + return jsonify({'message': f'Status updated to {status}'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/captura/vehicles//parts') +def api_captura_vehicle_parts(mye_id): + session = Session() + try: + vehicle = session.execute(text(""" + SELECT mye.id_mye, b.name_brand AS brand, m.name_model AS model, + y.year_car AS year, e.name_engine AS engine, mye.trim_level + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + WHERE mye.id_mye = :id + """), {'id': mye_id}).mappings().first() + + if not vehicle: + return jsonify({'error': 'Vehicle not found'}), 404 + + groups = session.execute(text(""" + SELECT pc.id_part_category, pc.name_part_category AS category, + pg.id_part_group, pg.name_part_group AS group_name, + pc.display_order AS cat_order, pg.display_order AS grp_order + FROM part_categories pc + JOIN part_groups pg ON pg.category_id = pc.id_part_category + ORDER BY pc.display_order, pg.display_order + """)).mappings().all() + + existing = session.execute(text(""" + SELECT vp.id_vehicle_part, vp.part_id, p.oem_part_number, p.name_part, + p.name_es, p.group_id, vp.quantity_required, + pp.name_position_part AS position + FROM vehicle_parts vp + JOIN parts p ON vp.part_id = p.id_part + LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part + WHERE vp.model_year_engine_id = :id + ORDER BY p.group_id, p.oem_part_number + """), {'id': mye_id}).mappings().all() + + return jsonify({ + 'vehicle': dict(vehicle), + 'groups': [dict(g) for g in groups], + 'parts': [dict(e) for e in existing] + }) + finally: + session.close() + + +@app.route('/api/captura/parts/without-aftermarket') +def api_captura_parts_without_aftermarket(): + session = Session() + try: + search = request.args.get('search', '') + group_id = request.args.get('group_id', '') + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + filters = ["NOT EXISTS (SELECT 1 FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id_part)"] + params = {'limit': per_page, 'offset': offset} + + if search: + filters.append("(p.oem_part_number ILIKE :search OR p.name_part ILIKE :search)") + params['search'] = f'%{search}%' + if group_id: + filters.append("p.group_id = :group_id") + params['group_id'] = int(group_id) + + where = ' AND '.join(filters) + + total = session.execute(text(f"SELECT COUNT(*) FROM parts p WHERE {where}"), params).scalar() + + rows = session.execute(text(f""" + SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, + pg.name_part_group AS group_name, pc.name_part_category AS category + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id_part_group + JOIN part_categories pc ON pg.category_id = pc.id_part_category + WHERE {where} + ORDER BY pc.display_order, pg.display_order, p.oem_part_number + LIMIT :limit OFFSET :offset + """), params).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/captura/parts/without-image') +def api_captura_parts_without_image(): + session = Session() + try: + search = request.args.get('search', '') + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + filters = ["(p.image_url IS NULL OR p.image_url = '')"] + params = {'limit': per_page, 'offset': offset} + + if search: + filters.append("(p.oem_part_number ILIKE :search OR p.name_part ILIKE :search)") + params['search'] = f'%{search}%' + + where = ' AND '.join(filters) + + total = session.execute(text(f"SELECT COUNT(*) FROM parts p WHERE {where}"), params).scalar() + + rows = session.execute(text(f""" + SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, + pg.name_part_group AS group_name, pc.name_part_category AS category + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id_part_group + JOIN part_categories pc ON pg.category_id = pc.id_part_category + WHERE {where} + ORDER BY p.oem_part_number + LIMIT :limit OFFSET :offset + """), params).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/captura/parts//image', methods=['POST']) +def api_captura_upload_part_image(part_id): + session = Session() + try: + if 'image' not in request.files: + return jsonify({'error': 'No image file provided'}), 400 + + file = request.files['image'] + if not file.filename: + return jsonify({'error': 'No file selected'}), 400 + + allowed = {'jpg', 'jpeg', 'png', 'webp'} + ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + if ext not in allowed: + return jsonify({'error': f'Tipo no permitido. Usar: {", ".join(allowed)}'}), 400 + + file.seek(0, 2) + size = file.tell() + file.seek(0) + if size > 2 * 1024 * 1024: + return jsonify({'error': 'Archivo muy grande (max 2MB)'}), 400 + + part = session.execute(text( + "SELECT oem_part_number FROM parts WHERE id_part = :id" + ), {'id': part_id}).mappings().first() + if not part: + return jsonify({'error': 'Part not found'}), 404 + + safe_oem = re.sub(r'[^a-zA-Z0-9_-]', '_', part['oem_part_number']) + filename = f"{safe_oem}.{ext}" + filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'parts', filename) + file.save(filepath) + + image_url = f"/static/parts/{filename}" + session.execute(text( + "UPDATE parts SET image_url = :url WHERE id_part = :id" + ), {'url': image_url, 'id': part_id}) + session.commit() + + return jsonify({'message': 'Image uploaded', 'image_url': image_url}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/captura/parts/check-oem') +def api_captura_check_oem(): + session = Session() + try: + oem = request.args.get('oem', '') + if not oem: + return jsonify({'exists': False}) + row = session.execute(text( + "SELECT id_part, oem_part_number, name_part, name_es, group_id FROM parts WHERE oem_part_number = :oem" + ), {'oem': oem}).mappings().first() + if row: + return jsonify({'exists': True, 'part': dict(row)}) + return jsonify({'exists': False}) + finally: + session.close() + + +@app.route('/api/captura/manufacturers') +def api_captura_manufacturers(): + session = Session() + try: + rows = session.execute(text(""" + SELECT m.id_manufacture AS id, m.name_manufacture AS name, + qt.name_quality AS quality + FROM manufacturers m + LEFT JOIN quality_tier qt ON m.id_quality_tier = qt.id_quality_tier + ORDER BY m.name_manufacture + """)).mappings().all() + return jsonify([dict(r) for r in rows]) + finally: + session.close() + + +@app.route('/api/captura/parts//aftermarket') +def api_captura_part_aftermarket(part_id): + session = Session() + try: + rows = session.execute(text(""" + SELECT ap.id_aftermarket_parts AS id, ap.part_number, + ap.name_aftermarket_parts AS name, + m.name_manufacture AS manufacturer, qt.name_quality AS quality, + ap.price_usd, ap.warranty_months + FROM aftermarket_parts ap + JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture + LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier + WHERE ap.oem_part_id = :id + ORDER BY m.name_manufacture + """), {'id': part_id}).mappings().all() + return jsonify([dict(r) for r in rows]) + finally: + session.close() + + +# ============================================================================ +# POS (Point of Sale) Endpoints +# ============================================================================ + +@app.route('/pos') +def pos_page(): + return send_from_directory('.', 'pos.html') + +@app.route('/pos.js') +def pos_js(): + return send_from_directory('.', 'pos.js') + +@app.route('/pos.css') +def pos_css(): + return send_from_directory('.', 'pos.css') + +@app.route('/cuentas') +def cuentas_page(): + return send_from_directory('.', 'cuentas.html') + +@app.route('/cuentas.js') +def cuentas_js(): + return send_from_directory('.', 'cuentas.js') + +@app.route('/cuentas.css') +def cuentas_css(): + return send_from_directory('.', 'cuentas.css') + + +# ---- Customers ---- + +@app.route('/api/pos/customers') +def api_pos_customers(): + session = Session() + try: + search = request.args.get('search', '') + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + filters = ["active = TRUE"] + params = {'limit': per_page, 'offset': offset} + if search: + filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)") + params['search'] = f'%{search}%' + + where = ' AND '.join(filters) + total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar() + + rows = session.execute(text(f""" + SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms + FROM customers WHERE {where} + ORDER BY name LIMIT :limit OFFSET :offset + """), params).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/pos/customers/') +def api_pos_customer_detail(customer_id): + session = Session() + try: + row = session.execute(text( + "SELECT * FROM customers WHERE id_customer = :id" + ), {'id': customer_id}).mappings().first() + if not row: + return jsonify({'error': 'Cliente no encontrado'}), 404 + return jsonify(dict(row)) + finally: + session.close() + + +@app.route('/api/pos/customers', methods=['POST']) +def api_pos_create_customer(): + session = Session() + try: + data = request.get_json() + result = session.execute(text(""" + INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms) + VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms) + RETURNING id_customer + """), { + 'name': data['name'], 'rfc': data.get('rfc'), + 'business_name': data.get('business_name'), + 'email': data.get('email'), 'phone': data.get('phone'), + 'address': data.get('address'), + 'credit_limit': data.get('credit_limit', 0), + 'payment_terms': data.get('payment_terms', 30) + }) + new_id = result.scalar() + session.commit() + return jsonify({'id': new_id, 'message': 'Cliente creado'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/pos/customers/', methods=['PUT']) +def api_pos_update_customer(customer_id): + session = Session() + try: + data = request.get_json() + session.execute(text(""" + UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name, + email = :email, phone = :phone, address = :address, + credit_limit = :credit_limit, payment_terms = :payment_terms + WHERE id_customer = :id + """), { + 'name': data['name'], 'rfc': data.get('rfc'), + 'business_name': data.get('business_name'), + 'email': data.get('email'), 'phone': data.get('phone'), + 'address': data.get('address'), + 'credit_limit': data.get('credit_limit', 0), + 'payment_terms': data.get('payment_terms', 30), + 'id': customer_id + }) + session.commit() + return jsonify({'message': 'Cliente actualizado'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +# ---- Invoices ---- + +@app.route('/api/pos/invoices') +def api_pos_invoices(): + session = Session() + try: + customer_id = request.args.get('customer_id', '') + status = request.args.get('status', '') + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 100) + offset = (page - 1) * per_page + + filters = ["1=1"] + params = {'limit': per_page, 'offset': offset} + if customer_id: + filters.append("i.customer_id = :customer_id") + params['customer_id'] = int(customer_id) + if status: + filters.append("i.status = :status") + params['status'] = status + + where = ' AND '.join(filters) + total = session.execute(text(f""" + SELECT COUNT(*) FROM invoices i WHERE {where} + """), params).scalar() + + rows = session.execute(text(f""" + SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount, + i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc + FROM invoices i + JOIN customers c ON i.customer_id = c.id_customer + WHERE {where} + ORDER BY i.date_issued DESC + LIMIT :limit OFFSET :offset + """), params).mappings().all() + return jsonify({'data': [dict(r) for r in rows], 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, + 'total_pages': (total + per_page - 1) // per_page + }}) + finally: + session.close() + + +@app.route('/api/pos/invoices/') +def api_pos_invoice_detail(invoice_id): + session = Session() + try: + inv = session.execute(text(""" + SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address + FROM invoices i JOIN customers c ON i.customer_id = c.id_customer + WHERE i.id_invoice = :id + """), {'id': invoice_id}).mappings().first() + if not inv: + return jsonify({'error': 'Factura no encontrada'}), 404 + + items = session.execute(text(""" + SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number + FROM invoice_items ii + LEFT JOIN parts p ON ii.part_id = p.id_part + LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts + WHERE ii.invoice_id = :id + ORDER BY ii.id_invoice_item + """), {'id': invoice_id}).mappings().all() + + return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]}) + finally: + session.close() + + +@app.route('/api/pos/invoices', methods=['POST']) +def api_pos_create_invoice(): + session = Session() + try: + data = request.get_json() + customer_id = data['customer_id'] + items = data['items'] + tax_rate = data.get('tax_rate', 0.16) + notes = data.get('notes', '') + + if not items: + return jsonify({'error': 'La factura debe tener al menos una linea'}), 400 + + folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar() + folio = f"NX-{folio_num:06d}" + + subtotal = sum(it['quantity'] * it['unit_price'] for it in items) + tax_amount = round(subtotal * tax_rate, 2) + total = round(subtotal + tax_amount, 2) + + result = session.execute(text(""" + INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes) + VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes) + RETURNING id_invoice + """), { + 'customer_id': customer_id, 'folio': folio, + 'subtotal': subtotal, 'tax_rate': tax_rate, + 'tax_amount': tax_amount, 'total': total, 'notes': notes + }) + invoice_id = result.scalar() + + for it in items: + line_total = it['quantity'] * it['unit_price'] + session.execute(text(""" + INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description, + quantity, unit_cost, margin_pct, unit_price, line_total) + VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total) + """), { + 'inv_id': invoice_id, + 'part_id': it.get('part_id'), + 'af_id': it.get('aftermarket_id'), + 'desc': it['description'], + 'qty': it['quantity'], + 'cost': it.get('unit_cost', 0), + 'margin': it.get('margin_pct', 30), + 'price': it['unit_price'], + 'total': line_total + }) + + session.execute(text( + "UPDATE customers SET balance = balance + :total WHERE id_customer = :id" + ), {'total': total, 'id': customer_id}) + + session.commit() + return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/pos/invoices//cancel', methods=['PUT']) +def api_pos_cancel_invoice(invoice_id): + session = Session() + try: + inv = session.execute(text( + "SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id" + ), {'id': invoice_id}).mappings().first() + if not inv: + return jsonify({'error': 'Factura no encontrada'}), 404 + if inv['status'] == 'cancelled': + return jsonify({'error': 'La factura ya esta cancelada'}), 400 + + session.execute(text( + "UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id" + ), {'id': invoice_id}) + + session.execute(text( + "UPDATE customers SET balance = balance - :total WHERE id_customer = :cid" + ), {'total': inv['total'], 'cid': inv['customer_id']}) + + session.commit() + return jsonify({'message': 'Factura cancelada'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +# ---- Payments ---- + +@app.route('/api/pos/payments', methods=['POST']) +def api_pos_create_payment(): + session = Session() + try: + data = request.get_json() + customer_id = data['customer_id'] + amount = float(data['amount']) + payment_method = data.get('payment_method', 'efectivo') + reference = data.get('reference') + invoice_id = data.get('invoice_id') + notes = data.get('notes') + + if amount <= 0: + return jsonify({'error': 'El monto debe ser mayor a 0'}), 400 + + result = session.execute(text(""" + INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes) + VALUES (:cid, :inv_id, :amount, :method, :ref, :notes) + RETURNING id_payment + """), { + 'cid': customer_id, 'inv_id': invoice_id, + 'amount': amount, 'method': payment_method, + 'ref': reference, 'notes': notes + }) + payment_id = result.scalar() + + session.execute(text( + "UPDATE customers SET balance = balance - :amount WHERE id_customer = :id" + ), {'amount': amount, 'id': customer_id}) + + if invoice_id: + session.execute(text( + "UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id" + ), {'amount': amount, 'id': invoice_id}) + session.execute(text(""" + UPDATE invoices SET status = CASE + WHEN amount_paid >= total THEN 'paid' + WHEN amount_paid > 0 THEN 'partial' + ELSE 'pending' + END WHERE id_invoice = :id + """), {'id': invoice_id}) + + session.commit() + return jsonify({'id': payment_id, 'message': 'Pago registrado'}) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/pos/customers//statement') +def api_pos_customer_statement(customer_id): + session = Session() + try: + customer = session.execute(text( + "SELECT * FROM customers WHERE id_customer = :id" + ), {'id': customer_id}).mappings().first() + if not customer: + return jsonify({'error': 'Cliente no encontrado'}), 404 + + invoices = session.execute(text(""" + SELECT id_invoice, folio, date_issued, total, amount_paid, status + FROM invoices WHERE customer_id = :id AND status != 'cancelled' + ORDER BY date_issued DESC LIMIT 100 + """), {'id': customer_id}).mappings().all() + + payments = session.execute(text(""" + SELECT p.id_payment, p.amount, p.payment_method, p.reference, + p.date_payment, p.notes, i.folio AS invoice_folio + FROM payments p + LEFT JOIN invoices i ON p.invoice_id = i.id_invoice + WHERE p.customer_id = :id + ORDER BY p.date_payment DESC LIMIT 100 + """), {'id': customer_id}).mappings().all() + + return jsonify({ + 'customer': dict(customer), + 'invoices': [dict(i) for i in invoices], + 'payments': [dict(p) for p in payments] + }) + finally: + session.close() + + +@app.route('/api/pos/search-parts') +def api_pos_search_parts(): + session = Session() + try: + q = request.args.get('q', '') + if len(q) < 2: + return jsonify([]) + + results = [] + + oem = session.execute(text(""" + SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, + p.cost_usd, pg.name_part_group AS group_name, + 'oem' AS part_type + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id_part_group + WHERE p.oem_part_number ILIKE :q OR p.name_part ILIKE :q + ORDER BY p.oem_part_number LIMIT 20 + """), {'q': f'%{q}%'}).mappings().all() + results.extend([dict(r) for r in oem]) + + af = session.execute(text(""" + SELECT ap.id_aftermarket_parts AS id_part, ap.part_number AS oem_part_number, + ap.name_aftermarket_parts AS name_part, ap.name_es, + COALESCE(ap.cost_usd, ap.price_usd) AS cost_usd, + m.name_manufacture AS group_name, + 'aftermarket' AS part_type + FROM aftermarket_parts ap + JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture + WHERE ap.part_number ILIKE :q OR ap.name_aftermarket_parts ILIKE :q + ORDER BY ap.part_number LIMIT 20 + """), {'q': f'%{q}%'}).mappings().all() + results.extend([dict(r) for r in af]) + + return jsonify(results) + finally: + session.close() + + +# ============================================================================ +# Store Dashboard Endpoints +# ============================================================================ + +@app.route('/demo') +def demo_page(): + return send_from_directory('.', 'demo.html') + + +@app.route('/bodega') +def bodega_page(): + return send_from_directory('.', 'bodega.html') + +@app.route('/bodega.js') +def bodega_js(): + return send_from_directory('.', 'bodega.js') + +@app.route('/bodega.css') +def bodega_css(): + return send_from_directory('.', 'bodega.css') + +@app.route('/login.html') +def login_page(): + return send_from_directory('.', 'login.html') + +@app.route('/login.js') +def login_js(): + return send_from_directory('.', 'login.js') + +@app.route('/login.css') +def login_css(): + return send_from_directory('.', 'login.css') + +@app.route('/tienda') +def tienda_page(): + return send_from_directory('.', 'tienda.html') + +@app.route('/tienda.js') +def tienda_js(): + return send_from_directory('.', 'tienda.js') + +@app.route('/tienda.css') +def tienda_css(): + return send_from_directory('.', 'tienda.css') + + +@app.route('/api/tienda/stats') +def api_tienda_stats(): + session = Session() + try: + today = "date_issued::date = CURRENT_DATE" + month = "date_issued >= date_trunc('month', CURRENT_DATE)" + + sales_today = session.execute(text(f""" + SELECT COALESCE(SUM(total), 0), COUNT(*) + FROM invoices WHERE {today} AND status != 'cancelled' + """)).fetchone() + + sales_month = session.execute(text(f""" + SELECT COALESCE(SUM(total), 0), COUNT(*) + FROM invoices WHERE {month} AND status != 'cancelled' + """)).fetchone() + + payments_today = session.execute(text(f""" + SELECT COALESCE(SUM(amount), 0), COUNT(*) + FROM payments WHERE date_payment::date = CURRENT_DATE + """)).fetchone() + + pending_balance = session.execute(text( + "SELECT COALESCE(SUM(balance), 0) FROM customers WHERE active = TRUE AND balance > 0" + )).scalar() + + pending_invoices = session.execute(text( + "SELECT COUNT(*) FROM invoices WHERE status IN ('pending', 'partial')" + )).scalar() + + total_customers = session.execute(text( + "SELECT COUNT(*) FROM customers WHERE active = TRUE" + )).scalar() + + total_parts = session.execute(text("SELECT COUNT(*) FROM parts")).scalar() + total_aftermarket = session.execute(text("SELECT COUNT(*) FROM aftermarket_parts")).scalar() + + recent_invoices = session.execute(text(""" + SELECT i.folio, i.total, i.status, i.date_issued, c.name AS customer_name + FROM invoices i JOIN customers c ON i.customer_id = c.id_customer + ORDER BY i.date_issued DESC LIMIT 8 + """)).mappings().all() + + top_debtors = session.execute(text(""" + SELECT id_customer, name, balance, credit_limit + FROM customers WHERE active = TRUE AND balance > 0 + ORDER BY balance DESC LIMIT 6 + """)).mappings().all() + + return jsonify({ + 'sales_today': {'total': float(sales_today[0]), 'count': int(sales_today[1])}, + 'sales_month': {'total': float(sales_month[0]), 'count': int(sales_month[1])}, + 'payments_today': {'total': float(payments_today[0]), 'count': int(payments_today[1])}, + 'pending_balance': float(pending_balance), + 'pending_invoices': pending_invoices, + 'total_customers': total_customers, + 'total_parts': total_parts, + 'total_aftermarket': total_aftermarket, + 'recent_invoices': [dict(r) for r in recent_invoices], + 'top_debtors': [dict(r) for r in top_debtors] + }) + finally: + session.close() + + +# ============================================================================ +# Auth Endpoints +# ============================================================================ + +@app.route('/api/auth/register', methods=['POST']) +def auth_register(): + """Register a new user (TALLER or BODEGA). Account starts inactive.""" + data = request.get_json() + if not data: + return jsonify({'error': 'Invalid JSON'}), 400 + + required = ['name', 'email', 'password', 'role', 'business_name'] + for field in required: + if not data.get(field): + return jsonify({'error': f'Missing required field: {field}'}), 400 + + role = data['role'].upper() + if role not in ('TALLER', 'BODEGA'): + return jsonify({'error': 'Role must be TALLER or BODEGA'}), 400 + + role_map = {'TALLER': 3, 'BODEGA': 4} + id_rol = role_map[role] + + hashed = hash_password(data['password']) + + session = Session() + try: + session.execute(text( + """INSERT INTO users (name_user, email, pass, id_rol, business_name, phone, address, is_active, created_at) + VALUES (:name, :email, :pass, :id_rol, :biz, :phone, :addr, false, NOW())""" + ), { + 'name': data['name'], + 'email': data['email'], + 'pass': hashed, + 'id_rol': id_rol, + 'biz': data['business_name'], + 'phone': data.get('phone', ''), + 'addr': data.get('address', '') + }) + session.commit() + return jsonify({'message': 'Registration successful. Account pending activation.'}), 201 + except IntegrityError: + session.rollback() + return jsonify({'error': 'Email already registered'}), 409 + finally: + session.close() + + +@app.route('/api/auth/login', methods=['POST']) +def auth_login(): + """Authenticate user and return access + refresh tokens.""" + data = request.get_json() + if not data or not data.get('email') or not data.get('password'): + return jsonify({'error': 'Email and password are required'}), 400 + + session = Session() + try: + row = session.execute(text( + """SELECT u.id_user, u.name_user, u.email, u.pass, u.is_active, + u.business_name, r.name_rol + FROM users u + JOIN roles r ON r.id_rol = u.id_rol + WHERE u.email = :email""" + ), {'email': data['email']}).mappings().first() + + if not row: + return jsonify({'error': 'Invalid email or password'}), 401 + + if not check_password(data['password'], row['pass']): + return jsonify({'error': 'Invalid email or password'}), 401 + + if not row['is_active']: + return jsonify({'error': 'Account is not active. Contact an administrator.'}), 403 + + # Update last_login + session.execute(text( + "UPDATE users SET last_login = NOW() WHERE id_user = :uid" + ), {'uid': row['id_user']}) + session.commit() + + access_token = create_access_token(row['id_user'], row['name_rol'], row['business_name']) + refresh_token = create_refresh_token(row['id_user']) + + return jsonify({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'user': { + 'id': row['id_user'], + 'name': row['name_user'], + 'role': row['name_rol'], + 'business_name': row['business_name'] + } + }) + finally: + session.close() + + +@app.route('/api/auth/refresh', methods=['POST']) +def auth_refresh(): + """Exchange a valid refresh token for a new access token.""" + data = request.get_json() + if not data or not data.get('refresh_token'): + return jsonify({'error': 'refresh_token is required'}), 400 + + session = Session() + try: + row = session.execute(text( + """SELECT s.user_id, s.expires_at, u.business_name, r.name_rol + FROM sessions s + JOIN users u ON u.id_user = s.user_id + JOIN roles r ON r.id_rol = u.id_rol + WHERE s.refresh_token = :token""" + ), {'token': data['refresh_token']}).mappings().first() + + if not row: + return jsonify({'error': 'Invalid refresh token'}), 401 + + if row['expires_at'] < datetime.utcnow(): + # Clean up expired token + session.execute(text( + "DELETE FROM sessions WHERE refresh_token = :token" + ), {'token': data['refresh_token']}) + session.commit() + return jsonify({'error': 'Refresh token expired'}), 401 + + access_token = create_access_token(row['user_id'], row['name_rol'], row['business_name']) + return jsonify({'access_token': access_token}) + finally: + session.close() + + +@app.route('/api/auth/me', methods=['GET']) +@require_auth() +def auth_me(): + """Return the current authenticated user's info from the JWT payload.""" + return jsonify(g.user) + + +# ============================================================================ +# Task 5: Admin User Management +# ============================================================================ + +import csv +import io +import math + +@app.route('/api/admin/users', methods=['GET']) +@require_auth('ADMIN', 'OWNER') +def admin_list_users(): + """Return list of all users with role info.""" + session = Session() + try: + rows = session.execute(text( + """SELECT u.id_user, u.name_user, u.email, u.business_name, + u.phone, u.is_active, u.created_at, u.last_login, + r.name_rol + FROM users u + JOIN roles r ON r.id_rol = u.id_rol + ORDER BY u.created_at DESC""" + )).mappings().all() + + users = [] + for r in rows: + users.append({ + 'id': r['id_user'], + 'name': r['name_user'], + 'email': r['email'], + 'business_name': r['business_name'], + 'phone': r['phone'], + 'is_active': r['is_active'], + 'created_at': r['created_at'].isoformat() if r['created_at'] else None, + 'last_login': r['last_login'].isoformat() if r['last_login'] else None, + 'role': r['name_rol'] + }) + return jsonify(users) + finally: + session.close() + + +@app.route('/api/admin/users//activate', methods=['PUT']) +@require_auth('ADMIN', 'OWNER') +def admin_activate_user(user_id): + """Activate or deactivate a user.""" + data = request.get_json() + if data is None or 'is_active' not in data: + return jsonify({'error': 'is_active field is required'}), 400 + + session = Session() + try: + result = session.execute(text( + "UPDATE users SET is_active = :active WHERE id_user = :uid" + ), {'active': bool(data['is_active']), 'uid': user_id}) + session.commit() + + if result.rowcount == 0: + return jsonify({'error': 'User not found'}), 404 + + return jsonify({'message': 'User updated', 'is_active': bool(data['is_active'])}) + finally: + session.close() + + +# ============================================================================ +# Task 6: Inventory Endpoints (BODEGA) +# ============================================================================ + +@app.route('/api/inventory/mapping', methods=['GET']) +@require_auth('BODEGA', 'ADMIN') +def inventory_get_mapping(): + """Return column mapping for current user.""" + session = Session() + try: + row = session.execute(text( + "SELECT mapping FROM inventory_column_mappings WHERE user_id = :uid" + ), {'uid': g.user['user_id']}).mappings().first() + + return jsonify({'mapping': row['mapping'] if row else {}}) + finally: + session.close() + + +@app.route('/api/inventory/mapping', methods=['PUT']) +@require_auth('BODEGA', 'ADMIN') +def inventory_put_mapping(): + """Upsert column mapping for current user.""" + data = request.get_json() + if not data or 'mapping' not in data: + return jsonify({'error': 'mapping is required'}), 400 + + mapping = data['mapping'] + required_keys = ['part_number', 'price', 'stock'] + missing = [k for k in required_keys if k not in mapping or not mapping[k]] + if missing: + return jsonify({'error': f'Missing required mapping keys: {", ".join(missing)}'}), 400 + + session = Session() + try: + session.execute(text( + """INSERT INTO inventory_column_mappings (user_id, mapping) + VALUES (:uid, :mapping) + ON CONFLICT (user_id) DO UPDATE SET mapping = :mapping""" + ), {'uid': g.user['user_id'], 'mapping': json_module.dumps(mapping)}) + session.commit() + return jsonify({'message': 'Mapping saved', 'mapping': mapping}) + finally: + session.close() + + +@app.route('/api/inventory/upload', methods=['POST']) +@require_auth('BODEGA', 'ADMIN') +def inventory_upload(): + """Upload inventory file (CSV or Excel), apply column mapping, upsert into warehouse_inventory.""" + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + if not file.filename: + return jsonify({'error': 'Empty filename'}), 400 + + session = Session() + try: + # 1. Get mapping + row = session.execute(text( + "SELECT mapping FROM inventory_column_mappings WHERE user_id = :uid" + ), {'uid': g.user['user_id']}).mappings().first() + + if not row or not row['mapping']: + return jsonify({'error': 'No column mapping configured. Set mapping first.'}), 400 + + mapping = row['mapping'] if isinstance(row['mapping'], dict) else json_module.loads(row['mapping']) + + # 2. Create upload record + upload = session.execute(text( + """INSERT INTO inventory_uploads (user_id, filename, status) + VALUES (:uid, :fname, 'processing') + RETURNING id_upload""" + ), {'uid': g.user['user_id'], 'fname': file.filename}).mappings().first() + session.commit() + upload_id = upload['id_upload'] + + # 3. Parse file + filename_lower = file.filename.lower() + rows_data = [] + + if filename_lower.endswith(('.xlsx', '.xls')): + import openpyxl + wb = openpyxl.load_workbook(io.BytesIO(file.read()), read_only=True, data_only=True) + ws = wb.active + headers = None + for row_cells in ws.iter_rows(values_only=True): + if headers is None: + headers = [str(c).strip() if c else '' for c in row_cells] + continue + row_dict = {} + for i, val in enumerate(row_cells): + if i < len(headers): + row_dict[headers[i]] = val + rows_data.append(row_dict) + wb.close() + else: + # CSV + content = file.read().decode('utf-8-sig', errors='replace') + reader = csv.DictReader(io.StringIO(content)) + for row_dict in reader: + rows_data.append(row_dict) + + # 4. Process rows + imported = 0 + errors = 0 + error_samples = [] + + def clean_price(val): + if val is None: + return None + s = str(val).replace('$', '').replace(',', '').strip() + try: + return float(s) + except (ValueError, TypeError): + return None + + def clean_stock(val): + if val is None: + return 0 + s = str(val).replace(',', '').strip() + try: + return int(float(s)) + except (ValueError, TypeError): + return 0 + + for i, row_dict in enumerate(rows_data): + try: + part_number_col = mapping.get('part_number', '') + price_col = mapping.get('price', '') + stock_col = mapping.get('stock', '') + location_col = mapping.get('location', '') + + part_number = str(row_dict.get(part_number_col, '')).strip() + if not part_number: + errors += 1 + if len(error_samples) < 10: + error_samples.append({'row': i + 2, 'error': 'Empty part number'}) + continue + + price = clean_price(row_dict.get(price_col)) + stock = clean_stock(row_dict.get(stock_col)) + location = str(row_dict.get(location_col, 'Principal')).strip() if location_col else 'Principal' + if not location: + location = 'Principal' + + # Find part by OEM part number + part_row = session.execute(text( + "SELECT id_part FROM parts WHERE oem_part_number = :pn LIMIT 1" + ), {'pn': part_number}).mappings().first() + + # Also try aftermarket_parts.part_number if OEM not found + if not part_row: + am_row = session.execute(text( + "SELECT oem_part_id FROM aftermarket_parts WHERE part_number = :pn LIMIT 1" + ), {'pn': part_number}).mappings().first() + if am_row: + part_row = {'id_part': am_row['oem_part_id']} + + if not part_row: + errors += 1 + if len(error_samples) < 10: + error_samples.append({'row': i + 2, 'error': f'Part not found: {part_number}'}) + continue + + # UPSERT into warehouse_inventory + session.execute(text( + """INSERT INTO warehouse_inventory (user_id, part_id, price, stock_quantity, warehouse_location, updated_at) + VALUES (:uid, :pid, :price, :stock, :loc, NOW()) + ON CONFLICT (user_id, part_id, warehouse_location) + DO UPDATE SET price = :price, stock_quantity = :stock, updated_at = NOW()""" + ), { + 'uid': g.user['user_id'], + 'pid': part_row['id_part'], + 'price': price, + 'stock': stock, + 'loc': location + }) + imported += 1 + except Exception as e: + errors += 1 + if len(error_samples) < 10: + error_samples.append({'row': i + 2, 'error': str(e)}) + + # 5. Update upload record + session.execute(text( + """UPDATE inventory_uploads + SET status = 'completed', rows_total = :total, + rows_imported = :imported, rows_errors = :errors, + error_log = :elog, completed_at = NOW() + WHERE id_upload = :uid""" + ), { + 'total': len(rows_data), + 'imported': imported, + 'errors': errors, + 'elog': json_module.dumps(error_samples) if error_samples else None, + 'uid': upload_id + }) + session.commit() + + return jsonify({ + 'message': 'Upload processed', + 'upload_id': upload_id, + 'imported': imported, + 'errors': errors, + 'error_samples': error_samples + }) + except Exception as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/inventory/uploads', methods=['GET']) +@require_auth('BODEGA', 'ADMIN') +def inventory_list_uploads(): + """Return last 50 uploads for current user.""" + session = Session() + try: + rows = session.execute(text( + """SELECT id_upload, filename, status, rows_total, rows_imported, + rows_errors, created_at, completed_at + FROM inventory_uploads + WHERE user_id = :uid + ORDER BY created_at DESC + LIMIT 50""" + ), {'uid': g.user['user_id']}).mappings().all() + + uploads = [] + for r in rows: + uploads.append({ + 'id': r['id_upload'], + 'filename': r['filename'], + 'status': r['status'], + 'rows_total': r['rows_total'], + 'rows_imported': r['rows_imported'], + 'rows_errors': r['rows_errors'], + 'created_at': r['created_at'].isoformat() if r['created_at'] else None, + 'completed_at': r['completed_at'].isoformat() if r['completed_at'] else None + }) + return jsonify(uploads) + finally: + session.close() + + +@app.route('/api/inventory/items', methods=['GET']) +@require_auth('BODEGA', 'ADMIN') +def inventory_list_items(): + """Paginated list of warehouse_inventory for current user.""" + page = max(1, request.args.get('page', 1, type=int)) + per_page = min(200, max(1, request.args.get('per_page', 50, type=int))) + q = request.args.get('q', '').strip() + + session = Session() + try: + params = {'uid': g.user['user_id'], 'offset': (page - 1) * per_page, 'limit': per_page} + where_clause = "WHERE wi.user_id = :uid" + + if q: + where_clause += " AND (p.oem_part_number ILIKE :q OR p.name_part ILIKE :q)" + params['q'] = f'%{q}%' + + count_row = session.execute(text( + f"""SELECT COUNT(*) AS cnt + FROM warehouse_inventory wi + JOIN parts p ON p.id_part = wi.part_id + {where_clause}""" + ), params).mappings().first() + total = count_row['cnt'] + + rows = session.execute(text( + f"""SELECT wi.id_inventory, wi.part_id, p.oem_part_number, + p.name_part, wi.price, wi.stock_quantity, + wi.warehouse_location, wi.updated_at + FROM warehouse_inventory wi + JOIN parts p ON p.id_part = wi.part_id + {where_clause} + ORDER BY wi.updated_at DESC + LIMIT :limit OFFSET :offset""" + ), params).mappings().all() + + data = [] + for r in rows: + data.append({ + 'id': r['id_inventory'], + 'part_id': r['part_id'], + 'oem_part_number': r['oem_part_number'], + 'name': r['name_part'], + 'price': float(r['price']) if r['price'] else None, + 'stock': r['stock_quantity'], + 'location': r['warehouse_location'], + 'updated_at': r['updated_at'].isoformat() if r['updated_at'] else None + }) + + total_pages = math.ceil(total / per_page) if total > 0 else 1 + return jsonify({ + 'data': data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total, + 'total_pages': total_pages + } + }) + finally: + session.close() + + +@app.route('/api/inventory/items', methods=['DELETE']) +@require_auth('BODEGA', 'ADMIN') +def inventory_delete_all(): + """Delete all warehouse_inventory for current user.""" + session = Session() + try: + result = session.execute(text( + "DELETE FROM warehouse_inventory WHERE user_id = :uid" + ), {'uid': g.user['user_id']}) + session.commit() + return jsonify({'message': 'Inventory cleared', 'deleted': result.rowcount}) + finally: + session.close() + + +# ============================================================================ +# Task 7: Part Availability & Aftermarket +# ============================================================================ + +@app.route('/api/parts//availability', methods=['GET']) +@require_auth('TALLER', 'ADMIN', 'OWNER') +def part_availability(part_id): + """Return all bodegas that have this part in stock.""" + session = Session() + try: + rows = session.execute(text( + """SELECT u.business_name, wi.price, wi.stock_quantity, + wi.warehouse_location, wi.updated_at + FROM warehouse_inventory wi + JOIN users u ON u.id_user = wi.user_id + WHERE wi.part_id = :pid + AND wi.stock_quantity > 0 + AND u.is_active = true + ORDER BY wi.price ASC""" + ), {'pid': part_id}).mappings().all() + + data = [] + for r in rows: + data.append({ + 'bodega': r['business_name'], + 'price': float(r['price']) if r['price'] else None, + 'stock': r['stock_quantity'], + 'location': r['warehouse_location'], + 'updated_at': r['updated_at'].isoformat() if r['updated_at'] else None + }) + return jsonify(data) + finally: + session.close() + + +@app.route('/api/parts//aftermarket', methods=['GET']) +def part_aftermarket(part_id): + """Return aftermarket alternatives and cross-references for a part (public).""" + session = Session() + try: + # Aftermarket alternatives + rows = session.execute(text( + """SELECT ap.id_aftermarket_parts, ap.part_number, + ap.name_aftermarket_parts, m.name_manufacture, + qt.name_quality, ap.price_usd + FROM aftermarket_parts ap + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + LEFT JOIN quality_tier qt ON qt.id_quality_tier = ap.id_quality_tier + WHERE ap.oem_part_id = :pid + ORDER BY ap.price_usd ASC NULLS LAST""" + ), {'pid': part_id}).mappings().all() + + alternatives = [] + for r in rows: + alternatives.append({ + 'id': r['id_aftermarket_parts'], + 'part_number': r['part_number'], + 'name': r['name_aftermarket_parts'], + 'manufacturer': r['name_manufacture'], + 'quality_tier': r['name_quality'], + 'price': float(r['price_usd']) if r['price_usd'] else None, + 'source': 'aftermarket' + }) + + # Cross-references + xrefs = session.execute(text( + """SELECT pcr.cross_reference_number, pcr.source_ref, pcr.notes + FROM part_cross_references pcr + WHERE pcr.part_id = :pid + ORDER BY pcr.cross_reference_number""" + ), {'pid': part_id}).mappings().all() + + cross_refs = [] + for x in xrefs: + cross_refs.append({ + 'cross_reference_number': x['cross_reference_number'], + 'source': x['source_ref'], + 'notes': x['notes'] + }) + + return jsonify({ + 'data': alternatives, + 'cross_references': cross_refs + }) + finally: + session.close() + + # ============================================================================ # Main Block # ============================================================================