- 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
912 lines
34 KiB
Python
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()
|