Files
Autoparts-DB/pos/blueprints/yonke_bp.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

912 lines
34 KiB
Python

# /home/Autopartes/pos/blueprints/yonke_bp.py
"""Nexus Yonke blueprint: used/rebuild parts, donor vehicles and consignments.
All endpoints live under /pos/api/yonke.
Permissions: yonke.view for reads, yonke.manage for writes.
AI vision grading (A4):
POST /pos/api/yonke/pieces/<id>/grade-suggest (multipart `file` | JSON image_base64)
POST /pos/api/yonke/grade-suggest (no piece required)
Both return the suggestion contract documented in the "AI vision grading"
section below; the front (yonke.js) can POST the photo and pre-fill the grade.
A5 additions (donor intake, reports, statements, labels, ML, bulk, AI):
POST /pos/api/yonke/donors/full — donor + N pieces (atomic)
POST /pos/api/yonke/donors/<vin>/allocate-cost — spread acquisition cost
GET /pos/api/yonke/reports/aging?days=90 — pieces aging in the yard
GET /pos/api/yonke/reports/summary — donors/margin/capital/grades
GET /pos/api/yonke/consignments/<id>/statement — consignor statement (JSON)
GET /pos/api/yonke/consignments/<id>/statement/print — consignor statement (HTML)
POST /pos/api/yonke/pieces/<id>/publish-meli — publish one piece to ML
POST /pos/api/yonke/pieces/bulk — bulk CSV import
GET/POST /pos/api/yonke/pieces/<id>/compat — "sirve para" fitment
GET /pos/api/yonke/pieces/<id>/label — printable barcode label
POST /pos/api/yonke/pieces/<id>/identify — vision identify (503 on AI down)
POST /pos/api/yonke/pieces/<id>/price-suggest — rule-based price suggestion
GET /pos/api/yonke/duplicates?threshold=0.85 — semantic duplicate groups
POST /pos/api/yonke/pieces/<id>/validate-photo — vision photo check
"""
import os
from io import BytesIO
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth
from tenant_db import get_tenant_conn
from services import yonke_service
from services.yonke_service import DuplicatePartNumber
from services.image_service import DATA_DIR, STATIC_DIR
from services.vin_decoder import decode_vin
yonke_bp = Blueprint('yonke', __name__, url_prefix='/pos/api/yonke')
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
def _int_arg(name, default, minimum, maximum):
try:
val = int(request.args.get(name, default))
except (TypeError, ValueError):
return default
return min(max(val, minimum), maximum)
def _branch_id(data=None):
"""Resolve the branch: explicit body/query value first, token fallback."""
if isinstance(data, dict) and data.get('branch_id'):
try:
return int(data['branch_id'])
except (TypeError, ValueError):
pass
arg = request.args.get('branch_id')
if arg:
try:
return int(arg)
except (TypeError, ValueError):
pass
return g.branch_id
# ─── Pieces ────────────────────────────────────────────────────────────────
@yonke_bp.route('/pieces', methods=['GET'])
@require_auth('yonke.view')
def list_pieces():
"""List used/rebuild pieces with filters and pagination."""
conn = get_tenant_conn(g.tenant_id)
try:
items, pagination = yonke_service.list_pieces(
conn,
filters={
'q': request.args.get('q'),
'donor_make': request.args.get('donor_make'),
'donor_model': request.args.get('donor_model'),
'donor_year': request.args.get('donor_year'),
'vin': request.args.get('vin'),
'grade': request.args.get('grade'),
'condition': request.args.get('condition'),
'ownership': request.args.get('ownership'),
},
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 25, 1, 200),
branch_id=_branch_id(),
)
return jsonify({'data': items, 'pagination': pagination})
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces', methods=['POST'])
@require_auth('yonke.manage')
def create_piece():
"""Create a used/rebuild piece (auto-SKU when part_number is omitted)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.create_piece(conn, data, branch_id=_branch_id(data))
conn.commit()
return jsonify(item), 201
except DuplicatePartNumber:
conn.rollback()
return jsonify({'error': 'part_number already exists'}), 409
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>', methods=['GET'])
@require_auth('yonke.view')
def get_piece(piece_id):
"""Get a single piece (extra helper for the detail/edit view)."""
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
if not item:
return jsonify({'error': 'Piece not found'}), 404
return jsonify(item)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>', methods=['PUT'])
@require_auth('yonke.manage')
def update_piece(piece_id):
"""Edit the Yonke fields of a piece."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.update_piece(conn, piece_id, data, branch_id=_branch_id(data))
if not item:
conn.rollback()
return jsonify({'error': 'Piece not found'}), 404
conn.commit()
return jsonify(item)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── Images ────────────────────────────────────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/images', methods=['POST'])
@require_auth('yonke.manage')
def upload_piece_images(piece_id):
"""Upload one or more photos for a piece (multipart, repeated `file` field)."""
files = [f for f in request.files.getlist('file') if f and f.filename]
if not files:
return jsonify({'error': 'No file provided'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
if not yonke_service.get_piece(conn, piece_id):
return jsonify({'error': 'Piece not found'}), 404
valid = []
for f in files:
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(sorted(ALLOWED_EXTENSIONS))}'
}), 400
raw = f.read()
if len(raw) > MAX_IMAGE_BYTES:
return jsonify({'error': 'File too large (max 5 MB)'}), 400
valid.append(BytesIO(raw))
images = yonke_service.add_piece_images(conn, g.tenant_id, piece_id, valid)
conn.commit()
return jsonify({'images': images})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/images/<int:image_id>', methods=['DELETE'])
@require_auth('yonke.manage')
def delete_piece_image(piece_id, image_id):
"""Delete one photo (row + files); promotes another when it was primary."""
conn = get_tenant_conn(g.tenant_id)
try:
deleted = yonke_service.delete_piece_image(conn, g.tenant_id, piece_id, image_id)
if not deleted:
conn.rollback()
return jsonify({'error': 'Image not found'}), 404
conn.commit()
return jsonify({'ok': True})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── AI vision grading (A4) ────────────────────────────────────────────────
#
# Front-end contract (yonke.js):
# POST /pos/api/yonke/pieces/<id>/grade-suggest
# body: multipart `file` (jpg/png/webp, <=5 MB) — or JSON {"image_base64": "..."}
# optional metadata overrides: name / category / condition
# 200 -> {condition_grade_suggested:"A|B|C|D", condition_suggested, defects[],
# confidence, notes, source, piece_id}
# 503 -> same shape but grade=null + `error` (AI backend unavailable)
# POST /pos/api/yonke/grade-suggest
# same body, no piece required (returns the suggestion only, never persists).
def _piece_primary_image_url(piece):
"""Primary photo URL for a piece, falling back to inventory.image_url."""
for img in (piece or {}).get('images') or []:
if img.get('is_primary') and img.get('url'):
return img['url']
for img in (piece or {}).get('images') or []:
if img.get('url'):
return img['url']
return (piece or {}).get('image_url') or None
def _read_local_image(url):
"""Read the bytes of an inventory_images/inventory URL stored on disk."""
if not url:
return None
idx = url.find('/images/parts/')
if idx == -1:
return None
rel = url[idx + len('/images/parts/'):]
for base in (DATA_DIR, STATIC_DIR):
try:
with open(os.path.join(base, rel), 'rb') as fh:
return fh.read()
except OSError:
continue
return None
def _read_image_request():
"""Extract the image from the request.
Returns (image_bytes, image_base64, error_response). Exactly one of
image_bytes / image_base64 is set when error_response is None.
"""
upload = request.files.get('file')
if upload and upload.filename:
raw = upload.read()
if not raw:
return None, None, (jsonify({'error': 'Empty file'}), 400)
if len(raw) > MAX_IMAGE_BYTES:
return None, None, (jsonify({'error': 'File too large (max 5 MB)'}), 400)
return raw, None, None
data = request.get_json(silent=True) or {}
b64 = (data.get('image_base64') or data.get('image') or '').strip()
if b64:
return None, b64, None
return None, None, None
def _meta_from_request(piece=None):
"""Metadata for the prompt: request overrides piece values."""
data = request.get_json(silent=True) or {}
form = request.form or {}
def pick(field):
return (form.get(field) or data.get(field) or '').strip() or None
piece = piece or {}
return {
'name': pick('name') or piece.get('name'),
'category': pick('category'),
'condition': pick('condition') or piece.get('condition') or 'usado',
}
def _piece_category_name(conn, piece_id):
cur = conn.cursor()
try:
cur.execute("""
SELECT c.name
FROM inventory i
LEFT JOIN part_categories c ON c.id = i.category_id
WHERE i.id = %s
""", (piece_id,))
row = cur.fetchone()
return (row[0] if row else None) or None
except Exception:
return None
finally:
cur.close()
def _grade_response(result, piece_id=None):
"""Normalize the service result into the HTTP response + status code."""
payload = dict(result)
if piece_id is not None:
payload['piece_id'] = piece_id
payload['available'] = result.get('condition_grade_suggested') is not None
status = 200 if payload['available'] else 503
return jsonify(payload), status
@yonke_bp.route('/pieces/<int:piece_id>/grade-suggest', methods=['POST'])
@require_auth('yonke.manage')
def grade_suggest_piece(piece_id):
"""Suggest a condition grade from a piece photo (upload/b64/primary photo)."""
image_bytes, image_b64, err = _read_image_request()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
piece = yonke_service.get_piece(conn, piece_id)
if not piece:
return jsonify({'error': 'Piece not found'}), 404
if not image_bytes and not image_b64:
image_bytes = _read_local_image(_piece_primary_image_url(piece))
if not image_bytes:
return jsonify({
'error': 'No image provided and the piece has no stored photo'
}), 400
meta = _meta_from_request(piece)
if not meta.get('category'):
meta['category'] = _piece_category_name(conn, piece_id)
from services import part_vision
result = part_vision.suggest_grade(
image_bytes=image_bytes, image_base64=image_b64,
name=meta['name'], category=meta['category'], condition=meta['condition'],
)
return _grade_response(result, piece_id=piece_id)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/grade-suggest', methods=['POST'])
@require_auth('yonke.manage')
def grade_suggest_adhoc():
"""Suggest a condition grade from a photo without requiring a piece."""
image_bytes, image_b64, err = _read_image_request()
if err:
return err
if not image_bytes and not image_b64:
return jsonify({'error': 'No image provided'}), 400
meta = _meta_from_request()
try:
from services import part_vision
result = part_vision.suggest_grade(
image_bytes=image_bytes, image_base64=image_b64,
name=meta['name'], category=meta['category'], condition=meta['condition'],
)
return _grade_response(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── VIN decoder ───────────────────────────────────────────────────────────
@yonke_bp.route('/vin/<vin>', methods=['GET'])
@require_auth('yonke.view')
def decode_vin_endpoint(vin):
"""Decode a VIN with the shared NHTSA decoder."""
result = decode_vin(vin)
return jsonify({
'vin': result.get('vin', (vin or '').strip().upper()),
'make': result.get('make', '') or '',
'model': result.get('model', '') or '',
'year': result.get('year'),
'engine': result.get('engine', '') or '',
'body_type': result.get('body_type', '') or '',
'fuel_type': result.get('fuel_type', '') or '',
'drive_type': result.get('drive_type', '') or '',
'trim': result.get('trim', '') or '',
'error': result.get('error', '') or '',
})
# ─── Donors ────────────────────────────────────────────────────────────────
@yonke_bp.route('/donors', methods=['GET'])
@require_auth('yonke.view')
def list_donors():
"""List donor vehicles grouped by VIN."""
conn = get_tenant_conn(g.tenant_id)
try:
data, pagination = yonke_service.list_donors(
conn,
q=request.args.get('q'),
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 25, 1, 200),
)
return jsonify({'data': data, 'pagination': pagination})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── Consignments ──────────────────────────────────────────────────────────
@yonke_bp.route('/consignments', methods=['GET'])
@require_auth('yonke.view')
def list_consignments():
"""List consignments with piece counts and amounts."""
conn = get_tenant_conn(g.tenant_id)
try:
data, pagination = yonke_service.list_consignments(
conn,
status=request.args.get('status'),
page=_int_arg('page', 1, 1, 10**9),
per_page=_int_arg('per_page', 50, 1, 200),
)
return jsonify({'data': data, 'pagination': pagination})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments', methods=['POST'])
@require_auth('yonke.manage')
def create_consignment():
"""Create a consignment."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
cons = yonke_service.create_consignment(conn, data)
conn.commit()
return jsonify(cons), 201
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>', methods=['GET'])
@require_auth('yonke.view')
def get_consignment(consignment_id):
"""Consignment detail with its items."""
conn = get_tenant_conn(g.tenant_id)
try:
cons = yonke_service.get_consignment(conn, consignment_id)
if not cons:
return jsonify({'error': 'Consignment not found'}), 404
return jsonify(cons)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/items', methods=['POST'])
@require_auth('yonke.manage')
def add_consignment_item(consignment_id):
"""Attach a piece to a consignment (sets inventory.ownership='consigna')."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item = yonke_service.add_consignment_item(conn, consignment_id, data)
conn.commit()
return jsonify(item), 201
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/settle', methods=['PUT'])
@require_auth('yonke.manage')
def settle_consignment(consignment_id):
"""Settle consignment items and return the payout summary."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
summary = yonke_service.settle_consignment(
conn, consignment_id, item_ids=data.get('item_ids')
)
if not summary:
conn.rollback()
return jsonify({'error': 'Consignment not found'}), 404
conn.commit()
return jsonify(summary)
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# A5 — Donor intake, reports, statements, labels, ML publish, bulk, AI
# ═══════════════════════════════════════════════════════════════════════════
def _public_base_url():
"""Tenant public base URL from the request (reverse-proxy aware)."""
proto = request.headers.get('X-Forwarded-Proto', request.scheme)
host = request.headers.get('X-Forwarded-Host', request.host)
cf = request.headers.get('CF-Visitor')
if cf and '"scheme":"https"' in cf:
proto = 'https'
if proto == 'http' and ('nexusautoparts.com.mx' in host
or request.headers.get('X-Forwarded-Ssl') == 'on'):
proto = 'https'
return f"{proto}://{host}/"
def _tenant_display_name():
"""Best-effort business name (from the master DB). Never raises."""
try:
from tenant_db import get_master_conn
conn = get_master_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,))
row = cur.fetchone()
cur.close()
return (row[0] if row else None) or None
finally:
conn.close()
except Exception:
return None
def _piece_or_404(conn, piece_id):
piece = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
if not piece:
return None, (jsonify({'error': 'Piece not found'}), 404)
return piece, None
def _read_any_image(piece):
"""Image bytes for AI endpoints: upload first, then the piece's stored photo."""
image_bytes, image_b64, err = _read_image_request()
if err:
return None, None, err
if not image_bytes and not image_b64:
image_bytes = _read_local_image(_piece_primary_image_url(piece))
if not image_bytes:
return None, None, (jsonify({
'error': 'No image provided and the piece has no stored photo'
}), 400)
return image_bytes, image_b64, None
# ─── 1. Full donor intake ──────────────────────────────────────────────────
@yonke_bp.route('/donors/full', methods=['POST'])
@require_auth('yonke.manage')
def create_donor_full():
"""Register a donor vehicle and all its pieces in one atomic call."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.create_donor_full(
conn, data, branch_id=_branch_id(data))
conn.commit()
return jsonify(result), 201
except DuplicatePartNumber:
conn.rollback()
return jsonify({'error': 'part_number already exists'}), 409
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 2. Cost allocation across a donor's pieces ────────────────────────────
@yonke_bp.route('/donors/<vin>/allocate-cost', methods=['POST'])
@require_auth('yonke.manage')
def allocate_donor_cost(vin):
"""Spread a donor's acquisition cost across its pieces."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.allocate_donor_cost(
conn, vin, data.get('total_cost'), method=data.get('method'))
if result is None:
conn.rollback()
return jsonify({'error': 'Donor not found or has no pieces'}), 404
conn.commit()
return jsonify(result)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 3. Aging report ───────────────────────────────────────────────────────
@yonke_bp.route('/reports/aging', methods=['GET'])
@require_auth('yonke.view')
def aging_report():
"""Pieces in the yard for at least `days` days (default 90)."""
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.aging_report(conn, days=_int_arg('days', 90, 0, 100000))
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 4. Summary report ─────────────────────────────────────────────────────
@yonke_bp.route('/reports/summary', methods=['GET'])
@require_auth('yonke.view')
def summary_report():
"""Donor breakdown, margins, invested capital and grade mix."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify(yonke_service.summary_report(conn))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 5-6. Consignment statement (+ print) ──────────────────────────────────
@yonke_bp.route('/consignments/<int:consignment_id>/statement', methods=['GET'])
@require_auth('yonke.view')
def consignment_statement(consignment_id):
"""Consignor account statement for an optional [from, to] period."""
conn = get_tenant_conn(g.tenant_id)
try:
statement = yonke_service.consignment_statement(
conn, consignment_id,
date_from=request.args.get('from'), date_to=request.args.get('to'))
if not statement:
return jsonify({'error': 'Consignment not found'}), 404
return jsonify(statement)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/consignments/<int:consignment_id>/statement/print', methods=['GET'])
@require_auth('yonke.view')
def consignment_statement_print(consignment_id):
"""Printable HTML for the consignor statement."""
conn = get_tenant_conn(g.tenant_id)
try:
statement = yonke_service.consignment_statement(
conn, consignment_id,
date_from=request.args.get('from'), date_to=request.args.get('to'))
if not statement:
return Response('<h1>404</h1>', status=404, mimetype='text/html')
html = yonke_service.render_statement_html(statement, _tenant_display_name())
return Response(html, mimetype='text/html')
finally:
conn.close()
# ─── 7. Publish one piece to MercadoLibre ──────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/publish-meli', methods=['POST'])
@require_auth('yonke.manage')
def publish_piece_meli(piece_id):
"""Publish this piece to MercadoLibre (degrades if ML is not configured)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.publish_piece_to_meli(
conn, piece_id,
category_id=data.get('category_id'),
price=data.get('price'),
stock=data.get('stock'),
branch_id=_branch_id(data),
listing_type=data.get('listing_type'),
shipping_mode=data.get('shipping_mode'),
base_url=_public_base_url(),
)
if result.get('not_found'):
conn.rollback()
return jsonify({'ok': False, 'error': result.get('error')}), 404
conn.commit()
return jsonify(result), (200 if result.get('ok') else 400)
except Exception as e:
conn.rollback()
return jsonify({'ok': False, 'error': str(e)}), 500
finally:
conn.close()
# ─── 8. Bulk CSV import ────────────────────────────────────────────────────
@yonke_bp.route('/pieces/bulk', methods=['POST'])
@require_auth('yonke.manage')
def bulk_create_pieces():
"""Create pieces in bulk from a CSV (multipart `file` or raw text body)."""
upload = request.files.get('file') or request.files.get('csv')
if upload and upload.filename:
raw = upload.read()
text = raw.decode('utf-8-sig', 'replace') if isinstance(raw, bytes) else str(raw)
elif request.is_json:
data = request.get_json(silent=True) or {}
text = (data.get('csv') or '').strip()
if not text:
return jsonify({'error': 'CSV file required'}), 400
else:
raw = request.get_data() or b''
if not raw:
return jsonify({'error': 'CSV file required'}), 400
text = raw.decode('utf-8-sig', 'replace')
conn = get_tenant_conn(g.tenant_id)
try:
result = yonke_service.bulk_create_pieces(
conn, text, branch_id=_branch_id())
conn.commit()
return jsonify(result)
except ValueError as e:
conn.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 9. Vehicle compatibility ("sirve para") ───────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['GET'])
@require_auth('yonke.view')
def list_piece_compat(piece_id):
"""List the vehicles this piece fits."""
conn = get_tenant_conn(g.tenant_id)
try:
_, err = _piece_or_404(conn, piece_id)
if err:
return err
return jsonify({'data': yonke_service.list_piece_compat(conn, piece_id)})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['POST'])
@require_auth('yonke.manage')
def add_piece_compat(piece_id):
"""Add manual vehicles ({make, model, year}) to a piece."""
data = request.get_json() or {}
items = data.get('items')
if not isinstance(items, list) or not items:
return jsonify({'error': 'items required (list of {make, model, year})'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
_, err = _piece_or_404(conn, piece_id)
if err:
return err
added = yonke_service.add_piece_compat(conn, piece_id, items)
conn.commit()
return jsonify({'added': added, 'count': len(added)}), 201
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# ─── 10. Printable piece label (barcode) ───────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/label', methods=['GET'])
@require_auth('yonke.view')
def piece_label(piece_id):
"""Printable HTML label with a Code39 barcode of the auto-SKU."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
html = yonke_service.render_label_html(conn, piece, _tenant_display_name())
return Response(html, mimetype='text/html')
except Exception as e:
return Response(f'<h1>Error</h1><p>{e}</p>', status=500, mimetype='text/html')
finally:
conn.close()
# ─── 11. Non-conversational AI ─────────────────────────────────────────────
@yonke_bp.route('/pieces/<int:piece_id>/identify', methods=['POST'])
@require_auth('yonke.manage')
def identify_piece(piece_id):
"""Vision identify: {name, category, confidence} (503 when AI is down)."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
image_bytes, image_b64, err = _read_any_image(piece)
if err:
return err
from services import yonke_ai
result = yonke_ai.identify_piece(
image_bytes=image_bytes, image_base64=image_b64,
hint_name=piece.get('name'))
status = 200 if result.get('available') else 503
return jsonify(result), status
except Exception as e:
return jsonify({'name': None, 'category': None, 'confidence': 0.0,
'available': False, 'error': str(e)}), 503
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/price-suggest', methods=['POST'])
@require_auth('yonke.manage')
def price_suggest(piece_id):
"""Rule-based price suggestion using cost/grade plus local comparables."""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
from services import yonke_ai
return jsonify(yonke_ai.suggest_price(conn, piece))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/duplicates', methods=['GET'])
@require_auth('yonke.view')
def duplicates():
"""Groups of near-duplicate used pieces (semantic similarity)."""
conn = get_tenant_conn(g.tenant_id)
try:
from services import yonke_ai
threshold = request.args.get('threshold', 0.85)
return jsonify(yonke_ai.find_duplicate_groups(conn, threshold=threshold))
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@yonke_bp.route('/pieces/<int:piece_id>/validate-photo', methods=['POST'])
@require_auth('yonke.manage')
def validate_photo(piece_id):
"""Vision check: is it an auto part with usable photo quality?"""
conn = get_tenant_conn(g.tenant_id)
try:
piece, err = _piece_or_404(conn, piece_id)
if err:
return err
image_bytes, image_b64, err = _read_any_image(piece)
if err:
return err
from services import yonke_ai
return jsonify(yonke_ai.validate_photo(
image_bytes=image_bytes, image_base64=image_b64))
except Exception as e:
return jsonify({'ok': True, 'issues': [], 'available': False,
'error': str(e)})
finally:
conn.close()