# /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//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//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//statement — consignor statement (JSON) GET /pos/api/yonke/consignments//statement/print — consignor statement (HTML) POST /pos/api/yonke/pieces//publish-meli — publish one piece to ML POST /pos/api/yonke/pieces/bulk — bulk CSV import GET/POST /pos/api/yonke/pieces//compat — "sirve para" fitment GET /pos/api/yonke/pieces//label — printable barcode label POST /pos/api/yonke/pieces//identify — vision identify (503 on AI down) POST /pos/api/yonke/pieces//price-suggest — rule-based price suggestion GET /pos/api/yonke/duplicates?threshold=0.85 — semantic duplicate groups POST /pos/api/yonke/pieces//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/', 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/', 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//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//images/', 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//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//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/', 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/', 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//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//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//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//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//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('

404

', 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//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//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//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//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'

Error

{e}

', status=500, mimetype='text/html') finally: conn.close() # ─── 11. Non-conversational AI ───────────────────────────────────────────── @yonke_bp.route('/pieces//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//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//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()